From 1c8de9b5f44aaff14cc649420e8cfbed60530f71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AD=94=E6=96=B9=E7=86=8A=E6=88=90=E7=A5=A5?= <3615096616@qq.com> Date: Wed, 29 Apr 2026 21:38:14 +0800 Subject: [PATCH] 1 --- .env | 1 + .gitignore | 4 + docs/home-interface-missing-fields.md | 381 +++ index.html | 13 + package.json | 29 + pnpm-lock.yaml | 1268 +++++++++ src/App.tsx | 27 + src/apis/apiMallApp.ts | 1 + src/apis/apiUser.ts | 67 + src/apis/base/axiosInstance.ts | 147 + src/apis/base/types.ts | 58 + src/apis/base/useAxios.ts | 78 + src/apis/index.ts | 4 + src/apis/mall-app/afterSale.ts | 15 + src/apis/mall-app/cart.ts | 36 + src/apis/mall-app/goods.ts | 39 + src/apis/mall-app/home.ts | 67 + src/apis/mall-app/index.ts | 7 + src/apis/mall-app/normalize.ts | 373 +++ src/apis/mall-app/order.ts | 48 + src/apis/mall-app/shared.ts | 34 + src/apis/mall-app/trade.ts | 32 + src/components/MarketNav.tsx | 34 + src/components/MarketPageHeader.tsx | 173 ++ src/config/source/config.ts | 79 + src/constants/mall.ts | 47 + src/index.css | 3614 +++++++++++++++++++++++++ src/main.tsx | 10 + src/mocks/mall.ts | 262 ++ src/pages/goods/GoodsDetailPage.tsx | 585 ++++ src/pages/home/HomePage.tsx | 1041 +++++++ src/pages/login/LoginPage.tsx | 412 +++ src/pages/member/MemberCenterPage.tsx | 455 ++++ src/pages/shop/ShopPage.tsx | 246 ++ src/store/user.ts | 105 + src/types/ITokenData.ts | 11 + src/types/IUserInfo.ts | 6 + src/types/index.ts | 3 + src/types/mall.ts | 659 +++++ src/utils/logger.ts | 74 + src/utils/mall/home.ts | 39 + src/utils/mall/index.ts | 2 + src/utils/mall/transform.ts | 133 + src/vite-env.d.ts | 2 + tsconfig.app.json | 26 + tsconfig.json | 14 + tsconfig.node.json | 17 + vite.config.ts | 19 + 48 files changed, 10797 insertions(+) create mode 100644 .env create mode 100644 .gitignore create mode 100644 docs/home-interface-missing-fields.md create mode 100644 index.html create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 src/App.tsx create mode 100644 src/apis/apiMallApp.ts create mode 100644 src/apis/apiUser.ts create mode 100644 src/apis/base/axiosInstance.ts create mode 100644 src/apis/base/types.ts create mode 100644 src/apis/base/useAxios.ts create mode 100644 src/apis/index.ts create mode 100644 src/apis/mall-app/afterSale.ts create mode 100644 src/apis/mall-app/cart.ts create mode 100644 src/apis/mall-app/goods.ts create mode 100644 src/apis/mall-app/home.ts create mode 100644 src/apis/mall-app/index.ts create mode 100644 src/apis/mall-app/normalize.ts create mode 100644 src/apis/mall-app/order.ts create mode 100644 src/apis/mall-app/shared.ts create mode 100644 src/apis/mall-app/trade.ts create mode 100644 src/components/MarketNav.tsx create mode 100644 src/components/MarketPageHeader.tsx create mode 100644 src/config/source/config.ts create mode 100644 src/constants/mall.ts create mode 100644 src/index.css create mode 100644 src/main.tsx create mode 100644 src/mocks/mall.ts create mode 100644 src/pages/goods/GoodsDetailPage.tsx create mode 100644 src/pages/home/HomePage.tsx create mode 100644 src/pages/login/LoginPage.tsx create mode 100644 src/pages/member/MemberCenterPage.tsx create mode 100644 src/pages/shop/ShopPage.tsx create mode 100644 src/store/user.ts create mode 100644 src/types/ITokenData.ts create mode 100644 src/types/IUserInfo.ts create mode 100644 src/types/index.ts create mode 100644 src/types/mall.ts create mode 100644 src/utils/logger.ts create mode 100644 src/utils/mall/home.ts create mode 100644 src/utils/mall/index.ts create mode 100644 src/utils/mall/transform.ts create mode 100644 src/vite-env.d.ts create mode 100644 tsconfig.app.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts diff --git a/.env b/.env new file mode 100644 index 0000000..d49180f --- /dev/null +++ b/.env @@ -0,0 +1 @@ +VITE_API_BASE_URL=https://test-gateway.yuntuo.hk diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f3a35a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +.DS_Store + diff --git a/docs/home-interface-missing-fields.md b/docs/home-interface-missing-fields.md new file mode 100644 index 0000000..d74dc22 --- /dev/null +++ b/docs/home-interface-missing-fields.md @@ -0,0 +1,381 @@ +# 商城首页接口缺字段补充建议 + +## 背景 + +当前首页已经按原来的页面结构保留: + +- 顶部搜索区 +- 左侧分类导航 +- 中间 Banner 主视觉 +- 右侧档口排行榜 +- 中下方 Banner 卡片区 +- 商品列表区 +- 店铺推荐区 + +同时页面展示已经改成“优先按接口字段渲染”,不再使用本地 mock 数据补业务内容。 + +当前直接使用的接口是: + +- `AppHome` +- `AppGoodsPage` + +现状是页面能跑通,但为了完全摆脱前端硬编码、数字占位和临时推导,首页还有几组字段缺口需要后端补齐。 + +## 当前接口已能直接支撑的内容 + +### `AppHome` + +当前已足够支撑: + +- Banner 图片:`banners[].imageUrl` +- Banner 主标题:`banners[].title` +- Banner 跳转信息:`banners[].linkType`、`banners[].linkValue` +- 推荐商品:`recommendGoods[]` +- 推荐店铺:`recommendStores[]` + +### `AppGoodsPage` + +当前已足够支撑: + +- 商品分页列表:`list[]` +- 商品基础卡片:`spuName`、`subTitle`、`coverUrl` +- 价格:`minPrice`、`maxPrice` +- 销量:`salesVolume`、`virtualSales` +- 库存:`totalStock` +- 排序:`sortType` +- 类目筛选请求值:`categoryId` + +## 缺字段清单 + +### 1. 首页分类导航缺少类目展示字段 + +#### 当前问题 + +当前前端只能拿到 `GoodsSummary.categoryId`,但左侧分类导航如果要保持现有样式,需要: + +- 类目名称 +- 类目排序 +- 商品数量 + +现在如果只靠 `categoryId`,前端只能显示成 `类目 12` 这种数字占位,不够可用,也不适合正式页面。 + +#### 为什么需要 + +左侧分类导航不仅是筛选入口,也是首页首屏的重要信息模块。如果没有类目名称: + +- 用户看不懂类目内容 +- 前端无法稳定排序 +- 无法避免写死类目文案或临时用数字占位 + +#### 建议补充方式 + +优先建议直接补到 `AppHomeResponse`,因为首页首屏进入时就需要这组数据。 + +#### 建议定义 + +```ts +interface HomeCategoryNav { + categoryId: number + categoryName: string + goodsCount: number + sort: number +} +``` + +建议追加到: + +```ts +interface AppHomeResponse { + banners: BannerInfo[] + recommendGoods: GoodsSummary[] + recommendStores: RecommendStoreInfo[] + categoryNavs: HomeCategoryNav[] +} +``` + +#### 示例 + +```json +{ + "categoryId": 101, + "categoryName": "手机配件", + "goodsCount": 286, + "sort": 10 +} +``` + +### 2. Banner 缺少副文案和按钮文案字段 + +#### 当前问题 + +`BannerInfo` 目前只有: + +- `title` +- `imageUrl` +- `linkType` +- `linkValue` + +这足够渲染图片和标题,但首屏 Banner 按当前页面样式还需要: + +- 副标题 +- 描述文案 +- 主按钮文案 + +不补的话,这些内容只能前端写死。 + +#### 为什么需要 + +Banner 是首页视觉中心。如果副文案和按钮文案不能由接口控制: + +- 运营无法灵活调整首屏内容 +- 前端只能用固定文案 +- 不同 Banner 的表达层级无法统一 + +#### 建议定义 + +```ts +interface BannerInfo { + id: number + title: string + subTitle: string + description: string + buttonText: string + positionCode: string + imageUrl: string + linkType: string + linkValue: string + sort: number + status: "enabled" | "disabled" + startTime: number + endTime: number +} +``` + +#### 示例 + +```json +{ + "id": 1, + "title": "春季上新", + "subTitle": "精选档口同步上新", + "description": "聚合热卖新品与高频补货商品", + "buttonText": "立即搜款", + "positionCode": "home_top", + "imageUrl": "https://cdn.example.com/banner/1.png", + "linkType": "goods", + "linkValue": "10001", + "sort": 1, + "status": "enabled", + "startTime": 1710000000, + "endTime": 1730000000 +} +``` + +### 3. 推荐店铺缺少推荐理由和排行依据 + +#### 当前问题 + +`RecommendStoreInfo` 现在只有: + +- `storeName` +- `storeLogo` +- `storeNotice` +- `storeStatus` + +右侧“档口排行榜”区域如果要保持现有样式和业务含义,还需要: + +- 推荐理由 +- 主营类目 +- 排行分值或展示指标 + +不补的话,只能展示店铺公告,排行榜会显得信息不完整。 + +#### 为什么需要 + +当前右侧区域不是普通店铺列表,而是“推荐 / 排行”型模块。用户需要知道: + +- 为什么推荐这家店 +- 它主要卖什么 +- 排行依据是什么 + +#### 建议定义 + +```ts +interface RecommendStoreInfo { + id: number + merchantId: number + storeName: string + storeLogo: string + storeNotice: string + storeStatus: "enabled" | "disabled" + mainCategoryName: string + recommendReason: string + rankScore: number +} +``` + +#### 示例 + +```json +{ + "id": 9001, + "merchantId": 5001, + "storeName": "星驰数码档口", + "storeLogo": "https://cdn.example.com/store/logo-1.png", + "storeNotice": "手机配件热销档口", + "storeStatus": "enabled", + "mainCategoryName": "手机配件", + "recommendReason": "近 7 天出单稳定,适合做日常补货", + "rankScore": 98.6 +} +``` + +### 4. 商品摘要缺少类目名称 + +#### 当前问题 + +`GoodsSummary` 里有 `categoryId`,但没有 `categoryName`。 + +这会导致: + +- 商品卡片无法展示人类可读的类目名 +- 分类筛选区无法直接使用列表结果做回显 +- 前端必须再维护一套 `categoryId -> 名称` 映射 + +#### 为什么需要 + +商品列表卡片、推荐商品卡片、筛选回显,都需要类目中文名。只靠 `categoryId` 不够。 + +#### 建议定义 + +```ts +interface GoodsSummary { + id: number + merchantId: number + storeId: number + categoryId: number + categoryName: string + spuNo: string + spuName: string + subTitle: string + coverUrl: string + status: "enabled" | "disabled" + auditStatus: "pending" | "approved" | "rejected" + saleStatus: "off" | "on" + sort: number + salesVolume: number + virtualSales: number + viewCount: number + minPrice: string + maxPrice: string + totalStock: number + lockedStock: number + createdAt: number + updatedAt: number +} +``` + +### 5. 搜索热词缺少接口字段 + +#### 当前问题 + +搜索框下方原样式里有热词区。当前文档没有专门的热词字段,所以前端只能临时从 `recommendGoods[].spuName` 里抽。 + +#### 为什么需要 + +热词一般是运营位,不应该完全依赖推荐商品标题: + +- 热词和推荐商品不是一个概念 +- 热词排序和内容应该可单独控制 +- 需要支持临时活动词、营销词 + +#### 建议定义 + +```ts +interface AppHomeResponse { + banners: BannerInfo[] + recommendGoods: GoodsSummary[] + recommendStores: RecommendStoreInfo[] + categoryNavs: HomeCategoryNav[] + searchKeywords: string[] +} +``` + +#### 示例 + +```json +[ + "磁吸手机壳", + "直播补光灯", + "蓝牙耳机", + "快充套装", + "智能手表表带" +] +``` + +### 6. 平台专区 / 频道卡片缺少独立数据源 + +#### 当前问题 + +原首页样式里有一块“平台专区 / 场景卡片”区域。当前接口没有对应字段,所以如果要保留这块业务含义,前端只能拿别的数据硬凑。 + +#### 为什么需要 + +这类区域通常代表固定业务入口,例如: + +- 平台铺货 +- 一件代发 +- 活动专区 +- 类目专题 + +它不适合和 Banner 或推荐商品混用。 + +#### 建议定义 + +```ts +interface HomeChannelCard { + id: number + title: string + subTitle: string + iconUrl: string + linkType: string + linkValue: string + sort: number + status: "enabled" | "disabled" +} +``` + +建议追加到: + +```ts +interface AppHomeResponse { + banners: BannerInfo[] + recommendGoods: GoodsSummary[] + recommendStores: RecommendStoreInfo[] + categoryNavs: HomeCategoryNav[] + searchKeywords: string[] + channels: HomeChannelCard[] +} +``` + +## 建议优先级 + +如果按“先把当前首页做完整”来排,建议优先级如下: + +1. `categoryNavs` +2. `BannerInfo.subTitle / description / buttonText` +3. `GoodsSummary.categoryName` +4. `RecommendStoreInfo.mainCategoryName / recommendReason / rankScore` +5. `searchKeywords` +6. `channels` + +## 结论 + +当前首页已经可以只用现有接口字段跑起来,但如果要真正做到: + +- 样式不变 +- 内容不写死 +- 页面表达完整 +- 运营可配置 + +上面这些字段建议尽快补到文档和接口定义里。 diff --git a/index.html b/index.html new file mode 100644 index 0000000..281dfbc --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + Mall App + + +
+ + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..9bfa408 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "mall-app", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "typecheck": "tsc -b", + "preview": "vite preview" + }, + "dependencies": { + "ahooks": "^3.8.5", + "axios": "^1.9.0", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-router-dom": "^7.6.3", + "sonner": "^2.0.6", + "zustand": "^5.0.6" + }, + "devDependencies": { + "@types/node": "^24.0.13", + "@types/react": "^18.2.48", + "@types/react-dom": "^18.2.18", + "@vitejs/plugin-react-swc": "^3.10.2", + "typescript": "~5.8.3", + "vite": "^7.0.4" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..1b94267 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1268 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + ahooks: + specifier: ^3.8.5 + version: 3.9.7(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + axios: + specifier: ^1.9.0 + version: 1.15.2 + react: + specifier: 18.2.0 + version: 18.2.0 + react-dom: + specifier: 18.2.0 + version: 18.2.0(react@18.2.0) + react-router-dom: + specifier: ^7.6.3 + version: 7.14.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + sonner: + specifier: ^2.0.6 + version: 2.0.7(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + zustand: + specifier: ^5.0.6 + version: 5.0.12(@types/react@18.3.28)(react@18.2.0) + devDependencies: + '@types/node': + specifier: ^24.0.13 + version: 24.12.2 + '@types/react': + specifier: ^18.2.48 + version: 18.3.28 + '@types/react-dom': + specifier: ^18.2.18 + version: 18.3.7(@types/react@18.3.28) + '@vitejs/plugin-react-swc': + specifier: ^3.10.2 + version: 3.11.0(vite@7.3.2(@types/node@24.12.2)) + typescript: + specifier: ~5.8.3 + version: 5.8.3 + vite: + specifier: ^7.0.4 + version: 7.3.2(@types/node@24.12.2) + +packages: + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.60.2': + resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.2': + resolution: {integrity: sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.2': + resolution: {integrity: sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.2': + resolution: {integrity: sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.2': + resolution: {integrity: sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.2': + resolution: {integrity: sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': + resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.60.2': + resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.60.2': + resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.60.2': + resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.60.2': + resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.60.2': + resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.60.2': + resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.60.2': + resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.60.2': + resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.60.2': + resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.60.2': + resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.60.2': + resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.60.2': + resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.60.2': + resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.2': + resolution: {integrity: sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.2': + resolution: {integrity: sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.2': + resolution: {integrity: sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.2': + resolution: {integrity: sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.2': + resolution: {integrity: sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==} + cpu: [x64] + os: [win32] + + '@swc/core-darwin-arm64@1.15.30': + resolution: {integrity: sha512-VvpP+vq08HmGYewMWvrdsxh9s2lthz/808zXm8Yu5kaqeR8Yia2b0eYXleHQ3VAjoStUDk6LzTheBW9KXYQdMA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.15.30': + resolution: {integrity: sha512-WiJA0hiZI3nwQAO6mu5RqigtWGDtth4Hiq6rbZxAaQyhIcqKIg5IoMRc1Y071lrNJn29eEDMC86Rq58xgUxlDg==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.15.30': + resolution: {integrity: sha512-YANuFUo48kIT6plJgCD0keae9HFXfjxsbvsgevqc0hr/07X/p7sAWTFOGYEc2SXcASaK7UvuQqzlbW8pr7R79g==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.15.30': + resolution: {integrity: sha512-VndG8jaR4ugY6u+iVOT0Q+d2fZd7sLgjPgN8W/Le+3EbZKl+cRfFxV7Eoz4gfLqhmneZPdcIzf9T3LkgkmqNLg==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-arm64-musl@1.15.30': + resolution: {integrity: sha512-1SYGs2l0Yyyi0pR/P/NKz/x0kqxkoiw+BXeJjLUdecSk/KasncWlJrc6hOvFSgKHOBrzgM5jwuluKtlT8dnrcA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-ppc64-gnu@1.15.30': + resolution: {integrity: sha512-TXREtiXeRhbfDFbmhnkIsXpKfzbfT73YkV2ZF6w0sfxgjC5zI2ZAbaCOq25qxvegofj2K93DtOpm9RLaBgqR2g==} + engines: {node: '>=10'} + cpu: [ppc64] + os: [linux] + + '@swc/core-linux-s390x-gnu@1.15.30': + resolution: {integrity: sha512-DCR2YYeyd6DQE4OuDhImouuNcjXEiEdnn1Y0DyGteugPEDvVuvYk8Xddi+4o2SgWH6jiW8/I+3emZvbep1NC+g==} + engines: {node: '>=10'} + cpu: [s390x] + os: [linux] + + '@swc/core-linux-x64-gnu@1.15.30': + resolution: {integrity: sha512-5Pizw3NgfOJ5BJOBK8TIRa59xFW2avESTOBDPTAYwZYa1JNDs+KMF9lUfjJiJLM5HiMs/wPheA9eiT0q9m2AoA==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-linux-x64-musl@1.15.30': + resolution: {integrity: sha512-qyqydP/wyH8alcIP4a2hnGSjHLJjm9H7yDFup+CPy9oTahFgLLwnNcv5UHXqO2Qs3AIND+cls5f/Bb6hqpxdgA==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-win32-arm64-msvc@1.15.30': + resolution: {integrity: sha512-CaQENgDHVGOg1mSF5sQVgvfFHG9kjMor2rkLMLeLOkfZYNj13ppnJ9+lfaBZLZUMMbnlGQnavCJb8PVBUOso7Q==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.15.30': + resolution: {integrity: sha512-30VdLeGk6fugiUs/kUdJ/pAg7z/zpvVbR11RH60jZ0Z42WIeIniYx0rLEWN7h/pKJ3CopqsQ3RsogCAkRKiA2g==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.15.30': + resolution: {integrity: sha512-4iObHPR+Q4oDY110EF5SF5eIaaVJNpMdG9C0q3Q92BsJ5y467uHz7sYQhP60WYlLFsLQ1el2YrIPUItUAQGOKg==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.15.30': + resolution: {integrity: sha512-R8VQbQY1BZcbIF2p3gjlTCwAQzx1A194ugWfwld5y+WgVVWqVKm7eURGGOVbQVubgKWzidP2agomBbg96rZilQ==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + + '@swc/types@0.1.26': + resolution: {integrity: sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/js-cookie@3.0.6': + resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==} + + '@types/node@24.12.2': + resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.28': + resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==} + + '@vitejs/plugin-react-swc@3.11.0': + resolution: {integrity: sha512-YTJCGFdNMHCMfjODYtxRNVAYmTWQ1Lb8PulP/2/f/oEEtglw8oKxKIZmmRkyXrVrHfsKOaVkAc3NT9/dMutO5w==} + peerDependencies: + vite: ^4 || ^5 || ^6 || ^7 + + ahooks@3.9.7: + resolution: {integrity: sha512-S0lvzhbdlhK36RFBkGv+RbOM/dbbweym+BIHM/bwwuWVSVN5TuVErHPMWo4w0t1NDYg5KPp2iEf7Y7E5LASYiw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.15.2: + resolution: {integrity: sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + dayjs@1.11.20: + resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + intersection-observer@0.12.2: + resolution: {integrity: sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==} + deprecated: The Intersection Observer polyfill is no longer needed and can safely be removed. Intersection Observer has been Baseline since 2019. + + js-cookie@3.0.5: + resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} + engines: {node: '>=14'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.10: + resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} + engines: {node: ^10 || ^12 || >=14} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + react-dom@18.2.0: + resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} + peerDependencies: + react: ^18.2.0 + + react-fast-compare@3.2.2: + resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} + + react-router-dom@7.14.2: + resolution: {integrity: sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.14.2: + resolution: {integrity: sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react@18.2.0: + resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} + engines: {node: '>=0.10.0'} + + resize-observer-polyfill@1.5.1: + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + + rollup@4.60.2: + resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + screenfull@5.2.0: + resolution: {integrity: sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==} + engines: {node: '>=0.10.0'} + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + vite@7.3.2: + resolution: {integrity: sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + zustand@5.0.12: + resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + '@babel/runtime@7.29.2': {} + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.60.2': + optional: true + + '@rollup/rollup-android-arm64@4.60.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.2': + optional: true + + '@rollup/rollup-darwin-x64@4.60.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.2': + optional: true + + '@swc/core-darwin-arm64@1.15.30': + optional: true + + '@swc/core-darwin-x64@1.15.30': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.15.30': + optional: true + + '@swc/core-linux-arm64-gnu@1.15.30': + optional: true + + '@swc/core-linux-arm64-musl@1.15.30': + optional: true + + '@swc/core-linux-ppc64-gnu@1.15.30': + optional: true + + '@swc/core-linux-s390x-gnu@1.15.30': + optional: true + + '@swc/core-linux-x64-gnu@1.15.30': + optional: true + + '@swc/core-linux-x64-musl@1.15.30': + optional: true + + '@swc/core-win32-arm64-msvc@1.15.30': + optional: true + + '@swc/core-win32-ia32-msvc@1.15.30': + optional: true + + '@swc/core-win32-x64-msvc@1.15.30': + optional: true + + '@swc/core@1.15.30': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.26 + optionalDependencies: + '@swc/core-darwin-arm64': 1.15.30 + '@swc/core-darwin-x64': 1.15.30 + '@swc/core-linux-arm-gnueabihf': 1.15.30 + '@swc/core-linux-arm64-gnu': 1.15.30 + '@swc/core-linux-arm64-musl': 1.15.30 + '@swc/core-linux-ppc64-gnu': 1.15.30 + '@swc/core-linux-s390x-gnu': 1.15.30 + '@swc/core-linux-x64-gnu': 1.15.30 + '@swc/core-linux-x64-musl': 1.15.30 + '@swc/core-win32-arm64-msvc': 1.15.30 + '@swc/core-win32-ia32-msvc': 1.15.30 + '@swc/core-win32-x64-msvc': 1.15.30 + + '@swc/counter@0.1.3': {} + + '@swc/types@0.1.26': + dependencies: + '@swc/counter': 0.1.3 + + '@types/estree@1.0.8': {} + + '@types/js-cookie@3.0.6': {} + + '@types/node@24.12.2': + dependencies: + undici-types: 7.16.0 + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.28)': + dependencies: + '@types/react': 18.3.28 + + '@types/react@18.3.28': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + '@vitejs/plugin-react-swc@3.11.0(vite@7.3.2(@types/node@24.12.2))': + dependencies: + '@rolldown/pluginutils': 1.0.0-beta.27 + '@swc/core': 1.15.30 + vite: 7.3.2(@types/node@24.12.2) + transitivePeerDependencies: + - '@swc/helpers' + + ahooks@3.9.7(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + dependencies: + '@babel/runtime': 7.29.2 + '@types/js-cookie': 3.0.6 + dayjs: 1.11.20 + intersection-observer: 0.12.2 + js-cookie: 3.0.5 + lodash: 4.18.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-fast-compare: 3.2.2 + resize-observer-polyfill: 1.5.1 + screenfull: 5.2.0 + tslib: 2.8.1 + + asynckit@0.4.0: {} + + axios@1.15.2: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.5 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + cookie@1.1.1: {} + + csstype@3.2.3: {} + + dayjs@1.11.20: {} + + delayed-stream@1.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + follow-redirects@1.16.0: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.3 + mime-types: 2.1.35 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + intersection-observer@0.12.2: {} + + js-cookie@3.0.5: {} + + js-tokens@4.0.0: {} + + lodash@4.18.1: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + nanoid@3.3.11: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.10: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + proxy-from-env@2.1.0: {} + + react-dom@18.2.0(react@18.2.0): + dependencies: + loose-envify: 1.4.0 + react: 18.2.0 + scheduler: 0.23.2 + + react-fast-compare@3.2.2: {} + + react-router-dom@7.14.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-router: 7.14.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + + react-router@7.14.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + dependencies: + cookie: 1.1.1 + react: 18.2.0 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 18.2.0(react@18.2.0) + + react@18.2.0: + dependencies: + loose-envify: 1.4.0 + + resize-observer-polyfill@1.5.1: {} + + rollup@4.60.2: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.2 + '@rollup/rollup-android-arm64': 4.60.2 + '@rollup/rollup-darwin-arm64': 4.60.2 + '@rollup/rollup-darwin-x64': 4.60.2 + '@rollup/rollup-freebsd-arm64': 4.60.2 + '@rollup/rollup-freebsd-x64': 4.60.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.2 + '@rollup/rollup-linux-arm-musleabihf': 4.60.2 + '@rollup/rollup-linux-arm64-gnu': 4.60.2 + '@rollup/rollup-linux-arm64-musl': 4.60.2 + '@rollup/rollup-linux-loong64-gnu': 4.60.2 + '@rollup/rollup-linux-loong64-musl': 4.60.2 + '@rollup/rollup-linux-ppc64-gnu': 4.60.2 + '@rollup/rollup-linux-ppc64-musl': 4.60.2 + '@rollup/rollup-linux-riscv64-gnu': 4.60.2 + '@rollup/rollup-linux-riscv64-musl': 4.60.2 + '@rollup/rollup-linux-s390x-gnu': 4.60.2 + '@rollup/rollup-linux-x64-gnu': 4.60.2 + '@rollup/rollup-linux-x64-musl': 4.60.2 + '@rollup/rollup-openbsd-x64': 4.60.2 + '@rollup/rollup-openharmony-arm64': 4.60.2 + '@rollup/rollup-win32-arm64-msvc': 4.60.2 + '@rollup/rollup-win32-ia32-msvc': 4.60.2 + '@rollup/rollup-win32-x64-gnu': 4.60.2 + '@rollup/rollup-win32-x64-msvc': 4.60.2 + fsevents: 2.3.3 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + screenfull@5.2.0: {} + + set-cookie-parser@2.7.2: {} + + sonner@2.0.7(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + + source-map-js@1.2.1: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tslib@2.8.1: {} + + typescript@5.8.3: {} + + undici-types@7.16.0: {} + + vite@7.3.2(@types/node@24.12.2): + dependencies: + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.10 + rollup: 4.60.2 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 24.12.2 + fsevents: 2.3.3 + + zustand@5.0.12(@types/react@18.3.28)(react@18.2.0): + optionalDependencies: + '@types/react': 18.3.28 + react: 18.2.0 diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..2056858 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,27 @@ +import { Navigate, Route, Routes } from 'react-router-dom' +import { Toaster } from 'sonner' +import GoodsDetailPage from '@/pages/goods/GoodsDetailPage' +import HomePage from '@/pages/home/HomePage' +import LoginPage from '@/pages/login/LoginPage' +import MemberCenterPage from '@/pages/member/MemberCenterPage' +import ShopPage from '@/pages/shop/ShopPage' + +function App() { + return ( + <> + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ) +} + +export default App diff --git a/src/apis/apiMallApp.ts b/src/apis/apiMallApp.ts new file mode 100644 index 0000000..f8e70ff --- /dev/null +++ b/src/apis/apiMallApp.ts @@ -0,0 +1 @@ +export * from './mall-app' diff --git a/src/apis/apiUser.ts b/src/apis/apiUser.ts new file mode 100644 index 0000000..08a2223 --- /dev/null +++ b/src/apis/apiUser.ts @@ -0,0 +1,67 @@ +import axiosInstance from './base/axiosInstance' +import type { IBaseResponse } from './base/types' +import type { ITokenData } from '@/types/ITokenData' +import type { IUserInfo } from '@/types/IUserInfo' + +export type UserAccountLoginType = '0' | '1' + +export interface UserLoginPayload { + account: string + captcha?: string + password?: string + loginType: UserAccountLoginType + type: '0' | '1' +} + +export interface UserRegisterPayload { + account: string + password: string + captcha: string + loginType: UserAccountLoginType + invite?: string + type: '0' +} + +export interface SmsCodePayload { + account: string + loginType: UserAccountLoginType + type: '0' | '1' | '2' +} + +export const apiUserLogin = async (payload: UserLoginPayload, signal?: AbortSignal) => { + const path = '/api/v1/user/login' + const response = await axiosInstance.post>(path, payload, { + signal, + }) + return response.data +} + +export const apiUserRegister = async (payload: UserRegisterPayload, signal?: AbortSignal) => { + const path = '/api/v1/user/register' + const response = await axiosInstance.post>(path, payload, { + signal, + }) + return response.data +} + +export const apiGetSmsCode = async (payload: SmsCodePayload, signal?: AbortSignal) => { + const path = '/api/v1/user/captcha' + const response = await axiosInstance.post>(path, payload, { + signal, + }) + return response.data +} + +export const apiGetUserInfo = async (signal?: AbortSignal) => { + const path = '/api/v1/user/detail' + const response = await axiosInstance.post>( + path, + {}, + { + signal, + } + ) + return response.data +} + +export const apiUserReg = apiUserRegister diff --git a/src/apis/base/axiosInstance.ts b/src/apis/base/axiosInstance.ts new file mode 100644 index 0000000..f6c8c76 --- /dev/null +++ b/src/apis/base/axiosInstance.ts @@ -0,0 +1,147 @@ +import axios, { AxiosHeaders } from 'axios' +import type { AxiosError, InternalAxiosRequestConfig } from 'axios' +import { getBaseUrl, getVersion } from '@/config/source/config' +import { useUserStore } from '@/store/user' +import { API_ERROR_CODES, API_ERROR_TYPES, ApiError, type IBaseResponse } from './types' + +const axiosInstance = axios.create({ + baseURL: getBaseUrl(), + timeout: 30 * 1000, +}) + +const getMessage = (data: unknown, fallback: string) => { + if (!data || typeof data !== 'object') { + return fallback + } + + const maybeMessage = (data as { message?: unknown; msg?: unknown }).message + if (typeof maybeMessage === 'string' && maybeMessage.trim()) { + return maybeMessage + } + + const maybeMsg = (data as { message?: unknown; msg?: unknown }).msg + if (typeof maybeMsg === 'string' && maybeMsg.trim()) { + return maybeMsg + } + + return fallback +} + +const handleHttpError = (status: number, data: unknown, message: string, error: unknown): never => { + if (status === 401) { + useUserStore.getState().logout() + throw new ApiError(API_ERROR_CODES.UNAUTHORIZED, 'Unauthorized', API_ERROR_TYPES.HTTP, data, error) + } + + if (status === 403) { + throw new ApiError(API_ERROR_CODES.FORBIDDEN, 'Forbidden', API_ERROR_TYPES.HTTP, data, error) + } + + if (status === 500 || status === 503) { + throw new ApiError(API_ERROR_CODES.SERVER_ERROR, 'Server error', API_ERROR_TYPES.HTTP, data, error) + } + + throw new ApiError(status, message, API_ERROR_TYPES.HTTP, data, error) +} + +const handleAxiosError = (error: AxiosError): never => { + if (error.code === 'ECONNABORTED') { + throw new ApiError( + API_ERROR_CODES.CLIENT_TIMEOUT_ERROR, + 'Request timeout', + API_ERROR_TYPES.NETWORK, + undefined, + error + ) + } + + if (error.response) { + const { status, data } = error.response + return handleHttpError(status, data, getMessage(data, error.message), error) + } + + if (error.request) { + throw new ApiError( + API_ERROR_CODES.CLIENT_NETWORK_ERROR, + 'Network error', + API_ERROR_TYPES.NETWORK, + undefined, + error + ) + } + + throw new ApiError( + API_ERROR_CODES.SERVER_ERROR, + error.message, + API_ERROR_TYPES.UNKNOWN, + undefined, + error + ) +} + +axiosInstance.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + const token = useUserStore.getState().token?.accessToken ?? '' + const version = getVersion() + + config.baseURL = getBaseUrl() + const headers = AxiosHeaders.from(config.headers) + headers.set('Content-Type', 'application/json;charset=utf-8') + headers.set('version', version) + + if (token) { + headers.set('authorization', token) + } + + config.headers = headers + + return config + }, + error => { + throw new ApiError( + API_ERROR_CODES.SERVER_ERROR, + 'Request setup failed', + API_ERROR_TYPES.UNKNOWN, + undefined, + error + ) + } +) + +axiosInstance.interceptors.response.use( + response => { + const payload = response.data as IBaseResponse + const code = payload?.code + + if (code === API_ERROR_CODES.SUCCESS) { + return response + } + + const message = getMessage(payload, 'Request failed') + if (code === API_ERROR_CODES.UNAUTHORIZED) { + useUserStore.getState().logout() + } + + throw new ApiError(code, message, API_ERROR_TYPES.BUSINESS, payload?.data) + }, + (error: unknown): never => { + if (axios.isCancel(error)) { + throw new ApiError( + API_ERROR_CODES.CLIENT_CANCEL_ERROR, + 'Request canceled', + API_ERROR_TYPES.UNKNOWN, + undefined, + error + ) + } + + if (error instanceof Error && 'isAxiosError' in error && (error as AxiosError).isAxiosError) { + return handleAxiosError(error as AxiosError) + } + + const message = error instanceof Error ? error.message : 'Unknown error' + throw new ApiError(API_ERROR_CODES.SERVER_ERROR, message, API_ERROR_TYPES.UNKNOWN, undefined, error) + } +) + +export default axiosInstance diff --git a/src/apis/base/types.ts b/src/apis/base/types.ts new file mode 100644 index 0000000..a159d5f --- /dev/null +++ b/src/apis/base/types.ts @@ -0,0 +1,58 @@ +export const API_ERROR_TYPES = { + HTTP: 'HTTP', + BUSINESS: 'BUSINESS', + NETWORK: 'NETWORK', + UNKNOWN: 'UNKNOWN', +} as const + +export type ApiErrorType = (typeof API_ERROR_TYPES)[keyof typeof API_ERROR_TYPES] + +export const API_ERROR_CODES = { + SUCCESS: 200, + UNAUTHORIZED: 600, + TOKEN_GENERATE_FAILED: 802, + PARAM_ERROR: 965, + SERVER_BUSY: 999, + DB_ERROR: 1001, + UPDATE_NO_ROWS: 1002, + FORBIDDEN: 403, + SERVER_ERROR: 500, + CLIENT_NETWORK_ERROR: 10000, + CLIENT_TIMEOUT_ERROR: 10001, + CLIENT_CANCEL_ERROR: 10002, +} as const + +export class ApiError extends Error { + code: number + data?: unknown + originalError?: unknown + type: ApiErrorType + + constructor( + code: number, + message: string, + type: ApiErrorType, + data?: unknown, + originalError?: unknown + ) { + super(message) + this.name = 'ApiError' + this.code = code + this.type = type + this.data = data + this.originalError = originalError + } +} + +export interface IBaseResponse { + code: number + message?: string + msg?: string + data: T +} + +export interface IPageParams { + page: number + size: number +} + diff --git a/src/apis/base/useAxios.ts b/src/apis/base/useAxios.ts new file mode 100644 index 0000000..6f6ed4e --- /dev/null +++ b/src/apis/base/useAxios.ts @@ -0,0 +1,78 @@ +import { useRequest } from 'ahooks' +import { useEffect, useRef } from 'react' +import { toast } from 'sonner' +import { API_ERROR_CODES, type ApiError } from './types' +import { useUserStore } from '@/store/user' + +type RequestService = (...args: TParams) => Promise + +export interface OptionsExt { + manual?: boolean + defaultParams?: TParams + showLoading?: boolean + showError?: boolean + showSuccess?: boolean + successText?: string + errorText?: string + onSuccess?: (data: TData, params: TParams) => void + onError?: (error: Error, params: TParams) => void +} + +export const useAxios = ( + service: RequestService, + options: OptionsExt = {} +) => { + const loadingToastRef = useRef(null) + const { logout } = useUserStore() + + useEffect(() => { + return () => { + if (loadingToastRef.current) { + toast.dismiss(loadingToastRef.current) + loadingToastRef.current = null + } + } + }, []) + + return useRequest(service, { + manual: options.manual ?? true, + defaultParams: options.defaultParams, + onBefore: () => { + if (options.showLoading) { + loadingToastRef.current = toast.loading('Processing...') + } + }, + onSuccess: (data, params) => { + if (loadingToastRef.current) { + toast.dismiss(loadingToastRef.current) + loadingToastRef.current = null + } + + if (options.showSuccess) { + toast.success(options.successText || 'Success') + } + + options.onSuccess?.(data, params) + }, + onError: (error, params) => { + if (loadingToastRef.current) { + toast.dismiss(loadingToastRef.current) + loadingToastRef.current = null + } + + const apiError = error as ApiError + if (options.showError !== false) { + toast.error(options.errorText || apiError.message || 'Request failed') + } + + if (apiError.code === API_ERROR_CODES.UNAUTHORIZED) { + logout() + } + + options.onError?.(error, params) + }, + }) +} + +export default useAxios + diff --git a/src/apis/index.ts b/src/apis/index.ts new file mode 100644 index 0000000..db5fb4f --- /dev/null +++ b/src/apis/index.ts @@ -0,0 +1,4 @@ +export * from './apiMallApp' +export * from './apiUser' +export * from './base/types' +export * from './base/useAxios' diff --git a/src/apis/mall-app/afterSale.ts b/src/apis/mall-app/afterSale.ts new file mode 100644 index 0000000..34c1608 --- /dev/null +++ b/src/apis/mall-app/afterSale.ts @@ -0,0 +1,15 @@ +import { APP_API_PATHS, normalizeMallAppPayload, postMallApp } from './shared' +import type { + AppAfterSaleApplyRequest, + AppAfterSaleApplyResponse, +} from '@/types/mall' + +export const apiAppAfterSaleApply = ( + payload: AppAfterSaleApplyRequest, + signal?: AbortSignal +) => + postMallApp( + APP_API_PATHS.afterSaleApply, + normalizeMallAppPayload(payload), + signal + ) diff --git a/src/apis/mall-app/cart.ts b/src/apis/mall-app/cart.ts new file mode 100644 index 0000000..6f401b4 --- /dev/null +++ b/src/apis/mall-app/cart.ts @@ -0,0 +1,36 @@ +import { APP_API_PATHS, normalizeAppCartListResponse, normalizeMallAppPayload, postMallApp } from './shared' +import type { + AppCartListRequest, + AppCartListResponse, + AppCartRemoveRequest, + AppCartRemoveResponse, + AppCartSaveRequest, + AppCartSaveResponse, +} from '@/types/mall' + +export const apiAppCartList = async (payload: AppCartListRequest, signal?: AbortSignal) => { + const response = await postMallApp( + APP_API_PATHS.cartList, + normalizeMallAppPayload(payload), + signal + ) + + return { + ...response, + data: normalizeAppCartListResponse(response.data), + } +} + +export const apiAppCartSave = (payload: AppCartSaveRequest, signal?: AbortSignal) => + postMallApp( + APP_API_PATHS.cartSave, + normalizeMallAppPayload(payload), + signal + ) + +export const apiAppCartRemove = (payload: AppCartRemoveRequest, signal?: AbortSignal) => + postMallApp( + APP_API_PATHS.cartRemove, + normalizeMallAppPayload(payload), + signal + ) diff --git a/src/apis/mall-app/goods.ts b/src/apis/mall-app/goods.ts new file mode 100644 index 0000000..6d68503 --- /dev/null +++ b/src/apis/mall-app/goods.ts @@ -0,0 +1,39 @@ +import { + APP_API_PATHS, + normalizeAppGoodsDetailResponse, + normalizeAppGoodsPageResponse, + normalizeMallAppPayload, + postMallApp, +} from './shared' +import type { + AppGoodsDetailRequest, + AppGoodsDetailResponse, + AppGoodsPageRequest, + AppGoodsPageResponse, +} from '@/types/mall' + +export const apiAppGoodsPage = async (payload: AppGoodsPageRequest, signal?: AbortSignal) => { + const response = await postMallApp( + APP_API_PATHS.goodsPage, + normalizeMallAppPayload(payload), + signal + ) + + return { + ...response, + data: normalizeAppGoodsPageResponse(response.data), + } +} + +export const apiAppGoodsDetail = async (payload: AppGoodsDetailRequest, signal?: AbortSignal) => { + const response = await postMallApp( + APP_API_PATHS.goodsDetail, + normalizeMallAppPayload(payload), + signal + ) + + return { + ...response, + data: normalizeAppGoodsDetailResponse(response.data), + } +} diff --git a/src/apis/mall-app/home.ts b/src/apis/mall-app/home.ts new file mode 100644 index 0000000..0471631 --- /dev/null +++ b/src/apis/mall-app/home.ts @@ -0,0 +1,67 @@ +import { + APP_API_PATHS, + normalizeAppHomeMerchantGoodsResponse, + normalizeAppHomeResponse, + normalizeAppRecentNewGoodsResponse, + normalizeMallAppPayload, + postMallApp, +} from './shared' +import type { + AppHomeMerchantGoodsRequest, + AppHomeMerchantGoodsResponse, + AppHomeRequest, + AppHomeResponse, + AppHomeStoreGoodsRequest, + AppRecentNewGoodsRequest, + AppRecentNewGoodsResponse, +} from '@/types/mall' + +export const apiAppHome = async (payload: AppHomeRequest = {}, signal?: AbortSignal) => { + const response = await postMallApp( + APP_API_PATHS.home, + normalizeMallAppPayload(payload), + signal + ) + + return { + ...response, + data: normalizeAppHomeResponse(response.data), + } +} + +export const apiAppRecentNewGoods = async ( + payload: AppRecentNewGoodsRequest = {}, + signal?: AbortSignal +) => { + const response = await postMallApp( + APP_API_PATHS.recentNewGoods, + normalizeMallAppPayload(payload), + signal + ) + + return { + ...response, + data: normalizeAppRecentNewGoodsResponse(response.data), + } +} + +export const apiAppHomeMerchantGoods = async ( + payload: AppHomeMerchantGoodsRequest = {}, + signal?: AbortSignal +) => { + const response = await postMallApp( + APP_API_PATHS.homeMerchantGoods, + normalizeMallAppPayload(payload), + signal + ) + + return { + ...response, + data: normalizeAppHomeMerchantGoodsResponse(response.data), + } +} + +export const apiAppHomeStoreGoods = ( + payload: AppHomeStoreGoodsRequest = {}, + signal?: AbortSignal +) => apiAppHomeMerchantGoods(payload, signal) diff --git a/src/apis/mall-app/index.ts b/src/apis/mall-app/index.ts new file mode 100644 index 0000000..a1dd192 --- /dev/null +++ b/src/apis/mall-app/index.ts @@ -0,0 +1,7 @@ +export * from './shared' +export * from './home' +export * from './goods' +export * from './cart' +export * from './trade' +export * from './order' +export * from './afterSale' diff --git a/src/apis/mall-app/normalize.ts b/src/apis/mall-app/normalize.ts new file mode 100644 index 0000000..c40a925 --- /dev/null +++ b/src/apis/mall-app/normalize.ts @@ -0,0 +1,373 @@ +import type { + AppCartListResponse, + AppGoodsDetailResponse, + AppGoodsPageResponse, + AppHomeMerchantGoodsResponse, + AppHomeResponse, + AppRecentNewGoodsResponse, + AppOrderDetailResponse, + AppOrderPageResponse, + CartItemInfo, + GoodsDetailInfo, + GoodsSkuInfo, + GoodsSummary, + AppTradePreviewResponse, + HomeMerchantGoodsBlock, + MerchantId, + MerchantInfo, + OrderDetailInfo, + OrderSummary, + RecommendMerchantInfo, + RecentNewGoodsItem, + TradePreviewInfo, + TradePreviewStoreInfo, +} from '@/types/mall' + +type MaybeMallUidPayload = { + uid?: number + memberId?: number +} + +type MaybeMallMerchantPayload = { + merchantId?: MerchantId | number + storeId?: number | string +} + +type RawRecommendMerchantPayload = Partial & { + id?: RecommendMerchantInfo['id'] + merchantId?: MerchantId | number | string + merchantName?: string + logo?: string + notice?: string + status?: string + rankScore?: number | string + goodsCount?: number | string + recentNewGoodsCount?: number | string + orderCount?: number | string +} + +type RawMerchantEntityPayload = Partial & { + id?: MerchantInfo['id'] | string + merchantId?: MerchantId | number | string + merchantName?: string + logo?: string + notice?: string + status?: string +} + +type RawGoodsSummaryPayload = Partial & { + merchantId?: MerchantId | number | string + createdAt?: GoodsSummary['createdAt'] | string | null + updatedAt?: GoodsSummary['updatedAt'] | string | null + createAt?: number | string | null + updateAt?: number | string | null + created_at?: number | string | null + updated_at?: number | string | null + createTime?: number | string | null + updateTime?: number | string | null + create_time?: number | string | null + update_time?: number | string | null +} + +const toMerchantId = (value: MerchantId | number | string | null | undefined): MerchantId => + value == null ? '' : String(value) + +const toNumber = (value: number | string | null | undefined): number => { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : 0 +} + +const pickText = (...values: Array): string => { + for (const value of values) { + if (typeof value === 'string' && value.trim()) { + return value + } + } + + return '' +} + +const toUnixTime = (value: number | string | null | undefined): number => { + if (typeof value === 'number' && Number.isFinite(value)) { + return value > 1_000_000_000_000 ? Math.floor(value / 1000) : value + } + + if (typeof value !== 'string') { + return 0 + } + + const text = value.trim() + if (!text) { + return 0 + } + + const numeric = Number(text) + if (Number.isFinite(numeric)) { + return numeric > 1_000_000_000_000 ? Math.floor(numeric / 1000) : numeric + } + + const parsed = Date.parse(text.replace(/-/g, '/')) + return Number.isFinite(parsed) ? Math.floor(parsed / 1000) : 0 +} + +export const normalizeMallAppPayload = (payload: TPayload): TPayload => { + const normalized = { ...payload } as TPayload & MaybeMallUidPayload & MaybeMallMerchantPayload + + if (normalized.uid == null && normalized.memberId != null) { + normalized.uid = normalized.memberId + } + delete normalized.memberId + + if (normalized.merchantId != null) { + normalized.merchantId = toMerchantId(normalized.merchantId) + } + if (normalized.merchantId == null && normalized.storeId != null) { + normalized.merchantId = toMerchantId(normalized.storeId) + } + delete normalized.storeId + + return normalized as TPayload +} + +const normalizeGoodsSku = (sku: GoodsSkuInfo): GoodsSkuInfo => ({ + ...sku, + merchantId: toMerchantId(sku.merchantId), +}) + +const normalizeGoodsSummary = (goods: RawGoodsSummaryPayload): GoodsSummary => { + const normalized = goods as GoodsSummary + + return { + ...normalized, + merchantId: toMerchantId(goods.merchantId), + merchantName: pickText(goods.merchantName), + merchantLogo: pickText(goods.merchantLogo), + createdAt: toUnixTime(goods.createdAt ?? goods.createAt ?? goods.created_at ?? goods.createTime ?? goods.create_time), + updatedAt: toUnixTime(goods.updatedAt ?? goods.updateAt ?? goods.updated_at ?? goods.updateTime ?? goods.update_time), + } +} + +const normalizeRecentNewGoodsItem = (goods: RecentNewGoodsItem): RecentNewGoodsItem => ({ + ...goods, + merchantId: goods.merchantId == null ? undefined : toMerchantId(goods.merchantId), + merchantName: pickText(goods.merchantName, goods.storeName), + merchantLogo: goods.merchantLogo?.trim(), + storeName: pickText(goods.storeName, goods.merchantName), + spuId: String(goods.spuId), + createdAt: toUnixTime(goods.createdAt), +}) + +const normalizeGoodsDetailInfo = (detail: GoodsDetailInfo): GoodsDetailInfo => ({ + ...detail, + merchantId: toMerchantId(detail.merchantId), + createdAt: toUnixTime(detail.createdAt), + updatedAt: toUnixTime(detail.updatedAt), + skus: (detail.skus ?? []).map(normalizeGoodsSku), +}) + +const normalizeCartItem = (item: CartItemInfo): CartItemInfo => ({ + ...item, + merchantId: toMerchantId(item.merchantId), +}) + +const normalizeTradePreviewGroup = (group: TradePreviewStoreInfo): TradePreviewStoreInfo => ({ + ...group, + merchantId: toMerchantId(group.merchantId), +}) + +const normalizeOrderSummary = (order: OrderSummary): OrderSummary => ({ + ...order, + merchantId: toMerchantId(order.merchantId), +}) + +const normalizeOrderDetailInfo = (order: OrderDetailInfo): OrderDetailInfo => ({ + ...order, + merchantId: toMerchantId(order.merchantId), +}) + +const normalizeRecommendMerchant = ( + merchant: RawRecommendMerchantPayload | null | undefined +): RecommendMerchantInfo | undefined => { + if (!merchant) { + return undefined + } + + return { + ...(merchant as RecommendMerchantInfo), + merchantId: toMerchantId(merchant.merchantId ?? merchant.id), + storeName: pickText(merchant.storeName, merchant.merchantName), + storeLogo: pickText(merchant.storeLogo, merchant.logo), + storeNotice: pickText(merchant.storeNotice, merchant.notice), + storeStatus: (merchant.storeStatus ?? merchant.status ?? 'enabled') as RecommendMerchantInfo['storeStatus'], + rankScore: toNumber(merchant.rankScore), + goodsCount: toNumber(merchant.goodsCount), + recentNewGoodsCount: toNumber(merchant.recentNewGoodsCount), + orderCount: toNumber(merchant.orderCount), + } +} + +const normalizeMerchantEntity = ( + merchant: RawMerchantEntityPayload | null | undefined +): MerchantInfo | undefined => { + if (!merchant) { + return undefined + } + + return { + ...(merchant as MerchantInfo), + merchantId: toMerchantId(merchant.merchantId ?? merchant.id), + storeName: pickText(merchant.storeName, merchant.merchantName), + storeLogo: pickText(merchant.storeLogo, merchant.logo), + storeNotice: pickText(merchant.storeNotice, merchant.notice), + storeStatus: (merchant.storeStatus ?? merchant.status ?? 'enabled') as MerchantInfo['storeStatus'], + } +} + +const normalizeHomeMerchantGoodsBlock = ( + block: HomeMerchantGoodsBlock | undefined +): HomeMerchantGoodsBlock | undefined => { + if (!block) { + return undefined + } + + const merchant = normalizeRecommendMerchant(block.merchant ?? block.store) + if (!merchant) { + return undefined + } + + return { + ...block, + merchant, + goods: block.goods.map(normalizeGoodsSummary), + store: merchant, + } +} + +export const normalizeAppGoodsPageResponse = ( + payload: AppGoodsPageResponse +): AppGoodsPageResponse => ({ + ...payload, + list: payload.list.map(normalizeGoodsSummary), +}) + +export const normalizeAppRecentNewGoodsResponse = ( + payload: AppRecentNewGoodsResponse +): AppRecentNewGoodsResponse => ({ + ...payload, + total: payload.total ?? 0, + list: (payload.list ?? []).map(normalizeRecentNewGoodsItem), +}) + +export const normalizeAppCartListResponse = ( + payload: AppCartListResponse +): AppCartListResponse => ({ + ...payload, + list: payload.list.map(normalizeCartItem), +}) + +export const normalizeAppOrderPageResponse = ( + payload: AppOrderPageResponse +): AppOrderPageResponse => ({ + ...payload, + list: payload.list.map(normalizeOrderSummary), +}) + +export const normalizeAppOrderDetailResponse = ( + payload: AppOrderDetailResponse +): AppOrderDetailResponse => ({ + ...payload, + data: normalizeOrderDetailInfo(payload.data), +}) + +export const normalizeAppHomeResponse = (payload: AppHomeResponse): AppHomeResponse => { + const recommendMerchants = (payload.recommendMerchants ?? payload.recommendStores ?? []) + .map(normalizeRecommendMerchant) + .filter(Boolean) as RecommendMerchantInfo[] + const featuredMerchants = (payload.featuredMerchants ?? payload.featuredStores ?? []) + .map(normalizeRecommendMerchant) + .filter(Boolean) as RecommendMerchantInfo[] + + return { + ...payload, + recommendGoods: payload.recommendGoods.map(normalizeGoodsSummary), + recommendMerchants, + featuredMerchants, + recommendStores: recommendMerchants, + featuredStores: featuredMerchants, + } +} + +export const normalizeAppHomeMerchantGoodsResponse = ( + payload: AppHomeMerchantGoodsResponse +): AppHomeMerchantGoodsResponse => ({ + ...payload, + data: + normalizeHomeMerchantGoodsBlock(payload.data) ?? + ({ + merchant: { + id: 0, + merchantId: '0', + storeName: '', + storeLogo: '', + storeNotice: '', + storeStatus: 'disabled', + mainCategoryName: '', + recommendReason: '', + rankScore: 0, + goodsCount: 0, + recentNewGoodsCount: 0, + orderCount: 0, + }, + store: { + id: 0, + merchantId: '0', + storeName: '', + storeLogo: '', + storeNotice: '', + storeStatus: 'disabled', + mainCategoryName: '', + recommendReason: '', + rankScore: 0, + goodsCount: 0, + recentNewGoodsCount: 0, + orderCount: 0, + }, + goods: [], + } satisfies HomeMerchantGoodsBlock), +}) + +export const normalizeAppGoodsDetailResponse = ( + payload: AppGoodsDetailResponse +): AppGoodsDetailResponse => { + const merchant = normalizeMerchantEntity(payload.merchant ?? payload.store) + + return { + ...payload, + data: normalizeGoodsDetailInfo(payload.data), + merchant: merchant ?? payload.store!, + store: merchant ?? payload.store, + } +} + +export const normalizeAppTradePreviewResponse = ( + payload: AppTradePreviewResponse +): AppTradePreviewResponse => { + const data = payload.data as TradePreviewInfo & { + uid?: number + memberId?: number + merchantGroups?: TradePreviewInfo['merchantGroups'] + storeGroups?: TradePreviewInfo['merchantGroups'] + } + const merchantGroups = data.merchantGroups ?? data.storeGroups ?? [] + + return { + ...payload, + data: { + ...data, + uid: data.uid ?? data.memberId ?? 0, + memberId: data.memberId ?? data.uid, + merchantGroups: merchantGroups.map(normalizeTradePreviewGroup), + storeGroups: merchantGroups.map(normalizeTradePreviewGroup), + }, + } +} diff --git a/src/apis/mall-app/order.ts b/src/apis/mall-app/order.ts new file mode 100644 index 0000000..e6f5313 --- /dev/null +++ b/src/apis/mall-app/order.ts @@ -0,0 +1,48 @@ +import { + APP_API_PATHS, + normalizeAppOrderDetailResponse, + normalizeAppOrderPageResponse, + normalizeMallAppPayload, + postMallApp, +} from './shared' +import type { + AppOrderConfirmRequest, + AppOrderConfirmResponse, + AppOrderDetailRequest, + AppOrderDetailResponse, + AppOrderPageRequest, + AppOrderPageResponse, +} from '@/types/mall' + +export const apiAppOrderPage = async (payload: AppOrderPageRequest, signal?: AbortSignal) => { + const response = await postMallApp( + APP_API_PATHS.orderPage, + normalizeMallAppPayload(payload), + signal + ) + + return { + ...response, + data: normalizeAppOrderPageResponse(response.data), + } +} + +export const apiAppOrderDetail = async (payload: AppOrderDetailRequest, signal?: AbortSignal) => { + const response = await postMallApp( + APP_API_PATHS.orderDetail, + normalizeMallAppPayload(payload), + signal + ) + + return { + ...response, + data: normalizeAppOrderDetailResponse(response.data), + } +} + +export const apiAppOrderConfirm = (payload: AppOrderConfirmRequest, signal?: AbortSignal) => + postMallApp( + APP_API_PATHS.orderConfirm, + normalizeMallAppPayload(payload), + signal + ) diff --git a/src/apis/mall-app/shared.ts b/src/apis/mall-app/shared.ts new file mode 100644 index 0000000..3467ce6 --- /dev/null +++ b/src/apis/mall-app/shared.ts @@ -0,0 +1,34 @@ +import axiosInstance from '@/apis/base/axiosInstance' +import type { IBaseResponse } from '@/apis/base/types' + +export const APP_API_PATHS = { + home: '/api/v1/mall/app/home', + recentNewGoods: '/api/v1/mall/app/recent_new_goods', + homeMerchantGoods: '/api/v1/mall/app/home_merchant_goods', + homeStoreGoods: '/api/v1/mall/app/home_merchant_goods', + goodsPage: '/api/v1/mall/app/goods_page', + goodsDetail: '/api/v1/mall/app/goods_detail', + cartList: '/api/v1/mall/app/cart_list', + cartSave: '/api/v1/mall/app/cart_save', + cartRemove: '/api/v1/mall/app/cart_remove', + tradePreview: '/api/v1/mall/app/trade_preview', + tradeCreate: '/api/v1/mall/app/trade_create', + orderPage: '/api/v1/mall/app/order_page', + orderDetail: '/api/v1/mall/app/order_detail', + orderConfirm: '/api/v1/mall/app/order_confirm', + afterSaleApply: '/api/v1/mall/app/after_sale_apply', +} as const + +export const postMallApp = async ( + path: string, + payload: TPayload, + signal?: AbortSignal +): Promise> => { + const response = await axiosInstance.post>(path, payload, { + signal, + }) + + return response.data +} + +export * from './normalize' diff --git a/src/apis/mall-app/trade.ts b/src/apis/mall-app/trade.ts new file mode 100644 index 0000000..22e861d --- /dev/null +++ b/src/apis/mall-app/trade.ts @@ -0,0 +1,32 @@ +import { + APP_API_PATHS, + normalizeAppTradePreviewResponse, + normalizeMallAppPayload, + postMallApp, +} from './shared' +import type { + AppTradeCreateRequest, + AppTradeCreateResponse, + AppTradePreviewRequest, + AppTradePreviewResponse, +} from '@/types/mall' + +export const apiAppTradePreview = async (payload: AppTradePreviewRequest, signal?: AbortSignal) => { + const response = await postMallApp( + APP_API_PATHS.tradePreview, + normalizeMallAppPayload(payload), + signal + ) + + return { + ...response, + data: normalizeAppTradePreviewResponse(response.data), + } +} + +export const apiAppTradeCreate = (payload: AppTradeCreateRequest, signal?: AbortSignal) => + postMallApp( + APP_API_PATHS.tradeCreate, + normalizeMallAppPayload(payload), + signal + ) diff --git a/src/components/MarketNav.tsx b/src/components/MarketNav.tsx new file mode 100644 index 0000000..cbdfb3b --- /dev/null +++ b/src/components/MarketNav.tsx @@ -0,0 +1,34 @@ +export type MarketNavKey = 'home' | 'rank' + +export const MARKET_NAV_ITEMS: Array<{ key: MarketNavKey; label: string }> = [ + { key: 'home', label: '首页' }, + { key: 'rank', label: '商家排行' }, +] + +interface MarketNavProps { + activeKey?: MarketNavKey + onSelect: (key: MarketNavKey) => void +} + +function MarketNav({ activeKey, onSelect }: MarketNavProps) { + return ( +
+
+
+ {MARKET_NAV_ITEMS.map(item => ( + + ))} +
+
+
+ ) +} + +export default MarketNav diff --git a/src/components/MarketPageHeader.tsx b/src/components/MarketPageHeader.tsx new file mode 100644 index 0000000..8645fca --- /dev/null +++ b/src/components/MarketPageHeader.tsx @@ -0,0 +1,173 @@ +import { Link, useNavigate } from 'react-router-dom' +import { toast } from 'sonner' +import MarketNav, { type MarketNavKey } from '@/components/MarketNav' +import { useUserStore } from '@/store/user' + +interface MarketPageHeaderProps { + activeNav?: MarketNavKey + hotKeywords?: string[] + keyword: string + onBrandClick?: () => void + onKeywordChange: (value: string) => void + onNavSelect?: (key: MarketNavKey) => void + onSearch?: (keyword: string) => void + searchInputId?: string + searchLoading?: boolean +} + +const getSearchPath = (keyword: string) => { + const trimmedKeyword = keyword.trim() + return trimmedKeyword ? `/list/new?key=${encodeURIComponent(trimmedKeyword)}` : '/list/new' +} + +function MarketPageHeader({ + activeNav, + hotKeywords = [], + keyword, + onBrandClick, + onKeywordChange, + onNavSelect, + onSearch, + searchInputId = 'market-search-input', + searchLoading = false, +}: MarketPageHeaderProps) { + const navigate = useNavigate() + const token = useUserStore(state => state.token) + const userInfo = useUserStore(state => state.userInfo) + const storedMemberId = useUserStore(state => state.memberId) + const logout = useUserStore(state => state.logout) + + const userLabel = userInfo?.name?.trim() || userInfo?.account?.trim() || token?.account?.trim() || String(storedMemberId || '') + + const handleSearch = (nextKeyword: string) => { + if (onSearch) { + onSearch(nextKeyword) + return + } + + navigate(getSearchPath(nextKeyword)) + } + + const handleNavSelect = (key: MarketNavKey) => { + if (onNavSelect) { + onNavSelect(key) + return + } + + navigate(key === 'rank' ? '/?nav=rank' : '/') + } + + return ( + <> +
+
+
+ {token ? `您好,${userLabel || '采购伙伴'}` : '您好!'} + {token ? ( + + ) : ( + <> + 请登录 + 注册 + + )} +
+
+
+ +
+
+ + +
{ + event.preventDefault() + handleSearch(keyword) + }} + > +
+
+
+ 商品 +
+ + + +
+ onKeywordChange(event.target.value)} + placeholder="商品标题 / 关键词 / 货号" + /> + + +
+
+
+ + {hotKeywords.length > 0 ? ( +
+ {hotKeywords.map(word => ( + + ))} +
+ ) : null} +
+ +
+ + +
+
+
+ + + + ) +} + +export default MarketPageHeader diff --git a/src/config/source/config.ts b/src/config/source/config.ts new file mode 100644 index 0000000..f0530b7 --- /dev/null +++ b/src/config/source/config.ts @@ -0,0 +1,79 @@ +import packageJson from '../../../package.json' + +export interface GlobalConfig { + VERSION: string + BASE_URL: string + RESOURCE_ASSETS_URL: string +} + +const DEFAULT_VERSION = packageJson.version ?? '0.1.0' +const DEFAULT_BASE_URL = 'https://test-gateway.yuntuo.hk' + +export const getGlobalConfig = (): GlobalConfig => { + if (typeof window === 'undefined') { + return { + VERSION: DEFAULT_VERSION, + BASE_URL: import.meta.env.VITE_API_BASE_URL || DEFAULT_BASE_URL, + RESOURCE_ASSETS_URL: '', + } + } + + const windowConfig = (window as Window & { config?: GlobalConfig }).config + return ( + windowConfig || { + VERSION: DEFAULT_VERSION, + BASE_URL: import.meta.env.VITE_API_BASE_URL || DEFAULT_BASE_URL, + RESOURCE_ASSETS_URL: '', + } + ) +} + +export const getBaseUrl = (): string => { + if (typeof window !== 'undefined') { + const config = (window as Window & { config?: GlobalConfig }).config + const fromConfig = config?.BASE_URL?.trim() + if (fromConfig) { + return fromConfig + } + + const primaryMeta = document.querySelector('meta[name="api-base-url"]') as + | HTMLMetaElement + | null + const altMeta = document.querySelector('meta[name="yt-api-base-url"]') as + | HTMLMetaElement + | null + + if (primaryMeta?.content?.trim()) { + return primaryMeta.content.trim() + } + + if (altMeta?.content?.trim()) { + return altMeta.content.trim() + } + } + + return import.meta.env.VITE_API_BASE_URL || DEFAULT_BASE_URL +} + +export const getVersion = (): string => DEFAULT_VERSION + +export const getResourceAssetsUrl = (): string => { + const value = getGlobalConfig().RESOURCE_ASSETS_URL + return typeof value === 'string' ? value.trim() : '' +} + +export const getResourceUrl = (path: string): string => { + const baseUrl = getResourceAssetsUrl() + if (!baseUrl) { + return path + } + + const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl + const cleanPath = path.startsWith('/') ? path.slice(1) : path + + if (cleanBaseUrl.includes('/dist/assets')) { + return `${cleanBaseUrl}/${cleanPath.replace(/^assets\//, '')}` + } + + return `${cleanBaseUrl}/${cleanPath}` +} diff --git a/src/constants/mall.ts b/src/constants/mall.ts new file mode 100644 index 0000000..38fd80d --- /dev/null +++ b/src/constants/mall.ts @@ -0,0 +1,47 @@ +import type { AfterSaleStatus, AfterSaleType, GoodsSortType, OrderStatus, SaleStatus } from '@/types/mall' + +export const GOODS_SORT_OPTIONS: Array<{ label: string; value: GoodsSortType }> = [ + { label: 'Comprehensive', value: 'comprehensive' }, + { label: 'Price low to high', value: 'price_asc' }, + { label: 'Price high to low', value: 'price_desc' }, + { label: 'Sales high to low', value: 'sales_desc' }, +] + +export const SALE_STATUS_LABELS: Record = { + off: 'Off shelf', + on: 'On shelf', +} + +export const ORDER_STATUS_LABELS: Record = { + pending_pay: 'Pending payment', + paid_wait_ship: 'Pending shipment', + shipped: 'Shipped', + finished: 'Finished', + canceled: 'Canceled', + after_sale: 'After sale', + refund_finished: 'Refund finished', +} + +export const AFTER_SALE_STATUS_LABELS: Record = { + none: 'No after sale', + pending: 'Pending review', + approved: 'Approved', + rejected: 'Rejected', + refunding: 'Refunding', + finished: 'Finished', + closed: 'Closed', +} + +export const AFTER_SALE_TYPE_LABELS: Record = { + 1: 'Refund only', + 2: 'Return and refund', +} + +export const APP_ORDER_PRIMARY_ACTIONS: Partial> = { + pending_pay: 'Pending payment', + paid_wait_ship: 'Pending shipment', + shipped: 'Confirm receipt', + finished: 'Apply after sale', + after_sale: 'After sale in progress', +} + diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..0326ad9 --- /dev/null +++ b/src/index.css @@ -0,0 +1,3614 @@ +:root { + --market-bg: #f5f7fa; + --market-surface: rgba(255, 255, 255, 0.98); + --market-ink: #0e1513; + --market-muted: rgba(14, 21, 19, 0.65); + --market-line: rgba(5, 5, 5, 0.06); + --market-brand: #156f4e; + --market-brand-soft: #349172; + --market-brand-strong: #054e38; + --market-brand-bg: #e5f2ee; + --market-brand-bg-hover: #cde7de; + --market-brand-border: #86c2ae; + --market-brand-border-hover: #61ae94; + --market-radius-xs: 4px; + --market-radius-sm: 6px; + --market-radius: 8px; + --market-radius-lg: 12px; + --market-radius-xl: 16px; + --market-shadow: 0 6px 18px rgba(15, 23, 42, 0.06); + font-family: + "PingFang SC", + "Hiragino Sans GB", + "Microsoft YaHei", + sans-serif; + color: var(--market-ink); + background: + radial-gradient(circle at top left, rgba(21, 111, 78, 0.08), transparent 26%), + radial-gradient(circle at top right, rgba(21, 111, 78, 0.04), transparent 22%), + linear-gradient(180deg, #fbfcfd 0%, #f5f7fa 100%); + line-height: 1.5715; + font-weight: 400; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + min-width: 320px; + font-size: 14px; + color: var(--market-ink); + background: var(--market-bg); +} + +body, +button, +input { + font: inherit; +} + +img { + display: block; + max-width: 100%; +} + +a { + color: inherit; + text-decoration: none; +} + +button { + border: 0; + background: none; + color: inherit; + cursor: pointer; + transition: + color 0.2s ease, + border-color 0.2s ease, + background-color 0.2s ease, + box-shadow 0.2s ease; +} + +input { + outline: none; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.6; + box-shadow: none; +} + +input::placeholder { + color: rgba(14, 21, 19, 0.35); +} + +#root { + min-height: 100vh; +} + +.shell { + width: min(1320px, calc(100% - 32px)); + margin: 0 auto; +} + +.market-app { + min-height: 100vh; +} + +.market-kicker { + margin: 0; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--market-brand); +} + +.market-link-button { + padding: 0; + color: inherit; +} + +.market-topbar { + background: #20242d; + color: rgba(255, 255, 255, 0.72); + font-size: 0.86rem; +} + +.market-topbar__inner, +.market-topbar__left { + display: flex; + align-items: center; +} + +.market-topbar__inner { + justify-content: space-between; + min-height: 44px; + gap: 16px; +} + +.market-topbar__left { + gap: 18px; + flex-wrap: wrap; +} + +.market-topbar__left a, +.market-topbar__left button { + color: #fff; +} + +.market-header { + background: rgba(255, 255, 255, 0.96); + border-bottom: 1px solid var(--market-line); + backdrop-filter: blur(12px); + position: sticky; + top: 0; + z-index: 10; +} + +.market-header__inner { + display: grid; + grid-template-columns: 220px minmax(0, 1fr) auto; + align-items: center; + gap: 24px; + min-height: 88px; +} + +.market-brand { + display: inline-flex; + align-items: center; + gap: 14px; + justify-self: start; + text-align: left; +} + +.market-brand strong { + display: block; + font-size: 1.12rem; +} + +.market-brand small { + display: block; + font-size: 0.86rem; + color: var(--market-muted); +} + +.market-brand__mark { + display: grid; + place-items: center; + width: 48px; + height: 48px; + border-radius: var(--market-radius-lg); + background: linear-gradient(135deg, var(--market-brand), var(--market-brand-soft)); + box-shadow: 0 10px 24px rgba(21, 111, 78, 0.18); + color: #fff; + font-size: 1rem; + font-weight: 800; +} + +.market-search { + display: grid; + gap: 8px; +} + +.market-search__bar { + display: block; +} + +.market-search__core { + position: relative; + display: grid; + grid-template-columns: auto auto minmax(0, 1fr); + align-items: center; + border: 2px solid var(--market-brand); + border-radius: 10px; + background: #fff; + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04); +} + +.market-search__picker-wrap { + position: relative; + min-width: 106px; +} + +.market-search__picker-trigger { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 44px; + padding: 0 16px; + color: var(--market-brand); + font-size: 0.98rem; + font-weight: 500; +} + +.market-search__picker-caret { + display: inline-block; + color: #8b98ae; + transition: transform 0.18s ease; +} + +.market-search__picker-caret.is-open { + transform: rotate(180deg); +} + +.market-search__picker-menu { + position: absolute; + left: -3px; + top: calc(100% + 8px); + z-index: 30; + display: grid; + min-width: 124px; + overflow: hidden; + border: 1px solid var(--market-brand-border); + border-radius: 10px; + background: #fff; + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12); +} + +.market-search__picker-menu button { + min-height: 44px; + padding: 0 16px; + text-align: left; + color: var(--market-ink); + font-size: 0.94rem; + font-weight: 500; + background: #fff; + transition: 0.18s ease; +} + +.market-search__picker-menu button + button { + border-top: 1px solid rgba(26, 38, 62, 0.08); +} + +.market-search__picker-menu button.is-active, +.market-search__picker-menu button:hover { + color: var(--market-brand); + background: var(--market-brand-bg); +} + +.market-search__divider { + width: 1px; + height: 20px; + background: rgba(26, 38, 62, 0.14); +} + +.market-search__field { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 0; + padding: 0; +} + +.market-search__field input { + width: 100%; + min-width: 0; + border: 0; + min-height: 44px; + padding: 0 16px; + font-size: 0.94rem; + background: transparent; +} + +.market-search__camera, +.market-ghost-action, +.market-primary-action { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--market-radius); + min-height: 40px; + padding: 0 15px; + font-size: 0.94rem; + font-weight: 500; + transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, color 0.2s ease; +} + +.market-search__camera, +.market-ghost-action { + border: 1px solid var(--market-brand-border); + background: #fff; + color: var(--market-brand); +} + +.market-primary-action, +.market-search__submit { + border: 1px solid var(--market-brand); + background: var(--market-brand); + color: #fff; + box-shadow: none; +} + +.market-search__submit { + display: inline-flex; + align-items: center; + gap: 8px; + border-radius: 0 var(--market-radius) var(--market-radius) 0; + min-height: 44px; + padding: 0 20px; + box-shadow: none; +} + +.market-search__camera:hover, +.market-ghost-action:hover, +.market-primary-action:hover, +.market-search__submit:hover { + box-shadow: none; +} + +.market-search__camera:hover, +.market-ghost-action:hover { + color: var(--market-brand-soft); + border-color: var(--market-brand-border-hover); + background: var(--market-brand-bg); +} + +.market-primary-action:hover, +.market-search__submit:hover { + border-color: var(--market-brand-soft); + background: var(--market-brand-soft); +} + +.market-primary-action:active, +.market-search__submit:active { + border-color: var(--market-brand-strong); + background: var(--market-brand-strong); +} + +.market-search__camera { + width: 44px; + min-width: 44px; + min-height: 44px; + border: 0; + border-left: 1px solid rgba(26, 38, 62, 0.08); + border-radius: 0; + background: transparent; +} + +.market-search__icon { + position: relative; + display: inline-block; + flex: 0 0 auto; +} + +.market-search__icon--search { + width: 18px; + height: 18px; + border: 2px solid currentColor; + border-radius: 50%; +} + +.market-search__icon--search::after { + content: ""; + position: absolute; + right: -4px; + bottom: -4px; + width: 8px; + height: 2px; + border-radius: 999px; + background: currentColor; + transform: rotate(45deg); + transform-origin: center; +} + +.market-search__icon--camera { + width: 18px; + height: 14px; + border: 2px solid currentColor; + border-radius: 4px; +} + +.market-search__icon--camera::before { + content: ""; + position: absolute; + left: 5px; + top: 3px; + width: 4px; + height: 4px; + border-radius: 50%; + background: currentColor; +} + +.market-search__icon--camera::after { + content: ""; + position: absolute; + left: 3px; + top: -5px; + width: 7px; + height: 4px; + border-radius: 4px 4px 0 0; + background: currentColor; +} + +.market-search__hotwords { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.market-search__hotwords button, +.market-meta-pills span { + padding: 4px 10px; + border-radius: 999px; + background: var(--market-brand-bg); + color: var(--market-brand); + font-size: 0.8rem; +} + +.market-search__hotwords button:hover { + background: var(--market-brand-bg-hover); +} + +.market-header__actions { + display: inline-flex; + align-items: center; + gap: 10px; +} + +.market-nav { + background: rgba(255, 255, 255, 0.9); + border-bottom: 1px solid var(--market-line); +} + +.market-nav__inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 52px; +} + +.market-nav__links { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.market-nav__links button { + border-radius: var(--market-radius); + padding: 6px 12px; + font-size: 0.94rem; + font-weight: 500; + color: var(--market-ink); +} + +.market-nav__links button:hover { + color: var(--market-brand); + background: var(--market-brand-bg); +} + +.market-nav__links button.is-active { + color: var(--market-brand); + font-weight: 700; + background: transparent; + box-shadow: inset 0 -2px 0 var(--market-brand); +} + +.market-source-chip { + border-radius: 999px; + padding: 6px 12px; + font-size: 0.82rem; + font-weight: 600; +} + +.market-source-chip--api { + background: var(--market-brand-bg); + color: var(--market-brand); +} + +.market-source-chip--empty { + background: rgba(250, 173, 20, 0.12); + color: #d48806; +} + +.market-source-chip--error { + background: rgba(239, 72, 94, 0.12); + color: #cf1322; +} + +.market-main { + padding: 16px 0 48px; +} + +.market-stage { + display: grid; + grid-template-columns: 270px minmax(0, 1fr) 300px; + gap: 18px; + align-items: start; +} + +.market-recent-panel, +.market-sort-panel, +.market-gallery-panel, +.market-rank-panel, +.market-section, +.login-showcase, +.login-card { + background: var(--market-surface); + border: 1px solid var(--market-line); + border-radius: var(--market-radius-xl); + box-shadow: var(--market-shadow); + backdrop-filter: blur(12px); +} + +.market-recent-panel, +.market-sort-panel, +.market-rank-panel { + min-height: 480px; + padding: 14px; +} + +.market-recent-panel { + display: flex; + flex-direction: column; + height: 480px; + min-height: 480px; + overflow: hidden; + background: #fff; +} + +.market-sort-panel { + position: relative; + overflow: visible; +} + +.market-recent-panel__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 0 2px 10px; +} + +.market-recent-panel__head strong { + display: block; + color: #ef4444; + font-size: 0.92rem; + font-weight: 800; + line-height: 1.25; +} + +.market-recent-panel__head span { + display: block; + margin-top: 4px; + color: var(--market-muted); + font-size: 0.78rem; +} + +.market-recent-panel__refresh { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 24px; + padding: 0 2px; + color: #ef4444; + font-size: 0.78rem; + font-weight: 600; + white-space: nowrap; +} + +.market-recent-panel__refresh:hover { + color: #dc2626; +} + +.market-refresh-icon { + position: relative; + display: inline-block; + width: 15px; + height: 15px; + border: 2px solid currentColor; + border-left-color: transparent; + border-radius: 50%; +} + +.market-refresh-icon::after { + content: ""; + position: absolute; + top: -3px; + right: -3px; + width: 6px; + height: 6px; + border-top: 2px solid currentColor; + border-right: 2px solid currentColor; + transform: rotate(35deg); +} + +.market-recent-list { + flex: 1; + min-height: 0; + max-height: 420px; + overflow-y: auto; + padding: 0 4px 2px 0; + scrollbar-width: thin; + scrollbar-color: rgba(148, 163, 184, 0.45) transparent; +} + +.market-recent-list::-webkit-scrollbar { + width: 6px; +} + +.market-recent-list::-webkit-scrollbar-thumb { + border-radius: 999px; + background: rgba(148, 163, 184, 0.45); +} + +.market-recent-item { + width: 100%; + display: grid; + grid-template-columns: 56px minmax(0, 1fr); + gap: 10px; + align-items: start; + padding: 10px 0; + border-bottom: 1px solid rgba(15, 23, 42, 0.1); + text-align: left; +} + +.market-recent-item:first-child { + padding-top: 8px; +} + +.market-recent-item:last-child { + border-bottom: 0; +} + +.market-recent-item:hover strong { + color: #ef4444; +} + +.market-recent-item img, +.market-recent-item__placeholder { + width: 56px; + height: 56px; + border-radius: var(--market-radius-sm); + object-fit: cover; +} + +.market-recent-item__placeholder { + display: grid; + place-items: center; + color: var(--market-muted); + background: rgba(248, 250, 252, 0.96); + border: 1px dashed rgba(5, 5, 5, 0.1); + font-size: 0.7rem; +} + +.market-recent-item__body { + display: grid; + gap: 4px; + min-width: 0; +} + +.market-recent-item__store { + display: -webkit-box; + overflow: hidden; + color: #20242d; + font-size: 0.86rem; + font-weight: 800; + line-height: 1.28; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; +} + +.market-recent-item__body strong { + display: -webkit-box; + overflow: hidden; + color: #3f434a; + font-size: 0.82rem; + font-weight: 500; + line-height: 1.32; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.market-recent-item__body small { + color: #8f959e; + font-size: 0.78rem; +} + +.market-empty-state--recent { + min-height: 390px; +} + +.market-sort-panel__head, +.market-rank-panel__head, +.market-section__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; +} + +.market-sort-panel__head strong, +.market-rank-panel__head strong, +.market-section__head h2 { + margin: 0; + font-size: 1rem; +} + +.market-sort-panel__head span, +.market-rank-panel__head span, +.market-section__meta, +.market-inline-note { + color: var(--market-muted); + font-size: 0.86rem; +} + +.market-sort-panel__head { + padding: 4px 4px 14px; + border-bottom: 1px solid rgba(226, 232, 240, 0.85); +} + +.market-sort-list { + display: grid; + gap: 0; + margin-top: 12px; +} + +.market-sort-list button { + display: grid; + grid-template-columns: 26px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 62px; + padding: 10px 8px; + border-radius: 0; + text-align: left; + background: transparent; + border: 0; + border-bottom: 1px solid rgba(226, 232, 240, 0.7); + transition: 0.18s ease; +} + +.market-sort-list button:hover { + background: rgba(21, 111, 78, 0.05); +} + +.market-sort-list button.is-active { + background: linear-gradient(90deg, rgba(21, 111, 78, 0.08), rgba(21, 111, 78, 0.02)); + box-shadow: inset 3px 0 0 var(--market-brand); +} + +.market-sort-list button:last-child { + border-bottom: 0; +} + +.market-sort-list small { + color: var(--market-muted); + font-size: 0.82rem; +} + +.market-sort-item__icon { + display: grid; + place-items: center; + width: 26px; + height: 26px; + border-radius: 0; + background: transparent; + color: #1f2937; +} + +.market-sort-item__icon svg { + width: 18px; + height: 18px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.7; +} + +.market-sort-item__body { + display: grid; + gap: 3px; + min-width: 0; +} + +.market-sort-item__body strong { + font-size: 0.98rem; + color: #111827; +} + +.market-sort-item__arrow { + color: rgba(148, 163, 184, 0.95); + font-size: 1.15rem; + line-height: 1; +} + +.market-category-mega { + position: absolute; + top: 12px; + left: calc(100% - 4px); + z-index: 18; + width: min(920px, calc(100vw - 380px)); + min-height: 420px; + padding: 22px 24px; + border-radius: 24px; + border: 1px solid rgba(15, 23, 42, 0.08); + background: rgba(255, 255, 255, 0.98); + box-shadow: 0 26px 54px rgba(15, 23, 42, 0.16); + opacity: 0; + visibility: hidden; + pointer-events: none; + transform: translateY(10px); + transition: 0.22s ease; +} + +.market-sort-panel.is-open .market-category-mega { + opacity: 1; + visibility: visible; + pointer-events: auto; + transform: translateY(0); +} + +.market-category-mega__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding-bottom: 16px; + border-bottom: 1px solid rgba(226, 232, 240, 0.9); +} + +.market-category-mega__head strong { + display: block; + margin: 0; + font-size: 1.18rem; + color: #0f172a; +} + +.market-category-mega__head span { + display: block; + margin-top: 6px; + color: var(--market-muted); + font-size: 0.88rem; +} + +.market-category-mega__action { + min-height: 34px; + padding: 0 14px; + border-radius: 999px; + border: 1px solid var(--market-brand-border); + background: var(--market-brand-bg); + color: var(--market-brand); + font-size: 0.84rem; + font-weight: 600; + white-space: nowrap; +} + +.market-category-mega__rows { + display: grid; + gap: 16px; + margin-top: 18px; +} + +.market-category-mega__row { + display: grid; + grid-template-columns: 112px minmax(0, 1fr); + gap: 18px; + align-items: start; +} + +.market-category-mega__row strong { + padding-top: 4px; + font-size: 1rem; + color: #111827; +} + +.market-category-mega__chips { + display: flex; + flex-wrap: wrap; + gap: 12px 18px; +} + +.market-category-mega__chips button { + padding: 0; + border: 0; + background: transparent; + color: #4b5563; + font-size: 0.96rem; + line-height: 1.7; +} + +.market-category-mega__chips button:hover { + color: var(--market-brand); +} + +.market-gallery-panel { + display: grid; + gap: 14px; + background: transparent; + border: 0; + box-shadow: none; +} + +.market-gallery-card { + position: relative; + min-height: 482px; + overflow: hidden; + border-radius: var(--market-radius-xl); + border: 1px solid rgba(26, 38, 62, 0.06); + box-shadow: var(--market-shadow); +} + +.market-gallery-card img { + width: 100%; + height: 482px; + object-fit: cover; +} + +.market-gallery-card__overlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + justify-content: flex-end; + padding: 24px; + background: + linear-gradient(180deg, rgba(17, 24, 39, 0.08), rgba(17, 24, 39, 0.62)), + linear-gradient(90deg, rgba(20, 32, 51, 0.54), rgba(20, 32, 51, 0.12)); + color: #fff; +} + +.market-gallery-card__overlay h1 { + margin: 10px 0; + font-size: clamp(1.6rem, 2.5vw, 2.3rem); + line-height: 1.14; +} + +.market-gallery-card__overlay p:last-of-type { + max-width: 560px; + margin: 0; + color: rgba(255, 255, 255, 0.84); +} + +.market-gallery-card__actions { + display: flex; + align-items: center; + gap: 12px; + margin-top: 18px; + flex-wrap: wrap; +} + +.market-gallery-strip { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.market-gallery-strip > .market-empty-state { + grid-column: 1 / -1; +} + +.market-spot-card, +.market-platform-card, +.market-store-card { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(246, 249, 255, 0.94)); + border: 1px solid rgba(26, 38, 62, 0.06); + box-shadow: 0 14px 34px rgba(17, 28, 45, 0.05); +} + +.market-spot-card { + display: grid; + grid-template-columns: 88px minmax(0, 1fr); + gap: 12px; + align-items: center; + padding: 12px; + border-radius: 18px; +} + +.market-spot-card img { + width: 88px; + height: 88px; + border-radius: 16px; + object-fit: cover; +} + +.market-spot-card span, +.market-product-card__platform { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.76rem; + font-weight: 700; + color: var(--market-brand-strong); +} + +.market-spot-card strong, +.market-ranking-item strong, +.market-store-card strong, +.market-product-card strong, +.login-showcase h1, +.login-card__head h2 { + font-size: 0.98rem; +} + +.market-spot-card p, +.market-ranking-item p, +.market-store-card p, +.market-product-card p, +.login-showcase p, +.market-platform-card p { + margin: 4px 0 0; + color: var(--market-muted); +} + +.market-rank-panel { + display: flex; + flex-direction: column; + gap: 14px; + padding: 18px 16px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.96)), + linear-gradient(180deg, rgba(21, 111, 78, 0.03), transparent 30%); +} + +.market-rank-panel__head button { + color: var(--market-brand); + font-weight: 500; +} + +.market-rank-panel__head { + align-items: flex-start; + gap: 14px; +} + +.market-rank-panel__head strong { + font-size: 1.12rem; + line-height: 1.3; +} + +.market-rank-panel__head span { + margin-top: 6px; + max-width: 190px; + line-height: 1.55; +} + +.market-ranking-list { + display: grid; + gap: 8px; +} + +.market-ranking-item { + display: grid; + align-items: center; + gap: 10px; + padding: 4px 0; + background: transparent; + border: 0; + box-shadow: none; + color: inherit; + text-decoration: none; + cursor: pointer; +} + +.market-ranking-item.is-podium { + grid-template-columns: 28px 42px minmax(0, 1fr); +} + +.market-ranking-item.is-compact { + grid-template-columns: 28px minmax(0, 1fr); + min-height: 34px; +} + +.market-rank-no { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border-radius: 10px; + color: #fff; + font-size: 0.88rem; + font-weight: 800; + line-height: 1; +} + +.market-rank-no.is-order { + background: #ef4444; +} + +.market-rank-no.is-top-1 { + background: linear-gradient(180deg, #f7c948, #e9a11d); +} + +.market-rank-no.is-top-2 { + background: linear-gradient(180deg, #d5dde8, #97a4b2); +} + +.market-rank-no.is-top-3 { + background: linear-gradient(180deg, #e6a66a, #c8793a); +} + +.market-ranking-item img { + width: 42px; + height: 42px; + border-radius: 50%; + object-fit: cover; +} + +.market-ranking-item__avatar-fallback { + display: grid; + place-items: center; + width: 42px; + height: 42px; + border-radius: 50%; + background: + radial-gradient(circle at top left, rgba(52, 145, 114, 0.26), transparent 45%), + linear-gradient(135deg, rgba(21, 111, 78, 0.12), rgba(21, 111, 78, 0.04)); + color: var(--market-brand-strong); + font-size: 0.82rem; + font-weight: 800; + letter-spacing: 0.02em; + border: 1px solid rgba(21, 111, 78, 0.1); +} + +.market-ranking-item__content { + min-width: 0; + padding-top: 1px; +} + +.market-ranking-item__content strong { + display: block; + overflow: hidden; + font-size: 0.92rem; + line-height: 1.45; + color: #111827; + white-space: nowrap; + text-overflow: ellipsis; +} + +.market-inline-note { + margin: 8px 0 0; +} + +.market-rank-page { + display: grid; + gap: 12px; +} + +.market-rank-filter { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; + min-height: 54px; + padding: 10px 12px; + border-radius: var(--market-radius); + background: #fff; +} + +.market-rank-filter label { + display: inline-flex; + align-items: center; + gap: 8px; + color: #111827; + font-weight: 600; +} + +.market-rank-filter input, +.market-rank-filter select { + height: 34px; + min-width: 172px; + border: 1px solid rgba(5, 5, 5, 0.12); + border-radius: var(--market-radius-sm); + background: #fff; + padding: 0 12px; + color: #111827; + font-weight: 400; +} + +.market-rank-filter select { + min-width: 142px; +} + +.market-rank-filter input:focus, +.market-rank-filter select:focus { + outline: none; + border-color: var(--market-brand-border-hover); + box-shadow: 0 0 0 2px rgba(21, 111, 78, 0.1); +} + +.market-rank-filter__submit, +.market-rank-filter__reset { + height: 34px; + border-radius: var(--market-radius-sm); + padding: 0 18px; + font-weight: 600; +} + +.market-rank-filter__submit { + background: var(--market-brand); + color: #fff; +} + +.market-rank-filter__submit:hover { + background: var(--market-brand-strong); +} + +.market-rank-filter__reset { + border: 1px solid rgba(5, 5, 5, 0.12); + background: #fff; + color: #111827; +} + +.market-rank-board { + padding: 20px 16px; + border-radius: var(--market-radius); + background: #fff; +} + +.market-rank-board__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 22px 34px; +} + +.market-rank-card { + display: grid; + grid-template-columns: 88px minmax(220px, 1fr) minmax(160px, 220px); + align-items: center; + gap: 18px; + min-height: 132px; +} + +.market-rank-card__medal { + position: relative; + display: grid; + place-items: center; + width: 78px; + height: 78px; + border-radius: 50%; + color: #fff; + box-shadow: inset 0 -8px 0 rgba(0, 0, 0, 0.08); +} + +.market-rank-card__medal::after { + content: ''; + position: absolute; + inset: -5px; + border-radius: 50%; + border: 3px solid rgba(255, 210, 96, 0.58); + box-shadow: 0 10px 18px rgba(15, 23, 42, 0.08); +} + +.market-rank-card__medal strong { + position: relative; + z-index: 1; + font-size: 2rem; + line-height: 1; +} + +.market-rank-card__medal.is-rank-1 { + background: linear-gradient(180deg, #ffd829, #e9a11d); +} + +.market-rank-card__medal.is-rank-2 { + background: linear-gradient(180deg, #2bd26a, #12a548); +} + +.market-rank-card__medal.is-rank-3 { + background: linear-gradient(180deg, #ffbd36, #e38a25); +} + +.market-rank-card__medal.is-rank-4 { + background: linear-gradient(180deg, #138bd6, #0769b3); +} + +.market-rank-card__medal.is-rank-5 { + background: linear-gradient(180deg, #ff86aa, #ec5b82); +} + +.market-rank-card__medal.is-rank-6 { + background: linear-gradient(180deg, #ff753a, #e84b17); +} + +.market-rank-card__medal.is-rank-7 { + background: linear-gradient(180deg, #ff5f1f, #df2e0d); +} + +.market-rank-card__medal.is-rank-8 { + background: linear-gradient(180deg, #d97ec8, #b555a8); +} + +.market-rank-card__content { + display: grid; + gap: 6px; + min-width: 0; +} + +.market-rank-card__title { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.market-rank-card__title strong { + overflow: hidden; + color: #333; + font-size: 1.08rem; + line-height: 1.35; + white-space: nowrap; + text-overflow: ellipsis; +} + +.market-rank-card__title span { + flex-shrink: 0; + border-radius: 999px; + padding: 2px 7px; + background: #fff4db; + color: #ff7a00; + font-size: 0.72rem; + font-weight: 700; +} + +.market-rank-card__stats { + display: flex; + align-items: center; + gap: 18px; + flex-wrap: wrap; + color: #111827; + font-size: 0.86rem; +} + +.market-rank-card__stats span { + white-space: nowrap; +} + +.market-rank-card__stats span:not(:first-child) { + color: #00b35a; +} + +.market-rank-card__content p { + margin: 0; + overflow: hidden; + color: #111827; + line-height: 1.45; + white-space: nowrap; + text-overflow: ellipsis; +} + +.market-rank-card__actions { + display: flex; + align-items: center; + gap: 18px; +} + +.market-rank-card__actions a, +.market-rank-card__actions button { + padding: 0; + color: #ef4444; + font-weight: 500; +} + +.market-rank-card__previews { + display: flex; + align-items: center; + justify-content: flex-end; + min-width: 0; +} + +.market-rank-card__previews img { + width: 74px; + height: 74px; + border: 3px solid #eef1f4; + border-radius: 50%; + object-fit: cover; + background: #fff; +} + +.market-rank-card__previews img + img { + margin-left: -18px; +} + +.market-rank-card__preview-fallback { + display: grid; + place-items: center; + width: 92px; + height: 74px; + border-radius: 999px; + background: rgba(248, 250, 252, 0.96); + color: var(--market-muted); + font-size: 0.82rem; +} + +.market-search-page { + display: grid; + grid-template-columns: 252px minmax(0, 1fr); + gap: 12px; + align-items: start; +} + +.market-search-sidebar, +.market-search-attrs, +.market-search-filters, +.market-search-sortbar { + border-radius: var(--market-radius); + background: #fff; +} + +.market-search-sidebar { + padding: 16px 14px; +} + +.market-search-sidebar__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} + +.market-search-sidebar__head strong { + color: #ef4444; + font-size: 1rem; +} + +.market-search-sidebar__head button { + color: #ef4444; + font-weight: 500; +} + +.market-search-ranking-list { + display: grid; + gap: 10px; +} + +.market-search-ranking-item { + display: grid; + align-items: center; + gap: 10px; + min-height: 36px; + color: inherit; + text-decoration: none; + cursor: pointer; +} + +.market-search-ranking-item.is-podium { + grid-template-columns: 28px 48px minmax(0, 1fr); +} + +.market-search-ranking-item.is-compact { + grid-template-columns: 28px minmax(0, 1fr); +} + +.market-search-rank-no { + display: grid; + place-items: center; + width: 26px; + height: 26px; + border-radius: 7px; + color: #fff; + font-size: 0.78rem; + font-weight: 800; +} + +.market-search-rank-no.is-order { + background: #ef4444; +} + +.market-search-rank-no.is-top-1 { + background: linear-gradient(180deg, #f7c948, #e9a11d); +} + +.market-search-rank-no.is-top-2 { + background: linear-gradient(180deg, #d5dde8, #97a4b2); +} + +.market-search-rank-no.is-top-3 { + background: linear-gradient(180deg, #e6a66a, #c8793a); +} + +.market-search-ranking-item img, +.market-search-ranking-avatar { + width: 48px; + height: 48px; + border-radius: 50%; +} + +.market-search-ranking-item img { + object-fit: cover; +} + +.market-search-ranking-avatar { + display: grid; + place-items: center; + background: var(--market-brand-bg); + color: var(--market-brand-strong); + font-size: 0.78rem; + font-weight: 800; +} + +.market-search-ranking-item strong { + overflow: hidden; + color: #333; + font-size: 0.92rem; + white-space: nowrap; + text-overflow: ellipsis; +} + +.market-search-results { + display: grid; + gap: 12px; + min-width: 0; +} + +.market-search-attrs { + display: grid; + gap: 10px; + padding: 12px; +} + +.market-search-attr-row { + display: grid; + grid-template-columns: 86px auto minmax(0, 1fr); + gap: 12px; + align-items: start; +} + +.market-search-attr-row:first-child { + grid-template-columns: 86px auto auto auto auto minmax(0, 1fr); + padding-bottom: 10px; + border-bottom: 1px dashed rgba(5, 5, 5, 0.22); +} + +.market-search-attr-row strong { + color: #111827; +} + +.market-search-attr-row span { + color: #111827; +} + +.market-search-chip-group { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.market-search-chip-group--wrap { + flex-wrap: wrap; +} + +.market-search-chip-group button { + padding: 0; + color: #111827; + white-space: nowrap; +} + +.market-search-chip-group button.is-active, +.market-search-chip-group button:hover { + color: #ef4444; +} + +.market-search-filters { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + min-height: 82px; + padding: 12px; +} + +.market-search-filters label { + display: inline-flex; + align-items: center; + gap: 8px; + color: #111827; + font-weight: 600; +} + +.market-search-filters input, +.market-search-filters select { + height: 34px; + min-width: 126px; + border: 1px solid rgba(15, 23, 42, 0.12); + border-radius: var(--market-radius-sm); + background: #fff; + padding: 0 10px; + color: #111827; + font-weight: 400; +} + +.market-search-price-field input { + width: 88px; + min-width: 88px; +} + +.market-search-price-field i { + color: #9ca3af; + font-style: normal; +} + +.market-search-filters input:focus, +.market-search-filters select:focus { + outline: none; + border-color: var(--market-brand-border-hover); + box-shadow: 0 0 0 2px rgba(21, 111, 78, 0.1); +} + +.market-search-filters__submit, +.market-search-filters__reset, +.market-search-batch-button { + height: 34px; + border-radius: var(--market-radius-sm); + padding: 0 18px; + font-weight: 600; +} + +.market-search-filters__submit, +.market-search-batch-button { + background: #ef4444; + color: #fff; +} + +.market-search-filters__submit:hover, +.market-search-batch-button:hover { + background: #dc2626; +} + +.market-search-filters__reset { + border: 1px solid rgba(15, 23, 42, 0.12); + background: #fff; + color: #111827; +} + +.market-search-sortbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 54px; + padding: 10px 12px; +} + +.market-search-sortbar__left { + display: flex; + align-items: center; + gap: 18px; + flex-wrap: wrap; +} + +.market-search-sortbar__left strong { + color: #ef4444; +} + +.market-search-sortbar__left button { + color: #111827; + font-weight: 500; +} + +.market-search-sortbar__left button.is-active, +.market-search-sortbar__left button:hover { + color: var(--market-brand); +} + +.market-search-product-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(184px, 1fr)); + gap: 12px; +} + +.market-section { + margin-top: 18px; + padding: 20px; +} + +.market-section__head { + margin-bottom: 16px; +} + +.market-section__toolbar { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.market-sort-tabs { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.market-sort-tabs button { + border: 1px solid rgba(5, 5, 5, 0.08); + border-radius: var(--market-radius); + background: #fff; + color: var(--market-muted); + padding: 6px 12px; + font-size: 0.86rem; + font-weight: 500; +} + +.market-sort-tabs button:hover { + color: var(--market-brand); + border-color: var(--market-brand-border-hover); + background: var(--market-brand-bg); +} + +.market-sort-tabs button.is-active { + color: var(--market-brand); + border-color: var(--market-brand-border); + background: var(--market-brand-bg); +} + +.market-platform-grid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 12px; +} + +.market-platform-card { + display: grid; + gap: 6px; + padding: 14px; + border-radius: var(--market-radius-xl); +} + +.market-platform-card strong { + font-size: 1rem; +} + +.market-product-grid, +.market-store-grid { + display: grid; + align-items: start; + justify-content: start; + gap: 12px; +} + +.market-product-grid { + grid-template-columns: repeat(auto-fill, minmax(204px, 204px)); +} + +.market-product-card { + display: grid; + grid-template-rows: auto auto auto; + gap: 0; + overflow: hidden; + border-radius: var(--market-radius); + background: #fff; + border: 1px solid rgba(26, 38, 62, 0.06); + box-shadow: 0 6px 16px rgba(15, 23, 42, 0.04); + transition: 0.2s ease; +} + +.market-product-card:hover, +.market-store-card:hover, +.market-spot-card:hover, +.market-platform-card:hover { + transform: translateY(-2px); + box-shadow: 0 18px 42px rgba(17, 28, 45, 0.08); +} + +.market-product-card__media { + position: relative; + overflow: hidden; + background: #f8fafc; +} + +.market-product-card__media img { + width: 100%; + aspect-ratio: 1 / 1; + object-fit: contain; +} + +.market-product-card__media span { + position: absolute; + left: 8px; + top: 8px; + max-width: calc(100% - 16px); + overflow: hidden; + border-radius: 999px; + padding: 4px 8px; + background: rgba(20, 32, 51, 0.72); + color: #fff; + font-size: 0.7rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.market-product-card__center, +.market-product-card__bottom { + display: flex; + align-items: center; + min-width: 0; + background: #fff; +} + +.market-product-card__center { + justify-content: space-between; + gap: 8px; + padding: 8px 10px 3px; +} + +.market-product-card__bottom { + justify-content: space-between; + gap: 8px; + padding: 0 10px 10px; + color: rgba(14, 21, 19, 0.68); + font-size: 0.66rem; +} + +.market-product-card__center strong, +.market-product-card__bottom span, +.market-store-card__body strong { + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.market-product-card__center strong { + color: #4b5563; + font-size: 0.8rem; + font-weight: 500; + line-height: 1.3; +} + +.market-product-card__center span { + flex: 0 0 auto; + color: #ff0036; + font-size: 0.9rem; + font-weight: 800; + line-height: 1.2; +} + +.market-product-card__bottom time { + flex: 0 0 auto; + color: rgba(14, 21, 19, 0.62); +} + +.market-empty-state, +.market-media-placeholder { + display: grid; + place-items: center; + text-align: center; + color: var(--market-muted); + background: rgba(248, 250, 252, 0.96); + border: 1px dashed rgba(5, 5, 5, 0.1); +} + +.market-empty-state { + min-height: 180px; + padding: 24px; + border-radius: var(--market-radius-lg); +} + +.market-empty-state--panel { + min-height: 220px; +} + +.market-member-layout { + display: grid; + grid-template-columns: 210px minmax(0, 1fr); + gap: 16px; + align-items: start; +} + +.market-member-sidebar, +.market-member-panel { + border: 1px solid var(--market-line); + border-radius: var(--market-radius-xl); + background: #fff; + box-shadow: var(--market-shadow); +} + +.market-member-sidebar { + position: sticky; + top: 128px; + display: grid; + gap: 16px; + padding: 18px; +} + +.market-member-sidebar > strong { + color: #111827; + font-size: 1.06rem; +} + +.market-member-tabs { + display: grid; + gap: 8px; +} + +.market-member-tabs button { + min-height: 40px; + padding: 0 12px; + border-radius: var(--market-radius); + color: #4b5563; + text-align: left; + font-weight: 600; +} + +.market-member-tabs button:hover, +.market-member-tabs button.is-active { + color: var(--market-brand); + background: var(--market-brand-bg); +} + +.market-member-panel { + min-height: 560px; + padding: 20px; +} + +.market-member-panel__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; +} + +.market-member-panel__head h1 { + margin: 2px 0 0; + color: #111827; + font-size: 1.28rem; +} + +.market-member-summary { + display: inline-flex; + align-items: baseline; + gap: 12px; + color: var(--market-muted); +} + +.market-member-summary strong { + color: #ef4444; + font-size: 1.2rem; +} + +.market-member-login-link { + display: inline-flex; + justify-content: center; + min-width: 92px; + margin-top: 12px; + padding: 8px 14px; + border-radius: var(--market-radius); + background: var(--market-brand); + color: #fff; + font-weight: 700; +} + +.market-cart-list, +.market-order-list { + display: grid; + gap: 12px; +} + +.market-cart-item { + display: grid; + grid-template-columns: auto 86px minmax(0, 1fr) 96px 112px 104px auto; + gap: 14px; + align-items: center; + padding: 14px; + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: var(--market-radius-lg); + background: #fff; +} + +.market-cart-item > input { + width: 16px; + height: 16px; + accent-color: var(--market-brand); +} + +.market-cart-item__media img, +.market-cart-item__media .market-media-placeholder { + width: 86px; + height: 86px; + border-radius: var(--market-radius); + object-fit: cover; +} + +.market-cart-item__body { + display: grid; + gap: 6px; + min-width: 0; +} + +.market-cart-item__body a { + overflow: hidden; + color: #111827; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.market-cart-item__body span, +.market-cart-item__body small { + overflow: hidden; + color: var(--market-muted); + text-overflow: ellipsis; + white-space: nowrap; +} + +.market-cart-item > strong { + color: #0f5138; +} + +.market-cart-item__quantity { + display: grid; + grid-template-columns: 30px 40px 30px; + align-items: center; + overflow: hidden; + border: 1px solid rgba(15, 23, 42, 0.12); + border-radius: var(--market-radius); +} + +.market-cart-item__quantity button, +.market-cart-item__quantity span { + display: grid; + place-items: center; + min-height: 32px; +} + +.market-cart-item__quantity span { + border-inline: 1px solid rgba(15, 23, 42, 0.08); +} + +.market-cart-item > button { + color: #ef4444; + font-weight: 700; +} + +.market-order-card { + display: grid; + gap: 14px; + padding: 16px; + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: var(--market-radius-lg); + background: #fff; +} + +.market-order-card__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.market-order-card__head div { + display: grid; + gap: 4px; +} + +.market-order-card__head strong { + color: #111827; +} + +.market-order-card__head span { + color: var(--market-muted); +} + +.market-order-status { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 10px; + border-radius: 999px; + background: var(--market-brand-bg); + color: var(--market-brand) !important; + font-weight: 700; + white-space: nowrap; +} + +.market-order-card__body { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.market-order-card__body article { + display: grid; + gap: 4px; + padding: 12px; + border-radius: var(--market-radius); + background: rgba(248, 250, 252, 0.9); +} + +.market-order-card__body span { + color: var(--market-muted); + font-size: 0.82rem; +} + +.market-order-card__body strong { + color: #111827; +} + +.market-media-placeholder { + min-height: 100%; + border-radius: 14px; + font-size: 0.86rem; +} + +.market-media-placeholder--banner { + min-height: 482px; + border-radius: var(--market-radius-xl); +} + +.market-media-placeholder--square, +.market-media-placeholder--store { + width: 88px; + height: 88px; +} + +.market-media-placeholder--product { + aspect-ratio: 1 / 1; + width: 100%; +} + +.market-store-grid { + grid-template-columns: repeat(auto-fill, minmax(210px, 210px)); + gap: 12px; +} + +.market-store-card { + display: grid; + gap: 10px; + padding: 10px; + border-radius: var(--market-radius-lg); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.99), rgba(246, 250, 255, 0.96)); + border: 1px solid rgba(15, 23, 42, 0.06); + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.05); +} + +.market-store-card__hero { + position: relative; + display: grid; + place-items: center; + min-height: 108px; + padding: 16px 12px 12px; + border-radius: var(--market-radius-lg); + overflow: hidden; + background: + radial-gradient(circle at top center, rgba(21, 111, 78, 0.16), transparent 34%), + radial-gradient(circle at center 78%, rgba(15, 23, 42, 0.12), transparent 42%), + linear-gradient(180deg, rgba(249, 252, 250, 0.98), rgba(237, 244, 240, 0.95)); +} + +.market-store-card__hero::after { + content: ''; + position: absolute; + left: 50%; + bottom: 8px; + width: 76px; + height: 20px; + transform: translateX(-50%); + border-radius: 999px; + background: rgba(15, 23, 42, 0.12); + filter: blur(12px); +} + +.market-store-card__badge { + position: absolute; + left: 10px; + top: 10px; + z-index: 1; + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 0 8px; + border-radius: 999px; + background: rgba(21, 111, 78, 0.1); + color: var(--market-brand-strong); + font-size: 0.7rem; + font-weight: 700; +} + +.market-store-card__logo-shell { + position: relative; + z-index: 1; + display: grid; + place-items: center; + width: 72px; + height: 72px; + padding: 5px; + border-radius: 50%; + background: linear-gradient(180deg, #fff, rgba(250, 252, 255, 0.94)); + border: 1px solid rgba(21, 111, 78, 0.1); + box-shadow: 0 10px 20px rgba(15, 23, 42, 0.08); +} + +.market-store-card__logo-shell img, +.market-store-card__avatar-fallback { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; +} + +.market-store-card__avatar-fallback { + display: grid; + place-items: center; + background: + radial-gradient(circle at top left, rgba(52, 145, 114, 0.28), transparent 45%), + linear-gradient(135deg, rgba(21, 111, 78, 0.18), rgba(21, 111, 78, 0.06)); + color: var(--market-brand-strong); + font-size: 1rem; + font-weight: 800; + letter-spacing: 0.03em; +} + +.market-store-card__body { + display: grid; + justify-items: center; + gap: 7px; + min-width: 0; + text-align: center; + padding: 0 2px; +} + +.market-store-card__body strong { + font-size: 0.98rem; + line-height: 1.28; + color: #111827; +} + +.market-store-card__reason { + margin: 0; + color: var(--market-brand-strong); + font-size: 0.76rem; + font-weight: 600; +} + +.market-store-card__stats { + width: 100%; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + padding: 8px 0; + border-top: 1px solid rgba(15, 23, 42, 0.06); + border-bottom: 1px solid rgba(15, 23, 42, 0.06); +} + +.market-store-card__stats article { + display: grid; + gap: 3px; + justify-items: center; + padding: 0 4px; +} + +.market-store-card__stats article + article { + border-left: 1px solid rgba(15, 23, 42, 0.06); +} + +.market-store-card__stats strong { + font-size: 0.9rem; + color: var(--market-brand-strong); +} + +.market-store-card__stats span { + color: #667085; + font-size: 0.7rem; + line-height: 1.25; +} + +.market-store-card__business { + margin: 0; + color: #667085; + font-size: 0.76rem; + line-height: 1.5; +} + +.market-header__inner--detail { + grid-template-columns: 220px minmax(0, 1fr); + min-height: 72px; +} + +.market-detail-breadcrumb { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + color: var(--market-muted); + font-size: 0.86rem; +} + +.market-detail-breadcrumb a { + color: var(--market-brand); + font-weight: 600; +} + +.market-detail-breadcrumb strong { + min-width: 0; + overflow: hidden; + color: #111827; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.market-detail-main { + padding-top: 18px; +} + +.market-detail-layout { + display: grid; + grid-template-columns: 420px minmax(0, 1fr) 260px; + gap: 16px; + align-items: start; + padding: 16px; + border: 1px solid var(--market-line); + border-radius: var(--market-radius-xl); + background: #fff; + box-shadow: var(--market-shadow); +} + +.market-detail-gallery, +.market-detail-info, +.market-detail-merchant { + min-width: 0; +} + +.market-detail-gallery__stage { + display: grid; + place-items: center; + overflow: hidden; + aspect-ratio: 1 / 1; + border-radius: var(--market-radius); + background: #f5f7fa; +} + +.market-detail-gallery__stage img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.market-detail-thumbs { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; + margin-top: 10px; +} + +.market-detail-thumbs button { + overflow: hidden; + aspect-ratio: 1 / 1; + border: 2px solid transparent; + border-radius: var(--market-radius); + background: #f8fafc; +} + +.market-detail-thumbs button.is-active { + border-color: #ef4444; +} + +.market-detail-thumbs img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.market-detail-gallery__stats { + display: flex; + justify-content: center; + gap: 16px; + margin-top: 12px; + color: #111827; + font-size: 0.86rem; + flex-wrap: wrap; +} + +.market-detail-info { + display: grid; + gap: 14px; +} + +.market-detail-title h1 { + margin: 0; + color: #111827; + font-size: 1.2rem; + line-height: 1.35; +} + +.market-detail-title p { + margin: 6px 0 0; + color: #111827; + font-weight: 600; +} + +.market-detail-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 18px; + padding: 14px; + border-radius: var(--market-radius); + background: #f5f6f8; +} + +.market-detail-meta article { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.market-detail-meta span, +.market-detail-line span, +.market-detail-services span, +.market-detail-price span { + flex: 0 0 auto; + color: #111827; + font-weight: 700; +} + +.market-detail-meta strong, +.market-detail-line strong { + min-width: 0; + overflow: hidden; + color: #111827; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +.market-detail-line, +.market-detail-services, +.market-detail-price { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + flex-wrap: wrap; +} + +.market-detail-line strong { + color: #ef4444; +} + +.market-detail-services strong { + padding: 2px 7px; + border-radius: var(--market-radius-xs); + background: #ff9f43; + color: #fff; + font-size: 0.82rem; +} + +.market-detail-price strong { + color: #ff0036; + font-size: 1.5rem; + line-height: 1; +} + +.market-detail-price em { + color: #ff0036; + font-style: normal; +} + +.market-detail-spec-group { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 10px; + align-items: start; +} + +.market-detail-spec-group > span { + padding-top: 8px; + color: #111827; + font-weight: 700; +} + +.market-detail-color-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(72px, 72px)); + gap: 10px 12px; +} + +.market-detail-color-grid button { + display: grid; + gap: 5px; + justify-items: center; + min-width: 0; + color: #111827; +} + +.market-detail-color-grid img { + width: 72px; + height: 72px; + object-fit: cover; + border: 2px solid rgba(15, 23, 42, 0.16); + border-radius: var(--market-radius); + background: #f8fafc; +} + +.market-detail-color-grid button.is-active img { + border-color: #ef4444; +} + +.market-detail-color-grid strong { + max-width: 72px; + overflow: hidden; + color: #111827; + font-size: 0.78rem; + font-weight: 600; + line-height: 1.2; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} + +.market-detail-color-grid button.is-active strong { + color: #ef4444; +} + +.market-detail-sku-list { + display: grid; + gap: 8px; + max-height: 420px; + overflow: auto; + padding-right: 4px; +} + +.market-detail-sku { + display: grid; + grid-template-columns: minmax(0, 1fr) 80px 70px 120px; + align-items: center; + gap: 10px; + min-height: 48px; + padding: 8px 10px; + border-radius: var(--market-radius); + background: #f5f6f8; +} + +.market-detail-sku span { + min-width: 0; + overflow: hidden; + color: #111827; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +.market-detail-sku strong { + color: #ff0036; + text-align: right; +} + +.market-detail-sku small { + color: var(--market-muted); +} + +.market-detail-sku div { + display: grid; + grid-template-columns: 28px minmax(0, 1fr) 28px; + align-items: center; + gap: 6px; +} + +.market-detail-sku button { + display: grid; + place-items: center; + height: 28px; + border-radius: var(--market-radius-xs); + background: #9ca3af; + color: #fff; + font-size: 1.1rem; + font-weight: 800; +} + +.market-detail-sku button:last-child { + background: #ef4444; +} + +.market-detail-sku b { + display: grid; + place-items: center; + height: 30px; + border-radius: var(--market-radius-xs); + background: #fff; + color: #111827; + font-weight: 500; +} + +.market-detail-merchant { + display: grid; + gap: 14px; + justify-items: center; + padding: 16px; + border-radius: var(--market-radius); + background: #f5f6f8; + text-align: center; +} + +.market-detail-merchant__logo { + display: grid; + place-items: center; + width: 112px; + height: 112px; + overflow: hidden; + border-radius: 50%; + background: #ef4444; + color: #fff; + box-shadow: inset 0 0 0 4px rgba(245, 158, 11, 0.8); + font-size: 2.7rem; + font-weight: 800; +} + +.market-detail-merchant__logo img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.market-detail-merchant h2 { + margin: 0; + color: #111827; + font-size: 1rem; +} + +.market-detail-merchant__metrics { + width: 100%; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.market-detail-merchant__metrics article { + display: grid; + gap: 4px; +} + +.market-detail-merchant__metrics strong { + color: #f59e0b; + font-size: 1rem; +} + +.market-detail-merchant__metrics span, +.market-detail-merchant p { + color: #4b5563; + font-size: 0.82rem; +} + +.market-detail-merchant p { + margin: 0; +} + +.market-detail-merchant button { + width: 100%; + min-height: 36px; + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: var(--market-radius); + background: #fff; + color: #111827; + font-weight: 600; +} + +.market-detail-merchant button:hover { + border-color: var(--market-brand-border); + color: var(--market-brand); +} + +.market-detail-merchant[role='button'] { + cursor: pointer; +} + +.market-detail-content-panel { + overflow: hidden; + border: 1px solid var(--market-line); + border-radius: 26px; + background: #fff; + box-shadow: var(--market-shadow); +} + +.market-detail-content-tabs { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0; + padding: 0; + background: #fff; +} + +.market-detail-content-tabs button { + display: grid; + place-items: center; + min-height: 58px; + border-radius: 0; + background: #fff; + color: #111827; + font-size: 1.05rem; + font-weight: 800; +} + +.market-detail-content-tabs button.is-active { + border-radius: 8px; + background: #ef4444; + color: #fff; +} + +.market-detail-content-body { + display: grid; + gap: 22px; + padding: 18px 0 34px; + background: #f5f6f8; +} + +.market-detail-param-row { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 18px 28px; + padding: 20px 28px; + border-radius: 14px; + background: #fff; + color: #111827; +} + +.market-detail-param-row article { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + font-size: 1rem; + line-height: 1.5; +} + +.market-detail-param-row strong { + flex: 0 0 auto; + font-weight: 800; +} + +.market-detail-param-row span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.market-detail-text-blocks, +.market-detail-dropship-list { + display: grid; + gap: 12px; + padding: 0 28px; +} + +.market-detail-text-blocks p, +.market-detail-dropship-list article { + margin: 0; + padding: 16px 18px; + border-radius: 14px; + background: #fff; + color: #111827; + line-height: 1.7; +} + +.market-detail-dropship-list article { + display: grid; + gap: 6px; +} + +.market-detail-dropship-list strong { + font-size: 1rem; +} + +.market-detail-dropship-list span { + color: var(--market-muted); +} + +.market-detail-long-images { + display: grid; + justify-items: center; + gap: 0; + padding: 0 28px; +} + +.market-detail-long-images img { + display: block; + width: min(100%, 1080px); + height: auto; + background: #fff; + object-fit: contain; +} + +.market-shop-layout { + display: grid; + grid-template-columns: 250px minmax(0, 1fr); + gap: 14px; + align-items: start; +} + +.market-shop-sidebar, +.market-shop-content, +.market-shop-filter, +.market-shop-search, +.market-shop-sort { + border: 1px solid var(--market-line); + border-radius: var(--market-radius-lg); + background: #fff; + box-shadow: var(--market-shadow); +} + +.market-shop-sidebar { + display: grid; + gap: 14px; + justify-items: center; + padding: 18px; + text-align: center; +} + +.market-shop-logo { + display: grid; + place-items: center; + width: 140px; + height: 140px; + overflow: hidden; + border-radius: 50%; + background: #ef4444; + color: #fff; + box-shadow: inset 0 0 0 4px rgba(245, 158, 11, 0.82); + font-size: 2.8rem; + font-weight: 800; +} + +.market-shop-logo img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.market-shop-sidebar h1 { + margin: 0; + color: #111827; + font-size: 1.08rem; +} + +.market-shop-metrics { + width: 100%; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px 8px; +} + +.market-shop-metrics article { + display: grid; + gap: 4px; +} + +.market-shop-metrics strong { + color: #f59e0b; + font-size: 1rem; +} + +.market-shop-metrics span, +.market-shop-sidebar p { + color: #4b5563; + font-size: 0.82rem; +} + +.market-shop-sidebar p { + margin: 0; + line-height: 1.6; +} + +.market-shop-content { + display: grid; + gap: 12px; + padding: 0; + border: 0; + background: transparent; + box-shadow: none; +} + +.market-shop-filter, +.market-shop-search, +.market-shop-sort { + display: flex; + align-items: center; + gap: 16px; + min-height: 58px; + padding: 12px; + flex-wrap: wrap; + box-shadow: none; +} + +.market-shop-filter strong, +.market-shop-sort strong { + color: #111827; +} + +.market-shop-filter span { + color: #ef4444; + font-weight: 600; +} + +.market-shop-search label { + display: inline-flex; + align-items: center; + gap: 8px; + color: #111827; + font-weight: 600; +} + +.market-shop-search input { + width: 160px; + min-height: 34px; + border: 1px solid rgba(15, 23, 42, 0.12); + border-radius: var(--market-radius); + padding: 0 10px; +} + +.market-shop-search button, +.market-shop-sort button { + min-height: 34px; + padding: 0 18px; + border: 1px solid rgba(15, 23, 42, 0.1); + border-radius: var(--market-radius); + background: #fff; + color: #111827; + font-weight: 600; +} + +.market-shop-search button[type='submit'], +.market-shop-sort button.is-active { + border-color: #ef4444; + background: #ef4444; + color: #fff; +} + +.market-shop-sort span { + margin-left: auto; + color: var(--market-muted); +} + +.market-shop-product-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(204px, 204px)); + gap: 12px; + align-items: start; +} + +.market-footer { + border-top: 1px solid rgba(26, 38, 62, 0.06); + background: rgba(255, 255, 255, 0.72); +} + +.market-footer__inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 20px 0 36px; + color: var(--market-muted); +} + +.market-footer__inner strong { + display: block; + margin-bottom: 6px; + color: var(--market-ink); +} + +.market-footer__inner p { + margin: 0; + max-width: 620px; +} + +.market-footer__inner div:last-child { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.login-page { + min-height: 100vh; + display: flex; + align-items: center; + padding: 28px 0; +} + +.login-page--with-market-header { + min-height: auto; + padding: 42px 0 56px; +} + +.login-layout { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(380px, 480px); + gap: 18px; + align-items: stretch; +} + +.login-showcase { + padding: 30px; + background: + radial-gradient(circle at top right, rgba(255, 255, 255, 0.16), transparent 24%), + linear-gradient(145deg, #103226, #156f4e 58%, #349172); + color: rgba(255, 255, 255, 0.84); +} + +.login-showcase h1 { + margin: 14px 0 10px; + font-size: clamp(1.8rem, 3.4vw, 2.6rem); + line-height: 1.18; + color: #fff; +} + +.login-back { + display: inline-flex; + align-items: center; + margin-bottom: 30px; + color: rgba(255, 255, 255, 0.7); +} + +.login-feature-list { + display: grid; + gap: 12px; + margin-top: 30px; +} + +.login-feature-list article { + padding: 14px 16px; + border-radius: var(--market-radius-xl); + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); +} + +.login-feature-list strong, +.login-feature-list span { + display: block; +} + +.login-feature-list span { + margin-top: 6px; + color: rgba(255, 255, 255, 0.7); +} + +.login-card { + padding: 28px; +} + +.login-auth-tabs { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-bottom: 18px; + padding: 6px; + border-radius: var(--market-radius-lg); + background: rgba(15, 23, 42, 0.04); +} + +.login-auth-tabs a { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 40px; + border-radius: var(--market-radius); + font-size: 0.94rem; + font-weight: 500; + color: var(--market-muted); +} + +.login-auth-tabs a.is-active { + background: #fff; + color: var(--market-brand); + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08); +} + +.login-card__head h2 { + margin: 10px 0 0; + font-size: 1.5rem; +} + +.login-mode-tabs { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-top: 24px; + padding: 6px; + border-radius: var(--market-radius-lg); + background: rgba(15, 23, 42, 0.04); +} + +.login-mode-tabs button { + min-height: 40px; + border-radius: var(--market-radius); + font-size: 0.94rem; + font-weight: 500; + color: var(--market-muted); +} + +.login-mode-tabs button.is-active { + background: #fff; + color: var(--market-brand); + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08); +} + +.login-form { + display: grid; + gap: 14px; + margin-top: 20px; +} + +.login-form__hint { + margin: 18px 0 0; + font-size: 0.9rem; + color: var(--market-muted); +} + +.login-form label { + display: grid; + gap: 10px; +} + +.login-form span { + font-size: 0.88rem; + font-weight: 500; +} + +.login-form input { + width: 100%; + min-height: 42px; + border-radius: var(--market-radius); + border: 1px solid rgba(5, 5, 5, 0.1); + background: #fff; + padding: 0 16px; +} + +.login-form input:focus { + border-color: var(--market-brand-border-hover); + box-shadow: 0 0 0 2px rgba(21, 111, 78, 0.12); +} + +.login-inline-field { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; +} + +.login-submit { + width: 100%; + margin-top: 8px; +} + +.login-card__footer { + margin: 18px 0 0; + font-size: 0.92rem; + color: var(--market-muted); +} + +.login-card__footer a { + margin-left: 6px; + color: var(--market-brand); + font-weight: 500; +} + +@media (max-width: 1180px) { + .market-header__inner { + grid-template-columns: 1fr; + justify-items: stretch; + padding: 18px 0 22px; + } + + .market-header__actions { + justify-content: flex-start; + } + + .market-stage, + .market-search-page, + .market-member-layout, + .market-detail-layout, + .market-shop-layout, + .market-platform-grid, + .login-layout { + grid-template-columns: 1fr; + } + + .market-member-sidebar { + position: static; + } + + .market-member-tabs { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .market-member-tabs button { + text-align: center; + } + + .market-cart-item { + grid-template-columns: auto 72px minmax(0, 1fr); + } + + .market-cart-item > strong, + .market-cart-item__quantity, + .market-cart-item > button { + grid-column: 3; + justify-self: start; + } + + .market-order-card__body { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .market-product-grid { + grid-template-columns: repeat(auto-fill, minmax(204px, 204px)); + } + + .market-rank-board__grid { + grid-template-columns: 1fr; + } + + .market-search-product-grid { + grid-template-columns: repeat(auto-fill, minmax(184px, 1fr)); + } + + .market-detail-param-row { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .market-store-grid { + grid-template-columns: repeat(auto-fill, minmax(210px, 210px)); + } + + .market-footer__inner { + flex-direction: column; + align-items: flex-start; + } + + .market-category-mega { + position: static; + width: 100%; + min-height: 0; + margin-top: 14px; + opacity: 1; + visibility: visible; + pointer-events: auto; + transform: none; + display: none; + } + + .market-sort-panel.is-open .market-category-mega { + display: block; + } +} + +@media (max-width: 760px) { + .shell { + width: min(100% - 20px, 1320px); + } + + .market-topbar__inner, + .market-nav__inner, + .market-nav__links, + .market-header__actions, + .market-gallery-card__actions { + align-items: flex-start; + } + + .market-topbar__inner, + .market-nav__inner, + .market-member-panel__head, + .market-footer__inner { + flex-direction: column; + } + + .market-search__field, + .login-inline-field { + grid-template-columns: 1fr; + } + + .market-member-tabs, + .market-order-card__body { + grid-template-columns: 1fr; + } + + .market-member-panel { + padding: 16px; + } + + .market-cart-item { + grid-template-columns: auto 64px minmax(0, 1fr); + gap: 10px; + padding: 12px; + } + + .market-cart-item__media img, + .market-cart-item__media .market-media-placeholder { + width: 64px; + height: 64px; + } + + .market-search__core { + grid-template-columns: 1fr; + } + + .market-search__picker-wrap { + width: 100%; + } + + .market-search__picker-trigger { + width: 100%; + justify-content: space-between; + } + + .market-search__picker-menu { + left: 0; + right: 0; + min-width: 0; + } + + .market-search__divider { + display: none; + } + + .market-search__field { + grid-template-columns: minmax(0, 1fr) auto auto; + padding: 0; + } + + .market-gallery-strip { + grid-template-columns: 1fr; + } + + .market-section, + .market-detail-layout, + .market-recent-panel, + .market-sort-panel, + .market-rank-panel, + .login-showcase, + .login-card { + padding: 18px; + border-radius: var(--market-radius-xl); + } + + .market-header__inner--detail { + grid-template-columns: 1fr; + } + + .market-detail-gallery__stage { + max-height: 560px; + } + + .market-detail-meta, + .market-detail-sku { + grid-template-columns: 1fr; + } + + .market-detail-spec-group { + grid-template-columns: 1fr; + } + + .market-detail-color-grid { + grid-template-columns: repeat(auto-fill, minmax(64px, 64px)); + } + + .market-detail-color-grid img { + width: 64px; + height: 64px; + } + + .market-detail-color-grid strong { + max-width: 64px; + } + + .market-detail-sku strong { + text-align: left; + } + + .market-detail-sku div { + max-width: 140px; + } + + .market-detail-content-tabs { + grid-template-columns: 1fr; + } + + .market-detail-content-tabs button, + .market-detail-content-tabs button.is-active { + min-height: 48px; + border-radius: 0; + font-size: 0.96rem; + } + + .market-detail-content-body { + padding: 14px 0 22px; + } + + .market-detail-param-row { + grid-template-columns: 1fr; + gap: 10px; + padding: 16px; + } + + .market-detail-param-row article { + font-size: 0.92rem; + } + + .market-detail-text-blocks, + .market-detail-dropship-list, + .market-detail-long-images { + padding: 0 16px; + } + + .market-product-grid { + grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); + } + + .market-shop-product-grid { + grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); + } + + .market-store-grid { + grid-template-columns: repeat(auto-fill, minmax(190px, 210px)); + } + + .market-search-attr-row, + .market-search-attr-row:first-child { + grid-template-columns: 1fr; + gap: 8px; + } + + .market-search-filters label, + .market-search-filters input, + .market-search-filters select { + width: 100%; + } + + .market-search-price-field { + display: grid; + grid-template-columns: 1fr; + } + + .market-search-price-field input { + width: 100%; + } + + .market-search-sortbar { + align-items: flex-start; + flex-direction: column; + } + + .market-search-batch-button { + width: 100%; + } + + .market-rank-filter label, + .market-rank-filter input, + .market-rank-filter select { + width: 100%; + } + + .market-rank-card { + grid-template-columns: 64px minmax(0, 1fr); + gap: 14px; + } + + .market-rank-card__medal { + width: 58px; + height: 58px; + } + + .market-rank-card__medal strong { + font-size: 1.5rem; + } + + .market-rank-card__previews { + grid-column: 1 / -1; + justify-content: flex-start; + padding-left: 72px; + } + + .market-rank-card__previews img { + width: 58px; + height: 58px; + } + + .market-shop-filter, + .market-shop-search, + .market-shop-sort { + align-items: flex-start; + } + + .market-shop-search label, + .market-shop-search input { + width: 100%; + } + + .market-shop-sort span { + width: 100%; + margin-left: 0; + } + + .market-sort-list button { + grid-template-columns: 34px minmax(0, 1fr); + } + + .market-sort-item__arrow { + display: none; + } + + .market-category-mega__head { + flex-direction: column; + align-items: flex-start; + } + + .market-category-mega__row { + grid-template-columns: 1fr; + gap: 8px; + } + + .market-spot-card { + grid-template-columns: 72px minmax(0, 1fr); + } + + .market-spot-card img { + width: 72px; + height: 72px; + } + + .market-ranking-item.is-podium { + grid-template-columns: 28px 40px minmax(0, 1fr); + } + + .market-ranking-item img { + width: 40px; + height: 40px; + } + + .market-ranking-item__avatar-fallback { + width: 40px; + height: 40px; + font-size: 0.78rem; + } + + .market-recent-item { + grid-template-columns: 72px minmax(0, 1fr); + gap: 12px; + } + + .market-recent-item img, + .market-recent-item__placeholder { + width: 72px; + height: 64px; + } + + .market-store-card { + padding: 16px; + } + + .market-store-card__hero { + min-height: 168px; + padding: 22px 16px 16px; + } + + .market-store-card__logo-shell { + width: 96px; + height: 96px; + } + + .market-store-card__stats { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + } + + .market-store-card__stats strong { + font-size: 1rem; + } + + .market-media-placeholder--square, + .market-media-placeholder--store { + width: 72px; + height: 72px; + } + + .login-layout { + grid-template-columns: 1fr; + } +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..9bf5366 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,10 @@ +import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import App from './App' +import './index.css' + +createRoot(document.getElementById('root')!).render( + + + +) diff --git a/src/mocks/mall.ts b/src/mocks/mall.ts new file mode 100644 index 0000000..23cafef --- /dev/null +++ b/src/mocks/mall.ts @@ -0,0 +1,262 @@ +import type { AppGoodsPageResponse, AppHomeResponse, GoodsSummary, RecommendMerchantInfo } from '@/types/mall' + +export interface MarketCategory { + key: string + label: string + subtitle: string + accent: string + accentSoft: string +} + +export interface PlatformChannel { + key: string + label: string + subtitle: string +} + +const createArtwork = ( + title: string, + subtitle: string, + accent: string, + accentSoft: string, + shadow = 'rgba(15, 23, 42, 0.18)' +) => + `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(` + + + + + + + + + + + + + + + ${title} + ${subtitle} + YT Mall Showcase + search-led sourcing · dropshipping · best-seller picks + + + + `)}` + +export const MARKET_CATEGORIES: MarketCategory[] = [ + { + key: 'all', + label: '全部货源', + subtitle: '一站搜款与热门供给', + accent: '#e4393c', + accentSoft: '#ff8a65', + }, + { + key: 'digital', + label: '数码配件', + subtitle: '耳机、充电、支架', + accent: '#ef4444', + accentSoft: '#f59e0b', + }, + { + key: 'wearables', + label: '智能穿戴', + subtitle: '手表、表带、贴膜', + accent: '#2563eb', + accentSoft: '#38bdf8', + }, + { + key: 'live', + label: '直播器材', + subtitle: '补光灯、麦克风、支架', + accent: '#7c3aed', + accentSoft: '#c084fc', + }, + { + key: 'home', + label: '小家电', + subtitle: '高转化日用电器', + accent: '#0f766e', + accentSoft: '#34d399', + }, + { + key: 'fashion', + label: '潮流饰品', + subtitle: '包袋、首饰、文创', + accent: '#db2777', + accentSoft: '#f472b6', + }, + { + key: 'office', + label: '电脑周边', + subtitle: '键鼠、扩展坞、线材', + accent: '#4f46e5', + accentSoft: '#8b5cf6', + }, +] + +export const PLATFORM_CHANNELS: PlatformChannel[] = [ + { key: 'taobao', label: '淘宝', subtitle: '传统电商铺货' }, + { key: 'douyin', label: '抖音', subtitle: '内容带货承接' }, + { key: 'pdd', label: '拼多多', subtitle: '性价比爆单' }, + { key: 'xiaohongshu', label: '小红书', subtitle: '种草与测品' }, + { key: 'kuaishou', label: '快手', subtitle: '直播出单' }, + { key: '1688', label: '1688', subtitle: '供应链补货' }, +] + +export const SERVICE_POINTS = [ + '精准搜款', + '一件代发', + '爆款推荐', + '商家排行', + '多平台上货', + '移动端协同', +] + +export const HOT_KEYWORDS = [ + '磁吸手机壳', + '降噪蓝牙耳机', + '直播补光灯', + '智能手表表带', + '氮化镓快充', + '车载支架', +] + +const productSeeds = [ + ['MagSafe 磁吸手机壳', '防摔透明边框,支持磁吸快充,适合直播起量'], + ['55W 氮化镓快充套装', '双口快充,适合拼团和福利品渠道'], + ['蓝牙主动降噪耳机', '通勤人群高复购款,支持多色混批'], + ['直播桌面补光灯', '可折叠支架,适合短视频拍摄'], + ['智能手表尼龙表带', '轻量快干,多尺寸兼容'], + ['平板磁吸键盘壳', '办公学习双场景,高客单价'], + ['无线领夹麦克风', '直播收音稳定,单反手机通用'], + ['桌面多口扩展坞', '接口齐全,适合办公设备渠道'], + ['车载磁吸导航支架', '车品长期热卖,支持门店陈列'], + ['桌面香薰小风扇', '礼品属性强,适合夏季上新'], + ['电竞机械键盘', 'RGB 灯效,适合年轻用户'], + ['创意数显充电线', '视觉卖点强,适合短视频素材'], +] + +const storeSeeds = [ + ['星驰数码商家', '手机配件热销商家'], + ['新锐直播器材', '补光灯与麦克风主推'], + ['轻潮智造馆', '智能穿戴与家居小电'], + ['云仓选品中心', '多平台一件代发'], + ['闪电快充工坊', '充电品类深度供给'], +] + +const buildGoods = (seed: string[], index: number): GoodsSummary => { + const category = MARKET_CATEGORIES[(index % (MARKET_CATEGORIES.length - 1)) + 1] + const price = 29 + index * 7 + + return { + id: 10001 + index, + merchantId: String(5001 + (index % 4)), + storeId: 6001 + (index % 5), + categoryId: index + 1, + categoryName: category.label, + spuNo: `YTSPU${String(index + 1).padStart(4, '0')}`, + spuName: seed[0], + subTitle: seed[1], + coverUrl: createArtwork(seed[0], category.label, category.accent, category.accentSoft), + status: 'enabled', + auditStatus: 'approved', + saleStatus: 'on', + sort: 100 - index, + salesVolume: 220 + index * 37, + virtualSales: 860 + index * 64, + viewCount: 1200 + index * 80, + minPrice: `${price}.00`, + maxPrice: `${price + 12}.00`, + totalStock: 320 + index * 18, + lockedStock: 16 + index, + createdAt: 1710000000 + index * 1000, + updatedAt: 1710003600 + index * 1000, + } +} + +const buildStore = (seed: string[], index: number): RecommendMerchantInfo => { + const category = MARKET_CATEGORIES[(index % (MARKET_CATEGORIES.length - 1)) + 1] + + return { + id: 9001 + index, + merchantId: String(5001 + index), + storeName: seed[0], + storeLogo: createArtwork(seed[0], seed[1], category.accent, category.accentSoft), + storeNotice: seed[1], + storeStatus: 'enabled', + mainCategoryName: category.label, + recommendReason: '首页推荐商家', + rankScore: 95 - index * 1.2, + goodsCount: 88 - index * 9, + recentNewGoodsCount: 12 - index, + orderCount: 1260 - index * 138, + } +} + +const goodsList = productSeeds.map(buildGoods) +const storeList = storeSeeds.map(buildStore) + +export const FALLBACK_GOODS_PAGE: AppGoodsPageResponse = { + total: goodsList.length, + list: goodsList, +} + +export const FALLBACK_HOME: AppHomeResponse = { + categoryNavs: MARKET_CATEGORIES.map((item, index) => ({ + categoryId: index + 1, + categoryName: item.label, + goodsCount: Math.max(0, goodsList.length - index), + sort: index, + })), + banners: [ + { + id: 1, + title: '精准搜款 · 一件代发', + subTitle: '多平台供给与商家资源聚合', + description: '首页主视觉广告栏', + buttonText: '立即搜款', + positionCode: 'home_top', + imageUrl: createArtwork('精准搜款', '多平台供给与商家资源聚合', '#e4393c', '#fb7185'), + linkType: 'goods', + linkValue: '10001', + sort: 1, + status: 'enabled', + startTime: 1710000000, + endTime: 1730000000, + }, + { + id: 2, + title: '热卖数码专场', + subTitle: '快充、支架、耳机同步起量', + description: '首页活动广告栏', + buttonText: '查看专场', + positionCode: 'home_top', + imageUrl: createArtwork('热卖数码专场', '快充、支架、耳机同步起量', '#2563eb', '#22d3ee'), + linkType: 'goods', + linkValue: '10002', + sort: 2, + status: 'enabled', + startTime: 1710000000, + endTime: 1730000000, + }, + ], + searchKeywords: HOT_KEYWORDS, + channels: PLATFORM_CHANNELS.map((item, index) => ({ + id: index + 1, + title: item.label, + subTitle: item.subtitle, + iconUrl: '', + linkType: 'topic', + linkValue: item.key, + sort: index, + status: 'enabled', + })), + recommendGoods: goodsList.slice(0, 6), + recommendMerchants: storeList, + featuredMerchants: storeList.slice(0, 3), +} + +export const createFallbackArtwork = createArtwork diff --git a/src/pages/goods/GoodsDetailPage.tsx b/src/pages/goods/GoodsDetailPage.tsx new file mode 100644 index 0000000..7c990c8 --- /dev/null +++ b/src/pages/goods/GoodsDetailPage.tsx @@ -0,0 +1,585 @@ +import { useEffect, useMemo, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { apiAppGoodsDetail } from '@/apis' +import MarketPageHeader from '@/components/MarketPageHeader' +import { useUserStore } from '@/store/user' +import type { + AppGoodsDetailResponse, + GoodsSkuInfo, + MerchantInfo, + ParamItem, + ResolvedGoodsDetailInfo, + ResolvedGoodsSkuInfo, +} from '@/types/mall' +import { resolveGoodsDetailInfo } from '@/utils/mall/transform' + +const detailDateFormatter = new Intl.DateTimeFormat('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', +}) + +const formatDate = (value: number) => { + if (!Number.isFinite(value) || value <= 0) { + return '日期待定' + } + + return detailDateFormatter.format(new Date(value * 1000)).replace(/\//g, '-') +} + +const formatMoney = (value?: string | number | null) => { + const text = value == null ? '' : String(value) + return text ? `¥${text.replace(/\.00$/, '')}` : '¥--' +} + +const getSkuImage = (sku: GoodsSkuInfo) => sku.imageUrl || '' + +type SkuDisplayOption = { + color: string + size: string + image: string + sku: ResolvedGoodsSkuInfo +} + +type DetailTabKey = 'detail' | 'dropship' | 'whiteImages' + +const DETAIL_TABS: Array<{ key: DetailTabKey; label: string }> = [ + { key: 'detail', label: '商品详情' }, + { key: 'dropship', label: '代发说明' }, + { key: 'whiteImages', label: '水洗图/白底图' }, +] + +const COLOR_NAME_PATTERNS = ['颜色', '色号', '色系', 'color'] +const SIZE_NAME_PATTERNS = ['尺码', '尺寸', '型号', '机型', '规格', '容量', '版本', 'size', 'model'] +const SKU_NAME_SEPARATORS = /[_\-//|,,;;]+/ +const COLOR_TEXT_PATTERN = /色|白|黑|红|粉|蓝|绿|黄|橙|紫|灰|金|银|棕|咖|奶|米|香|透明|原色/i +const IMAGE_URL_PATTERN = /^(https?:)?\/\/|^data:image\//i +const WHITE_IMAGE_KEYWORDS = ['white', 'wash', 'clean', 'plain', 'background', '白底', '水洗'] + +const getSpecValue = (sku: ResolvedGoodsSkuInfo, patterns: string[]) => { + const matched = sku.specs.find(spec => { + const name = typeof spec.name === 'string' ? spec.name : '' + + return patterns.some(pattern => name.toLowerCase().includes(pattern.toLowerCase())) + }) + + return typeof matched?.value === 'string' ? matched.value.trim() : '' +} + +const splitSkuName = (skuName: string) => + skuName + .split(SKU_NAME_SEPARATORS) + .map(item => item.trim()) + .filter(Boolean) + +const resolveSkuColorSize = (sku: ResolvedGoodsSkuInfo) => { + const specColor = getSpecValue(sku, COLOR_NAME_PATTERNS) + const specSize = getSpecValue(sku, SIZE_NAME_PATTERNS) + const parts = splitSkuName(sku.skuName) + const colorIndex = parts.findIndex(part => COLOR_TEXT_PATTERN.test(part)) + const fallbackColor = colorIndex >= 0 ? parts[colorIndex] : parts[1] || parts[0] || '默认' + const fallbackSize = + parts.find((_, index) => index !== colorIndex) || sku.skuName || sku.skuNo || '默认尺寸' + + return { + color: specColor || fallbackColor, + size: specSize || fallbackSize, + } +} + +const resolveSkuOptions = (skus: ResolvedGoodsSkuInfo[]): SkuDisplayOption[] => + skus.map(sku => { + const { color, size } = resolveSkuColorSize(sku) + + return { + color, + size, + image: getSkuImage(sku), + sku, + } + }) + +const isImageUrl = (value: string) => IMAGE_URL_PATTERN.test(value.trim()) + +const getUniqueTexts = (values: string[]) => + Array.from(new Set(values.map(value => value.trim()).filter(Boolean))) + +const collectImages = (value: unknown): string[] => { + if (typeof value === 'string') { + const text = value.trim() + + if (!text) { + return [] + } + + if ((text.startsWith('[') && text.endsWith(']')) || (text.startsWith('{') && text.endsWith('}'))) { + try { + return collectImages(JSON.parse(text) as unknown) + } catch { + return isImageUrl(text) ? [text] : [] + } + } + + return text + .split(/[,,\n\r]+/) + .map(item => item.trim()) + .filter(isImageUrl) + } + + if (Array.isArray(value)) { + return value.flatMap(collectImages) + } + + if (value && typeof value === 'object') { + return Object.values(value as Record).flatMap(collectImages) + } + + return [] +} + +const getDetailImageBlocks = (detail: ResolvedGoodsDetailInfo) => + getUniqueTexts( + detail.detail.blocks + .filter(block => block.type.toLowerCase().includes('image') || isImageUrl(block.value)) + .flatMap(block => collectImages(block.value)) + ) + +const getDetailTextBlocks = (detail: ResolvedGoodsDetailInfo) => + detail.detail.blocks + .filter(block => !block.type.toLowerCase().includes('image') && !isImageUrl(block.value)) + .map(block => block.value.trim()) + .filter(Boolean) + +const getWhiteImageList = (detail: ResolvedGoodsDetailInfo) => { + const matchedImages = Object.entries(detail.extraInfo) + .filter(([key]) => { + const normalizedKey = key.toLowerCase() + return WHITE_IMAGE_KEYWORDS.some(keyword => normalizedKey.includes(keyword)) + }) + .flatMap(([, value]) => collectImages(value)) + + return getUniqueTexts(matchedImages) +} + +const getProductParams = (detail: ResolvedGoodsDetailInfo): ParamItem[] => { + const params = detail.params.filter(item => item.name?.trim() && item.value?.trim()) + + if (params.length > 0) { + return params + } + + return [ + { name: '商品分类', value: detail.categoryName || `类目 ${detail.categoryId}` }, + { name: '商品货号', value: detail.spuNo }, + { name: '库存', value: String(detail.totalStock) }, + ].filter(item => item.value) +} + +function GoodsDetailPage() { + const { spuId = '' } = useParams() + const navigate = useNavigate() + const token = useUserStore(state => state.token) + const userInfo = useUserStore(state => state.userInfo) + const storedMemberId = useUserStore(state => state.memberId) + + const currentUid = userInfo?.uid ?? storedMemberId ?? token?.memberId ?? token?.uid ?? 0 + + const [payload, setPayload] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [activeImage, setActiveImage] = useState('') + const [keyword, setKeyword] = useState('') + const [selectedColor, setSelectedColor] = useState('') + const [activeDetailTab, setActiveDetailTab] = useState('detail') + const [quantities, setQuantities] = useState>({}) + + useEffect(() => { + if (!spuId) { + setError('商品参数缺失') + setLoading(false) + return + } + + const controller = new AbortController() + setLoading(true) + setError(null) + + apiAppGoodsDetail({ uid: currentUid, spuId }, controller.signal) + .then(response => { + setPayload(response.data) + }) + .catch(nextError => { + if (!controller.signal.aborted) { + setError(nextError instanceof Error ? nextError.message : '商品详情接口请求失败') + } + }) + .finally(() => { + if (!controller.signal.aborted) { + setLoading(false) + } + }) + + return () => controller.abort() + }, [currentUid, spuId]) + + const detail = useMemo(() => (payload?.data ? resolveGoodsDetailInfo(payload.data) : null), [payload]) + const merchant = payload?.merchant ?? payload?.store ?? null + const merchantName = getMerchantName(merchant) + + const galleryImages = useMemo(() => { + if (!detail) { + return [] + } + + return Array.from( + new Set([ + detail.coverUrl, + ...detail.media.images, + ...detail.skus.map(getSkuImage), + ].filter(Boolean)) + ) + }, [detail]) + + const skuOptions = useMemo(() => resolveSkuOptions(detail?.skus ?? []), [detail]) + const colorOptions = useMemo(() => { + const options = new Map() + + skuOptions.forEach(option => { + if (!options.has(option.color)) { + options.set(option.color, option) + } + }) + + return Array.from(options.values()) + }, [skuOptions]) + const selectedColorOptions = useMemo( + () => skuOptions.filter(option => option.color === selectedColor), + [selectedColor, skuOptions] + ) + const productParams = useMemo(() => (detail ? getProductParams(detail) : []), [detail]) + const detailImages = useMemo(() => { + if (!detail) { + return [] + } + + return getDetailImageBlocks(detail) + }, [detail]) + const detailTextBlocks = useMemo(() => (detail ? getDetailTextBlocks(detail) : []), [detail]) + const fallbackDetailImages = useMemo(() => { + if (detailImages.length > 0) { + return detailImages + } + + return galleryImages + }, [detailImages, galleryImages]) + const whiteImages = useMemo(() => (detail ? getWhiteImageList(detail) : []), [detail]) + + useEffect(() => { + setActiveImage(galleryImages[0] ?? '') + }, [galleryImages]) + + useEffect(() => { + setSelectedColor(colorOptions[0]?.color ?? '') + }, [colorOptions]) + + const updateQuantity = (skuId: number, delta: number) => { + setQuantities(current => { + const nextValue = Math.max(0, (current[skuId] ?? 0) + delta) + return { + ...current, + [skuId]: nextValue, + } + }) + } + + const handleHeaderSearch = (nextKeyword: string) => { + const trimmedKeyword = nextKeyword.trim() + navigate(trimmedKeyword ? `/list/new?key=${encodeURIComponent(trimmedKeyword)}` : '/list/new') + } + + return ( +
+ + +
+ {loading ?
商品详情加载中...
: null} + {!loading && error ? ( +
+ 商品详情加载失败 + {error} +
+ ) : null} + {!loading && detail ? ( + <> +
+ + +
+
+

{detail.spuName}

+ {detail.subTitle && detail.subTitle !== '暂无副标题' ?

{detail.subTitle}

: null} +
+ +
+
+ 商品ID + {detail.id} +
+
+ 货号 + {detail.spuNo} +
+
+ 上架时间 + {formatDate(detail.createdAt)} +
+
+ 更新时间 + {formatDate(detail.updatedAt)} +
+
+ +
+ 分类: + {detail.categoryName || `类目 ${detail.categoryId}`} +
+ + {detail.services.length > 0 ? ( +
+ 服务: + {detail.services.map(service => ( + {service.title} + ))} +
+ ) : null} + +
+ 价格: + {formatMoney(detail.minPrice)} + {detail.minPrice !== detail.maxPrice ? {formatMoney(detail.maxPrice)} : null} +
+ + {colorOptions.length > 0 ? ( +
+ 颜色: +
+ {colorOptions.map(option => ( + + ))} +
+
+ ) : null} + + {selectedColorOptions.length > 0 ? ( +
+ 尺码: +
+ {selectedColorOptions.map(option => ( +
+ {option.size} + {formatMoney(option.sku.salePrice || detail.minPrice)} + 库存 {option.sku.stock} +
+ + {quantities[option.sku.id] ?? 0} + +
+
+ ))} +
+
+ ) : ( +
暂无 SKU 信息
+ )} +
+ + +
+ +
+
+ {DETAIL_TABS.map(tab => ( + + ))} +
+ + {activeDetailTab === 'detail' ? ( +
+ {productParams.length > 0 ? ( +
+ {productParams.map(item => ( +
+ {item.name}: + {item.value} +
+ ))} +
+ ) : null} + + {detailTextBlocks.length > 0 ? ( +
+ {detailTextBlocks.map(text => ( +

{text}

+ ))} +
+ ) : null} + + {fallbackDetailImages.length > 0 ? ( +
+ {fallbackDetailImages.map(image => ( + {`${detail.spuName} + ))} +
+ ) : ( +
暂无商品详情图
+ )} +
+ ) : null} + + {activeDetailTab === 'dropship' ? ( +
+ {detail.services.length > 0 ? ( +
+ {detail.services.map(service => ( +
+ {service.title} + {service.desc ? {service.desc} : null} +
+ ))} +
+ ) : ( +
暂无代发说明
+ )} +
+ ) : null} + + {activeDetailTab === 'whiteImages' ? ( +
+ {whiteImages.length > 0 ? ( +
+ {whiteImages.map(image => ( + {`${detail.spuName} + ))} +
+ ) : ( +
暂无水洗图/白底图
+ )} +
+ ) : null} +
+ + ) : null} +
+
+ ) +} + +const getMerchantName = (merchant: MerchantInfo | null) => { + const name = merchant?.storeName?.trim() || merchant?.merchantName?.trim() + return name || '未命名商家' +} + +export default GoodsDetailPage diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx new file mode 100644 index 0000000..e34d86a --- /dev/null +++ b/src/pages/home/HomePage.tsx @@ -0,0 +1,1041 @@ +import { useEffect, useMemo, useState } from 'react' +import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom' +import { toast } from 'sonner' +import { apiAppGoodsPage, apiAppHome, apiAppRecentNewGoods } from '@/apis' +import MarketPageHeader from '@/components/MarketPageHeader' +import type { MarketNavKey } from '@/components/MarketNav' +import { useUserStore } from '@/store/user' +import type { + AppGoodsPageResponse, + AppHomeResponse, + AppRecentNewGoodsResponse, + GoodsSortType, + GoodsSummary, + RecommendStoreInfo, + RecentNewGoodsItem, +} from '@/types/mall' +import { + resolveHomeFeaturedStores, + resolveHomeRankStores, + resolveHomeSearchKeywords, + resolveHomeTopBanners, +} from '@/utils/mall' +import logger from '@/utils/logger' + +const GOODS_SORT_OPTIONS: Array<{ key: GoodsSortType; label: string }> = [ + { key: 'comprehensive', label: '综合排序' }, + { key: 'price_asc', label: '价格升序' }, + { key: 'price_desc', label: '价格降序' }, + { key: 'sales_desc', label: '销量优先' }, +] + +const SEARCH_IMAGE_OPTIONS = [ + { key: 'all', label: '全部' }, + { key: 'with_image', label: '有图' }, + { key: 'without_image', label: '无图' }, +] as const + +const SEARCH_UPDATED_OPTIONS = [ + { key: 'all', label: '全部' }, + { key: 'today', label: '今天' }, + { key: '7d', label: '近 7 天' }, + { key: '30d', label: '近 30 天' }, +] as const + +type SearchImageFilter = (typeof SEARCH_IMAGE_OPTIONS)[number]['key'] +type SearchUpdatedFilter = (typeof SEARCH_UPDATED_OPTIONS)[number]['key'] + +const RECENT_GOODS_PAGE_SIZE = 20 +const recentGoodsDateFormatter = new Intl.DateTimeFormat('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', +}) + +const getGoodsList = (payload: AppGoodsPageResponse | null) => + (payload?.list ?? []).filter(item => item.saleStatus === 'on') + +const getRecentNewGoodsList = (payload: AppRecentNewGoodsResponse | null) => + [...(payload?.list ?? [])].sort((a, b) => { + const timeDiff = b.createdAt - a.createdAt + + if (timeDiff !== 0) { + return timeDiff + } + + return String(b.spuId).localeCompare(String(a.spuId), 'zh-CN', { numeric: true }) + }) + +const formatRecentGoodsDate = (createdAt: number) => { + if (!Number.isFinite(createdAt) || createdAt <= 0) { + return '日期待定' + } + + return recentGoodsDateFormatter.format(new Date(createdAt * 1000)).replace(/\//g, '-') +} + +const getRecentGoodsStoreName = (item: RecentNewGoodsItem) => item.merchantName?.trim() || '' + +const formatGoodsUpdatedDate = (updatedAt: number) => formatRecentGoodsDate(updatedAt) + +const getPriceText = (item: GoodsSummary) => { + if (item.minPrice === item.maxPrice) { + return `¥${item.minPrice}` + } + + return `¥${item.minPrice} - ¥${item.maxPrice}` +} + +const getGoodsPriceNumber = (item: GoodsSummary) => { + const price = Number(item.minPrice) + return Number.isFinite(price) ? price : 0 +} + +const isGoodsInUpdatedRange = (item: GoodsSummary, range: SearchUpdatedFilter) => { + if (range === 'all') { + return true + } + + const updatedAt = Number(item.updatedAt) + + if (!Number.isFinite(updatedAt) || updatedAt <= 0) { + return false + } + + const now = Date.now() + const updatedTime = updatedAt * 1000 + const dayMs = 24 * 60 * 60 * 1000 + + if (range === 'today') { + return new Date(updatedTime).toDateString() === new Date(now).toDateString() + } + + return now - updatedTime <= (range === '7d' ? 7 : 30) * dayMs +} + +const getStoreDisplayName = (name?: string | null) => { + const text = typeof name === 'string' ? name.trim() : '' + return text || '未命名商家' +} + +const getStoreFallbackText = (name?: string | null) => { + const text = getStoreDisplayName(name) + return text === '未命名商家' ? '商家' : text.slice(0, Math.min(2, text.length)) +} + +const getStoreLink = (store: RecommendStoreInfo) => `/shop/${encodeURIComponent(store.merchantId)}` + +const formatStoreStat = (value?: number | null) => { + if (!Number.isFinite(value)) { + return '0' + } + + return new Intl.NumberFormat('zh-CN').format(value ?? 0) +} + +const getMerchantPreviewImages = ( + store: RecommendStoreInfo, + previewMap: Map +) => { + const goodsImages = previewMap.get(store.merchantId) ?? [] + + if (goodsImages.length > 0) { + return goodsImages + } + + return store.storeLogo ? [store.storeLogo] : [] +} + +function HomePage() { + const navigate = useNavigate() + const location = useLocation() + const [searchParams] = useSearchParams() + const token = useUserStore(state => state.token) + const userInfo = useUserStore(state => state.userInfo) + const storedMemberId = useUserStore(state => state.memberId) + + const currentUid = userInfo?.uid ?? storedMemberId ?? token?.memberId ?? token?.uid ?? 0 + const initialSearchKeyword = searchParams.get('key')?.trim() ?? '' + const isInitialSearchPage = location.pathname === '/list/new' || Boolean(initialSearchKeyword) + + const [keyword, setKeyword] = useState(initialSearchKeyword) + const [submittedKeyword, setSubmittedKeyword] = useState(initialSearchKeyword) + const [activeNav, setActiveNav] = useState( + searchParams.get('nav') === 'rank' ? 'rank' : 'home' + ) + const [isSearchPage, setIsSearchPage] = useState(isInitialSearchPage) + const [rankKeyword, setRankKeyword] = useState('') + const [submittedRankKeyword, setSubmittedRankKeyword] = useState('') + const [rankCategory, setRankCategory] = useState('全部') + const [searchImageFilter, setSearchImageFilter] = useState('all') + const [searchCategory, setSearchCategory] = useState('全部') + const [searchMerchantId, setSearchMerchantId] = useState('全部') + const [searchGoodsNo, setSearchGoodsNo] = useState('') + const [searchMinPrice, setSearchMinPrice] = useState('') + const [searchMaxPrice, setSearchMaxPrice] = useState('') + const [searchUpdatedRange, setSearchUpdatedRange] = useState('all') + const [sortType, setSortType] = useState('comprehensive') + const [homePayload, setHomePayload] = useState(null) + const [goodsPayload, setGoodsPayload] = useState(null) + const [recentNewGoodsPayload, setRecentNewGoodsPayload] = useState(null) + const [, setHomeLoading] = useState(true) + const [goodsLoading, setGoodsLoading] = useState(true) + const [recentNewGoodsLoading, setRecentNewGoodsLoading] = useState(true) + const [homeError, setHomeError] = useState(null) + const [goodsError, setGoodsError] = useState(null) + const [recentNewGoodsError, setRecentNewGoodsError] = useState(null) + const [recentNewGoodsRefreshKey, setRecentNewGoodsRefreshKey] = useState(0) + + useEffect(() => { + let canceled = false + + const loadHome = async () => { + setHomeLoading(true) + + try { + const response = await apiAppHome({ uid: currentUid }) + + if (canceled) { + return + } + + setHomePayload(response.data) + setHomeError(null) + } catch (error) { + if (canceled) { + return + } + + logger.warn('Load mall home failed', error) + setHomePayload(null) + setHomeError(error instanceof Error ? error.message : '首页接口请求失败') + } finally { + if (!canceled) { + setHomeLoading(false) + } + } + } + + void loadHome() + + return () => { + canceled = true + } + }, [currentUid]) + + useEffect(() => { + let canceled = false + + const loadRecentNewGoods = async () => { + setRecentNewGoodsLoading(true) + + try { + const response = await apiAppRecentNewGoods({ + uid: currentUid, + page: 1, + size: RECENT_GOODS_PAGE_SIZE, + }) + + if (canceled) { + return + } + + setRecentNewGoodsPayload(response.data) + setRecentNewGoodsError(null) + } catch (error) { + if (canceled) { + return + } + + logger.warn('Load mall recent new goods failed', error) + setRecentNewGoodsPayload(null) + setRecentNewGoodsError(error instanceof Error ? error.message : '最近上新商品接口请求失败') + } finally { + if (!canceled) { + setRecentNewGoodsLoading(false) + } + } + } + + void loadRecentNewGoods() + + return () => { + canceled = true + } + }, [currentUid, recentNewGoodsRefreshKey]) + + useEffect(() => { + let canceled = false + + const loadGoods = async () => { + setGoodsLoading(true) + + try { + const response = await apiAppGoodsPage({ + uid: currentUid, + keyword: submittedKeyword || undefined, + sortType, + page: 1, + size: 12, + }) + + if (canceled) { + return + } + + setGoodsPayload(response.data) + setGoodsError(null) + } catch (error) { + if (canceled) { + return + } + + logger.warn('Load mall goods page failed', error) + setGoodsPayload(null) + setGoodsError(error instanceof Error ? error.message : '商品列表接口请求失败') + } finally { + if (!canceled) { + setGoodsLoading(false) + } + } + } + + void loadGoods() + + return () => { + canceled = true + } + }, [currentUid, sortType, submittedKeyword]) + + const banners = useMemo(() => resolveHomeTopBanners(homePayload), [homePayload]) + const recommendStores = useMemo(() => resolveHomeRankStores(homePayload), [homePayload]) + const featuredStores = useMemo(() => resolveHomeFeaturedStores(homePayload), [homePayload]) + const searchKeywords = useMemo(() => resolveHomeSearchKeywords(homePayload), [homePayload]) + const goodsList = useMemo(() => getGoodsList(goodsPayload), [goodsPayload]) + const recentNewGoodsList = useMemo(() => getRecentNewGoodsList(recentNewGoodsPayload), [recentNewGoodsPayload]) + const hotKeywords = useMemo(() => searchKeywords.slice(0, 5), [searchKeywords]) + const rankStores = useMemo(() => { + const seenMerchantIds = new Set() + const stores: RecommendStoreInfo[] = [] + + ;[...recommendStores, ...featuredStores].forEach(store => { + if (seenMerchantIds.has(store.merchantId)) { + return + } + + seenMerchantIds.add(store.merchantId) + stores.push(store) + }) + + return stores + }, [featuredStores, recommendStores]) + const rankCategoryOptions = useMemo(() => { + const names = rankStores + .map(store => (typeof store.mainCategoryName === 'string' ? store.mainCategoryName.trim() : '')) + .filter(Boolean) + + return ['全部', ...Array.from(new Set(names))] + }, [rankStores]) + const merchantPreviewMap = useMemo(() => { + const previewMap = new Map() + + goodsList.forEach(goods => { + if (!goods.coverUrl) { + return + } + + const images = previewMap.get(goods.merchantId) ?? [] + + if (images.length >= 4 || images.includes(goods.coverUrl)) { + return + } + + previewMap.set(goods.merchantId, [...images, goods.coverUrl]) + }) + + return previewMap + }, [goodsList]) + const filteredRankStores = useMemo(() => { + const keywordText = submittedRankKeyword.trim().toLowerCase() + + return rankStores.filter(store => { + const matchesCategory = rankCategory === '全部' || store.mainCategoryName === rankCategory + const searchText = [ + store.storeName, + store.mainCategoryName, + store.storeNotice, + store.recommendReason, + ] + .join(' ') + .toLowerCase() + + return matchesCategory && (!keywordText || searchText.includes(keywordText)) + }) + }, [rankCategory, rankStores, submittedRankKeyword]) + const searchCategoryOptions = useMemo(() => { + const names = goodsList + .map(goods => goods.categoryName?.trim() ?? '') + .filter(Boolean) + + return ['全部', ...Array.from(new Set(names))] + }, [goodsList]) + const searchMerchantOptions = useMemo(() => { + const merchants = new Map() + + rankStores.forEach(store => { + merchants.set(store.merchantId, getStoreDisplayName(store.storeName)) + }) + + goodsList.forEach(goods => { + if (!goods.merchantId) { + return + } + + merchants.set(goods.merchantId, goods.merchantName?.trim() || merchants.get(goods.merchantId) || '未命名商家') + }) + + return [ + { id: '全部', name: '全部' }, + ...Array.from(merchants.entries()).map(([id, name]) => ({ id, name })), + ] + }, [goodsList, rankStores]) + const filteredSearchGoods = useMemo(() => { + const goodsNo = searchGoodsNo.trim().toLowerCase() + const minPrice = searchMinPrice.trim() ? Number(searchMinPrice) : null + const maxPrice = searchMaxPrice.trim() ? Number(searchMaxPrice) : null + + return goodsList.filter(goods => { + const price = getGoodsPriceNumber(goods) + const matchesImage = + searchImageFilter === 'all' || + (searchImageFilter === 'with_image' && Boolean(goods.coverUrl)) || + (searchImageFilter === 'without_image' && !goods.coverUrl) + const matchesCategory = searchCategory === '全部' || goods.categoryName === searchCategory + const matchesMerchant = searchMerchantId === '全部' || goods.merchantId === searchMerchantId + const matchesGoodsNo = !goodsNo || goods.spuNo.toLowerCase().includes(goodsNo) + const matchesMinPrice = minPrice == null || !Number.isFinite(minPrice) || price >= minPrice + const matchesMaxPrice = maxPrice == null || !Number.isFinite(maxPrice) || price <= maxPrice + + return ( + matchesImage && + matchesCategory && + matchesMerchant && + matchesGoodsNo && + matchesMinPrice && + matchesMaxPrice && + isGoodsInUpdatedRange(goods, searchUpdatedRange) + ) + }) + }, [ + goodsList, + searchCategory, + searchGoodsNo, + searchImageFilter, + searchMaxPrice, + searchMerchantId, + searchMinPrice, + searchUpdatedRange, + ]) + + const heroBanner = banners[0] ?? null + const loadError = homeError || goodsError || recentNewGoodsError + + const handleSearch = (nextKeyword: string) => { + const trimmedKeyword = nextKeyword.trim() + + setKeyword(trimmedKeyword) + setSubmittedKeyword(trimmedKeyword) + setActiveNav('home') + setIsSearchPage(true) + navigate(trimmedKeyword ? `/list/new?key=${encodeURIComponent(trimmedKeyword)}` : '/list/new') + } + + const handleRecentNewGoodsRefresh = () => { + setRecentNewGoodsRefreshKey(value => value + 1) + } + + const handleRecentNewGoodsClick = (item: RecentNewGoodsItem) => { + navigate(`/goods/${item.spuId}`) + } + + const handleRankSearch = () => { + setSubmittedRankKeyword(rankKeyword) + } + + const handleRankReset = () => { + setRankKeyword('') + setSubmittedRankKeyword('') + setRankCategory('全部') + } + + const handleMarketNavSelect = (key: MarketNavKey) => { + setIsSearchPage(false) + setActiveNav(key) + navigate(key === 'rank' ? '/?nav=rank' : '/') + } + + const handleSearchFilterReset = () => { + setKeyword('') + setSubmittedKeyword('') + setSearchImageFilter('all') + setSearchCategory('全部') + setSearchMerchantId('全部') + setSearchGoodsNo('') + setSearchMinPrice('') + setSearchMaxPrice('') + setSearchUpdatedRange('all') + setSortType('comprehensive') + setIsSearchPage(true) + setActiveNav('home') + navigate('/list/new') + } + + return ( +
+ { + setActiveNav('home') + setIsSearchPage(false) + navigate('/') + }} + onKeywordChange={setKeyword} + onNavSelect={handleMarketNavSelect} + onSearch={handleSearch} + /> + +
+ {isSearchPage ? ( +
+ + +
+
+
+ 商品属性: + 图片: +
+ {SEARCH_IMAGE_OPTIONS.map(option => ( + + ))} +
+ 视频: +
+ +
+
+
+ 商品分类: +
+ {searchCategoryOptions.map(option => ( + + ))} +
+
+
+ +
{ + event.preventDefault() + handleSearch(keyword) + }} + > + + + + + + + +
+ +
+
+ 排序: + {GOODS_SORT_OPTIONS.map(item => ( + + ))} +
+ +
+ + {filteredSearchGoods.length > 0 ? ( +
+ {filteredSearchGoods.map(item => ( + +
+ {item.coverUrl ? ( + {item.spuName} + ) : ( +
暂无主图
+ )} + {item.categoryName ? {item.categoryName} : null} +
+
+ {item.spuName} + {getPriceText(item)} +
+
+ {item.merchantName || '未命名商家'} + +
+ + ))} +
+ ) : ( +
{goodsLoading ? '搜索商品加载中...' : '暂无匹配商品'}
+ )} +
+
+ ) : activeNav === 'rank' ? ( +
+
{ + event.preventDefault() + handleRankSearch() + }} + > + + + + +
+ +
+ {filteredRankStores.length > 0 ? ( +
+ {filteredRankStores.map((store, index) => { + const storeName = getStoreDisplayName(store.storeName) + const previewImages = getMerchantPreviewImages(store, merchantPreviewMap) + const businessSummary = store.mainCategoryName || store.storeNotice + + return ( +
+
+ {index + 1} +
+
+
+ {storeName} + 优选商家 +
+
+ 商品数: {formatStoreStat(store.goodsCount)} + 近期上新: {formatStoreStat(store.recentNewGoodsCount)} + 出单数: {formatStoreStat(store.orderCount)} +
+ {businessSummary ?

主营: {businessSummary}

: null} +
+ 商家首页 + +
+
+
+ {previewImages.length > 0 ? ( + previewImages.slice(0, 4).map(image => ( + {`${storeName} + )) + ) : ( + 暂无商品图 + )} +
+
+ ) + })} +
+ ) : ( +
+ {homeError ? homeError : '暂无符合条件的商家'} +
+ )} +
+
+ ) : ( + <> +
+ + +
+
+ {heroBanner?.imageUrl ? ( + {heroBanner.title + ) : ( +
暂无 Banner 图片
+ )} +
+
+ + +
+ +
+
+
+

商家与供给

+

推荐商家

+
+
+ + {featuredStores.length > 0 ? ( +
+ {featuredStores.map((store, index) => { + const storeName = getStoreDisplayName(store.storeName) + const businessSummary = store.mainCategoryName || store.storeNotice + + return ( + +
+ 推荐第 {index + 1} 位 +
+ {store.storeLogo ? ( + {storeName} + ) : ( +
{getStoreFallbackText(storeName)}
+ )} +
+
+
+ {storeName} + {store.recommendReason ? ( + {store.recommendReason} + ) : null} +
+
+ {formatStoreStat(store.goodsCount)} + 商品数 +
+
+ {formatStoreStat(store.recentNewGoodsCount)} + 近期上新 +
+
+ {formatStoreStat(store.orderCount)} + 出单数 +
+
+ {businessSummary ?

主营:{businessSummary}

: null} +
+ + ) + })} +
+ ) : ( +
暂无推荐商家
+ )} +
+ +
+
+
+

{submittedKeyword ? '搜索结果' : '商品列表'}

+

{submittedKeyword ? `围绕“${submittedKeyword}”的商品陈列` : '商品列表'}

+
+
+ 总数 {goodsPayload?.total ?? 0} +
+ {GOODS_SORT_OPTIONS.map(item => ( + + ))} +
+
+
+ + {goodsList.length > 0 ? ( +
+ {goodsList.map(item => ( + +
+ {item.coverUrl ? ( + {item.spuName} + ) : ( +
暂无主图
+ )} + {item.categoryName ? {item.categoryName} : null} +
+
+ {item.spuName} + {getPriceText(item)} +
+
+ {item.merchantName || '未命名商家'} + +
+ + ))} +
+ ) : ( +
{goodsLoading ? '商品列表加载中...' : '暂无商品'}
+ )} +
+ + )} +
+ +
+
+
+ 云拓选货 +
+
+
+
+ ) +} + +export default HomePage diff --git a/src/pages/login/LoginPage.tsx b/src/pages/login/LoginPage.tsx new file mode 100644 index 0000000..8bc54e4 --- /dev/null +++ b/src/pages/login/LoginPage.tsx @@ -0,0 +1,412 @@ +import { useEffect, useRef, useState } from 'react' +import { Link, useLocation, useNavigate } from 'react-router-dom' +import { toast } from 'sonner' +import { apiGetSmsCode, apiUserLogin, apiUserRegister } from '@/apis' +import MarketPageHeader from '@/components/MarketPageHeader' +import { useUserStore } from '@/store/user' + +type LoginMode = 'password' | 'sms' + +function LoginPage() { + const location = useLocation() + const navigate = useNavigate() + const token = useUserStore(state => state.token) + const setSession = useUserStore(state => state.setSession) + const refreshUser = useUserStore(state => state.refreshUser) + const isRegisterView = location.pathname === '/register' + + const [mode, setMode] = useState('password') + const [account, setAccount] = useState('') + const [password, setPassword] = useState('') + const [captcha, setCaptcha] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [inviteCode, setInviteCode] = useState('') + const [headerKeyword, setHeaderKeyword] = useState('') + const [loading, setLoading] = useState(false) + const [smsSending, setSmsSending] = useState(false) + const [smsLeft, setSmsLeft] = useState(0) + const timerRef = useRef(null) + + useEffect(() => { + if (token?.accessToken) { + navigate('/', { replace: true }) + } + }, [navigate, token]) + + useEffect(() => { + return () => { + if (timerRef.current) { + window.clearInterval(timerRef.current) + timerRef.current = null + } + } + }, []) + + useEffect(() => { + if (!isRegisterView) { + return + } + + const searchParams = new URLSearchParams(location.search) + const invite = searchParams.get('inviteCode') || searchParams.get('invite') || '' + if (invite) { + setInviteCode(invite) + } + }, [isRegisterView, location.search]) + + const clearCountdown = () => { + if (timerRef.current) { + window.clearInterval(timerRef.current) + timerRef.current = null + } + setSmsLeft(0) + } + + useEffect(() => { + clearCountdown() + setCaptcha('') + setPassword('') + setConfirmPassword('') + + if (!isRegisterView) { + setInviteCode('') + } + }, [isRegisterView]) + + const startCountdown = (seconds: number) => { + clearCountdown() + setSmsLeft(seconds) + + timerRef.current = window.setInterval(() => { + setSmsLeft(value => { + if (value <= 1) { + if (timerRef.current) { + window.clearInterval(timerRef.current) + timerRef.current = null + } + return 0 + } + return value - 1 + }) + }, 1000) + } + + const handleSendSms = async () => { + if (!/^1\d{10}$/.test(account.trim())) { + toast.error('请输入有效的手机号') + return + } + + try { + setSmsSending(true) + await apiGetSmsCode({ + account: account.trim(), + loginType: '0', + type: isRegisterView ? '0' : '1', + }) + toast.success('验证码已发送') + startCountdown(60) + } catch (error: unknown) { + toast.error(error instanceof Error ? error.message : '发送验证码失败') + } finally { + setSmsSending(false) + } + } + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault() + + if (!account.trim()) { + toast.error('请输入手机号') + return + } + + if (!/^1\d{10}$/.test(account.trim())) { + toast.error('请输入有效的手机号') + return + } + + if (!isRegisterView && mode === 'password' && password.trim().length < 6) { + toast.error('请输入至少 6 位密码') + return + } + + if (!isRegisterView && mode === 'sms' && !/^\d{4}$/.test(captcha.trim())) { + toast.error('请输入 4 位短信验证码') + return + } + + if (isRegisterView && !/^\d{4}$/.test(captcha.trim())) { + toast.error('请输入 4 位短信验证码') + return + } + + if (isRegisterView && password.trim().length < 6) { + toast.error('请输入至少 6 位密码') + return + } + + if (isRegisterView && confirmPassword.trim().length < 6) { + toast.error('请再次输入至少 6 位密码') + return + } + + if (isRegisterView && password.trim() !== confirmPassword.trim()) { + toast.error('两次输入的密码不一致') + return + } + + try { + setLoading(true) + const response = isRegisterView + ? await apiUserRegister({ + account: account.trim(), + password: password.trim(), + captcha: captcha.trim(), + loginType: '0', + type: '0', + invite: inviteCode.trim() || undefined, + }) + : await apiUserLogin( + mode === 'password' + ? { + account: account.trim(), + password: password.trim(), + loginType: '0', + type: '0', + } + : { + account: account.trim(), + captcha: captcha.trim(), + loginType: '0', + type: '1', + } + ) + + setSession({ + token: response.data, + memberId: response.data.memberId ?? response.data.uid ?? null, + }) + await refreshUser() + toast.success(isRegisterView ? '注册成功' : '登录成功') + navigate('/', { replace: true }) + } catch (error: unknown) { + toast.error(error instanceof Error ? error.message : isRegisterView ? '注册失败' : '登录失败') + } finally { + setLoading(false) + } + } + + const handleHeaderSearch = (nextKeyword: string) => { + const trimmedKeyword = nextKeyword.trim() + navigate(trimmedKeyword ? `/list/new?key=${encodeURIComponent(trimmedKeyword)}` : '/list/new') + } + + return ( +
+ + +
+
+
+ + 返回商城首页 + +

{isRegisterView ? '用户注册' : '用户登录'}

+

{isRegisterView ? '把 ERP 注册接口接进商城前台。' : '把 ERP 登录接口接进商城前台。'}

+

+ 登录、注册、验证码和用户详情统一复用 `YT-ERP` 的 ERP 用户接口定义,商城前台只保留自己的页面视觉和路由。 +

+ +
+
+ 登录接口 + `/api/v1/user/login` +
+
+ 注册接口 + `/api/v1/user/register` +
+
+ 短信与用户详情 + `/api/v1/user/captcha` · `/api/v1/user/detail` +
+
+
+ +
+
+ + 登录 + + + 注册 + +
+ +
+

{isRegisterView ? '创建账户' : '欢迎回来'}

+

{isRegisterView ? '注册 ERP 采购账户' : '登录 ERP 采购账户'}

+
+ + {!isRegisterView ? ( +
+ + +
+ ) : ( +

注册使用 ERP 短信验证码接口,邀请码为选填项。

+ )} + +
+ + + {!isRegisterView && mode === 'password' ? ( + + ) : null} + + {!isRegisterView && mode === 'sms' ? ( + + ) : null} + + {isRegisterView ? ( + <> + + + + + + + + + ) : null} + + +
+ +

+ {isRegisterView ? ( + <> + 已有账号?去登录 + + ) : ( + <> + 还没有账号?立即注册 + + )} +

+
+
+
+
+ ) +} + +export default LoginPage diff --git a/src/pages/member/MemberCenterPage.tsx b/src/pages/member/MemberCenterPage.tsx new file mode 100644 index 0000000..f785713 --- /dev/null +++ b/src/pages/member/MemberCenterPage.tsx @@ -0,0 +1,455 @@ +import { useEffect, useMemo, useState } from 'react' +import { Link, useNavigate, useSearchParams } from 'react-router-dom' +import { toast } from 'sonner' +import { apiAppCartList, apiAppCartRemove, apiAppCartSave, apiAppOrderPage } from '@/apis' +import MarketPageHeader from '@/components/MarketPageHeader' +import { useUserStore } from '@/store/user' +import type { AppCartListResponse, AppOrderPageResponse, CartItemInfo, OrderSummary } from '@/types/mall' + +type MemberTabKey = 'cart' | 'orders' | 'after-sales' + +const MEMBER_TABS: Array<{ key: MemberTabKey; label: string }> = [ + { key: 'cart', label: '购物车' }, + { key: 'orders', label: '订单页面' }, + { key: 'after-sales', label: '售后管理' }, +] + +const ORDER_STATUS_TEXT: Record = { + pending_pay: '待付款', + paid_wait_ship: '待发货', + shipped: '待收货', + finished: '已完成', + canceled: '已取消', + after_sale: '售后中', + refund_finished: '退款完成', +} + +const AFTER_SALE_STATUS_TEXT: Record = { + none: '无售后', + pending: '待处理', + approved: '已同意', + rejected: '已拒绝', + refunding: '退款中', + finished: '已完成', + closed: '已关闭', +} + +const memberDateFormatter = new Intl.DateTimeFormat('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', +}) + +const getCurrentTab = (value: string | null): MemberTabKey => + MEMBER_TABS.some(item => item.key === value) ? (value as MemberTabKey) : 'cart' + +const formatDate = (value: number) => { + if (!Number.isFinite(value) || value <= 0) { + return '-' + } + + return memberDateFormatter.format(new Date(value * 1000)).replace(/\//g, '-') +} + +const formatMoney = (value?: string | number | null) => { + const text = value == null ? '' : String(value) + return text ? `¥${text.replace(/\.00$/, '')}` : '¥0' +} + +const parseSkuSpecs = (value: string) => { + if (!value) { + return '' + } + + try { + const parsed = JSON.parse(value) as unknown + + if (Array.isArray(parsed)) { + return parsed + .map(item => { + if (!item || typeof item !== 'object') { + return '' + } + + const spec = item as { name?: unknown; value?: unknown } + const name = typeof spec.name === 'string' ? spec.name : '' + const specValue = typeof spec.value === 'string' ? spec.value : '' + + return [name, specValue].filter(Boolean).join(': ') + }) + .filter(Boolean) + .join(' / ') + } + + if (parsed && typeof parsed === 'object') { + return Object.entries(parsed as Record) + .map(([key, specValue]) => `${key}: ${String(specValue)}`) + .join(' / ') + } + } catch { + return value + } + + return value +} + +const getCartItemAmount = (item: CartItemInfo) => { + const price = Number(item.salePrice) + const count = Number(item.buyNum) + + if (!Number.isFinite(price) || !Number.isFinite(count)) { + return '0' + } + + return (price * count).toFixed(2) +} + +const getOrderStatusText = (order: OrderSummary) => ORDER_STATUS_TEXT[order.orderStatus] ?? order.orderStatus + +const getAfterSaleStatusText = (order: OrderSummary) => + AFTER_SALE_STATUS_TEXT[order.afterSaleStatus] ?? order.afterSaleStatus + +function MemberCenterPage() { + const navigate = useNavigate() + const [searchParams, setSearchParams] = useSearchParams() + const token = useUserStore(state => state.token) + const userInfo = useUserStore(state => state.userInfo) + const storedMemberId = useUserStore(state => state.memberId) + const currentUid = userInfo?.uid ?? storedMemberId ?? token?.memberId ?? token?.uid ?? 0 + const activeTab = getCurrentTab(searchParams.get('tab')) + + const [keyword, setKeyword] = useState('') + const [cartPayload, setCartPayload] = useState(null) + const [orderPayload, setOrderPayload] = useState(null) + const [cartLoading, setCartLoading] = useState(false) + const [ordersLoading, setOrdersLoading] = useState(false) + const [cartError, setCartError] = useState(null) + const [ordersError, setOrdersError] = useState(null) + const [cartRefreshKey, setCartRefreshKey] = useState(0) + + const isLoggedIn = Boolean(token) + + useEffect(() => { + if (!isLoggedIn) { + setCartPayload(null) + return + } + + const controller = new AbortController() + setCartLoading(true) + setCartError(null) + + apiAppCartList({ uid: currentUid }, controller.signal) + .then(response => { + setCartPayload(response.data) + }) + .catch(error => { + if (!controller.signal.aborted) { + setCartPayload(null) + setCartError(error instanceof Error ? error.message : '购物车加载失败') + } + }) + .finally(() => { + if (!controller.signal.aborted) { + setCartLoading(false) + } + }) + + return () => controller.abort() + }, [cartRefreshKey, currentUid, isLoggedIn]) + + useEffect(() => { + if (!isLoggedIn) { + setOrderPayload(null) + return + } + + const controller = new AbortController() + setOrdersLoading(true) + setOrdersError(null) + + apiAppOrderPage({ uid: currentUid, page: 1, size: 50 }, controller.signal) + .then(response => { + setOrderPayload(response.data) + }) + .catch(error => { + if (!controller.signal.aborted) { + setOrderPayload(null) + setOrdersError(error instanceof Error ? error.message : '订单加载失败') + } + }) + .finally(() => { + if (!controller.signal.aborted) { + setOrdersLoading(false) + } + }) + + return () => controller.abort() + }, [currentUid, isLoggedIn]) + + const cartItems = cartPayload?.list ?? [] + const orders = orderPayload?.list ?? [] + const afterSaleOrders = useMemo( + () => orders.filter(order => order.afterSaleStatus !== 'none' || order.orderStatus === 'after_sale'), + [orders] + ) + const checkedCartAmount = + cartPayload?.checkedAmount ?? + cartItems + .reduce((total, item) => { + if (!item.checked) { + return total + } + + return total + Number(getCartItemAmount(item)) + }, 0) + .toFixed(2) + + const handleTabChange = (key: MemberTabKey) => { + setSearchParams({ tab: key }) + } + + const handleCartQuantityChange = async (item: CartItemInfo, buyNum: number) => { + if (buyNum < 1) { + return + } + + try { + await apiAppCartSave({ + uid: currentUid, + skuId: item.skuId, + buyNum, + checked: item.checked, + }) + setCartRefreshKey(value => value + 1) + } catch (error) { + toast.error(error instanceof Error ? error.message : '购物车更新失败') + } + } + + const handleCartCheckedChange = async (item: CartItemInfo, checked: boolean) => { + try { + await apiAppCartSave({ + uid: currentUid, + skuId: item.skuId, + buyNum: item.buyNum, + checked, + }) + setCartRefreshKey(value => value + 1) + } catch (error) { + toast.error(error instanceof Error ? error.message : '购物车更新失败') + } + } + + const handleCartRemove = async (item: CartItemInfo) => { + try { + await apiAppCartRemove({ uid: currentUid, skuIds: [item.skuId] }) + setCartRefreshKey(value => value + 1) + } catch (error) { + toast.error(error instanceof Error ? error.message : '商品移除失败') + } + } + + const handleSearch = (nextKeyword: string) => { + const trimmedKeyword = nextKeyword.trim() + navigate(trimmedKeyword ? `/list/new?key=${encodeURIComponent(trimmedKeyword)}` : '/list/new') + } + + return ( +
+ + +
+
+ + +
+ {!isLoggedIn ? ( +
+ 请先登录 + + 去登录 + +
+ ) : null} + + {isLoggedIn && activeTab === 'cart' ? ( + <> +
+
+

购物车

+

我的购物车

+
+
+ 已选 {cartPayload?.checkedCount ?? 0} 件 + {formatMoney(checkedCartAmount)} +
+
+ + {cartLoading ?
购物车加载中...
: null} + {!cartLoading && cartError ?
{cartError}
: null} + {!cartLoading && !cartError && cartItems.length === 0 ? ( +
购物车暂无商品
+ ) : null} + {!cartLoading && !cartError && cartItems.length > 0 ? ( +
+ {cartItems.map(item => { + const specText = parseSkuSpecs(item.skuSpecJson) + + return ( +
+ handleCartCheckedChange(item, event.target.checked)} + /> + + {item.coverUrl ? ( + {item.spuName} + ) : ( + 暂无主图 + )} + +
+ {item.spuName} + {item.skuName ? {item.skuName} : null} + {specText ? {specText} : null} +
+ {formatMoney(item.salePrice)} +
+ + {item.buyNum} + +
+ {formatMoney(getCartItemAmount(item))} + +
+ ) + })} +
+ ) : null} + + ) : null} + + {isLoggedIn && activeTab === 'orders' ? ( + <> +
+
+

订单

+

订单页面

+
+ 共 {orderPayload?.total ?? orders.length} 个订单 +
+ + + ) : null} + + {isLoggedIn && activeTab === 'after-sales' ? ( + <> +
+
+

售后

+

售后管理

+
+ 共 {afterSaleOrders.length} 个售后订单 +
+ + + ) : null} +
+
+
+
+ ) +} + +function OrderList({ + emptyText, + error, + loading, + orders, + showAfterSale = false, +}: { + emptyText: string + error: string | null + loading: boolean + orders: OrderSummary[] + showAfterSale?: boolean +}) { + if (loading) { + return
订单加载中...
+ } + + if (error) { + return
{error}
+ } + + if (orders.length === 0) { + return
{emptyText}
+ } + + return ( +
+ {orders.map(order => ( +
+
+
+ {order.orderNo} + {order.storeName} +
+ + {showAfterSale ? getAfterSaleStatusText(order) : getOrderStatusText(order)} + +
+
+
+ 商品金额 + {formatMoney(order.goodsAmount)} +
+
+ 运费 + {formatMoney(order.freightAmount)} +
+
+ 实付 + {formatMoney(order.payAmount)} +
+
+ 下单时间 + {formatDate(order.createdAt)} +
+
+
+ ))} +
+ ) +} + +export default MemberCenterPage diff --git a/src/pages/shop/ShopPage.tsx b/src/pages/shop/ShopPage.tsx new file mode 100644 index 0000000..3fe6a50 --- /dev/null +++ b/src/pages/shop/ShopPage.tsx @@ -0,0 +1,246 @@ +import { useEffect, useMemo, useState } from 'react' +import { Link, useNavigate, useParams } from 'react-router-dom' +import { apiAppGoodsPage, apiAppHomeMerchantGoods } from '@/apis' +import MarketPageHeader from '@/components/MarketPageHeader' +import { useUserStore } from '@/store/user' +import type { AppGoodsPageResponse, HomeMerchantGoodsBlock, GoodsSortType, GoodsSummary } from '@/types/mall' + +const GOODS_SORT_OPTIONS: Array<{ key: GoodsSortType; label: string }> = [ + { key: 'comprehensive', label: '综合' }, + { key: 'sales_desc', label: '上新' }, + { key: 'price_asc', label: '价格升序' }, + { key: 'price_desc', label: '价格降序' }, +] + +const shopDateFormatter = new Intl.DateTimeFormat('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', +}) + +const formatDate = (value: number) => { + if (!Number.isFinite(value) || value <= 0) { + return '日期待定' + } + + return shopDateFormatter.format(new Date(value * 1000)).replace(/\//g, '-') +} + +const formatPrice = (item: GoodsSummary) => { + const min = item.minPrice?.replace(/\.00$/, '') || '--' + const max = item.maxPrice?.replace(/\.00$/, '') || min + + return min === max ? `¥${min}` : `¥${min}-${max}` +} + +function ShopPage() { + const { merchantId = '' } = useParams() + const navigate = useNavigate() + const token = useUserStore(state => state.token) + const userInfo = useUserStore(state => state.userInfo) + const storedMemberId = useUserStore(state => state.memberId) + + const currentUid = userInfo?.uid ?? storedMemberId ?? token?.memberId ?? token?.uid ?? 0 + + const [headerKeyword, setHeaderKeyword] = useState('') + const [keyword, setKeyword] = useState('') + const [searchKeyword, setSearchKeyword] = useState('') + const [sortType, setSortType] = useState('comprehensive') + const [merchantBlock, setMerchantBlock] = useState(null) + const [goodsPayload, setGoodsPayload] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + if (!merchantId) { + setError('商家参数缺失') + setLoading(false) + return + } + + const controller = new AbortController() + setError(null) + + apiAppHomeMerchantGoods({ uid: currentUid, merchantId, limit: 12 }, controller.signal) + .then(response => { + setMerchantBlock(response.data.data) + }) + .catch(() => undefined) + + return () => controller.abort() + }, [currentUid, merchantId]) + + useEffect(() => { + if (!merchantId) { + return + } + + const controller = new AbortController() + setLoading(true) + setError(null) + + apiAppGoodsPage( + { + uid: currentUid, + merchantId, + keyword: searchKeyword || undefined, + sortType, + page: 1, + size: 24, + }, + controller.signal + ) + .then(response => { + setGoodsPayload(response.data) + }) + .catch(nextError => { + if (!controller.signal.aborted) { + setError(nextError instanceof Error ? nextError.message : '商家商品接口请求失败') + } + }) + .finally(() => { + if (!controller.signal.aborted) { + setLoading(false) + } + }) + + return () => controller.abort() + }, [currentUid, merchantId, searchKeyword, sortType]) + + const goodsList = useMemo(() => goodsPayload?.list ?? merchantBlock?.goods ?? [], [goodsPayload, merchantBlock]) + const merchant = merchantBlock?.merchant ?? merchantBlock?.store ?? null + const merchantName = merchant?.storeName?.trim() || goodsList[0]?.merchantName?.trim() || '未命名商家' + + const handleSearch = () => { + setSearchKeyword(keyword.trim()) + } + + const handleHeaderSearch = (nextKeyword: string) => { + const trimmedKeyword = nextKeyword.trim() + navigate(trimmedKeyword ? `/list/new?key=${encodeURIComponent(trimmedKeyword)}` : '/list/new') + } + + return ( +
+ + +
+
+ + +
+
+ 商品属性: + 图片:全部 + 状态:上架 + 视频:全部 +
+ +
{ + event.preventDefault() + handleSearch() + }} + > + + + +
+ +
+ 排序: + {GOODS_SORT_OPTIONS.map(item => ( + + ))} + 共 {goodsPayload?.total ?? goodsList.length} 件商品 +
+ + {error ?
{error}
: null} + {!error && goodsList.length > 0 ? ( +
+ {goodsList.map(item => ( + +
+ {item.coverUrl ? ( + {item.spuName} + ) : ( +
暂无主图
+ )} + {item.categoryName ? {item.categoryName} : null} +
+
+ {item.spuName} + {formatPrice(item)} +
+
+ {item.merchantName || merchantName} + +
+ + ))} +
+ ) : null} + {!error && !loading && goodsList.length === 0 ?
暂无商品
: null} +
+
+
+
+ ) +} + +export default ShopPage diff --git a/src/store/user.ts b/src/store/user.ts new file mode 100644 index 0000000..1b79449 --- /dev/null +++ b/src/store/user.ts @@ -0,0 +1,105 @@ +import { create } from 'zustand' +import { createJSONStorage, persist } from 'zustand/middleware' +import { apiGetUserInfo } from '@/apis/apiUser' +import type { ITokenData } from '@/types/ITokenData' +import type { IUserInfo } from '@/types/IUserInfo' +import logger from '@/utils/logger' + +const STORAGE_KEY = 'mall-user-store' + +interface UserState { + token: ITokenData | null + userInfo: IUserInfo | null + memberId: number | null + refreshUsering: boolean + setToken: (token: ITokenData | null) => void + setUserInfo: (userInfo: IUserInfo | null) => void + setMemberId: (memberId: number | null) => void + setSession: (payload: { + token: ITokenData | null + memberId?: number | null + userInfo?: IUserInfo | null + }) => void + refreshUser: () => Promise + refreshAll: () => Promise + logout: () => void + clearStorage: () => void +} + +export const useUserStore = create()( + persist( + (set, get) => ({ + token: null, + userInfo: null, + memberId: null, + refreshUsering: false, + setToken: token => { + set({ + token, + memberId: token?.memberId ?? token?.uid ?? get().memberId, + }) + }, + setUserInfo: userInfo => { + set({ + userInfo, + memberId: userInfo?.uid ?? get().memberId, + }) + }, + setMemberId: memberId => { + set({ memberId }) + }, + setSession: ({ token, memberId, userInfo }) => { + set({ + token, + userInfo: userInfo ?? get().userInfo, + memberId: memberId ?? userInfo?.uid ?? token?.memberId ?? token?.uid ?? null, + }) + }, + refreshUser: async () => { + try { + if (!get().token?.accessToken) { + return + } + set({ refreshUsering: true }) + const response = await apiGetUserInfo() + set({ + userInfo: response.data, + memberId: response.data.uid, + }) + } catch (error) { + logger.warn('Refresh user failed', error) + } finally { + set({ refreshUsering: false }) + } + }, + refreshAll: async () => { + await get().refreshUser() + }, + logout: () => { + get().clearStorage() + }, + clearStorage: () => { + set({ + token: null, + userInfo: null, + memberId: null, + }) + localStorage.removeItem(STORAGE_KEY) + }, + }), + { + name: STORAGE_KEY, + storage: createJSONStorage(() => localStorage), + partialize: state => ({ + token: state.token, + userInfo: state.userInfo, + memberId: state.memberId, + }), + onRehydrateStorage: () => state => { + if (state?.token?.accessToken) { + void state.refreshAll() + } + }, + } + ) +) diff --git a/src/types/ITokenData.ts b/src/types/ITokenData.ts new file mode 100644 index 0000000..fa099dc --- /dev/null +++ b/src/types/ITokenData.ts @@ -0,0 +1,11 @@ +export interface ITokenData { + accessToken: string + accessExpire: number + refreshAfter: number + role?: string[] + utype?: string + uid?: number + account?: string + refreshToken?: string + memberId?: number +} diff --git a/src/types/IUserInfo.ts b/src/types/IUserInfo.ts new file mode 100644 index 0000000..1046cdb --- /dev/null +++ b/src/types/IUserInfo.ts @@ -0,0 +1,6 @@ +export interface IUserInfo { + uid: number + name: string + account: string +} + diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..40fe781 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,3 @@ +export * from './ITokenData' +export * from './IUserInfo' +export * from './mall' diff --git a/src/types/mall.ts b/src/types/mall.ts new file mode 100644 index 0000000..1c2b479 --- /dev/null +++ b/src/types/mall.ts @@ -0,0 +1,659 @@ +import type { IPageParams } from '@/apis/base/types' + +export type EnabledStatus = 'enabled' | 'disabled' +export type AuditStatus = 'pending' | 'approved' | 'rejected' +export type SettleStatus = 'pending' | 'active' | 'frozen' +export type SaleStatus = 'off' | 'on' +export type PayStatus = 'pending' | 'paid' | 'refunded' +export type TradeStatus = 'pending' | 'paid' | 'closed' | 'finished' | 'refunding' | 'refunded' +export type OrderStatus = + | 'pending_pay' + | 'paid_wait_ship' + | 'shipped' + | 'finished' + | 'canceled' + | 'after_sale' + | 'refund_finished' +export type AfterSaleStatus = + | 'none' + | 'pending' + | 'approved' + | 'rejected' + | 'refunding' + | 'finished' + | 'closed' +export type CartItemStatus = 'enabled' | 'disabled' +export type GoodsSortType = 'comprehensive' | 'price_asc' | 'price_desc' | 'sales_desc' +export type AfterSaleType = 1 | 2 +export type MerchantId = string +export type HomePositionCode = + | 'home_top' + | 'home_rank_store' + | 'home_featured_store' + | 'home_featured_goods' +export type BannerLinkType = 'goods' | 'store' | 'topic' | 'h5' | (string & {}) + +export interface MediaJson { + images: string[] + videos: string[] +} + +export interface DetailBlock { + type: string + value: string +} + +export interface DetailJson { + blocks: DetailBlock[] +} + +export interface SpecItem { + name: string + value: string +} + +export interface ParamItem { + name: string + value: string +} + +export interface ServicePromiseItem { + title: string + desc: string +} + +export interface EvidenceJson { + images: string[] + videos: string[] +} + +export interface ExtraInfoJson { + source?: string + tags?: string[] + [key: string]: unknown +} + +export interface StoreInfo { + id: number + merchantId: MerchantId + merchantName?: string + logo?: string + storeNo: string + storeName: string + storeLogo: string + storeBanner: string + storeNotice: string + storeStatus: EnabledStatus + auditStatus: AuditStatus + servicePhone: string + serviceEmail: string + contactName: string + contactMobile: string + province: string + city: string + district: string + addressDetail: string + zipCode: string + sort: number + createdAt: number + updatedAt: number +} + +export type MerchantInfo = StoreInfo + +export interface GoodsSkuInfo { + id: number + spuId: number + merchantId: MerchantId + storeId: number + skuNo: string + skuName: string + barcode: string + imageUrl: string + specJson: string + salePrice: string + originPrice: string + costPrice: string + weight: string + status: EnabledStatus + stock: number + lockedStock: number + warnStock: number + createdAt: number + updatedAt: number +} + +export interface GoodsSummary { + id: number + merchantId: MerchantId + merchantName?: string + merchantLogo?: string + storeId: number + categoryId: number + categoryName: string + spuNo: string + spuName: string + subTitle: string + coverUrl: string + status: EnabledStatus + auditStatus: AuditStatus + saleStatus: SaleStatus + sort: number + salesVolume: number + virtualSales: number + viewCount: number + minPrice: string + maxPrice: string + totalStock: number + lockedStock: number + createdAt: number + updatedAt: number +} + +export interface GoodsDetailInfo { + id: number + merchantId: MerchantId + storeId: number + categoryId: number + categoryName?: string + spuNo: string + spuName: string + subTitle: string + coverUrl: string + mediaJson: string + detailJson: string + extraInfoJson: string + status: EnabledStatus + auditStatus: AuditStatus + saleStatus: SaleStatus + sort: number + salesVolume: number + virtualSales: number + viewCount: number + minPrice: string + maxPrice: string + totalStock: number + lockedStock: number + paramJson: string + serviceJson: string + skus: GoodsSkuInfo[] + createdAt: number + updatedAt: number +} + +export interface CartItemInfo { + id: number + uid: number + memberId?: number + merchantId: MerchantId + storeId: number + spuId: number + skuId: number + spuName: string + skuName: string + coverUrl: string + skuSpecJson: string + buyNum: number + checked: boolean + salePrice: string + status: CartItemStatus + createdAt: number + updatedAt: number +} + +export interface TradeBuyItem { + skuId: number + buyNum: number +} + +export interface TradePreviewItemInfo { + spuId: number + skuId: number + spuName: string + skuName: string + coverUrl: string + skuSpecJson: string + buyNum: number + salePrice: string + itemAmount: string +} + +export interface TradePreviewStoreInfo { + merchantId: MerchantId + storeId: number + storeName: string + goodsAmount: string + freightAmount: string + discountAmount: string + payAmount: string + items: TradePreviewItemInfo[] +} + +export interface TradePreviewInfo { + uid: number + memberId?: number + addressId: number + receiverName: string + receiverMobile: string + receiverCountryCode: string + receiverProvince: string + receiverCity: string + receiverDistrict: string + receiverAddress: string + receiverZipCode: string + buyerRemark: string + currency: string + goodsAmount: string + freightAmount: string + discountAmount: string + payAmount: string + orderCount: number + merchantGroups: TradePreviewStoreInfo[] + storeGroups?: TradePreviewStoreInfo[] +} + +export interface OrderItemInfo { + id: number + orderId: number + tradeId: number + orderNo: string + spuId: number + skuId: number + spuName: string + skuName: string + coverUrl: string + skuSpecJson: string + buyNum: number + salePrice: string + originPrice: string + discountAmount: string + itemAmount: string + refundAmount: string + afterSaleStatus: AfterSaleStatus +} + +export interface OrderSummary { + id: number + tradeId: number + tradeNo: string + orderNo: string + uid: number + memberId?: number + merchantId: MerchantId + storeId: number + storeName: string + orderStatus: OrderStatus + afterSaleStatus: AfterSaleStatus + currency: string + goodsAmount: string + freightAmount: string + discountAmount: string + payAmount: string + payTime: number + shipTime: number + finishTime: number + cancelTime: number + createdAt: number + updatedAt: number +} + +export interface OrderDetailInfo { + id: number + tradeId: number + tradeNo: string + orderNo: string + uid: number + memberId?: number + merchantId: MerchantId + storeId: number + storeName: string + orderStatus: OrderStatus + afterSaleStatus: AfterSaleStatus + currency: string + goodsAmount: string + freightAmount: string + discountAmount: string + payAmount: string + buyerRemark: string + sellerRemark: string + receiverName: string + receiverMobile: string + receiverCountryCode: string + receiverProvince: string + receiverCity: string + receiverDistrict: string + receiverAddress: string + receiverZipCode: string + deliveryCompany: string + deliveryNo: string + payChannel: string + extraInfoJson: string + payTime: number + shipTime: number + finishTime: number + cancelTime: number + autoConfirmAt: number + items: OrderItemInfo[] + createdAt: number + updatedAt: number +} + +export interface BannerInfo { + id: number + title: string + subTitle: string + description: string + buttonText: string + positionCode: HomePositionCode | string + imageUrl: string + linkType: BannerLinkType + linkValue: string + sort: number + status: EnabledStatus + startTime: number + endTime: number +} + +export interface HomeCategoryNav { + categoryId: number + categoryName: string + goodsCount: number + sort: number +} + +export interface HomeChannelCard { + id: number + title: string + subTitle: string + iconUrl: string + linkType: BannerLinkType + linkValue: string + sort: number + status: EnabledStatus +} + +export interface RecommendStoreInfo { + id: string | number + merchantId: MerchantId + storeName: string + storeLogo: string + storeNotice: string + storeStatus: EnabledStatus + mainCategoryName: string + recommendReason: string + rankScore: number + goodsCount: number + recentNewGoodsCount: number + orderCount: number +} + +export interface HomeStoreGoodsBlock { + merchant: RecommendStoreInfo + goods: GoodsSummary[] + store?: RecommendStoreInfo +} + +export type RecommendMerchantInfo = RecommendStoreInfo +export type HomeMerchantGoodsBlock = HomeStoreGoodsBlock +export type TradePreviewMerchantInfo = TradePreviewStoreInfo + +export interface ResolvedGoodsSkuInfo extends GoodsSkuInfo { + specs: SpecItem[] +} + +export interface ResolvedGoodsDetailInfo extends GoodsDetailInfo { + media: MediaJson + detail: DetailJson + extraInfo: ExtraInfoJson + params: ParamItem[] + services: ServicePromiseItem[] + skus: ResolvedGoodsSkuInfo[] +} + +export interface ResolvedCartItemInfo extends CartItemInfo { + skuSpecs: SpecItem[] +} + +export interface ResolvedTradePreviewItemInfo extends TradePreviewItemInfo { + skuSpecs: SpecItem[] +} + +export interface ResolvedTradePreviewStoreInfo extends TradePreviewStoreInfo { + items: ResolvedTradePreviewItemInfo[] +} + +export interface ResolvedTradePreviewInfo extends TradePreviewInfo { + merchantGroups: ResolvedTradePreviewStoreInfo[] + storeGroups?: ResolvedTradePreviewStoreInfo[] +} + +export type ResolvedTradePreviewMerchantInfo = ResolvedTradePreviewStoreInfo + +export interface ResolvedOrderItemInfo extends OrderItemInfo { + skuSpecs: SpecItem[] +} + +export interface ResolvedOrderDetailInfo extends OrderDetailInfo { + extraInfo: ExtraInfoJson + items: ResolvedOrderItemInfo[] +} + +export interface AppHomeRequest { + uid?: number + /** @deprecated use uid */ + memberId?: number +} + +export interface AppHomeResponse { + banners: BannerInfo[] + categoryNavs: HomeCategoryNav[] + searchKeywords: string[] + channels: HomeChannelCard[] + recommendGoods: GoodsSummary[] + recommendMerchants: RecommendMerchantInfo[] + featuredMerchants: RecommendMerchantInfo[] + /** @deprecated use recommendMerchants */ + recommendStores?: RecommendStoreInfo[] + /** @deprecated use featuredMerchants */ + featuredStores?: RecommendStoreInfo[] +} + +export interface RecentNewGoodsItem { + merchantId?: MerchantId + merchantName?: string + merchantLogo?: string + storeName?: string + spuId: string + spuName: string + coverUrl: string + createdAt: number +} + +export interface AppRecentNewGoodsRequest { + uid?: number + /** @deprecated use uid */ + memberId?: number + page?: number + size?: number +} + +export interface AppRecentNewGoodsResponse { + total: number + list: RecentNewGoodsItem[] +} + +export interface AppHomeMerchantGoodsRequest { + uid?: number + /** @deprecated use uid */ + memberId?: number + merchantId?: MerchantId + /** @deprecated use merchantId */ + storeId?: number + limit?: number +} + +export interface AppHomeMerchantGoodsResponse { + data: HomeMerchantGoodsBlock +} + +export interface AppGoodsPageRequest extends IPageParams { + uid?: number + /** @deprecated use uid */ + memberId?: number + merchantId?: MerchantId + /** @deprecated use merchantId */ + storeId?: number + categoryId?: number + keyword?: string + sortType?: GoodsSortType +} + +export interface AppGoodsPageResponse { + total: number + list: GoodsSummary[] +} + +export interface AppGoodsDetailRequest { + uid?: number + /** @deprecated use uid */ + memberId?: number + spuId: number | string +} + +export interface AppGoodsDetailResponse { + data: GoodsDetailInfo + merchant: MerchantInfo + /** @deprecated use merchant */ + store?: StoreInfo +} + +export interface AppCartListRequest { + uid?: number + /** @deprecated use uid */ + memberId?: number +} + +export interface AppCartListResponse { + list: CartItemInfo[] + checkedCount: number + checkedAmount: string +} + +export interface AppCartSaveRequest { + uid?: number + /** @deprecated use uid */ + memberId?: number + skuId: number + buyNum: number + checked: boolean +} + +export interface AppCartSaveResponse { + cartId: number + buyNum: number + checked: boolean +} + +export interface AppCartRemoveRequest { + uid?: number + /** @deprecated use uid */ + memberId?: number + skuIds: number[] +} + +export interface AppCartRemoveResponse { + removedCount: number +} + +export interface AppTradePreviewRequest { + uid?: number + /** @deprecated use uid */ + memberId?: number + buyItems?: TradeBuyItem[] + addressId: number + buyerRemark?: string +} + +export interface AppTradePreviewResponse { + data: TradePreviewInfo +} + +export interface AppTradeCreateRequest { + uid?: number + /** @deprecated use uid */ + memberId?: number + buyItems?: TradeBuyItem[] + addressId: number + buyerRemark?: string + payChannel: string +} + +export interface AppTradeCreateResponse { + tradeId: number + tradeNo: string + payStatus: PayStatus + tradeStatus: TradeStatus + payAmount: string + expiredAt: number + orderIds: number[] + orderNos: string[] +} + +export interface AppOrderPageRequest extends IPageParams { + uid?: number + /** @deprecated use uid */ + memberId?: number + orderStatus?: OrderStatus + afterSaleStatus?: AfterSaleStatus +} + +export interface AppOrderPageResponse { + total: number + list: OrderSummary[] +} + +export interface AppOrderDetailRequest { + uid?: number + /** @deprecated use uid */ + memberId?: number + orderId: number +} + +export interface AppOrderDetailResponse { + data: OrderDetailInfo +} + +export interface AppOrderConfirmRequest { + uid?: number + /** @deprecated use uid */ + memberId?: number + orderId: number +} + +export interface AppOrderConfirmResponse { + orderId: number + orderStatus: OrderStatus + finishTime: number +} + +export interface AppAfterSaleApplyRequest { + uid?: number + /** @deprecated use uid */ + memberId?: number + orderId: number + orderItemId: number + afterSaleType: AfterSaleType + reason: string + description?: string + evidenceJson?: string + applyAmount: string +} + +export interface AppAfterSaleApplyResponse { + afterSaleId: number + afterSaleNo: string + afterSaleStatus: Exclude +} + +export type AppHomeStoreGoodsRequest = AppHomeMerchantGoodsRequest +export type AppHomeStoreGoodsResponse = AppHomeMerchantGoodsResponse diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..770d8ef --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,74 @@ +type LogLevel = 'error' | 'warn' | 'info' | 'log' | 'debug' + +const levelWeight: Record = { + error: 4, + warn: 3, + info: 2, + log: 2, + debug: 1, +} + +const getEnabled = () => { + const value = (import.meta.env.VITE_ENABLE_LOGS ?? '').toString().trim().toLowerCase() + if (value === 'false' || value === '0' || value === 'off' || value === 'no') { + return false + } + if (value === 'true' || value === '1' || value === 'on' || value === 'yes') { + return true + } + return import.meta.env.MODE !== 'production' +} + +const getMinLevel = (): LogLevel => { + const value = (import.meta.env.VITE_LOG_LEVEL ?? '').toString().trim().toLowerCase() + if (value === 'error' || value === 'warn' || value === 'info' || value === 'log' || value === 'debug') { + return value + } + return 'warn' +} + +const shouldLog = (level: LogLevel) => { + if (!getEnabled()) { + return false + } + return levelWeight[level] >= levelWeight[getMinLevel()] +} + +export class WebLogger { + private static prefix = '[mall-app]' + + static log(...args: unknown[]) { + if (shouldLog('log')) { + console.log(this.prefix, ...args) + } + } + + static info(...args: unknown[]) { + if (shouldLog('info')) { + console.info(this.prefix, ...args) + } + } + + static warn(...args: unknown[]) { + if (shouldLog('warn')) { + console.warn(this.prefix, ...args) + } + } + + static error(...args: unknown[]) { + if (shouldLog('error')) { + console.error(this.prefix, ...args) + } + } + + static debug(...args: unknown[]) { + if (shouldLog('debug')) { + console.debug(this.prefix, ...args) + } + } +} + +export const logger = WebLogger + +export default logger + diff --git a/src/utils/mall/home.ts b/src/utils/mall/home.ts new file mode 100644 index 0000000..11ec83e --- /dev/null +++ b/src/utils/mall/home.ts @@ -0,0 +1,39 @@ +import type { + AppHomeResponse, + BannerInfo, + GoodsSummary, + HomeCategoryNav, + HomeChannelCard, + RecommendStoreInfo, +} from '@/types/mall' + +const bySort = (a: T, b: T) => a.sort - b.sort + +const isEnabledStatus = (item: T) => item.status === 'enabled' + +const isEnabledStore = (store: RecommendStoreInfo) => store.storeStatus === 'enabled' + +const isOnSaleGoods = (item: GoodsSummary) => item.saleStatus === 'on' + +export const resolveHomeTopBanners = (payload: AppHomeResponse | null): BannerInfo[] => + (payload?.banners ?? []) + .filter(item => item.positionCode === 'home_top' && isEnabledStatus(item)) + .sort(bySort) + +export const resolveHomeCategoryNavs = (payload: AppHomeResponse | null): HomeCategoryNav[] => + [...(payload?.categoryNavs ?? [])].sort(bySort) + +export const resolveHomeSearchKeywords = (payload: AppHomeResponse | null): string[] => + (payload?.searchKeywords ?? []).filter(Boolean) + +export const resolveHomeChannels = (payload: AppHomeResponse | null): HomeChannelCard[] => + (payload?.channels ?? []).filter(isEnabledStatus).sort(bySort) + +export const resolveHomeRecommendGoods = (payload: AppHomeResponse | null): GoodsSummary[] => + (payload?.recommendGoods ?? []).filter(isOnSaleGoods) + +export const resolveHomeRankStores = (payload: AppHomeResponse | null): RecommendStoreInfo[] => + (payload?.recommendMerchants ?? payload?.recommendStores ?? []).filter(isEnabledStore) + +export const resolveHomeFeaturedStores = (payload: AppHomeResponse | null): RecommendStoreInfo[] => + (payload?.featuredMerchants ?? payload?.featuredStores ?? []).filter(isEnabledStore) diff --git a/src/utils/mall/index.ts b/src/utils/mall/index.ts new file mode 100644 index 0000000..79ed109 --- /dev/null +++ b/src/utils/mall/index.ts @@ -0,0 +1,2 @@ +export * from './transform' +export * from './home' diff --git a/src/utils/mall/transform.ts b/src/utils/mall/transform.ts new file mode 100644 index 0000000..1557910 --- /dev/null +++ b/src/utils/mall/transform.ts @@ -0,0 +1,133 @@ +import type { + CartItemInfo, + DetailJson, + EvidenceJson, + ExtraInfoJson, + GoodsDetailInfo, + GoodsSkuInfo, + MediaJson, + OrderDetailInfo, + OrderItemInfo, + ParamItem, + ResolvedCartItemInfo, + ResolvedGoodsDetailInfo, + ResolvedGoodsSkuInfo, + ResolvedOrderDetailInfo, + ResolvedOrderItemInfo, + ResolvedTradePreviewInfo, + ResolvedTradePreviewItemInfo, + ResolvedTradePreviewStoreInfo, + ServicePromiseItem, + SpecItem, + TradePreviewInfo, + TradePreviewItemInfo, + TradePreviewStoreInfo, +} from '@/types/mall' +import logger from '@/utils/logger' + +const EMPTY_MEDIA_JSON: MediaJson = { + images: [], + videos: [], +} + +const EMPTY_DETAIL_JSON: DetailJson = { + blocks: [], +} + +const EMPTY_EVIDENCE_JSON: EvidenceJson = { + images: [], + videos: [], +} + +const EMPTY_EXTRA_INFO_JSON: ExtraInfoJson = {} +const EMPTY_NAME_VALUE_LIST: SpecItem[] = [] +const EMPTY_PARAM_LIST: ParamItem[] = [] +const EMPTY_SERVICE_LIST: ServicePromiseItem[] = [] + +const parseJson = (fieldName: string, value: string | null | undefined, fallback: T): T => { + if (!value) { + return fallback + } + + try { + return JSON.parse(value) as T + } catch (error) { + logger.warn(`Failed to parse ${fieldName}`, error, value) + return fallback + } +} + +export const parseMediaJson = (value?: string | null): MediaJson => + parseJson('mediaJson', value, EMPTY_MEDIA_JSON) + +export const parseDetailJson = (value?: string | null): DetailJson => + parseJson('detailJson', value, EMPTY_DETAIL_JSON) + +export const parseSpecJson = (value?: string | null): SpecItem[] => + parseJson('specJson', value, EMPTY_NAME_VALUE_LIST) + +export const parseParamJson = (value?: string | null): ParamItem[] => + parseJson('paramJson', value, EMPTY_PARAM_LIST) + +export const parseServiceJson = (value?: string | null): ServicePromiseItem[] => + parseJson('serviceJson', value, EMPTY_SERVICE_LIST) + +export const parseEvidenceJson = (value?: string | null): EvidenceJson => + parseJson('evidenceJson', value, EMPTY_EVIDENCE_JSON) + +export const parseExtraInfoJson = (value?: string | null): ExtraInfoJson => + parseJson('extraInfoJson', value, EMPTY_EXTRA_INFO_JSON) + +export const serializeEvidenceJson = (value?: EvidenceJson | null): string => + JSON.stringify(value ?? EMPTY_EVIDENCE_JSON) + +export const resolveGoodsSkuInfo = (sku: GoodsSkuInfo): ResolvedGoodsSkuInfo => ({ + ...sku, + specs: parseSpecJson(sku.specJson), +}) + +export const resolveGoodsDetailInfo = (detail: GoodsDetailInfo): ResolvedGoodsDetailInfo => ({ + ...detail, + media: parseMediaJson(detail.mediaJson), + detail: parseDetailJson(detail.detailJson), + extraInfo: parseExtraInfoJson(detail.extraInfoJson), + params: parseParamJson(detail.paramJson), + services: parseServiceJson(detail.serviceJson), + skus: detail.skus.map(resolveGoodsSkuInfo), +}) + +export const resolveCartItemInfo = (item: CartItemInfo): ResolvedCartItemInfo => ({ + ...item, + skuSpecs: parseSpecJson(item.skuSpecJson), +}) + +export const resolveTradePreviewItemInfo = ( + item: TradePreviewItemInfo +): ResolvedTradePreviewItemInfo => ({ + ...item, + skuSpecs: parseSpecJson(item.skuSpecJson), +}) + +export const resolveTradePreviewStoreInfo = ( + group: TradePreviewStoreInfo +): ResolvedTradePreviewStoreInfo => ({ + ...group, + items: group.items.map(resolveTradePreviewItemInfo), +}) + +export const resolveTradePreviewInfo = (preview: TradePreviewInfo): ResolvedTradePreviewInfo => ({ + ...preview, + merchantGroups: (preview.merchantGroups ?? preview.storeGroups ?? []).map(resolveTradePreviewStoreInfo), + storeGroups: (preview.merchantGroups ?? preview.storeGroups ?? []).map(resolveTradePreviewStoreInfo), +}) + +export const resolveOrderItemInfo = (item: OrderItemInfo): ResolvedOrderItemInfo => ({ + ...item, + skuSpecs: parseSpecJson(item.skuSpecJson), +}) + +export const resolveOrderDetailInfo = (detail: OrderDetailInfo): ResolvedOrderDetailInfo => ({ + ...detail, + extraInfo: parseExtraInfoJson(detail.extraInfoJson), + items: detail.items.map(resolveOrderItemInfo), +}) diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..ed77210 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,2 @@ +/// + diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..d5da4ed --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "allowSyntheticDefaultImports": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "resolveJsonModule": true, + "skipLibCheck": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "types": ["vite/client"] + }, + "include": ["src"] +} + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4e91a73 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "baseUrl": "./", + "paths": { + "@/*": ["src/*"] + } + }, + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} + diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..b531d12 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["vite.config.ts"] +} + diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..616ed10 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react-swc' +import { dirname, resolve } from 'path' +import { fileURLToPath } from 'url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, + server: { + port: 5000, + }, +}) +