diff --git a/soleradar/.env.example b/soleradar/.env.example new file mode 100644 index 000000000..c820a3510 --- /dev/null +++ b/soleradar/.env.example @@ -0,0 +1 @@ +TINYFISH_API_KEY=your-tinyfish-api-key-here diff --git a/soleradar/.gitignore b/soleradar/.gitignore new file mode 100644 index 000000000..e8781244c --- /dev/null +++ b/soleradar/.gitignore @@ -0,0 +1,5 @@ +.env.local +.env +node_modules/ +.next/ +out/ diff --git a/soleradar/next-env.d.ts b/soleradar/next-env.d.ts new file mode 100644 index 000000000..1b3be0840 --- /dev/null +++ b/soleradar/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/soleradar/next.config.js b/soleradar/next.config.js new file mode 100644 index 000000000..d10837f26 --- /dev/null +++ b/soleradar/next.config.js @@ -0,0 +1,3 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {} +module.exports = nextConfig diff --git a/soleradar/package-lock.json b/soleradar/package-lock.json new file mode 100644 index 000000000..7cadcbd57 --- /dev/null +++ b/soleradar/package-lock.json @@ -0,0 +1,976 @@ +{ + "name": "soleradar", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "soleradar", + "version": "0.1.0", + "dependencies": { + "next": "15.1.0", + "react": "^18", + "react-dom": "^18" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "typescript": "^5" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@next/env": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.1.0.tgz", + "integrity": "sha512-UcCO481cROsqJuszPPXJnb7GGuLq617ve4xuAyyNG4VSSocJNtMU5Fsx+Lp6mlN8c7W58aZLc5y6D/2xNmaK+w==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.1.0.tgz", + "integrity": "sha512-ZU8d7xxpX14uIaFC3nsr4L++5ZS/AkWDm1PzPO6gD9xWhFkOj2hzSbSIxoncsnlJXB1CbLOfGVN4Zk9tg83PUw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.0.tgz", + "integrity": "sha512-DQ3RiUoW2XC9FcSM4ffpfndq1EsLV0fj0/UY33i7eklW5akPUCo6OX2qkcLXZ3jyPdo4sf2flwAED3AAq3Om2Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.0.tgz", + "integrity": "sha512-M+vhTovRS2F//LMx9KtxbkWk627l5Q7AqXWWWrfIzNIaUFiz2/NkOFkxCFyNyGACi5YbA8aekzCLtbDyfF/v5Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.0.tgz", + "integrity": "sha512-Qn6vOuwaTCx3pNwygpSGtdIu0TfS1KiaYLYXLH5zq1scoTXdwYfdZtwvJTpB1WrLgiQE2Ne2kt8MZok3HlFqmg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.0.tgz", + "integrity": "sha512-yeNh9ofMqzOZ5yTOk+2rwncBzucc6a1lyqtg8xZv0rH5znyjxHOWsoUtSq4cUTeeBIiXXX51QOOe+VoCjdXJRw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.0.tgz", + "integrity": "sha512-t9IfNkHQs/uKgPoyEtU912MG6a1j7Had37cSUyLTKx9MnUpjj+ZDKw9OyqTI9OwIIv0wmkr1pkZy+3T5pxhJPg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.0.tgz", + "integrity": "sha512-WEAoHyG14t5sTavZa1c6BnOIEukll9iqFRTavqRVPfYmfegOAd5MaZfXgOGG6kGo1RduyGdTHD4+YZQSdsNZXg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.0.tgz", + "integrity": "sha512-J1YdKuJv9xcixzXR24Dv+4SaDKc2jj31IVUEMdO5xJivMTXuE6MAdIi4qPjSymHuFG8O5wbfWKnhJUcHHpj5CA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.37", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", + "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001777", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", + "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT", + "optional": true + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT", + "optional": true + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "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/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/next/-/next-15.1.0.tgz", + "integrity": "sha512-QKhzt6Y8rgLNlj30izdMbxAwjHMFANnLwDwZ+WQh5sMhyt4lEBqDK9QpvWHtIM4rINKPoJ8aiRZKg5ULSybVHw==", + "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.", + "license": "MIT", + "dependencies": { + "@next/env": "15.1.0", + "@swc/counter": "0.1.3", + "@swc/helpers": "0.5.15", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.1.0", + "@next/swc-darwin-x64": "15.1.0", + "@next/swc-linux-arm64-gnu": "15.1.0", + "@next/swc-linux-arm64-musl": "15.1.0", + "@next/swc-linux-x64-gnu": "15.1.0", + "@next/swc-linux-x64-musl": "15.1.0", + "@next/swc-win32-arm64-msvc": "15.1.0", + "@next/swc-win32-x64-msvc": "15.1.0", + "sharp": "^0.33.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "optional": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/soleradar/package.json b/soleradar/package.json new file mode 100644 index 000000000..23aee305a --- /dev/null +++ b/soleradar/package.json @@ -0,0 +1,25 @@ +{ + "name": "soleradar", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "next": "15.3.1", + "react": "^18", + "react-dom": "^18", + "@tiny-fish/sdk": "latest" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "typescript": "^5" + } +} + + diff --git a/soleradar/readme.md b/soleradar/readme.md new file mode 100644 index 000000000..0cf3efda6 --- /dev/null +++ b/soleradar/readme.md @@ -0,0 +1,167 @@ +# SoleRadar + +**Real-time sneaker prices and stock across every retailer in your region, instantly.** + +SoleRadar lets you look up any sneaker and get live pricing, stock status, colorway availability, and purchase links — scraped in parallel from 7–9 region-specific retailers using TinyFish AI agents, all in real time. + +**Live:** https://soleradar.vercel.app/ + +--- + +## What it does + +Type in a sneaker name (e.g. `Jordan 1 Low`) and a region (e.g. `Singapore`) and SoleRadar will: + +1. **Discover** the right retailers for your region — Novelship, StockX, GOAT, Nike, Adidas, and local market-specific stores +2. **Scrape** all sites concurrently using parallel TinyFish agents, streaming live agent status back in real time +3. **Score** every result by availability, price completeness, and link quality into a 1–10 quality score +4. **Display** a clean dashboard with prices, sizes, stock status, colorways, purchase links, and a side-by-side compare tool + +--- + +## TinyFish API Usage + +The app uses `@tiny-fish/sdk` to run one Agent per retailer in parallel with `browser_profile: 'stealth'` since sneaker sites have strong bot detection. Results stream back via SSE as each agent completes: + +```typescript +import { TinyFish, EventType, RunStatus } from '@tiny-fish/sdk' + +const client = new TinyFish({ apiKey: process.env.TINYFISH_API_KEY }) + +const stream = await client.agent.stream( + { url, goal, browser_profile: 'stealth' }, + { + onStreamingUrl: (event) => { + // forward live browser preview URL to frontend + }, + onComplete: (event) => { + if (event.status === RunStatus.COMPLETED) { + // event.result contains extracted sneaker listings JSON + } + }, + } +) + +for await (const event of stream) { + if (event.type === EventType.COMPLETE) break +} +``` + +--- + +## Architecture + +``` +User Input (sneaker + size + colorway + region + currency) + │ + ▼ +┌─────────────────────────────────────┐ +│ /api/find-sites │ +│ Looks up curated retailer list │ +│ for the selected region: │ +│ - Brand-aware sorting │ +│ (Nike stores first for Jordans, │ +│ Adidas first for Yeezys) │ +│ - Returns top 9 retailer URLs │ +└──────────────┬──────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ /api/search │ +│ @tiny-fish/sdk — one agent per │ +│ retailer, all running in parallel │ +│ │ +│ ┌─────────────┐ ┌───────────────┐ │ +│ │ StockX │ │ Novelship │ │ +│ └─────────────┘ └───────────────┘ │ +│ ┌─────────────┐ ┌───────────────┐ │ +│ │ Nike SG │ │ Foot Locker │ │ +│ └─────────────┘ └───────────────┘ │ +└──────────────┬──────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ Client-side scoring │ +│ Quality score computed per result │ +│ → structured SneakerDrop[] cards │ +└──────────────┬──────────────────────┘ + │ + ▼ + Next.js Frontend + (price, stock, size, colorway, + quality score, compare dashboard) +``` + +--- + +## How to run locally + +**1. Install dependencies** +```bash +cd soleradar +npm install +``` + +**2. Set up environment variables** + +Create a `.env.local` file: +``` +TINYFISH_API_KEY=your_tinyfish_key_here +``` + +Get a key at [agent.tinyfish.ai/api-keys](https://agent.tinyfish.ai/api-keys) + +**3. Run the dev server** +```bash +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000) + +--- + +## How to use + +1. Enter a **sneaker name** — e.g. `Jordan 1 Low`, `Yeezy 350 V2`, `New Balance 550` +2. Enter a **size** (optional) — e.g. `US 10`, `UK 9` +3. Enter a **colorway** (optional) — e.g. `University Blue`, `Bred` +4. Select a **region** and **currency** +5. Click **Lock on Target** +6. Watch the agents run live across all retailers, then browse your results + +--- + +## Tech Stack + +| Layer | Technology | +|---|---| +| Framework | Next.js 15 (App Router) | +| Language | TypeScript | +| Web scraping | TinyFish Agent API (`@tiny-fish/sdk`) | +| Streaming | Server-Sent Events (SSE) | +| Styling | Inline CSS with CSS variables | +| Deployment | Vercel | + +--- + +## Supported Regions + +| Region | Key Retailers | +|---|---| +| 🇸🇬 Singapore | Novelship, KicksCrew, SOLE WHAT, StockX, Nike SG, Adidas SG | +| 🇺🇸 United States | StockX, GOAT, Flight Club, Nike US, Foot Locker, Stadium Goods | +| 🇬🇧 United Kingdom | END. Clothing, JD Sports, KLEKT, Nike UK, Adidas UK, Size? | +| 🇯🇵 Japan | Atmos, Snkrdunk, Zozotown, Rakuten, Mita Sneakers, Nike JP | +| 🇦🇺 Australia | Culture Kings, Stylerunner, GOAT, StockX, Foot Locker AU | +| 🇩🇪 Germany | Solebox, Asphaltgold, Snipes, KLEKT, Nike DE, Adidas DE | +| 🇨🇦 Canada | GOAT, StockX, Haven, Bodega, Livestock, Nike CA | +| 🇫🇷 France | Courir, Footdistrict, KLEKT, Zalando FR, Nike FR | +| 🇮🇳 India | Superkicks, VegNonVeg, Mainstreet Marketplace, Nike IN | + +--- + +## Environment Variables + +| Variable | Description | +|---|---| +| `TINYFISH_API_KEY` | TinyFish API key — get one at [agent.tinyfish.ai/api-keys](https://agent.tinyfish.ai/api-keys) | diff --git a/soleradar/src/app/api/find-sites/route.ts b/soleradar/src/app/api/find-sites/route.ts new file mode 100644 index 000000000..3912f1058 --- /dev/null +++ b/soleradar/src/app/api/find-sites/route.ts @@ -0,0 +1,136 @@ +import { NextRequest } from 'next/server' + +export const runtime = 'nodejs' + +const REGION_SITES: Record = { + singapore: [ + { name: 'Novelship', url: 'https://novelship.com' }, + { name: 'KicksCrew', url: 'https://www.kickscrew.com' }, + { name: 'SOLE WHAT', url: 'https://solewhat.com' }, + { name: 'Sneak Avenues', url: 'https://sneakavenues.com' }, + { name: 'StockX', url: 'https://stockx.com' }, + { name: 'KLEKT', url: 'https://www.klekt.com' }, + { name: 'Foot Locker SG', url: 'https://www.footlocker.com.sg' }, + { name: 'Nike SG', url: 'https://www.nike.com/sg' }, + { name: 'Adidas SG', url: 'https://www.adidas.com.sg' }, + ], + 'united states': [ + { name: 'StockX', url: 'https://stockx.com' }, + { name: 'GOAT', url: 'https://www.goat.com' }, + { name: 'Foot Locker', url: 'https://www.footlocker.com' }, + { name: 'Nike US', url: 'https://www.nike.com' }, + { name: 'Adidas US', url: 'https://www.adidas.com' }, + { name: 'Flight Club', url: 'https://www.flightclub.com' }, + { name: 'Stadium Goods', url: 'https://www.stadiumgoods.com' }, + { name: 'Champs Sports', url: 'https://www.champssports.com' }, + { name: 'Shoe Palace', url: 'https://www.shoepalace.com' }, + ], + 'united kingdom': [ + { name: 'END. Clothing', url: 'https://www.endclothing.com' }, + { name: 'KLEKT', url: 'https://www.klekt.com' }, + { name: 'Foot Locker UK', url: 'https://www.footlocker.co.uk' }, + { name: 'Nike UK', url: 'https://www.nike.com/gb' }, + { name: 'Adidas UK', url: 'https://www.adidas.co.uk' }, + { name: 'JD Sports', url: 'https://www.jdsports.co.uk' }, + { name: 'Offspring', url: 'https://www.offspring.co.uk' }, + { name: 'Size?', url: 'https://www.size.co.uk' }, + { name: 'StockX', url: 'https://stockx.com' }, + ], + japan: [ + { name: 'Atmos Japan', url: 'https://www.atmos-tokyo.com' }, + { name: 'Snkrdunk', url: 'https://snkrdunk.com' }, + { name: 'Nike JP', url: 'https://www.nike.com/jp' }, + { name: 'Adidas JP', url: 'https://www.adidas.co.jp' }, + { name: 'Zozotown', url: 'https://zozo.jp' }, + { name: 'Rakuten', url: 'https://search.rakuten.co.jp' }, + { name: 'United Arrows', url: 'https://store.united-arrows.co.jp' }, + { name: 'Mita Sneakers', url: 'https://mita-sneakers.co.jp' }, + { name: 'StockX', url: 'https://stockx.com' }, + ], + australia: [ + { name: 'Nike AU', url: 'https://www.nike.com/au' }, + { name: 'Adidas AU', url: 'https://www.adidas.com.au' }, + { name: 'Foot Locker AU', url: 'https://www.footlocker.com.au' }, + { name: 'Stylerunner', url: 'https://www.stylerunner.com' }, + { name: 'Culture Kings', url: 'https://www.culturekings.com.au' }, + { name: 'GOAT', url: 'https://www.goat.com' }, + { name: 'StockX', url: 'https://stockx.com' }, + { name: 'Laced', url: 'https://www.laced.com' }, + { name: 'JD Sports AU', url: 'https://www.jdsports.com.au' }, + ], + germany: [ + { name: 'Snipes', url: 'https://www.snipes.com' }, + { name: 'Solebox', url: 'https://www.solebox.com' }, + { name: 'Asphaltgold', url: 'https://asphaltgold.com' }, + { name: 'KLEKT', url: 'https://www.klekt.com' }, + { name: 'Nike DE', url: 'https://www.nike.com/de' }, + { name: 'Adidas DE', url: 'https://www.adidas.de' }, + { name: 'Foot Locker DE', url: 'https://www.footlocker.de' }, + { name: 'About You', url: 'https://www.aboutyou.de' }, + { name: 'StockX', url: 'https://stockx.com' }, + ], + canada: [ + { name: 'GOAT', url: 'https://www.goat.com' }, + { name: 'StockX', url: 'https://stockx.com' }, + { name: 'Nike CA', url: 'https://www.nike.com/ca' }, + { name: 'Adidas CA', url: 'https://www.adidas.ca' }, + { name: 'Foot Locker CA', url: 'https://www.footlocker.ca' }, + { name: 'JD Sports CA', url: 'https://www.jdsports.ca' }, + { name: 'Livestock', url: 'https://www.deadstock.ca' }, + { name: 'Haven', url: 'https://www.havenshop.ca' }, + { name: 'Bodega', url: 'https://bdgastore.com' }, + ], + france: [ + { name: 'Courir', url: 'https://www.courir.com' }, + { name: 'Footdistrict', url: 'https://footdistrict.com' }, + { name: 'KLEKT', url: 'https://www.klekt.com' }, + { name: 'Nike FR', url: 'https://www.nike.com/fr' }, + { name: 'Adidas FR', url: 'https://www.adidas.fr' }, + { name: 'Foot Locker FR', url: 'https://www.footlocker.fr' }, + { name: 'Zalando FR', url: 'https://www.zalando.fr' }, + { name: 'END. Clothing', url: 'https://www.endclothing.com' }, + { name: 'StockX', url: 'https://stockx.com' }, + ], + india: [ + { name: 'Superkicks', url: 'https://www.superkicks.in' }, + { name: 'VegNonVeg', url: 'https://www.vegnonveg.com' }, + { name: 'Mainstreet Marketplace', url: 'https://www.mainstreetmarketplace.in' }, + { name: 'Crepslocker', url: 'https://crepslocker.com' }, + { name: 'Nike IN', url: 'https://www.nike.com/in' }, + { name: 'Adidas IN', url: 'https://www.adidas.co.in' }, + { name: 'Myntra Sneakers', url: 'https://www.myntra.com/sneakers' }, + { name: 'Flipkart Sneakers', url: 'https://www.flipkart.com/footwear/sports-shoes/sneakers' }, + { name: 'StockX', url: 'https://stockx.com' }, + ], +} + +function findRegion(regionInput: string): string { + const lower = regionInput.toLowerCase() + for (const key of Object.keys(REGION_SITES)) { + if (lower.includes(key) || key.includes(lower)) return key + } + // Strip non-ASCII (emojis, flags) and try again + const cleaned = lower.replace(/[^\x00-\x7F]/g, '').trim() + for (const key of Object.keys(REGION_SITES)) { + if (cleaned.includes(key.split(' ')[0])) return key + } + return 'united states' +} + +export async function GET(req: NextRequest) { + const region = req.nextUrl.searchParams.get('region') || 'singapore' + const sneaker = req.nextUrl.searchParams.get('sneaker') || '' + + const regionKey = findRegion(region) + let sites = REGION_SITES[regionKey] || REGION_SITES['united states'] + + // Prioritise brand-specific official stores + const lowerSneaker = sneaker.toLowerCase() + if (lowerSneaker.includes('nike') || lowerSneaker.includes('jordan') || lowerSneaker.includes('air')) { + sites = [...sites].sort((a) => (a.name.toLowerCase().includes('nike') ? -1 : 0)) + } else if (lowerSneaker.includes('adidas') || lowerSneaker.includes('yeezy') || lowerSneaker.includes('boost')) { + sites = [...sites].sort((a) => (a.name.toLowerCase().includes('adidas') ? -1 : 0)) + } + + return Response.json({ sites: sites.slice(0, 9), region: regionKey }) +} diff --git a/soleradar/src/app/api/search/route.ts b/soleradar/src/app/api/search/route.ts new file mode 100644 index 000000000..394365464 --- /dev/null +++ b/soleradar/src/app/api/search/route.ts @@ -0,0 +1,81 @@ +import { NextRequest } from 'next/server' +import { TinyFish, EventType, RunStatus } from '@tiny-fish/sdk' + +export const runtime = 'nodejs' +export const maxDuration = 300 + +const sseData = (payload: unknown) => `data: ${JSON.stringify(payload)}\n\n` + +export async function POST(req: NextRequest) { + const { url, goal } = await req.json() + + const apiKey = process.env.TINYFISH_API_KEY + if (!apiKey) { + return new Response(JSON.stringify({ error: 'Missing TINYFISH_API_KEY' }), { status: 500 }) + } + + const encoder = new TextEncoder() + + const stream = new ReadableStream({ + async start(controller) { + const enqueue = (payload: unknown) => + controller.enqueue(encoder.encode(sseData(payload))) + + try { + const client = new TinyFish({ apiKey }) + let resultFound = false + + const tfStream = await client.agent.stream( + { url, goal, browser_profile: 'stealth' }, + { + onStreamingUrl: (event) => { + enqueue({ type: 'STREAMING_URL', streaming_url: event.streaming_url }) + }, + onComplete: (event) => { + resultFound = true + if (event.status === RunStatus.COMPLETED) { + enqueue({ type: 'COMPLETE', result: event.result ?? null }) + enqueue({ type: 'done', payload: { type: 'COMPLETE', result: event.result ?? null } }) + } else { + enqueue({ type: 'error', message: event.error?.message ?? 'Agent run failed' }) + } + }, + } + ) + + // Drain stream so callbacks fire + for await (const event of tfStream) { + if (event.type === EventType.COMPLETE && !resultFound) { + resultFound = true + if (event.status === RunStatus.COMPLETED) { + enqueue({ type: 'COMPLETE', result: event.result ?? null }) + enqueue({ type: 'done', payload: { type: 'COMPLETE', result: event.result ?? null } }) + } else { + enqueue({ type: 'error', message: event.error?.message ?? 'Agent run failed' }) + } + } + } + + if (!resultFound) { + enqueue({ type: 'error', message: 'Stream ended without a result' }) + } + + enqueue({ type: 'stream_end' }) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err) + enqueue({ type: 'error', message }) + } finally { + controller.close() + } + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }) +} diff --git a/soleradar/src/app/globals.css b/soleradar/src/app/globals.css new file mode 100644 index 000000000..08d4fdaf1 --- /dev/null +++ b/soleradar/src/app/globals.css @@ -0,0 +1,61 @@ +:root { + --black: #0a0a0a; + --white: #f5f0e8; + --yellow: #FFE600; + --red: #FF2E2E; + --orange: #FF6B00; + --ink: #1a1a1a; + --mid: #2a2a2a; + --dim: #444; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + background: var(--black); + color: var(--white); + font-family: 'Space Grotesk', sans-serif; + overflow-x: hidden; + cursor: crosshair; +} + +body::before { + content: ''; + position: fixed; + inset: 0; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E"); + pointer-events: none; + z-index: 9999; + opacity: 0.5; +} + +@keyframes pulse { 0%,100%{transform:scale(1)} 50%{transform:scale(1.4)} } +@keyframes floatShoe { + 0% { transform: translateY(0px) rotate(-5deg); } + 25% { transform: translateY(-30px) rotate(3deg); } + 50% { transform: translateY(-10px) rotate(-8deg); } + 75% { transform: translateY(-40px) rotate(5deg); } + 100% { transform: translateY(0px) rotate(-5deg); } +} +@keyframes barSweep { + 0% { width: 10%; margin-left: 0; } + 50% { width: 50%; margin-left: 30%; } + 100% { width: 10%; margin-left: 90%; } +} +@keyframes agentGlow { + 0%,100% { box-shadow: 0 0 0 rgba(255,230,0,0); } + 50% { box-shadow: 0 0 12px rgba(255,230,0,0.15); } +} +@keyframes fadeOut { + 0% { opacity: 0.5; } + 100% { opacity: 0; height: 0; padding: 0; margin: 0; overflow: hidden; } +} +@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } } +@keyframes marquee { 0% { transform: translateX(0); } 100% { transform: translateX(-50%); } } +@keyframes spin { to { transform: rotate(360deg); } } +@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } + +input, select, button { font-family: 'Space Grotesk', sans-serif; } +::-webkit-scrollbar { width: 4px; height: 4px; } +::-webkit-scrollbar-track { background: var(--black); } +::-webkit-scrollbar-thumb { background: #2a2a2a; border-radius: 2px; } diff --git a/soleradar/src/app/layout.tsx b/soleradar/src/app/layout.tsx new file mode 100644 index 000000000..f8c9ec4bf --- /dev/null +++ b/soleradar/src/app/layout.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from 'next' +import './globals.css' + +export const metadata: Metadata = { + title: 'SoleRadar — Global Sneaker Intelligence', + description: 'Find your grail anywhere. AI agents scan 7+ retailers in your region simultaneously.', +} + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + + {children} + + ) +} diff --git a/soleradar/src/app/page.tsx b/soleradar/src/app/page.tsx new file mode 100644 index 000000000..66cdcc598 --- /dev/null +++ b/soleradar/src/app/page.tsx @@ -0,0 +1,1105 @@ +'use client' + +import { useState, useRef, useCallback } from 'react' +import type { SneakerDrop, AgentState, SearchParams } from '@/lib/types' + +// ─── helpers ──────────────────────────────────────────────────────────────── + +function buildGoal(p: SearchParams, currency: string): string { + return `TASK: Extract ${p.sneakerName} of colour ${p.colorway} of size ${p.size}. +SPEED: You must complete this task as fast as possible. Do not wait, do not explore, do not browse around. Go directly to the product. If a search bar is available, use it immediately. Extract and return — do not waste time. +RULES: +1) Focus only on the user-specified sneaker(s) and variants (size, color, region). +2) Stay on the page. Do not click unrelated links. If the product is not on this page after one search, return empty — do not keep browsing. +3) Open pages fully to read dynamic content (stock status, "coming soon," price, sizes). +4) Avoid unnecessary navigation; be fast and efficient. For the colourway, colours are separated by commas and if there is something like "Red and Black" that means a shoe with both red and black colours. In the purchase link, put the direct link to the product page. +5) If a page has multiple products, only extract information relevant to the selected sneaker model and size. +6) Display all prices in ${currency}. +7) If the product is not found after a direct search, immediately return an empty sneakerDrops array. Do not keep trying. +RETURN JSON: +{ + "sneakerDrops": [ + { + "Sneaker Name": "", + "Brand": "", + "Size": "", + "Colorway": "", + "Region / Country": "${p.region}", + "Stock Status": "", + "Price": "", + "Release Time / Restock Time": "", + "Website": "", + "Purchase Link": "", + "Notes": "" + } + ] +}` +} + +function scoreResult(drop: SneakerDrop): number { + let s = 5 + const stock = (drop['Stock Status'] || '').toLowerCase() + if (stock.includes('in stock') || stock.includes('available')) s += 3 + else if (stock.includes('low') || stock.includes('few')) s += 1 + else if (stock.includes('out') || stock.includes('coming soon')) s -= 2 + if (drop['Price'] && drop['Price'] !== '' && drop['Price'] !== 'N/A') s += 1 + if (drop['Purchase Link'] && drop['Purchase Link'].startsWith('http')) s += 1 + return Math.min(10, Math.max(1, s)) +} + +function extractDrops(raw: unknown): SneakerDrop[] { + if (!raw) return [] + + // Handle string — try to parse it + if (typeof raw === 'string') { + try { + const clean = raw.replace(/```json|```/g, '').trim() + raw = JSON.parse(clean) + } catch { return [] } + } + + const obj = raw as Record + let drops: unknown[] = [] + + if (Array.isArray(obj)) { + drops = obj + } else if (Array.isArray(obj.sneakerDrops)) { + drops = obj.sneakerDrops as unknown[] + } else if (obj.resultJson) { + // TinyFish COMPLETE event: { resultJson: { sneakerDrops: [...] } } + const rj = obj.resultJson as Record + if (Array.isArray(rj.sneakerDrops)) drops = rj.sneakerDrops as unknown[] + else if (Array.isArray(rj)) drops = rj as unknown[] + else { + // resultJson might itself be a string + if (typeof rj === 'string') { + try { + const p = JSON.parse((rj as unknown as string).replace(/```json|```/g, '').trim()) + if (Array.isArray(p)) drops = p + else if (Array.isArray(p.sneakerDrops)) drops = p.sneakerDrops as unknown[] + } catch { /* empty */ } + } + } + } else if (obj.payload) { + // Our own wrapper: { type: 'done', payload: } + return extractDrops(obj.payload) + } else if (Array.isArray(obj.result)) { + drops = obj.result as unknown[] + } else if (typeof obj.result === 'string') { + try { + const p = JSON.parse((obj.result as string).replace(/```json|```/g, '').trim()) + if (Array.isArray(p)) drops = p + else if (Array.isArray(p.sneakerDrops)) drops = p.sneakerDrops as unknown[] + } catch { return [] } + } else if (typeof obj.output === 'string') { + try { + const p = JSON.parse((obj.output as string).replace(/```json|```/g, '').trim()) + if (Array.isArray(p)) drops = p + else if (Array.isArray(p.sneakerDrops)) drops = p.sneakerDrops as unknown[] + } catch { return [] } + } + + return (drops as SneakerDrop[]).filter((d) => d && typeof d === 'object') +} + +const CURRENCY_OPTIONS = [ + { code: 'SGD', symbol: 'S$', label: 'SGD — Singapore Dollar' }, + { code: 'USD', symbol: '$', label: 'USD — US Dollar' }, + { code: 'GBP', symbol: '£', label: 'GBP — British Pound' }, + { code: 'JPY', symbol: '¥', label: 'JPY — Japanese Yen' }, + { code: 'AUD', symbol: 'A$', label: 'AUD — Australian Dollar' }, + { code: 'EUR', symbol: '€', label: 'EUR — Euro' }, + { code: 'CAD', symbol: 'C$', label: 'CAD — Canadian Dollar' }, + { code: 'INR', symbol: '₹', label: 'INR — Indian Rupee' }, +] + +const REGION_OPTIONS = [ + '🇸🇬 Singapore', + '🇺🇸 United States', + '🇬🇧 United Kingdom', + '🇯🇵 Japan', + '🇦🇺 Australia', + '🇩🇪 Germany', + '🇨🇦 Canada', + '🇫🇷 France', + '🇮🇳 India', +] + +// ─── components ───────────────────────────────────────────────────────────── + +function AgentWindow({ agent }: { agent: AgentState }) { + const isDone = agent.status === 'done' + const isError = agent.status === 'error' + const isRunning = agent.status === 'running' || agent.status === 'pending' + + return ( +
+ {/* progress bar */} +
+ {isRunning && ( +
+ )} + {isDone &&
} +
+ + {/* live dot */} +
+ +
{agent.site}
+
+ {agent.status === 'pending' ? 'Queued...' : + agent.status === 'error' ? '✕ Error' : + isDone ? `✓ ${agent.results.length} result${agent.results.length !== 1 ? 's' : ''} found` : + agent.statusMsg || 'Connecting...'} +
+
+ ) +} + +function ProductCard({ + drop, + selected, + onToggleCompare, + currencySymbol, +}: { + drop: SneakerDrop + selected: boolean + onToggleCompare: () => void + currencySymbol: string +}) { + const score = drop._qualityScore ?? scoreResult(drop) + const stock = (drop['Stock Status'] || '').toLowerCase() + const inStock = stock.includes('in stock') || stock.includes('available') + const low = stock.includes('low') || stock.includes('few') || stock.includes('limited') + const purchaseLink = drop['Purchase Link'] + const hasLink = purchaseLink && purchaseLink.startsWith('http') + + return ( +
{ (e.currentTarget as HTMLDivElement).style.borderColor = '#FFE600'; (e.currentTarget as HTMLDivElement).style.transform = 'translateY(-2px)' }} + onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.borderColor = selected ? '#FFE600' : '#1a1a1a'; (e.currentTarget as HTMLDivElement).style.transform = 'translateY(0)' }} + > + {selected && ( +
+ )} + + {/* image placeholder */} +
+ 👟 +
+
{drop['Website'] || drop._agentSite || '—'}
+
+ +
+
{drop['Sneaker Name'] || '—'}
+
+ {drop['Colorway'] || '—'} {drop['Brand'] ? `· ${drop['Brand']}` : ''} +
+ +
+ {drop['Price'] && drop['Price'] !== 'N/A' && drop['Price'] !== '' ? drop['Price'] : '—'} +
+ +
+ SIZE {drop['Size'] || '—'} + + {low ? '⚠ Low Stock' : inStock ? '✓ In Stock' : drop['Stock Status'] || '—'} + +
+ + {/* quality score bar */} +
+
+ Quality + {score}/10 +
+
+
+
+
+ + +
+
+ ) +} + +function CompareDashboard({ + items, + onClose, +}: { + items: SneakerDrop[] + onClose: () => void +}) { + if (items.length === 0) return null + + const scored = items.map(d => ({ ...d, _qualityScore: scoreResult(d) })) + const best = scored.reduce((a, b) => ((a._qualityScore ?? 0) >= (b._qualityScore ?? 0) ? a : b)) + + return ( +
{ if (e.target === e.currentTarget) onClose() }} + > +
+ {/* header */} +
+
+ COMPARE DASHBOARD +
+ +
+ + {/* best pick */} +
+
BEST PICK
+
+
{best['Website']} — {best['Sneaker Name']}
+
+ Quality score {best._qualityScore}/10 · {best['Stock Status']} · {best['Price']} +
+
+
+ + {/* table */} +
+ + + + + {scored.map((d, i) => ( + + ))} + + + + {[ + { label: 'Price', render: (d: SneakerDrop) => {d['Price'] || '—'} }, + { label: 'Size', render: (d: SneakerDrop) => d['Size'] || '—' }, + { label: 'Stock', render: (d: SneakerDrop) => { + const s = (d['Stock Status'] || '').toLowerCase() + const c = s.includes('in stock') || s.includes('available') ? '#4ade80' : s.includes('low') ? '#FF6B00' : '#666' + return {d['Stock Status'] || '—'} + }}, + { label: 'Colorway', render: (d: SneakerDrop) => d['Colorway'] || '—' }, + { label: 'Region', render: (d: SneakerDrop) => d['Region / Country'] || '—' }, + { label: 'Release', render: (d: SneakerDrop) => d['Release Time / Restock Time'] || '—' }, + { label: 'Quality', render: (d: SneakerDrop) => { + const sc = d._qualityScore ?? 5 + return ( + + + {scored.map((d, i) => ( + + ))} + + ))} + +
+ {d['Website'] || d._agentSite || `Option ${i + 1}`} + {d === best && ' ★'} +
{row.label} + {row.render(d)} +
+
+
+
+ ) +} + +// ─── main page ─────────────────────────────────────────────────────────────── + +export default function Home() { + const [sneakerName, setSneakerName] = useState('') + const [size, setSize] = useState('') + const [colorway, setColorway] = useState('') + const [region, setRegion] = useState('🇸🇬 Singapore') + const [currency, setCurrency] = useState('SGD') + + const [agents, setAgents] = useState([]) + const [allDrops, setAllDrops] = useState([]) + const [searching, setSearching] = useState(false) + const [searched, setSearched] = useState(false) + + const [filterSite, setFilterSite] = useState('All') + const [filterStock, setFilterStock] = useState('All') + const [sortBy, setSortBy] = useState<'price-asc' | 'price-desc' | 'quality' | 'newest'>('quality') + + const [compareItems, setCompareItems] = useState([]) + const [showDashboard, setShowDashboard] = useState(false) + const [compareError, setCompareError] = useState(false) + + const abortRef = useRef(null) + + const currencySymbol = CURRENCY_OPTIONS.find(c => c.code === currency)?.symbol || '$' + + const runAgent = useCallback(async (site: { name: string; url: string }, goal: string, idx: number) => { + const updateAgent = (patch: Partial) => { + setAgents(prev => prev.map((a, i) => i === idx ? { ...a, ...patch } : a)) + } + + updateAgent({ status: 'running', startedAt: Date.now() }) + + try { + const res = await fetch('/api/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: site.url, goal }), + signal: abortRef.current?.signal, + }) + + if (!res.ok || !res.body) { + updateAgent({ status: 'error', statusMsg: 'Failed to connect' }) + return + } + + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + let finalResults: SneakerDrop[] = [] + + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed || !trimmed.startsWith('data:')) continue + + const dataStr = trimmed.slice(5).trim() + try { + const parsed = JSON.parse(dataStr) + + // stream_end = stream closed naturally, just finish with what we have + if (parsed.type === 'stream_end') { + break + } + + // error from our proxy — log the status but keep going, don't mark as error + // (TinyFish sometimes sends error-like messages mid-stream that aren't fatal) + if (parsed.type === 'error') { + updateAgent({ statusMsg: `Note: ${(parsed.message || 'unknown').slice(0, 60)}` }) + break + } + + // Our done wrapper — TinyFish COMPLETE payload + if (parsed.type === 'done') { + if (parsed.payload) { + const drops = extractDrops(parsed.payload) + if (drops.length > 0) { + finalResults = drops.map(d => ({ ...d, _agentSite: site.name, _qualityScore: scoreResult(d) })) + } + } + break + } + + // TinyFish native event types + const tfType = (parsed.type || '').toUpperCase() + + if (tfType === 'STARTED') { + updateAgent({ statusMsg: 'Agent started...' }) + } + + if (tfType === 'HEARTBEAT') { + // keep-alive ping from TinyFish — ignore, just means it's still running + } + + if (tfType === 'PROGRESS' && parsed.purpose) { + updateAgent({ statusMsg: String(parsed.purpose).slice(0, 80) }) + } + + if (tfType === 'COMPLETE' || parsed.resultJson !== undefined) { + const drops = extractDrops(parsed) + if (drops.length > 0) { + finalResults = drops.map(d => ({ ...d, _agentSite: site.name, _qualityScore: scoreResult(d) })) + } + break + } + + } catch { + // plain text status message — just show it, keep running + if (dataStr && dataStr.length < 200 && !dataStr.startsWith('{')) { + updateAgent({ statusMsg: dataStr.slice(0, 80) }) + } + } + } + } + + // Filter out empty or "not found" results + const validResults = finalResults.filter(d => { + const name = (d['Sneaker Name'] || '').toLowerCase().trim() + const price = (d['Price'] || '').trim() + const link = (d['Purchase Link'] || '').trim() + const stock = (d['Stock Status'] || '').toLowerCase() + // Skip if no name, or explicitly says not found + if (!name || name === 'n/a' || name === 'not found' || name === '—' || name === '-') return false + // Skip if no price AND no link (truly empty result) + if (!price && !link) return false + // Skip if stock explicitly says not found / not available with no link + if ((stock.includes('not found') || stock.includes('not available')) && !link) return false + return true + }) + updateAgent({ status: 'done', results: validResults }) + if (validResults.length > 0) { + setAllDrops(prev => [...prev, ...validResults]) + } + + } catch (err: unknown) { + // AbortError = user triggered new search, silently stop + if (err instanceof Error && err.name === 'AbortError') return + // Any other error (network drop, timeout) — mark done with whatever was found + // Never show "error" to the user for a long-running search that just closed + updateAgent({ status: 'done', results: [] }) + } + }, []) + + const handleSearch = async () => { + if (!sneakerName.trim()) return + + abortRef.current?.abort() + abortRef.current = new AbortController() + + setAllDrops([]) + setAgents([]) + setSearching(true) + setSearched(true) + setFilterSite('All') + setCompareItems([]) + + // Fetch sites for region + const sitesRes = await fetch(`/api/find-sites?region=${encodeURIComponent(region)}&sneaker=${encodeURIComponent(sneakerName)}`) + const sitesData = await sitesRes.json() + const sites: { name: string; url: string }[] = sitesData.sites || [] + + const initialAgents: AgentState[] = sites.map(s => ({ + site: s.name, url: s.url, status: 'running', + statusMsg: 'Connecting...', results: [], + })) + setAgents(initialAgents) + + const params: SearchParams = { sneakerName, size, colorway, region, currency } + const goal = buildGoal(params, currency) + + // Launch all agents in parallel + await Promise.allSettled(sites.map((site, idx) => runAgent(site, goal, idx))) + setSearching(false) + } + + // ─── filtering & sorting ────────────────────────────────────────────────── + + const sitesInResults = ['All', ...Array.from(new Set(allDrops.map(d => d['Website'] || d._agentSite || 'Unknown')))] + + let displayDrops = [...allDrops] + if (filterSite !== 'All') displayDrops = displayDrops.filter(d => (d['Website'] || d._agentSite) === filterSite) + if (filterStock !== 'All') { + displayDrops = displayDrops.filter(d => { + const s = (d['Stock Status'] || '').toLowerCase() + if (filterStock === 'In Stock') return s.includes('in stock') || s.includes('available') + if (filterStock === 'Low Stock') return s.includes('low') || s.includes('few') || s.includes('limited') + if (filterStock === 'Pre-Order') return s.includes('pre') || s.includes('coming') + return true + }) + } + + if (sortBy === 'price-asc') { + displayDrops.sort((a, b) => { + const pa = parseFloat((a['Price'] || '0').replace(/[^0-9.]/g, '')) || 0 + const pb = parseFloat((b['Price'] || '0').replace(/[^0-9.]/g, '')) || 0 + return pa - pb + }) + } else if (sortBy === 'price-desc') { + displayDrops.sort((a, b) => { + const pa = parseFloat((a['Price'] || '0').replace(/[^0-9.]/g, '')) || 0 + const pb = parseFloat((b['Price'] || '0').replace(/[^0-9.]/g, '')) || 0 + return pb - pa + }) + } else if (sortBy === 'quality') { + displayDrops.sort((a, b) => (b._qualityScore ?? 0) - (a._qualityScore ?? 0)) + } + + const activeAgents = agents.filter(a => a.status === 'running' || a.status === 'pending').length + const lowestPrice = allDrops.reduce((min, d) => { + const p = parseFloat((d['Price'] || '').replace(/[^0-9.]/g, '')) + return (!isNaN(p) && p < min) ? p : min + }, Infinity) + + const toggleCompare = (drop: SneakerDrop) => { + setCompareError(false) + setCompareItems(prev => { + const exists = prev.some(d => d['Purchase Link'] === drop['Purchase Link'] && d['Website'] === drop['Website']) + if (exists) return prev.filter(d => !(d['Purchase Link'] === drop['Purchase Link'] && d['Website'] === drop['Website'])) + if (prev.length >= 5) return prev + return [...prev, drop] + }) + } + + const isSelected = (drop: SneakerDrop) => + compareItems.some(d => d['Purchase Link'] === drop['Purchase Link'] && d['Website'] === drop['Website']) + + // ─── render ─────────────────────────────────────────────────────────────── + + return ( +
+ {/* Floating shoes */} + {['5%', '35%', 'calc(100% - 200px)', '65%', '-5%'].map((top, i) => ( +
👟
+ ))} + + {/* Header */} +
+
+
+ SOLERADAR +
+
+ {searching && ( + + ● {activeAgents} AGENTS ACTIVE + + )} + POWERED BY TINYFISH +
+
+ + {/* Hero */} +
+
GLOBAL SNEAKER INTELLIGENCE
+ +

+ FIND YOUR + GRAIL + ANYWHERE +

+ +

+ Enter any sneaker. SoleRadar deploys AI agents across 7+ retailers in your region — simultaneously — and surfaces every deal, size, and colorway in real-time. +

+
+ + {/* Search */} +
+
+
+ {[ + { label: 'Sneaker Name', value: sneakerName, setter: setSneakerName, placeholder: 'e.g. Jordan 1 Low, Yeezy 350...' }, + { label: 'Size', value: size, setter: setSize, placeholder: 'e.g. US 10, UK 9...' }, + { label: 'Colorway', value: colorway, setter: setColorway, placeholder: 'e.g. University Blue...' }, + ].map((field, i) => ( +
+
{field.label}
+ field.setter(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleSearch() }} + placeholder={field.placeholder} + style={{ + width: '100%', background: 'transparent', border: 'none', + outline: 'none', color: '#f5f0e8', fontSize: 15, fontWeight: 500, + padding: '12px 16px', cursor: 'text', + }} + /> +
+ ))} + + {/* Region dropdown */} +
+
Region
+ +
+ + {/* Currency dropdown */} +
+
Currency
+ +
+
+ + +
+
+ + {/* Counter strip */} + {searched && ( +
+ {[ + { num: agents.length, lbl: 'Sites Targeted' }, + { num: allDrops.length, lbl: 'Results Found' }, + { num: activeAgents, lbl: 'Agents Active' }, + { num: lowestPrice === Infinity ? '—' : `${currencySymbol}${lowestPrice.toFixed(0)}`, lbl: 'Lowest Price' }, + ].map((item, i) => ( +
+ {item.num} + {item.lbl} +
+ ))} +
+ )} + + {/* Agents section */} + {agents.length > 0 && ( +
+
+ AGENTS +
+ + {activeAgents} RUNNING · {agents.filter(a => a.status === 'done').length} COMPLETE + +
+
+ {agents.map((agent, i) => )} +
+
+ )} + + {/* Results */} + {searched && ( +
+ + {/* Sidebar */} + + + {/* Main results */} +
+
+
+
+ {displayDrops.length} RESULTS +
+ {searched && ( +
+ {sneakerName} · {colorway || 'Any colorway'} · {size || 'Any size'} · {region} +
+ )} +
+
+ {(['quality', 'price-asc', 'price-desc', 'newest'] as const).map(s => ( + + ))} +
+
+ + {allDrops.length === 0 && !searching && ( +
+ {searched ? 'No results found — try a different sneaker or region' : 'Enter a sneaker above to start hunting'} +
+ )} + + {allDrops.length === 0 && searching && ( +
+ Agents deploying... results will appear as they come in +
+ )} + +
+ {displayDrops.map((drop, i) => ( + toggleCompare(drop)} + currencySymbol={currencySymbol} + /> + ))} +
+
+
+ )} + + {/* Compare bar — ALWAYS visible after search */} + {searched && ( +
0 ? '#FFE600' : '#1a1a1a', + zIndex: 100, padding: '14px 40px', + display: 'flex', alignItems: 'center', justifyContent: 'space-between', + borderTop: compareItems.length === 0 ? '1px solid #222' : 'none', + transition: 'background 0.3s', + animation: 'slideUp 0.3s ease', + }}> +
+ 0 ? '#0a0a0a' : '#333', letterSpacing: 2, + }}>COMPARE + {compareItems.length === 0 && ( + + SELECT ITEMS TO COMPARE + + )} + {compareItems.map((item, i) => ( +
+ {item['Website'] || item._agentSite} {item['Price'] ? `· ${item['Price']}` : ''} + toggleCompare(item)} + style={{ cursor: 'crosshair', opacity: 0.6, fontSize: 13 }} + >× +
+ ))} + {compareError && ( + + ⚠ SELECT AT LEAST 2 ITEMS + + )} +
+ +
+ )} + + {/* Dashboard */} + {showDashboard && ( + setShowDashboard(false)} + /> + )} + + {/* Bottom padding for compare bar */} + {searched &&
} +
+ ) +} diff --git a/soleradar/src/lib/types.ts b/soleradar/src/lib/types.ts new file mode 100644 index 000000000..7e37b73b2 --- /dev/null +++ b/soleradar/src/lib/types.ts @@ -0,0 +1,32 @@ +export interface SneakerDrop { + 'Sneaker Name': string + 'Brand': string + 'Size': string + 'Colorway': string + 'Region / Country': string + 'Stock Status': string + 'Price': string + 'Release Time / Restock Time': string + 'Website': string + 'Purchase Link': string + 'Notes': string + _qualityScore?: number + _agentSite?: string +} + +export interface AgentState { + site: string + url: string + status: 'pending' | 'running' | 'done' | 'error' + statusMsg: string + results: SneakerDrop[] + startedAt?: number +} + +export interface SearchParams { + sneakerName: string + size: string + colorway: string + region: string + currency: string +} diff --git a/soleradar/tsconfig.json b/soleradar/tsconfig.json new file mode 100644 index 000000000..83d7b514a --- /dev/null +++ b/soleradar/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "target": "ES2017", + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./src/*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +}