diff --git a/package-lock.json b/package-lock.json index 82ed0bc..c4e1024 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,10 @@ "name": "react-example", "version": "0.0.0", "dependencies": { + "@astryxdesign/core": "0.3.0", + "@astryxdesign/theme-neutral": "0.3.0", "@google/genai": "^2.4.0", + "@stylexjs/stylex": "^0.19.0", "@tailwindcss/vite": "^4.1.14", "@tanstack/react-query": "^5.101.0", "@types/leaflet": "^1.9.21", @@ -29,8 +32,11 @@ "xlsx": "^0.18.5" }, "devDependencies": { + "@astryxdesign/cli": "^0.3.0", "@types/express": "^4.17.21", "@types/node": "^22.14.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", "autoprefixer": "^10.4.21", "esbuild": "^0.25.0", "tailwindcss": "^4.1.14", @@ -39,6 +45,83 @@ "vite": "^6.2.3" } }, + "node_modules/@astryxdesign/cli": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@astryxdesign/cli/-/cli-0.3.0.tgz", + "integrity": "sha512-iLFJsBlZUb66GskA1r3AmuodW72yu0PhCRAorcGPMuwHbGYn54pXGb/EnnWSdHf3Yuw4E3OuN7r4dY0TYJuYmQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "commander": "^12.1.0", + "jiti": "^2.7.0", + "jscodeshift": "^17.3.0", + "zod": "^4.4.3" + }, + "bin": { + "astryx": "clients/cli/bin/astryx.mjs", + "cli": "clients/cli/bin/astryx.mjs" + }, + "peerDependencies": { + "@astryxdesign/charts": "*", + "@astryxdesign/core": "*", + "@astryxdesign/lab": "*", + "@astryxdesign/theme-neutral": "*", + "gpt-tokenizer": "^3.4.0" + }, + "peerDependenciesMeta": { + "@astryxdesign/charts": { + "optional": true + }, + "@astryxdesign/core": { + "optional": true + }, + "@astryxdesign/lab": { + "optional": true + }, + "@astryxdesign/theme-neutral": { + "optional": true + } + } + }, + "node_modules/@astryxdesign/core": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@astryxdesign/core/-/core-0.3.0.tgz", + "integrity": "sha512-t2hHvR7AcwW0oKrnxCwbdEEE5Ytgy0Ug61DcYAl9BxAuV5z4XL0/kMo5DiPvX9jbDxzysmOa8ClnXqzxouRUVQ==", + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "intl-messageformat": "^11.2.9" + }, + "peerDependencies": { + "@stylexjs/stylex": "^0.19.0", + "react": ">=19.0.0", + "react-dom": ">=19.0.0" + } + }, + "node_modules/@astryxdesign/theme-neutral": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@astryxdesign/theme-neutral/-/theme-neutral-0.3.0.tgz", + "integrity": "sha512-CeKRfmTEtww60WZK4pSlW7bheBNyVPpgEliiXXUHRStXgcXOhwiUA3D/cXhMJlEx+VRgVxnzl0hMbWIr42RaVQ==", + "license": "MIT", + "dependencies": { + "lucide-react": "^1.18.0" + }, + "peerDependencies": { + "@astryxdesign/core": "0.3.0", + "react": ">=19" + } + }, + "node_modules/@astryxdesign/theme-neutral/node_modules/lucide-react": { + "version": "1.31.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.31.0.tgz", + "integrity": "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -109,6 +192,19 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "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", @@ -125,6 +221,28 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, "node_modules/@babel/helper-globals": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", @@ -134,6 +252,20 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", + "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-imports": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", @@ -164,6 +296,19 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-plugin-utils": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", @@ -173,6 +318,38 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.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", @@ -228,6 +405,155 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/plugin-syntax-flow": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.29.7.tgz", + "integrity": "sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz", + "integrity": "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-flow-strip-types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.29.7.tgz", + "integrity": "sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-flow": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz", + "integrity": "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz", + "integrity": "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz", + "integrity": "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-react-jsx-self": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", @@ -258,6 +584,84 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", + "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-syntax-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-flow": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-flow/-/preset-flow-7.29.7.tgz", + "integrity": "sha512-KYIRV0BuaN68CDdsqFkAD7MU7yipUqQNuNElwATdxaIdpTjhvtY82QvkBJs7zV3Evxj2jFAAZ1iO8nyy0nhjqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-transform-flow-strip-types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz", + "integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/register": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/register/-/register-7.29.7.tgz", + "integrity": "sha512-AMGJoWuES861riy6pcB0fphE1YXybtQnBYQMuIyPv6mKLiosfa79BKTnAOyx215c/3RJPJpdQwoHZ3earVH7AA==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "find-cache-dir": "^2.0.0", + "make-dir": "^2.1.0", + "pirates": "^4.0.6", + "source-map-support": "^0.5.16" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -719,6 +1123,27 @@ "node": ">=18" } }, + "node_modules/@formatjs/fast-memoize": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.7.tgz", + "integrity": "sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==", + "license": "MIT" + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.16.tgz", + "integrity": "sha512-kl6b/4D56gjGZi4ZewSmvXbalHwjOUI5ogEHPZqw42goeXTTrL7/yuPzvdrvr0QigDtvaOeb+UeMf62jks43Yg==", + "license": "MIT", + "dependencies": { + "@formatjs/icu-skeleton-parser": "2.1.11" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.11.tgz", + "integrity": "sha512-j8cUmOJzVgkHuS0QiQ6ga76UIoLOFSAMWhs7aZJztH3aAdCOAE6vpC8KVvFB4cU10ON0y2/5oOVmPJ43s2lTwA==", + "license": "MIT" + }, "node_modules/@google/genai": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.7.0.tgz", @@ -1241,6 +1666,18 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, + "node_modules/@stylexjs/stylex": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@stylexjs/stylex/-/stylex-0.19.0.tgz", + "integrity": "sha512-CnUFp7YMaDLDeemsWOfJgoC/gKM5P/yBNMcpJaE6ChJmXr7s0DJwSeGTTlHJcqqwN9OW1qGtmARWLFhGZN1pTA==", + "license": "MIT", + "peer": true, + "dependencies": { + "css-mediaquery": "^0.1.2", + "invariant": "^2.2.4", + "styleq": "0.2.1" + } + }, "node_modules/@tailwindcss/node": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", @@ -1733,6 +2170,27 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, "node_modules/@types/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", @@ -1835,6 +2293,19 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/autoprefixer": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", @@ -1992,6 +2463,13 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2069,6 +2547,21 @@ "integrity": "sha512-j7oSibqQdIODNhkI1sEJzHMiPsF43L/GqNbcA+eDDyGM10+x2sH9NW/PK6vM3z0J2tLDKMBcc5ZjVaoRinhuCA==", "license": "ISC" }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2087,6 +2580,23 @@ "node": ">=0.8" } }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -2141,6 +2651,19 @@ "node": ">=0.8" } }, + "node_modules/css-mediaquery": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz", + "integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==", + "license": "BSD" + }, + "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==", + "devOptional": true, + "license": "MIT" + }, "node_modules/d3-array": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", @@ -2487,6 +3010,20 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -2642,6 +3179,57 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/find-cache-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz", + "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^2.0.0", + "pkg-dir": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/flow-estree": { + "version": "0.326.0", + "resolved": "https://registry.npmjs.org/flow-estree/-/flow-estree-0.326.0.tgz", + "integrity": "sha512-43Qv+Ei9qfabhLx8JGEJku4frHGD5zI2EuL73Hs9HrWqPMbTT/DS18Az7bcodGwdQEv1DVIEI2WowkEbMIk4BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/flow-parser": { + "version": "0.326.0", + "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.326.0.tgz", + "integrity": "sha512-H/Wqt2SDkQ8GH8wpyAj434zN40zomipZWBbKYlAwQYyNdMIQMKqpXTx82FMnITt2iDQ5zrV80NvSPAgIAafwGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "flow-estree": "0.326.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -2857,6 +3445,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gpt-tokenizer": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/gpt-tokenizer/-/gpt-tokenizer-3.4.0.tgz", + "integrity": "sha512-wxFLnhIXTDjYebd9A9pGl3e31ZpSypbpIJSOswbgop5jLte/AsZVDvjlbEuVFlsqZixVKqbcoNmRlFDf6pz/UQ==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -2942,6 +3538,16 @@ "url": "https://opencollective.com/immer" } }, + "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/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2957,6 +3563,25 @@ "node": ">=12" } }, + "node_modules/intl-messageformat": { + "version": "11.2.13", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.13.tgz", + "integrity": "sha512-JaPaE6TIX+TAS5XLhDUh41geLw4QfBHX4s5pW8Km+L9fVC8HzB9yOuhbh4EMR/F1+8C6b9qk4763Cv+LdOG1kg==", + "license": "BSD-3-Clause", + "dependencies": { + "@formatjs/fast-memoize": "3.1.7", + "@formatjs/icu-messageformat-parser": "3.5.16" + } + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -2966,6 +3591,29 @@ "node": ">= 0.10" } }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -2981,6 +3629,47 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jscodeshift": { + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/jscodeshift/-/jscodeshift-17.4.0.tgz", + "integrity": "sha512-i3ESKiiTsGynxzTg5BhsZViD0ai72/6SsI1efDZxG6/5KCoElsmquxtyhXK5lpEgoO7MTNpYkjFEdEL97SkBNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.7", + "@babel/parser": "^7.24.7", + "@babel/plugin-transform-class-properties": "^7.24.7", + "@babel/plugin-transform-modules-commonjs": "^7.24.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/preset-flow": "^7.24.7", + "@babel/preset-typescript": "^7.24.7", + "@babel/register": "^7.24.6", + "flow-parser": "0.*", + "graceful-fs": "^4.2.4", + "neo-async": "^2.5.0", + "picocolors": "^1.0.1", + "picomatch": "^4.0.2", + "recast": "^0.23.11", + "tmp": "^0.2.3", + "write-file-atomic": "^5.0.1" + }, + "bin": { + "jscodeshift": "bin/jscodeshift.js" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@babel/preset-env": "^7.1.6" + }, + "peerDependenciesMeta": { + "@babel/preset-env": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3035,6 +3724,16 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/leaflet": { "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", @@ -3291,12 +3990,38 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3324,6 +4049,30 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/make-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", + "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^4.0.1", + "semver": "^5.6.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3467,6 +4216,13 @@ "node": ">= 0.6" } }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -3538,6 +4294,35 @@ "node": ">= 0.8" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/p-retry": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", @@ -3551,6 +4336,16 @@ "node": ">=8" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -3560,6 +4355,16 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", @@ -3585,6 +4390,39 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz", + "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -3834,6 +4672,23 @@ "url": "https://opencollective.com/express" } }, + "node_modules/recast": { + "version": "0.23.21", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.21.tgz", + "integrity": "sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, "node_modules/recharts": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", @@ -4046,6 +4901,19 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -4118,6 +4986,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4127,6 +5018,17 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/ssf": { "version": "0.11.2", "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", @@ -4148,6 +5050,12 @@ "node": ">= 0.8" } }, + "node_modules/styleq": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/styleq/-/styleq-0.2.1.tgz", + "integrity": "sha512-L0TR0NQb+X4/ktDEKmjWyp27gla+LUYi/by5k5SjKXf6/pvZP7wbwEB5J+tqxdFVPgzbsuz+d4RTScO/QZquBw==", + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", @@ -4189,6 +5097,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -4904,6 +5822,20 @@ "node": ">=0.8" } }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", @@ -4951,6 +5883,16 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 2852268..7052be5 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,10 @@ "lint": "tsc --noEmit" }, "dependencies": { + "@astryxdesign/core": "0.3.0", + "@astryxdesign/theme-neutral": "0.3.0", "@google/genai": "^2.4.0", + "@stylexjs/stylex": "^0.19.0", "@tailwindcss/vite": "^4.1.14", "@tanstack/react-query": "^5.101.0", "@types/leaflet": "^1.9.21", @@ -32,8 +35,11 @@ "xlsx": "^0.18.5" }, "devDependencies": { + "@astryxdesign/cli": "^0.3.0", "@types/express": "^4.17.21", "@types/node": "^22.14.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", "autoprefixer": "^10.4.21", "esbuild": "^0.25.0", "tailwindcss": "^4.1.14", diff --git a/src/App.tsx b/src/App.tsx index 1fbb9ef..b8c7d47 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,9 @@ import React, { useState, useEffect } from 'react'; import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom'; +import PosStaffPanelAstryx from './components/spike/PosStaffPanelAstryx'; // PHASE 1 SPIKE — throwaway +import ThemeShowcase from './components/spike/ThemeShowcase'; // PHASE 2 gate — throwaway +import ConsoleUiProbe from './components/spike/ConsoleUiProbe'; // PHASE 3 verification — throwaway import { Network, Truck, @@ -560,6 +563,14 @@ export default function App() { if (authRole === null) { return ( + {/* PHASE 1 SPIKE — throwaway, delete with the spike folder. Sits above + the auth gate only so the probe is reachable without a login. */} + } /> + {/* PHASE 2 gate — the stock Astryx showcase rendered through the Nearle + theme. This is the acceptance test for the palette: every component + on one page, in whichever mode the OS is set to. */} + } /> + } /> } /> } /> diff --git a/src/components/AdminConsole.tsx b/src/components/AdminConsole.tsx index 5996292..a9d2178 100644 --- a/src/components/AdminConsole.tsx +++ b/src/components/AdminConsole.tsx @@ -33,8 +33,13 @@ import { import { FIESTA_TENANT_ID, str as fstr, num as fnum } from '../services/fiestaApi'; import StoreQRView from './StoreQRView'; import AddressAutocomplete, { type AddressResult } from './AddressAutocomplete'; +import { useNotify } from './Toasts'; export default function AdminConsole({ activeTab: propActiveTab, showHeader = true, onBack, tenantId }: { activeTab?: 'tenant' | 'store' | 'rider', showHeader?: boolean, onBack?: () => void, tenantId?: number }) { + // PHASE 6 — replaces alert(). A native alert freezes the whole browser + // until dismissed; a toast does not, and errors stay until read. + const { notifyOk, notifyFail } = useNotify(); + const [activeTab, setActiveTab] = useState<'tenant' | 'store' | 'rider'>(propActiveTab || 'tenant'); useEffect(() => { @@ -171,7 +176,7 @@ export default function AdminConsole({ activeTab: propActiveTab, showHeader = tr const handleTenantSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!tenantForm.tenantname || !tenantForm.companyname || !tenantForm.primarycontact || !tenantForm.primaryemail || !tenantForm.locationname) { - alert('Kindly fill in all required fields.'); + notifyFail('Kindly fill in all required fields.'); return; } try { @@ -197,16 +202,16 @@ export default function AdminConsole({ activeTab: propActiveTab, showHeader = tr }, }); setTenantSuccess(res); - alert('Tenant Onboarded successfully!'); + notifyOk('Tenant onboarded.'); } catch (err: any) { - alert(err.message || 'Failed to onboard tenant.'); + notifyFail(err, 'Failed to onboard tenant.'); } }; const handleStoreSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!storeForm.locationname) { - alert('Location Name is required.'); + notifyFail('Location name is required.'); return; } try { @@ -221,16 +226,16 @@ export default function AdminConsole({ activeTab: propActiveTab, showHeader = tr roleid: 4, }); setStoreSuccess(res); - alert('Store Location created successfully!'); + notifyOk('Store location created.'); } catch (err: any) { - alert(err.message || 'Failed to create store location.'); + notifyFail(err, 'Failed to create store location.'); } }; const handleRiderSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!riderForm.firstname || !riderForm.email || !riderForm.contactno || !riderForm.locationid) { - alert('Please fill out the Rider personal details and assign a store.'); + notifyFail('Fill out the rider details and assign a store.'); return; } try { @@ -282,9 +287,9 @@ VALUES (${newUserId}, 1, 'Active', NOW()); userid: newUserId, }); - alert('Rider User created in app_users! SQL Script generated below.'); + notifyOk('Rider user created. SQL script generated below.'); } catch (err: any) { - alert(err.message || 'Failed to create rider user.'); + notifyFail(err, 'Failed to create rider user.'); } }; @@ -902,7 +907,7 @@ VALUES (${newUserId}, 1, 'Active', NOW()); > {locations.map((loc) => ( - + ))} @@ -1261,7 +1266,7 @@ VALUES (${newUserId}, 1, 'Active', NOW()); {tenantsQ.isLoading ? ( ) : (tenantsQ.data || []).map((t) => ( - + ))} @@ -1574,7 +1579,7 @@ VALUES (${newUserId}, 1, 'Active', NOW()); > {locations.map((loc) => ( - + ))} diff --git a/src/components/BulkCartDrawer.tsx b/src/components/BulkCartDrawer.tsx index 5e371ff..a72b833 100644 --- a/src/components/BulkCartDrawer.tsx +++ b/src/components/BulkCartDrawer.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { ShoppingCart, X, Package, Trash2, Plus, ArrowRight, Layers } from 'lucide-react'; import { useCompare } from '../contexts/CompareContext'; import { useStoreCatalogue } from '../services/storeCatalogue'; +import { useNotify } from './Toasts'; interface BulkCartDrawerProps { isOpen: boolean; @@ -12,13 +13,14 @@ interface BulkCartDrawerProps { export default function BulkCartDrawer({ isOpen, onClose, onRequestSamples }: BulkCartDrawerProps) { const { selectedProducts, removeProduct, clearSelection, setIsComparing } = useCompare(); const storeCat = useStoreCatalogue(); + const { notifyOk } = useNotify(); if (!isOpen) return null; const handleBulkAdd = () => { let addedCount = 0; selectedProducts.forEach(prod => { - if (prod.verified && !storeCat.has(prod.id)) { + if (prod.verified && !storeCat.has(String(prod.id))) { storeCat.add({ productid: String(prod.id), name: prod.name, @@ -43,7 +45,10 @@ export default function BulkCartDrawer({ isOpen, onClose, onRequestSamples }: Bu addedCount++; } }); - alert(addedCount > 0 ? `Successfully added ${addedCount} product(s) to the store catalogue!` : 'Selected products are already in the catalogue or not verified.'); + // PHASE 6 — "nothing was added" is not an error, it is a result, so it goes + // through notifyOk rather than being dressed up as a failure. + if (addedCount > 0) notifyOk(`Added ${addedCount} product${addedCount === 1 ? '' : 's'} to the store catalogue.`); + else notifyOk('Nothing to add — those products are already in the catalogue, or not verified.'); if (addedCount > 0) onClose(); }; @@ -55,7 +60,8 @@ export default function BulkCartDrawer({ isOpen, onClose, onRequestSamples }: Bu removedCount++; } }); - alert(removedCount > 0 ? `Successfully removed ${removedCount} product(s) from the store catalogue!` : 'None of the selected products were in the catalogue.'); + if (removedCount > 0) notifyOk(`Removed ${removedCount} product${removedCount === 1 ? '' : 's'} from the store catalogue.`); + else notifyOk('Nothing to remove — none of those products were in the catalogue.'); if (removedCount > 0) onClose(); }; diff --git a/src/components/CatalogueBrowser.tsx b/src/components/CatalogueBrowser.tsx index 2cb3b91..ae2ac52 100644 --- a/src/components/CatalogueBrowser.tsx +++ b/src/components/CatalogueBrowser.tsx @@ -12,7 +12,13 @@ import { useStoreCatalogue, usePriceEverywhere } from '../services/storeCatalogu import { useFiestaTenantLocations, useFiestaProductLocations } from '../services/fiestaQueries'; import { num as fnum } from '../services/fiestaApi'; import ImportProductModal from './ImportProductModal'; +import { useNotify } from './Toasts'; import { CardCarousel } from './CatalogueGallery'; +// PHASE 6 — the Global Catalogue's own controls. This component is rendered by +// BOTH the admin (via InventoryView) and the super admin, so a change here +// lands on two logins at once. +import { TextInput } from '@astryxdesign/core/TextInput'; +import { CheckboxInput } from '@astryxdesign/core/CheckboxInput'; interface CatalogueBrowserProps { tenantid: number; @@ -87,6 +93,9 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata [tenantCategories], ); + // PHASE 6 — replaces alert(), which blocked the whole browser until dismissed. + const { notifyOk, notifyFail } = useNotify(); + const handleImportSubmit = (item: ImportCatalogueProductRequest) => { importProductMutation.mutate([item], { onSuccess: () => { @@ -101,10 +110,14 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata // // The product now lands in the Admin Catalogue alone. Pricing and // publishing happen there, in that order. + // + // PHASE 6 — the toast says where it went. Since the import stopped + // publishing, a silent success looked identical to nothing happening: + // the product does not appear in any store, which is correct but + // indistinguishable from a failure. + notifyOk('Imported to your Admin Catalogue. Set a price there to publish it.'); }, - onError: (err: any) => { - alert(err.message || 'Failed to import product.'); - } + onError: (err: any) => notifyFail(err, 'Failed to import product.'), }); }; @@ -131,21 +144,14 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata ) : (
{brandsData.map((b) => ( - +
+ setBrand(brand === b.brand ? undefined : b.brand)} + /> + {b.product_count} +
))}
)} @@ -198,23 +204,16 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
-
- - + setKeyword(e.target.value)} - className="bg-transparent border-none outline-none text-xs font-semibold text-slate-700 ml-2 w-full placeholder:text-slate-400 placeholder:font-medium" + onChange={(v) => setKeyword(v)} + placeholder="Search catalogue…" + startIcon={} + hasClear /> - {keyword && ( - - )}
diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx index ecff1ca..d085162 100644 --- a/src/components/ConfirmModal.tsx +++ b/src/components/ConfirmModal.tsx @@ -1,5 +1,26 @@ import React from 'react'; -import { AlertTriangle, X } from 'lucide-react'; +import { AlertTriangle } from 'lucide-react'; +import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; +import { Button } from '@astryxdesign/core/Button'; + +/** + * PHASE 5 — rebuilt on Astryx Dialog. + * + * The props are untouched, so both call sites were left alone. What changed is + * everything underneath: this was a hand-rolled `fixed inset-0` overlay, and it + * is now a real ``. + * + * What that buys, none of which the original had: + * • Escape closes it (verified — see the `purpose` note below, the first + * choice of 'required' silently disabled this) + * • focus is trapped inside while open, and returns to the opener on close + * • the rest of the page is inert to screen readers + * • width comes from the Dialog, so it can no longer be squeezed by the + * `max-w-*` collision that once rendered these as ~100px strips + * + * `isLoading` still guards dismissal: onOpenChange is ignored mid-flight so a + * stray Escape cannot close the box while a mutation is in the air. + */ interface ConfirmModalProps { isOpen: boolean; @@ -24,67 +45,42 @@ export function ConfirmModal({ isDestructive = false, isLoading = false, }: ConfirmModalProps) { - if (!isOpen) return null; - return ( -
{ if (e.target === e.currentTarget && !isLoading) onCancel(); }} + { + // Ignore every close request while a mutation is running — the original + // did this by disabling the buttons and swallowing backdrop clicks. + if (!open && !isLoading) onCancel(); + }} + width={400} + // 'info', not 'required'. Verified in the browser: 'required' blocks + // Escape and backdrop dismissal entirely, which would have been a + // regression — the original closed on a backdrop click. Cancelling a + // confirmation is the safe direction, so making it harder protects + // nothing. 'info' keeps backdrop-dismiss and adds Escape. + purpose="info" > -
e.stopPropagation()} - > -
-
- {isDestructive && } -

{title}

-
- -
+ : undefined} + onOpenChange={(open) => { if (!open && !isLoading) onCancel(); }} + /> -
-
- {message} -
-
+
{message}
-
- - -
+
+
-
+
); } + +export default ConfirmModal; diff --git a/src/components/InventoryView.tsx b/src/components/InventoryView.tsx index 39d0c84..6c22151 100644 --- a/src/components/InventoryView.tsx +++ b/src/components/InventoryView.tsx @@ -58,6 +58,7 @@ import AwaitingApi from './AwaitingApi'; import { SlideDrawer, Skeleton, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND, tint, edge, StatusChip } from './consoleUi'; import FMCGHoverOverlay from './FMCGHoverOverlay'; import CatalogueDetailPanel from './CatalogueDetailPanel'; +import { useNotify } from './Toasts'; import CatalogueBrowser from './CatalogueBrowser'; import OfflineSalesUpload from './OfflineSalesUpload'; import { useCompare } from '../contexts/CompareContext'; @@ -178,6 +179,9 @@ export default function InventoryView({ }, [storeCat.items]); const { priceEverywhere, isPending: isPricing } = usePriceEverywhere(tenantId); + // PHASE 6 — replaces alert(). A native alert blocks the whole browser and + // has to be dismissed before anything else can happen; a toast does not. + const { notifyOk, notifyFail } = useNotify(); const publishMut = usePublishProduct(); const unpublishMut = useUnpublishProduct(); const [importPrice, setImportPrice] = useState(''); @@ -734,9 +738,13 @@ export default function InventoryView({ taxpercent: Number(cardImportTax) || 0, }, { - onSuccess: () => setAddingPriceProdId(null), - onError: (err: any) => - alert(err?.message || 'Could not publish this product.'), + onSuccess: () => { + setAddingPriceProdId(null); + // Publishing used to succeed in silence — the only + // sign it worked was a row changing somewhere below. + notifyOk(`${prod.name} is live in every outlet.`); + }, + onError: (err: any) => notifyFail(err, 'Could not publish this product.'), }, ); }} @@ -1091,7 +1099,10 @@ export default function InventoryView({ price: Number(importPrice), taxpercent: Number(drawerTax) || 0, }, - { onError: (err: any) => alert(err?.message || 'Could not publish this product.') }, + { + onSuccess: () => notifyOk(`${selectedAdminProduct.name} is live in every outlet.`), + onError: (err: any) => notifyFail(err, 'Could not publish this product.'), + }, ); }} className="w-full flex items-center justify-center gap-2 py-3 rounded-xl text-xs font-extrabold transition-all bg-[#662582] hover:bg-[#531e6a] text-white shadow-md disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer" diff --git a/src/components/NavRail.tsx b/src/components/NavRail.tsx new file mode 100644 index 0000000..8df7ead --- /dev/null +++ b/src/components/NavRail.tsx @@ -0,0 +1,98 @@ +/** + * @license + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * PHASE 4 — the one left rail. + * + * `SuperAdminSidebar` and `UserStoreSidebar` were 95% the same file. A diff of + * the two showed exactly three differences: the route prefix, the item corner + * radius, and the fact that the user rail closes itself after a tap on mobile + * while the super-admin rail does not. Everything else — the brand-purple rail, + * the collapse-to-icons behaviour, the backdrop, the active states — was + * duplicated line for line, which is why a fix to one never reached the other. + * + * Both are now thin wrappers over this. Each keeps its own props and its own + * appearance, so no page changed. + * + * NOT built on Astryx's `SideNav`. That would replace the main navigation of + * every role at once, and those screens sit behind a login I cannot reach to + * check the result. Deduplicating is safe and verifiable today; restyling the + * navigation is not. The swap belongs in a step where someone can look at it. + */ + +import React from 'react'; +import { type LucideIcon } from 'lucide-react'; +import { NavLink } from 'react-router-dom'; + +export interface NavRailItem { + id: string; + label: string; + icon: LucideIcon; +} + +export interface NavRailProps { + items: NavRailItem[]; + /** Route prefix each item id is appended to, e.g. `/superadmin`. */ + basePath: string; + /** Collapsed → icon-only rail; expanded → labels visible. */ + isOpen: boolean; + onClose: () => void; + /** + * Item corner radius. The two rails disagreed, so it stays a prop rather than + * silently restyling one of them. + */ + itemRadius?: 'none' | 'lg'; +} + +export default function NavRail({ items, basePath, isOpen, onClose, itemRadius = 'none' }: NavRailProps) { + return ( + <> + {/* Mobile Overlay */} + {isOpen && ( +
+ )} + + + + ); +} diff --git a/src/components/OperationsView.tsx b/src/components/OperationsView.tsx index 38866bf..e7a25f1 100644 --- a/src/components/OperationsView.tsx +++ b/src/components/OperationsView.tsx @@ -30,6 +30,7 @@ import { import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr } from '../services/fiestaApi'; import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers'; import AwaitingApi from './AwaitingApi'; +import { useNotify } from './Toasts'; interface OperationsViewProps { searchQuery: string; @@ -37,6 +38,10 @@ interface OperationsViewProps { } export default function OperationsView({ searchQuery, isCoimbatoreView }: OperationsViewProps) { + // PHASE 6 — replaces alert(). A native alert freezes the whole browser + // until dismissed; a toast does not, and errors stay until read. + const { notifyOk, notifyFail } = useNotify(); + // Sub-tabs state const [activeSubTab, setActiveSubTab] = useState<'inventory' | 'catalogue' | 'import'>('inventory'); @@ -111,7 +116,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat const handleAddSku = (e: React.FormEvent) => { e.preventDefault(); if (!newSku.sku || !newSku.name || !newSku.warehouse) { - alert('Please fill out all metadata fields before committing.'); + notifyFail('Fill out all metadata fields before committing.'); return; } @@ -143,7 +148,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat const handleExecuteTransfer = (e: React.FormEvent) => { e.preventDefault(); if (!transferData.sku || !transferData.origin || !transferData.destination) { - alert('Kindly configure transfer coordinates and SKU.'); + notifyFail('Configure the transfer coordinates and SKU first.'); return; } @@ -162,7 +167,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat }) ); - alert(`Transfer of ${transferData.quantity} units committed successfully. Tracking ID: TRF-${Math.floor(Math.random() * 900000 + 100000)}`); + notifyOk(`Transfer of ${transferData.quantity} units committed successfully. Tracking ID: TRF-${Math.floor(Math.random() * 900000 + 100000)}`); setShowTransferModal(false); }; @@ -414,7 +419,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat onClick={() => { const amount = prompt('Enter return item SKU code:'); if (amount) { - alert(`Returns logged successfully for target SKU code ${amount}. Waiting for physical hub clearance inspection.`); + notifyOk(`Returns logged successfully for target SKU code ${amount}. Waiting for physical hub clearance inspection.`); } }} className="bg-[#f8fafc] border border-[#e2e8f0] p-sm rounded-lg flex flex-col items-center gap-xs text-zinc-700 hover:bg-[#faf5ff] hover:text-[#662582] hover:border-[#662582] transition-all cursor-pointer group" @@ -425,7 +430,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat + />
} /> @@ -380,9 +396,13 @@ export default function PosConsoleView({
{pendingTotal} bill{pendingTotal === 1 ? '' : 's'} still sitting on{' '} {strandedTills} till{strandedTills === 1 ? '' : 's'} and not yet synced. - + {/* Reads as a link mid-sentence but performs an in-page action, so + it stays a
)} @@ -983,54 +1003,60 @@ function BillsTab(props: { )} -
-
- - +
+ setReference(e.target.value)} + onChange={(v) => setReference(v)} onKeyDown={(e) => e.key === 'Enter' && onLookup()} placeholder="Invoice, order UUID or bill id…" - className="bg-transparent outline-none text-[12.5px] w-56" - style={{ color: TEXT }} + startIcon={} + // Replaces the separate magnifier button beside the field. The + // clear affordance now belongs to the input, and Enter still + // runs the lookup exactly as before. + hasClear /> -
- +
+ setTerminalFilter(String(v ?? ''))} + placeholder="All terminals" + options={[ + { value: '', label: 'All terminals' }, + ...terminalOptions.filter(Boolean).map((t) => ({ value: t, label: t })), + ]} + /> +
- +
+ setModeFilter(String(v ?? ''))} + placeholder="All tenders" + options={[ + { value: '', label: 'All tenders' }, + ...['cash', 'card', 'upi', 'wallet'].map((m) => ({ value: m, label: m })), + ]} + /> +
-
- - + setCashierFilter(e.target.value)} - placeholder="Cashier (exact name)" - className="bg-transparent outline-none text-[12.5px] w-40" - style={{ color: TEXT }} + onChange={(v) => setCashierFilter(v)} + placeholder="Exact name" + startIcon={} + hasClear />
@@ -1071,22 +1097,23 @@ function BillsTab(props: { Page {pageno + 1} of {pages}
- - + />
) : undefined @@ -1187,17 +1214,20 @@ function BillDetail({ bill, loading, reference }: { bill: PosBill | null; loadin {/* The drawer clips horizontally, so the line table scrolls inside its own box rather than being cut off at the panel edge. */}
- - - - - - - - - - - + {/* The bill's line items. Header cells move to Astryx; the rows + below stay as the caller's own , so the money formatting + and the barcode sub-line are untouched. */} + + + + Item + Qty + Rate + GST + Total + + + {items.map((it) => ( ))} - -
ItemQtyRateGSTTotal
@@ -1219,8 +1249,8 @@ function BillDetail({ bill, loading, reference }: { bill: PosBill | null; loadin {inr(it.linetotal)}
+ +
) : ( @@ -1460,25 +1490,27 @@ function PosTable({ children: React.ReactNode; }) { return ( -
+ // PHASE 6 — one conversion covers the Terminals, Bills and Catalogue tabs, + // since all three render through this. The hardcoded `bg-white` is gone + // with it: Card takes its surface from the theme, so this is one of the + // places that no longer blocks dark mode. +
- - - + + + {columns.map((c, i) => ( - + + {c.label} + ))} - - - - {children} - -
- {c.label} -
+ + + {children} +
{footer} -
+ ); } diff --git a/src/components/PosErrorBoundary.tsx b/src/components/PosErrorBoundary.tsx index 62fd224..f199f85 100644 --- a/src/components/PosErrorBoundary.tsx +++ b/src/components/PosErrorBoundary.tsx @@ -19,6 +19,10 @@ import React from 'react'; import { AlertTriangle } from 'lucide-react'; +import { Card as AxCard } from '@astryxdesign/core/Card'; +import { Button } from '@astryxdesign/core/Button'; +import { Heading } from '@astryxdesign/core/Heading'; +import { Text } from '@astryxdesign/core/Text'; interface Props { children: React.ReactNode; @@ -29,15 +33,11 @@ interface State { } export default class PosErrorBoundary extends React.Component { - // This project has no `@types/react` installed, so `React.Component`'s - // inherited members are not typed and `this.props` / `this.setState` do not - // resolve. (It is the same gap that makes `key` unassignable on a custom - // component, which is why the codebase wraps keyed elements in fragments.) - // Re-declared here rather than cast at each use — `declare` emits nothing and - // keeps the bodies below honest. Delete once the types are added. - declare props: Props; - declare setState: (state: State) => void; - + // The `declare props` / `declare setState` workaround that used to sit here + // is gone. It existed only because the project had no `@types/react`, so + // React.Component's inherited members did not resolve. Phase 0 installed the + // types, so the real ones now apply and the shim was actively misleading — + // it narrowed setState to a shape React does not actually guarantee. state: State = { error: null }; static getDerivedStateFromError(error: Error): State { @@ -54,28 +54,30 @@ export default class PosErrorBoundary extends React.Component { if (!this.state.error) return this.props.children; return ( + // PHASE 6 — Card + Button. The hardcoded `bg-white` and `#662582` are + // gone, so the fallback follows the theme like everything else. That + // matters more here than elsewhere: this is the screen someone sees when + // things have already gone wrong, and a white slab on a dark page would + // read as a second failure.
-
-
- + +
+
+ +
+ The POS screen hit a problem +
+ + Counter sales are unaffected — bills keep syncing from the tills whatever this + page does. Only the display failed. + +
+

+ {this.state.error.message} +

+
-

- The POS screen hit a problem -

-

- Counter sales are unaffected — bills keep syncing from the tills whatever this page - does. Only the display failed. -

-

- {this.state.error.message} -

- -
+
); } diff --git a/src/components/PosStaffPanel.tsx b/src/components/PosStaffPanel.tsx index 8751278..94cd06c 100644 --- a/src/components/PosStaffPanel.tsx +++ b/src/components/PosStaffPanel.tsx @@ -20,6 +20,12 @@ */ import React, { useMemo, useState } from 'react'; +// PHASE 6 — this page's form is the first converted to Astryx. +import { TextInput } from '@astryxdesign/core/TextInput'; +import { Selector } from '@astryxdesign/core/Selector'; +import { Button } from '@astryxdesign/core/Button'; +import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; +import { TimeInput } from '@astryxdesign/core/TimeInput'; import { AlertTriangle, Check, @@ -106,6 +112,19 @@ export default function PosStaffPanel({ const [phone, setPhone] = useState(''); const [shiftId, setShiftId] = useState(0); const [formError, setFormError] = useState(''); + /** + * PHASE 6 — per-field errors, replacing the single `formError` string for the + * create form. + * + * The old validation returned on the first failure, so an empty form told you + * about the name, then about the role once you fixed it, then about the PIN. + * Four round trips to learn four things. Astryx's inputs take a `status`, so + * every failure can be shown at once, against the field that caused it. + * + * `formError` stays for the edit and reset dialogs, which are not converted + * yet — Phase 6 moves one form at a time. + */ + const [fieldErrs, setFieldErrs] = useState>>({}); const [issued, setIssued] = useState(null); const [copied, setCopied] = useState(false); const [confirmOff, setConfirmOff] = useState(null); @@ -138,29 +157,44 @@ export default function PosStaffPanel({ setPhone(''); setShiftId(0); setFormError(''); + // Clear the per-field errors too, or reopening the form shows the errors + // from the last attempt against fields that are now empty. + setFieldErrs({}); }; const submit = () => { const name = fullName.trim(); const chosen = role || (roles.length === 1 ? roles[0].role : ''); - if (!name) return setFormError('Enter the person’s name.'); - if (!chosen) return setFormError('Choose a role.'); - if (!/^\d{4}$/.test(pin)) return setFormError('The PIN must be exactly 4 digits.'); - // Checked here rather than left to the server: two people sharing a PIN at - // one counter both appear on the till's sign-in list and neither can be - // told apart on a bill. - if (staff.some((s) => s.pin === pin && (s.status || '').toLowerCase() !== 'inactive')) { - return setFormError('Another active person at this outlet already uses that PIN.'); + // Same rules, same wording, same order as before — the only change is that + // every failure is collected and attached to its field instead of the first + // one short-circuiting the rest. + const errs: typeof fieldErrs = {}; + + if (!name) errs.name = 'Enter the person’s name.'; + if (!chosen) errs.role = 'Choose a role.'; + + if (!/^\d{4}$/.test(pin)) { + errs.pin = 'The PIN must be exactly 4 digits.'; + } else if (staff.some((s) => s.pin === pin && (s.status || '').toLowerCase() !== 'inactive')) { + // Checked here rather than left to the server: two people sharing a PIN at + // one counter both appear on the till's sign-in list and neither can be + // told apart on a bill. + errs.pin = 'Another active person at this outlet already uses that PIN.'; } + // The till signs in with this, so it is required here even though the // column allows blank — an account without one can only ever log in by // username, which is what this change exists to stop relying on. const digits = phone.replace(/\D/g, '').replace(/^91(?=\d{10}$)/, '').replace(/^0(?=\d{10}$)/, ''); - if (digits.length !== 10) return setFormError('Enter a 10-digit mobile number.'); - if (staff.some((s) => (s.contactno || '') === digits && (s.status || '').toLowerCase() !== 'inactive')) { - return setFormError('Another active person here already signs in with that number.'); + if (digits.length !== 10) { + errs.phone = 'Enter a 10-digit mobile number.'; + } else if (staff.some((s) => (s.contactno || '') === digits && (s.status || '').toLowerCase() !== 'inactive')) { + errs.phone = 'Another active person here already signs in with that number.'; } + setFieldErrs(errs); + if (Object.keys(errs).length) return; + setFormError(''); createMut.mutate( { @@ -302,87 +336,87 @@ export default function PosStaffPanel({ {showForm && (
+ {/* Every field now carries a real label rather than relying on a + placeholder. A placeholder disappears the moment you type, so + the only cue for what a half-filled field is went away exactly + when it was needed. */}
- setFullName(e.target.value)} - placeholder="Full name" - className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white" - style={{ border: `1px solid ${BORDER}`, color: TEXT }} + onChange={(v) => setFullName(v)} + placeholder="e.g. Anitha R" + status={fieldErrs.name ? { type: 'error', message: fieldErrs.name } : undefined} /> - - setRole(String(v ?? ''))} + placeholder="Choose a role…" + options={roles.map((r) => ({ value: r.role, label: r.label }))} + status={fieldErrs.role ? { type: 'error', message: fieldErrs.role } : undefined} + /> + setPin(e.target.value.replace(/\D/g, '').slice(0, 4))} - placeholder="4-digit PIN" - inputMode="numeric" - className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white font-mono tracking-widest" - style={{ border: `1px solid ${BORDER}`, color: TEXT }} + onChange={(v) => setPin(v.replace(/\D/g, '').slice(0, 4))} + placeholder="4 digits" + status={fieldErrs.pin ? { type: 'error', message: fieldErrs.pin } : undefined} />
- setPhone(e.target.value.replace(/[^\d+\s-]/g, '').slice(0, 16))} - placeholder="Mobile number — used to sign in" - inputMode="tel" - className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white font-mono" - style={{ border: `1px solid ${BORDER}`, color: TEXT }} + onChange={(v) => setPhone(v.replace(/[^\d+\s-]/g, '').slice(0, 16))} + placeholder="10 digits" + status={fieldErrs.phone ? { type: 'error', message: fieldErrs.phone } : undefined} + /> + setShiftId(Number(v ?? 0))} + placeholder={shiftsQ.data?.length ? 'No shift assigned' : 'No shifts set up yet'} + options={[ + { value: '0', label: shiftsQ.data?.length ? 'No shift assigned' : 'No shifts set up yet' }, + ...(shiftsQ.data ?? []).map((sh) => ({ + value: String(sh.staff_shift_id), + label: `${sh.name} · ${sh.start_time}–${sh.end_time}`, + })), + ]} /> -

- The mobile number is what they type at the till. A username and password are also - generated; the password is shown once, on the next screen. + A username and password are also generated; the password is shown once, on the + next screen.

- {formError && ( -

{formError}

- )} + {/* The mobile-number hint moved onto the field itself as a + `description`, so this paragraph no longer repeats it. */}
- - + /> +
)} @@ -503,71 +537,53 @@ export default function PosStaffPanel({ {editing && ( setEditing(null)} title={`Edit ${editing.full_name}`}>
- setEditName(e.target.value)} - placeholder="Full name" - className="w-full rounded-lg px-3 py-2 text-[13px] outline-none" - style={{ border: `1px solid ${BORDER}`, color: TEXT }} - /> - - setEditRole(String(v ?? ''))} + options={roles.map((r) => ({ value: r.role, label: r.label }))} + /> + setEditPin(e.target.value.replace(/\D/g, '').slice(0, 4))} - placeholder="4-digit PIN" - inputMode="numeric" - className="w-full rounded-lg px-3 py-2 text-[13px] outline-none font-mono tracking-widest" - style={{ border: `1px solid ${BORDER}`, color: TEXT }} + onChange={(v) => setEditPin(v.replace(/\D/g, '').slice(0, 4))} + placeholder="4 digits" /> - setEditPhone(e.target.value.replace(/[^\d+\s-]/g, '').slice(0, 16))} - placeholder="Mobile number — used to sign in" - inputMode="tel" - className="w-full rounded-lg px-3 py-2 text-[13px] outline-none font-mono" - style={{ border: `1px solid ${BORDER}`, color: TEXT }} + onChange={(v) => setEditPhone(v.replace(/[^\d+\s-]/g, '').slice(0, 16))} + /> + setEditShift(Number(v ?? 0))} + options={[ + { value: '0', label: 'No shift assigned' }, + ...(shiftsQ.data ?? []).map((sh) => ({ + value: String(sh.staff_shift_id), + label: `${sh.name} · ${sh.start_time}–${sh.end_time}`, + })), + ]} /> -
+ {/* editError stays a single message here. The edit dialog validates + one rule at a time against fields that are already populated, so + there is no four-errors-at-once problem to solve — unlike the + create form, which starts empty. */} {editError &&

{editError}

}
- - + /> +
)} @@ -781,26 +797,28 @@ function ShiftManager({ {open && (
- setName(e.target.value)} - placeholder="Name — e.g. Morning" - className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white" - style={{ border: `1px solid ${BORDER}`, color: TEXT }} + onChange={(v) => setName(v)} + placeholder="e.g. Morning" /> - setStart(e.target.value)} - className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white" - style={{ border: `1px solid ${BORDER}`, color: TEXT }} + {/* PHASE 7's TimeInput, landing here rather than as a separate + step — these two fields are the reason it was on the list. + It emits an ISO time string or undefined, so the `?? ''` + keeps the existing string state contract unchanged. */} + setStart((v ?? '') as string)} /> - setEnd(e.target.value)} - className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white" - style={{ border: `1px solid ${BORDER}`, color: TEXT }} + setEnd((v ?? '') as string)} />
@@ -947,24 +965,24 @@ function Modal({ onClose: () => void; children: React.ReactNode; }) { + // PHASE 6 — on Astryx Dialog. All three dialogs on this page (edit, reset + // password, deactivate) render through here, so converting this one function + // converts all three without touching a single call site. + // + // isOpen is always true because callers mount this conditionally + // (`{editing && }`) rather than passing a flag. Dialog wants the + // flag, so it gets a constant one; the caller's conditional is still what + // controls visibility. + // + // The `minWidth: min(20rem, 100%)` floor is gone with the hand-rolled card. + // It existed to survive `max-w-sm` collapsing to 8px, and Dialog sizes itself + // from a `width` prop rather than a Tailwind utility — so the collision that + // bug came from cannot reach it. return ( -
-
-
- {/* Names come from user input and can be long and unbroken. */} -

- {title} -

- {children} -
-
+ { if (!open) onClose(); }} width={400} purpose="info"> + {/* Names come from user input and can be long and unbroken. */} + { if (!open) onClose(); }} /> + {children} + ); } diff --git a/src/components/PosView.tsx b/src/components/PosView.tsx index dcc435b..8725712 100644 --- a/src/components/PosView.tsx +++ b/src/components/PosView.tsx @@ -2,6 +2,51 @@ import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react' import { Search, Plus, Minus, Trash2, Receipt, X, Tag, QrCode, CreditCard, PieChart, Barcode, ShoppingCart, User, RefreshCcw, Save, Download, ChevronRight, Zap, Wallet, Smartphone, AlertCircle } from 'lucide-react'; import { useFiestaStockStatement, FIESTA_TENANT_ID } from '../services/fiestaQueries'; import { num as fnum, str as fstr, type Row } from '../services/fiestaApi'; +import { useNotify } from './Toasts'; +import { Dialog } from '@astryxdesign/core/Dialog'; + +/** + * PHASE 6 — the one modal shell for this screen. + * + * PosView carried SEVEN hand-rolled `fixed inset-0` overlays, the largest + * concentration anywhere in the app. Every one was the same construction: a + * blurred backdrop, a white card, an inline `maxWidth`, and a header row with a + * close button. None trapped focus, none closed on Escape, and each had to be + * dismissed by finding its own X. + * + * They now share this. Widths are passed per call site so each dialog keeps the + * exact size it had (500 / 450 / 400px), and `maxHeight` is preserved where the + * original set one — the two that scroll internally would otherwise grow past + * the viewport on a long list. + * + * `print:hidden` is intentionally NOT carried over. It was on the backdrop of + * every overlay including the receipt one, which is why the receipt modal had + * to fight it back with `print:max-w-none`. Dialog renders in the top layer, + * where the browser's own print handling applies. + */ +function PosModal({ + onClose, + width, + maxHeight, + children, +}: { + onClose: () => void; + width: number; + maxHeight?: string; + children: React.ReactNode; +}) { + return ( + { if (!open) onClose(); }} + width={width} + maxHeight={maxHeight} + purpose="info" + > + {children} + + ); +} interface PosViewProps { locationid?: number; @@ -24,6 +69,10 @@ interface CartItem { } export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: PosViewProps) { + // PHASE 6 — replaces alert(). A native alert freezes the whole browser + // until dismissed; a toast does not, and errors stay until read. + const { notifyOk, notifyFail } = useNotify(); + const stockQ = useFiestaStockStatement({ tenantid: tenantId, locationid: locationid ?? 0, pagesize: 200 }); const [search, setSearch] = useState(''); @@ -236,7 +285,7 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos if (product) { handleProductScanned(product); } else { - alert('Product not found in inventory.'); + notifyFail('Product not found in inventory.'); } } } @@ -733,8 +782,7 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos {/* ── MODAL: Manual Product Search ──────────────────────────────── */} {showManualSearch && ( -
-
+ setShowManualSearch(false)} width={500} maxHeight="85vh">

Product Search @@ -781,14 +829,12 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos ))}

-
-
+ )} {/* ── MODAL: Customer CRM ───────────────────────────────────────── */} {showCustomerPrompt && ( -
-
+
@@ -842,14 +888,12 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos )}
-
-
+
)} {/* ── MODAL: Parked Carts ───────────────────────────────────────── */} {showParkedPrompt && ( -
-
+ setShowParkedPrompt(false)} width={450} maxHeight="80vh">

Parked Sales @@ -883,14 +927,12 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos )) )}

-
-
+ )} {/* ── MODAL: Discount ───────────────────────────────────────────── */} {discountPromptItem && ( -
-
+ setDiscountPromptItem(null)} width={400}>
@@ -938,14 +980,12 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos Confirm Discount
-
-
+
)} {/* ── MODAL: Payment ────────────────────────────────────────────── */} {showPayment && ( -
-
+ setShowPayment(false)} width={450}> {/* Header */}
@@ -1104,14 +1144,12 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos Confirm {grandTotal < 0 ? 'Refund' : 'Payment'}
-
-
+ )} {/* ── MODAL: Receipt ────────────────────────────────────────────── */} {showReceipt && ( -
-
+

Receipt @@ -1177,14 +1215,12 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos Print

-
-
+ )} {/* ── MODAL: Daily Report ───────────────────────────────────────── */} {showReport && ( -
-
+ setShowReport(false)} width={450}>

Daily Sales Report @@ -1274,8 +1310,7 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos )}

-
-
+ )}
); diff --git a/src/components/ReportsView.tsx b/src/components/ReportsView.tsx index c78303d..93fd8f0 100644 --- a/src/components/ReportsView.tsx +++ b/src/components/ReportsView.tsx @@ -40,6 +40,7 @@ import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr, import { stockRowToProduct } from '../services/fiestaMappers'; import AwaitingApi from './AwaitingApi'; import SalesRevenueReport from './SalesRevenueReport'; +import { useNotify } from './Toasts'; interface ReportsViewProps { searchQuery: string; @@ -52,6 +53,10 @@ const WEEK_KEYS = ['week1', 'week2', 'week3', 'week4', 'week5']; const WEEK_LABELS = ['Week 1', 'Week 2', 'Week 3', 'Week 4', 'Week 5']; export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimbatoreView, tenantId = FIESTA_TENANT_ID }: ReportsViewProps) { + // PHASE 6 — replaces alert(). A native alert freezes the whole browser + // until dismissed; a toast does not, and errors stay until read. + const { notifyOk, notifyFail } = useNotify(); + const [selectedTimeframe, setSelectedTimeframe] = useState('This Year (YTD)'); const [selectedRegion, setSelectedRegion] = useState<'all' | 'coimbatore' | 'chennai' | 'bangalore'>('all'); const [activeStoreId, setActiveStoreId] = useState('all'); @@ -433,7 +438,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba clearInterval(interval); setTimeout(() => { setExportingFormat(null); - alert(`${format} ledger exported successfully.`); + notifyOk(`${format} ledger exported.`); }, 450); return 100; } diff --git a/src/components/RiderTelemetryPanel.tsx b/src/components/RiderTelemetryPanel.tsx index 424fe19..d5043c5 100644 --- a/src/components/RiderTelemetryPanel.tsx +++ b/src/components/RiderTelemetryPanel.tsx @@ -43,7 +43,10 @@ export default function RiderTelemetryPanel({ logs, riderName, isLoading }: Ride const lat = latest?.latitude || latest?.lat; const lon = latest?.longitude || latest?.lng; const battery = latest?.batterypercentage || latest?.battery; - const signal = latest?.signalstrength || latest?.signal; + // Typed at the declaration rather than cast at each use. The telemetry rows + // are untyped, so without this `signal` is `unknown` and cannot be rendered + // or passed to getSignalBars. The value is a dBm reading or absent. + const signal = (latest?.signalstrength || latest?.signal) as number | undefined; const speed = latest?.speed || 0; const timestamp = latest?.logdate || latest?.timestamp; diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 802ca1d..de3150a 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -25,6 +25,7 @@ import AwaitingApi from './AwaitingApi'; import AdminConsole from './AdminConsole'; import { ConfirmModal } from './ConfirmModal'; import type { AuthUser } from '../services/auth'; +import { useNotify } from './Toasts'; type TabKey = 'profile' | 'outlets' | 'users'; @@ -128,6 +129,10 @@ export interface SettingsViewProps { } export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: SettingsViewProps) { + // PHASE 6 — replaces alert(). A native alert freezes the whole browser + // until dismissed; a toast does not, and errors stay until read. + const { notifyOk, notifyFail } = useNotify(); + const [activeTab, setActiveTab] = useState('profile'); // Live tenant profile + outlets. @@ -166,9 +171,9 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett }); setShowEditStoreModal(false); setEditingStore(null); - alert('Outlet updated successfully!'); + notifyOk('Outlet updated.'); } catch (err: any) { - alert(err.message || 'Failed to update outlet.'); + notifyFail(err, 'Failed to update outlet.'); } }; @@ -183,7 +188,7 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett await deleteLocationMut.mutateAsync({ tenantid: tenantId, locationid: loc.locationid }); setConfirmModalState({ isOpen: false, loc: null }); } catch (err: any) { - alert(err.message || 'Failed to delete outlet.'); + notifyFail(err, 'Failed to delete outlet.'); setConfirmModalState({ isOpen: false, loc: null }); } }; diff --git a/src/components/StoreCatalogView.tsx b/src/components/StoreCatalogView.tsx index 183123e..aaef1b7 100644 --- a/src/components/StoreCatalogView.tsx +++ b/src/components/StoreCatalogView.tsx @@ -33,6 +33,12 @@ import { CardCarousel, ProductGallery } from './CatalogueGallery'; import FMCGHoverOverlay from './FMCGHoverOverlay'; import CatalogueDetailPanel from './CatalogueDetailPanel'; import OfflineSalesUpload from './OfflineSalesUpload'; +import { useNotify } from './Toasts'; +// PHASE 6 — the store catalogue's own controls. +import { TextInput } from '@astryxdesign/core/TextInput'; +import { NumberInput } from '@astryxdesign/core/NumberInput'; +import { DateInput } from '@astryxdesign/core/DateInput'; +import { CheckboxInput } from '@astryxdesign/core/CheckboxInput'; const PLACEHOLDER = 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200'; @@ -79,6 +85,10 @@ function catBadgeClass(category: string): string { } export default function StoreCatalogView({ locationid, storeName = 'your store', tenantId = FIESTA_TENANT_ID, isSidebarOpen = false }: StoreCatalogViewProps) { + // PHASE 6 — replaces alert(). A native alert freezes the whole browser + // until dismissed; a toast does not, and errors stay until read. + const { notifyOk, notifyFail } = useNotify(); + const tenantid = tenantId; const [view, setView] = useState<'catalogue' | 'inventory' | 'requests'>('catalogue'); const [isLocalSidebarOpen, setIsLocalSidebarOpen] = useState(false); @@ -146,7 +156,11 @@ export default function StoreCatalogView({ locationid, storeName = 'your store', ); const stockRequestsQ = useFiestaGetStockRequests({ tenantid, locationid: storeLocationId, pagesize: 500, date: requestDate }); - const [picks, setPicks] = useState>({}); + // `requestid` is written by the effect below and read by the Mark Received + // button; it was simply missing from this type, so the read did not compile + // once React types were installed. The value has always been present at + // runtime — this closes the type gap, it does not change behaviour. + const [picks, setPicks] = useState>({}); useEffect(() => { if (stockRequestsQ.data) { @@ -187,7 +201,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store', if (picks[String(data.productid)]?.status === 'Received') return; if (updateRequestMutation.isPending) return; if (!storeLocationId) { - alert('Your account isn\'t linked to a store outlet yet, so stock can\'t be received.'); + notifyFail('Your account isn\'t linked to a store outlet yet, so stock can\'t be received.'); return; } updateRequestMutation.mutate( @@ -200,7 +214,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store', }, { onError: (err: any) => { - alert(err.message || 'Failed to record received stock.'); + notifyFail(err, 'Failed to record received stock.'); }, }, ); @@ -213,7 +227,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store', setPicks(prev => ({ ...prev, [id]: { ...prev[id], status: 'Cancelled', resolvedAt: new Date().toISOString() } })); } else { if (!storeLocationId) { - alert('Your account isn\'t linked to a store outlet yet, so stock can\'t be requested.'); + notifyFail('Your account isn\'t linked to a store outlet yet, so stock can\'t be requested.'); return; } setPicks(prev => ({ ...prev, [id]: { qty: 1, status: 'Pending', requestedAt: new Date().toISOString() } })); @@ -231,7 +245,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store', const setPickQty = (id: string, qty: number) => { const safeQty = Math.max(1, Math.round(qty) || 1); if (!storeLocationId) { - alert('Your account isn\'t linked to a store outlet yet, so stock can\'t be requested.'); + notifyFail('Your account isn\'t linked to a store outlet yet, so stock can\'t be requested.'); return; } setPicks((prev) => { @@ -404,14 +418,16 @@ export default function StoreCatalogView({ locationid, storeName = 'your store', Upload Offline Sales + {/* PHASE 6 — DateInput carries its own label, so the separate "Date:" + caption is dropped rather than duplicated beside it. The comment sits + outside the conditional: as its first child it read as a second root + element and broke the whole file. */} {view === 'requests' && ( -
- Date: - setRequestDate(e.target.value)} - className="px-3 py-1.5 border border-slate-200 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-[#662582] focus:border-[#662582] text-xs font-semibold text-slate-700 bg-white" +
+ setRequestDate((v ?? '') as string)} />
)} @@ -436,20 +452,13 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
{categories.map((c) => ( - +
+ toggleCategory(c)} + /> +
))}
@@ -463,20 +472,13 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
{['Healthy', 'Low', 'Critical', 'Out of stock'].map((h) => ( - +
+ toggleStockHealth(h)} + /> +
))}
@@ -521,20 +523,20 @@ export default function StoreCatalogView({ locationid, storeName = 'your store', Filter -
- - + setSearch(e.target.value)} - className="w-full h-full pl-9 pr-8 py-2 bg-white border border-slate-200 rounded-xl text-xs text-slate-800 placeholder-slate-400 focus:outline-none focus:border-[#662582] transition-all shadow-sm font-medium" + onChange={(v) => setSearch(v)} + placeholder={view === 'catalogue' ? 'Search catalogue…' : 'Search your stock…'} + startIcon={} + hasClear /> - {search && ( - - )}
@@ -881,24 +883,15 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
-
- -
-
- -
- setTempQty(Math.max(1, parseInt(e.target.value) || 1))} - className="w-full h-10 pl-9 pr-12 text-right text-lg font-bold font-mono text-[#662582] bg-slate-50/50 rounded-lg border border-slate-200 outline-none focus:border-[#662582] focus:bg-white transition-all shadow-inner" - /> -
- Units -
-
-
+ {/* NumberInput enforces the minimum itself, so the Math.max guard + stays only as the floor for a pasted or cleared value. */} + setTempQty(Math.max(1, Number(v) || 1))} + startIcon={} + />
{/* Total & Submit */} diff --git a/src/components/SuperAdminSidebar.tsx b/src/components/SuperAdminSidebar.tsx index af50da4..94f23d7 100644 --- a/src/components/SuperAdminSidebar.tsx +++ b/src/components/SuperAdminSidebar.tsx @@ -3,16 +3,19 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** + * PHASE 4 — now a wrapper over the shared `NavRail`. + * + * The props and the rendered result are unchanged, so `SuperAdminPage` was not + * touched. The only behavioural difference is that tapping an item on a phone + * now closes the rail, which the user rail already did and this one didn't. + */ + import React from 'react'; -import { type LucideIcon } from 'lucide-react'; -import { NavLink } from 'react-router-dom'; +import NavRail, { type NavRailItem } from './NavRail'; /** One entry in the super admin sidebar. */ -export interface SuperAdminNavItem { - id: string; - label: string; - icon: LucideIcon; -} +export type SuperAdminNavItem = NavRailItem; interface SuperAdminSidebarProps { items: SuperAdminNavItem[]; @@ -21,53 +24,6 @@ interface SuperAdminSidebarProps { onClose: () => void; } -/** - * The Super Admin page's left rail. Visually identical to the admin `Sidebar`. - */ export default function SuperAdminSidebar({ items, isOpen, onClose }: SuperAdminSidebarProps) { - return ( - <> - {/* Mobile Overlay */} - {isOpen && ( -
- )} - - - - ); + return ; } diff --git a/src/components/Toasts.tsx b/src/components/Toasts.tsx new file mode 100644 index 0000000..4e9caca --- /dev/null +++ b/src/components/Toasts.tsx @@ -0,0 +1,52 @@ +/** + * PHASE 5 — toasts. + * + * The console has never had any. Publishing a product, importing from the + * catalogue, deactivating a till user and deleting a product location all + * complete in silence today: the only feedback is that a row eventually + * changes, or doesn't. When something fails, the user usually cannot tell + * whether it failed or simply hasn't finished. + * + * `ToastViewport` is mounted once in main.tsx. Anything below it can call + * `useToast()` and get a function that shows one. This file adds two thin + * helpers over that so call sites read as intent rather than configuration. + * + * Astryx's ToastType is only 'info' | 'error' — there is no success type — so + * `notifyOk` uses 'info'. Do not invent a 'success' string; it will not + * typecheck, and silently falling back would be worse. + */ + +import { useToast } from '@astryxdesign/core/Toast'; + +/** + * Toasts for the result of a mutation. + * + * @example + * const { notifyOk, notifyFail } = useNotify(); + * publishMut.mutate(vars, { + * onSuccess: () => notifyOk('Published to all outlets'), + * onError: (e) => notifyFail(e), + * }); + */ +export function useNotify() { + const toast = useToast(); + + return { + notifyOk: (message: string) => toast({ body: message, isAutoHide: true }), + + /** + * Accepts the raw error so no call site has to remember how to unwrap one. + * Errors do NOT auto-hide: a failure the user blinked past is a failure + * they will report as "nothing happened". + */ + notifyFail: (err: unknown, fallback = 'Something went wrong. Please try again.') => { + const message = + err instanceof Error ? err.message + : typeof err === 'string' ? err + : fallback; + return toast({ body: message, type: 'error', isAutoHide: false }); + }, + }; +} + +export { useToast }; diff --git a/src/components/TrialBatchDrawer.tsx b/src/components/TrialBatchDrawer.tsx index cd4c77a..24d9201 100644 --- a/src/components/TrialBatchDrawer.tsx +++ b/src/components/TrialBatchDrawer.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import { Package, MapPin, Truck, ChevronDown, Check, X } from 'lucide-react'; import { useFiestaTenantLocations } from '../services/fiestaQueries'; import { str as fstr } from '../services/fiestaApi'; +import { useNotify } from './Toasts'; export interface TrialProduct { id: string | number; @@ -21,6 +22,10 @@ interface TrialBatchDrawerProps { } export default function TrialBatchDrawer({ isOpen, onClose, product, tenantId, onSuccess }: TrialBatchDrawerProps) { + // PHASE 6 — replaces alert(). A native alert freezes the whole browser + // until dismissed; a toast does not, and errors stay until read. + const { notifyOk, notifyFail } = useNotify(); + const [qty, setQty] = useState(5); const [isSubmitting, setIsSubmitting] = useState(false); const [isSuccess, setIsSuccess] = useState(false); @@ -49,7 +54,7 @@ export default function TrialBatchDrawer({ isOpen, onClose, product, tenantId, o const handleSubmit = () => { if (selectedLocationIds.length === 0) { - alert("Please select at least one store delivery hub."); + notifyFail('Select at least one store delivery hub.'); return; } setIsSubmitting(true); diff --git a/src/components/UserStorePage.tsx b/src/components/UserStorePage.tsx index acfc012..67356ef 100644 --- a/src/components/UserStorePage.tsx +++ b/src/components/UserStorePage.tsx @@ -41,6 +41,7 @@ import PosConsoleView from './PosConsoleView'; import PosErrorBoundary from './PosErrorBoundary'; import UserStoreSidebar, { type UserNavItem } from './UserStoreSidebar'; import ComparisonModal from './ComparisonModal'; +import { useNotify } from './Toasts'; interface UserStorePageProps { /** Returns to the login screen. */ onLogout: () => void; @@ -68,6 +69,10 @@ type StoreShape = React.ComponentProps['store']; * no other stores. Layout mirrors the admin console (fixed Header + left rail). */ export default function UserStorePage({ onLogout, user }: UserStorePageProps) { + // PHASE 6 — replaces alert(). A native alert freezes the whole browser + // until dismissed; a toast does not, and errors stay until read. + const { notifyOk, notifyFail } = useNotify(); + const [sidebarOpen, setSidebarOpen] = useState(false); const [showQrModal, setShowQrModal] = useState(false); const location = useLocation(); @@ -151,7 +156,7 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) { .toUpperCase() || 'NA'; const handleHelp = () => - alert('nearledaily Store User Help — contact your store administrator for access or data questions.'); + notifyOk('Contact your store administrator for access or data questions.'); // Build the store shape StoreDetailView expects, mirroring the App registry // mapping so the console renders identically to the admin's store view. diff --git a/src/components/UserStoreSidebar.tsx b/src/components/UserStoreSidebar.tsx index 446516f..5686008 100644 --- a/src/components/UserStoreSidebar.tsx +++ b/src/components/UserStoreSidebar.tsx @@ -3,16 +3,19 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** + * PHASE 4 — now a wrapper over the shared `NavRail`. + * + * Props and rendered result are unchanged, so `UserStorePage` was not touched. + * `itemRadius="lg"` preserves this rail's rounded items, which is where it + * differed from the super-admin one. + */ + import React from 'react'; -import { type LucideIcon } from 'lucide-react'; -import { NavLink } from 'react-router-dom'; +import NavRail, { type NavRailItem } from './NavRail'; /** One entry in the user store sidebar. Add to `UserStorePage`'s `NAV_ITEMS`. */ -export interface UserNavItem { - id: string; - label: string; - icon: LucideIcon; -} +export type UserNavItem = NavRailItem; interface UserStoreSidebarProps { items: UserNavItem[]; @@ -21,60 +24,6 @@ interface UserStoreSidebarProps { onClose: () => void; } -/** - * The user store page's left rail. Visually identical to the admin `Sidebar` - * (fixed, sits under the fixed Header, collapses to an icon rail) but driven by - * a generic items list so new user sections can be added without touching this. - */ export default function UserStoreSidebar({ items, isOpen, onClose }: UserStoreSidebarProps) { - return ( - <> - {/* Mobile Overlay */} - {isOpen && ( -
- )} - - - - ); + return ; } diff --git a/src/components/UsersPanel.tsx b/src/components/UsersPanel.tsx index 64b9501..27a63f9 100644 --- a/src/components/UsersPanel.tsx +++ b/src/components/UsersPanel.tsx @@ -34,6 +34,7 @@ import { useFiestaUsers, useFiestaCreateUser, useFiestaUpdateUser, useFiestaRide import { useAppRoles } from '../services/queries'; import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi'; import AddressAutocomplete, { type AddressResult } from './AddressAutocomplete'; +import { useNotify } from './Toasts'; interface UsersPanelProps { tenantId?: number; @@ -83,6 +84,10 @@ const FALLBACK_ROLE_CHOICES = [ ]; export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUserRole = 4 }: UsersPanelProps) { + // PHASE 6 — replaces alert(). A native alert freezes the whole browser + // until dismissed; a toast does not, and errors stay until read. + const { notifyOk, notifyFail } = useNotify(); + const usersQ = useFiestaUsers({ tenantid: tenantId, pagesize: 100 }); const createUserMut = useFiestaCreateUser(); const updateUserMut = useFiestaUpdateUser(); @@ -105,7 +110,7 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser await updateUserMut.mutateAsync({ userid, roleid: -1, locationid: -1, applocationid: -1 }); } } catch (err) { - alert(`Could not update user role: ${err instanceof Error ? err.message : 'Unknown error'}`); + notifyFail(err, 'Could not update user role.'); } }; @@ -246,23 +251,23 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser const handleCreateUser = async (e: React.FormEvent) => { e.preventDefault(); if (!newUser.firstname || !newUser.email || !newUser.contactno) { - alert('Please provide first name, contact number, and email.'); + notifyFail('Please provide first name, contact number, and email.'); return; } if (!/^\d{10}$/.test(newUser.contactno.trim())) { - alert('Contact number must be exactly 10 digits.'); + notifyFail('Contact number must be exactly 10 digits.'); return; } if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newUser.email.trim())) { - alert('Please enter a valid email address.'); + notifyFail('Please enter a valid email address.'); return; } if (!Number(newUser.roleid)) { - alert('Please select a role.'); + notifyFail('Please select a role.'); return; } if (!newUser.locationid) { - alert('Please select the store this user belongs to.'); + notifyFail('Please select the store this user belongs to.'); return; } try { @@ -288,9 +293,9 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser }); setShowAddUserModal(false); setNewUser({ firstname: '', email: '', contactno: '', roleid: defaultNewUserRole, locationid: 0, applocationid: 0, address: '', suburb: '', city: '', state: '', postcode: '', latitude: '', longitude: '', shiftid: 0 }); - alert(`Team member "${newUser.firstname}" added successfully.`); + notifyOk(`Team member "${newUser.firstname}" added.`); } catch (err) { - alert(`Could not add team member: ${err instanceof Error ? err.message : 'Unknown error'}`); + notifyFail(err, 'Could not add team member.'); } }; @@ -715,7 +720,7 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser }); setAssignUserId(null); } catch (err) { - alert(`Could not assign user: ${err instanceof Error ? err.message : 'Unknown error'}`); + notifyFail(err, 'Could not assign user.'); } }} className="p-6 space-y-5">
diff --git a/src/components/consoleUi.tsx b/src/components/consoleUi.tsx index 6556ffd..0c8eeee 100644 --- a/src/components/consoleUi.tsx +++ b/src/components/consoleUi.tsx @@ -18,6 +18,41 @@ import React, { useEffect } from 'react'; import { createPortal } from 'react-dom'; import { X, Info } from 'lucide-react'; +import { Card as AxCard } from '@astryxdesign/core/Card'; +import { Skeleton as AxSkeleton } from '@astryxdesign/core/Skeleton'; +import { Tooltip as AxTooltip } from '@astryxdesign/core/Tooltip'; +import { StatusDot } from '@astryxdesign/core/StatusDot'; +import { + Table as AxTable, + TableHeader, + TableHeaderCell, + TableRow, + TableBody, +} from '@astryxdesign/core/Table'; + +/* + * PHASE 3 — Astryx adapters. + * + * Every export below keeps its original name and signature, so the ten files + * that import from here were not touched. What changed is what each one renders + * underneath. + * + * SEVEN of the fourteen primitives now sit on Astryx: Card, FilterBar, + * TableShell, Skeleton, Tooltip, LiveStatus and SlideDrawer. Each was a + * lossless swap. + * + * THE OTHER SEVEN DELIBERATELY DID NOT MOVE, and the reason is the same for + * most of them: `Pill`, `StatusChip` and `MetricPill` take an arbitrary hex via + * a `color` prop, fed from ORDER_STATUS / DELIVERY_STATUS. Astryx's Badge takes + * a named variant from a fixed set of fourteen. Mapping onto it is lossy in a + * way that matters — both `accepted` (#6366f1) and `picked` (#8b5cf6) collapse + * to `purple`, so two distinct states on the delivery board would become + * indistinguishable. That is a functional regression, not a cosmetic one, so + * these keep rendering their own markup until the status maps themselves are + * reworked onto semantic names. GradientHeader (brand gradient), KpiStrip + * (bespoke layout), StampCell and SearchPill are unchanged for want of a + * genuine equivalent. + */ // ── Design tokens ──────────────────────────────────────────────────────────────── export const BRAND = '#662582'; @@ -114,13 +149,13 @@ function BrandMark() { /** Live / loading / error status line used under the header title. */ export function LiveStatus({ state, label }: { state: 'live' | 'loading' | 'error'; label: string }) { - const color = state === 'error' ? '#ef4444' : state === 'loading' ? '#94a3b8' : '#10b981'; + // The three states map exactly onto three Astryx variants, and `isPulsing` + // replaces the hand-rolled animate-pulse. Semantic tokens mean these now + // track the theme instead of three hardcoded hexes. + const variant = state === 'error' ? 'error' : state === 'loading' ? 'neutral' : 'success'; return ( - + {label} ); @@ -153,7 +188,9 @@ export function KpiStrip({ items, loading }: { items: KpiItem[]; loading?: boole className="p-1.5 rounded-lg transition-transform group-hover:scale-110 flex items-center justify-center shrink-0" style={{ background: soft(it.color), color: it.color }} > - {React.cloneElement(it.icon as React.ReactElement, { className: 'w-3.5 h-3.5' })} + {/* The element's props have to be named for cloneElement to accept + className — a bare ReactElement is typed as Partial. */} + {React.cloneElement(it.icon as React.ReactElement<{ className?: string }>, { className: 'w-3.5 h-3.5' })}
@@ -276,21 +313,20 @@ export function SearchPill({ value, onChange, placeholder, color = BRAND, inputR // ── Card shells & table head cell ──────────────────────────────────────────────── export function Card({ children, className = '', flush }: { children: React.ReactNode; className?: string; flush?: 'top' }) { return ( -
+ // padding={0} because every caller already supplies its own padding + // classes; letting Astryx add its default would shift ten screens at once. + {children} -
+ ); } /** Filter/tab bar paper that visually joins the table below it (flat bottom). */ export function FilterBar({ children, className = '' }: { children: React.ReactNode; className?: string }) { return ( -
+ {children} -
+ ); } @@ -306,24 +342,31 @@ export const TH_STYLE: React.CSSProperties = { }; export function TableShell({ head, children, minWidth, footer }: { head: string[]; children: React.ReactNode; minWidth?: number; footer?: React.ReactNode }) { + // `children` is a stream of caller-authored /, so the body stays a + // plain TableBody passthrough — swapping every row in ten screens is Phase 6's + // job, not this one. Head cells move to Astryx now because they are owned + // here and nobody outside supplies them. return ( -
+
- - - + + + {head.map((h, i) => ( - + // TableHeaderCell has no `align` prop, so the right-alignment + // from column three onwards is kept on an inner span rather + // than lost. + + 1 ? 'block text-right' : 'block'}>{h} + ))} - - - - {children} - -
1 ? 'text-right' : ''}`}>{h}
+ + + {children} +
{footer} -
+ ); } @@ -334,11 +377,13 @@ export function TableShell({ head, children, minWidth, footer }: { head: string[ * Uses a pulsing animation to match shape before data arrives. */ export function Skeleton({ className = '', style }: { className?: string; style?: React.CSSProperties }) { + // Callers size this with Tailwind classes (`h-4 w-20`), while Astryx's + // Skeleton wants numeric width/height. The wrapper keeps the caller's sizing + // and lets the Astryx one fill it, so no call site had to change. return ( -
+
+ +
); } @@ -347,16 +392,14 @@ export function Skeleton({ className = '', style }: { className?: string; style? * Wraps children and shows a sleek dark explanation box on hover. */ export function Tooltip({ children, content, position = 'top' }: { children: React.ReactNode; content: React.ReactNode; position?: 'top' | 'bottom' }) { + // Straight swap. Astryx's version also opens on keyboard focus, which the + // hover-only original never did — a real accessibility gain for free. return ( -
- {children} - + // Astryx names placements by flow direction, not by physical edge — so + // top/bottom become above/below. The public prop keeps its old names. + + {children} + ); } @@ -376,20 +419,37 @@ export function SlideDrawer({ children: React.ReactNode; }) { - // Prevent body scroll when open + // Kept rather than delegated to Astryx's Dialog. Dialog is a centred modal + // built on ; this is an edge-anchored drawer, and forcing one into + // the other would change the shape of six screens in a phase whose whole + // premise is that call sites do not change. What it does gain is Dialog's + // two behaviours that were missing here: Escape closes it, and focus is + // restored to whatever opened it. Phase 5 is where the overlay family gets + // unified properly. useEffect(() => { if (isOpen) document.body.style.overflow = 'hidden'; else document.body.style.overflow = ''; return () => { document.body.style.overflow = ''; }; }, [isOpen]); + useEffect(() => { + if (!isOpen) return; + const opener = document.activeElement as HTMLElement | null; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('keydown', onKey); + opener?.focus?.(); + }; + }, [isOpen, onClose]); + if (!isOpen) return null; return (
{/* Muted Backdrop */} -
diff --git a/src/components/spike/ConsoleUiProbe.tsx b/src/components/spike/ConsoleUiProbe.tsx new file mode 100644 index 0000000..3dc8f35 --- /dev/null +++ b/src/components/spike/ConsoleUiProbe.tsx @@ -0,0 +1,121 @@ +/** + * PHASE 3 verification harness — THROWAWAY, delete with the spike folder. + * + * The ten screens that consume consoleUi sit behind a login, so this renders + * the seven rewritten primitives directly with stand-in data. It imports them + * by their original names through the original module path, which is the point: + * if these render, the adapters are signature-compatible. + */ + +import React, { useState } from 'react'; +import { + Card, + FilterBar, + TableShell, + Skeleton, + Tooltip, + LiveStatus, + SlideDrawer, + Pill, + StatusChip, + MetricPill, + StampCell, + SearchPill, + KpiStrip, + ORDER_STATUS, + DELIVERY_STATUS, + statusColor, + BRAND, +} from '../consoleUi'; +import { Package } from 'lucide-react'; +import { ConfirmModal } from '../ConfirmModal'; +import { useNotify } from '../Toasts'; + +export default function ConsoleUiProbe() { + const { notifyOk, notifyFail } = useNotify(); + const [confirm, setConfirm] = useState(false); + const [drawer, setDrawer] = useState(false); + const [q, setQ] = useState(''); + const [tab, setTab] = useState('pending'); + + return ( +
+

consoleUi — Phase 3 adapters

+ + }, + { label: 'Delivered', value: '1,102', color: '#10b981', icon: , badge: '88%' }, + { label: 'Pending', value: '94', color: '#f59e0b', icon: }, + { label: 'Cancelled', value: '52', color: '#ef4444', icon: }, + ]} + /> + + +
+ + + +
+ {['pending', 'confirmed', 'delivered', 'cancelled'].map((s) => ( + setTab(s)} count={12}> + {s} + + ))} + + hover me + + +
+
+ + + {[ + { id: '#1043', s: 'delivered', km: '4.2', amt: '₹248' }, + { id: '#1041', s: 'cancelled', km: '1.1', amt: '₹89' }, + { id: '#1040', s: 'pending', km: '7.8', amt: '₹156' }, + ].map((r) => ( + + {r.id} + + {r.km} km + {r.amt} + + + ))} + + + +
Skeletons
+
+ + + +
+
+ + +
Phase 5 — overlays & toasts
+
+ + + +
+
+ + { setConfirm(false); notifyOk('Unpublished from 3 outlets'); }} + onCancel={() => setConfirm(false)} + /> + + setDrawer(false)} title="Order #1043"> +

Escape should close this, and focus should return to the button that opened it.

+
+
+ ); +} diff --git a/src/components/spike/PosStaffPanelAstryx.tsx b/src/components/spike/PosStaffPanelAstryx.tsx new file mode 100644 index 0000000..741de86 --- /dev/null +++ b/src/components/spike/PosStaffPanelAstryx.tsx @@ -0,0 +1,207 @@ +/** + * PHASE 1 SPIKE — THROWAWAY. Delete this whole folder to revert. + * + * A rebuild of the create-staff form and roster table from PosStaffPanel.tsx + * on Astryx, to answer three questions before committing to the migration: + * + * 1. Does the cascade hold? Specifically, do our unlayered `.max-w-*` + * overrides in index.css bleed into Astryx components — they beat every + * cascade layer, including `astryx-base`. + * 2. What does it cost in bundle size? + * 3. Do Field / TextInput / Selector actually cover our validation patterns? + * + * The validation rules below are copied verbatim in behaviour from + * PosStaffPanel.submit() — same order, same messages, same PIN and phone + * normalisation — so the comparison is like-for-like. This component is wired + * to no mutation: it is a rendering and typing probe, not a working screen. + */ + +import React, { useState } from 'react'; +import { Button } from '@astryxdesign/core/Button'; +import { TextInput } from '@astryxdesign/core/TextInput'; +import { Selector } from '@astryxdesign/core/Selector'; +import { Card } from '@astryxdesign/core/Card'; +import { Badge } from '@astryxdesign/core/Badge'; +import { Banner } from '@astryxdesign/core/Banner'; +import { Text } from '@astryxdesign/core/Text'; +import { Heading } from '@astryxdesign/core/Heading'; +import { + Table, + TableHeader, + TableHeaderCell, + TableRow, + TableCell, + TableBody, +} from '@astryxdesign/core/Table'; + +type Row = { + pos_user_id: number; + full_name: string; + role: string; + pin: string; + contactno: string; + status: string; +}; + +/** Stand-in roster. The real panel reads this from usePosStaff(). */ +const SAMPLE: Row[] = [ + { pos_user_id: 1, full_name: 'Anitha R', role: 'Supervisor', pin: '4417', contactno: '9840012345', status: 'active' }, + { pos_user_id: 2, full_name: 'Karthik S', role: 'Cashier', pin: '2280', contactno: '9840067890', status: 'active' }, + { pos_user_id: 3, full_name: 'Meena V', role: 'Cashier', pin: '9032', contactno: '9840055512', status: 'inactive' }, +]; + +const ROLE_OPTIONS = [ + { value: 'Supervisor', label: 'Supervisor' }, + { value: 'Cashier', label: 'Cashier' }, +]; + +/** Per-field errors — the shape Astryx's `status` prop wants. */ +type Errs = Partial>; + +export default function PosStaffPanelAstryx() { + const [fullName, setFullName] = useState(''); + const [role, setRole] = useState(''); + const [pin, setPin] = useState(''); + const [phone, setPhone] = useState(''); + const [errs, setErrs] = useState({}); + const [banner, setBanner] = useState(''); + + const staff = SAMPLE; + + /** + * Same rules and same wording as PosStaffPanel.submit(), but each failure is + * attached to the field that caused it instead of being funnelled into one + * `formError` string above the form. That is the substantive difference the + * spike is measuring: today the second error only appears after the first is + * fixed, because there is one slot to show it in. + */ + const submit = () => { + const next: Errs = {}; + const name = fullName.trim(); + + if (!name) next.name = 'Enter the person’s name.'; + if (!role) next.role = 'Choose a role.'; + if (!/^\d{4}$/.test(pin)) { + next.pin = 'The PIN must be exactly 4 digits.'; + } else if (staff.some((s) => s.pin === pin && (s.status || '').toLowerCase() !== 'inactive')) { + next.pin = 'Another active person at this outlet already uses that PIN.'; + } + + const digits = phone.replace(/\D/g, '').replace(/^91(?=\d{10}$)/, '').replace(/^0(?=\d{10}$)/, ''); + if (digits.length !== 10) { + next.phone = 'Enter a 10-digit mobile number.'; + } else if (staff.some((s) => (s.contactno || '') === digits && (s.status || '').toLowerCase() !== 'inactive')) { + next.phone = 'Another active person here already signs in with that number.'; + } + + setErrs(next); + setBanner(Object.keys(next).length ? '' : `Would create ${name} as ${role}. (Spike — no mutation is wired.)`); + }; + + return ( +
+
+ POS staff — Astryx spike + + Throwaway rebuild of the create-staff form and roster. Compare against the live panel + at Settings › POS staff. + +
+ + {/* Banner takes `title`, not children — one of several components here + that are label-prop driven rather than composition driven. */} + {banner && } + + {/* Q1 probe: max-w-sm is one of the five utilities our unlayered repair + restates. If the repair bleeds past the layer boundary it will squeeze + this card, exactly as it squeezed the modals before the repair. */} + +
+ Add a person + + setFullName(v)} + placeholder="e.g. Anitha R" + status={errs.name ? { type: 'error', message: errs.name } : undefined} + /> + + setRole(String(v ?? ''))} + placeholder="Choose a role" + status={errs.role ? { type: 'error', message: errs.role } : undefined} + /> + + setPin(v)} + placeholder="4 digits" + description="Used to sign in at the till." + status={errs.pin ? { type: 'error', message: errs.pin } : undefined} + /> + + setPhone(v)} + placeholder="10 digits" + status={errs.phone ? { type: 'error', message: errs.phone } : undefined} + /> + +
+
+
+
+ +
+ Roster + + + + Name + Role + PIN + Mobile + Status + + + + {staff.map((s) => ( + + {s.full_name} + {s.role} + {s.pin} + {s.contactno} + + + + + ))} + +
+
+
+ ); +} diff --git a/src/components/spike/ThemeShowcase.tsx b/src/components/spike/ThemeShowcase.tsx new file mode 100644 index 0000000..484ba0c --- /dev/null +++ b/src/components/spike/ThemeShowcase.tsx @@ -0,0 +1,1273 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +import {type CSSProperties, type ReactNode} from 'react'; +import { + Plus, + Search, + Tag, + Folder, + MapPin, + List, + LayoutGrid, + ShoppingBag, + Banknote, + Mic, + CreditCard, + Lock, + X, + Download, + Smartphone, + Wallet, + User, +} from 'lucide-react'; +import {Text, Heading} from '@astryxdesign/core/Text'; +import {VStack, HStack} from '@astryxdesign/core/Layout'; +import {useAppShellMobile} from '@astryxdesign/core/AppShell'; +import {Grid, GridSpan} from '@astryxdesign/core/Grid'; +import {Card} from '@astryxdesign/core/Card'; +import {Button} from '@astryxdesign/core/Button'; +import {Link} from '@astryxdesign/core/Link'; +import {Badge} from '@astryxdesign/core/Badge'; +import {Banner} from '@astryxdesign/core/Banner'; +import {Divider} from '@astryxdesign/core/Divider'; +import {CheckboxInput} from '@astryxdesign/core/CheckboxInput'; +import {Item} from '@astryxdesign/core/Item'; +import {Table, proportional, pixel} from '@astryxdesign/core/Table'; +import {TextInput} from '@astryxdesign/core/TextInput'; +import {Selector} from '@astryxdesign/core/Selector'; +import {RadioList, RadioListItem} from '@astryxdesign/core/RadioList'; +import {SelectableCard} from '@astryxdesign/core/SelectableCard'; +import {MoreMenu} from '@astryxdesign/core/MoreMenu'; +import {Center} from '@astryxdesign/core/Center'; +import {Section} from '@astryxdesign/core/Section'; +import {AspectRatio} from '@astryxdesign/core/AspectRatio'; +import {NumberInput} from '@astryxdesign/core/NumberInput'; +import {OverflowList} from '@astryxdesign/core/OverflowList'; +import {TopNav, TopNavHeading, TopNavItem} from '@astryxdesign/core/TopNav'; +import { + ChatComposer, + ChatMessage, + ChatMessageBubble, + ChatMessageList, + ChatSystemMessage, +} from '@astryxdesign/core/Chat'; + +// Styles passed to Astryx components via their `style` prop. Astryx components +// forward the DOM `style` prop, so these work with no CSS compiler — in +// compiled builds and in the live playground preview alike. +const styles: Record = { + card: { + backgroundColor: 'var(--color-background-body)', + color: 'var(--color-text-primary)', + minWidth: 0, + borderColor: 'transparent', + }, + checkoutStack: { + minWidth: 0, + width: '100%', + }, + paymentCardContent: { + minWidth: 0, + width: '100%', + textAlign: 'center', + wordBreak: 'break-word', + }, + inventoryCard: { + backgroundColor: 'var(--color-background-surface)', + color: 'var(--color-text-primary)', + overflow: 'hidden', + }, + inventoryHeader: { + paddingBlock: 'var(--spacing-6)', + paddingInline: 'var(--spacing-6)', + }, + inventoryFilterRow: { + paddingBlock: 'var(--spacing-4)', + paddingInline: 'var(--spacing-6)', + width: '100%', + overflowX: 'auto' as const, + }, + // Inset the table by --spacing-6 (the card is padding={0}) so its edge lines + // up with the header/filter row in every theme's spacing scale. + inventoryTableWrap: { + paddingInline: 'var(--spacing-6)', + paddingBlockEnd: 'var(--spacing-2)', + }, + searchInput: { + flex: 1, + minWidth: 0, + maxWidth: 240, + }, + activityCard: { + backgroundColor: 'var(--color-background-surface)', + color: 'var(--color-text-primary)', + minWidth: 0, + height: '100%', + }, + chatCard: { + backgroundColor: 'var(--color-background-surface)', + color: 'var(--color-text-primary)', + minWidth: 0, + overflow: 'hidden', + display: 'flex', + flexDirection: 'column' as const, + }, + chatHeader: { + paddingBlock: 'var(--spacing-4)', + paddingInline: 'var(--spacing-4)', + }, + activityCardStack: { + height: '100%', + }, + activityListFade: { + flex: 1, + minHeight: 0, + overflow: 'hidden', + maskImage: + 'linear-gradient(to bottom, black calc(100% - 48px), transparent)', + WebkitMaskImage: + 'linear-gradient(to bottom, black calc(100% - 48px), transparent)', + marginInline: 'calc(var(--spacing-2) * -1)', + }, + content: { + maxWidth: 960, + marginInline: 'auto', + minWidth: 0, + }, + contentFluid: { + maxWidth: 880, + }, + heroText: { + textAlign: 'center' as const, + maxWidth: 560, + }, + centerText: { + textAlign: 'center', + }, + cardStack: { + height: '100%', + }, + cardDescription: { + flex: 1, + }, + quantityInput: { + // minWidth (not a hard width) so the field grows to fit the digit + the + // theme's input padding. A fixed 40px was too tight on themes with larger + // padding / bigger type scale (e.g. Matcha, Y2K), clipping the value. + minWidth: 64, + flexShrink: 0, + }, + cartButton: { + flex: 1, + }, +}; + +// Styles applied directly to plain DOM elements via the `style` prop. +// Plain inline styles so they render with no CSS compiler. All are static +// (no media/pseudo variants), so inline styles reproduce them exactly. +const inlineStyles: Record = { + inventoryBannerWrap: { + paddingInline: 'var(--spacing-6)', + paddingBottom: 'var(--spacing-4)', + }, + thumbnail: { + width: 40, + height: 40, + borderRadius: 'var(--radius-element)', + objectFit: 'cover', + display: 'block', + flexShrink: 0, + }, + thumbnailFallback: { + width: 40, + height: 40, + borderRadius: 'var(--radius-element)', + backgroundColor: 'var(--color-background-muted)', + color: 'var(--color-text-secondary)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: 'var(--font-size-sm)', + fontWeight: 600, + flexShrink: 0, + }, + kpiValue: { + fontSize: 24, + fontWeight: 700, + lineHeight: 1.1, + color: 'var(--color-text-primary)', + fontFamily: 'var(--font-family-heading)', + letterSpacing: '-0.01em', + }, + chatBody: { + flex: 1, + minHeight: 0, + overflow: 'hidden', + }, + chatSuggestions: { + paddingInline: 'var(--spacing-4)', + paddingBottom: 'var(--spacing-2)', + }, + chatComposer: { + paddingInline: 'var(--spacing-4)', + paddingBottom: 'var(--spacing-4)', + }, + activityIcon: { + width: 32, + height: 32, + borderRadius: 'var(--radius-full)', + backgroundColor: 'var(--color-background-muted)', + color: 'var(--color-text-secondary)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + }, + productImage: { + width: '100%', + height: '100%', + objectFit: 'cover', + }, + cardBody: { + padding: 'var(--spacing-4)', + flex: 1, + display: 'flex', + flexDirection: 'column', + }, +}; + +const PRODUCT_IMAGE_KEYS = ['watch', 'headphones', 'backpack']; + +/** Categorical badge variants usable for showcase product/inventory tags. */ +export type ShowcaseBadgeVariant = + | 'blue' + | 'cyan' + | 'green' + | 'orange' + | 'pink' + | 'purple' + | 'red' + | 'teal' + | 'yellow'; + +export interface ProductSpec { + name: string; + description: string; + badge: string; + badgeVariant: ShowcaseBadgeVariant; +} + +const DEFAULT_PRODUCTS: ProductSpec[] = [ + { + name: 'Minimalist Watch', + description: 'Clean design meets everyday durability.', + badge: 'New', + badgeVariant: 'blue', + }, + { + name: 'Wireless Headphones', + description: 'Immersive sound, all-day comfort.', + badge: 'Popular', + badgeVariant: 'green', + }, + { + name: 'Canvas Backpack', + description: 'Water-resistant canvas with a quiet, modern profile.', + badge: 'Limited', + badgeVariant: 'yellow', + }, +]; + +// Neutral product photos, served from the shared astryx asset CDN so the +// scaffolded template renders real imagery without needing local public assets. +const DEFAULT_IMAGES: Record = { + watch: 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%20preserveAspectRatio%3D%22xMidYMid%20slice%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22%23f5f6f8%22%2F%3E%3Cg%20transform%3D%22translate%28200%20150%29%22%20fill%3D%22none%22%20stroke%3D%22%23c2cad6%22%20stroke-width%3D%225%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Crect%20x%3D%22-44%22%20y%3D%22-44%22%20width%3D%2288%22%20height%3D%2288%22%20rx%3D%2216%22%2F%3E%3Ccircle%20cx%3D%2218%22%20cy%3D%22-18%22%20r%3D%222.5%22%20fill%3D%22%23c2cad6%22%20stroke%3D%22none%22%2F%3E%3Cpath%20d%3D%22M-34%2030%20L-8%200%20L10%2018%20L20%208%20L34%2024%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E', + headphones: + 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%20preserveAspectRatio%3D%22xMidYMid%20slice%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22%23f5f6f8%22%2F%3E%3Cg%20transform%3D%22translate%28200%20150%29%22%20fill%3D%22none%22%20stroke%3D%22%23c2cad6%22%20stroke-width%3D%225%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Crect%20x%3D%22-44%22%20y%3D%22-44%22%20width%3D%2288%22%20height%3D%2288%22%20rx%3D%2216%22%2F%3E%3Ccircle%20cx%3D%2218%22%20cy%3D%22-18%22%20r%3D%222.5%22%20fill%3D%22%23c2cad6%22%20stroke%3D%22none%22%2F%3E%3Cpath%20d%3D%22M-34%2030%20L-8%200%20L10%2018%20L20%208%20L34%2024%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E', + backpack: 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%20preserveAspectRatio%3D%22xMidYMid%20slice%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22%23f5f6f8%22%2F%3E%3Cg%20transform%3D%22translate%28200%20150%29%22%20fill%3D%22none%22%20stroke%3D%22%23c2cad6%22%20stroke-width%3D%225%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Crect%20x%3D%22-44%22%20y%3D%22-44%22%20width%3D%2288%22%20height%3D%2288%22%20rx%3D%2216%22%2F%3E%3Ccircle%20cx%3D%2218%22%20cy%3D%22-18%22%20r%3D%222.5%22%20fill%3D%22%23c2cad6%22%20stroke%3D%22none%22%2F%3E%3Cpath%20d%3D%22M-34%2030%20L-8%200%20L10%2018%20L20%208%20L34%2024%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E', + wallet: 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%20preserveAspectRatio%3D%22xMidYMid%20slice%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22%23f5f6f8%22%2F%3E%3Cg%20transform%3D%22translate%28200%20150%29%22%20fill%3D%22none%22%20stroke%3D%22%23c2cad6%22%20stroke-width%3D%225%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Crect%20x%3D%22-44%22%20y%3D%22-44%22%20width%3D%2288%22%20height%3D%2288%22%20rx%3D%2216%22%2F%3E%3Ccircle%20cx%3D%2218%22%20cy%3D%22-18%22%20r%3D%222.5%22%20fill%3D%22%23c2cad6%22%20stroke%3D%22none%22%2F%3E%3Cpath%20d%3D%22M-34%2030%20L-8%200%20L10%2018%20L20%208%20L34%2024%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E', + tumbler: 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%20preserveAspectRatio%3D%22xMidYMid%20slice%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22%23f5f6f8%22%2F%3E%3Cg%20transform%3D%22translate%28200%20150%29%22%20fill%3D%22none%22%20stroke%3D%22%23c2cad6%22%20stroke-width%3D%225%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Crect%20x%3D%22-44%22%20y%3D%22-44%22%20width%3D%2288%22%20height%3D%2288%22%20rx%3D%2216%22%2F%3E%3Ccircle%20cx%3D%2218%22%20cy%3D%22-18%22%20r%3D%222.5%22%20fill%3D%22%23c2cad6%22%20stroke%3D%22none%22%2F%3E%3Cpath%20d%3D%22M-34%2030%20L-8%200%20L10%2018%20L20%208%20L34%2024%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E', + throw_: 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%20preserveAspectRatio%3D%22xMidYMid%20slice%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22%23f5f6f8%22%2F%3E%3Cg%20transform%3D%22translate%28200%20150%29%22%20fill%3D%22none%22%20stroke%3D%22%23c2cad6%22%20stroke-width%3D%225%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Crect%20x%3D%22-44%22%20y%3D%22-44%22%20width%3D%2288%22%20height%3D%2288%22%20rx%3D%2216%22%2F%3E%3Ccircle%20cx%3D%2218%22%20cy%3D%22-18%22%20r%3D%222.5%22%20fill%3D%22%23c2cad6%22%20stroke%3D%22none%22%2F%3E%3Cpath%20d%3D%22M-34%2030%20L-8%200%20L10%2018%20L20%208%20L34%2024%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E', +}; + +export interface ThemeShowcaseProps { + /** Product card images keyed by slot (watch/headphones/backpack/…). */ + images?: Record; + /** The three hero product cards. Defaults to the neutral store products. */ + products?: ProductSpec[]; + /** Inventory table rows. Defaults to the neutral store inventory. */ + inventory?: InventoryRow[]; +} + +// Default export is the route page (sandbox renders this as a Next.js page, so +// it must take no props / satisfy PageProps). It renders the store with the +// neutral defaults. Consumers that need per-theme content import the named +// `ThemeShowcaseStore` below and pass images/products/inventory. +export default function ThemeShowcase() { + return ; +} + +export function ThemeShowcaseStore({ + images = DEFAULT_IMAGES, + products = DEFAULT_PRODUCTS, + inventory = DEFAULT_INVENTORY, +}: ThemeShowcaseProps = {}) { + const {isMobile} = useAppShellMobile(); + return ( +
+ +
+ +
+
+ ); +} + +function CardShowcase({ + images, + inventory, + isMobile, +}: { + images: Record; + inventory: InventoryRow[]; + isMobile: boolean; +}) { + const columns = isMobile ? 1 : ({minWidth: 200, repeat: 'fit'} as const); + + return ( + + + + + + + + + + + + + + + + + + + ); +} + +function StorePreview({ + images, + products, + isMobile, +}: { + images: Record; + products: ProductSpec[]; + isMobile: boolean; +}) { + return ( +
+ + } + centerContent={ + isMobile ? undefined : ( + <> + + + + + + ) + } + endContent={ + + +
+ + + ))} + + + + +
+ ); +} + +function CheckoutCard({isMobile}: {isMobile: boolean}) { + return ( + + + Checkout + + + {}} + size="lg" + /> + + {}}> + + $12.00 + + } + /> + + $16.00 + + } + /> + + $24.00 + + } + /> + + + + + Payment method + + + {}} + padding={3}> + + + + Card + + + + {}} + padding={3}> + + + + Apple Pay + + + + {}} + padding={3}> + + + + Google Pay + + + + + + + {}} + startIcon={} + size="lg" + /> + + + {}} + size="lg" + /> + {}} + size="lg" + /> + + + {}} + size="lg" + options={[ + {value: 'us', label: 'United States'}, + {value: 'ca', label: 'Canada'}, + {value: 'uk', label: 'United Kingdom'}, + {value: 'de', label: 'Germany'}, + {value: 'jp', label: 'Japan'}, + {value: 'au', label: 'Australia'}, + ]} + /> + + + {}} + /> + +
+ +
+ {}} + onSubmit={() => {}} + placeholder="Ask Studio AI…" + footerActions={ +
+ + ); +} + +interface ActivityRow { + id: string; + icon: ReactNode; + label: string; + detail: string; + time: string; + amount: number; +} + +const ACTIVITY: ActivityRow[] = [ + { + id: '1', + icon: , + label: 'Order #1043', + detail: 'Placed · 1:59 pm', + time: '1:59 pm', + amount: 248, + }, + { + id: '2', + icon: , + label: 'Order #1041', + detail: 'Refunded · 12:40 pm', + time: '12:40 pm', + amount: -89, + }, + { + id: '3', + icon: , + label: 'Order #1040', + detail: 'Placed · 10:30 am', + time: '10:30 am', + amount: 156, + }, + { + id: '4', + icon: , + label: 'Order #1038', + detail: 'Placed · 9:11 am', + time: '9:11 am', + amount: 412, + }, + { + id: '5', + icon: , + label: 'Order #1037', + detail: 'Placed · 8:42 am', + time: '8:42 am', + amount: 95, + }, +]; + +function formatAmount(amount: number): string { + const sign = amount < 0 ? '−' : '+'; + return sign + '$' + Math.abs(amount).toLocaleString(); +} + +function LatestActivityCard({isMobile}: {isMobile: boolean}) { + return ( + + + Revenue + + + + 18K + + Monthly revenue + + + + +12% + + Order growth + + + + + + + + Activity + See all + + + + {ACTIVITY.map(item => ( +
+ } + label={item.label} + description={item.detail} + endContent={ + + {formatAmount(item.amount)} + + } + href="#" + /> + ))} + + + + ); +} + +type TagSpec = {label: string; variant: ShowcaseBadgeVariant}; + +export interface InventoryRow extends Record { + id: string; + name: string; + meta: string; + available: number; + location: string; + tags: TagSpec[]; + imageKey?: string; + thumbnailFallback: string; + selected: boolean; +} + +const DEFAULT_INVENTORY: InventoryRow[] = [ + { + id: 'a', + name: 'Minimalist Watch', + meta: 'Stainless steel, sapphire crystal', + available: 42, + location: 'Aisle 3', + tags: [{label: 'New', variant: 'blue'}], + imageKey: 'watch', + thumbnailFallback: 'M', + selected: false, + }, + { + id: 'b', + name: 'Wireless Headphones', + meta: 'ANC, 30hr battery', + available: 128, + location: 'Aisle 1', + tags: [{label: 'Popular', variant: 'green'}], + imageKey: 'headphones', + thumbnailFallback: 'W', + selected: true, + }, + { + id: 'c', + name: 'Canvas Backpack', + meta: 'Water-resistant, 25L', + available: 63, + location: 'Aisle 2', + tags: [{label: 'Limited', variant: 'yellow'}], + imageKey: 'backpack', + thumbnailFallback: 'C', + selected: false, + }, + { + id: 'd', + name: 'Leather Wallet', + meta: 'Full-grain, RFID blocking', + available: 15, + location: 'Aisle 4', + tags: [{label: 'Leather', variant: 'yellow'}], + imageKey: 'wallet', + thumbnailFallback: 'L', + selected: true, + }, + { + id: 'e', + name: 'Travel Tumbler', + meta: 'Vacuum insulated, 16oz', + available: 87, + location: 'Aisle 5', + tags: [{label: 'Drinkware', variant: 'green'}], + imageKey: 'tumbler', + thumbnailFallback: 'T', + selected: false, + }, + { + id: 'f', + name: 'Linen Throw', + meta: 'Heavyweight, oat', + available: 24, + location: 'Aisle 6', + tags: [{label: 'Home', variant: 'orange'}], + imageKey: 'throw_', + thumbnailFallback: 'L', + selected: true, + }, +]; + +const LOW_STOCK_THRESHOLD = 25; + +function SelectCell({row}: {row: InventoryRow}) { + return ( + {}} + /> + ); +} + +function ItemCell({ + row, + images, +}: { + row: InventoryRow; + images: Record; +}) { + const thumbnailSrc = row.imageKey ? images[row.imageKey] : undefined; + return ( + + {thumbnailSrc ? ( + + ) : ( + + )} + + + {row.name} + + + {row.meta} + + + + ); +} + +function TagsCell({row}: {row: InventoryRow}) { + return ( + + {row.tags.map(tag => ( + + ))} + + ); +} + +function ActionsCell() { + return ( + + ); +} + +function InventoryCard({ + images, + inventory, +}: { + images: Record; + inventory: InventoryRow[]; +}) { + const lowStockCount = inventory.filter( + row => row.available < LOW_STOCK_THRESHOLD, + ).length; + return ( + + + Inventory +