Просмотр исходного кода

feat: [AE-会员端] AE二期重构

bill 1 неделя назад
Родитель
Сommit
f3a9899906
100 измененных файлов с 14295 добавлено и 887 удалено
  1. 1 1
      docs/ui-refactor/phase-1-login.md
  2. 8 0
      src/api/config.js
  3. 14 1
      src/api/shop.js
  4. 287 278
      src/api/user.js
  5. 1 1
      src/components/ui/UiSectionCard.vue
  6. 72 71
      src/permission.js
  7. 284 253
      src/router/index.js
  8. 34 0
      src/ui-refactor/components/OrderSuccessDialog.vue
  9. 271 0
      src/ui-refactor/components/PaymentMethods.vue
  10. 163 0
      src/ui-refactor/components/PaystackPaymentDialog.vue
  11. 91 0
      src/ui-refactor/components/ProductDetailsDialog.vue
  12. 48 0
      src/ui-refactor/components/README.md
  13. 147 0
      src/ui-refactor/components/UiDialog.vue
  14. 1 0
      src/ui-refactor/index.vue
  15. 87 0
      src/ui-refactor/styles/__tests__/theme.spec.js
  16. 231 0
      src/ui-refactor/styles/theme.scss
  17. 9 0
      src/ui-refactor/utils/paymentSummary.js
  18. 90 0
      src/ui-refactor/views/account/AccountPage.vue
  19. 91 0
      src/ui-refactor/views/account/README.md
  20. 172 0
      src/ui-refactor/views/account/__tests__/AccountBalances.spec.js
  21. 263 0
      src/ui-refactor/views/account/__tests__/AccountOrders.spec.js
  22. 345 0
      src/ui-refactor/views/account/__tests__/AccountProfile.spec.js
  23. 183 0
      src/ui-refactor/views/account/__tests__/MemberSummary.spec.js
  24. 284 0
      src/ui-refactor/views/account/__tests__/PreviousBonusDialog.spec.js
  25. 359 0
      src/ui-refactor/views/account/__tests__/RechargeAndTransferDialog.spec.js
  26. 247 0
      src/ui-refactor/views/account/__tests__/SponsorNetwork.spec.js
  27. 144 0
      src/ui-refactor/views/account/components/AccountActivity.vue
  28. 346 0
      src/ui-refactor/views/account/components/AccountBalances.vue
  29. 51 0
      src/ui-refactor/views/account/components/AccountOrders.vue
  30. 20 0
      src/ui-refactor/views/account/components/AccountOverview.vue
  31. 65 0
      src/ui-refactor/views/account/components/AccountProfile.vue
  32. 180 0
      src/ui-refactor/views/account/components/AccountShortcuts.vue
  33. 317 0
      src/ui-refactor/views/account/components/MemberSummary.vue
  34. 65 0
      src/ui-refactor/views/account/components/bonus/HistoricalCumulativeBonusDialog.vue
  35. 111 0
      src/ui-refactor/views/account/components/bonus/PreviousBonusDialog.vue
  36. 26 0
      src/ui-refactor/views/account/components/finance/README.md
  37. 34 0
      src/ui-refactor/views/account/components/finance/RechargeAndTransferDialog.vue
  38. 204 0
      src/ui-refactor/views/account/components/finance/RechargePanel.vue
  39. 236 0
      src/ui-refactor/views/account/components/finance/TransferPanel.vue
  40. 84 0
      src/ui-refactor/views/account/components/finance/finance-panel.scss
  41. 187 0
      src/ui-refactor/views/account/components/network/SponsorNetwork.vue
  42. 290 0
      src/ui-refactor/views/account/components/network/SponsorNetworkTree.vue
  43. 254 0
      src/ui-refactor/views/account/components/order/AccountOrderCards.vue
  44. 80 0
      src/ui-refactor/views/account/components/profile/AddressForm.vue
  45. 100 0
      src/ui-refactor/views/account/components/profile/DeliveryAddresses.vue
  46. 20 0
      src/ui-refactor/views/account/components/profile/LoginPasswordPanel.vue
  47. 64 0
      src/ui-refactor/views/account/components/profile/PasswordCard.vue
  48. 20 0
      src/ui-refactor/views/account/components/profile/PaymentPasswordPanel.vue
  49. 65 0
      src/ui-refactor/views/account/components/profile/PersonalInformation.vue
  50. 23 0
      src/ui-refactor/views/account/components/profile/README.md
  51. 157 0
      src/ui-refactor/views/account/components/profile/profile.scss
  52. 12 0
      src/ui-refactor/views/guest-shop/GuestCart.vue
  53. 57 0
      src/ui-refactor/views/guest-shop/GuestCheckoutFields.vue
  54. 71 0
      src/ui-refactor/views/guest-shop/GuestOrderPage.vue
  55. 355 0
      src/ui-refactor/views/guest-shop/GuestPurchaseFlow.vue
  56. 36 0
      src/ui-refactor/views/guest-shop/GuestShopPage.vue
  57. 133 0
      src/ui-refactor/views/guest-shop/MarketDialog.vue
  58. 55 0
      src/ui-refactor/views/guest-shop/OrderPaystack.js
  59. 79 0
      src/ui-refactor/views/guest-shop/README.md
  60. 43 0
      src/ui-refactor/views/guest-shop/__tests__/verify.cjs
  61. 165 0
      src/ui-refactor/views/guest-shop/__tests__/verifyBrowser.cjs
  62. 41 0
      src/ui-refactor/views/guest-shop/__tests__/verifyMutations.cjs
  63. 431 0
      src/ui-refactor/views/guest-shop/guestPurchase.js
  64. 28 0
      src/ui-refactor/views/guest-shop/markets.js
  65. 43 0
      src/ui-refactor/views/guest-shop/routes.js
  66. 17 0
      src/ui-refactor/views/guest-shop/shippingFields.js
  67. 101 0
      src/ui-refactor/views/purchase/CartItems.vue
  68. 401 0
      src/ui-refactor/views/purchase/CheckoutFields.vue
  69. 407 0
      src/ui-refactor/views/purchase/OrderSummary.vue
  70. 738 0
      src/ui-refactor/views/purchase/PurchaseFlow.vue
  71. 58 0
      src/ui-refactor/views/purchase/PurchasePage.vue
  72. 82 0
      src/ui-refactor/views/purchase/README.md
  73. 83 0
      src/ui-refactor/views/purchase/UpgradeLevelFields.vue
  74. 201 0
      src/ui-refactor/views/purchase/__tests__/OrderNavigation.spec.js
  75. 820 0
      src/ui-refactor/views/purchase/__tests__/PurchaseFlow.spec.js
  76. 41 0
      src/ui-refactor/views/purchase/__tests__/drafts.spec.js
  77. 37 0
      src/ui-refactor/views/purchase/__tests__/loadComponent.js
  78. 166 0
      src/ui-refactor/views/purchase/__tests__/verifyBrowser.cjs
  79. 30 0
      src/ui-refactor/views/purchase/__tests__/verifyMutations.cjs
  80. 53 0
      src/ui-refactor/views/purchase/adapters.js
  81. 26 0
      src/ui-refactor/views/purchase/drafts.js
  82. 565 0
      src/ui-refactor/views/purchase/purchaseBehavior.js
  83. 58 0
      src/ui-refactor/views/purchase/routes.js
  84. 134 0
      src/ui-refactor/views/purchase/submitPurchase.js
  85. 59 68
      src/ui-refactor/views/signup/SignupPage.vue
  86. 16 193
      src/ui-refactor/views/signup/components/SignupDialogs.vue
  87. 47 21
      src/ui-refactor/views/signup/components/SignupProducts.vue
  88. 11 0
      src/ui-refactor/views/team-inquiry/README.md
  89. 164 0
      src/ui-refactor/views/team-inquiry/TeamInquiryPage.vue
  90. 154 0
      src/ui-refactor/views/team-inquiry/__tests__/TeamInquiryPage.spec.js
  91. 292 0
      src/ui-refactor/views/welcome-pack/MemberWelcomePack.vue
  92. 70 0
      src/ui-refactor/views/welcome-pack/README.md
  93. 92 0
      src/ui-refactor/views/welcome-pack/WelcomePackDialogs.vue
  94. 270 0
      src/ui-refactor/views/welcome-pack/WelcomePackFields.vue
  95. 33 0
      src/ui-refactor/views/welcome-pack/WelcomePackPayment.vue
  96. 338 0
      src/ui-refactor/views/welcome-pack/__tests__/MemberWelcomePack.spec.js
  97. 117 0
      src/ui-refactor/views/welcome-pack/__tests__/WelcomePackNavigation.spec.js
  98. 67 0
      src/ui-refactor/views/welcome-pack/__tests__/preview.cjs
  99. 38 0
      src/ui-refactor/views/welcome-pack/__tests__/verify.cjs
  100. 34 0
      src/ui-refactor/views/welcome-pack/__tests__/verifyMutations.cjs

+ 1 - 1
docs/ui-refactor/phase-1-login.md

@@ -17,7 +17,7 @@
 | 外观 | 深蓝品牌面板、白色表单卡片、Montserrat;按钮主色保持 `#202259`,Element UI 控件交互沿用既有机制 |
 | 资源 | Logo 位于 `src/assets/icons/`;字体位于 `src/assets/custom-theme/fonts/`,当前不再引用旧登录页私有资源目录 |
 | 语言 | 登录页展示英文,没有 LangSelect;进入页面时通过 `app/setLanguage` 同步英文状态 |
-| 登录业务 | 保留验证码查询 / 刷新、密码显隐、Caps Lock、焦点、Enter,以及 `user/login → user/getUserInfo → user/getBaseInfo` 和原 redirect / query 传递 |
+| 登录业务 | 保留验证码查询 / 刷新、密码显隐、Caps Lock、焦点、Enter,以及 `user/login → user/getUserInfo → user/getBaseInfo`;2026-10-20 起默认及原首页 redirect 改为 `/dashboard/repeat-purchase`,其他指定页面的 redirect 与附加 query 继续保留 |
 | 附加入口 | Register 跳转 `/signup` 并保留查询参数;Forgot password 显示右下角客服 Notification;Shop now 仍是静态文案 |
 | 注册路由 | `src/router/modules/refactor.js` 定义,`src/router/index.js` 加入常量路由,`src/permission.js` 保留 `/signup` 匿名白名单 |
 

+ 8 - 0
src/api/config.js

@@ -44,3 +44,11 @@ export function getRegionList(query) {
   })
 }
 
+// 无账号下单的国家区域列表
+export function getGuestRegionList(query) {
+  return request({
+    url: '/v1/config/no-login-region-list',
+    method: 'get',
+    params: query
+  })
+}

+ 14 - 1
src/api/shop.js

@@ -1,5 +1,18 @@
 import request from '@/utils/request'
 
+// 游客购物与会员接口分开,不要求会员身份或支付密码。
+export function fetchGuestProducts(query) {
+  return request({ url: '/v1/shop/no-login-reconsume', method: 'get', params: query })
+}
+
+export function fetchGuestCart(query) {
+  return request({ url: '/v1/shop/no-login-show-cart', method: 'get', params: query })
+}
+
+export function createGuestOrder(data) {
+  return request({ url: '/v1/shop/no-login-reconsume-sure-order', method: 'post', data })
+}
+
 // 商品列表
 export function fetchProductList(query) {
   return request({
@@ -32,7 +45,7 @@ export function downloadDecOrder(orderSn) {
   return request({
     url: `/v1/shop/dec-order-export/${orderSn}`,
     method: 'get',
-    params: { }
+    params: {}
   })
 }
 

+ 287 - 278
src/api/user.js

@@ -1,278 +1,287 @@
-import request from '@/utils/request'
-
-/**
- * 登录
- * @param data
- * @param pageId
- * @returns {*}
- */
-export function login(data, pageId = '') {
-  const url = pageId ? '/v1/oauth/login?page_id=' + pageId : '/v1/oauth/login'
-  return request({
-    url,
-    method: 'post',
-    data
-  })
-}
-
-/**
- * 快速登录
- * @param params
- * @returns {*}
- */
-export function backendQuickLogin(params) {
-  const url = '/v1/oauth/login-by-backend'
-  return request({
-    url,
-    method: 'post',
-    data: {
-      ...params
-    }
-  })
-}
-
-/**
- * 登录校验
- * @param data
- * @returns {*}
- */
-export function isLoginVerify(data) {
-  return request({
-    url: '/v1/oauth/is-login-verify',
-    method: 'post',
-    data
-  })
-}
-
-/**
- * 用户信息
- * @param data
- * @returns {*}
- */
-export function getUserInfo(data) {
-  return request({
-    url: '/v1/oauth/info',
-    method: 'get',
-    data
-  })
-}
-
-/**
- * 系统信息
- * @returns {*}
- */
-export function getBaseInfo(data) {
-  return request({
-    url: '/v1/site/base-info',
-    method: 'get',
-    params: data
-  })
-}
-
-/**
- * 消息列表
- * @returns {*}
- */
-export function getMessageData() {
-  return request({
-    url: '/v1/message/unread-num',
-    method: 'get'
-  })
-}
-
-/**
- * 修改用户
- * @param data
- * @returns {*}
- */
-export function updateInfo(data) {
-  return request({
-    url: '/v1/user/edit',
-    method: 'post',
-    data
-  })
-}
-
-/**
- * 修改登录密码
- * @param data
- * @returns {*}
- */
-export function updateLoginPassword(data) {
-  return request({
-    url: '/v1/user/password',
-    method: 'post',
-    data
-  })
-}
-
-/**
- * 修改支付密码
- * @param data
- * @returns {*}
- */
-export function updatePaymentPassword(data) {
-  return request({
-    url: '/v1/user/pay-password',
-    method: 'post',
-    data
-  })
-}
-
-/**
- * BA报单
- * @returns {*}
- */
-export function fetchBrandAmbassador() {
-  return request({
-    url: '/v1/user/ba-dec',
-    method: 'get'
-  })
-}
-
-/**
- * BA报单
- * @returns {*}
- */
-export function createBrandAmbassador(data) {
-  return request({
-    url: '/v1/user/ba-dec',
-    method: 'post',
-    data
-  })
-}
-
-// 查询用户
-export function fetchFullInfo(data) {
-  return request({
-    url: '/v1/user/full-info',
-    method: 'get',
-    params: data
-  })
-}
-
-export function fetchInspectNetwork(data) {
-  return request({
-    url: '/v1/user/inspect-network',
-    method: 'get',
-    params: data
-  })
-}
-
-// 游客查询推荐人
-export function fetchGuestFullInfo(data) {
-  return request({
-    url: '/v1/user/guest-full-info',
-    method: 'get',
-    params: data
-  })
-}
-
-// 游客查询实际推荐人
-export function fetchGuestInspectNetwork(data) {
-  return request({
-    url: '/v1/user/guest-inspect-network',
-    method: 'get',
-    params: data
-  })
-}
-
-// 用户升级查询
-export function fetchUpgrade(data) {
-  return request({
-    url: '/v1/user/upgrade',
-    method: 'get',
-    params: data
-  })
-}
-
-// 用户升级
-export function fetchSendUpgrade(data) {
-  return request({
-    url: '/v1/user/upgrade',
-    method: 'post',
-    data
-  })
-}
-
-// 用户升级信息
-export function fetchUpgradeInfo(data) {
-  return request({
-    url: '/v1/user/upgrade-info',
-    method: 'post',
-    data
-  })
-}
-
-// BA会员查询
-export function fetchBrandAmbassadorInfo(data) {
-  return request({
-    url: '/v1/user/ba-user-info',
-    method: 'get',
-    params: data
-  })
-}
-
-// 自动查询区位
-export function fetchAutoPlace(data) {
-  return request({
-    url: '/v1/user/get-auto-place',
-    method: 'get',
-    params: data
-  })
-}
-
-// 用户报单查询
-export function fetchWelcomePack(data) {
-  return request({
-    url: '/v1/user/dec',
-    method: 'get',
-    params: data
-  })
-}
-
-// 用户报单/BA升级
-export function createWelcomePack(data) {
-  return request({
-    url: '/v1/user/dec',
-    method: 'post',
-    data
-  })
-}
-
-// 游客报单查询
-export function fetchGuestDec(data) {
-  return request({
-    url: '/v1/user/guest-dec',
-    method: 'get',
-    params: data
-  })
-}
-
-// 游客报单
-export function createGuestDec(data) {
-  return request({
-    url: '/v1/user/guest-dec',
-    method: 'post',
-    data
-  })
-}
-
-// 订单列表
-export function fetchOrderList(query) {
-  return request({
-    url: '/v1/shop/member-order',
-    method: 'get',
-    params: query
-  })
-}
-
-// 下载订单PDF
-export function downloadOrder(orderSn) {
-  return request({
-    url: `/v1/shop/order-export/${orderSn}`,
-    method: 'get',
-    params: {}
-  })
-}
-
+import request from '@/utils/request'
+
+/**
+ * 登录
+ * @param data
+ * @param pageId
+ * @returns {*}
+ */
+export function login(data, pageId = '') {
+  const url = pageId ? '/v1/oauth/login?page_id=' + pageId : '/v1/oauth/login'
+  return request({
+    url,
+    method: 'post',
+    data
+  })
+}
+
+/**
+ * 快速登录
+ * @param params
+ * @returns {*}
+ */
+export function backendQuickLogin(params) {
+  const url = '/v1/oauth/login-by-backend'
+  return request({
+    url,
+    method: 'post',
+    data: {
+      ...params
+    }
+  })
+}
+
+/**
+ * 登录校验
+ * @param data
+ * @returns {*}
+ */
+export function isLoginVerify(data) {
+  return request({
+    url: '/v1/oauth/is-login-verify',
+    method: 'post',
+    data
+  })
+}
+
+/**
+ * 用户信息
+ * @param data
+ * @returns {*}
+ */
+export function getUserInfo(data) {
+  return request({
+    url: '/v1/oauth/info',
+    method: 'get',
+    data
+  })
+}
+
+/**
+ * 推荐网络统计
+ */
+export function fetchNetworkStats() {
+  return request({
+    url: '/v1/user/network-stats',
+    method: 'get'
+  })
+}
+
+/**
+ * 系统信息
+ * @returns {*}
+ */
+export function getBaseInfo(data) {
+  return request({
+    url: '/v1/site/base-info',
+    method: 'get',
+    params: data
+  })
+}
+
+/**
+ * 消息列表
+ * @returns {*}
+ */
+export function getMessageData() {
+  return request({
+    url: '/v1/message/unread-num',
+    method: 'get'
+  })
+}
+
+/**
+ * 修改用户
+ * @param data
+ * @returns {*}
+ */
+export function updateInfo(data) {
+  return request({
+    url: '/v1/user/edit',
+    method: 'post',
+    data
+  })
+}
+
+/**
+ * 修改登录密码
+ * @param data
+ * @returns {*}
+ */
+export function updateLoginPassword(data) {
+  return request({
+    url: '/v1/user/password',
+    method: 'post',
+    data
+  })
+}
+
+/**
+ * 修改支付密码
+ * @param data
+ * @returns {*}
+ */
+export function updatePaymentPassword(data) {
+  return request({
+    url: '/v1/user/pay-password',
+    method: 'post',
+    data
+  })
+}
+
+/**
+ * BA报单
+ * @returns {*}
+ */
+export function fetchBrandAmbassador() {
+  return request({
+    url: '/v1/user/ba-dec',
+    method: 'get'
+  })
+}
+
+/**
+ * BA报单
+ * @returns {*}
+ */
+export function createBrandAmbassador(data) {
+  return request({
+    url: '/v1/user/ba-dec',
+    method: 'post',
+    data
+  })
+}
+
+// 查询用户
+export function fetchFullInfo(data) {
+  return request({
+    url: '/v1/user/full-info',
+    method: 'get',
+    params: data
+  })
+}
+
+export function fetchInspectNetwork(data) {
+  return request({
+    url: '/v1/user/inspect-network',
+    method: 'get',
+    params: data
+  })
+}
+
+// 游客查询推荐人
+export function fetchGuestFullInfo(data) {
+  return request({
+    url: '/v1/user/guest-full-info',
+    method: 'get',
+    params: data
+  })
+}
+
+// 游客查询实际推荐人
+export function fetchGuestInspectNetwork(data) {
+  return request({
+    url: '/v1/user/guest-inspect-network',
+    method: 'get',
+    params: data
+  })
+}
+
+// 用户升级查询
+export function fetchUpgrade(data) {
+  return request({
+    url: '/v1/user/upgrade',
+    method: 'get',
+    params: data
+  })
+}
+
+// 用户升级
+export function fetchSendUpgrade(data) {
+  return request({
+    url: '/v1/user/upgrade',
+    method: 'post',
+    data
+  })
+}
+
+// 用户升级信息
+export function fetchUpgradeInfo(data) {
+  return request({
+    url: '/v1/user/upgrade-info',
+    method: 'post',
+    data
+  })
+}
+
+// BA会员查询
+export function fetchBrandAmbassadorInfo(data) {
+  return request({
+    url: '/v1/user/ba-user-info',
+    method: 'get',
+    params: data
+  })
+}
+
+// 自动查询区位
+export function fetchAutoPlace(data) {
+  return request({
+    url: '/v1/user/get-auto-place',
+    method: 'get',
+    params: data
+  })
+}
+
+// 用户报单查询
+export function fetchWelcomePack(data) {
+  return request({
+    url: '/v1/user/dec',
+    method: 'get',
+    params: data
+  })
+}
+
+// 用户报单/BA升级
+export function createWelcomePack(data) {
+  return request({
+    url: '/v1/user/dec',
+    method: 'post',
+    data
+  })
+}
+
+// 游客报单查询
+export function fetchGuestDec(data) {
+  return request({
+    url: '/v1/user/guest-dec',
+    method: 'get',
+    params: data
+  })
+}
+
+// 游客报单
+export function createGuestDec(data) {
+  return request({
+    url: '/v1/user/guest-dec',
+    method: 'post',
+    data
+  })
+}
+
+// 订单列表
+export function fetchOrderList(query) {
+  return request({
+    url: '/v1/shop/member-order',
+    method: 'get',
+    params: query
+  })
+}
+
+// 下载订单PDF
+export function downloadOrder(orderSn) {
+  return request({
+    url: `/v1/shop/order-export/${orderSn}`,
+    method: 'get',
+    params: {}
+  })
+}

+ 1 - 1
src/components/ui/UiSectionCard.vue

@@ -29,7 +29,7 @@ export default {
 
 <style lang="scss" scoped>
 .ng-ui-section-card {
-  margin-bottom: 32px;
+  margin-bottom: 24px;
   border-radius: 8px;
 }
 

+ 72 - 71
src/permission.js

@@ -1,71 +1,72 @@
-import getPageTitle from '@/utils/get-page-title'
-import usersInfo from '@/utils/usersInfo'
-import NProgress from 'nprogress' // progress bar
-import 'nprogress/nprogress.css' // progress bar style
-import router, { resetRouter } from './router'
-import store from './store'
-
-NProgress.configure({ showSpinner: false }) // NProgress Configuration
-
-const whiteList = ['/login', '/signup', '/auth-redirect', '/login-by-backend'] // no redirect whitelist
-
-router.beforeEach(async(to, from, next) => {
-  // start progress bar
-  NProgress.start()
-
-  // set page title
-  document.title = getPageTitle(to.meta.title)
-
-  // determine whether the user has logged in
-  const hasToken = usersInfo.hasLogin() // getToken()
-  if (hasToken) {
-    if (to.path === '/login') {
-      // if is logged in, redirect to the home page
-      next({ path: '/' })
-      NProgress.done() // hack: https://github.com/PanJiaChen/vue-element-admin/pull/2939
-    } else {
-      // determine whether the user has obtained his permission roles through getInfo
-      const hasRoles = localStorage.getItem('accessToken')
-      if (hasRoles) {
-        next()
-      } else {
-        try {
-          // get user info
-          // note: roles must be a object array! such as: ['admin'] or ,['developer','editor']
-          // const { roles } = await store.dispatch('user/getInfo')
-          const { roles } = ['admin']
-          resetRouter()
-          // generate accessible routes map based on roles
-          const accessRoutes = await store.dispatch('permission/generateRoutes', roles)
-          // dynamically add accessible routes
-          router.addRoutes(accessRoutes)
-          // hack method to ensure that addRoutes is complete
-          // set the replace: true, so the navigation will not leave a history record
-          next({ ...to, replace: true })
-        } catch (error) {
-          usersInfo.clear()
-          // remove token and go to login page to re-login
-          // await store.dispatch('user/resetToken')
-          // Message.error(error || 'Has Error')
-          next(`/login?redirect=${to.path}`)
-          NProgress.done()
-        }
-      }
-    }
-  } else {
-    /* has no token*/
-    if (whiteList.indexOf(to.path) !== -1) {
-      // in the free login whitelist, go directly
-      next()
-    } else {
-      // other pages that do not have permission to access are redirected to the login page.
-      next(`/login?redirect=${to.path}`)
-      NProgress.done()
-    }
-  }
-})
-
-router.afterEach(() => {
-  // finish progress bar
-  NProgress.done()
-})
+import getPageTitle from '@/utils/get-page-title'
+import usersInfo from '@/utils/usersInfo'
+import NProgress from 'nprogress' // progress bar
+import 'nprogress/nprogress.css' // progress bar style
+import router, { resetRouter } from './router'
+import store from './store'
+import { guestPaths } from '@/ui-refactor/views/guest-shop/routes'
+
+NProgress.configure({ showSpinner: false }) // NProgress Configuration
+
+const whiteList = ['/login', '/signup', '/auth-redirect', '/login-by-backend', ...guestPaths] // no redirect whitelist
+
+router.beforeEach(async(to, from, next) => {
+  // start progress bar
+  NProgress.start()
+
+  // set page title
+  document.title = getPageTitle(to.meta.title)
+
+  // determine whether the user has logged in
+  const hasToken = usersInfo.hasLogin() // getToken()
+  if (hasToken) {
+    if (to.path === '/login') {
+      // if is logged in, redirect to the home page
+      next({ path: '/' })
+      NProgress.done() // hack: https://github.com/PanJiaChen/vue-element-admin/pull/2939
+    } else {
+      // determine whether the user has obtained his permission roles through getInfo
+      const hasRoles = localStorage.getItem('accessToken')
+      if (hasRoles) {
+        next()
+      } else {
+        try {
+          // get user info
+          // note: roles must be a object array! such as: ['admin'] or ,['developer','editor']
+          // const { roles } = await store.dispatch('user/getInfo')
+          const { roles } = ['admin']
+          resetRouter()
+          // generate accessible routes map based on roles
+          const accessRoutes = await store.dispatch('permission/generateRoutes', roles)
+          // dynamically add accessible routes
+          router.addRoutes(accessRoutes)
+          // hack method to ensure that addRoutes is complete
+          // set the replace: true, so the navigation will not leave a history record
+          next({ ...to, replace: true })
+        } catch (error) {
+          usersInfo.clear()
+          // remove token and go to login page to re-login
+          // await store.dispatch('user/resetToken')
+          // Message.error(error || 'Has Error')
+          next(`/login?redirect=${to.path}`)
+          NProgress.done()
+        }
+      }
+    }
+  } else {
+    /* has no token*/
+    if (whiteList.indexOf(to.path) !== -1) {
+      // in the free login whitelist, go directly
+      next()
+    } else {
+      // other pages that do not have permission to access are redirected to the login page.
+      next(`/login?redirect=${to.path}`)
+      NProgress.done()
+    }
+  }
+})
+
+router.afterEach(() => {
+  // finish progress bar
+  NProgress.done()
+})

+ 284 - 253
src/router/index.js

@@ -1,253 +1,284 @@
-import Vue from 'vue'
-import Router from 'vue-router'
-
-Vue.use(Router)
-
-/* Layout */
-import Layout from '@/layout'
-
-/* Router Modules */
-import articleRouter from '@/router/modules/article'
-import atlasRouter from '@/router/modules/atlas'
-import bonusRouter from '@/router/modules/bonus'
-import configRouter from '@/router/modules/config'
-import financeRouter from '@/router/modules/finance'
-import profileRouter from '@/router/modules/profile'
-import shopRouter from '@/router/modules/shop'
-import userRouter from '@/router/modules/user'
-import chartsRouter from './modules/charts'
-import componentsRouter from './modules/components'
-import nestedRouter from './modules/nested'
-import tableRouter from './modules/table'
-import refactorRouter from '@/router/modules/refactor'
-
-/**
- * Note: sub-menu only appear when route children.length >= 1
- * Detail see: https://panjiachen.github.io/vue-element-admin-site/guide/essentials/router-and-nav.html
- *
- * hidden: true                   if set true, item will not show in the sidebar(default is false)
- * alwaysShow: true               if set true, will always show the root menu
- *                                if not set alwaysShow, when item has more than one children route,
- *                                it will becomes nested mode, otherwise not show the root menu
- * redirect: noRedirect           if set noRedirect will no redirect in the breadcrumb
- * name:'router-name'             the name is used by <keep-alive> (must set!!!)
- * meta : {
-    roles: ['admin','editor']    control the page roles (you can set multiple roles)
-    title: 'title'               the name show in sidebar and breadcrumb (recommend set)
-    icon: 'svg-name'/'el-icon-x' the icon show in the sidebar
-    noCache: true                if set true, the page will no be cached(default is false)
-    affix: true                  if set true, the tag will affix in the tags-view
-    breadcrumb: false            if set false, the item will hidden in breadcrumb(default is true)
-    activeMenu: '/example/list'  if set path, the sidebar will highlight the path you set
-  }
- */
-
-/**
- * constantRoutes
- * a base page that does not have permission requirements
- * all roles can be accessed
- */
-export const constantRoutes = [
-  {
-    path: '/redirect',
-    component: Layout,
-    hidden: true,
-    children: [
-      {
-        path: '/redirect/:path(.*)',
-        component: () => import('@/views/redirect/index')
-      }
-    ]
-  },
-  {
-    path: '/login',
-    component: () => import('@/views/login/index'),
-    hidden: true,
-    meta: { title: '会员管理系统' }
-  },
-  {
-    path: '/auth-redirect',
-    component: () => import('@/views/login/auth-redirect'),
-    hidden: true
-  },
-  {
-    path: '/login-by-backend',
-    component: () => import('@/views/login/login-by-backend'),
-    name: 'login-by-backend'
-  },
-  {
-    path: '/404',
-    component: () => import('@/views/error-page/404'),
-    hidden: true
-  },
-  {
-    path: '/401',
-    component: () => import('@/views/error-page/401'),
-    hidden: true
-  },
-  {
-    path: '/',
-    component: Layout,
-    redirect: '/dashboard/index',
-    children: [
-      {
-        path: 'dashboard/index',
-        component: () => import('@/views/dashboard/index'),
-        name: 'Dashboard',
-        meta: { title: 'dashboard', icon: 'dashboard', affix: true }
-      }
-    ]
-  },
-  {
-    path: '/dashboard/index',
-    component: Layout,
-    redirect: '/dashboard/index',
-    children: [
-      {
-        path: 'dashboard',
-        component: () => import('@/views/dashboard/index'),
-        name: 'Dashboard',
-        meta: { title: 'dashboard', icon: 'dashboard', affix: true }
-      }
-    ]
-  },
-  refactorRouter
-]
-
-/**
- * asyncRoutes
- * the routes that need to be dynamically loaded based on user roles
- */
-export const asyncRoutes = [
-  {
-    path: '/permission',
-    component: Layout,
-    redirect: '/permission/page',
-    alwaysShow: true, // will always show the root menu
-    name: 'Permission',
-    meta: {
-      title: 'permission',
-      icon: 'lock',
-      roles: ['admin', 'editor'] // you can set roles in root nav
-    },
-    children: [
-      {
-        path: 'page',
-        component: () => import('@/views/permission/page'),
-        name: 'PagePermission',
-        meta: {
-          title: 'pagePermission',
-          roles: ['admin'] // or you can only set roles in sub nav
-        }
-      },
-      {
-        path: 'directive',
-        component: () => import('@/views/permission/directive'),
-        name: 'DirectivePermission',
-        meta: {
-          title: 'directivePermission'
-          // if do not set roles, means: this page does not require permission
-        }
-      },
-      {
-        path: 'role',
-        component: () => import('@/views/permission/role'),
-        name: 'RolePermission',
-        meta: {
-          title: 'rolePermission',
-          roles: ['admin']
-        }
-      }
-    ]
-  },
-
-  {
-    path: '/icon',
-    component: Layout,
-    children: [
-      {
-        path: 'index',
-        component: () => import('@/views/icons/index'),
-        name: 'Icons',
-        meta: { title: 'icons', icon: 'icon', noCache: true }
-      }
-    ]
-  },
-
-  componentsRouter,
-  chartsRouter,
-  nestedRouter,
-  tableRouter,
-  shopRouter,
-  profileRouter,
-  configRouter,
-  atlasRouter,
-  articleRouter,
-  bonusRouter,
-  financeRouter,
-  userRouter,
-
-  {
-    path: '/error',
-    component: Layout,
-    redirect: 'noRedirect',
-    name: 'ErrorPages',
-    meta: {
-      title: 'errorPages',
-      icon: '404'
-    },
-    children: [
-      {
-        path: '401',
-        component: () => import('@/views/error-page/401'),
-        name: 'Page401',
-        meta: { title: 'page401', noCache: true }
-      },
-      {
-        path: '404',
-        component: () => import('@/views/error-page/404'),
-        name: 'Page404',
-        meta: { title: 'page404', noCache: true }
-      }
-    ]
-  },
-
-  {
-    path: '/error-log',
-    component: Layout,
-    children: [
-      {
-        path: 'log',
-        component: () => import('@/views/error-log/index'),
-        name: 'ErrorLog',
-        meta: { title: 'errorLog', icon: 'bug' }
-      }
-    ]
-  },
-
-  // 404 page must be placed at the end !!!
-  { path: '*', redirect: '/404', hidden: true }
-]
-
-const createRouter = () => new Router({
-  // mode: 'history', // require service support
-  scrollBehavior: () => ({ y: 0 }),
-  routes: constantRoutes.concat(asyncRoutes)
-  // routes: constantRoutes.concat(asyncRoutes)
-})
-
-const router = createRouter()
-
-// Detail see: https://github.com/vuejs/vue-router/issues/1234#issuecomment-357941465
-export function resetRouter() {
-  const newRouter = createRouter()
-  router.matcher = newRouter.matcher // reset router
-}
-
-export function selfAddRoutes(params) {
-  const newRouter = createRouter()
-  router.matcher = newRouter.matcher
-  router.addRoutes(params)
-}
-
-export default router
+import Vue from 'vue'
+import Router from 'vue-router'
+
+Vue.use(Router)
+
+/* Layout */
+import Layout from '@/layout'
+
+/* Router Modules */
+import articleRouter from '@/router/modules/article'
+import atlasRouter from '@/router/modules/atlas'
+import bonusRouter from '@/router/modules/bonus'
+import configRouter from '@/router/modules/config'
+import financeRouter from '@/router/modules/finance'
+import profileRouter from '@/router/modules/profile'
+import shopRouter from '@/router/modules/shop'
+import userRouter from '@/router/modules/user'
+import chartsRouter from './modules/charts'
+import componentsRouter from './modules/components'
+import nestedRouter from './modules/nested'
+import tableRouter from './modules/table'
+import refactorRouter from '@/router/modules/refactor'
+import purchaseRoutes from '@/ui-refactor/views/purchase/routes'
+import { guestRoutes } from '@/ui-refactor/views/guest-shop/routes'
+
+/**
+ * Note: sub-menu only appear when route children.length >= 1
+ * Detail see: https://panjiachen.github.io/vue-element-admin-site/guide/essentials/router-and-nav.html
+ *
+ * hidden: true                   if set true, item will not show in the sidebar(default is false)
+ * alwaysShow: true               if set true, will always show the root menu
+ *                                if not set alwaysShow, when item has more than one children route,
+ *                                it will becomes nested mode, otherwise not show the root menu
+ * redirect: noRedirect           if set noRedirect will no redirect in the breadcrumb
+ * name:'router-name'             the name is used by <keep-alive> (must set!!!)
+ * meta : {
+    roles: ['admin','editor']    control the page roles (you can set multiple roles)
+    title: 'title'               the name show in sidebar and breadcrumb (recommend set)
+    icon: 'svg-name'/'el-icon-x' the icon show in the sidebar
+    noCache: true                if set true, the page will no be cached(default is false)
+    affix: true                  if set true, the tag will affix in the tags-view
+    breadcrumb: false            if set false, the item will hidden in breadcrumb(default is true)
+    activeMenu: '/example/list'  if set path, the sidebar will highlight the path you set
+  }
+ */
+
+/**
+ * constantRoutes
+ * a base page that does not have permission requirements
+ * all roles can be accessed
+ */
+export const constantRoutes = [
+  {
+    path: '/redirect',
+    component: Layout,
+    hidden: true,
+    children: [
+      {
+        path: '/redirect/:path(.*)',
+        component: () => import('@/views/redirect/index')
+      }
+    ]
+  },
+  {
+    path: '/login',
+    component: () => import('@/views/login/index'),
+    hidden: true,
+    meta: { title: '会员管理系统' }
+  },
+  {
+    path: '/auth-redirect',
+    component: () => import('@/views/login/auth-redirect'),
+    hidden: true
+  },
+  {
+    path: '/login-by-backend',
+    component: () => import('@/views/login/login-by-backend'),
+    name: 'login-by-backend'
+  },
+  {
+    path: '/404',
+    component: () => import('@/views/error-page/404'),
+    hidden: true
+  },
+  {
+    path: '/401',
+    component: () => import('@/views/error-page/401'),
+    hidden: true
+  },
+  {
+    path: '/',
+    component: () => import('@/ui-refactor/index.vue'),
+    redirect: '/dashboard/index',
+    children: [
+      ...purchaseRoutes,
+      ...guestRoutes,
+      {
+        path: 'dashboard/member-welcome-pack',
+        component: () => import('@/ui-refactor/views/welcome-pack/MemberWelcomePack.vue'),
+        name: 'MemberWelcomePack',
+        meta: {
+          title: 'Member Welcome Pack',
+          publicLayout: {
+            hero: {
+              breadcrumbs: [
+                { text: 'My Account', to: '/dashboard/index' },
+                { text: 'Member Welcome Pack' }
+              ],
+              titleParts: [{ text: 'Member ' }, { text: 'Welcome Pack.', accent: true }],
+              subtitle:
+                'Continue your order with your current member info. Referrer fields are pre-filled with your profile, and payment runs through the unified checkout flow.'
+            }
+          }
+        }
+      },
+      {
+        path: 'dashboard/index',
+        alias: '/dashboard/index/dashboard',
+        component: () => import('@/ui-refactor/views/account/AccountPage.vue'),
+        name: 'Dashboard',
+        meta: { title: 'dashboard', icon: 'dashboard', affix: true }
+      },
+      {
+        path: 'dashboard/team-inquiry',
+        component: () => import('@/ui-refactor/views/team-inquiry/TeamInquiryPage.vue'),
+        name: 'MemberTeamInquiry',
+        meta: {
+          title: 'Team Inquiry',
+          publicLayout: {
+            hero: {
+              breadcrumbs: [
+                { text: 'My Account', to: '/dashboard/index' },
+                { text: 'Team Inquiry' }
+              ],
+              titleParts: [{ text: 'Your team, ' }, { text: 'at a glance.', accent: true }],
+              subtitle:
+                'View your personal member summary and your direct team details. Calculations are refreshed at the end of each pay cycle.'
+            }
+          }
+        }
+      }
+    ]
+  },
+  refactorRouter
+]
+
+/**
+ * asyncRoutes
+ * the routes that need to be dynamically loaded based on user roles
+ */
+export const asyncRoutes = [
+  {
+    path: '/permission',
+    component: Layout,
+    redirect: '/permission/page',
+    alwaysShow: true, // will always show the root menu
+    name: 'Permission',
+    meta: {
+      title: 'permission',
+      icon: 'lock',
+      roles: ['admin', 'editor'] // you can set roles in root nav
+    },
+    children: [
+      {
+        path: 'page',
+        component: () => import('@/views/permission/page'),
+        name: 'PagePermission',
+        meta: {
+          title: 'pagePermission',
+          roles: ['admin'] // or you can only set roles in sub nav
+        }
+      },
+      {
+        path: 'directive',
+        component: () => import('@/views/permission/directive'),
+        name: 'DirectivePermission',
+        meta: {
+          title: 'directivePermission'
+          // if do not set roles, means: this page does not require permission
+        }
+      },
+      {
+        path: 'role',
+        component: () => import('@/views/permission/role'),
+        name: 'RolePermission',
+        meta: {
+          title: 'rolePermission',
+          roles: ['admin']
+        }
+      }
+    ]
+  },
+
+  {
+    path: '/icon',
+    component: Layout,
+    children: [
+      {
+        path: 'index',
+        component: () => import('@/views/icons/index'),
+        name: 'Icons',
+        meta: { title: 'icons', icon: 'icon', noCache: true }
+      }
+    ]
+  },
+
+  componentsRouter,
+  chartsRouter,
+  nestedRouter,
+  tableRouter,
+  shopRouter,
+  profileRouter,
+  configRouter,
+  atlasRouter,
+  articleRouter,
+  bonusRouter,
+  financeRouter,
+  userRouter,
+
+  {
+    path: '/error',
+    component: Layout,
+    redirect: 'noRedirect',
+    name: 'ErrorPages',
+    meta: {
+      title: 'errorPages',
+      icon: '404'
+    },
+    children: [
+      {
+        path: '401',
+        component: () => import('@/views/error-page/401'),
+        name: 'Page401',
+        meta: { title: 'page401', noCache: true }
+      },
+      {
+        path: '404',
+        component: () => import('@/views/error-page/404'),
+        name: 'Page404',
+        meta: { title: 'page404', noCache: true }
+      }
+    ]
+  },
+
+  {
+    path: '/error-log',
+    component: Layout,
+    children: [
+      {
+        path: 'log',
+        component: () => import('@/views/error-log/index'),
+        name: 'ErrorLog',
+        meta: { title: 'errorLog', icon: 'bug' }
+      }
+    ]
+  },
+
+  // 404 page must be placed at the end !!!
+  { path: '*', redirect: '/404', hidden: true }
+]
+
+const createRouter = () =>
+  new Router({
+    // mode: 'history', // require service support
+    scrollBehavior: () => ({ y: 0 }),
+    routes: constantRoutes.concat(asyncRoutes)
+    // routes: constantRoutes.concat(asyncRoutes)
+  })
+
+const router = createRouter()
+
+// Detail see: https://github.com/vuejs/vue-router/issues/1234#issuecomment-357941465
+export function resetRouter() {
+  const newRouter = createRouter()
+  router.matcher = newRouter.matcher // reset router
+}
+
+export function selfAddRoutes(params) {
+  const newRouter = createRouter()
+  router.matcher = newRouter.matcher
+  router.addRoutes(params)
+}
+
+export default router

+ 34 - 0
src/ui-refactor/components/OrderSuccessDialog.vue

@@ -0,0 +1,34 @@
+<template>
+  <ui-dialog
+    title="Order successful"
+    :visible="visible"
+    width="450px"
+    :show-close="false"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+  >
+    <el-result
+      icon="success"
+      :title="$t('common.successfully')"
+      :sub-title="$t('shop.successOrderTips')"
+    />
+    <template slot="footer">
+      <el-button type="primary" @click="$emit('orders')">
+        {{ $t('shop.goBack') }} ({{ countdown }})
+      </el-button>
+    </template>
+  </ui-dialog>
+</template>
+
+<script>
+import UiDialog from './UiDialog.vue'
+
+export default {
+  name: 'OrderSuccessDialog',
+  components: { UiDialog },
+  props: {
+    visible: { type: Boolean, default: false },
+    countdown: { type: Number, required: true }
+  }
+}
+</script>

+ 271 - 0
src/ui-refactor/components/PaymentMethods.vue

@@ -0,0 +1,271 @@
+<template>
+  <div class="ng-payment-methods">
+    <ui-section-card title="Payment method">
+      <el-radio-group
+        :value="value"
+        :disabled="disabled"
+        class="ng-payment-methods-options"
+        aria-label="Payment method"
+        @input="$emit('input', $event)"
+      >
+        <el-row type="flex" :gutter="16" class="ng-payment-methods-grid">
+          <el-col
+            v-for="option in items"
+            :key="option.label"
+            :xs="24"
+            :sm="12"
+            class="ng-payment-methods-option"
+          >
+            <el-radio :label="option.label" border>
+              <el-avatar
+                :size="40"
+                shape="square"
+                :icon="option.icon"
+                class="ng-payment-methods-icon"
+                aria-hidden="true"
+              />
+              <span class="ng-payment-methods-copy">
+                <strong>{{ option.title }}</strong>
+                <span v-if="isBalance(option)">({{ money(balances[option.label]) }})</span>
+                <span>{{ option.description }}</span>
+              </span>
+            </el-radio>
+          </el-col>
+        </el-row>
+      </el-radio-group>
+      <el-card v-if="selected" shadow="never" class="ng-payment-methods-detail" aria-live="polite">
+        <h3 class="ng-payment-methods-title">{{ selected.title }}</h3>
+        <template v-if="isBalance(selected)">
+          <p>
+            {{
+              selected.label === 'cash'
+                ? 'Pay for this order directly from the member’s Ecoin account balance.'
+                : 'Pay using your ' + selected.title + ' balance.'
+            }}
+          </p>
+          <el-descriptions
+            :column="1"
+            :colon="false"
+            class="ng-payment-methods-summary"
+            content-class-name="ng-payment-methods-amount"
+          >
+            <el-descriptions-item label="Available balance">{{
+              money(summary.balance)
+            }}</el-descriptions-item>
+            <el-descriptions-item label="Order total">{{
+              money(summary.amount)
+            }}</el-descriptions-item>
+            <el-descriptions-item label="Balance after payment">{{
+              money(summary.remaining)
+            }}</el-descriptions-item>
+          </el-descriptions>
+          <el-alert
+            v-if="summary.insufficient"
+            class="ng-payment-methods-warning"
+            title="The available balance is not enough to cover this order. Choose another payment method or reduce the quantity."
+            type="warning"
+            :closable="false"
+            show-icon
+          />
+          <p v-else>The balance is deducted automatically when the order is placed.</p>
+        </template>
+        <template v-else-if="selected.label === 'pay_stack'">
+          <p>
+            Pay <strong>{{ money(summary.amount) }}</strong> through Paystack's secure checkout.
+          </p>
+          <p>
+            Submit the form to continue — you will confirm your email and be taken to the Paystack
+            payment page.
+          </p>
+        </template>
+        <p v-else>Continue with {{ selected.title }} when you place this order.</p>
+      </el-card>
+    </ui-section-card>
+  </div>
+</template>
+
+<script>
+import UiSectionCard from '@/components/ui/UiSectionCard.vue'
+import { toThousandFilter } from '@/filters'
+
+export default {
+  name: 'PaymentMethods',
+  components: { UiSectionCard },
+  props: {
+    options: { type: [Array, Object], required: true },
+    value: { type: String, default: '' },
+    currency: { type: String, default: '' },
+    balances: { type: Object, default: () => ({}) },
+    summary: { type: Object, required: true },
+    disabled: { type: Boolean, default: false },
+    guest: { type: Boolean, default: false }
+  },
+  computed: {
+    items() {
+      return Object.values(this.options).map(option => ({
+        ...option,
+        title:
+          option.label === 'cash' && !this.guest
+            ? 'Account Balance'
+            : option.label === 'pay_stack'
+              ? 'Paystack'
+              : option.name,
+        description:
+          option.label === 'pay_stack'
+            ? 'Pay via Paystack checkout'
+            : option.label === 'cash' && !this.guest
+              ? 'Pay with your Ecoin balance'
+              : option.name,
+        icon:
+          option.label === 'pay_stack'
+            ? 'el-icon-key'
+            : this.isBalance(option)
+              ? 'el-icon-wallet'
+              : 'el-icon-bank-card'
+      }))
+    },
+    selected() {
+      return this.items.find(option => option.label === this.value)
+    }
+  },
+  methods: {
+    isBalance(option) {
+      return !this.guest && ['cash', 'prp'].includes(option.label)
+    },
+    money(value) {
+      if (value == null || value === '' || !Number.isFinite(Number(value))) return '—'
+      return `${this.currency} ${toThousandFilter(value)}`.trim()
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.ng-payment-methods-title {
+  margin: 0 0 12px;
+  color: #202259;
+  font-size: 16px;
+  font-weight: 700;
+  line-height: 1.5;
+}
+
+.ng-payment-methods-options {
+  display: block;
+  width: 100%;
+}
+
+.ng-payment-methods-grid {
+  flex-wrap: wrap;
+}
+
+.ng-payment-methods-option {
+  display: flex;
+  min-width: 0;
+  margin-bottom: 16px;
+}
+
+.ng-payment-methods-options ::v-deep .el-radio {
+  display: flex;
+  flex-direction: row-reverse;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  width: 100%;
+  min-height: 110px;
+  height: auto;
+  margin: 0;
+  padding: 16px;
+  white-space: normal;
+}
+
+.ng-payment-methods-options ::v-deep .el-radio__input {
+  flex-shrink: 0;
+}
+
+.ng-payment-methods-options ::v-deep .el-radio__label {
+  display: flex;
+  flex: 1;
+  align-items: center;
+  gap: 12px;
+  min-width: 0;
+  padding: 0;
+}
+
+.ng-payment-methods-options ::v-deep .el-radio.is-checked {
+  background: #e9f4f8;
+}
+
+.ng-payment-methods-icon {
+  flex: 0 0 40px;
+  background: #f6f7f8;
+  color: #25292c;
+}
+
+.ng-payment-methods-options ::v-deep .is-checked .ng-payment-methods-icon {
+  background: #202259;
+  color: #fff;
+}
+
+.ng-payment-methods-copy {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  min-width: 0;
+  color: #707780;
+  font-size: 13px;
+  line-height: 1.5;
+  overflow-wrap: anywhere;
+}
+
+.ng-payment-methods-copy strong {
+  color: #25292c;
+  font-size: 14px;
+  font-weight: 500;
+}
+
+.ng-payment-methods-detail {
+  margin-bottom: 20px;
+  border-radius: 8px;
+  color: #606266;
+  font-size: 14px;
+  line-height: 1.7;
+  overflow-wrap: anywhere;
+}
+
+.ng-payment-methods-detail ::v-deep > .el-card__body {
+  padding: 20px;
+}
+
+.ng-payment-methods-detail p {
+  margin: 0 0 16px;
+}
+
+.ng-payment-methods-detail p:last-child {
+  margin-bottom: 0;
+}
+
+.ng-payment-methods-summary {
+  margin: 16px 0 8px;
+}
+
+.ng-payment-methods-summary ::v-deep .ng-payment-methods-amount {
+  min-width: 0;
+  justify-content: flex-end;
+  font-variant-numeric: tabular-nums;
+  text-align: right;
+}
+
+.ng-payment-methods-warning {
+  margin-top: 12px;
+}
+
+@media (max-width: 767px) {
+  .ng-payment-methods-options ::v-deep .el-radio {
+    min-height: 96px;
+  }
+
+  .ng-payment-methods-detail ::v-deep > .el-card__body {
+    padding: 16px;
+  }
+}
+</style>

+ 163 - 0
src/ui-refactor/components/PaystackPaymentDialog.vue

@@ -0,0 +1,163 @@
+<template>
+  <ui-dialog
+    class="ng-paystack-payment-dialog"
+    :title="title"
+    width="460px"
+    top="0"
+    :visible="visible"
+    :close-on-click-modal="false"
+    :show-close="!loading"
+    v-bind="$attrs"
+    v-on="$listeners"
+  >
+    <p class="ng-paystack-payment-note">{{ note }}</p>
+    <el-form
+      v-loading="loading"
+      class="ng-paystack-payment-form"
+      label-position="top"
+      :disabled="loading"
+      @submit.native.prevent
+    >
+      <el-form-item label="Email" for="ng-paystack-payment-email">
+        <el-input
+          id="ng-paystack-payment-email"
+          :value="email"
+          :readonly="readonlyEmail"
+          @input="$emit('update:email', $event)"
+        />
+      </el-form-item>
+    </el-form>
+    <div class="ng-paystack-payment-amount">
+      <span>Amount</span>
+      <strong>{{ currency }} {{ amount | toThousandFilter }}</strong>
+    </div>
+    <div class="ng-paystack-payment-trigger"><slot /></div>
+    <el-button
+      class="ng-paystack-payment-continue"
+      type="primary"
+      native-type="button"
+      :disabled="loading || !visible"
+      @click="$emit('continue')"
+    >
+      {{ continueLabel }}
+    </el-button>
+    <template slot="footer"><span /></template>
+  </ui-dialog>
+</template>
+
+<script>
+import UiDialog from './UiDialog.vue'
+import { toThousandFilter } from '@/filters'
+
+export default {
+  name: 'PaystackPaymentDialog',
+  components: { UiDialog },
+  filters: { toThousandFilter },
+  inheritAttrs: false,
+  props: {
+    visible: { type: Boolean, default: false },
+    title: { type: String, default: 'Paystack Payment' },
+    note: {
+      type: String,
+      default: 'Confirm your email to continue to the Paystack secure checkout.'
+    },
+    continueLabel: { type: String, default: 'Continue to Paystack' },
+    email: { type: String, default: '' },
+    amount: { type: [Number, String], default: 0 },
+    currency: { type: String, default: '' },
+    readonlyEmail: { type: Boolean, default: false },
+    loading: { type: Boolean, default: false }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.ng-paystack-payment-dialog {
+  display: flex;
+  padding: 16px;
+}
+.ng-paystack-payment-dialog ::v-deep .el-dialog {
+  flex-shrink: 0;
+  max-width: 100%;
+  margin: auto !important;
+  border-radius: 14px;
+}
+.ng-paystack-payment-dialog ::v-deep .el-dialog__header {
+  padding: 24px 40px 0 24px;
+}
+.ng-paystack-payment-dialog ::v-deep .el-dialog__title {
+  color: #25292c;
+  font-size: 24px;
+  font-weight: 700;
+  line-height: 1.3;
+}
+.ng-paystack-payment-dialog ::v-deep .el-dialog__headerbtn {
+  top: 16px;
+  right: 16px;
+}
+.ng-paystack-payment-dialog ::v-deep .el-dialog__body {
+  padding: 10px 24px 24px;
+}
+.ng-paystack-payment-dialog ::v-deep .el-dialog__footer,
+.ng-paystack-payment-trigger {
+  display: none;
+}
+.ng-paystack-payment-note {
+  margin: 0 0 16px;
+  color: #6b737a;
+  font-size: 14px;
+  line-height: 1.5;
+  word-break: normal;
+  overflow-wrap: break-word;
+}
+.ng-paystack-payment-form ::v-deep .el-form-item {
+  margin-bottom: 0;
+}
+.ng-paystack-payment-form ::v-deep .el-form-item__label {
+  padding-bottom: 8px;
+  color: #25292c;
+  line-height: 1.5;
+}
+.ng-paystack-payment-form ::v-deep .el-input__inner {
+  height: 54px;
+  border-radius: 8px;
+  font-size: 16px;
+  line-height: 54px;
+}
+.ng-paystack-payment-amount {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px 16px;
+  margin: 16px 0;
+  padding: 12px 16px;
+  border-radius: 8px;
+  background: #f8f9fa;
+  color: #6b737a;
+  font-size: 14px;
+  line-height: 1.5;
+}
+.ng-paystack-payment-amount strong {
+  min-width: 0;
+  color: #1f6b3f;
+  font-size: 18px;
+  font-variant-numeric: tabular-nums;
+  text-align: right;
+  overflow-wrap: anywhere;
+}
+.ng-paystack-payment-continue.el-button--primary {
+  width: 100%;
+  min-height: 50px;
+  padding: 14px 16px;
+  border-radius: 8px;
+  font-size: 16px;
+  font-weight: 700;
+  line-height: 1.4;
+  white-space: normal;
+}
+@media (max-width: 480px) {
+  .ng-paystack-payment-dialog ::v-deep .el-dialog__title {
+    font-size: 22px;
+  }
+}
+</style>

+ 91 - 0
src/ui-refactor/components/ProductDetailsDialog.vue

@@ -0,0 +1,91 @@
+<template>
+  <ui-dialog
+    class="ng-product-details-dialog"
+    :visible="visible"
+    :title="product.GOODS_NO"
+    width="800px"
+    @update:visible="$emit('update:visible', $event)"
+  >
+    <el-row :gutter="24" class="ng-product-details">
+      <el-col :xs="24" :sm="12">
+        <el-image
+          class="ng-product-details-image"
+          :src="tool.getArImage(product.COVER, '/files/')"
+          :alt="product.GOODS_NAME"
+          fit="contain"
+        />
+      </el-col>
+      <el-col :xs="24" :sm="12" class="ng-product-details-info">
+        <el-descriptions :column="1" size="small" border>
+          <h3 slot="title" class="ng-product-details-name">{{ product.GOODS_NAME }}</h3>
+          <el-descriptions-item label="Product Code">{{ product.GOODS_NO }}</el-descriptions-item>
+          <el-descriptions-item
+            label="Sales Price"
+          >{{ currency }} {{ product.SELL_PRICE | toThousandFilter }}</el-descriptions-item>
+          <el-descriptions-item
+            label="Market Price"
+          >{{ currency }} {{ product.MARKET_PRICE | toThousandFilter }}</el-descriptions-item>
+          <el-descriptions-item v-if="showBv" label="BV">{{
+            product.PRICE_PV | toThousandFilter
+          }}</el-descriptions-item>
+          <el-descriptions-item label="Tax Rate">{{ product.TAX_RATE / 100 }}</el-descriptions-item>
+          <el-descriptions-item
+            label="Tax"
+          >{{ currency }} {{ product.taxAmount | toThousandFilter }}</el-descriptions-item>
+          <el-descriptions-item label="Inventory">{{ product.STORE_NUMS }}</el-descriptions-item>
+        </el-descriptions>
+      </el-col>
+    </el-row>
+  </ui-dialog>
+</template>
+
+<script>
+import tool from '@/utils/tool'
+import { toThousandFilter } from '@/filters'
+import UiDialog from './UiDialog.vue'
+
+export default {
+  name: 'ProductDetailsDialog',
+  components: { UiDialog },
+  filters: { toThousandFilter },
+  props: {
+    visible: { type: Boolean, default: false },
+    showBv: { type: Boolean, default: true },
+    product: { type: Object, required: true },
+    currency: { type: String, default: '' }
+  },
+  data() {
+    return { tool }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.ng-product-details-dialog ::v-deep .el-dialog__title {
+  overflow-wrap: anywhere;
+}
+
+.ng-product-details {
+  overflow-wrap: anywhere;
+}
+
+.ng-product-details-image {
+  width: 100%;
+  height: 280px;
+}
+
+.ng-product-details-name {
+  margin: 0;
+  font-size: inherit;
+}
+
+@media (max-width: 767px) {
+  .ng-product-details-info {
+    margin-top: 24px;
+  }
+
+  .ng-product-details-image {
+    height: 180px;
+  }
+}
+</style>

+ 48 - 0
src/ui-refactor/components/README.md

@@ -0,0 +1,48 @@
+# 公共弹窗
+
+`UiDialog.vue` 包装现有 Element UI Dialog,只统一布局和键盘焦点,不包含业务请求。使用 Vue 2 的 `:visible.sync`,通过 `$attrs` / `$listeners` 传入 title、width、before-close、closed 等已有属性或事件。默认提供 Close 按钮,业务可以替换 footer 插槽。样式只限定在 `.ng-ui-dialog`。
+
+```vue
+<ui-dialog :visible.sync="visible" title="Title" width="960px">
+  <feature-content />
+  <template slot="footer">
+    <el-button @click="visible = false">Close</el-button>
+  </template>
+</ui-dialog>
+```
+
+默认宽度沿用当前屏幕宽度判断,调用方可用 width 覆盖;最大宽高限制在视口内,内容区独立滚动,页脚保持可达。打开时聚焦内部控件,Tab / Shift+Tab 在弹窗内循环,关闭后恢复入口焦点;Escape、遮罩关闭和嵌套层级继续使用 Element UI。
+
+连续切换业务弹窗时,关闭上一层不会抢走新弹窗焦点;原触发按钮已隐藏时,关闭后聚焦仍打开的父弹窗。列表/详情、充值/转账申请以及充值凭证弹窗共用此行为。
+
+账户卡片需要打开其他业务弹窗时,在 `AccountShortcuts.vue` 注册对应组件,并给卡片设置 `dialog: '组件名'`;页面跳转仍用 to。卡片按需挂载弹窗,closed 后卸载,下次打开重新加载;业务请求放在各自组件中。当前 `PreviousBonusDialog` 的列表和详情已共用外壳,不需要新增路由或通用弹窗服务。
+
+## 商品详情
+
+`ProductDetailsDialog.vue` 供注册、Member Welcome Pack 和会员购买流程共用,基于现有 `UiDialog` 展示图片、商品编号、销售价、市场价、BV、税率、税额和库存。统一使用现有千分位过滤器,税率仍为 `TAX_RATE / 100`,税额直接读取页面提供的 `taxAmount`,不查询接口或计算订单金额。
+
+```vue
+<product-details-dialog :visible.sync="visibleProduct" :product="product" :currency="currencyUnitCode" />
+```
+
+三个属性分别为显示状态、原商品对象和币种;关闭通过 `update:visible` 通知调用方。图片及明细用 Element UI `el-row/el-col` 排版,七项字段由 `el-descriptions` 展示,窄屏自动改为单列。商品查询、状态和业务事件继续由页面维护。
+
+## Paystack 邮箱确认
+
+`PaystackPaymentDialog.vue` 提取注册页原有的邮箱、金额条及 Continue to Paystack 展示,注册和会员购买共用现有 `UiDialog` 外壳。调用方传入 `visible`、`email`、`amount`、`currency`、`loading`;会员通过 `email.sync` 保留邮箱编辑,游客注册通过 `readonly-email` 保留只读。`before-close` 继续由页面处理,`continue` 事件调用页面自己的 Paystack SDK。
+
+SDK 放在默认插槽内隐藏挂载;公共组件不查询接口、不创建或取消订单,也不转换支付金额。游客仍使用 GuestPaystack 的 accessCode,会员仍使用原 SDK、金额 ×100、metadata、reference、channels 和各自成功/取消回调。会员原有提交校验及支付密码步骤保持。
+
+## 支付方式展示
+
+`PaymentMethods.vue` 统一注册、Member Welcome Pack 和会员购买页的选项卡片及说明区域。`options` 接收页面已有的数组或对象,保留原顺序和 `label` / `name`;Vue 2 `v-model` 使用 `value` / `input`,只回传选中项的原始 label,不补充、过滤或自动选择支付方式。
+
+选项使用 Element UI 栅格排列,宽屏两列、窄屏单列;保留原单选卡片,图标用 `el-avatar`。所选方式的说明集中在下方 `el-card`,余额明细用 `el-descriptions`,余额不足用不可关闭的 `el-alert` 展示。
+
+```vue
+<payment-methods v-model="form.payType" :options="payList" :currency="currencyUnitCode" :balances="userBalance" :summary="paymentSummary" :disabled="busy" />
+```
+
+页面提供 `summary.amount`、`balance`、`remaining`、`insufficient`,组件只格式化展示,不计算订单金额或判断能否提交。`balances` 用于选项上的余额展示;缺失金额显示 `—`,真实零值仍显示为 0。Paystack、余额支付和其他接口返回方式均保留,加载和支付中的禁用状态由页面传入。SDK、下单、取消和余额校验不进入公共组件。
+
+`WelcomePackPayment.vue` 保留为会员 Welcome Pack 的展示适配器,继续从该页面的金额和余额生成 summary;它不再维护独立的支付卡片样式。

+ 147 - 0
src/ui-refactor/components/UiDialog.vue

@@ -0,0 +1,147 @@
+<template>
+  <el-dialog
+    custom-class="ng-ui-dialog"
+    append-to-body
+    v-bind="{ width: screenWidth, top: '8vh', ...$attrs }"
+    @closed="restoreFocus"
+    @keydown.native="keepFocus"
+    v-on="$listeners"
+  >
+    <slot />
+    <template slot="footer">
+      <slot name="footer">
+        <el-button native-type="button" @click="$emit('update:visible', false)">Close</el-button>
+      </slot>
+    </template>
+  </el-dialog>
+</template>
+
+<script>
+import { getScreenWidth } from '@/utils'
+
+export default {
+  name: 'UiDialog',
+  inheritAttrs: false,
+  computed: {
+    screenWidth() {
+      return getScreenWidth() > 600 ? '70%' : getScreenWidth() + 'px'
+    }
+  },
+  watch: {
+    '$attrs.visible': {
+      immediate: true,
+      handler(visible) {
+        if (visible) {
+          this.returnFocus = document.activeElement
+          this.$nextTick(() => {
+            if (this.$attrs.visible) this.focusableElements()[0]?.focus()
+          })
+        }
+      }
+    }
+  },
+  beforeDestroy() {
+    this.restoreFocus()
+  },
+  methods: {
+    focusableElements() {
+      return Array.from(
+        this.$el.querySelectorAll('button, a[href], input, select, textarea, [tabindex]')
+      ).filter(
+        element =>
+          !element.disabled &&
+          element.tabIndex >= 0 &&
+          element.getClientRects().length &&
+          getComputedStyle(element).visibility !== 'hidden'
+      )
+    },
+    keepFocus(event) {
+      if (event.key !== 'Tab') return
+      const elements = this.focusableElements()
+      const first = elements[0]
+      const last = elements[elements.length - 1]
+      if (
+        (event.shiftKey && event.target === first) ||
+        (!event.shiftKey && event.target === last)
+      ) {
+        event.preventDefault()
+        const target = event.shiftKey ? last : first
+        target.focus()
+      }
+    },
+    restoreFocus() {
+      // 查看凭证切到上传等场景中,保留新弹窗已获得的焦点。
+      const activeDialog =
+        document.activeElement && document.activeElement.closest('[role="dialog"]')
+      if (
+        this.returnFocus &&
+        this.returnFocus.isConnected &&
+        (!activeDialog || this.$el.contains(activeDialog))
+      ) {
+        const dialogs = Array.from(document.querySelectorAll('.ng-ui-dialog')).filter(
+          element => element.getClientRects().length
+        )
+        const target = this.returnFocus.getClientRects().length
+          ? this.returnFocus
+          : dialogs[dialogs.length - 1]?.querySelector('button')
+        target?.focus()
+      }
+      this.returnFocus = null
+    }
+  }
+}
+</script>
+
+<style lang="scss">
+// append-to-body 后仍以独立弹窗类限定样式,不影响旧页面弹窗。
+.ng-ui-dialog {
+  display: flex;
+  max-width: calc(100% - 32px);
+  max-height: 84vh;
+  flex-direction: column;
+  overflow: hidden;
+  border-radius: 14px;
+  font-family: 'Ng Public Montserrat', 'Helvetica Neue', Arial, sans-serif;
+
+  > .el-dialog__header {
+    flex-shrink: 0;
+    padding: 24px 48px 16px 24px;
+  }
+
+  .el-dialog__title {
+    color: #202259;
+    font-size: 20px;
+    font-weight: 700;
+    line-height: 1.4;
+  }
+
+  > .el-dialog__body {
+    min-height: 0;
+    overflow: auto;
+    padding: 0 24px 24px;
+  }
+
+  > .el-dialog__footer {
+    flex-shrink: 0;
+    padding: 16px 24px;
+    border-top: 1px solid #dfe4ed;
+    background: #f7f8fa;
+  }
+}
+
+@media (max-width: 767px) {
+  .ng-ui-dialog {
+    > .el-dialog__header {
+      padding: 20px 44px 16px 16px;
+    }
+
+    > .el-dialog__body {
+      padding: 0 16px 20px;
+    }
+
+    > .el-dialog__footer {
+      padding: 12px 16px;
+    }
+  }
+}
+</style>

+ 1 - 0
src/ui-refactor/index.vue

@@ -12,6 +12,7 @@
 </template>
 
 <script>
+import './styles/theme.scss'
 import PublicHeader from './layouts/PublicHeader.vue'
 import PageHero from './layouts/PageHero.vue'
 import PublicFooter from './layouts/PublicFooter.vue'

+ 87 - 0
src/ui-refactor/styles/__tests__/theme.spec.js

@@ -0,0 +1,87 @@
+/** @jest-environment node */
+/* eslint-env jest */
+import fs from 'fs'
+import path from 'path'
+import sass from 'sass'
+import postcss from 'postcss'
+import { JSDOM } from 'jsdom'
+
+const compile = file => sass.renderSync({
+  data: fs.readFileSync(path.resolve(file), 'utf8').replace(/~element-ui/g, 'element-ui'),
+  includePaths: [path.resolve('node_modules').replace(/\\/g, '/')]
+}).css.toString()
+const theme = compile('src/ui-refactor/styles/theme.scss')
+const original = compile('src/styles/element-variables.scss')
+const { window } = new JSDOM('<!doctype html><html><head></head><body></body></html>')
+const { document } = window
+let stylesheet
+
+function color(scope, classes, property = 'backgroundColor') {
+  const container = document.createElement('div')
+  container.className = scope
+  const button = document.createElement('button')
+  button.className = 'el-button ' + classes
+  container.appendChild(button)
+  document.body.appendChild(container)
+  const result = window.getComputedStyle(button)[property]
+  container.remove()
+  return result
+}
+
+describe('new page theme preserves Element UI and legacy pages', () => {
+  beforeAll(() => {
+    stylesheet = document.createElement('style')
+    stylesheet.textContent = original + '\n' + theme
+    document.head.appendChild(stylesheet)
+  })
+  afterAll(() => stylesheet.remove())
+
+  it('themes new pages and detached dialogs without changing legacy or semantic buttons', () => {
+    expect(color('legacy-page', 'el-button--primary')).toBe('rgb(24, 144, 255)')
+    for (const scope of ['ng-public-layout', 'ng-ui-dialog', 'ng-refactor-popper']) {
+      expect(color(scope, 'el-button--primary')).toBe('rgb(32, 34, 89)')
+      expect(color(scope, 'el-button--primary is-disabled')).toBe('rgb(144, 145, 172)')
+      expect(color(scope, 'el-button--primary is-plain')).toBe('rgb(233, 233, 238)')
+      for (const type of ['success', 'warning', 'danger', 'info', 'default']) {
+        expect(color(scope, 'el-button--' + type)).toBe(color('legacy-page', 'el-button--' + type))
+      }
+    }
+  })
+
+  it('retains control geometry and confines table styles to new pages', () => {
+    const root = postcss.parse(theme)
+    root.walkRules(rule => {
+      expect(rule.selectors.every(selector => /^\.(ng-public-layout|ng-ui-dialog|ng-refactor-popper) /.test(selector))).toBe(true)
+    })
+    root.walkDecls(declaration => {
+      if (['font-size', 'width', 'line-height', 'overflow-wrap', 'text-transform'].includes(declaration.prop)) {
+        expect(declaration.parent.selectors.every(selector => /\.(el-table|ng-account-history-table|ng-previous-bonus)\b/.test(selector))).toBe(true)
+      } else {
+        expect(['color', 'background', 'background-color', 'border-color', 'stroke', 'outline', 'box-shadow']).toContain(declaration.prop)
+      }
+      if (declaration.prop === 'outline') expect(declaration.value).toBe('none')
+      if (declaration.prop === 'box-shadow') expect(declaration.value).toMatch(/^(?:-1px 0 0 0|0 0 2px 2px) #202259$/)
+    })
+    for (const property of ['padding', 'height', 'fontSize', 'borderRadius', 'boxShadow']) {
+      expect(color('ng-public-layout', 'el-button--primary', property)).toBe(color('legacy-page', 'el-button--primary', property))
+    }
+  })
+
+  it('keeps Element hover, active, disabled and error colors in the cascade', () => {
+    const root = postcss.parse(theme)
+    function value(selector, property) {
+      let result
+      root.walkRules(rule => {
+        if (rule.selectors.includes(selector)) rule.walkDecls(property, declaration => { result = declaration.value })
+      })
+      return result
+    }
+    expect(value('.ng-public-layout .el-button--primary:hover', 'background')).toBe('#4d4e7a')
+    expect(value('.ng-public-layout .el-button--primary:active', 'background')).toBe('#1d1f50')
+    expect(value('.ng-public-layout .el-button--primary.is-disabled:hover', 'background-color')).toBe('#9091ac')
+    expect(value('.ng-public-layout .el-form-item.is-error .el-input__inner:focus', 'border-color')).toBe('#ff4949')
+    expect(value('.ng-refactor-popper .el-select-dropdown__item.selected:not(.is-disabled)', 'color')).toBe('#202259')
+    expect(value('.ng-refactor-popper .el-date-table td.current:not(.disabled) span', 'background-color')).toBe('#202259')
+    expect(fs.readFileSync(path.resolve('src/ui-refactor/index.vue'), 'utf8')).toContain("import './styles/theme.scss'")
+  })
+})

+ 231 - 0
src/ui-refactor/styles/theme.scss

@@ -0,0 +1,231 @@
+$--color-primary: #202259;
+@import '~element-ui/packages/theme-chalk/src/mixins/button';
+
+// 只覆盖颜色;弹层移到 body 后通过独立类保持新旧页面主题隔离。
+.ng-public-layout,
+.ng-public-login-v2,
+.ng-ui-dialog,
+.ng-refactor-popper {
+  .el-button--primary {
+    @include button-variant($--color-white, $--color-primary, $--color-primary);
+  }
+
+  .el-button--default:not(.is-disabled) {
+    &:hover,
+    &:focus {
+      color: $--color-primary;
+      border-color: $--color-primary-light-7;
+      background-color: $--color-primary-light-9;
+    }
+
+    &.is-plain:hover,
+    &.is-plain:focus {
+      border-color: $--color-primary;
+      background-color: $--color-white;
+    }
+
+    &:active,
+    &.is-active {
+      color: mix($--color-black, $--color-primary, $--button-active-shade-percent);
+      border-color: mix($--color-black, $--color-primary, $--button-active-shade-percent);
+    }
+  }
+
+  .el-button--text:not(.is-disabled) {
+    color: $--color-primary;
+
+    &:hover,
+    &:focus {
+      color: $--color-primary-light-2;
+    }
+
+    &:active {
+      color: mix($--color-black, $--color-primary, $--button-active-shade-percent);
+    }
+  }
+
+  .el-input:not(.is-disabled) > .el-input__inner:focus,
+  .el-textarea:not(.is-disabled) > .el-textarea__inner:focus,
+  .el-select .el-input.is-focus:not(.is-disabled) > .el-input__inner,
+  .el-cascader:not(.is-disabled) .el-input.is-focus > .el-input__inner,
+  .el-cascader:not(.is-disabled) .el-input > .el-input__inner:focus,
+  .el-input-number:not(.is-disabled)
+    .el-input-number__increase:not(.is-disabled):hover
+    ~ .el-input
+    .el-input__inner,
+  .el-input-number:not(.is-disabled)
+    .el-input-number__decrease:not(.is-disabled):hover
+    ~ .el-input
+    .el-input__inner,
+  .el-pagination__sizes .el-input:not(.is-disabled) .el-input__inner:hover {
+    border-color: $--color-primary;
+  }
+
+  // 错误态优先于聚焦色,沿用项目当前的校验颜色。
+  .el-form-item.is-error .el-input__inner,
+  .el-form-item.is-error .el-input__inner:focus,
+  .el-form-item.is-error .el-textarea__inner,
+  .el-form-item.is-error .el-textarea__inner:focus,
+  .el-form-item.is-error .el-select .el-input.is-focus > .el-input__inner,
+  .el-form-item.is-error .el-cascader .el-input.is-focus > .el-input__inner {
+    border-color: #ff4949;
+  }
+
+  .el-input-number:not(.is-disabled) .el-input-number__increase:not(.is-disabled):hover,
+  .el-input-number:not(.is-disabled) .el-input-number__decrease:not(.is-disabled):hover,
+  .el-radio-button:not(.is-disabled) .el-radio-button__inner:hover,
+  .el-tabs__item:not(.is-disabled):hover,
+  .el-tabs__item.is-active:not(.is-disabled),
+  .el-pagination button:not(:disabled):hover,
+  .el-pager li:not(.disabled):hover,
+  .el-pager li.active,
+  .el-select-dropdown__item.selected:not(.is-disabled),
+  .el-cascader-node:not(.is-disabled).in-active-path,
+  .el-cascader-node:not(.is-disabled).is-active,
+  .el-cascader-node:not(.is-disabled).is-selectable.in-checked-path,
+  .el-cascader__suggestion-item.is-checked,
+  .el-date-table td.today span,
+  .el-date-table td.available:hover,
+  .el-month-table td.today .cell,
+  .el-month-table td:not(.disabled) .cell:hover,
+  .el-month-table td.current:not(.disabled) .cell,
+  .el-year-table td.today .cell,
+  .el-year-table td:not(.disabled) .cell:hover,
+  .el-year-table td.current:not(.disabled) .cell,
+  .el-picker-panel__icon-btn:hover,
+  .el-date-picker__header-label:hover,
+  .el-date-picker__header-label.active,
+  .el-picker-panel__shortcut:hover,
+  .el-time-panel__btn.confirm,
+  .el-dialog__headerbtn:hover .el-dialog__close,
+  .el-dialog__headerbtn:focus .el-dialog__close,
+  .el-upload--picture-card:hover,
+  .el-upload:focus,
+  .el-upload-dragger .el-upload__text em,
+  .el-upload-list__item .el-icon-close-tip,
+  .el-upload-list__item-name:hover,
+  .el-upload-list__item-name:focus {
+    color: $--color-primary;
+  }
+
+  .el-tabs__active-bar,
+  .el-date-table td.current:not(.disabled) span,
+  .el-date-table td.start-date span,
+  .el-date-table td.end-date span,
+  .el-date-table td.selected span {
+    background-color: $--color-primary;
+  }
+
+  .el-date-table td.today.current span,
+  .el-date-table td.today.start-date span,
+  .el-date-table td.today.end-date span,
+  .el-date-table td.selected span {
+    color: $--color-white;
+  }
+
+  .el-pagination.is-background .el-pager li:not(.disabled).active {
+    color: $--color-white;
+    background-color: $--color-primary;
+  }
+
+  .el-radio:not(.is-disabled).is-bordered.is-checked,
+  .el-radio__input:not(.is-disabled).is-focus .el-radio__inner,
+  .el-radio__input:not(.is-disabled) .el-radio__inner:hover,
+  .el-checkbox__input:not(.is-disabled).is-focus .el-checkbox__inner,
+  .el-checkbox__input:not(.is-disabled) .el-checkbox__inner:hover,
+  .el-upload--picture-card:hover,
+  .el-upload:focus,
+  .el-upload:focus .el-upload-dragger,
+  .el-upload-dragger:not(.is-disabled):hover,
+  .el-upload-dragger.is-dragover {
+    border-color: $--color-primary;
+  }
+
+  .el-radio__input.is-checked:not(.is-disabled) .el-radio__inner,
+  .el-checkbox__input.is-checked:not(.is-disabled) .el-checkbox__inner,
+  .el-checkbox__input.is-indeterminate:not(.is-disabled) .el-checkbox__inner,
+  .el-switch.is-checked .el-switch__core {
+    border-color: $--color-primary;
+    background-color: $--color-primary;
+  }
+
+  .el-radio__input.is-checked:not(.is-disabled) + .el-radio__label,
+  .el-checkbox__input.is-checked:not(.is-disabled) + .el-checkbox__label,
+  .el-switch__label.is-active {
+    color: $--color-primary;
+  }
+
+  .el-radio-button__orig-radio:checked:not(:disabled) + .el-radio-button__inner {
+    color: $--color-white;
+    border-color: $--color-primary;
+    background-color: $--color-primary;
+    box-shadow: -1px 0 0 0 $--color-primary;
+  }
+
+  // Element 原有的分段边线与键盘焦点环仅换色,几何参数保持原样。
+  .el-radio:focus:not(.is-focus):not(:active):not(.is-disabled) .el-radio__inner,
+  .el-radio-button:focus:not(.is-focus):not(:active):not(.is-disabled) {
+    box-shadow: 0 0 2px 2px $--color-primary;
+  }
+
+  .el-dropdown-menu__item:not(.is-disabled):hover,
+  .el-dropdown-menu__item:not(.is-disabled):focus {
+    color: $--color-primary-light-2;
+    background-color: $--color-primary-light-9;
+  }
+
+  .el-link.el-link--primary:not(.is-disabled) {
+    color: $--color-primary;
+
+    &:hover {
+      color: $--color-primary-light-2;
+    }
+  }
+
+  .el-link.el-link--primary.is-disabled {
+    color: $--color-primary-light-5;
+  }
+
+  .el-link.el-link--default:not(.is-disabled):hover {
+    color: $--color-primary;
+  }
+
+  .el-link.el-link--primary::after,
+  .el-link.el-link--default::after {
+    border-color: $--color-primary;
+  }
+
+  .el-loading-spinner .path {
+    stroke: $--color-primary;
+  }
+
+  .el-loading-spinner i,
+  .el-loading-spinner .el-loading-text {
+    color: $--color-primary;
+  }
+
+  .el-table th.el-table__cell {
+    background: #f7f8fa;
+    color: #606266;
+    text-transform: uppercase;
+  }
+
+  .ng-account-history-table th.el-table__cell,
+  .ng-previous-bonus th.el-table__cell {
+    font-size: 12px;
+  }
+
+  .ng-account-history-table {
+    width: 100%;
+    font-size: 13px;
+
+    th.el-table__cell {
+      background: #f8f9fa;
+    }
+
+    .cell {
+      line-height: 1.6;
+      overflow-wrap: anywhere;
+    }
+  }
+}

+ 9 - 0
src/ui-refactor/utils/paymentSummary.js

@@ -0,0 +1,9 @@
+export function getPaymentSummary(amount, balance, checkBalance) {
+  return {
+    amount,
+    balance,
+    remaining:
+      balance == null || balance === '' ? null : Math.max(0, Number(balance) - Number(amount)),
+    insufficient: checkBalance && balance != null && Number(balance) < Number(amount)
+  }
+}

+ 90 - 0
src/ui-refactor/views/account/AccountPage.vue

@@ -0,0 +1,90 @@
+<template>
+  <div class="ng-account-page" lang="en">
+    <member-summary />
+    <el-tabs v-model="activeTab" class="ng-account-tabs">
+      <el-tab-pane label="Overview" name="overview">
+        <account-overview />
+      </el-tab-pane>
+      <el-tab-pane label="Orders" name="orders" lazy>
+        <account-orders />
+      </el-tab-pane>
+      <el-tab-pane label="Profile" name="profile" lazy>
+        <account-profile />
+      </el-tab-pane>
+    </el-tabs>
+  </div>
+</template>
+
+<script>
+import MemberSummary from './components/MemberSummary.vue'
+import AccountOverview from './components/AccountOverview.vue'
+import AccountOrders from './components/AccountOrders.vue'
+import AccountProfile from './components/AccountProfile.vue'
+
+export default {
+  name: 'AccountPage',
+  components: { MemberSummary, AccountOverview, AccountOrders, AccountProfile },
+  data() {
+    return { activeTab: 'overview' }
+  },
+  watch: {
+    '$route.query.tab': {
+      immediate: true,
+      handler(tab) {
+        this.activeTab = ['overview', 'orders', 'profile'].includes(tab) ? tab : 'overview'
+      }
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.ng-account-page {
+  min-width: 0;
+  color: #25292c;
+}
+
+.ng-account-page ::v-deep .ng-account-card {
+  min-width: 0;
+  margin-bottom: 24px;
+  border-radius: 8px;
+}
+
+.ng-account-page ::v-deep .ng-account-title {
+  margin: 0;
+  color: #202259;
+  font-size: 20px;
+  font-weight: 700;
+  line-height: 1.4;
+}
+
+.ng-account-page ::v-deep .ng-account-note {
+  margin: 8px 0 0;
+  color: #606266;
+  font-size: 13px;
+  line-height: 1.7;
+}
+
+.ng-account-page ::v-deep .ng-account-eyebrow {
+  margin: 0 0 6px;
+  color: #1b6c88;
+  font-size: 11px;
+  font-weight: 700;
+  letter-spacing: 0.12em;
+  text-transform: uppercase;
+}
+
+.ng-account-tabs ::v-deep > .el-tabs__header {
+  margin-bottom: 24px;
+}
+
+.ng-account-page ::v-deep .ng-account-card > .el-card__body {
+  padding: 24px;
+}
+
+@media (max-width: 767px) {
+  .ng-account-page ::v-deep .ng-account-card > .el-card__body {
+    padding: 20px 16px;
+  }
+}
+</style>

+ 91 - 0
src/ui-refactor/views/account/README.md

@@ -0,0 +1,91 @@
+# 会员端二期:账户页面
+
+参考:`C:/Users/Administrator/Desktop/water1013v1/water/account.html`。只采纳内容结构和布局,不执行或移植参考脚本、模拟会员数据、财务规则及 localStorage 行为。
+
+## 当前范围
+
+- `AccountPage.vue` 是内容入口,已按用户后续要求接入 `/dashboard/index`,复用 `src/ui-refactor/index.vue` 布局下的 header/footer;不重复创建 header、footer 或语言切换。
+- `/` 和路由名称 `Dashboard` 统一进入新会员页;`/dashboard/index/dashboard` 保留为兼容别名。旧首页源码、现有 header、接口、Store、全局样式和依赖均保持原样。
+- 页面使用英文。会员信息区已迁入原首页逻辑,余额/交易、奖金、充值/转账、Orders 及 Profile 复用旧页业务;其余未接入区块仍为静态展示,用 `—` 和空状态表示,不把未查询的数据当成真实的零余额。
+- Overview / Orders / Profile、订单类别及网络模式可在本地切换。网络搜索和层数输入仅展示控件,不执行查询。
+- 首页可用 `?tab=overview|orders|profile` 打开指定主页签,并响应同页 query 更新;缺失或非法值回到 Overview。Orders 内层通过 `orderCategory=self|member` 选择 My orders / Member orders,缺失或非法值默认 My orders,仍可手动切换。会员购买成功后按原分类进入对应页签,Welcome Pack 进入 Member orders。
+- 会员信息区支持原有退出、推荐链接复制与 WhatsApp / Facebook 分享;余额卡按既有 `dealSwitch` 显示 View History,支持原有交易筛选和分页;九宫格的 Team Inquiry 跳转至 `/dashboard/team-inquiry`,Previous Bonus 打开历史奖金弹窗,Historical Cumulative Bonus 打开累计奖金卡片弹窗,Recharge and Transfer 打开充值/转账弹窗;Profile 接入原邮箱修改、地址和两类密码流程。Quick actions 的 Talk to support 按原型在新标签页打开 `https://wa.me/2348000000000`,使用 `noopener`;其余未接入入口继续禁用。
+- 使用 Vue 2 / JavaScript / Options API / Element UI。布局参考深蓝标题、白色区块和灰色页面底,控件状态继承现有主题;样式限定在账户页面内。
+
+## 组件边界
+
+| 组件 | 职责 |
+| --- | --- |
+| `AccountPage` | 页面组装与主页签状态 |
+| `MemberSummary` | 身份、五项基础信息、WAT 时钟、推荐链接分享/复制与退出 |
+| `AccountOverview` | 组装 Overview 的四个区块 |
+| `AccountBalances` | Member Bonus / Member Ecoin 余额卡及交易历史弹窗 |
+| `network/SponsorNetwork` | 网络模式、统计、查询控件和树空状态 |
+| `AccountShortcuts` | 九宫格功能入口 |
+| `bonus/PreviousBonusDialog` | 历史奖金列表与原 View 详情弹窗 |
+| `bonus/HistoricalCumulativeBonusDialog` | 累计奖金卡片弹窗,继承旧页查询与金额展示 |
+| `finance/RechargeAndTransferDialog` | 充值/转账页签与公共弹窗组装 |
+| `finance/RechargePanel` | 充值记录、申请/编辑、凭证查看与上传 |
+| `finance/TransferPanel` | 转账记录、筛选分页、申请和原密码提示链路 |
+| `AccountActivity` | 快捷操作和推荐 BV 汇总 |
+| `AccountOrders` | 分类切换,分别继承旧我的订单 / 会员订单脚本 |
+| `AccountOrderCards` | 共用订单卡片、商品明细、下载、Subtotal 和 TOTAL 展示 |
+| `AccountProfile` | 读取旧身份字段并组装个人信息、地址与两类密码组件 |
+
+Sponsor Network 四项统计已改用用户指定的 `/v1/user/network-stats`,目前按无参 GET 接入,首次挂载及切换普通/Ex 模式时刷新。依次读取 `DOWNLINE_COUNT`、`DIRECT_DOWNLINE_COUNT`、`MAX_DOWNLINE_DEEP`、`DOWNLINE_PCV`;保留零值和 PCV 小数,缺失或请求失败显示 `—`,快速切换忽略旧请求结果。树查询继续沿用原接口。真实账号联调尚未执行。
+
+复用已有 `@/components/ui/UiSectionCard.vue`,不创建第二套卡片封装。会员信息区只读复用既有 API/Store/缓存,余额卡继承旧账户组件的数据与方法;其余区块暂不添加业务状态或推测性的 props/events,后续逐块接入时按实际接口明确组件数据流。
+
+## 路由接入与后续业务
+
+Orders 只调整展示:两个分类挂载时分别调用原接口。旧 `order-list.vue` / `member-order.vue` 的脚本通过 Vue Loader 查询导入,data、created、getList、handleDownload 和金额/税额/BV 过滤器原样继承,旧模板和全局样式不加载。总数来自当前分类的 `totalCount`,不合并两类总数,不推测商品图片字段。Subtotal 按每单明细汇总数量、原价格/BV/税额过滤器的结果;用户已明确 TOTAL 为商品金额合计加当前明细税额合计。金额以分累加并保留两位小数,空明细为零,无效金额显示 `—`;不改原税额算法,不固定税率,不影响支付或服务端订单金额。原请求失败及 loading 收尾行为也保持,真实接口和下载文件仍需登录环境联调。
+
+用户后续明确指定 `/dashboard/index` 为二期入口,已更新 `src/router/index.js` 的首页布局和内容映射,并将重复 Dashboard 定义合并为兼容别名。会员页面仍受现有 `src/permission.js` 保护,未加入匿名白名单。
+
+业务逐块接入需要以现有会员模块、真实接口和用户确认的规则为准。参考页中的演示订单、充值转账、密码校验、分享地址及客服号码不作为业务依据。
+
+## 本轮验证(2026-10-14)
+
+- 定向 ESLint:新增的 9 个 Vue 文件无错误、无警告。
+- 使用当前 Vue 2.7 编译器和真实 Element UI 做临时 DOM 自检:10 个 SFC(含复用卡片)编译通过;主页签与订单分类切换正常;九宫格、五项基础信息和六个禁用密码框存在;业务按钮全部禁用,密码为空,无 Vue 组件警告。
+- 在临时目录用现有 Vue CLI 配置和独立入口完成生产构建,退出码 0;保留资源体积及浏览器兼容数据过期提示。没有修改项目配置,也没有写入项目 `dist`。
+- 独立浏览器预览复用现有布局/header/footer:320、375、768、1024、1440px 下三个页签均无页面横向溢出;375×480 短视口可纵向滚动,键盘方向键可切换主页签。网络模式切换与输入 Enter 不触发业务操作。
+- 对开始时已有文件及忽略的环境文件、lockfile 做 SHA-256 对比,零变化;`git diff` 为空,只新增当前账户目录。
+- 以上记录为静态骨架首次交付时的验证结果。后续路由接入只改变首页映射,未连接真实会员数据,未执行登录、下单、转账、地址保存或密码修改。
+
+### 同日首页路由接入验证
+
+- `/`、`/dashboard/index`、兼容别名 `/dashboard/index/dashboard` 和命名路由 `Dashboard` 均解析为新布局与 `AccountPage`,查询参数保留;`/signup` 映射不变。
+- 独立展示入口使用实际路由配置检查 `/dashboard/index`,渲染新会员页和已有 header,没有旧后台首页或侧栏。
+- 完整应用入口 `src/main.js` 的临时目录生产构建退出码 0;定向 lint 通过。使用完整应用及原权限守卫,未登录访问 `/dashboard/index` 返回 `/login?redirect=%2Fdashboard%2Findex`。
+- 本次存量文件哈希变化仅为明确授权的 `src/router/index.js`;旧首页、header、权限守卫、环境及 lockfile 未变。真实登录成功后的端到端流程仍未验证。
+
+### 会员信息区迁移
+
+- 用户指定从 `src/views/dashboard/admin/index.vue` 第 20 行附近迁移到 `components/MemberSummary.vue`,旧首页及现有 header 不修改。
+- 姓名、编号、头像和缓存等级沿用旧首页来源;`dashboard/getIndex` 只取 `periodNum` / `decLv` / `empLv` / `eliteLv`,不迁入轮播、新闻或旧首页样式。
+- 等级优先读取既有英文翻译,缺失时回退到 `baseInfo` 的等级表;接口失败保留缓存值并提示,loading 始终收尾。没有会员编号时不请求受保护接口,也不生成无效推荐链接。
+- 系统时间按 WAT(UTC+1)每秒刷新,组件销毁清理定时器。复制复用 `@/utils/clipboard`;分享继续编码原有完整推荐链接;退出先等待既有 `user/logout`,再保留原返回 URL 跳转。
+- 11 项 Jest 回归测试使用 Vue 2.7 自带编译器加载真实 SFC 和 Element UI,覆盖模板绑定、接口/缓存回退、空缓存、时钟清理、分享、复制点击及退出顺序。
+- 执行命令:`node node_modules/@vue/cli-service/bin/vue-cli-service.js test:unit --runInBand --no-cache --testMatch '**/ui-refactor/**/__tests__/*.spec.js'`。只为本次执行指定测试目录,未修改根测试配置。
+- UI 使用临时隔离样例数据验证,不向真实登录缓存写入样例值;真实账号、后端联调及社交平台最终发送未执行。
+- 最终定向 lint、11 项测试和完整应用临时目录生产构建通过。填入长姓名、完整 WAT 时间、英文等级和推荐链接后,320 / 375 / 768 / 1024 / 1440px 下无横向溢出或卡片内容越界;375×480 可纵向滚动,分享菜单正常。旧首页、header、API、Store、路由以及环境/lockfile 均未产生本轮修改。
+
+### 余额卡与交易历史 UI
+
+- 用户要求只修改 `AccountBalances.vue` 的展示,旧 `src/views/bonus/account-list.vue` 的逻辑全部保留。新组件直接导入旧 Vue 组件并通过 `extends` 继承业务逻辑,使用新模板展示;旧组件样式也会随导入加载。不重写 data、created 或三个业务方法,不修改旧文件、API、全局过滤器及 Pagination。
+- 只展示 `bonus` / `cash` 两类钱包,分别对应 Member Bonus / Member Ecoin。标识依据是既有 `src/views/finance/transfer-list.vue`;其他钱包不纳入本次两张卡片的 UI 范围,旧组件的其他方法分支仍保留。
+- 两类历史共用弹窗布局:日期、备注、表格、分页和右下角 Reset / Select。Bonus 保留交易类型下拉与列,Ecoin 按旧条件隐藏它们;下拉数据仍取服务端 `dealTypes`。
+- 请求继续使用 `fetchAccount()` / `fetchAccountFlow(listQuery)`,保留所有查询字段、打开时不清空已有筛选、`dealSwitch` 显示条件和 Reset 的 page=1 / pageSize=5。旧请求失败行为也未作修改。
+- 金额取值、计算及格式化原样保持:卡片使用 `toThousandFilter`;交易前余额为 `tool.formatPrice(TOTAL - AMOUNT)`,交易后余额和交易金额仍取 `AMOUNT`,正号仍由 `IS_INCR > 0` 决定。这里只记录原写法,不将其视为重新确认的财务规则;未移植参考图的模拟交易或 Breakdown。
+- 新增 8 项回归测试,直接比较旧方法引用并验证真实新模板。与会员信息区合计 19 项定向测试通过;定向 lint 与完整应用临时目录生产构建通过。
+- 两类弹窗在 320 / 375 / 768 / 1024 / 1440px 和 375×480 短视口下均位于可视区域内,底部操作可达,表格/分页在自身区域滚动。截图来自临时隔离 API 样例,不写入登录缓存,不代表已完成真实账户联调。
+
+### Previous Bonus 弹窗
+
+- Previous Bonus 卡片改为打开 `PreviousBonusDialog.vue`,没有新增路由或改动旧 `/bonus/other`。卡片通过 dialog 字段指定组件名;Team Inquiry 仍按 to 跳转。
+- 公共外壳 `src/ui-refactor/components/UiDialog.vue` 统一标题、滚动内容、页脚、关闭和键盘焦点。列表与 View 详情已共用它,其他卡片可以按同一方式注册业务弹窗,接入示例见公共组件 README。
+- 奖金组件只继承旧 `src/views/bonus/bonus-list.vue` 的脚本,旧 data / created / getList / getOne 都不覆盖。继续使用 `fetchBonusList()`、`fetchBonusDetail({ periodNum })`、原 `flowBonusSwitch === 1`、原字段、金额显示方式、标题与详情列数;旧页已注释的 Performance Bonus 不重新启用。
+- 原空列表及请求失败的 loading 行为保持;弹窗只在点击时挂载,关闭过渡结束后卸载,下次打开重新查询。请求返回空列表时仍显示原 noData 文案,不在本次 UI 改造中修正业务错误处理。
+- 6 项新回归测试和 4 项团队入口回归通过,定向 lint 与完整应用临时生产构建通过。使用临时 API 样例验证卡片点击、View、逐层 Close / Escape、焦点恢复、Tab 循环、320 / 375 / 768 / 1024 / 1440px 和 375×480 短视口;真实登录后的接口联调未执行。
+- 全量回归 29 项中 28 项通过;既有余额 Reset 测试仍因 `.ng-account-history-actions` 定位失效而失败,本轮未改动余额组件或测试。旧奖金页、API、权限、路由及环境/lockfile 无本轮修改。

+ 172 - 0
src/ui-refactor/views/account/__tests__/AccountBalances.spec.js

@@ -0,0 +1,172 @@
+/* eslint-env jest */
+import Vue from 'vue'
+import ElementUI from 'element-ui'
+import fs from 'fs'
+import path from 'path'
+import { transformSync } from '@babel/core'
+import { parseComponent, compileTemplate } from 'vue/compiler-sfc'
+import { fetchAccount, fetchAccountFlow } from '@/api/bonus'
+
+jest.mock('@/api/bonus', () => ({ fetchAccount: jest.fn(), fetchAccountFlow: jest.fn() }))
+jest.mock('@/utils/usersInfo', () => ({}))
+
+Vue.use(ElementUI)
+
+// 使用当前 Vue 2.7 编译真实新旧组件,避开 vue-jest 的旧编译器版本冲突。
+const components = new Map()
+function loadComponent(filename) {
+  if (components.has(filename)) return components.get(filename)
+  const { script, template } = parseComponent(fs.readFileSync(filename, 'utf8'))
+  const code = transformSync(script.content, {
+    babelrc: false, configFile: false,
+    plugins: [require.resolve('@babel/plugin-transform-modules-commonjs')]
+  }).code
+  const module = { exports: {}}
+  const localRequire = id => {
+    if (id === '@/components/Pagination') return loadComponent(path.resolve(process.cwd(), 'src/components/Pagination/index.vue'))
+    if (id.endsWith('.vue')) return loadComponent(path.resolve(process.cwd(), 'src', id.slice(2)))
+    return require(id)
+  }
+  new Function('require', 'module', 'exports', code)(localRequire, module, module.exports)
+  const result = module.exports.default
+  const compiled = compileTemplate({ source: template.content, filename })
+  if (compiled.errors.length) throw new Error(compiled.errors.join('\n'))
+  Object.assign(result, new Function(compiled.code + '; return { render, staticRenderFns }')())
+  components.set(filename, result)
+  return result
+}
+
+const LegacyAccount = loadComponent(path.resolve(process.cwd(), 'src/views/bonus/account-list.vue'))
+const AccountBalances = loadComponent(path.resolve(__dirname, '../components/AccountBalances.vue'))
+const instances = []
+const wallets = [
+  { walletType: 'bonus', walletName: 'Original Bonus Name', amount: 1234.5 },
+  { walletType: 'cash', walletName: 'Original Ecoin Name', amount: 73 },
+  { walletType: 'userPerformanceBonus', walletName: 'Performance Bonus', amount: 99 }
+]
+const record = { CREATED_AT: 1791972000, DEAL_TYPE_NAME: 'Credit', TOTAL: 100, AMOUNT: 30, IS_INCR: 1, REMARK: 'Test record' }
+
+function instance() {
+  const vm = new Vue({
+    ...AccountBalances,
+    beforeCreate() {
+      this.$t = key => key === 'bonus.bonusFlowTitle' ? '%s Trading Record' : key
+    }
+  }).$mount()
+  instances.push(vm)
+  return vm
+}
+
+async function settle() {
+  await Promise.resolve()
+  await Vue.nextTick()
+  await Vue.nextTick()
+}
+
+describe('AccountBalances preserves the existing account logic', () => {
+  beforeEach(() => {
+    jest.clearAllMocks()
+    fetchAccount.mockResolvedValue({ data: { wallet: wallets, dealSwitch: '1' }})
+    fetchAccountFlow.mockResolvedValue({ data: { list: [record], totalCount: 25, dealTypes: { credit: 'Credit', debit: 'Debit' }}})
+  })
+  afterEach(async() => {
+    await settle()
+    instances.splice(0).forEach(vm => vm.$destroy())
+  })
+
+  it('inherits all three original methods and the original created hook without overrides', () => {
+    expect(AccountBalances.extends).toBe(LegacyAccount)
+    expect(AccountBalances.methods).toBeUndefined()
+    expect(AccountBalances.created).toBeUndefined()
+    const vm = instance()
+    Object.keys(LegacyAccount.methods).forEach(name => {
+      expect(vm.$options.methods[name]).toBe(LegacyAccount.methods[name])
+    })
+  })
+
+  it('fetches the original wallets and displays only the two requested cards', async() => {
+    const vm = instance()
+    await settle()
+    expect(fetchAccount).toHaveBeenCalledTimes(1)
+    expect(fetchAccount).toHaveBeenCalledWith()
+    expect(vm.walletData).toBe(wallets)
+    expect(vm.listQuery).toEqual({ page: 1, pageSize: 10, walletType: '', createAt: [], remark: '', dealType: '' })
+    expect(vm.$el.querySelectorAll('.ng-account-balance-label').length).toBe(2)
+    expect(vm.$el.textContent).toContain('1,234.5')
+    expect(vm.$el.textContent).toContain('Member Ecoin')
+  })
+
+  it('uses dealSwitch to hide View History exactly as before', async() => {
+    fetchAccount.mockResolvedValue({ data: { wallet: wallets, dealSwitch: '0' }})
+    const vm = instance()
+    await settle()
+    expect(vm.dealSwitch).toBe(false)
+    expect(Array.from(vm.$el.querySelectorAll('.ng-account-history')).every(button => button.style.display === 'none')).toBe(true)
+  })
+
+  it('opens Bonus history with the original wallet type/name and unchanged query', async() => {
+    const vm = instance()
+    await settle()
+    vm.$el.querySelector('.ng-account-history').click()
+    await settle()
+    expect(fetchAccountFlow).toHaveBeenCalledWith(vm.listQuery)
+    expect(vm.listQuery.walletType).toBe('bonus')
+    expect(vm.walletName).toBe('Original Bonus Name')
+    expect(vm.bonusFlowType).toBe('Original Bonus Name Trading Record')
+    expect(vm.dialog).toBe(true)
+    expect(vm.dealTypeSwitch).toBe(true)
+    expect(vm.bonusData).toEqual([record])
+    expect(vm.total).toBe(25)
+    expect(vm.$el.textContent).toContain('Member Bonus Trading Record')
+    expect(vm.$el.querySelector('.ng-account-history-type')).not.toBeNull()
+  })
+
+  it('keeps the existing transaction fields and arithmetic instead of adopting the reference data', async() => {
+    const vm = instance()
+    await settle()
+    vm.handleBonusFlow('bonus', wallets[0].walletName)
+    await settle()
+    const cells = Array.from(vm.$el.querySelectorAll('.el-table__body tr td')).map(cell => cell.textContent.replace(/\s+/g, ' ').trim())
+    expect(cells.slice(1)).toEqual(['Credit', '70.00', '30.00', '+ 30.00', 'Test record'])
+  })
+
+  it('hides the Ecoin transaction type without clearing the existing query fields', async() => {
+    const vm = instance()
+    await settle()
+    Object.assign(vm.listQuery, { page: 3, pageSize: 20, createAt: ['2026-10-01', '2026-10-14'], dealType: 'credit', remark: 'existing filter' })
+    vm.handleBonusFlow('cash', wallets[1].walletName)
+    await settle()
+    expect(fetchAccountFlow).toHaveBeenCalledWith({ page: 3, pageSize: 20, walletType: 'cash', createAt: ['2026-10-01', '2026-10-14'], dealType: 'credit', remark: 'existing filter' })
+    expect(vm.dealTypeSwitch).toBe(false)
+    expect(vm.$el.querySelector('.ng-account-history-type')).toBeNull()
+    expect(vm.$el.textContent).toContain('Member Ecoin Trading Record')
+    expect(vm.$el.querySelectorAll('.el-table__body tr td').length).toBe(5)
+  })
+
+  it('resets page size to the original 5 and repeats the same request', async() => {
+    const vm = instance()
+    await settle()
+    vm.handleBonusFlow('bonus', wallets[0].walletName)
+    await settle()
+    Object.assign(vm.listQuery, { page: 3, pageSize: 20, createAt: ['2026-10-01'], remark: 'old', dealType: 'credit' })
+    const reset = Array.from(vm.$el.querySelectorAll('.ng-account-history-actions button')).find(button => button.textContent.trim() === 'Reset')
+    reset.click()
+    await settle()
+    expect(fetchAccountFlow).toHaveBeenLastCalledWith({ page: 1, pageSize: 5, walletType: 'bonus', createAt: [], remark: '', dealType: '' })
+  })
+
+  it('preserves pagination request values and the original performance-wallet branch', async() => {
+    const vm = instance()
+    await settle()
+    vm.handleBonusFlow('bonus', wallets[0].walletName)
+    await settle()
+    vm.listQuery.page = 2
+    vm.listQuery.pageSize = 20
+    vm.$children.find(child => child.$options.name === 'ElDialog').$children.find(child => child.$options.name === 'Pagination').$emit('pagination')
+    await settle()
+    expect(fetchAccountFlow).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, pageSize: 20, walletType: 'bonus' }))
+    vm.handleBonusFlow('userPerformanceBonus', 'Performance Bonus')
+    await settle()
+    expect(vm.userPerformanceBonusDialog).toBe(true)
+  })
+})

+ 263 - 0
src/ui-refactor/views/account/__tests__/AccountOrders.spec.js

@@ -0,0 +1,263 @@
+/* eslint-env jest */
+import Vue from 'vue'
+import ElementUI from 'element-ui'
+import fs from 'fs'
+import path from 'path'
+import { transformSync } from '@babel/core'
+import { parseComponent, compileTemplate } from 'vue/compiler-sfc'
+import * as shopApi from '@/api/shop'
+import * as userApi from '@/api/user'
+import tool from '@/utils/tool'
+import { toThousandFilter } from '@/filters'
+
+jest.mock('@/api/shop', () => ({ fetchOrderList: jest.fn(), downloadOrder: jest.fn() }))
+jest.mock('@/api/user', () => ({ fetchOrderList: jest.fn(), downloadOrder: jest.fn() }))
+jest.mock('@/utils/usersInfo', () => ({}))
+Vue.use(ElementUI)
+Vue.prototype.$t = key => key
+
+// 沿用现有测试的真实 SFC 编译方式,并支持只加载旧脚本。
+const components = new Map()
+function loadComponent(filename, scriptOnly = false) {
+  const key = filename + scriptOnly
+  if (components.has(key)) return components.get(key)
+  const { script, template } = parseComponent(fs.readFileSync(filename, 'utf8'))
+  const code = transformSync(script.content, {
+    babelrc: false, configFile: false,
+    plugins: [require.resolve('@babel/plugin-transform-modules-commonjs')]
+  }).code
+  const module = { exports: {}}
+  const localRequire = id => {
+    if (id === '@/components/Pagination') return loadComponent(path.resolve(process.cwd(), 'src/components/Pagination/index.vue'))
+    const [file, query] = id.split('?')
+    if (file.endsWith('.vue')) {
+      const resolved = file.startsWith('.') ? path.resolve(path.dirname(filename), file) : path.resolve(process.cwd(), 'src', file.slice(2))
+      return loadComponent(resolved, !!query)
+    }
+    return require(id)
+  }
+  new Function('require', 'module', 'exports', code)(localRequire, module, module.exports)
+  const result = module.exports.default
+  if (!scriptOnly) {
+    const compiled = compileTemplate({ source: template.content, filename })
+    if (compiled.errors.length) throw new Error(compiled.errors.join('\n'))
+    Object.assign(result, new Function(compiled.code + '; return { render, staticRenderFns }')())
+  }
+  components.set(key, result)
+  return result
+}
+
+const AccountOrders = loadComponent(path.resolve(__dirname, '../components/AccountOrders.vue'))
+const legacy = file => loadComponent(path.resolve(process.cwd(), 'src/views', file), true)
+const product = { SKU_CODE: 'SKU-1', GOODS_TITLE: 'Original product', REAL_PRICE: 38000, REAL_PV: 76, BUY_NUMS: 2, TAX_RATE: 7.5 }
+const order = {
+  SN: 'OWN-1', USER_NAME: 'NG00001', REAL_NAME: 'Member name', CONSIGNEE: 'Recipient name',
+  MOBILE: '+234000000', FULL_ADDRESS: 'Original shipping address', COUNTRY: 'Nigeria', CURRENCY: 'NGN',
+  PAY_TYPE: 'Bank', CREATED_AT: 1792000000, PAY_AT: 1792000300, STATUS: 'Unpaid', ORDER_TYPE: 'Original order type',
+  hasChildren: [product, { ...product, SKU_CODE: 'SKU-2', GOODS_TITLE: 'Second product', BUY_NUMS: 1 }]
+}
+const instances = []
+function instance() {
+  const vm = new Vue(AccountOrders).$mount()
+  document.body.appendChild(vm.$el)
+  instances.push(vm)
+  return vm
+}
+async function settle() {
+  for (let i = 0; i < 4; i++) {
+    await Promise.resolve()
+    await Vue.nextTick()
+  }
+  jest.advanceTimersByTime(500)
+  await Vue.nextTick()
+}
+function view(vm) {
+  return vm.$children.find(child => /^Account(Own|Member)Orders$/.test(child.$options.name) && !child._inactive)
+}
+
+beforeEach(() => {
+  jest.useFakeTimers()
+  jest.clearAllMocks()
+  shopApi.fetchOrderList.mockResolvedValue({ data: { list: [order, { ...order, SN: 'OWN-2', STATUS: 'Paid', hasChildren: [] }], totalCount: '42' }})
+  userApi.fetchOrderList.mockResolvedValue({ data: { list: [{ ...order, SN: 'MEMBER-1' }], totalCount: '1' }})
+  for (const api of [shopApi, userApi]) api.downloadOrder.mockResolvedValue({ data: { fileUrl: 'orders/test.pdf', targetName: 'order.pdf' }})
+})
+afterEach(() => {
+  instances.splice(0).forEach(vm => { vm.$destroy(); vm.$el.remove() })
+  jest.clearAllTimers()
+  jest.useRealTimers()
+  jest.restoreAllMocks()
+})
+
+it('inherits each old page without replacing its state, requests, downloads or amount filters', () => {
+  for (const [name, file] of [['OwnOrders', 'shop/order-list.vue'], ['MemberOrders', 'user/member-order.vue']]) {
+    const options = AccountOrders.components[name]
+    const old = legacy(file)
+    expect(options.extends).toBe(old)
+    expect(options.data).toBeUndefined()
+    expect(options.methods).toBeUndefined()
+    expect(options.created).toBeUndefined()
+    const merged = Vue.extend(options).options
+    expect(merged.methods.getList).toBe(old.methods.getList)
+    expect(merged.methods.handleDownload).toBe(old.methods.handleDownload)
+    for (const filter of ['bvFilter', 'priceAmountFilter', 'taxAmountFilter', 'statusFilter']) expect(merged.filters[filter]).toBe(old.filters[filter])
+  }
+})
+
+it.each(['self', 'member'])('renders COVER images and aligned totals for %s orders', async category => {
+  const covered = { ...order, hasChildren: [{ ...product, COVER: 'orders/product.jpg' }, { ...product, SKU_CODE: 'SKU-2', BUY_NUMS: 1, COVER: null }] }
+  for (const api of [shopApi, userApi]) api.fetchOrderList.mockResolvedValue({ data: { list: [covered], totalCount: '1' }})
+  const vm = instance()
+  vm.category = category
+  await settle()
+  const image = vm.$el.querySelector('.ng-order-product-image')
+  expect(image.__vue__.src).toBe(tool.getArImage('orders/product.jpg', '/files/'))
+  expect(image.__vue__.$attrs.alt).toBe(product.GOODS_TITLE)
+  expect(vm.$el.querySelectorAll('.ng-order-product-image')).toHaveLength(1)
+  expect(vm.$el.querySelectorAll('.el-table__body tbody tr')[1].querySelector('td').textContent.trim()).toBe('—')
+  const cells = Array.from(vm.$el.querySelectorAll('.el-table__footer-wrapper td'), cell => cell.textContent.trim())
+  expect(cells).toEqual(['Subtotal', '', '', '3', '', '114,000.00', '228', '', '1,121.34'])
+})
+
+it('loads only the selected endpoint and displays multiple orders, all details and products', async() => {
+  const vm = instance()
+  await settle()
+  expect(shopApi.fetchOrderList).toHaveBeenCalledWith({ v: 2, page: 1, pageSize: 20 })
+  expect(userApi.fetchOrderList).not.toHaveBeenCalled()
+  expect(vm.$el.querySelectorAll('.ng-order-card')).toHaveLength(2)
+  const text = vm.$el.textContent
+  for (const value of [order.SN, 'OWN-2', order.USER_NAME, order.REAL_NAME, order.CONSIGNEE, order.MOBILE, order.FULL_ADDRESS, order.COUNTRY, order.CURRENCY, order.PAY_TYPE, order.ORDER_TYPE, product.GOODS_TITLE, 'Second product']) expect(text).toContain(value)
+  expect(text).toContain(tool.convertToNigeriaTime(order.CREATED_AT))
+  expect(text).toContain(tool.convertToNigeriaTime(order.PAY_AT))
+  expect(text).toContain('42 orders in total')
+  expect(vm.$el.querySelector('.el-tag--info').textContent).toBe('Unpaid')
+  expect(vm.$el.querySelector('.el-tag--success').textContent).toBe('Paid')
+  const cells = vm.$el.querySelector('.el-table__body tbody tr').textContent
+  const filters = legacy('shop/order-list.vue').filters
+  for (const filter of ['bvFilter', 'priceAmountFilter', 'taxAmountFilter']) expect(cells).toContain(toThousandFilter(filters[filter](product)))
+  expect(cells).toContain('0.075')
+})
+
+it('keeps separate pagination when switching categories and does not duplicate requests on return', async() => {
+  const vm = instance()
+  await settle()
+  const own = view(vm)
+  const pager = own.$children.find(child => child.$options.name === 'Pagination')
+  pager.$emit('update:page', 2)
+  pager.$emit('update:limit', 5)
+  pager.$emit('pagination')
+  await settle()
+  expect(shopApi.fetchOrderList).toHaveBeenLastCalledWith({ v: 2, page: 2, pageSize: 5 })
+  vm.category = 'member'
+  await settle()
+  expect(userApi.fetchOrderList).toHaveBeenCalledWith({ v: 2, page: 1, pageSize: 20 })
+  expect(vm.$el.textContent).toContain('MEMBER-1')
+  expect(vm.$el.textContent).not.toContain('OWN-1')
+  vm.category = 'self'
+  await settle()
+  expect(view(vm)).toBe(own)
+  expect(own.listQuery).toEqual({ v: 2, page: 2, pageSize: 5 })
+  expect(shopApi.fetchOrderList).toHaveBeenCalledTimes(2)
+})
+
+it('uses the selected old download method and preserves its URL and filename', async() => {
+  const click = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function() {
+    expect(this.getAttribute('href')).toBe(process.env.VUE_APP_BASE_DO_API + '/orders/test.pdf')
+    expect(this.download).toBe('order.pdf')
+    expect(this.target).toBe('_blank')
+  })
+  const vm = instance()
+  await settle()
+  vm.$el.querySelector('.ng-order-header button').click()
+  await settle()
+  expect(shopApi.downloadOrder).toHaveBeenCalledWith('OWN-1')
+  expect(userApi.downloadOrder).not.toHaveBeenCalled()
+  vm.category = 'member'
+  await settle()
+  vm.$el.querySelector('.ng-order-header button').click()
+  await settle()
+  expect(userApi.downloadOrder).toHaveBeenCalledWith('MEMBER-1')
+  expect(click).toHaveBeenCalledTimes(2)
+})
+
+it('does not let a late response from another category replace the visible list', async() => {
+  let resolveOwn
+  shopApi.fetchOrderList.mockReturnValue(new Promise(resolve => { resolveOwn = resolve }))
+  const vm = instance()
+  await Vue.nextTick()
+  vm.category = 'member'
+  await settle()
+  resolveOwn({ data: { list: [order], totalCount: '1' }})
+  await settle()
+  expect(vm.$el.textContent).toContain('MEMBER-1')
+  expect(vm.$el.textContent).not.toContain('OWN-1')
+  expect(view(vm).listLoading).toBe(false)
+})
+
+it('shows an empty state and hides pagination after the original loading delay', async() => {
+  shopApi.fetchOrderList.mockResolvedValue({ data: { list: [], totalCount: '0' }})
+  const vm = instance()
+  expect(view(vm).listLoading).toBe(true)
+  await settle()
+  expect(view(vm).listLoading).toBe(false)
+  expect(vm.$el.textContent).toContain('No orders yet.')
+  expect(vm.$el.querySelector('.pagination-container').style.display).toBe('none')
+})
+
+it('summarizes each order separately using the existing displayed quantity, price, BV and tax', async() => {
+  const vm = instance()
+  await settle()
+  expect(view(vm).orderSummaries[0].cells).toEqual(['Subtotal', '', '', '3', '', '114,000.00', '228', '', '1,121.34'])
+  expect(view(vm).orderSummaries[1].cells).toEqual(['Subtotal', '', '', '0', '', '0.00', '0', '', '0.00'])
+  expect(view(vm).orderSummaries[0].total).toBe('115,121.34')
+  expect(view(vm).orderSummaries[1].total).toBe('0.00')
+  const footer = vm.$el.querySelector('.el-table__footer-wrapper')
+  expect(footer.textContent).toContain('Subtotal')
+  expect(footer.textContent).toContain('114,000.00')
+  expect(footer.textContent).toContain('1,121.34')
+  expect(vm.$el.querySelector('.ng-order-total').textContent).toContain('115,121.34')
+  vm.category = 'member'
+  await settle()
+  expect(view(vm).orderSummaries[0].cells).toEqual(['Subtotal', '', '', '3', '', '114,000.00', '228', '', '1,121.34'])
+  expect(view(vm).orderSummaries[0].total).toBe('115,121.34')
+})
+
+it('adds decimal amounts in cents and recalculates after list data changes', async() => {
+  const rows = ['0.10', '0.20'].map((price, i) => ({ ...product, SKU_CODE: 'DECIMAL-' + i, REAL_PRICE: price, REAL_PV: '0', BUY_NUMS: '1', TAX_RATE: '0' }))
+  shopApi.fetchOrderList.mockResolvedValue({ data: { list: [{ ...order, hasChildren: rows }], totalCount: '1' }})
+  const vm = instance()
+  await settle()
+  expect(view(vm).orderSummaries[0].cells).toEqual(['Subtotal', '', '', '2', '', '0.30', '0', '', '0.00'])
+  expect(view(vm).orderSummaries[0].total).toBe('0.30')
+  view(vm).tableData = [{ ...order, hasChildren: [{ ...product, REAL_PRICE: '12.34', REAL_PV: '0', BUY_NUMS: '0' }] }]
+  await Vue.nextTick()
+  expect(view(vm).orderSummaries[0].cells).toEqual(['Subtotal', '', '', '0', '', '0.00', '0', '', '0.00'])
+  expect(view(vm).orderSummaries[0].total).toBe('0.00')
+})
+
+it('shows unavailable instead of crashing or inventing zero totals for invalid amounts', async() => {
+  shopApi.fetchOrderList.mockResolvedValue({ data: { list: [{ ...order, hasChildren: [{ ...product, REAL_PRICE: 'invalid' }] }], totalCount: '1' }})
+  const vm = instance()
+  await settle()
+  expect(view(vm).orderSummaries[0].cells[5]).toBe('—')
+  expect(view(vm).orderSummaries[0].cells[8]).toBe('—')
+  expect(view(vm).orderSummaries[0].total).toBe('—')
+})
+
+it('shows the centered empty state for each category only after loading, with an enabled purchase button', async() => {
+  for (const api of [shopApi, userApi]) api.fetchOrderList.mockResolvedValue({ data: { list: [], totalCount: '0' }})
+  const vm = instance()
+  expect(vm.$el.querySelector('.ng-order-empty')).toBeNull()
+  await settle()
+  expect(vm.$el.querySelector('.ng-order-empty').textContent).toContain('No orders yet.')
+  expect(vm.$el.querySelector('.ng-order-empty button').textContent).toContain('Repeat Purchase')
+  vm.category = 'member'
+  await settle()
+  const empty = vm.$el.querySelector('.ng-order-empty')
+  expect(empty.textContent).toContain('No member orders yet. Use Member Repeat Purchase or Auto-Maintenance to place orders for your downline.')
+  expect(empty.querySelector('.el-icon-box')).not.toBeNull()
+  expect(empty.querySelector('button').textContent).toContain('Member Repeat Purchase')
+  expect(empty.querySelector('button').disabled).toBe(false)
+  expect(vm.$el.querySelector('.ng-order-card')).toBeNull()
+  expect(vm.$el.querySelector('.ng-order-total')).toBeNull()
+})

+ 345 - 0
src/ui-refactor/views/account/__tests__/AccountProfile.spec.js

@@ -0,0 +1,345 @@
+/* eslint-env jest */
+import Vue from 'vue'
+import ElementUI from 'element-ui'
+import Vuex from 'vuex'
+import fs from 'fs'
+import path from 'path'
+import { transformSync } from '@babel/core'
+import { parseComponent, compileTemplate } from 'vue/compiler-sfc'
+import * as userApi from '@/api/user'
+import * as addressApi from '@/api/config'
+import usersInfo from '@/utils/usersInfo'
+
+jest.mock('@/api/user', () => ({ updateInfo: jest.fn(), updateLoginPassword: jest.fn(), updatePaymentPassword: jest.fn() }))
+jest.mock('@/api/config', () => ({ fetchAddressList: jest.fn(), createAddress: jest.fn(), updateAddress: jest.fn(), deleteAddress: jest.fn(), getRegionList: jest.fn() }))
+jest.mock('@/utils/usersInfo', () => ({ userName: jest.fn(), userEmail: jest.fn(), baseData: jest.fn(), logout: jest.fn() }))
+Vue.use(ElementUI)
+Vue.use(Vuex)
+Vue.prototype.$t = key => key
+const pageLocation = { reload: jest.fn() }
+
+// 使用当前 Vue 2.7 编译真实新旧组件,避开 vue-jest 的旧编译器版本冲突。
+const components = new Map()
+function loadComponent(filename) {
+  if (components.has(filename)) return components.get(filename)
+  const { script, template } = parseComponent(fs.readFileSync(filename, 'utf8'))
+  const code = transformSync(script.content, {
+    babelrc: false, configFile: false,
+    plugins: [require.resolve('@babel/plugin-transform-modules-commonjs')]
+  }).code
+  const module = { exports: {}}
+  const localRequire = id => {
+    if (id === '@/components/Pagination') return loadComponent(path.resolve(process.cwd(), 'src/components/Pagination/index.vue'))
+    if (id.endsWith('.vue')) return loadComponent(id.startsWith('.') ? path.resolve(path.dirname(filename), id) : path.resolve(process.cwd(), 'src', id.slice(2)))
+    return require(id)
+  }
+  new Function('require', 'module', 'exports', 'location', code)(localRequire, module, module.exports, pageLocation)
+  const result = module.exports.default
+  const compiled = compileTemplate({ source: template.content, filename })
+  if (compiled.errors.length) throw new Error(compiled.errors.join('\n'))
+  Object.assign(result, new Function(compiled.code + '; return { render, staticRenderFns }')())
+  components.set(filename, result)
+  return result
+}
+
+const source = name => loadComponent(path.resolve(process.cwd(), name))
+const current = name => loadComponent(path.resolve(__dirname, '../components/', name))
+const Account = source('src/views/profile/components/Account.vue')
+const Login = source('src/views/profile/components/LoginPassword.vue')
+const Payment = source('src/views/profile/components/PaymentPassword.vue')
+const Address = source('src/views/config/shipping-address-list.vue')
+const Personal = current('profile/PersonalInformation.vue')
+const LoginPanel = current('profile/LoginPasswordPanel.vue')
+const PaymentPanel = current('profile/PaymentPasswordPanel.vue')
+const Addresses = current('profile/DeliveryAddresses.vue')
+const Profile = current('AccountProfile.vue')
+const instances = []
+const identity = { name: 'NG00001', email: 'preview@example.invalid', mobile: '+234000000000', realName: 'UI Preview Member' }
+const row = { ID: 'ui-address', CONSIGNEE: 'UI Recipient', MOBILE: '+234000000000', PROVINCE: 'R1', PROVINCE_NAME: 'Preview Region', CITY_NAME: 'Preview City', LGA_NAME: 'Preview District', ADDRESS: 'Preview Street', IS_DEFAULT: '1' }
+const payload = { consignee: 'UI Recipient', mobile: '+234000000000', province: 'R1', cityName: 'Preview City', lgaName: 'Preview District', address: 'Preview Street', isDefault: '1' }
+function instance(component, propsData = {}) {
+  const countries = Vue.observable({ list: [] })
+  const store = { getters: {}, dispatch: jest.fn(() => {
+    countries.list = [{ ID: 'PREVIEW', NAME: 'Nigeria' }]
+    return Promise.resolve(countries.list)
+  }) }
+  Object.defineProperty(store.getters, 'getCountriesList', { get: () => countries.list })
+  const vm = new Vue({
+    ...component,
+    propsData,
+    beforeCreate() {
+      this.$store = store
+      this.$message = jest.fn()
+      this.$confirm = jest.fn().mockResolvedValue()
+    }
+  }).$mount()
+  instances.push(vm)
+  return vm
+}
+async function settle() {
+  for (let i = 0; i < 2; i++) {
+    await Promise.resolve()
+    await Vue.nextTick()
+    jest.advanceTimersByTime(1500)
+  }
+  await Promise.resolve()
+  await Vue.nextTick()
+}
+function button(vm, text) {
+  return Array.from(vm.$el.querySelectorAll('button')).find(el => el.textContent.trim() === text)
+}
+function input(vm, id, value) {
+  const element = vm.$el.querySelector('#' + id)
+  element.value = value
+  element.dispatchEvent(new Event('input', { bubbles: true }))
+}
+function descendants(vm, name) {
+  return vm.$children.flatMap(child => (child.$options.name === name ? [child] : []).concat(descendants(child, name)))
+}
+
+describe('Profile UI preserves existing account, password and address behavior', () => {
+  beforeEach(() => {
+    jest.useFakeTimers()
+    jest.clearAllMocks()
+    pageLocation.reload.mockClear()
+    localStorage.setItem('baseData', JSON.stringify({ COUNTRY_ID: 'PREVIEW' }))
+    usersInfo.userName.mockReturnValue(identity.name)
+    usersInfo.userEmail.mockReturnValue(identity.email)
+    usersInfo.baseData.mockReturnValue({ MOBILE: identity.mobile, REAL_NAME: identity.realName })
+    userApi.updateInfo.mockResolvedValue({})
+    userApi.updateLoginPassword.mockResolvedValue({})
+    userApi.updatePaymentPassword.mockResolvedValue({})
+    addressApi.fetchAddressList.mockImplementation(() => Promise.resolve({ data: { list: [{ ...row }], totalCount: 25 }}))
+    addressApi.getRegionList.mockResolvedValue({ data: [{ value: 'R1', label: 'Preview Region' }] })
+    addressApi.createAddress.mockResolvedValue({})
+    addressApi.updateAddress.mockResolvedValue({})
+    addressApi.deleteAddress.mockResolvedValue({})
+  })
+  afterEach(async() => {
+    await settle()
+    instances.splice(0).forEach(vm => vm.$destroy())
+    localStorage.removeItem('baseData')
+    jest.clearAllTimers()
+    jest.useRealTimers()
+  })
+
+  it('inherits all existing business methods and password/address data without overrides', () => {
+    for (const [component, legacy] of [[Personal, Account], [LoginPanel, Login], [PaymentPanel, Payment], [Addresses, Address]]) {
+      expect(component.extends).toBe(legacy)
+      if (component === LoginPanel || component === PaymentPanel) expect(component.methods).toBeUndefined()
+      const vm = instance(component, component === Personal ? { user: { ...identity }} : {})
+      Object.keys(legacy.methods).forEach(name => expect(vm.$options.methods[name]).toBe(legacy.methods[name]))
+      expect(component.created).toBeUndefined()
+      expect(component.mounted).toBeUndefined()
+    }
+  })
+
+  it('assembles the requested sections from the original cache fields with empty password inputs', async() => {
+    const vm = instance(Profile)
+    await settle()
+    expect(vm.user).toEqual(identity)
+    expect(vm.$el.textContent).toContain('Personal information')
+    expect(vm.$el.textContent).toContain('Delivery addresses')
+    expect(vm.$el.textContent).toContain('Payment password')
+    expect(vm.$el.textContent).toContain('Login password')
+    expect(vm.$el.querySelectorAll('input[type=password]').length).toBe(6)
+    expect(Array.from(vm.$el.querySelectorAll('input[type=password]')).every(el => el.value === '')).toBe(true)
+    expect(vm.$el.querySelector('#ng-profile-country').value).toBe('Nigeria')
+    expect(vm.$store.dispatch).toHaveBeenCalledWith('settings/getCountries')
+  })
+
+  it('keeps the original read-only identity fields and only enables email editing with Cancel restore', async() => {
+    const vm = instance(Personal, { user: { ...identity }})
+    await settle()
+    expect(vm.$el.querySelector('#ng-profile-email').readOnly).toBe(true)
+    button(vm, 'Edit').click()
+    await settle()
+    for (const name of ['member-id', 'member-name', 'country', 'phone']) expect(vm.$el.querySelector('#ng-profile-' + name).readOnly).toBe(true)
+    expect(vm.$el.querySelector('#ng-profile-email').readOnly).toBe(false)
+    input(vm, 'ng-profile-email', ' changed@example.invalid ')
+    await settle()
+    expect(vm.user.email).toBe('changed@example.invalid')
+    button(vm, 'Cancel').click()
+    await settle()
+    expect(vm.user.email).toBe(identity.email)
+    expect(userApi.updateInfo).not.toHaveBeenCalled()
+  })
+
+  it('keeps the exact personal-information payload, email cache update and reload after success', async() => {
+    const vm = instance(Personal, { user: { ...identity }})
+    await settle()
+    button(vm, 'Edit').click()
+    await settle()
+    input(vm, 'ng-profile-email', 'new@example.invalid')
+    await settle()
+    button(vm, 'Save changes').click()
+    await settle()
+    expect(userApi.updateInfo).toHaveBeenCalledWith({ bankNo: '1', bankAddress: '1', email: 'new@example.invalid', mobile: identity.mobile, nation: '', openBank: '', realName: identity.realName })
+    expect(usersInfo.userEmail).toHaveBeenCalledWith('new@example.invalid')
+    expect(pageLocation.reload).toHaveBeenCalledTimes(1)
+    expect(vm.loading).toBe(false)
+  })
+
+  it('preserves the editable view and original error/loading behavior when saving email fails', async() => {
+    userApi.updateInfo.mockRejectedValue('Preview error')
+    const vm = instance(Personal, { user: { ...identity }})
+    await settle()
+    vm.startEditing()
+    await settle()
+    button(vm, 'Save changes').click()
+    await settle()
+    expect(vm.editing).toBe(true)
+    expect(vm.loading).toBe(false)
+    expect(pageLocation.reload).not.toHaveBeenCalled()
+    expect(vm.$message).toHaveBeenCalledWith(expect.objectContaining({ message: 'Preview error', type: 'error' }))
+  })
+
+  it('keeps each password field name and trim binding, with logout only after login-password success', async() => {
+    for (const [component, newField, api] of [[PaymentPanel, 'payPassword', userApi.updatePaymentPassword], [LoginPanel, 'password', userApi.updateLoginPassword]]) {
+      const vm = instance(component)
+      await settle()
+      input(vm, 'ng-profile-' + newField + '-oldPassword', ' old-test-only ')
+      input(vm, 'ng-profile-' + newField + '-' + newField, ' new-test-only ')
+      input(vm, 'ng-profile-' + newField + '-verifyPassword', ' new-test-only ')
+      await settle()
+      expect(vm.password.oldPassword).toBe('old-test-only')
+      expect(vm.password[newField]).toBe('new-test-only')
+      expect(vm.password.verifyPassword).toBe('new-test-only')
+      expect(vm.$el.querySelectorAll('input[type=password]').length).toBe(3)
+      button(vm, 'Save').click()
+      await settle()
+      expect(api).toHaveBeenCalledWith(vm.password)
+      expect(vm.loading).toBe(false)
+      if (component === PaymentPanel) expect(usersInfo.logout).not.toHaveBeenCalled()
+    }
+    expect(usersInfo.logout).toHaveBeenCalledTimes(1)
+  })
+
+  it('keeps password failures on the current page and blocks Save while loading', async() => {
+    userApi.updateLoginPassword.mockRejectedValue('Preview login error')
+    userApi.updatePaymentPassword.mockRejectedValue('Preview payment error')
+    for (const component of [PaymentPanel, LoginPanel]) {
+      const vm = instance(component)
+      await settle()
+      button(vm, 'Save').click()
+      await settle()
+      expect(vm.loading).toBe(false)
+      expect(vm.$message).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
+      vm.loading = true
+      await Vue.nextTick()
+      expect(button(vm, 'Save').disabled).toBe(true)
+    }
+    expect(usersInfo.logout).not.toHaveBeenCalled()
+  })
+
+  it('renders address fields and the original default flag and keeps pagination query values', async() => {
+    const vm = instance(Addresses)
+    await settle()
+    expect(addressApi.fetchAddressList).toHaveBeenCalledWith({ page: 1, pageSize: 20 })
+    for (const value of [row.CONSIGNEE, row.MOBILE, row.ADDRESS, row.CITY_NAME, row.LGA_NAME, row.PROVINCE_NAME, 'DEFAULT']) expect(vm.$el.textContent).toContain(value)
+    vm.listQuery.page = 2
+    descendants(vm, 'Pagination')[0].$emit('pagination')
+    await settle()
+    expect(addressApi.fetchAddressList).toHaveBeenLastCalledWith({ page: 2, pageSize: 20 })
+  })
+
+  it('uses the original create model and region list inline, retaining the draft on Cancel', async() => {
+    const vm = instance(Addresses)
+    await settle()
+    button(vm, 'Add new address').click()
+    await settle()
+    expect(vm.dialog).toBe(true)
+    expect(addressApi.getRegionList).toHaveBeenCalledTimes(1)
+    const form = descendants(vm, 'AddressForm')[0]
+    expect(form.form).toBe(vm.dataForm)
+    expect(form.regions).toBe(vm.regionData)
+    input(vm, 'ng-profile-address-create-recipient', 'UI draft')
+    await settle()
+    button(form, 'Cancel').click()
+    await settle()
+    expect(vm.dialog).toBe(false)
+    expect(vm.dataForm.consignee).toBe('UI draft')
+    expect(addressApi.createAddress).not.toHaveBeenCalled()
+  })
+
+  it('sends the original address create payload and refreshes the list after success', async() => {
+    const vm = instance(Addresses)
+    await settle()
+    button(vm, 'Add new address').click()
+    await settle()
+    Object.assign(vm.dataForm, payload)
+    button(descendants(vm, 'AddressForm')[0], 'Confirm').click()
+    await settle()
+    expect(addressApi.createAddress).toHaveBeenCalledWith(payload)
+    expect(vm.dialog).toBe(false)
+    expect(vm.dialogLoading).toBe(false)
+    expect(addressApi.fetchAddressList).toHaveBeenCalledTimes(2)
+  })
+
+  it('preserves edit row mapping, ID and default string values without changing other card actions', async() => {
+    const vm = instance(Addresses)
+    await settle()
+    button(vm, 'Edit').click()
+    await settle()
+    expect(vm.editForm).toEqual({ id: row.ID, ...payload })
+    expect(button(vm, 'Add new address').disabled).toBe(true)
+    expect(button(vm, 'Delete').disabled).toBe(true)
+    const form = descendants(vm, 'AddressForm')[0]
+    expect(form.form).toBe(vm.editForm)
+    vm.editForm.address = 'Edited preview street'
+    button(form, 'Confirm').click()
+    await settle()
+    expect(addressApi.updateAddress).toHaveBeenCalledWith({ id: row.ID, ...payload, address: 'Edited preview street' })
+    expect(vm.dialogEdit).toBe(false)
+    const toggle = descendants(vm, 'ElSwitch')[0]
+    toggle.$emit('input', '0')
+    toggle.$emit('change', '0')
+    await settle()
+    expect(addressApi.updateAddress).toHaveBeenLastCalledWith({ id: row.ID, ...payload, isDefault: '0' })
+  })
+
+  it('retains the delete confirmation before sending the selected ID list', async() => {
+    const vm = instance(Addresses)
+    await settle()
+    let approve
+    vm.$confirm.mockReturnValue(new Promise(resolve => { approve = resolve }))
+    button(vm, 'Delete').click()
+    expect(vm.$confirm).toHaveBeenCalledWith('common.deleteTips', 'common.hint', expect.objectContaining({ type: 'warning', customClass: 'csClass' }))
+    expect(addressApi.deleteAddress).not.toHaveBeenCalled()
+    approve()
+    await settle()
+    expect(addressApi.deleteAddress).toHaveBeenCalledWith({ selected: [row.ID] })
+    expect(addressApi.fetchAddressList).toHaveBeenCalledTimes(2)
+  })
+
+  it('keeps create/edit forms open and their original error/loading behavior after failures', async() => {
+    addressApi.createAddress.mockRejectedValue('Preview create error')
+    addressApi.updateAddress.mockRejectedValue('Preview update error')
+    const vm = instance(Addresses)
+    await settle()
+    button(vm, 'Add new address').click()
+    await settle()
+    button(descendants(vm, 'AddressForm')[0], 'Confirm').click()
+    await settle()
+    expect(vm.dialog).toBe(true)
+    expect(vm.dialogLoading).toBe(false)
+    button(descendants(vm, 'AddressForm')[0], 'Cancel').click()
+    await settle()
+    button(vm, 'Edit').click()
+    await settle()
+    button(descendants(vm, 'AddressForm')[0], 'Confirm').click()
+    await settle()
+    expect(vm.dialogEdit).toBe(true)
+    expect(vm.editLoading).toBe(false)
+    expect(vm.$message).toHaveBeenCalledWith(expect.objectContaining({ message: 'Preview update error', type: 'error' }))
+  })
+
+  it('shows the empty personal state when the existing identity cache is absent', async() => {
+    usersInfo.userName.mockReturnValue('')
+    usersInfo.baseData.mockReturnValue(null)
+    const vm = instance(Profile)
+    await settle()
+    expect(vm.$el.textContent).toContain('No member information to display.')
+    expect(vm.$store.dispatch).not.toHaveBeenCalled()
+  })
+})

+ 183 - 0
src/ui-refactor/views/account/__tests__/MemberSummary.spec.js

@@ -0,0 +1,183 @@
+/* eslint-env jest */
+import Vue from 'vue'
+import ElementUI from 'element-ui'
+import fs from 'fs'
+import path from 'path'
+import { transformSync } from '@babel/core'
+import { parseComponent, compileTemplate } from 'vue/compiler-sfc'
+import usersInfo from '@/utils/usersInfo'
+import handleClipboard from '@/utils/clipboard'
+
+jest.mock('@/utils/usersInfo', () => ({
+  userName: jest.fn(), baseData: jest.fn(), getBaseInfo: jest.fn()
+}))
+jest.mock('@/utils/clipboard', () => jest.fn())
+
+Vue.use(ElementUI)
+
+// 使用 Vue 2.7 自带编译器,避免当前 vue-jest 加载不匹配的旧模板编译器。
+const filename = path.resolve(__dirname, '../components/MemberSummary.vue')
+const { script, template } = parseComponent(fs.readFileSync(filename, 'utf8'))
+const code = transformSync(script.content, {
+  babelrc: false,
+  configFile: false,
+  plugins: [require.resolve('@babel/plugin-transform-modules-commonjs')]
+}).code
+const componentModule = { exports: {}}
+new Function('require', 'module', 'exports', code)(require, componentModule, componentModule.exports)
+const MemberSummary = componentModule.exports.default
+const compiled = compileTemplate({ source: template.content, filename })
+if (compiled.errors.length) throw new Error(compiled.errors.join('\n'))
+Object.assign(MemberSummary, new Function(compiled.code + '; return { render, staticRenderFns }')())
+const instances = []
+
+function instance(dispatch = jest.fn().mockResolvedValue({ data: {}})) {
+  const router = { push: jest.fn() }
+  const message = { error: jest.fn() }
+  const translations = { 'decLvs.gold': 'Gold', 'empLevels.director': '1 Star Director', 'eliteLevels.elite': 'Elite' }
+  const vm = new Vue({
+    ...MemberSummary,
+    beforeCreate() {
+      this.$store = { dispatch }
+      this.$router = router
+      this.$route = { fullPath: '/dashboard/index?source=test' }
+      this.$message = message
+      this.$te = jest.fn((key, locale) => locale === 'en' && key in translations)
+      this.$t = jest.fn(key => translations[key])
+    }
+  }).$mount()
+  instances.push(vm)
+  return { vm, dispatch, router, message }
+}
+
+async function settle() {
+  await Promise.resolve()
+  await Vue.nextTick()
+}
+
+describe('MemberSummary migration', () => {
+  beforeEach(() => {
+    jest.useFakeTimers()
+    jest.clearAllMocks()
+    usersInfo.userName.mockReturnValue('TEST-MEMBER')
+    usersInfo.baseData.mockReturnValue({ REAL_NAME: 'Test member', DEC_LV: 'silver', EMP_LV: 'cached-honor', ELITE_LV: 'cached-elite' })
+    usersInfo.getBaseInfo.mockReturnValue({
+      periodNum: 'cached-period',
+      decLevels: { silver: { LEVEL_NAME: 'Cached Silver' }},
+      empLevels: { 'cached-honor': { LEVEL_NAME: 'Cached Honor' }},
+      eliteLevels: { 'cached-elite': { LEVEL_NAME: 'Cached Elite' }}
+    })
+  })
+
+  afterEach(() => {
+    instances.splice(0).forEach(vm => vm.$destroy())
+    jest.restoreAllMocks()
+    jest.useRealTimers()
+  })
+
+  it('reads cached identity and levels without importing the old dashboard', async() => {
+    const { vm } = instance()
+    await settle()
+    expect(vm.memberName).toBe('Test member')
+    expect(vm.memberId).toBe('TEST-MEMBER')
+    expect(vm.memberDetails.slice(1).map(item => item.value)).toEqual(['cached-period', 'Cached Silver', 'Cached Honor', 'Cached Elite'])
+    expect(vm.referralLink).toBe('https://meads.elken.com/#/login?ref=TEST-MEMBER')
+    expect(vm.$el.querySelector('h1').textContent).toBe('Test member')
+    expect(vm.$el.querySelector('input').value).toBe(vm.referralLink)
+  })
+
+  it('uses dashboard values and explicitly resolves English level names', async() => {
+    const dispatch = jest.fn().mockResolvedValue({ data: { periodNum: 'current-period', decLv: 'gold', empLv: 'director', eliteLv: 'elite' }})
+    const { vm } = instance(dispatch)
+    await settle()
+    expect(dispatch).toHaveBeenCalledTimes(1)
+    expect(dispatch).toHaveBeenCalledWith('dashboard/getIndex', {})
+    expect(vm.memberDetails.slice(1).map(item => item.value)).toEqual(['current-period', 'Gold', '1 Star Director', 'Elite'])
+    expect(vm.$t).toHaveBeenCalledWith('decLvs.gold', 'en')
+    expect(vm.loading).toBe(false)
+  })
+
+  it('shows cached values and closes loading when the dashboard request fails', async() => {
+    const { vm, message } = instance(jest.fn().mockRejectedValue(new Error('offline')))
+    await settle()
+    expect(vm.loading).toBe(false)
+    expect(vm.memberDetails[1].value).toBe('cached-period')
+    expect(message.error).toHaveBeenCalledTimes(1)
+  })
+
+  it('leaves empty cache safe and skips the protected request without a member ID', async() => {
+    usersInfo.userName.mockReturnValue(null)
+    usersInfo.baseData.mockReturnValue(null)
+    usersInfo.getBaseInfo.mockReturnValue(null)
+    const { vm, dispatch } = instance()
+    await settle()
+    expect(vm.memberName).toBe('My account')
+    expect(vm.memberDetails.slice(1).map(item => item.value)).toEqual(['—', '—', '—', '—'])
+    expect(vm.referralLink).toBe('')
+    expect(dispatch).not.toHaveBeenCalled()
+    expect(Array.from(vm.$el.querySelectorAll('button')).every(button => button.disabled)).toBe(true)
+  })
+
+  it('updates WAT time every second and clears its interval on destruction', () => {
+    const now = jest.spyOn(Date, 'now').mockReturnValue(Date.UTC(2026, 9, 14))
+    const clear = jest.spyOn(window, 'clearInterval')
+    const { vm } = instance()
+    expect(vm.memberSystemTime).toBe('2026-10-14 01:00:00 (WAT)')
+    now.mockReturnValue(Date.UTC(2026, 9, 14, 0, 0, 1))
+    jest.advanceTimersByTime(1000)
+    expect(vm.memberSystemTime).toBe('2026-10-14 01:00:01 (WAT)')
+    const clock = vm.memberClock
+    vm.$destroy()
+    expect(clear).toHaveBeenCalledWith(clock)
+  })
+
+  it.each([
+    ['whatsapp', 'https://wa.me/?text='],
+    ['facebook', 'https://www.facebook.com/sharer/sharer.php?u=']
+  ])('shares the complete encoded referral link through %s', (channel, prefix) => {
+    const open = jest.spyOn(window, 'open').mockImplementation(() => {})
+    const { vm } = instance()
+    vm.handleShareLink(channel)
+    expect(open).toHaveBeenCalledWith(prefix + encodeURIComponent(vm.referralLink), '_blank', 'noopener,noreferrer')
+  })
+
+  it('does not open unknown share destinations or share without a member ID', () => {
+    const open = jest.spyOn(window, 'open').mockImplementation(() => {})
+    const { vm } = instance()
+    vm.handleShareLink('unknown')
+    vm.memberId = ''
+    vm.handleShareLink('whatsapp')
+    expect(open).not.toHaveBeenCalled()
+  })
+
+  it('reuses the clipboard helper with the original click event', () => {
+    const { vm } = instance()
+    const button = Array.from(vm.$el.querySelectorAll('button')).find(button => button.textContent.includes('Copy link'))
+    expect(button.disabled).toBe(false)
+    button.click()
+    expect(handleClipboard).toHaveBeenCalledWith(vm.referralLink, expect.objectContaining({ target: button }))
+  })
+
+  it('awaits the existing logout action before preserving the return URL', async() => {
+    let completeLogout
+    const dispatch = jest.fn().mockImplementation(action => action === 'user/logout'
+      ? new Promise(resolve => { completeLogout = resolve })
+      : Promise.resolve({ data: {}}))
+    const { vm, router } = instance(dispatch)
+    const logout = vm.handleMemberLogout()
+    expect(dispatch).toHaveBeenCalledWith('user/logout')
+    expect(router.push).not.toHaveBeenCalled()
+    completeLogout()
+    await logout
+    expect(router.push).toHaveBeenCalledWith('/login?redirect=/dashboard/index?source=test')
+  })
+
+  it('does not redirect if the existing logout action rejects', async() => {
+    const dispatch = jest.fn().mockImplementation(action => action === 'user/logout'
+      ? Promise.reject(new Error('logout failed'))
+      : Promise.resolve({ data: {}}))
+    const { vm, router } = instance(dispatch)
+    await expect(vm.handleMemberLogout()).rejects.toThrow('logout failed')
+    expect(router.push).not.toHaveBeenCalled()
+  })
+})

+ 284 - 0
src/ui-refactor/views/account/__tests__/PreviousBonusDialog.spec.js

@@ -0,0 +1,284 @@
+/* eslint-env jest */
+import Vue from 'vue'
+import ElementUI from 'element-ui'
+import fs from 'fs'
+import path from 'path'
+import { transformSync } from '@babel/core'
+import { parseComponent, compileTemplate } from 'vue/compiler-sfc'
+import { fetchBonusList, fetchBonusDetail, fetchHistoricalCumulativeBonus } from '@/api/bonus'
+
+jest.mock('@/api/bonus', () => ({ fetchBonusList: jest.fn(), fetchBonusDetail: jest.fn(), fetchHistoricalCumulativeBonus: jest.fn() }))
+Vue.use(ElementUI)
+Vue.prototype.$t = key => key === 'bonus.periodBonus' ? 'Period %s Bonus' : key
+
+// 使用当前 Vue 2.7 编译真实新旧组件,避开 vue-jest 的旧编译器版本冲突。
+const components = new Map()
+function loadComponent(filename) {
+  if (components.has(filename)) return components.get(filename)
+  const { script, template } = parseComponent(fs.readFileSync(filename, 'utf8'))
+  const code = transformSync(script.content, {
+    babelrc: false, configFile: false,
+    plugins: [require.resolve('@babel/plugin-transform-modules-commonjs')]
+  }).code
+  const module = { exports: {}}
+  const localRequire = id => {
+    if (id === '@/components/Pagination') return loadComponent(path.resolve(process.cwd(), 'src/components/Pagination/index.vue'))
+    if (id.endsWith('.vue')) return loadComponent(id.startsWith('.') ? path.resolve(path.dirname(filename), id) : path.resolve(process.cwd(), 'src', id.slice(2)))
+    return require(id)
+  }
+  new Function('require', 'module', 'exports', code)(localRequire, module, module.exports)
+  const result = module.exports.default
+  const compiled = compileTemplate({ source: template.content, filename })
+  if (compiled.errors.length) throw new Error(compiled.errors.join('\n'))
+  Object.assign(result, new Function(compiled.code + '; return { render, staticRenderFns }')())
+  components.set(filename, result)
+  return result
+}
+
+const LegacyBonus = loadComponent(path.resolve(process.cwd(), 'src/views/bonus/bonus-list.vue'))
+const BonusDialog = loadComponent(path.resolve(__dirname, '../components/bonus/PreviousBonusDialog.vue'))
+const Shortcuts = loadComponent(path.resolve(__dirname, '../components/AccountShortcuts.vue'))
+const UiDialog = loadComponent(path.resolve(process.cwd(), 'src/ui-refactor/components/UiDialog.vue'))
+const LegacyCumulativeBonus = loadComponent(path.resolve(process.cwd(), 'src/views/bonus/historical-cumulative-bonus.vue'))
+const CumulativeBonusDialog = loadComponent(path.resolve(__dirname, '../components/bonus/HistoricalCumulativeBonusDialog.vue'))
+const instances = []
+const record = { period: '172', eliteBonus: 1234, proEliteBonus: '2.00', superEliteBonus: '3.00', stockistBonus: '4.00', leaderShipBonus: '5.00', welcomeBonus: '6.00', carBonus: '7.00', userBuilderBonus: '8.00', userPerformanceBonus: '99.00' }
+const detail = { ...record, userName: 'NG00001', decLevel: 'Silver', empLevel: 'None', eliteLevel: 'N/A', totalBonus: '123.00' }
+
+function instance(component = BonusDialog, extra = {}) {
+  const vm = new Vue({
+    ...component,
+    ...extra,
+    beforeCreate() {
+      this.$t = key => key === 'bonus.periodBonus' ? 'Period %s Bonus' : key
+      this.$router = { push: jest.fn() }
+      this.$route = { query: { ref: 'preview' }}
+    }
+  }).$mount()
+  instances.push(vm)
+  return vm
+}
+async function settle() {
+  await Promise.resolve()
+  await Vue.nextTick()
+  await Vue.nextTick()
+}
+function descendants(vm, name) {
+  return vm.$children.flatMap(child => (child.$options.name === name ? [child] : []).concat(descendants(child, name)))
+}
+
+describe('Previous Bonus dialog reuses the original business logic', () => {
+  beforeEach(() => {
+    jest.clearAllMocks()
+    fetchBonusList.mockResolvedValue({ data: { tableData: [record], flowBonusSwitch: '1' }})
+    fetchBonusDetail.mockResolvedValue({ data: detail })
+  })
+  afterEach(async() => {
+    await settle()
+    instances.splice(0).forEach(vm => vm.$destroy())
+  })
+
+  it('inherits original data, created hook and both request methods without overrides', () => {
+    const vm = instance()
+    expect(BonusDialog.extends).toBe(LegacyBonus)
+    expect(BonusDialog.data).toBeUndefined()
+    expect(BonusDialog.created).toBeUndefined()
+    expect(BonusDialog.methods).toBeUndefined()
+    Object.keys(LegacyBonus.methods).forEach(name => expect(vm.$options.methods[name]).toBe(LegacyBonus.methods[name]))
+  })
+
+  it('renders the original fields and formatting without enabling the commented performance column', async() => {
+    const vm = instance(BonusDialog, { propsData: { visible: true }})
+    await settle()
+    expect(fetchBonusList).toHaveBeenCalledTimes(1)
+    expect(fetchBonusList).toHaveBeenCalledWith()
+    expect(vm.tableData).toEqual([record])
+    const cells = Array.from(vm.$el.querySelectorAll('.el-table__body-wrapper .el-table__body tr td')).map(cell => cell.textContent.trim())
+    expect(cells).toEqual(['172', '1,234', '2.00', '3.00', '4.00', '5.00', '6.00', '7.00', '8.00', 'common.view'])
+    expect(vm.$el.textContent).not.toContain('99.00')
+  })
+
+  it('keeps View hidden when the existing flowBonusSwitch is off and preserves empty-list loading behavior', async() => {
+    fetchBonusList.mockResolvedValue({ data: { tableData: [record], flowBonusSwitch: '0' }})
+    const vm = instance(BonusDialog, { propsData: { visible: true }})
+    await settle()
+    expect(vm.flowBonusSwitch).toBe(0)
+    expect(vm.$el.querySelectorAll('.el-table button').length).toBe(0)
+    fetchBonusList.mockResolvedValue({ data: { tableData: [], flowBonusSwitch: '0' }})
+    vm.getList()
+    await settle()
+    expect(vm.loading).toBe(true)
+    expect(vm.$el.textContent).toContain('common.noData')
+  })
+
+  it('opens the detail with the unchanged periodNum payload and original title and values', async() => {
+    const vm = instance(BonusDialog, { propsData: { visible: true }})
+    await settle()
+    vm.$el.querySelector('.el-table__body-wrapper button').click()
+    await settle()
+    expect(fetchBonusDetail).toHaveBeenCalledWith({ periodNum: '172' })
+    expect(vm.bonusData).toBe(detail)
+    expect(vm.bonusPayCycle).toBe('Period 172 Bonus')
+    expect(vm.dialog).toBe(true)
+    const details = descendants(vm, 'UiDialog')[1]
+    expect(details.$el.textContent).toContain('NG00001')
+    expect(details.$el.textContent).toContain('123.00')
+    expect(details.$el.querySelectorAll('.el-descriptions-item__label').length).toBe(13)
+    details.$el.querySelector('.el-dialog__footer button').click()
+    await settle()
+    expect(vm.dialog).toBe(false)
+    expect(vm.visible).toBe(true)
+  })
+
+  it('mounts the dialog on card click, leaves the route alone and releases it after closing', async() => {
+    const vm = instance(Shortcuts)
+    await settle()
+    expect(fetchBonusList).not.toHaveBeenCalled()
+    const previousBonus = Array.from(vm.$el.querySelectorAll('nav button')).find(button => button.textContent.includes('Previous Bonus'))
+    expect(previousBonus.disabled).toBe(false)
+    previousBonus.click()
+    await settle()
+    expect(vm.activeDialog).toBe('PreviousBonusDialog')
+    expect(vm.dialogVisible).toBe(true)
+    expect(vm.$router.push).not.toHaveBeenCalled()
+    expect(fetchBonusList).toHaveBeenCalledTimes(1)
+    const feature = descendants(vm, 'PreviousBonusDialog')[0]
+    const shell = descendants(feature, 'UiDialog')[0]
+    shell.$el.querySelector('.el-dialog__footer button').click()
+    await settle()
+    expect(vm.dialogVisible).toBe(false)
+    shell.$children[0].$emit('closed')
+    await settle()
+    expect(vm.activeDialog).toBeNull()
+    previousBonus.click()
+    await settle()
+    expect(fetchBonusList).toHaveBeenCalledTimes(2)
+  })
+
+  it('forwards dialog attributes/events and keeps keyboard focus inside the reusable shell', async() => {
+    const closed = jest.fn()
+    const update = jest.fn()
+    const vm = instance({
+      render(h) {
+        return h(UiDialog, { attrs: { visible: true, title: 'Reusable example', width: '720px', 'close-on-click-modal': false }, on: { closed, 'update:visible': update }})
+      }
+    })
+    const shell = vm.$children[0]
+    const dialog = shell.$children[0]
+    expect(dialog.$options.name).toBe('ElDialog')
+    expect(UiDialog.inheritAttrs).toBe(false)
+    expect(dialog.visible).toBe(true)
+    expect(dialog.title).toBe('Reusable example')
+    expect(dialog.width).toBe('720px')
+    expect(dialog.closeOnClickModal).toBe(false)
+    await settle()
+    const buttons = Array.from(shell.$el.querySelectorAll('button'))
+    buttons.forEach(button => jest.spyOn(button, 'getClientRects').mockReturnValue([{}]))
+    const first = buttons[0]
+    const last = buttons[buttons.length - 1]
+    last.focus()
+    last.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }))
+    expect(document.activeElement).toBe(first)
+    first.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true }))
+    expect(document.activeElement).toBe(last)
+    const nextDialog = document.createElement('div')
+    nextDialog.setAttribute('role', 'dialog')
+    const nextButton = document.createElement('button')
+    nextDialog.appendChild(nextButton)
+    document.body.appendChild(nextDialog)
+    shell.returnFocus = first
+    nextButton.focus()
+    shell.restoreFocus()
+    expect(document.activeElement).toBe(nextButton)
+    nextDialog.remove()
+    const hiddenTrigger = document.createElement('button')
+    hiddenTrigger.hidden = true
+    document.body.appendChild(hiddenTrigger)
+    jest.spyOn(dialog.$refs.dialog, 'getClientRects').mockReturnValue([{}])
+    shell.returnFocus = hiddenTrigger
+    shell.restoreFocus()
+    expect(document.activeElement).toBe(first)
+    hiddenTrigger.remove()
+    dialog.$emit('closed')
+    expect(closed).toHaveBeenCalledTimes(1)
+    shell.$el.querySelector('.el-dialog__footer button').click()
+    expect(update).toHaveBeenCalledWith(false)
+  })
+})
+
+describe('Historical Cumulative Bonus dialog preserves the original query and values', () => {
+  const rows = [
+    { name: 'userBuilderBonus', bonus: 1234.5 },
+    { name: 'eliteBonus', bonus: '0' },
+    { name: 'carBonus', bonus: -12.5 },
+    { name: 'extraBonus', bonus: 0 }
+  ]
+  beforeEach(() => {
+    jest.clearAllMocks()
+    fetchHistoricalCumulativeBonus.mockResolvedValue({ data: { tableData: rows, bonusSwitch: 0 }})
+  })
+  afterEach(async() => {
+    await settle()
+    instances.splice(0).forEach(vm => vm.$destroy())
+  })
+
+  it('inherits data, created and getList without changing the request or bonusSwitch behavior', async() => {
+    const vm = instance(CumulativeBonusDialog, { propsData: { visible: true }})
+    await settle()
+    expect(CumulativeBonusDialog.extends).toBe(LegacyCumulativeBonus)
+    for (const option of ['data', 'created', 'methods']) expect(CumulativeBonusDialog[option]).toBeUndefined()
+    expect(vm.$options.methods.getList).toBe(LegacyCumulativeBonus.methods.getList)
+    expect(fetchHistoricalCumulativeBonus).toHaveBeenCalledTimes(1)
+    expect(fetchHistoricalCumulativeBonus).toHaveBeenCalledWith()
+    expect(vm.tableData).toBe(rows)
+    expect(vm.loading).toBe(false)
+    expect(vm.$el.querySelectorAll('.el-card').length).toBe(rows.length)
+  })
+
+  it('renders server order, dynamic names, zeros and the original amount formatting', async() => {
+    const vm = instance(CumulativeBonusDialog, { propsData: { visible: true }})
+    await settle()
+    const cards = Array.from(vm.$el.querySelectorAll('.el-card'))
+    expect(cards.map(card => card.querySelector('h3').textContent)).toEqual(rows.map(row => 'bonus.' + row.name))
+    expect(cards.map(card => card.querySelector('p').textContent)).toEqual(['1,234.5', '0', '-12.5', '0'])
+    expect(vm.$el.textContent).not.toContain('730')
+    expect(descendants(vm, 'UiDialog')[0].$children[0].title).toBe('Historical Cumulative Bonus')
+  })
+
+  it('shows loading until the original query resolves and then shows the empty state', async() => {
+    let resolveQuery
+    fetchHistoricalCumulativeBonus.mockReturnValue(new Promise(resolve => { resolveQuery = resolve }))
+    const vm = instance(CumulativeBonusDialog, { propsData: { visible: true }})
+    await settle()
+    expect(vm.loading).toBe(true)
+    expect(vm.$el.querySelector('.el-empty')).toBeNull()
+    resolveQuery({ data: { tableData: [], bonusSwitch: 1 }})
+    await settle()
+    expect(vm.loading).toBe(false)
+    expect(vm.$el.querySelectorAll('.el-card').length).toBe(0)
+    expect(vm.$el.textContent).toContain('common.noData')
+  })
+
+  it('queries only after card click, closes without navigation and refreshes on reopening', async() => {
+    const vm = instance({ ...Shortcuts, components: { ...Shortcuts.components, HistoricalCumulativeBonusDialog: CumulativeBonusDialog }})
+    await settle()
+    expect(fetchHistoricalCumulativeBonus).not.toHaveBeenCalled()
+    const card = Array.from(vm.$el.querySelectorAll('nav button')).find(button => button.textContent.includes('Historical Cumulative Bonus'))
+    expect(card.disabled).toBe(false)
+    card.click()
+    await settle()
+    expect(vm.activeDialog).toBe('HistoricalCumulativeBonusDialog')
+    expect(vm.$router.push).not.toHaveBeenCalled()
+    expect(fetchHistoricalCumulativeBonus).toHaveBeenCalledTimes(1)
+    const shell = descendants(vm, 'UiDialog')[0]
+    shell.$el.querySelector('.el-dialog__footer button').click()
+    await settle()
+    expect(vm.dialogVisible).toBe(false)
+    shell.$children[0].$emit('closed')
+    await settle()
+    expect(vm.activeDialog).toBeNull()
+    card.click()
+    await settle()
+    expect(fetchHistoricalCumulativeBonus).toHaveBeenCalledTimes(2)
+  })
+})

+ 359 - 0
src/ui-refactor/views/account/__tests__/RechargeAndTransferDialog.spec.js

@@ -0,0 +1,359 @@
+/* eslint-env jest */
+import Vue from 'vue'
+import ElementUI from 'element-ui'
+import fs from 'fs'
+import path from 'path'
+import { transformSync } from '@babel/core'
+import { parseComponent, compileTemplate } from 'vue/compiler-sfc'
+import * as finance from '@/api/finance'
+import { fetchToken } from '@/api/file'
+import { fetchFullInfo } from '@/api/user'
+import usersInfo from '@/utils/usersInfo'
+
+jest.mock('@/api/finance', () => ({
+  fetchRechargeList: jest.fn(), fetchBankList: jest.fn(), fetchApplyRecharge: jest.fn(), editApplyRecharge: jest.fn(),
+  fetchTransferList: jest.fn(), fetchChkTransferUser: jest.fn(), fetchBalance: jest.fn(), fetchTransferCode: jest.fn(), fetchApplyTransfer: jest.fn()
+}))
+jest.mock('@/api/file', () => ({ fetchToken: jest.fn() }))
+jest.mock('@/api/bonus', () => ({}))
+jest.mock('@/api/user', () => ({ fetchFullInfo: jest.fn() }))
+jest.mock('@/utils/usersInfo', () => ({ userName: jest.fn() }))
+jest.mock('@/components/Upload/Uploader', () => ({ name: 'UnusedLegacyUploader', render: h => h('div') }))
+Vue.use(ElementUI)
+Vue.prototype.$t = key => key
+
+// 使用当前 Vue 2.7 编译真实新旧组件,避开 vue-jest 的旧编译器版本冲突。
+const components = new Map()
+function loadComponent(filename) {
+  if (components.has(filename)) return components.get(filename)
+  const { script, template } = parseComponent(fs.readFileSync(filename, 'utf8'))
+  const code = transformSync(script.content, {
+    babelrc: false, configFile: false,
+    plugins: [require.resolve('@babel/plugin-transform-modules-commonjs'), require.resolve('babel-plugin-dynamic-import-node')]
+  }).code
+  const module = { exports: {}}
+  const localRequire = id => {
+    if (id === '@/components/Pagination') return loadComponent(path.resolve(process.cwd(), 'src/components/Pagination/index.vue'))
+    if (id.endsWith('.vue')) return loadComponent(id.startsWith('.') ? path.resolve(path.dirname(filename), id) : path.resolve(process.cwd(), 'src', id.slice(2)))
+    return require(id)
+  }
+  new Function('require', 'module', 'exports', code)(localRequire, module, module.exports)
+  const result = module.exports.default
+  const compiled = compileTemplate({ source: template.content, filename })
+  if (compiled.errors.length) throw new Error(compiled.errors.join('\n'))
+  Object.assign(result, new Function(compiled.code + '; return { render, staticRenderFns }')())
+  components.set(filename, result)
+  return result
+}
+
+const LegacyRecharge = loadComponent(path.resolve(process.cwd(), 'src/views/finance/recharge-list.vue'))
+const LegacyTransfer = loadComponent(path.resolve(process.cwd(), 'src/views/finance/transfer-list.vue'))
+const Recharge = loadComponent(path.resolve(__dirname, '../components/finance/RechargePanel.vue'))
+const Transfer = loadComponent(path.resolve(__dirname, '../components/finance/TransferPanel.vue'))
+const Dialog = loadComponent(path.resolve(__dirname, '../components/finance/RechargeAndTransferDialog.vue'))
+const Shortcuts = loadComponent(path.resolve(__dirname, '../components/AccountShortcuts.vue'))
+const instances = []
+const rechargeRows = [
+  { ID: 'preview-pending', CREATED_AT: 1791972000, AMOUNT: 1234, AUDIT_STATUS: '0', STATUS_NAME: 'Pending', TYPE: 'Bank', OPEN_BANK_NAME: 'Preview Bank', OPEN_BANK: 'PREVIEW', BANK_NO: 'UI-REFERENCE', PAY_DATE: '2026-10-14', RECHARGE_REMARK: 'UI preview', BANK_PROVE: 'preview.png' },
+  { ID: 'preview-approved', CREATED_AT: 1791972000, AMOUNT: 500, AUDIT_STATUS: '2', STATUS_NAME: 'Approved', TYPE: 'Cash', BANK_PROVE: 'preview.png' }
+]
+const transferRows = [{ OUT_WALLET_NAME: 'Bonus', LAST_IN_USER_NAME: 'NG00002', LAST_IN_REAL_NAME: 'Preview Member', IN_WALLET_NAME: 'Ecoin', ORI_AMOUNT: 6000, FEE: 300, AMOUNT: 5700, CREATED_AT: 1791972000, REMARK: 'UI preview' }]
+function instance(component) {
+  const vm = new Vue({
+    ...component,
+    propsData: component === Dialog ? { visible: true } : {},
+    beforeCreate() {
+      this.$message = jest.fn()
+      this.$prompt = jest.fn().mockResolvedValue({ value: ' test-only-password ' })
+      this.$router = { push: jest.fn() }
+      this.$route = { query: { ref: 'preview' }}
+    }
+  }).$mount()
+  instances.push(vm)
+  return vm
+}
+async function settle() {
+  await Promise.resolve()
+  await Vue.nextTick()
+  await Promise.resolve()
+  await Vue.nextTick()
+  jest.advanceTimersByTime(500)
+  await Vue.nextTick()
+}
+function descendants(vm, name) {
+  return vm.$children.flatMap(child => (child.$options.name === name ? [child] : []).concat(descendants(child, name)))
+}
+function button(vm, text) {
+  return Array.from(vm.$el.querySelectorAll('button')).find(el => el.textContent.trim() === text)
+}
+function formDialog(vm) {
+  return descendants(vm, 'UiDialog')[0]
+}
+
+describe('Recharge and Transfer preserve existing finance behavior', () => {
+  beforeEach(() => {
+    jest.useFakeTimers()
+    jest.clearAllMocks()
+    usersInfo.userName.mockReturnValue('NGSELF')
+    finance.fetchRechargeList.mockResolvedValue({ data: { list: rechargeRows, totalCount: 25 }})
+    finance.fetchBankList.mockResolvedValue({ data: { allOpenBank: [{ BANK_NAME: 'Preview Bank', BANK_CODE: 'PREVIEW' }], type: ['Cash', 'Bank'] }})
+    finance.fetchApplyRecharge.mockResolvedValue({ data: 'Preview success' })
+    finance.editApplyRecharge.mockResolvedValue({ data: 'Preview success' })
+    finance.fetchTransferList.mockResolvedValue({ data: { list: transferRows, totalCount: 25, showFee: true }})
+    finance.fetchChkTransferUser.mockResolvedValue({ data: { userInfo: { TRANSFER_PROP: 100 }, transferConfig: [{ typeId: 1, outName: 'Bonus', inName: 'Ecoin', fee: 5 }], minAmount: 1 }})
+    finance.fetchBalance.mockResolvedValue({ data: { bonus: 10000, cash: 73 }})
+    finance.fetchTransferCode.mockResolvedValue({ data: { transferCode: 'preview-code' }})
+    finance.fetchApplyTransfer.mockResolvedValue({ data: 'Preview success' })
+    fetchFullInfo.mockResolvedValue({ data: { REAL_NAME: 'Preview Receiver' }})
+    fetchToken.mockResolvedValue({ data: 'preview-upload-token' })
+  })
+  afterEach(async() => {
+    await settle()
+    instances.splice(0).forEach(vm => vm.$destroy())
+    jest.clearAllTimers()
+    jest.useRealTimers()
+  })
+
+  it('inherits every original lifecycle, data, computed value, watcher and method without replacing business logic', () => {
+    for (const [current, legacy] of [[Recharge, LegacyRecharge], [Transfer, LegacyTransfer]]) {
+      expect(current.extends).toBe(legacy)
+      for (const option of ['data', 'created', 'mounted', 'methods', 'watch', 'computed']) expect(current[option]).toBeUndefined()
+      const vm = instance(current)
+      Object.keys(legacy.methods).forEach(name => expect(vm.$options.methods[name]).toBe(legacy.methods[name]))
+    }
+  })
+
+  it('loads recharge first and transfer only on first tab visit, retaining state between tab switches', async() => {
+    const vm = instance(Dialog)
+    await settle()
+    expect(finance.fetchRechargeList).toHaveBeenCalledTimes(1)
+    expect(finance.fetchBankList).toHaveBeenCalledTimes(1)
+    expect(finance.fetchTransferList).not.toHaveBeenCalled()
+    vm.$el.querySelector('#tab-transfer').click()
+    await settle()
+    expect(finance.fetchTransferList).toHaveBeenCalledTimes(1)
+    expect(finance.fetchChkTransferUser).toHaveBeenCalledTimes(1)
+    expect(finance.fetchBalance).toHaveBeenCalledTimes(1)
+    const transfer = descendants(vm, 'TransferPanel')[0]
+    transfer.listQuery.userName = 'keep-filter'
+    vm.$el.querySelector('#tab-recharge').click()
+    await settle()
+    vm.$el.querySelector('#tab-transfer').click()
+    await settle()
+    expect(transfer.listQuery.userName).toBe('keep-filter')
+    expect(finance.fetchTransferList).toHaveBeenCalledTimes(1)
+  })
+
+  it('opens the finance card as a dialog without navigation or eager requests', async() => {
+    const vm = instance(Shortcuts)
+    await settle()
+    expect(finance.fetchRechargeList).not.toHaveBeenCalled()
+    const card = Array.from(vm.$el.querySelectorAll('nav button')).find(el => el.textContent.includes('Recharge and Transfer'))
+    card.click()
+    await settle()
+    await settle()
+    expect(vm.activeDialog).toBe('RechargeAndTransferDialog')
+    expect(vm.dialogVisible).toBe(true)
+    expect(vm.$router.push).not.toHaveBeenCalled()
+    expect(finance.fetchRechargeList).toHaveBeenCalledTimes(1)
+  })
+
+  it('keeps recharge columns, status filters, voucher action conditions and pagination query', async() => {
+    const vm = instance(Recharge)
+    await settle()
+    expect(finance.fetchRechargeList).toHaveBeenCalledWith({ page: 1, pageSize: 10 })
+    expect(vm.$el.textContent).toContain('1,234')
+    expect(vm.$el.textContent).toContain('Pending')
+    const rows = vm.$el.querySelectorAll('.el-table__body tr')
+    expect(rows[0].textContent).toContain('finance.uploadVoucher')
+    expect(rows[0].textContent).toContain('finance.uploadEdit')
+    expect(rows[1].textContent).toContain('finance.viewVoucher')
+    expect(rows[1].textContent).not.toContain('finance.uploadEdit')
+    expect(vm.$options.filters.statusFilter).toBe(LegacyRecharge.filters.statusFilter)
+    vm.listQuery.page = 2
+    descendants(vm, 'Pagination')[0].$emit('pagination')
+    await settle()
+    expect(finance.fetchRechargeList).toHaveBeenLastCalledWith({ page: 2, pageSize: 10 })
+  })
+
+  it('keeps recharge rules, numeric step, Cash bank-field clearing and exact create payload', async() => {
+    const vm = instance(Recharge)
+    await settle()
+    button(vm, 'finance.recharge').click()
+    await settle()
+    expect(vm.$refs.form.rules).toBe(vm.rules)
+    const numeric = descendants(vm, 'ElInputNumber')[0]
+    expect([numeric.min, numeric.max, numeric.step]).toEqual([1, 10000000000, 10000])
+    vm.form.rechargeType = 'Bank'
+    await settle()
+    expect(vm.$refs.form.fields.some(field => field.prop === 'bankNo')).toBe(true)
+    vm.form.bankNo = 'clear-me'
+    vm.form.openBank = 'PREVIEW'
+    vm.form.rechargeType = 'Cash'
+    await settle()
+    expect(vm.form.bankNo).toBe('')
+    expect(vm.form.openBank).toBe('')
+    expect(vm.$refs.form.fields.some(field => field.prop === 'bankNo')).toBe(false)
+    Object.assign(vm.form, { applyAmount: 10000, payDate: '2026-10-14', rechargeRemark: 'UI test' })
+    await settle()
+    button(formDialog(vm), 'table.confirm').click()
+    await settle()
+    expect(finance.fetchApplyRecharge).toHaveBeenCalledWith({ applyAmount: 10000, bankNo: '', openBank: '', bankAddress: '', rechargeType: 'Cash', payDate: '2026-10-14', rechargeRemark: 'UI test' })
+    expect(vm.dialog).toBe(false)
+    expect(vm.form.applyAmount).toBe(1)
+  })
+
+  it('blocks invalid recharge and uses the edit endpoint with the original row ID', async() => {
+    const vm = instance(Recharge)
+    await settle()
+    button(vm, 'finance.recharge').click()
+    await settle()
+    button(formDialog(vm), 'table.confirm').click()
+    await settle()
+    expect(finance.fetchApplyRecharge).not.toHaveBeenCalled()
+    button(formDialog(vm), 'table.cancel').click()
+    await settle()
+    button(vm, 'finance.uploadEdit').click()
+    await settle()
+    expect(vm.isEdit).toBe(true)
+    expect(vm.form.ID).toBe('preview-pending')
+    button(formDialog(vm), 'table.confirm').click()
+    await settle()
+    expect(finance.editApplyRecharge).toHaveBeenCalledWith({ applyAmount: 1234, bankNo: 'UI-REFERENCE', openBank: 'PREVIEW', bankAddress: '', id: 'preview-pending', rechargeType: 'Bank', payDate: '2026-10-14', rechargeRemark: 'UI preview' })
+    expect(finance.fetchApplyRecharge).not.toHaveBeenCalled()
+  })
+
+  it('retains voucher preview, reupload URL and the original token callbacks', async() => {
+    const vm = instance(Recharge)
+    await settle()
+    button(vm, 'finance.viewVoucher').click()
+    await settle()
+    expect(vm.viewVoucher).toBe(true)
+    expect(vm.auditId).toBe('preview-approved')
+    expect(vm.voucher).toBe(vm.tool.getArImage('preview.png', 'files/'))
+    vm.uploadAgain(vm.auditId)
+    await settle()
+    expect(vm.uploadUrl).toBe(process.env.VUE_APP_BASE_API + '/v1/finance/prove-add?id=preview-approved')
+    const upload = descendants(vm, 'ElUpload')[0]
+    expect(upload.action).toBe(vm.uploadUrl)
+    expect(upload.beforeUpload).toBe(vm.uploaderHandleBefore)
+    expect(upload.onSuccess).toBe(vm.handleSuccess)
+    expect(upload.onError).toBe(vm.handleError)
+    await upload.beforeUpload()
+    expect(fetchToken).toHaveBeenCalledTimes(1)
+    expect(vm.uploaderData.uploadToken).toBe('preview-upload-token')
+    expect(vm.uploaderHeaders.Authorization).toBe(process.env.VUE_APP_ACCESS_TOKEN_PREFIX + 'preview-upload-token')
+  })
+
+  it('preserves transfer values, fee visibility, filters and Reset defaults', async() => {
+    const vm = instance(Transfer)
+    await settle()
+    expect(finance.fetchTransferList).toHaveBeenCalledWith({ page: 1, pageSize: 20, inWallet: '', outWallet: '', realName: '', userName: '', dateRange: [] })
+    const cells = Array.from(vm.$el.querySelectorAll('.el-table__body tr td')).map(el => el.textContent.trim())
+    expect(cells.slice(0, 7)).toEqual(['Bonus', 'NG00002', 'Preview Member', 'Ecoin', '6,000', '300', '5,700'])
+    Object.assign(vm.listQuery, { page: 2, pageSize: 10, inWallet: 'cash', outWallet: 'bonus', realName: 'Receiver', userName: 'NG00002', dateRange: ['2026-10-01', '2026-10-14'] })
+    button(vm, 'common.select').click()
+    await settle()
+    expect(finance.fetchTransferList).toHaveBeenLastCalledWith(vm.listQuery)
+    expect(vm.listQuery.page).toBe(2)
+    finance.fetchTransferList.mockResolvedValue({ data: { list: transferRows, totalCount: 25, showFee: false }})
+    button(vm, 'common.reset').click()
+    await settle()
+    expect(vm.listQuery).toEqual({ page: 1, pageSize: 20, inWallet: '', outWallet: '', realName: '', userName: '', dateRange: [] })
+    expect(vm.$el.textContent).not.toContain('finance.serviceCharge')
+    vm.listQuery.page = 2
+    descendants(vm, 'Pagination')[0].$emit('pagination')
+    await settle()
+    expect(finance.fetchTransferList).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, pageSize: 20 }))
+  })
+
+  it('keeps transfer code, balances, recipient lookup and self-transfer rejection', async() => {
+    const vm = instance(Transfer)
+    await settle()
+    button(vm, 'finance.applyTransfer').click()
+    await settle()
+    expect(finance.fetchTransferCode).toHaveBeenCalledTimes(1)
+    expect(vm.form.transferCode).toBe('preview-code')
+    expect([vm.balance, vm.cash, vm.maxAmount, vm.fee]).toEqual([10000, 73, 10000, 5])
+    expect(vm.$refs.form.model).toBe(vm.form)
+    expect(descendants(vm, 'ElSelect').some(select => select.value === 1)).toBe(true)
+    vm.form.toUserName = 'NGSELF'
+    vm.fetchMember()
+    expect(vm.form.toUserName).toBe('')
+    expect(fetchFullInfo).not.toHaveBeenCalled()
+    vm.form.toUserName = ' NG00002 '
+    vm.fetchMember()
+    await settle()
+    expect(fetchFullInfo).toHaveBeenCalledWith({ userName: 'NG00002' })
+    expect(vm.form.toRealName).toBe('Preview Receiver')
+  })
+
+  it('keeps the minimum check and payment-password prompt before the exact transfer payload', async() => {
+    const vm = instance(Transfer)
+    await settle()
+    button(vm, 'finance.applyTransfer').click()
+    await settle()
+    vm.form.amount = 100
+    button(formDialog(vm), 'table.confirm').click()
+    await settle()
+    expect(vm.$prompt).not.toHaveBeenCalled()
+    expect(finance.fetchApplyTransfer).not.toHaveBeenCalled()
+    let submitted
+    finance.fetchApplyTransfer.mockImplementation(payload => { submitted = { ...payload }; return Promise.resolve({ data: 'Preview success' }) })
+    Object.assign(vm.form, { amount: 6000, toUserName: 'NG00002', remark: 'UI transfer' })
+    button(formDialog(vm), 'table.confirm').click()
+    await settle()
+    expect(vm.$prompt).toHaveBeenCalledWith('finance.enterPasswordTips', 'common.hint', expect.objectContaining({ inputType: 'password', inputPattern: /\S+/ }))
+    expect(submitted).toEqual({ type: 1, toUserName: 'NG00002', toRealName: null, amount: 6000, payPassword: 'test-only-password', remark: 'UI transfer', transferCode: 'preview-code' })
+    expect(vm.realAmount).toBe('5700.00')
+    expect(vm.dialog).toBe(false)
+    expect(finance.fetchTransferList).toHaveBeenCalledTimes(3)
+  })
+
+  it('keeps the moved footer buttons blocked by the original loading state', async() => {
+    for (const component of [Recharge, Transfer]) {
+      const vm = instance(component)
+      await settle()
+      vm.dialog = true
+      await settle()
+      vm.loading = true
+      await Vue.nextTick()
+      const cancel = button(formDialog(vm), 'table.cancel')
+      const confirm = button(formDialog(vm), 'table.confirm')
+      expect(cancel.disabled).toBe(true)
+      expect(confirm.disabled).toBe(true)
+      confirm.click()
+      expect(finance.fetchApplyRecharge).not.toHaveBeenCalled()
+      expect(finance.fetchApplyTransfer).not.toHaveBeenCalled()
+    }
+  })
+
+  it('preserves failure handling and cancel/reset for both application forms', async() => {
+    const recharge = instance(Recharge)
+    const transfer = instance(Transfer)
+    await settle()
+    recharge.dialog = true
+    transfer.handleApplyTransfer()
+    await settle()
+    finance.fetchApplyRecharge.mockRejectedValue('Preview recharge error')
+    Object.assign(recharge.form, { applyAmount: 10000, payDate: '2026-10-14', rechargeRemark: 'UI test' })
+    button(formDialog(recharge), 'table.confirm').click()
+    finance.fetchApplyTransfer.mockRejectedValue('Preview transfer error')
+    transfer.form.amount = 6000
+    button(formDialog(transfer), 'table.confirm').click()
+    await settle()
+    expect(recharge.loading).toBe(false)
+    expect(transfer.loading).toBe(false)
+    expect(recharge.dialog).toBe(true)
+    expect(transfer.dialog).toBe(true)
+    expect(recharge.$message).toHaveBeenCalledWith(expect.objectContaining({ message: 'Preview recharge error', type: 'error' }))
+    expect(transfer.$message).toHaveBeenCalledWith(expect.objectContaining({ message: 'Preview transfer error', type: 'error' }))
+    button(formDialog(recharge), 'table.cancel').click()
+    button(formDialog(transfer), 'table.cancel').click()
+    await settle()
+    expect(recharge.dialog).toBe(false)
+    expect(transfer.dialog).toBe(false)
+    expect(recharge.form.rechargeRemark).toBe('')
+    expect(transfer.form.amount).toBe(0)
+  })
+})

+ 247 - 0
src/ui-refactor/views/account/__tests__/SponsorNetwork.spec.js

@@ -0,0 +1,247 @@
+/* eslint-env jest */
+import Vue from 'vue'
+import ElementUI from 'element-ui'
+import fs from 'fs'
+import path from 'path'
+import { transformSync } from '@babel/core'
+import { parseComponent, compileTemplate } from 'vue/compiler-sfc'
+import * as atlasApi from '@/api/atlas'
+import { fetchNetworkStats } from '@/api/user'
+import request from '@/utils/request'
+
+jest.mock('@/api/atlas', () => ({ fetchMainUserInfo: jest.fn(), fetchSponsorNetwork: jest.fn(), fetchSponsorNetworkList: jest.fn() }))
+jest.mock('@/api/user', () => ({ fetchNetworkStats: jest.fn() }))
+jest.mock('@/utils/request', () => jest.fn())
+jest.mock('@/components/TreeChart/index.vue', () => ({}))
+jest.mock('@/utils/usersInfo', () => ({}))
+Vue.use(ElementUI)
+Vue.prototype.$t = key => key
+Vue.prototype.$message = jest.fn()
+
+// 编译实际模板与原脚本,沿用项目已有的 Vue 2.7 测试加载方式。
+const components = new Map()
+function loadComponent(filename) {
+  if (components.has(filename)) return components.get(filename)
+  const { script, template } = parseComponent(fs.readFileSync(filename, 'utf8'))
+  const code = transformSync(script.content, {
+    babelrc: false, configFile: false,
+    plugins: [require.resolve('@babel/plugin-transform-modules-commonjs')]
+  }).code
+  const module = { exports: {}}
+  const localRequire = id => {
+    if (id === '@/components/TreeChart/index.vue') return require(id)
+    if (id === '@/components/Pagination') return loadComponent(path.resolve(process.cwd(), 'src/components/Pagination/index.vue'))
+    if (id.endsWith('.vue')) return loadComponent(id.startsWith('.') ? path.resolve(path.dirname(filename), id) : path.resolve(process.cwd(), 'src', id.slice(2)))
+    return require(id)
+  }
+  new Function('require', 'module', 'exports', code)(localRequire, module, module.exports)
+  const result = module.exports.default
+  const compiled = compileTemplate({ source: template.content, filename })
+  if (compiled.errors.length) throw new Error(compiled.errors.join('\n'))
+  Object.assign(result, new Function(compiled.code + '; return { render, staticRenderFns }')())
+  components.set(filename, result)
+  return result
+}
+
+const SponsorNetwork = loadComponent(path.resolve(__dirname, '../components/network/SponsorNetwork.vue'))
+const legacy = file => loadComponent(path.resolve(process.cwd(), 'src/views/atlas', file))
+const root = { USER_ID: 'root', USER_NAME: 'NG00001', REAL_NAME: '', DEC_LV_NAME: 'Silver', EMP_LV_NAME: 'Gold', PERIOD_AT: 173, TOP_NETWORK_DEEP: 3, listPeriodNum: [173], children: null, leaf: false }
+const child = { ...root, USER_ID: 'child', USER_NAME: 'NG00002', REAL_NAME: 'Preview Member', PERIOD_AT: 164 }
+const leaf = { ...child, USER_ID: 'leaf', USER_NAME: 'NG00003', REAL_NAME: 'Preview Leaf', leaf: true }
+const instances = []
+function instance() {
+  const vm = new Vue(SponsorNetwork).$mount()
+  instances.push(vm)
+  return vm
+}
+async function settle() {
+  for (let i = 0; i < 4; i++) {
+    await Promise.resolve()
+    await Vue.nextTick()
+  }
+  jest.advanceTimersByTime(1500)
+  await Vue.nextTick()
+}
+function view(vm) {
+  return vm.$children[0].$children.find(child => child.$options.name.startsWith('SponsorNetworkTree') && !child._inactive)
+}
+function toggle(vm, id) {
+  return vm.$refs.tree.getNode(id)
+}
+function setInput(vm, id, value) {
+  const input = vm.$el.querySelector(id)
+  input.value = value
+  input.dispatchEvent(new Event('input', { bubbles: true }))
+}
+
+describe('Sponsor Network UI reuses both original network scripts', () => {
+  beforeEach(() => {
+    jest.useFakeTimers()
+    jest.clearAllMocks()
+    fetchNetworkStats.mockReset()
+    fetchNetworkStats.mockResolvedValue({ data: { DOWNLINE_COUNT: 36, DIRECT_DOWNLINE_COUNT: 6, MAX_DOWNLINE_DEEP: 8, DOWNLINE_PCV: 11169.45 }})
+    atlasApi.fetchMainUserInfo.mockImplementation(() => Promise.resolve({ data: [{ ...root }] }))
+    atlasApi.fetchSponsorNetwork.mockImplementation(() => Promise.resolve({ data: { allData: [{ ...child }], periodNum: 173 }}))
+    atlasApi.fetchSponsorNetworkList.mockResolvedValue({ data: { listTopDeep: 3, list: [] }})
+  })
+  afterEach(async() => {
+    await settle()
+    instances.splice(0).forEach(vm => vm.$destroy())
+    jest.clearAllTimers()
+    jest.useRealTimers()
+  })
+
+  it('requests current account statistics without invented mode parameters', () => {
+    jest.requireActual('@/api/user').fetchNetworkStats()
+    expect(request).toHaveBeenCalledWith({ url: '/v1/user/network-stats', method: 'get' })
+  })
+
+  it('loads and maps the four backend statistics, keeping decimals and zero values', async() => {
+    const vm = instance()
+    await settle()
+    const values = () => Array.from(vm.$el.querySelectorAll('.ng-account-network-value'), el => el.textContent)
+    expect(fetchNetworkStats).toHaveBeenCalledTimes(1)
+    expect(fetchNetworkStats).toHaveBeenCalledWith()
+    expect(values()).toEqual(['36', '6', '8', '11,169.45'])
+    expect(vm.$el.querySelector('[title="Sample data"]')).toBeNull()
+    fetchNetworkStats.mockResolvedValueOnce({ data: { DOWNLINE_COUNT: 0, DIRECT_DOWNLINE_COUNT: 0, DOWNLINE_PCV: 0 }})
+    vm.$el.querySelectorAll('input[type=radio]')[1].click()
+    await settle()
+    expect(values()).toEqual(['0', '0', '—', '0'])
+    expect(vm.statsLoading).toBe(false)
+  })
+
+  it('clears previous statistics when a switched request fails', async() => {
+    const vm = instance()
+    await settle()
+    fetchNetworkStats.mockRejectedValueOnce(new Error('Network error'))
+    vm.$el.querySelectorAll('input[type=radio]')[1].click()
+    await settle()
+    expect(vm.stats.map(stat => stat.value)).toEqual(['—', '—', '—', '—'])
+    expect(vm.statsLoading).toBe(false)
+  })
+
+  it('ignores older responses and failures after rapid switches', async() => {
+    const pending = []
+    fetchNetworkStats.mockImplementation(() => new Promise((resolve, reject) => pending.push({ resolve, reject })))
+    const vm = instance()
+    vm.$el.querySelectorAll('input[type=radio]')[1].click()
+    await settle()
+    vm.$el.querySelectorAll('input[type=radio]')[0].click()
+    await settle()
+    expect(pending).toHaveLength(3)
+    expect(vm.statsLoading).toBe(true)
+    pending[2].resolve({ data: { DOWNLINE_COUNT: 42 }})
+    await settle()
+    pending[0].resolve({ data: { DOWNLINE_COUNT: 9 }})
+    pending[1].reject(new Error('Older request failed'))
+    await settle()
+    expect(vm.stats[0].value).toBe('42')
+    expect(vm.statsLoading).toBe(false)
+  })
+
+  it('inherits each original hook and all business methods without overrides', async() => {
+    const vm = instance()
+    await settle()
+    for (const [name, file] of [['NetworkTree', 'sponsor-network.vue'], ['NetworkTreeEx', 'sponsor-network-ex.vue']]) {
+      const component = SponsorNetwork.components[name]
+      expect(component.extends).toBe(legacy(file))
+      expect(component.created).toBeUndefined()
+      Object.keys(legacy(file).methods).forEach(method => expect(component.methods[method]).toBeUndefined())
+    }
+    const normal = view(vm)
+    Object.keys(legacy('sponsor-network.vue').methods).forEach(method => expect(normal.$options.methods[method]).toBe(legacy('sponsor-network.vue').methods[method]))
+    expect(atlasApi.fetchMainUserInfo).toHaveBeenCalledWith({ userName: '', periodNum: null })
+    expect(atlasApi.fetchSponsorNetworkList).toHaveBeenCalledWith(expect.objectContaining({ userName: null, deep: 5, periodNum: null, page: undefined, pageSize: undefined }))
+    expect(atlasApi.fetchSponsorNetwork).not.toHaveBeenCalled()
+    expect(normal.topDeep).toBe(3)
+    expect(vm.$el.textContent).toContain('Period: 173')
+    expect(vm.$el.textContent).not.toContain('173 PCV')
+  })
+
+  it('keeps the original untrimmed username, unrestricted depth and period query on form submission', async() => {
+    const vm = instance()
+    await settle()
+    const normal = view(vm)
+    setInput(vm, '#ng-network-member-false', ' NG00002 ')
+    setInput(vm, '#ng-network-depth-false', '12')
+    normal.periodNum = 173
+    await Vue.nextTick()
+    vm.$el.querySelector('form').dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
+    await settle()
+    expect(atlasApi.fetchMainUserInfo).toHaveBeenLastCalledWith({ userName: ' NG00002 ', periodNum: 173 })
+    normal.$el.querySelector('.ng-network-toggle').click()
+    await settle()
+    expect(atlasApi.fetchSponsorNetwork).toHaveBeenLastCalledWith({ id: root.USER_ID, deep: '12', periodNum: 173 })
+  })
+
+  it('loads normal children once and collapses/reopens cached nodes without a second request', async() => {
+    const vm = instance()
+    await settle()
+    const normal = view(vm)
+    normal.$el.querySelector('.ng-network-toggle').click()
+    await settle()
+    expect(atlasApi.fetchSponsorNetwork).toHaveBeenCalledWith({ id: 'root', deep: 5, periodNum: null })
+    expect(normal.treeData[0].isExpanded).toBe(true)
+    expect(normal.treeData[0].displayNone).toBe('display-none')
+    expect(normal.listPeriodNum).toBe(173)
+    expect(normal.$el.textContent).toContain('Preview Member')
+    normal.$el.querySelector('.ng-network-toggle').click()
+    await Vue.nextTick()
+    expect(toggle(normal, 'root').expanded).toBe(false)
+    normal.$el.querySelector('.ng-network-toggle').click()
+    await settle()
+    expect(toggle(normal, 'root').expanded).toBe(true)
+    expect(atlasApi.fetchSponsorNetwork).toHaveBeenCalledTimes(1)
+  })
+
+  it('keeps leaf nodes non-fetching and displays member names as text', async() => {
+    atlasApi.fetchMainUserInfo.mockResolvedValue({ data: [{ ...leaf, REAL_NAME: '<img src=x onerror=alert(1)>' }] })
+    const vm = instance()
+    await settle()
+    const normal = view(vm)
+    expect(normal.$el.querySelector('.ng-network-toggle')).toBeNull()
+    normal.$el.querySelector('.ng-network-member').click()
+    await settle()
+    expect(atlasApi.fetchSponsorNetwork).not.toHaveBeenCalled()
+    expect(normal.$el.querySelector('img')).toBeNull()
+    expect(normal.$el.textContent).toContain('<img src=x onerror=alert(1)>')
+  })
+
+  it('uses Ex node insertion and reruns the original initialization on every mode switch', async() => {
+    const vm = instance()
+    await settle()
+    const normal = view(vm)
+    normal.mainUserName = 'NG00100'
+    vm.$el.querySelectorAll('input[type=radio]')[1].click()
+    await settle()
+    const extended = view(vm)
+    expect(extended.extended).toBe(true)
+    expect(extended.$options.methods.getNodeData).toBe(legacy('sponsor-network-ex.vue').methods.getNodeData)
+    expect(extended.treeChartShow).toBe(true)
+    expect(extended.treeData.children[0].name).toBe(child.USER_NAME)
+    expect(atlasApi.fetchMainUserInfo).toHaveBeenCalledTimes(2)
+    expect(atlasApi.fetchSponsorNetworkList).toHaveBeenCalledTimes(1)
+    expect(atlasApi.fetchSponsorNetwork).toHaveBeenCalledTimes(1)
+    atlasApi.fetchSponsorNetwork.mockResolvedValue({ data: { allData: [{ ...leaf }], periodNum: 174 }})
+    const childNode = toggle(extended, 'child')
+    extended.toggleNode(childNode.data, childNode)
+    await settle()
+    expect(atlasApi.fetchSponsorNetwork).toHaveBeenLastCalledWith({ id: 'child', deep: 5, periodNum: null })
+    expect(extended.treeData.children[0].children[0].USER_ID).toBe('leaf')
+    expect(extended.listPeriodNum).toBe(174)
+    expect(extended.$el.textContent).toContain('Preview Leaf')
+    vm.$el.querySelectorAll('input[type=radio]')[0].click()
+    await settle()
+    expect(view(vm)).not.toBe(normal)
+    expect(view(vm).mainUserName).toBe('')
+    expect(atlasApi.fetchMainUserInfo).toHaveBeenCalledTimes(3)
+    expect(atlasApi.fetchSponsorNetworkList).toHaveBeenCalledTimes(2)
+    vm.$el.querySelectorAll('input[type=radio]')[1].click()
+    await settle()
+    expect(view(vm)).not.toBe(extended)
+    expect(atlasApi.fetchMainUserInfo).toHaveBeenCalledTimes(4)
+    expect(atlasApi.fetchSponsorNetwork).toHaveBeenCalledTimes(3)
+    expect(fetchNetworkStats).toHaveBeenCalledTimes(4)
+  })
+})

+ 144 - 0
src/ui-refactor/views/account/components/AccountActivity.vue

@@ -0,0 +1,144 @@
+<template>
+  <div class="ng-account-activity">
+    <section aria-labelledby="ng-account-actions-title">
+      <h2 id="ng-account-actions-title" class="ng-account-title ng-account-activity-title">
+        Quick actions
+      </h2>
+      <el-card shadow="never" class="ng-account-card">
+        <div class="ng-account-quick-actions">
+          <el-button
+            v-for="action in actions"
+            :key="action.label"
+            native-type="button"
+            :disabled="!action.to && !action.href"
+            class="ng-account-quick-action"
+            @click="handleAction(action)"
+          >
+            <span class="ng-account-quick-copy">
+              <strong>{{ action.label }}</strong>
+              <span class="ng-account-note">{{ action.note }}</span>
+            </span>
+            <i class="el-icon-right" aria-hidden="true" />
+          </el-button>
+        </div>
+      </el-card>
+    </section>
+    <!--    <section aria-labelledby="ng-account-activity-title">-->
+    <!--      <h2 id="ng-account-activity-title" class="ng-account-title ng-account-activity-title">Referrals · BV credited to you</h2>-->
+    <!--      <el-card shadow="never" class="ng-account-card">-->
+    <!--        <dl class="ng-account-referral-stats">-->
+    <!--          <div>-->
+    <!--            <dt class="ng-account-note">Referred BV</dt>-->
+    <!--            <dd class="ng-account-referral-value">— <small>BV</small></dd>-->
+    <!--          </div>-->
+    <!--          <div>-->
+    <!--            <dt class="ng-account-note">Referred orders</dt>-->
+    <!--            <dd class="ng-account-referral-value">—</dd>-->
+    <!--          </div>-->
+    <!--        </dl>-->
+    <!--        <p class="ng-account-note ng-account-referral-empty">No referral records to display.</p>-->
+    <!--      </el-card>-->
+    <!--    </section>-->
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'AccountActivity',
+  data() {
+    return {
+      actions: [
+        {
+          label: 'Browse products',
+          note: 'View our health & beauty collection',
+          to: '/dashboard/repeat-purchase'
+        },
+        {
+          label: 'Talk to support',
+          note: 'Avg reply 2 min · WhatsApp',
+          href: 'https://wa.me/2348000000000'
+        }
+      ]
+    }
+  },
+  methods: {
+    handleAction(action) {
+      if (action.href) {
+        window.open(action.href, '_blank', 'noopener')
+      } else if (action.to) {
+        this.$router.push({ path: action.to, query: this.$route.query })
+      }
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.ng-account-activity {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 24px;
+}
+
+.ng-account-activity-title {
+  margin-bottom: 16px;
+  font-size: 18px;
+}
+
+.ng-account-quick-actions {
+  display: grid;
+  gap: 12px;
+}
+
+.ng-account-quick-action {
+  width: 100%;
+  margin: 0;
+  padding: 16px;
+  white-space: normal;
+  text-align: left;
+}
+
+.ng-account-quick-action ::v-deep > span {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+}
+
+.ng-account-quick-copy {
+  display: flex;
+  flex-direction: column;
+  line-height: 1.5;
+}
+
+.ng-account-referral-stats {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 16px;
+  margin: 0;
+}
+
+.ng-account-referral-value {
+  margin: 12px 0 0;
+  color: #202259;
+  font-size: 28px;
+  font-weight: 700;
+}
+
+.ng-account-referral-value small {
+  font-size: 14px;
+}
+
+.ng-account-referral-empty {
+  margin-top: 24px;
+  padding-top: 20px;
+  border-top: 1px solid #e6ebf5;
+}
+
+@media (max-width: 767px) {
+  .ng-account-activity {
+    grid-template-columns: minmax(0, 1fr);
+    gap: 0;
+  }
+}
+</style>

+ 346 - 0
src/ui-refactor/views/account/components/AccountBalances.vue

@@ -0,0 +1,346 @@
+<template>
+  <div v-loading="loading" class="ng-account-balances">
+    <el-card v-for="item in balances" :key="item.walletType" shadow="never" class="ng-account-card">
+      <h2 class="ng-account-balance-label">
+        {{ item.walletType === 'cash' ? 'Member Ecoin' : 'Member Bonus' }}
+      </h2>
+      <p class="ng-account-balance-value">
+        {{ item.amount | toThousandFilter }}
+        <span class="ng-account-balance-unit">{{ item.walletType === 'cash' ? 'EC' : 'BV' }}</span>
+      </p>
+      <p class="ng-account-note">
+        {{
+          item.walletType === 'cash' ? 'Your current ecoin balance' : 'Your current bonus balance'
+        }}
+      </p>
+      <el-button
+        v-show="dealSwitch"
+        type="primary"
+        native-type="button"
+        class="ng-account-history"
+        @click="handleBonusFlow(item.walletType, item.walletName)"
+      >View History</el-button>
+    </el-card>
+
+    <el-dialog
+      v-loading="loading"
+      :title="
+        listQuery.walletType === 'cash'
+          ? 'Member Ecoin Trading Record'
+          : 'Member Bonus Trading Record'
+      "
+      :visible.sync="dialog"
+      :width="screenWidth"
+      class="ng-account-balance-dialog"
+    >
+      <div>
+        <el-form
+          class="ng-account-history-filters"
+          :class="{ 'ng-account-history-filters--cash': listQuery.walletType === 'cash' }"
+          @submit.native.prevent
+        >
+          <div class="ng-account-history-field">
+            <label
+              for="ng-account-history-start"
+              class="ng-account-visually-hidden"
+            >Start date</label>
+            <el-date-picker
+              id="ng-account-history-start"
+              v-model="listQuery.createAt[0]"
+              type="date"
+              placeholder="YYYY-MM-DD"
+              value-format="yyyy-MM-dd"
+              clearable
+              popper-class="ng-refactor-popper"
+            />
+          </div>
+          <div class="ng-account-history-field">
+            <label for="ng-account-history-end" class="ng-account-visually-hidden">End date</label>
+            <el-date-picker
+              id="ng-account-history-end"
+              v-model="listQuery.createAt[1]"
+              type="date"
+              placeholder="YYYY-MM-DD"
+              value-format="yyyy-MM-dd"
+              clearable
+              popper-class="ng-refactor-popper"
+            />
+          </div>
+          <div
+            v-if="listQuery.walletType !== 'cash'"
+            class="ng-account-history-field ng-account-history-type"
+          >
+            <label
+              for="ng-account-history-type"
+              class="ng-account-visually-hidden"
+            >Transaction type</label>
+            <el-select
+              id="ng-account-history-type"
+              v-model="listQuery.dealType"
+              placeholder="All Trans Type"
+              clearable
+              popper-class="ng-refactor-popper"
+            >
+              <el-option
+                v-for="(item, key) in dealTypeList"
+                :key="key"
+                :label="item"
+                :value="key"
+              />
+            </el-select>
+          </div>
+          <div class="ng-account-history-field ng-account-history-remark">
+            <el-input v-model="listQuery.remark" placeholder="Remark" label="Remark" clearable />
+          </div>
+        </el-form>
+
+        <el-table
+          stripe
+          :data="bonusData"
+          fit
+          highlight-current-row
+          class="ng-account-history-table"
+        >
+          <el-table-column label="Trans Date" min-width="140">
+            <template slot-scope="{ row }">
+              <span>{{ row.CREATED_AT | parseTime('{y}-{m}-{d} {h}:{i}') }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column v-if="dealTypeSwitch" label="Trans Type" min-width="110">
+            <template slot-scope="{ row }">
+              <span>{{ row.DEAL_TYPE_NAME }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="Pre Trans Balance" min-width="155">
+            <template slot-scope="{ row }">
+              <span>{{ tool.formatPrice(row.TOTAL - row.AMOUNT) | amountFilter }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="Post Trans Balance" min-width="160">
+            <template slot-scope="{ row }">
+              <span>{{ row.AMOUNT | amountFilter }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="Trans Amount" min-width="130">
+            <template slot-scope="{ row }">
+              <span
+                :class="{ 'ng-account-amount-credit': row.IS_INCR > 0 }"
+              >{{ row.IS_INCR > 0 ? '+' : '' }} {{ row.AMOUNT | amountFilter }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="Remark" min-width="180">
+            <template slot-scope="{ row }">
+              <span>{{ row.REMARK }}</span>
+            </template>
+          </el-table-column>
+        </el-table>
+
+        <pagination
+          v-show="total > 0"
+          :total="total"
+          :page.sync="listQuery.page"
+          :limit.sync="listQuery.pageSize"
+          float="none"
+          class="ng-account-history-pagination"
+          popper-class="ng-refactor-popper"
+          @pagination="handleBonusFlow(listQuery.walletType, walletName)"
+        />
+      </div>
+      <div slot="footer">
+        <el-button
+          native-type="button"
+          @click="handleFilterClear(listQuery.walletType, walletName)"
+        >Reset</el-button>
+        <el-button
+          type="primary"
+          native-type="button"
+          @click="handleBonusFlow(listQuery.walletType, walletName)"
+        >Select</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import MemberAccount from '@/views/bonus/account-list.vue'
+import { toThousandFilter, amountFilter, parseTime } from '@/filters'
+
+export default {
+  name: 'AccountBalances',
+  filters: { toThousandFilter, amountFilter, parseTime },
+  // 继承旧组件的业务逻辑,使用当前模板展示。
+  extends: MemberAccount,
+  computed: {
+    balances() {
+      return this.walletData.filter(
+        item => item.walletType === 'bonus' || item.walletType === 'cash'
+      )
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.ng-account-balances {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 24px;
+  min-height: 180px;
+}
+
+.ng-account-balance-label {
+  margin: 0;
+  color: #606266;
+  font-size: 12px;
+  font-weight: 700;
+  text-transform: uppercase;
+}
+
+.ng-account-balance-value {
+  margin: 16px 0 0;
+  color: #202259;
+  font-size: 40px;
+  font-weight: 700;
+  line-height: 1.2;
+}
+
+.ng-account-balance-unit {
+  font-size: 16px;
+  font-weight: 500;
+}
+
+.ng-account-history {
+  margin-top: 20px;
+}
+
+.ng-account-balance-dialog {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 24px;
+}
+
+.ng-account-balance-dialog ::v-deep .el-dialog {
+  display: flex;
+  max-width: 100%;
+  max-height: calc(100vh - 48px);
+  flex-direction: column;
+  margin: 0 !important;
+  overflow: hidden;
+  border-radius: 14px;
+}
+
+.ng-account-balance-dialog ::v-deep .el-dialog__header {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  padding: 24px 24px 16px;
+}
+
+.ng-account-balance-dialog ::v-deep .el-dialog__title {
+  min-width: 0;
+  color: #202259;
+  font-size: 20px;
+  font-weight: 700;
+  line-height: 1.4;
+}
+
+.ng-account-balance-dialog ::v-deep .el-dialog__body {
+  min-height: 0;
+  padding: 0 24px 24px;
+  overflow: auto;
+}
+
+.ng-account-balance-dialog ::v-deep .el-dialog__footer {
+  padding: 16px 24px;
+  border-top: 1px solid #e6ebf5;
+  background: #f8f9fa;
+}
+
+.ng-account-history-filters {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 10px;
+  margin-bottom: 12px;
+  padding-bottom: 16px;
+  border-bottom: 1px solid #e6ebf5;
+}
+
+.ng-account-history-filters--cash {
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.ng-account-history-field {
+  min-width: 0;
+}
+
+.ng-account-history-field ::v-deep .el-select,
+.ng-account-history-field ::v-deep .el-date-editor {
+  width: 100%;
+}
+
+.ng-account-visually-hidden {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  overflow: hidden;
+  clip: rect(0, 0, 0, 0);
+  white-space: nowrap;
+}
+
+.ng-account-amount-credit {
+  color: #1f6b3f;
+}
+
+.ng-account-balance-dialog .ng-account-history-pagination {
+  max-width: 100%;
+  margin-top: 0;
+  padding: 16px 0 0;
+  overflow-x: auto;
+}
+
+@media (max-width: 767px) {
+  .ng-account-balance-dialog {
+    padding: 16px;
+  }
+
+  .ng-account-balance-dialog ::v-deep .el-dialog {
+    max-height: calc(100vh - 32px);
+  }
+
+  .ng-account-balance-dialog ::v-deep .el-dialog__header {
+    padding: 20px 16px 16px;
+  }
+
+  .ng-account-balance-dialog ::v-deep .el-dialog__title {
+    font-size: 18px;
+  }
+
+  .ng-account-balance-dialog ::v-deep .el-dialog__body {
+    padding: 0 16px 16px;
+  }
+
+  .ng-account-balance-dialog ::v-deep .el-dialog__footer {
+    padding: 16px;
+  }
+
+  .ng-account-history-filters {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .ng-account-history-filters--cash .ng-account-history-remark {
+    grid-column: 1 / -1;
+  }
+}
+
+@media (max-width: 575px) {
+  .ng-account-balances {
+    grid-template-columns: minmax(0, 1fr);
+    gap: 0;
+  }
+
+  .ng-account-history-filters {
+    grid-template-columns: minmax(0, 1fr);
+  }
+}
+</style>

+ 51 - 0
src/ui-refactor/views/account/components/AccountOrders.vue

@@ -0,0 +1,51 @@
+<template>
+  <section aria-labelledby="ng-account-orders-title">
+    <h2 id="ng-account-orders-title" class="ng-account-title">Orders</h2>
+    <p class="ng-account-note">View your purchases and orders placed for members.</p>
+    <div class="ng-account-orders">
+      <el-tabs v-model="category">
+        <el-tab-pane label="My orders" name="self" />
+        <el-tab-pane label="Member orders" name="member" />
+      </el-tabs>
+      <!--      <keep-alive>-->
+      <component
+        :is="category === 'self' ? 'OwnOrders' : 'MemberOrders'"
+        :key="category"
+        :member-orders="category === 'member'"
+      />
+      <!--      </keep-alive>-->
+    </div>
+  </section>
+</template>
+
+<script>
+// 只继承旧脚本,避免加载旧页面的全局表格样式。
+import OwnOrders from '@/views/shop/order-list.vue?vue&type=script&lang=js'
+import MemberOrders from '@/views/user/member-order.vue?vue&type=script&lang=js'
+import OrderCards from './order/AccountOrderCards.vue'
+
+export default {
+  name: 'AccountOrders',
+  components: {
+    OwnOrders: { ...OrderCards, name: 'AccountOwnOrders', extends: OwnOrders },
+    MemberOrders: { ...OrderCards, name: 'AccountMemberOrders', extends: MemberOrders }
+  },
+  data() {
+    return { category: 'self' }
+  },
+  watch: {
+    '$route.query.orderCategory': {
+      immediate: true,
+      handler(category) {
+        this.category = category === 'member' ? 'member' : 'self'
+      }
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.ng-account-orders {
+  margin-top: 24px;
+}
+</style>

+ 20 - 0
src/ui-refactor/views/account/components/AccountOverview.vue

@@ -0,0 +1,20 @@
+<template>
+  <div>
+    <account-balances />
+    <sponsor-network />
+    <account-shortcuts />
+    <account-activity />
+  </div>
+</template>
+
+<script>
+import AccountBalances from './AccountBalances.vue'
+import SponsorNetwork from './network/SponsorNetwork.vue'
+import AccountShortcuts from './AccountShortcuts.vue'
+import AccountActivity from './AccountActivity.vue'
+
+export default {
+  name: 'AccountOverview',
+  components: { AccountBalances, SponsorNetwork, AccountShortcuts, AccountActivity }
+}
+</script>

+ 65 - 0
src/ui-refactor/views/account/components/AccountProfile.vue

@@ -0,0 +1,65 @@
+<template>
+  <div class="ng-account-profile">
+    <personal-information v-if="user.name" :user="user" class="ng-account-profile-wide" />
+    <ui-section-card v-else title="Personal information" class="ng-account-profile-wide">
+      <el-empty description="No member information to display." />
+    </ui-section-card>
+    <delivery-addresses class="ng-account-profile-wide" />
+    <payment-password-panel />
+    <login-password-panel />
+  </div>
+</template>
+
+<script>
+import usersInfo from '@/utils/usersInfo'
+import UiSectionCard from '@/components/ui/UiSectionCard.vue'
+import PersonalInformation from './profile/PersonalInformation.vue'
+import DeliveryAddresses from './profile/DeliveryAddresses.vue'
+import PaymentPasswordPanel from './profile/PaymentPasswordPanel.vue'
+import LoginPasswordPanel from './profile/LoginPasswordPanel.vue'
+
+export default {
+  name: 'AccountProfile',
+  components: {
+    UiSectionCard,
+    PersonalInformation,
+    DeliveryAddresses,
+    PaymentPasswordPanel,
+    LoginPasswordPanel
+  },
+  data() {
+    const member = usersInfo.baseData() || {}
+    return {
+      // 只取旧 Profile 中本区块使用的身份字段,不重复迁入会员卡和等级信息。
+      user: {
+        name: usersInfo.userName(),
+        email: usersInfo.userEmail(),
+        mobile: member.MOBILE,
+        realName: member.REAL_NAME
+      }
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.ng-account-profile {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 0 24px;
+}
+
+.ng-account-profile-wide {
+  grid-column: 1 / -1;
+}
+
+.ng-account-profile > * {
+  min-width: 0;
+}
+
+@media (max-width: 767px) {
+  .ng-account-profile {
+    grid-template-columns: minmax(0, 1fr);
+  }
+}
+</style>

+ 180 - 0
src/ui-refactor/views/account/components/AccountShortcuts.vue

@@ -0,0 +1,180 @@
+<template>
+  <div>
+    <nav class="ng-account-shortcuts" aria-label="Member services">
+      <button
+        v-for="item in shortcuts"
+        :key="item.label"
+        type="button"
+        class="ng-account-shortcut"
+        :disabled="!item.to && !item.dialog"
+        @click="openShortcut(item)"
+      >
+        <span class="ng-account-shortcut-icon"><i :class="item.icon" aria-hidden="true" /></span>
+        <span class="ng-account-shortcut-label">{{ item.label }}</span>
+        <span class="ng-account-shortcut-note">{{ item.note }}</span>
+      </button>
+    </nav>
+    <component
+      :is="activeDialog"
+      v-if="activeDialog"
+      :visible.sync="dialogVisible"
+      @closed="closeDialog"
+    />
+  </div>
+</template>
+
+<script>
+import PreviousBonusDialog from './bonus/PreviousBonusDialog.vue'
+
+export default {
+  name: 'AccountShortcuts',
+  components: {
+    PreviousBonusDialog,
+    HistoricalCumulativeBonusDialog: () => import('./bonus/HistoricalCumulativeBonusDialog.vue'),
+    RechargeAndTransferDialog: () => import('./finance/RechargeAndTransferDialog.vue')
+  },
+  data() {
+    return {
+      activeDialog: null,
+      dialogVisible: false,
+      shortcuts: [
+        {
+          label: 'Repeat Purchase',
+          note: 'Browse & buy again',
+          icon: 'el-icon-box',
+          to: '/dashboard/repeat-purchase'
+        },
+        {
+          label: 'Member Welcome Pack',
+          note: 'Invite & earn rewards',
+          icon: 'el-icon-present',
+          to: '/dashboard/member-welcome-pack'
+        },
+        {
+          label: 'Member Repeat Purchase',
+          note: 'Search member & place order',
+          icon: 'el-icon-refresh',
+          to: '/dashboard/member-repurchase'
+        },
+        {
+          label: 'Member Auto-Maintenance',
+          note: 'Maintain member status',
+          icon: 'el-icon-setting',
+          to: '/dashboard/member-auto-maintenance'
+        },
+        {
+          label: 'Member Upgrade',
+          note: 'Adjust member rank',
+          icon: 'el-icon-top',
+          to: '/dashboard/member-upgrade'
+        },
+        {
+          label: 'Previous Bonus',
+          note: 'View historical bonus periods',
+          icon: 'el-icon-data-analysis',
+          dialog: 'PreviousBonusDialog'
+        },
+        {
+          label: 'Team Inquiry',
+          note: 'View your team details',
+          icon: 'el-icon-user',
+          to: '/dashboard/team-inquiry'
+        },
+        {
+          label: 'Historical Cumulative Bonus',
+          note: 'View lifetime bonus totals',
+          icon: 'el-icon-medal',
+          dialog: 'HistoricalCumulativeBonusDialog'
+        },
+        {
+          label: 'Recharge and Transfer',
+          note: 'Recharge bonus & transfer funds',
+          icon: 'el-icon-bank-card',
+          dialog: 'RechargeAndTransferDialog'
+        }
+      ]
+    }
+  },
+  methods: {
+    openShortcut(item) {
+      if (item.to) {
+        this.$router.push({ path: item.to, query: this.$route.query })
+      } else if (item.dialog) {
+        this.activeDialog = item.dialog
+        this.dialogVisible = true
+      }
+    },
+    closeDialog() {
+      if (!this.dialogVisible) this.activeDialog = null
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.ng-account-shortcuts {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 16px;
+  overflow: hidden;
+  margin-bottom: 32px;
+  //border: 1px solid #e6ebf5;
+  //border-radius: 8px;
+  //background: #e6ebf5;
+}
+
+.ng-account-shortcut {
+  display: flex;
+  min-width: 0;
+  align-items: center;
+  flex-direction: column;
+  gap: 8px;
+  padding: 28px 16px;
+  border: 0;
+  background: #fff;
+  color: #202259;
+  font: inherit;
+  line-height: 1.5;
+  text-align: center;
+  cursor: pointer;
+  border-radius: 8px;
+}
+
+.ng-account-shortcut-icon {
+  display: flex;
+  width: 44px;
+  height: 44px;
+  align-items: center;
+  justify-content: center;
+  margin-bottom: 4px;
+  border-radius: 50%;
+  background: #f4f6f8;
+  font-size: 22px;
+}
+
+.ng-account-shortcut-label {
+  font-size: 14px;
+  font-weight: 700;
+}
+
+.ng-account-shortcut-note {
+  color: #606266;
+  font-size: 12px;
+}
+
+@media (max-width: 767px) {
+  .ng-account-shortcuts {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .ng-account-shortcut {
+    padding: 24px 12px;
+  }
+}
+
+@media (max-width: 359px) {
+  .ng-account-shortcuts {
+    grid-template-columns: minmax(0, 1fr);
+  }
+}
+</style>

+ 317 - 0
src/ui-refactor/views/account/components/MemberSummary.vue

@@ -0,0 +1,317 @@
+<template>
+  <el-card shadow="never" class="ng-account-card">
+    <div class="ng-account-identity">
+      <el-avatar :src="avatar" :size="48" icon="el-icon-user-solid" class="ng-account-avatar" />
+      <div class="ng-account-identity-copy">
+        <h1 class="ng-account-name">{{ memberName }}</h1>
+        <p class="ng-account-note">Member ID: {{ memberId || '—' }}</p>
+      </div>
+      <el-button
+        type="text"
+        :disabled="!memberId"
+        class="ng-account-signout"
+        @click="handleMemberLogout"
+      >Sign out</el-button>
+    </div>
+
+    <dl v-loading="loading" class="ng-account-details">
+      <div v-for="item in memberDetails" :key="item.label" class="ng-account-detail">
+        <i :class="item.icon" class="ng-account-detail-icon" aria-hidden="true" />
+        <div class="ng-account-detail-copy">
+          <dt class="ng-account-detail-label">{{ item.label }}</dt>
+          <dd class="ng-account-detail-value">{{ item.value }}</dd>
+        </div>
+      </div>
+    </dl>
+
+    <section class="ng-account-referral" aria-labelledby="ng-account-referral-title">
+      <div class="ng-account-referral-copy">
+        <p class="ng-account-eyebrow">Invite &amp; earn</p>
+        <h2 id="ng-account-referral-title" class="ng-account-title">Your referral link</h2>
+        <p class="ng-account-note">
+          Share this link. When friends order <strong>without signing in</strong>, their order's BV
+          is credited to your member ID.
+        </p>
+      </div>
+      <el-input
+        :value="referralLink"
+        readonly
+        placeholder="—"
+        label="Your referral link"
+        class="ng-account-referral-field"
+      />
+      <div class="ng-account-referral-actions">
+        <el-dropdown trigger="click" placement="bottom-start" @command="handleShareLink">
+          <el-button type="primary" icon="el-icon-share" native-type="button" :disabled="!memberId">
+            Share link <i class="el-icon-arrow-down el-icon--right" aria-hidden="true" />
+          </el-button>
+          <el-dropdown-menu slot="dropdown" class="ng-refactor-popper">
+            <el-dropdown-item command="whatsapp" :disabled="!memberId">WhatsApp</el-dropdown-item>
+            <el-dropdown-item command="facebook" :disabled="!memberId">Facebook</el-dropdown-item>
+          </el-dropdown-menu>
+        </el-dropdown>
+        <el-button
+          native-type="button"
+          :disabled="!memberId"
+          icon="el-icon-copy-document"
+          @click="handleClipboard(referralLink, $event)"
+        >Copy link</el-button>
+      </div>
+    </section>
+  </el-card>
+</template>
+
+<script>
+import moment from 'moment'
+import usersInfo from '@/utils/usersInfo'
+import handleClipboard from '@/utils/clipboard'
+import avatar from '@/assets/user_images/avatar.png'
+
+export default {
+  name: 'MemberSummary',
+  data() {
+    return {
+      avatar,
+      memberId: usersInfo.userName() || '',
+      memberData: usersInfo.baseData() || {},
+      memberBaseInfo: usersInfo.getBaseInfo() || {},
+      memberNowTime: Date.now(),
+      memberClock: null,
+      cycle: '',
+      decLv: '',
+      heightEmpLv: '',
+      eliteLv: '',
+      loading: false
+    }
+  },
+  computed: {
+    memberName() {
+      return this.memberData.REAL_NAME || this.memberId || 'My account'
+    },
+    memberSystemTime() {
+      return moment(this.memberNowTime).utcOffset('+0100').format('YYYY-MM-DD HH:mm:ss') + ' (WAT)'
+    },
+    referralLink() {
+      return this.memberId ? 'https://meads.elken.com/#/login?ref=' + this.memberId : ''
+    },
+    memberDetails() {
+      return [
+        { label: 'System time', icon: 'el-icon-time', value: this.memberSystemTime },
+        {
+          label: 'Settlement cycle',
+          icon: 'el-icon-date',
+          value: this.cycle || this.memberBaseInfo.periodNum || '—'
+        },
+        {
+          label: 'Member level',
+          icon: 'el-icon-star-off',
+          value: this.memberLevelName('decLvs', 'decLevels', this.decLv || this.memberData.DEC_LV)
+        },
+        {
+          label: 'Highest honor level',
+          icon: 'el-icon-medal',
+          value: this.memberLevelName(
+            'empLevels',
+            'empLevels',
+            this.heightEmpLv || this.memberData.EMP_LV
+          )
+        },
+        {
+          label: 'Elite level',
+          icon: 'el-icon-medal-1',
+          value: this.memberLevelName(
+            'eliteLevels',
+            'eliteLevels',
+            this.eliteLv || this.memberData.ELITE_LV
+          )
+        }
+      ]
+    }
+  },
+  mounted() {
+    this.memberClock = window.setInterval(() => {
+      this.memberNowTime = Date.now()
+    }, 1000)
+    this.loadMemberInfo()
+  },
+  beforeDestroy() {
+    window.clearInterval(this.memberClock)
+  },
+  methods: {
+    handleClipboard,
+    async loadMemberInfo() {
+      // 空缓存的独立预览不请求受保护接口;已登录时沿用旧首页的数据来源。
+      if (!this.memberId) return
+      this.loading = true
+      try {
+        const { data } = await this.$store.dispatch('dashboard/getIndex', {})
+        this.cycle = data.periodNum
+        this.decLv = data.decLv
+        this.heightEmpLv = data.empLv
+        this.eliteLv = data.eliteLv
+      } catch (error) {
+        this.$message.error('Unable to load member information. Please try again later.')
+      } finally {
+        this.loading = false
+      }
+    },
+    memberLevelName(messages, levels, id) {
+      if (!id) return '—'
+      const key = messages + '.' + id
+      if (this.$te(key, 'en')) return this.$t(key, 'en')
+      const level = (this.memberBaseInfo[levels] || {})[id]
+      return level ? level.LEVEL_NAME : '—'
+    },
+    handleShareLink(channel) {
+      if (!this.memberId) return
+      // 编码完整链接,保留 Hash 路由和 ref 参数。
+      const link = encodeURIComponent(this.referralLink)
+      const shareUrls = {
+        whatsapp: 'https://wa.me/?text=' + link,
+        facebook: 'https://www.facebook.com/sharer/sharer.php?u=' + link
+      }
+      if (channel !== 'whatsapp' && channel !== 'facebook') return
+      window.open(shareUrls[channel], '_blank', 'noopener,noreferrer')
+    },
+    async handleMemberLogout() {
+      await this.$store.dispatch('user/logout')
+      this.$router.push(`/login?redirect=${this.$route.fullPath}`)
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.ng-account-identity {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+}
+
+.ng-account-avatar {
+  flex-shrink: 0;
+  background: #202259;
+}
+
+.ng-account-identity-copy {
+  flex: 1;
+  min-width: 0;
+}
+
+.ng-account-name {
+  margin: 0;
+  color: #202259;
+  font-size: 28px;
+  line-height: 1.3;
+  overflow-wrap: anywhere;
+}
+
+.ng-account-signout {
+  margin-left: auto;
+}
+
+.ng-account-details {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: space-between;
+  gap: 24px;
+  margin: 24px 0 0;
+  padding: 24px 0;
+  border-top: 1px solid #e6ebf5;
+}
+
+.ng-account-detail {
+  display: flex;
+  min-width: 0;
+  gap: 10px;
+}
+
+.ng-account-detail-copy {
+  min-width: 0;
+}
+
+.ng-account-detail-icon {
+  flex-shrink: 0;
+  color: #859eb3;
+  font-size: 18px;
+}
+
+.ng-account-detail-label {
+  color: #606266;
+  font-size: 11px;
+  line-height: 1.5;
+  text-transform: uppercase;
+}
+
+.ng-account-detail-value {
+  margin: 8px 0 0;
+  color: #202259;
+  font-size: 14px;
+  font-weight: 700;
+  line-height: 1.5;
+  overflow-wrap: anywhere;
+}
+
+.ng-account-referral {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: flex-end;
+  gap: 16px 24px;
+  padding-top: 24px;
+  border-top: 1px solid #e6ebf5;
+}
+
+.ng-account-referral-copy {
+  flex: 1 1 320px;
+  min-width: 0;
+}
+
+.ng-account-referral-field {
+  flex: 1 1 260px;
+  min-width: 0;
+}
+
+.ng-account-referral-actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px;
+}
+
+.ng-account-referral-actions .el-button {
+  margin: 0;
+}
+
+@media (max-width: 767px) {
+  .ng-account-identity {
+    flex-wrap: wrap;
+  }
+
+  .ng-account-identity-copy {
+    flex-basis: calc(100% - 64px);
+  }
+
+  .ng-account-name {
+    font-size: 24px;
+  }
+
+  .ng-account-details {
+    display: grid;
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+    gap: 20px 12px;
+  }
+
+  .ng-account-referral-field,
+  .ng-account-referral-actions {
+    flex-basis: 100%;
+  }
+
+  .ng-account-referral-actions > .el-button,
+  .ng-account-referral-actions > .el-dropdown {
+    flex: 1;
+  }
+
+  .ng-account-referral-actions .el-dropdown > .el-button {
+    width: 100%;
+  }
+}
+</style>

+ 65 - 0
src/ui-refactor/views/account/components/bonus/HistoricalCumulativeBonusDialog.vue

@@ -0,0 +1,65 @@
+<template>
+  <ui-dialog
+    :visible="visible"
+    title="Historical Cumulative Bonus"
+    @update:visible="$emit('update:visible', $event)"
+    @closed="$emit('closed')"
+  >
+    <div v-loading="loading" class="ng-cumulative-bonus">
+      <el-row v-if="tableData.length" :gutter="24">
+        <el-col v-for="(item, index) in tableData" :key="index" :xs="24" :sm="12" :lg="6">
+          <el-card shadow="never">
+            <h3>{{ $t(`bonus.${item.name}`) }}</h3>
+            <p>{{ item.bonus | toThousandFilter }}</p>
+          </el-card>
+        </el-col>
+      </el-row>
+      <el-empty v-else-if="!loading" :description="$t('common.noData')" />
+    </div>
+  </ui-dialog>
+</template>
+
+<script>
+import HistoricalCumulativeBonus from '@/views/bonus/historical-cumulative-bonus.vue'
+import { toThousandFilter } from '@/filters'
+import UiDialog from '@/ui-refactor/components/UiDialog.vue'
+
+export default {
+  name: 'HistoricalCumulativeBonusDialog',
+  components: { UiDialog },
+  filters: { toThousandFilter },
+  // 继承旧组件的请求和状态,使用当前模板展示。
+  extends: HistoricalCumulativeBonus,
+  props: {
+    visible: { type: Boolean, default: false }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.ng-cumulative-bonus {
+  min-height: 120px;
+
+  .el-card {
+    margin-bottom: 24px;
+  }
+
+  h3 {
+    margin: 0;
+    color: #606266;
+    font-size: 12px;
+    line-height: 1.5;
+    text-transform: uppercase;
+    overflow-wrap: anywhere;
+  }
+
+  p {
+    margin: 8px 0 0;
+    color: #202259;
+    font-size: 32px;
+    font-weight: 700;
+    line-height: 1.3;
+    overflow-wrap: anywhere;
+  }
+}
+</style>

+ 111 - 0
src/ui-refactor/views/account/components/bonus/PreviousBonusDialog.vue

@@ -0,0 +1,111 @@
+<template>
+  <ui-dialog
+    :visible="visible"
+    title="Previous Bonus — Historical Periods"
+    @update:visible="$emit('update:visible', $event)"
+    @closed="$emit('closed')"
+  >
+    <div v-if="tableData.length" v-loading="loading" class="ng-previous-bonus">
+      <el-table :data="tableData" style="width: 100%">
+        <el-table-column min-width="110" :label="$t('bonus.payCycle')" prop="period" />
+        <el-table-column min-width="140" :label="$t('bonus.eliteBonus')" prop="eliteBonus">
+          <template slot-scope="{ row }">{{ row.eliteBonus | toThousandFilter }}</template>
+        </el-table-column>
+        <el-table-column min-width="150" :label="$t('bonus.proEliteBonus')" prop="proEliteBonus" />
+        <el-table-column
+          min-width="160"
+          :label="$t('bonus.superEliteBonus')"
+          prop="superEliteBonus"
+        />
+        <el-table-column min-width="150" :label="$t('bonus.stockistBonus')" prop="stockistBonus" />
+        <el-table-column
+          min-width="180"
+          :label="$t('bonus.leaderShipBonus')"
+          prop="leaderShipBonus"
+        />
+        <el-table-column min-width="160" :label="$t('bonus.welcomeBonus')" prop="welcomeBonus" />
+        <el-table-column min-width="140" :label="$t('bonus.carBonus')" prop="carBonus" />
+        <el-table-column
+          min-width="160"
+          :label="$t('bonus.userBuilderBonus')"
+          prop="userBuilderBonus"
+        />
+        <el-table-column width="90" fixed="right" :label="$t('common.action')">
+          <template slot-scope="{ row }">
+            <el-button
+              v-if="flowBonusSwitch === 1"
+              type="primary"
+              size="mini"
+              @click="getOne(row.period)"
+            >{{ $t('common.view') }}</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </div>
+    <el-empty v-else :description="$t('common.noData')" />
+
+    <ui-dialog v-if="bonusData" :visible.sync="dialog" :title="bonusPayCycle">
+      <el-descriptions class="ng-previous-bonus-detail" :column="1" border size="medium">
+        <el-descriptions-item
+          v-for="field in detailFields"
+          :key="field.prop"
+          :label="$t(field.label)"
+        >
+          {{ bonusData[field.prop] }}
+        </el-descriptions-item>
+      </el-descriptions>
+    </ui-dialog>
+  </ui-dialog>
+</template>
+
+<script>
+import PreviousBonus from '@/views/bonus/bonus-list.vue'
+import { toThousandFilter } from '@/filters'
+import UiDialog from '@/ui-refactor/components/UiDialog.vue'
+
+export default {
+  name: 'PreviousBonusDialog',
+  components: { UiDialog },
+  filters: { toThousandFilter },
+  extends: PreviousBonus,
+  props: {
+    visible: { type: Boolean, default: false }
+  },
+  computed: {
+    detailFields() {
+      return [
+        { prop: 'userName', label: 'shop.memberCode' },
+        { prop: 'decLevel', label: 'profile.memberLevel' },
+        { prop: 'empLevel', label: 'profile.highestDirector' },
+        { prop: 'eliteLevel', label: 'profile.eliteLevel' },
+        { prop: 'eliteBonus', label: 'bonus.eliteBonus' },
+        { prop: 'proEliteBonus', label: 'bonus.proEliteBonus' },
+        { prop: 'superEliteBonus', label: 'bonus.superEliteBonus' },
+        { prop: 'stockistBonus', label: 'bonus.stockistBonus' },
+        { prop: 'leaderShipBonus', label: 'bonus.leaderShipBonus' },
+        { prop: 'welcomeBonus', label: 'bonus.welcomeBonus' },
+        { prop: 'carBonus', label: 'bonus.carBonus' },
+        { prop: 'userBuilderBonus', label: 'bonus.userBuilderBonus' },
+        { prop: 'totalBonus', label: 'bonus.totalBonus' }
+      ]
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.ng-previous-bonus {
+  min-height: 120px;
+}
+
+.ng-previous-bonus-detail ::v-deep .el-descriptions-item__label,
+.ng-previous-bonus-detail ::v-deep .el-descriptions-item__content {
+  overflow-wrap: anywhere;
+}
+
+@media (max-width: 767px) {
+  .ng-previous-bonus-detail ::v-deep .el-descriptions__body table {
+    table-layout: fixed;
+  }
+}
+</style>

+ 26 - 0
src/ui-refactor/views/account/components/finance/README.md

@@ -0,0 +1,26 @@
+# Recharge and Transfer
+
+账户首页卡片通过 `RechargeAndTransferDialog.vue` 打开现有 `UiDialog`,不新增路由。Recharge / Transfer 页签使用 Element UI lazy:首次访问才挂载对应面板,往返保留各自查询状态,关闭整个弹窗后随卡片容器卸载。
+
+两个面板分别直接导入并继承旧 `src/views/finance/recharge-list.vue` 和 `transfer-list.vue`,旧组件样式也会随导入加载。不覆盖 data、created、mounted、computed、watch 或 methods,不改旧页、API、权限、缓存、语言资源或依赖。只替换模板、局部样式和弹窗布局。
+
+保留的充值行为:
+
+- 列表与分页仍使用 page=1 / pageSize=10,显示原时间、金额、状态和凭证操作条件。
+- 申请/编辑继续使用原 form、rules、isCashType、Cash 银行字段清空、日期格式、最小/最大金额及 10000 步长。原新增/编辑 API、id 和 payload 均不变。
+- 凭证查看、重新上传、上传地址、上传 Token、headers/data 与成功/失败回调均沿用原方法。上传前仍绑定 uploaderHandleBefore,不把旧页未使用的 beforeUpload 验证自动加上。
+- 新表单首次打开后保留挂载,供原 watcher 的 nextTick clearValidate 使用;关闭后清理展示校验信息,resetForm 和请求方法保持原样。
+
+保留的转账行为:
+
+- 查询字段、钱包选项、Reset 的 page=1 / pageSize=20、showFee 条件、原金额字段/格式化和分页请求不变。
+- 保留原配置、余额、转账码、会员查询、自转限制、手续费与最低到账金额判断,以及 Confirm 后的支付密码 prompt 和提交 payload。
+- 不新增表单规则,不取消密码提示,不移植参考截图的示例金额、手续费或状态。原失败及 loading 收尾行为保持。
+
+UI 适配:内层表单宽 560px、标签置顶;窄屏改为单列,内容区域滚动,固定页脚可达。分页在自身容器横向滚动,不滚动弹窗背后的页面。Cancel / Confirm 移入页脚后仍受原 loading 状态阻挡;转账申请保留原弹窗 loading、禁止遮罩关闭和 destroy-on-close。
+
+公共弹窗只补充连续切换的焦点处理:查看凭证切到上传时保留新弹窗焦点,原触发按钮隐藏后关闭上传会回到仍打开的父弹窗。
+
+验证(2026-10-14):12 项财务定向测试直接加载真实 SFC 和 Element UI,比较旧方法引用并验证模板、接口参数、校验、筛选/分页、现金/银行、凭证回调、收款会员、最低金额、密码 prompt、失败收尾与 loading。相关奖金和团队入口回归合计 22 项通过;定向 lint、完整应用临时构建通过。浏览器使用临时 API 样例覆盖四类视图的 320 / 375 / 768 / 1024 / 1440px 和 375×480;没有提交真实充值、转账或文件上传,也未修改认证缓存。
+
+全量回归的既有余额 Reset CSS 定位测试仍失败,本轮不修改余额组件或其测试。真实账号及后端财务联调未执行;原逻辑边界和异常处理不在此次 UI 改造中调整。

+ 34 - 0
src/ui-refactor/views/account/components/finance/RechargeAndTransferDialog.vue

@@ -0,0 +1,34 @@
+<template>
+  <ui-dialog
+    :visible="visible"
+    title="Recharge and Transfer"
+    @update:visible="$emit('update:visible', $event)"
+    @closed="$emit('closed')"
+  >
+    <el-tabs v-model="activeTab">
+      <el-tab-pane label="Recharge" name="recharge" lazy>
+        <recharge-panel />
+      </el-tab-pane>
+      <el-tab-pane label="Transfer" name="transfer" lazy>
+        <transfer-panel />
+      </el-tab-pane>
+    </el-tabs>
+  </ui-dialog>
+</template>
+
+<script>
+import UiDialog from '@/ui-refactor/components/UiDialog.vue'
+import RechargePanel from './RechargePanel.vue'
+import TransferPanel from './TransferPanel.vue'
+
+export default {
+  name: 'RechargeAndTransferDialog',
+  components: { UiDialog, RechargePanel, TransferPanel },
+  props: {
+    visible: { type: Boolean, default: false }
+  },
+  data() {
+    return { activeTab: 'recharge' }
+  }
+}
+</script>

+ 204 - 0
src/ui-refactor/views/account/components/finance/RechargePanel.vue

@@ -0,0 +1,204 @@
+<template>
+  <div v-loading="listLoading" class="ng-finance-panel">
+    <div class="ng-finance-toolbar">
+      <el-button type="primary" size="small" @click=";(dialog = true), (isEdit = false)">{{
+        $t('finance.recharge')
+      }}</el-button>
+    </div>
+    <el-table :data="list" style="width: 100%">
+      <el-table-column min-width="180" :label="$t('finance.createdTime')" prop="CREATED_AT">
+        <template slot-scope="{ row }">{{ tool.convertToNigeriaTime(row.CREATED_AT) }}</template>
+      </el-table-column>
+      <el-table-column min-width="160" :label="$t('finance.rechargeAmount')" prop="AMOUNT">
+        <template slot-scope="{ row }">{{ row.AMOUNT | toThousandFilter }}</template>
+      </el-table-column>
+      <el-table-column min-width="160" :label="$t('finance.rechargeStatus')" prop="STATUS_NAME">
+        <template
+          slot-scope="{ row }"
+        ><el-tag size="mini" :type="row.AUDIT_STATUS | statusFilter">{{
+          row.STATUS_NAME
+        }}</el-tag></template>
+      </el-table-column>
+      <el-table-column min-width="100" :label="$t('finance.type')" prop="TYPE" />
+      <el-table-column min-width="160" :label="$t('finance.bankName')" prop="OPEN_BANK_NAME" />
+      <el-table-column min-width="180" :label="$t('finance.paymentReference')" prop="BANK_NO" />
+      <el-table-column min-width="230" :label="$t('finance.printVoucher')">
+        <template slot-scope="{ row }">
+          <el-button
+            v-if="row.AUDIT_STATUS === '0'"
+            type="primary"
+            size="mini"
+            @click="handleUpload(row.ID)"
+          >{{ $t('finance.uploadVoucher') }}</el-button>
+          <el-button
+            v-if="row.AUDIT_STATUS !== '0'"
+            type="success"
+            size="mini"
+            @click="handleView(row)"
+          >{{ $t('finance.viewVoucher') }}</el-button>
+          <el-button
+            v-if="row.AUDIT_STATUS === '0'"
+            type="success"
+            size="mini"
+            @click="handleEdit(row)"
+          >{{ $t('finance.uploadEdit') }}</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <div class="ng-finance-pagination">
+      <pagination
+        v-show="total > 0"
+        :total="total"
+        :page.sync="listQuery.page"
+        :limit.sync="listQuery.pageSize"
+        :auto-scroll="false"
+        float="none"
+        popper-class="ng-refactor-popper"
+        @pagination="getList"
+      />
+    </div>
+
+    <ui-dialog
+      :visible.sync="dialog"
+      :title="$t('finance.recharge')"
+      width="560px"
+      @closed="$refs.form && $refs.form.clearValidate()"
+    >
+      <!-- 保留已挂载的 form,供旧 watch 的 nextTick 校验回调访问。 -->
+      <el-form
+        ref="form"
+        v-loading="loading"
+        class="ng-finance-form"
+        :model="form"
+        :rules="rules"
+        label-position="top"
+      >
+        <div class="ng-finance-form-grid">
+          <el-form-item :label="$t('finance.rechargeAmount')" prop="applyAmount" required>
+            <el-input-number
+              v-model.number="form.applyAmount"
+              :min="minAmount"
+              :max="maxAmount"
+              :step="10000"
+              clearable
+            />
+          </el-form-item>
+          <el-form-item :label="$t('finance.type')" prop="rechargeType">
+            <el-select
+              v-model="form.rechargeType"
+              clearable
+              filterable
+              popper-class="ng-refactor-popper"
+            >
+              <el-option v-for="(item, key) in typeList" :key="key" :label="item" :value="item" />
+            </el-select>
+          </el-form-item>
+          <el-form-item
+            class="ng-finance-full"
+            :label="$t('finance.paymentDate')"
+            prop="payDate"
+            required
+          >
+            <el-date-picker
+              v-model="form.payDate"
+              type="date"
+              value-format="yyyy-MM-dd"
+              placeholder="YYYY-MM-DD"
+              popper-class="ng-refactor-popper"
+            />
+          </el-form-item>
+          <el-form-item
+            v-if="!isCashType"
+            :label="$t('finance.paymentReference')"
+            prop="bankNo"
+            :required="!isCashType"
+          >
+            <el-input v-model="form.bankNo" type="text" clearable />
+          </el-form-item>
+          <el-form-item
+            v-if="!isCashType"
+            :label="$t('finance.bankName')"
+            prop="openBank"
+            :required="!isCashType"
+          >
+            <el-select
+              v-model="form.openBank"
+              clearable
+              filterable
+              popper-class="ng-refactor-popper"
+            >
+              <el-option
+                v-for="(item, key) in bankList"
+                :key="key"
+                :label="item.BANK_NAME"
+                :value="item.BANK_CODE"
+              />
+            </el-select>
+          </el-form-item>
+          <el-form-item
+            class="ng-finance-full"
+            :label="$t('finance.remark')"
+            prop="rechargeRemark"
+            required
+          >
+            <el-input
+              v-model="form.rechargeRemark"
+              type="textarea"
+              :rows="3"
+              :placeholder="$t('finance.enterRemarkTips')"
+            />
+          </el-form-item>
+        </div>
+      </el-form>
+      <template slot="footer">
+        <el-button size="small" :disabled="loading" @click="onCancel">{{
+          $t('table.cancel')
+        }}</el-button>
+        <el-button type="primary" size="small" :disabled="loading" @click="onSubmit">{{
+          $t('table.confirm')
+        }}</el-button>
+      </template>
+    </ui-dialog>
+
+    <ui-dialog :visible.sync="viewVoucher" :title="$t('finance.viewVoucher')" width="560px">
+      <img class="ng-finance-voucher" :src="voucher" alt="Recharge voucher">
+      <template slot="footer">
+        <el-button size="small" type="primary" @click="uploadAgain(auditId)">{{
+          $t('finance.uploadVoucher')
+        }}</el-button>
+        <el-button size="small" @click="viewVoucher = false">{{ $t('common.close') }}</el-button>
+      </template>
+    </ui-dialog>
+
+    <ui-dialog :visible.sync="uploadVoucher" :title="$t('finance.uploadVoucher')" width="560px">
+      <el-upload
+        class="ng-finance-uploader"
+        :action="uploadUrl"
+        :show-file-list="false"
+        :headers="uploaderHeaders"
+        :data="uploaderData"
+        :on-success="handleSuccess"
+        :before-upload="uploaderHandleBefore"
+        :on-error="handleError"
+      >
+        <img v-if="imageUrl" :src="imageUrl" alt="Selected recharge voucher">
+        <i v-else class="el-icon-plus" aria-label="Select recharge voucher" />
+      </el-upload>
+    </ui-dialog>
+  </div>
+</template>
+
+<script>
+import Recharge from '@/views/finance/recharge-list.vue'
+import UiDialog from '@/ui-refactor/components/UiDialog.vue'
+import { toThousandFilter } from '@/filters'
+
+export default {
+  name: 'RechargePanel',
+  components: { UiDialog },
+  filters: { toThousandFilter },
+  extends: Recharge
+}
+</script>
+
+<style lang="scss" scoped src="./finance-panel.scss" />

+ 236 - 0
src/ui-refactor/views/account/components/finance/TransferPanel.vue

@@ -0,0 +1,236 @@
+<template>
+  <div v-loading="listLoading" class="ng-finance-panel">
+    <el-form class="ng-finance-filters" label-position="top" @submit.native.prevent>
+      <div class="ng-finance-filter-grid">
+        <el-form-item :label="$t('common.startDate')">
+          <el-date-picker
+            v-model="listQuery.dateRange[0]"
+            type="date"
+            :placeholder="$t('common.startDate')"
+            value-format="yyyy-MM-dd"
+            clearable
+            popper-class="ng-refactor-popper"
+          />
+        </el-form-item>
+        <el-form-item :label="$t('common.endDate')">
+          <el-date-picker
+            v-model="listQuery.dateRange[1]"
+            type="date"
+            :placeholder="$t('common.endDate')"
+            value-format="yyyy-MM-dd"
+            clearable
+            popper-class="ng-refactor-popper"
+          />
+        </el-form-item>
+        <el-form-item :label="$t('finance.transferAccounts')">
+          <el-select
+            v-model="listQuery.outWallet"
+            :placeholder="$t('finance.transferOutAccountHint')"
+            clearable
+            popper-class="ng-refactor-popper"
+          >
+            <el-option
+              v-for="(item, key) in walletType"
+              :key="key"
+              :label="item.label"
+              :value="item.type"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item :label="$t('finance.transferIntoAccount')">
+          <el-select
+            v-model="listQuery.inWallet"
+            :placeholder="$t('finance.transferInAccountHint')"
+            clearable
+            popper-class="ng-refactor-popper"
+          >
+            <el-option
+              v-for="(item, key) in walletType"
+              :key="key"
+              :label="item.label"
+              :value="item.type"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item :label="$t('finance.receivingMemberCode')">
+          <el-input
+            v-model="listQuery.userName"
+            clearable
+            :placeholder="$t('finance.receiveMemberCodeHint')"
+          />
+        </el-form-item>
+        <el-form-item :label="$t('finance.receivingMemberName')">
+          <el-input
+            v-model="listQuery.realName"
+            clearable
+            :placeholder="$t('finance.receiveMemberNameHint')"
+            autocomplete="off"
+          />
+        </el-form-item>
+      </div>
+      <div class="ng-finance-filter-actions">
+        <el-button size="small" @click="handleFilterClear">{{ $t('common.reset') }}</el-button>
+        <el-button size="small" type="primary" @click="getList">{{
+          $t('common.select')
+        }}</el-button>
+      </div>
+    </el-form>
+
+    <el-table :data="list" style="width: 100%">
+      <el-table-column
+        min-width="170"
+        :label="$t('finance.transferAccounts')"
+        prop="OUT_WALLET_NAME"
+      />
+      <el-table-column
+        min-width="200"
+        :label="$t('finance.receivingMemberCode')"
+        prop="LAST_IN_USER_NAME"
+      />
+      <el-table-column
+        min-width="210"
+        :label="$t('finance.receivingMemberName')"
+        prop="LAST_IN_REAL_NAME"
+      />
+      <el-table-column
+        min-width="180"
+        :label="$t('finance.transferIntoAccount')"
+        prop="IN_WALLET_NAME"
+      />
+      <el-table-column min-width="150" :label="$t('finance.transferAmount')" prop="ORI_AMOUNT">
+        <template slot-scope="{ row }">{{
+          tool.formatPrice(row.ORI_AMOUNT) | toThousandFilter
+        }}</template>
+      </el-table-column>
+      <el-table-column
+        v-if="showFee"
+        min-width="150"
+        :label="$t('finance.serviceCharge')"
+        prop="FEE"
+      >
+        <template slot-scope="{ row }">{{ tool.formatPrice(row.FEE) | toThousandFilter }}</template>
+      </el-table-column>
+      <el-table-column
+        v-if="showFee"
+        min-width="190"
+        :label="$t('finance.actualAmountTransferredInto')"
+        prop="AMOUNT"
+      >
+        <template slot-scope="{ row }">{{
+          tool.formatPrice(row.AMOUNT) | toThousandFilter
+        }}</template>
+      </el-table-column>
+      <el-table-column min-width="180" :label="$t('finance.transferTime')" prop="CREATED_AT">
+        <template slot-scope="{ row }">{{ tool.convertToNigeriaTime(row.CREATED_AT) }}</template>
+      </el-table-column>
+      <el-table-column min-width="160" :label="$t('finance.remark')" prop="REMARK" />
+    </el-table>
+    <div class="ng-finance-toolbar ng-finance-toolbar--bottom">
+      <el-button size="small" type="primary" @click="handleApplyTransfer">{{
+        $t('finance.applyTransfer')
+      }}</el-button>
+    </div>
+    <div class="ng-finance-pagination">
+      <pagination
+        v-show="total > 0"
+        :total="total"
+        :page.sync="listQuery.page"
+        :limit.sync="listQuery.pageSize"
+        :auto-scroll="false"
+        float="none"
+        popper-class="ng-refactor-popper"
+        @pagination="getList"
+      />
+    </div>
+
+    <ui-dialog
+      v-loading="loading"
+      :visible.sync="dialog"
+      :title="$t('finance.applyTransfer')"
+      width="560px"
+      :close-on-click-modal="false"
+      :destroy-on-close="true"
+    >
+      <el-form
+        ref="form"
+        v-loading="loading"
+        class="ng-finance-form"
+        :model="form"
+        label-position="top"
+      >
+        <el-form-item
+          :label="$t('finance.transferType')"
+          prop="type"
+          :placeholder="$t('finance.selectTransferType')"
+          required
+        >
+          <el-select v-model="form.type" popper-class="ng-refactor-popper">
+            <el-option
+              v-for="(item, key) in transferConfig"
+              :key="key"
+              :label="item.outName + ' To ' + item.inName"
+              :value="item.typeId"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item :label="$t('finance.currentAvailableBonus')">
+          <el-input v-model="balance" readonly disabled />
+        </el-form-item>
+        <el-form-item :label="$t('finance.currentAvailableEcoin')">
+          <el-input v-model="cash" readonly disabled />
+        </el-form-item>
+        <el-form-item :label="$t('finance.memberCode')" prop="toUserName" required>
+          <el-input v-model="form.toUserName" type="text" @change="fetchMember" />
+        </el-form-item>
+        <el-form-item
+          v-show="form.toRealName !== null"
+          :label="$t('finance.memberName')"
+          prop="toRealName"
+        >
+          <el-input v-model="form.toRealName" type="text" disabled />
+        </el-form-item>
+        <el-form-item :label="$t('finance.transferAmount')" prop="amount" required>
+          <el-input
+            v-model.number="form.amount"
+            type="number"
+            :min="minAmount"
+            :max="maxAmount"
+            @change="chkReal"
+          />
+        </el-form-item>
+        <el-form-item v-show="fee > 0" :label="$t('finance.transferAmount')">
+          <el-tag type="info">{{ fee }}%</el-tag>
+        </el-form-item>
+        <el-form-item v-show="fee > 0" :label="$t('finance.actualAmountTransferredInto')">
+          <el-tag type="warning">{{ realAmount }}</el-tag>
+        </el-form-item>
+        <el-form-item :label="$t('finance.remark')" prop="remark">
+          <el-input v-model="form.remark" type="text" />
+        </el-form-item>
+      </el-form>
+      <template slot="footer">
+        <el-button size="small" :disabled="loading" @click="onCancel">{{
+          $t('table.cancel')
+        }}</el-button>
+        <el-button type="primary" size="small" :disabled="loading" @click="onSubmit('form')">{{
+          $t('table.confirm')
+        }}</el-button>
+      </template>
+    </ui-dialog>
+  </div>
+</template>
+
+<script>
+import Transfer from '@/views/finance/transfer-list.vue'
+import UiDialog from '@/ui-refactor/components/UiDialog.vue'
+import { toThousandFilter } from '@/filters'
+
+export default {
+  name: 'TransferPanel',
+  components: { UiDialog },
+  filters: { toThousandFilter },
+  extends: Transfer
+}
+</script>
+
+<style lang="scss" scoped src="./finance-panel.scss" />

+ 84 - 0
src/ui-refactor/views/account/components/finance/finance-panel.scss

@@ -0,0 +1,84 @@
+.ng-finance-panel {
+  min-width: 0;
+}
+
+.ng-finance-toolbar {
+  margin-bottom: 16px;
+}
+
+.ng-finance-toolbar--bottom {
+  margin: 16px 0 0;
+}
+
+.ng-finance-pagination {
+  max-width: 100%;
+  overflow-x: auto;
+}
+
+.ng-finance-form ::v-deep .el-input,
+.ng-finance-form ::v-deep .el-input-number,
+.ng-finance-form ::v-deep .el-select,
+.ng-finance-filters ::v-deep .el-input,
+.ng-finance-filters ::v-deep .el-select {
+  width: 100%;
+}
+
+.ng-finance-form-grid,
+.ng-finance-filter-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 0 20px;
+}
+
+.ng-finance-filter-grid {
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 0 16px;
+}
+
+.ng-finance-full {
+  grid-column: 1 / -1;
+}
+
+.ng-finance-filter-actions {
+  margin-bottom: 20px;
+  text-align: right;
+}
+
+.ng-finance-voucher {
+  display: block;
+  width: 100%;
+  height: auto;
+}
+
+.ng-finance-uploader ::v-deep .el-upload {
+  display: flex;
+  width: 178px;
+  max-width: 100%;
+  height: 178px;
+  align-items: center;
+  justify-content: center;
+  overflow: hidden;
+  border: 1px dashed #d9d9d9;
+  border-radius: 6px;
+  color: #8c939d;
+  font-size: 28px;
+}
+
+.ng-finance-uploader img {
+  width: 100%;
+  height: 100%;
+  object-fit: contain;
+}
+
+@media (max-width: 1023px) {
+  .ng-finance-filter-grid {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+}
+
+@media (max-width: 767px) {
+  .ng-finance-filter-grid,
+  .ng-finance-form-grid {
+    grid-template-columns: minmax(0, 1fr);
+  }
+}

+ 187 - 0
src/ui-refactor/views/account/components/network/SponsorNetwork.vue

@@ -0,0 +1,187 @@
+<template>
+  <el-card shadow="never" class="ng-account-card ng-account-network">
+    <section aria-labelledby="ng-account-network-title">
+      <div class="ng-account-network-head">
+        <div>
+          <p class="ng-account-eyebrow">Downline</p>
+          <h2 id="ng-account-network-title" class="ng-account-title">Sponsor Network</h2>
+          <p class="ng-account-note">
+            See who is in your organisation, their levels, and how deep your network spreads.
+          </p>
+        </div>
+        <el-radio-group
+          v-model="mode"
+          size="small"
+          class="ng-account-network-modes"
+          aria-label="Sponsor Network mode"
+        >
+          <el-radio-button label="normal">Sponsor Network</el-radio-button>
+          <el-radio-button label="ex">Sponsor Network Ex</el-radio-button>
+        </el-radio-group>
+      </div>
+
+      <dl
+        v-loading="statsLoading"
+        class="ng-account-network-stats"
+        aria-label="Network statistics"
+        :aria-busy="statsLoading"
+      >
+        <div v-for="stat in stats" :key="stat.label" class="ng-account-network-stat">
+          <dt class="ng-account-network-label">{{ stat.label }}</dt>
+          <dd class="ng-account-network-value">{{ stat.value }}</dd>
+        </div>
+      </dl>
+
+      <component
+        :is="mode === 'ex' ? 'NetworkTreeEx' : 'NetworkTree'"
+        :key="mode"
+        :extended="mode === 'ex'"
+      />
+    </section>
+  </el-card>
+</template>
+
+<script>
+import SponsorNetwork from '@/views/atlas/sponsor-network.vue'
+import SponsorNetworkEx from '@/views/atlas/sponsor-network-ex.vue'
+import NetworkTree from './SponsorNetworkTree.vue'
+import { fetchNetworkStats } from '@/api/user'
+import { toThousandFilter } from '@/filters'
+
+export default {
+  name: 'SponsorNetwork',
+  // 共用展示模板,两个模式分别继承对应的旧组件。
+  components: {
+    NetworkTree: { ...NetworkTree, extends: SponsorNetwork },
+    NetworkTreeEx: { ...NetworkTree, name: 'SponsorNetworkTreeEx', extends: SponsorNetworkEx }
+  },
+  data() {
+    return {
+      mode: 'normal',
+      networkStats: null,
+      statsLoading: false,
+      statsRequestId: 0
+    }
+  },
+  computed: {
+    stats() {
+      return [
+        { label: 'Downline members', field: 'DOWNLINE_COUNT' },
+        { label: 'Direct', field: 'DIRECT_DOWNLINE_COUNT' },
+        { label: 'Levels', field: 'MAX_DOWNLINE_DEEP' },
+        { label: 'Downline PCV', field: 'DOWNLINE_PCV' }
+      ].map(stat => {
+        const value = this.networkStats && this.networkStats[stat.field]
+        return { label: stat.label, value: value == null ? '—' : toThousandFilter(value) }
+      })
+    }
+  },
+  watch: {
+    mode: {
+      immediate: true,
+      handler: 'loadStats'
+    }
+  },
+  beforeDestroy() {
+    this.statsRequestId++
+  },
+  methods: {
+    async loadStats() {
+      const requestId = ++this.statsRequestId
+      this.networkStats = null
+      this.statsLoading = true
+      try {
+        const response = await fetchNetworkStats()
+        // 快速切换时,只展示最后一次请求的结果。
+        if (requestId === this.statsRequestId) this.networkStats = response.data
+      } catch (error) {
+        if (requestId === this.statsRequestId) this.networkStats = null
+      } finally {
+        if (requestId === this.statsRequestId) this.statsLoading = false
+      }
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.ng-account-network ::v-deep > .el-card__body {
+  padding: 0 !important;
+}
+
+.ng-account-network-head {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  flex-wrap: wrap;
+  gap: 20px;
+  padding: 20px 24px;
+  background: #e8f4f8;
+}
+
+.ng-account-network-modes {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+.ng-account-network-modes ::v-deep .el-radio-button__inner {
+  padding: 12px 16px;
+  border: 1px solid #dfe4ed;
+  border-radius: 24px;
+  color: #202259;
+  box-shadow: none;
+}
+
+.ng-account-network-modes ::v-deep .el-radio-button__orig-radio:checked + .el-radio-button__inner {
+  color: #fff;
+  background: #202259;
+  border-color: #202259;
+  box-shadow: none;
+}
+
+.ng-account-network-stats {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 16px;
+  margin: 20px 24px;
+}
+
+.ng-account-network-stat {
+  min-width: 0;
+  padding: 12px 16px;
+  background: #f7f8fa;
+  border-radius: 8px;
+}
+
+.ng-account-network-label {
+  color: #606266;
+  font-size: 12px;
+  font-weight: 700;
+  line-height: 1.5;
+  text-transform: uppercase;
+}
+
+.ng-account-network-value {
+  margin: 4px 0 0;
+  color: #202259;
+  font-size: 20px;
+  font-weight: 700;
+}
+
+@media (max-width: 767px) {
+  .ng-account-network-head {
+    padding: 20px 16px;
+  }
+
+  .ng-account-network-stats {
+    margin: 20px 16px;
+  }
+}
+
+@media (max-width: 575px) {
+  .ng-account-network-stats {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+}
+</style>

+ 290 - 0
src/ui-refactor/views/account/components/network/SponsorNetworkTree.vue

@@ -0,0 +1,290 @@
+<template>
+  <div v-loading="loading" class="ng-network-view">
+    <el-form :inline="true" label-position="left" @submit.native.prevent="getMainData()">
+      <el-form-item label="Top Member" :for="'ng-network-member-' + extended">
+        <el-input
+          :id="'ng-network-member-' + extended"
+          v-model="mainUserName"
+          placeholder="Member ID"
+        />
+      </el-form-item>
+      <el-form-item label="Spread Depth" :for="'ng-network-depth-' + extended">
+        <el-input-number
+          :id="'ng-network-depth-' + extended"
+          v-model="expandDeep"
+          controls-position="right"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button
+          type="primary"
+          native-type="submit"
+          class="ng-network-confirm"
+        >Confirm</el-button>
+      </el-form-item>
+    </el-form>
+
+    <el-tree
+      v-if="!extended || treeChartShow"
+      ref="tree"
+      class="ng-network-tree"
+      :data="networkNodes"
+      :props="{ label: 'USER_NAME', children: 'children' }"
+      node-key="USER_ID"
+      :indent="24"
+      default-expand-all
+      :expand-on-click-node="false"
+      :aria-label="extended ? 'Sponsor Network Ex' : 'Sponsor Network'"
+      empty-text="No network records to display."
+      @node-click="toggleNode"
+    >
+      <div
+        slot-scope="{ node, data }"
+        class="ng-network-member"
+        :class="{ 'ng-network-member--root': node.level === 1 }"
+      >
+        <span class="ng-network-avatar" aria-hidden="true">{{ initials(data) }}</span>
+        <div class="ng-network-copy">
+          <strong>{{ data.REAL_NAME || 'Member ' + data.USER_NAME }}</strong>
+          <p>
+            {{ data.USER_NAME }} · {{ data.DEC_LV_NAME }} · Highest: {{ data.EMP_LV_NAME }} ·
+            Period: {{ data.PERIOD_AT }}
+          </p>
+        </div>
+        <el-button
+          v-if="!data.leaf"
+          class="ng-network-toggle"
+          :aria-label="
+            (node.expanded && node.childNodes.length ? 'Collapse ' : 'Expand ') + data.USER_NAME
+          "
+          :aria-expanded="String(node.expanded && node.childNodes.length > 0)"
+          @click.stop="toggleNode(data, node)"
+        >
+          <i v-if="data.displayNone === ''" class="el-icon-loading" aria-label="Loading" />
+          <span v-else aria-hidden="true">{{
+            node.expanded && node.childNodes.length ? '−' : '+'
+          }}</span>
+        </el-button>
+      </div>
+    </el-tree>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'SponsorNetworkTree',
+  props: {
+    extended: Boolean
+  },
+  computed: {
+    networkNodes() {
+      if (Array.isArray(this.treeData)) return this.treeData
+      return this.treeData && this.treeData.USER_ID != null ? [this.treeData] : []
+    }
+  },
+  methods: {
+    initials(data) {
+      return (data.REAL_NAME || data.USER_NAME || '')
+        .trim()
+        .split(/\s+/)
+        .map(word => word[0])
+        .slice(0, 2)
+        .join('')
+        .toUpperCase()
+    },
+    toggleNode(data, node) {
+      if (node.expanded && node.childNodes.length) {
+        node.collapse()
+        return
+      }
+      if (this.extended) {
+        this.getNodeData({ data, name: data.USER_NAME, collapsed: false })
+      } else {
+        this.getChildData(data, node)
+      }
+      node.expand()
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.ng-network-view {
+  min-height: 180px;
+  padding: 0 24px 32px;
+}
+
+.ng-network-form {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: flex-end;
+  gap: 0 16px;
+}
+
+.ng-network-form .el-form-item {
+  margin-right: 0;
+  margin-bottom: 24px;
+}
+
+.ng-network-form ::v-deep .el-form-item__label {
+  padding-bottom: 8px;
+  color: #606266;
+  font-size: 12px;
+  font-weight: 700;
+  line-height: 1.5;
+  text-transform: uppercase;
+  white-space: nowrap;
+}
+
+.ng-network-form ::v-deep .el-input__inner {
+  height: 48px;
+  border-radius: 8px;
+  line-height: 48px;
+}
+
+.ng-network-confirm {
+  //min-height: 44px;
+  //padding: 12px 16px;
+  //border-radius: 8px;
+  border-color: #202259;
+  background: #202259;
+  //font-size: 13px;
+  //font-weight: 700;
+}
+
+.ng-network-search {
+  width: 230px;
+  max-width: 100%;
+}
+
+.ng-network-search ::v-deep .el-form-item__content,
+.ng-network-depth ::v-deep .el-form-item__content {
+  display: block;
+}
+
+.ng-network-depth {
+  width: 96px;
+}
+
+.ng-network-tree {
+  overflow-x: auto;
+}
+
+.ng-network-tree ::v-deep .el-tree-node__content {
+  height: auto;
+  min-height: 60px;
+  line-height: normal;
+  background: transparent;
+}
+
+.ng-network-tree ::v-deep .el-tree-node__expand-icon {
+  display: none;
+}
+
+.ng-network-tree ::v-deep .el-tree-node__children {
+  padding: 0;
+}
+
+.ng-network-tree ::v-deep .el-tree-node::before {
+  content: none;
+}
+
+.ng-network-member {
+  position: relative;
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  width: max-content;
+  max-width: 100%;
+  min-width: 0;
+  margin: 0 0 4px 24px;
+  padding: 8px 0 8px 8px;
+  color: #202259;
+  white-space: normal;
+}
+
+.ng-network-member::before {
+  position: absolute;
+  top: -4px;
+  left: -16px;
+  width: 20px;
+  height: 36px;
+  border-bottom: 1px solid #d0dae5;
+  border-left: 1px solid #d0dae5;
+  border-radius: 0 0 0 10px;
+  content: '';
+}
+
+.ng-network-member--root {
+  margin-left: 0;
+  padding: 8px;
+  border: 1px solid #dfe4ed;
+  border-radius: 8px;
+  background: #f7f8fa;
+}
+
+.ng-network-member--root::before {
+  content: none;
+}
+
+.ng-network-avatar {
+  display: flex;
+  flex: 0 0 32px;
+  align-items: center;
+  justify-content: center;
+  height: 32px;
+  border-radius: 50%;
+  color: #fff;
+  background: #20aacc;
+  font-size: 13px;
+  font-weight: 700;
+}
+
+.ng-network-member--root .ng-network-avatar {
+  background: #202259;
+}
+
+.ng-network-copy {
+  min-width: 0;
+  overflow-wrap: anywhere;
+}
+
+.ng-network-copy strong {
+  font-size: 14px;
+  line-height: 1.5;
+}
+
+.ng-network-copy p {
+  margin: 4px 0 0;
+  color: #606266;
+  font-size: 12px;
+  line-height: 1.6;
+}
+
+.ng-network-toggle {
+  flex-shrink: 0;
+  align-self: flex-start;
+  min-width: 28px;
+  min-height: 28px;
+  padding: 6px;
+  border-radius: 8px;
+  color: #606266;
+}
+
+.ng-network-toggle:focus-visible {
+  outline: 2px solid #202259;
+  outline-offset: 2px;
+}
+
+@media (max-width: 767px) {
+  .ng-network-view {
+    padding: 0 16px 24px;
+  }
+}
+
+@media (max-width: 575px) {
+  .ng-network-search {
+    width: 100%;
+  }
+}
+</style>

+ 254 - 0
src/ui-refactor/views/account/components/order/AccountOrderCards.vue

@@ -0,0 +1,254 @@
+<template>
+  <div v-loading="listLoading" class="ng-order-list" :aria-busy="listLoading">
+    <p class="ng-account-note" aria-live="polite">{{ total }} orders in total</p>
+    <el-card v-if="!listLoading && !tableData.length" class="ng-order-empty" role="status">
+      <i class="el-icon-box" aria-hidden="true" />
+      <p>
+        {{
+          memberOrders
+            ? 'No member orders yet. Use Member Repeat Purchase or Auto-Maintenance to place orders for your downline.'
+            : 'No orders yet.'
+        }}
+      </p>
+      <el-button
+        type="primary"
+        size="small"
+        native-type="button"
+        @click="
+          $router.push(memberOrders ? '/dashboard/member-repurchase' : '/dashboard/repeat-purchase')
+        "
+      >{{ memberOrders ? 'Member Repeat Purchase' : 'Repeat Purchase' }}</el-button>
+    </el-card>
+
+    <el-card
+      v-for="(order, index) in tableData"
+      :key="order.SN"
+      shadow="never"
+      class="ng-account-card ng-order-card"
+    >
+      <div class="ng-order-header">
+        <div class="ng-order-heading">
+          <h3 class="ng-order-number">{{ order.SN }}</h3>
+          <span>{{ order.ORDER_TYPE }}</span>
+          <el-tag :type="order.STATUS | statusFilter" size="small">{{ order.STATUS }}</el-tag>
+        </div>
+        <el-button size="small" native-type="button" @click="handleDownload(order.SN)">{{
+          $t('shop.download')
+        }}</el-button>
+      </div>
+
+      <dl class="ng-order-details">
+        <div v-for="field in detailFields" :key="field.prop" class="ng-order-detail">
+          <dt>{{ $t(field.label) }}</dt>
+          <dd>{{ order[field.prop] }}</dd>
+        </div>
+      </dl>
+
+      <el-table
+        :data="order.hasChildren"
+        fit
+        show-summary
+        :summary-method="() => orderSummaries[index].cells"
+        class="ng-order-products"
+      >
+        <el-table-column prop="COVER" width="100" align="center">
+          <template slot-scope="{ row }">
+            <el-image
+              v-if="row.COVER"
+              :src="tool.getArImage(row.COVER, '/files/')"
+              :alt="row.GOODS_TITLE"
+              fit="contain"
+              class="ng-order-product-image"
+            >
+              <span slot="error">—</span>
+            </el-image>
+            <span v-else>—</span>
+          </template>
+        </el-table-column>
+        <el-table-column :label="$t('shop.productCode')" prop="SKU_CODE" min-width="120" />
+        <el-table-column :label="$t('shop.productName')" prop="GOODS_TITLE" min-width="180" />
+        <el-table-column :label="$t('shop.qty')" prop="BUY_NUMS" min-width="80" align="right" />
+        <el-table-column :label="$t('shop.productPrice')" min-width="130" align="right">
+          <template slot-scope="{ row }">{{ row.REAL_PRICE | toThousandFilter }}</template>
+        </el-table-column>
+        <el-table-column :label="$t('shop.totalPrice')" min-width="130" align="right">
+          <template slot-scope="{ row }">{{ row | priceAmountFilter | toThousandFilter }}</template>
+        </el-table-column>
+        <el-table-column :label="$t('shop.totalBV')" min-width="100" align="right">
+          <template slot-scope="{ row }">{{ row | bvFilter | toThousandFilter }}</template>
+        </el-table-column>
+        <el-table-column :label="$t('shop.taxRate')" min-width="100" align="right">
+          <template slot-scope="{ row }">{{ row.TAX_RATE / 100 }}</template>
+        </el-table-column>
+        <el-table-column :label="$t('shop.totalTaxAmount')" min-width="130" align="right">
+          <template slot-scope="{ row }">{{ row | taxAmountFilter | toThousandFilter }}</template>
+        </el-table-column>
+      </el-table>
+      <div class="ng-order-total">
+        <strong>TOTAL</strong>
+        <strong>{{ orderSummaries[index].total }}</strong>
+      </div>
+    </el-card>
+
+    <!--    <div class="ng-order-pagination">-->
+    <!--      <pagination v-show="total > 0" :total="total" :page.sync="listQuery.page" :limit.sync="listQuery.pageSize" float="none" @pagination="getList" />-->
+    <!--    </div>-->
+  </div>
+</template>
+
+<script>
+import { amountFilter, toThousandFilter } from '@/filters'
+
+export default {
+  name: 'AccountOrderCards',
+  filters: { toThousandFilter },
+  props: {
+    memberOrders: { type: Boolean, default: false }
+  },
+  computed: {
+    orderSummaries() {
+      const { priceAmountFilter, bvFilter, taxAmountFilter } = this.$options.filters
+      const formatMoney = cents => (Number.isFinite(cents) ? amountFilter(cents / 100) : '—')
+      const formatNumber = value => (Number.isFinite(value) ? toThousandFilter(value) : '—')
+      return this.tableData.map(order => {
+        // 金额按旧明细过滤器逐行取值,以分累加,避免浮点误差。
+        const [quantity, price, bv, tax] = (order.hasChildren || []).reduce(
+          (sums, row) => [
+            sums[0] + Number(row.BUY_NUMS),
+            sums[1] + Math.round(Number(priceAmountFilter(row)) * 100),
+            sums[2] + Number(bvFilter(row)),
+            sums[3] + Math.round(Number(taxAmountFilter(row)) * 100)
+          ],
+          [0, 0, 0, 0]
+        )
+        return {
+          cells: [
+            'Subtotal',
+            '',
+            '',
+            formatNumber(quantity),
+            '',
+            formatMoney(price),
+            formatNumber(bv),
+            '',
+            formatMoney(tax)
+          ],
+          total: formatMoney(price + tax)
+        }
+      })
+    },
+    detailFields() {
+      return [
+        { prop: 'USER_NAME', label: 'shop.memberCode' },
+        { prop: 'REAL_NAME', label: 'shop.memberName' },
+        { prop: 'CONSIGNEE', label: 'shop.recipientName' },
+        { prop: 'MOBILE', label: 'shop.phoneNumber' }
+      ]
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.ng-order-list {
+  min-width: 0;
+  min-height: 120px;
+}
+
+.ng-order-card {
+  margin-top: 16px;
+}
+
+.ng-order-empty {
+  padding: 64px 16px;
+  text-align: center;
+}
+
+.ng-order-empty > i {
+  color: #d3dae2;
+  font-size: 40px;
+}
+
+.ng-order-empty > p {
+  margin: 16px 0;
+  color: #202259;
+  font-size: 16px;
+  font-weight: 600;
+  line-height: 1.6;
+}
+
+.ng-order-header,
+.ng-order-heading {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 12px;
+}
+
+.ng-order-header {
+  justify-content: space-between;
+}
+
+.ng-order-number {
+  margin: 0;
+  color: #202259;
+  font-size: 16px;
+  overflow-wrap: anywhere;
+}
+
+.ng-order-heading {
+  min-width: 0;
+}
+
+.ng-order-details {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px 24px;
+  margin: 20px 0;
+  font-size: 13px;
+  line-height: 1.7;
+}
+
+.ng-order-detail {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px 8px;
+  min-width: 0;
+  max-width: 100%;
+  overflow-wrap: anywhere;
+}
+
+.ng-order-detail dd {
+  margin: 0;
+  color: #606266;
+}
+
+.ng-order-products {
+  width: 100%;
+}
+
+.ng-order-product-image {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 56px;
+  height: 56px;
+  vertical-align: middle;
+}
+
+.ng-order-total {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: space-between;
+  gap: 8px;
+  padding: 16px 10px;
+  border-bottom: 1px solid #ebeef5;
+  color: #202259;
+  font-size: 14px;
+  overflow-wrap: anywhere;
+}
+
+.ng-order-pagination {
+  overflow-x: auto;
+}
+</style>

+ 80 - 0
src/ui-refactor/views/account/components/profile/AddressForm.vue

@@ -0,0 +1,80 @@
+<template>
+  <section v-loading="loading" class="ng-profile-address-form" :aria-label="title">
+    <h3 class="ng-profile-subtitle">{{ title }}</h3>
+    <el-form class="ng-profile-form" :model="form" label-position="top" @submit.native.prevent>
+      <div class="ng-profile-grid">
+        <el-form-item label="Recipient" prop="consignee" required :for="idPrefix + '-recipient'">
+          <el-input
+            :id="idPrefix + '-recipient'"
+            ref="recipient"
+            v-model="form.consignee"
+            type="text"
+          />
+        </el-form-item>
+        <el-form-item label="Phone" prop="mobile" required :for="idPrefix + '-phone'">
+          <el-input :id="idPrefix + '-phone'" v-model="form.mobile" />
+        </el-form-item>
+        <el-form-item label="Region" prop="province" required :for="idPrefix + '-region'">
+          <el-select
+            :id="idPrefix + '-region'"
+            v-model="form.province"
+            popper-class="ng-refactor-popper"
+          >
+            <el-option
+              v-for="(item, index) in regions"
+              :key="index"
+              :label="item.label"
+              :value="item.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="City" prop="cityName" required :for="idPrefix + '-city'">
+          <el-input :id="idPrefix + '-city'" v-model="form.cityName" />
+        </el-form-item>
+        <el-form-item
+          class="ng-profile-full"
+          label="Street address"
+          prop="address"
+          required
+          :for="idPrefix + '-street'"
+        >
+          <el-input :id="idPrefix + '-street'" v-model="form.address" />
+        </el-form-item>
+        <el-form-item label="District" prop="lgaName" required :for="idPrefix + '-district'">
+          <el-input :id="idPrefix + '-district'" v-model="form.lgaName" />
+        </el-form-item>
+        <el-form-item class="ng-profile-full" prop="isDefault">
+          <el-switch
+            v-model="form.isDefault"
+            active-value="1"
+            inactive-value="0"
+            aria-label="Set as default"
+          />
+          <span class="ng-profile-switch-label">Set as default</span>
+        </el-form-item>
+      </div>
+      <div class="ng-profile-actions">
+        <el-button :disabled="loading" @click="$emit('cancel')">Cancel</el-button>
+        <el-button type="primary" :disabled="loading" @click="$emit('confirm')">Confirm</el-button>
+      </div>
+    </el-form>
+  </section>
+</template>
+
+<script>
+export default {
+  name: 'AddressForm',
+  props: {
+    title: { type: String, required: true },
+    idPrefix: { type: String, required: true },
+    form: { type: Object, required: true },
+    regions: { type: Array, default: () => [] },
+    loading: { type: Boolean, default: false }
+  },
+  mounted() {
+    this.$refs.recipient.focus()
+  }
+}
+</script>
+
+<style lang="scss" scoped src="./profile.scss" />

+ 100 - 0
src/ui-refactor/views/account/components/profile/DeliveryAddresses.vue

@@ -0,0 +1,100 @@
+<template>
+  <ui-section-card v-loading="listLoading" class="ng-profile-addresses">
+    <template slot="header">
+      <div class="ng-profile-head">
+        <h2 class="ng-profile-title">Delivery addresses</h2>
+        <el-button
+          ref="addAddress"
+          :disabled="dialog || dialogEdit"
+          @click=";(dialog = true), getRegion()"
+        >Add new address</el-button>
+      </div>
+    </template>
+    <address-form
+      v-if="dialog"
+      title="Add new address"
+      id-prefix="ng-profile-address-create"
+      :form="dataForm"
+      :regions="regionData"
+      :loading="dialogLoading"
+      @cancel="dialog = false"
+      @confirm="createAddress"
+    />
+    <address-form
+      v-if="dialogEdit"
+      title="Edit address"
+      id-prefix="ng-profile-address-edit"
+      :form="editForm"
+      :regions="regionData"
+      :loading="editLoading"
+      @cancel="dialogEdit = false"
+      @confirm="updateAddress"
+    />
+    <el-empty v-if="!list.length" description="No delivery addresses to display." />
+    <article v-for="address in list" :key="address.ID" class="ng-profile-address">
+      <div class="ng-profile-address-copy">
+        <h3 class="ng-profile-address-name">
+          {{ address.CONSIGNEE }} · {{ address.MOBILE }}
+          <el-tag v-if="address.IS_DEFAULT === '1'" type="success">DEFAULT</el-tag>
+        </h3>
+        <p>{{ address.ADDRESS }}</p>
+        <p>
+          {{ address.CITY_NAME }}<span v-if="address.CITY_NAME && address.LGA_NAME">, </span>{{ address.LGA_NAME }}
+        </p>
+        <p>{{ address.PROVINCE_NAME }}</p>
+      </div>
+      <div class="ng-profile-address-actions">
+        <el-button
+          :disabled="dialog || dialogEdit"
+          @click="handleEdit(address), getRegion()"
+        >Edit</el-button>
+        <el-button
+          v-if="address.IS_DEFAULT !== '1'"
+          type="info"
+          :aria-label="'Default address: ' + address.CONSIGNEE"
+          @click="changeDefault($event, address)"
+        >Set as default</el-button>
+        <el-button
+          type="danger"
+          :disabled="dialog || dialogEdit"
+          @click="handleDelete(address.ID)"
+        >Delete</el-button>
+      </div>
+    </article>
+    <div class="ng-profile-pagination">
+      <pagination
+        v-show="total > 0"
+        :total="total"
+        :page.sync="listQuery.page"
+        :limit.sync="listQuery.pageSize"
+        :auto-scroll="false"
+        float="none"
+        popper-class="ng-refactor-popper"
+        @pagination="getList"
+      />
+    </div>
+  </ui-section-card>
+</template>
+
+<script>
+import ShippingAddress from '@/views/config/shipping-address-list.vue'
+import UiSectionCard from '@/components/ui/UiSectionCard.vue'
+import AddressForm from './AddressForm.vue'
+
+export default {
+  name: 'DeliveryAddresses',
+  components: { UiSectionCard, AddressForm },
+  extends: ShippingAddress,
+  watch: {
+    dialog: 'focusAddButton',
+    dialogEdit: 'focusAddButton'
+  },
+  methods: {
+    focusAddButton(open) {
+      if (!open) this.$nextTick(() => this.$refs.addAddress?.$el.focus())
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped src="./profile.scss" />

+ 20 - 0
src/ui-refactor/views/account/components/profile/LoginPasswordPanel.vue

@@ -0,0 +1,20 @@
+<template>
+  <password-card
+    title="Login password"
+    :password="password"
+    new-field="password"
+    :loading="loading"
+    @save="submit"
+  />
+</template>
+
+<script>
+import LoginPassword from '@/views/profile/components/LoginPassword.vue'
+import PasswordCard from './PasswordCard.vue'
+
+export default {
+  name: 'LoginPasswordPanel',
+  components: { PasswordCard },
+  extends: LoginPassword
+}
+</script>

+ 64 - 0
src/ui-refactor/views/account/components/profile/PasswordCard.vue

@@ -0,0 +1,64 @@
+<template>
+  <ui-section-card :title="title">
+    <el-form
+      v-loading="loading"
+      class="ng-profile-form"
+      label-position="top"
+      @submit.native.prevent
+    >
+      <el-form-item
+        v-for="field in fields"
+        :key="field.key"
+        :label="field.label"
+        :for="'ng-profile-' + newField + '-' + field.key"
+      >
+        <el-input
+          :id="'ng-profile-' + newField + '-' + field.key"
+          v-model.trim="password[field.key]"
+          type="password"
+          :placeholder="field.placeholder"
+          autocomplete="off"
+          show-password
+        />
+      </el-form-item>
+      <el-form-item class="ng-profile-actions">
+        <el-button type="primary" :disabled="loading" @click="$emit('save')">Save</el-button>
+      </el-form-item>
+    </el-form>
+  </ui-section-card>
+</template>
+
+<script>
+import UiSectionCard from '@/components/ui/UiSectionCard.vue'
+
+export default {
+  name: 'PasswordCard',
+  components: { UiSectionCard },
+  props: {
+    title: { type: String, required: true },
+    password: { type: Object, required: true },
+    newField: { type: String, required: true },
+    loading: { type: Boolean, default: false }
+  },
+  computed: {
+    fields() {
+      return [
+        {
+          key: 'oldPassword',
+          label: this.newField === 'payPassword' ? 'Old password' : 'Current password',
+          placeholder:
+            this.newField === 'payPassword' ? 'Enter old password' : 'Enter current password'
+        },
+        { key: this.newField, label: 'New password', placeholder: 'Enter new password' },
+        {
+          key: 'verifyPassword',
+          label: 'Confirm new password',
+          placeholder: 'Confirm new password'
+        }
+      ]
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped src="./profile.scss" />

+ 20 - 0
src/ui-refactor/views/account/components/profile/PaymentPasswordPanel.vue

@@ -0,0 +1,20 @@
+<template>
+  <password-card
+    title="Payment password"
+    :password="password"
+    new-field="payPassword"
+    :loading="loading"
+    @save="submit"
+  />
+</template>
+
+<script>
+import PaymentPassword from '@/views/profile/components/PaymentPassword.vue'
+import PasswordCard from './PasswordCard.vue'
+
+export default {
+  name: 'PaymentPasswordPanel',
+  components: { PasswordCard },
+  extends: PaymentPassword
+}
+</script>

+ 65 - 0
src/ui-refactor/views/account/components/profile/PersonalInformation.vue

@@ -0,0 +1,65 @@
+<template>
+  <ui-section-card class="ng-profile-personal">
+    <template slot="header">
+      <div class="ng-profile-head">
+        <h2 class="ng-profile-title">Personal information</h2>
+        <el-button v-if="!editing" native-type="button" @click="startEditing">Edit</el-button>
+      </div>
+    </template>
+    <el-form
+      v-loading="loading"
+      class="ng-profile-form"
+      label-position="top"
+      @submit.native.prevent
+    >
+      <div class="ng-profile-grid">
+        <el-form-item label="Member ID" for="ng-profile-member-id">
+          <el-input id="ng-profile-member-id" v-model="user.name" readonly />
+          <p class="ng-profile-note">This ID is fixed and cannot be changed.</p>
+        </el-form-item>
+        <el-form-item label="Member name" for="ng-profile-member-name">
+          <el-input id="ng-profile-member-name" v-model="user.realName" readonly />
+        </el-form-item>
+        <el-form-item label="Country" for="ng-profile-country">
+          <el-input id="ng-profile-country" v-model="countryName" readonly />
+        </el-form-item>
+        <el-form-item label="Email" for="ng-profile-email">
+          <el-input id="ng-profile-email" v-model.trim="user.email" :readonly="!editing" />
+        </el-form-item>
+        <el-form-item label="Phone" for="ng-profile-phone">
+          <el-input id="ng-profile-phone" v-model.trim="user.mobile" readonly />
+        </el-form-item>
+      </div>
+      <div v-if="editing" class="ng-profile-actions">
+        <el-button :disabled="loading" @click="cancelEditing">Cancel</el-button>
+        <el-button type="primary" :disabled="loading" @click="submit">Save changes</el-button>
+      </div>
+    </el-form>
+  </ui-section-card>
+</template>
+
+<script>
+import Account from '@/views/profile/components/Account.vue'
+import UiSectionCard from '@/components/ui/UiSectionCard.vue'
+
+export default {
+  name: 'PersonalInformation',
+  components: { UiSectionCard },
+  extends: Account,
+  data() {
+    return { editing: false, originalEmail: '' }
+  },
+  methods: {
+    startEditing() {
+      this.originalEmail = this.user.email
+      this.editing = true
+    },
+    cancelEditing() {
+      this.user.email = this.originalEmail
+      this.editing = false
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped src="./profile.scss" />

+ 23 - 0
src/ui-refactor/views/account/components/profile/README.md

@@ -0,0 +1,23 @@
+# Profile UI
+
+`AccountProfile.vue` 仅组装三个区域,继续在账户首页现有 Profile lazy 页签展示,不改路由或旧页面。身份字段沿用旧 Profile 的 `usersInfo.userName()` / `userEmail()` / `baseData().MOBILE` / `REAL_NAME`,不重复迁入会员头像、等级和时钟。
+
+| 组件 | 复用范围 |
+| --- | --- |
+| PersonalInformation | 继承旧 profile/components/Account 脚本,增加本地 Edit / Cancel 展示状态 |
+| DeliveryAddresses | 继承旧 config/shipping-address-list 脚本,将弹窗改为内联表单与地址卡片 |
+| AddressForm | 新增/编辑共用展示,直接绑定原 dataForm / editForm,传出 cancel / confirm |
+| LoginPasswordPanel / PaymentPasswordPanel | 分别继承原密码脚本,调用原 submit |
+| PasswordCard | 两类密码共用三字段 UI,按 password / payPassword 绑定新密码字段 |
+
+直接导入旧 Vue 组件并继承其业务逻辑,使用新模板展示;旧组件样式也会随导入加载。原提交、查询、校验、缓存与成功/失败方法均不覆盖;只添加 UI 状态和焦点处理。
+
+个人资料按旧权限仅允许修改邮箱。姓名、会员编号、国家、手机号保持只读,不新增截图中的手机号/姓名修改、区号下拉或国家编辑。国家继续由旧 countries getter、settings/getCountries 与 COUNTRY_ID 查找。Save changes 使用原 updateInfo payload,成功后仍更新邮箱缓存并 reload;Cancel 仅恢复本次 UI 编辑前的邮箱。
+
+地址保留原分页、地区查询、字段映射、字符串默认值、创建/编辑请求、默认地址更新及 Delete 确认。手机号直接使用原 mobile / MOBILE,不新增拆分、拼接或自动预填。Cancel 保留原草稿,不新增前端提交校验。编辑期间暂停卡片其他操作,等同原弹窗对背后操作的阻挡;loading 继续阻挡提交。新增和编辑展开时聚焦收件人,收起后聚焦 Add new address。分页只在自身区域横向滚动,不滚动背后页面。
+
+密码保留原 data 与字段对象,包括原初始化写法;使用原 trim 绑定和 show-password,不新增长度、必填或两次密码一致规则。支付密码成功提示与失败收尾保持;登录密码成功后仍调用 usersInfo.logout()。
+
+验证(2026-10-14):14 项定向测试加载真实 Vue 2 / Element UI,比较旧方法引用,覆盖身份/国家来源、只读范围、邮箱取消/提交/缓存刷新、密码字段和退出、地址列表/分页/草稿/创建/编辑/默认/删除确认/失败。定向 lint 和完整应用临时目录生产构建通过;全量 55 项中 54 项通过,既有余额 Reset CSS 定位失败未修改。
+
+浏览器使用临时独立身份和地址样例,国家查找所需的旧 localStorage 输入在临时脚本中隔离提供,不写真实认证缓存。覆盖 320 / 375 / 768 / 1024 / 1440px、375×480 短视口、地区选择、编辑回填、邮箱取消、展开/收起焦点和空密码框;无页面横向溢出或浏览器错误。没有保存真实资料、变更默认地址、删除真实地址或提交密码;真实账号与后端联调未执行。旧页面、API、Store、路由、主题和环境/lockfile 无本轮修改。

+ 157 - 0
src/ui-refactor/views/account/components/profile/profile.scss

@@ -0,0 +1,157 @@
+.ng-profile-head {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.ng-profile-title,
+.ng-profile-subtitle {
+  margin: 0;
+  color: #202259;
+  font-size: 16px;
+  line-height: 1.5;
+}
+
+.ng-profile-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 0 24px;
+}
+
+.ng-profile-full {
+  grid-column: 1 / -1;
+}
+
+.ng-profile-form ::v-deep .el-select {
+  width: 100%;
+}
+
+.ng-profile-personal ::v-deep input[readonly] {
+  background: #f4f6f8;
+}
+
+.ng-profile-actions {
+  margin-bottom: 0;
+  padding-bottom: 20px;
+  text-align: right;
+}
+
+.ng-profile-note {
+  margin: 8px 0 0;
+  color: #606266;
+  font-size: 12px;
+  line-height: 1.6;
+}
+
+.ng-profile-address-form,
+.ng-profile-address {
+  margin-bottom: 16px;
+  padding: 20px;
+  border: 1px solid #dfe4ed;
+  border-radius: 8px;
+}
+
+.ng-profile-address-form {
+  background: #f7f8fa;
+}
+
+.ng-profile-address-form .ng-profile-subtitle {
+  margin-bottom: 20px;
+}
+
+.ng-profile-address {
+  display: flex;
+  justify-content: space-between;
+  gap: 24px;
+}
+
+.ng-profile-address-copy {
+  min-width: 0;
+  overflow-wrap: anywhere;
+}
+
+.ng-profile-address-name {
+  margin: 0 0 10px;
+  color: #202259;
+  font-size: 14px;
+  line-height: 1.6;
+}
+
+.ng-profile-address-copy p {
+  margin: 4px 0;
+  color: #606266;
+  font-size: 13px;
+  line-height: 1.6;
+}
+
+.ng-profile-address-actions {
+  display: flex;
+  flex-shrink: 0;
+  flex-direction: column;
+  align-items: stretch;
+  gap: 8px;
+}
+
+.ng-profile-address-actions ::v-deep .el-button {
+  min-width: 80px;
+  margin-left: 0;
+}
+
+//.ng-profile-addresses ::v-deep .ng-profile-head > .el-button,
+//.ng-profile-address-actions ::v-deep .el-button {
+//  min-height: 44px;
+//  padding: 12px 16px;
+//  border-radius: 8px;
+//  font-size: 13px;
+//  font-weight: 600;
+//  line-height: 18px;
+//}
+//
+//.ng-profile-addresses ::v-deep .ng-profile-head > .el-button:not(.is-disabled),
+//.ng-profile-address-actions ::v-deep .el-button:not(.is-disabled) {
+//  color: #202259;
+//  background: #fff;
+//  border-color: #dfe4ed;
+//}
+//
+//.ng-profile-address-actions ::v-deep .el-button--danger:not(.is-disabled) {
+//  color: #e00000;
+//  border-color: #ff0000;
+//}
+//
+//.ng-profile-addresses ::v-deep .el-button:focus-visible {
+//  outline: 2px solid #202259;
+//  outline-offset: 2px;
+//}
+
+.ng-profile-pagination {
+  max-width: 100%;
+  overflow-x: auto;
+}
+
+.ng-profile-switch-label {
+  margin-left: 10px;
+}
+
+@media (max-width: 767px) {
+  .ng-profile-grid {
+    grid-template-columns: minmax(0, 1fr);
+  }
+
+  .ng-profile-address-form,
+  .ng-profile-address {
+    padding: 16px;
+  }
+
+  .ng-profile-address {
+    flex-direction: column;
+    gap: 16px;
+  }
+
+  .ng-profile-address-actions {
+    flex-direction: row;
+    flex-wrap: wrap;
+  }
+}

+ 12 - 0
src/ui-refactor/views/guest-shop/GuestCart.vue

@@ -0,0 +1,12 @@
+<template>
+  <cart-items v-bind="$attrs" v-on="$listeners" />
+</template>
+
+<script>
+import CartItems from '../purchase/CartItems.vue'
+export default {
+  name: 'GuestCart',
+  components: { CartItems },
+  inheritAttrs: false
+}
+</script>

+ 57 - 0
src/ui-refactor/views/guest-shop/GuestCheckoutFields.vue

@@ -0,0 +1,57 @@
+<template>
+  <el-form
+    ref="form"
+    :model="model"
+    :rules="rules"
+    label-position="top"
+    :disabled="disabled"
+    @submit.native.prevent
+  >
+    <checkout-fields
+      guest
+      :model="model"
+      :country="country"
+      :regions="regions"
+      :disabled="disabled"
+      @change="$emit('change', $event)"
+    />
+  </el-form>
+</template>
+
+<script>
+import CheckoutFields from '../purchase/CheckoutFields.vue'
+import { addressFields } from './shippingFields'
+
+export default {
+  name: 'GuestCheckoutFields',
+  components: { CheckoutFields },
+  props: {
+    model: { type: Object, required: true },
+    country: { type: Object, required: true },
+    regions: { type: Array, default: () => [] },
+    disabled: Boolean
+  },
+  data() {
+    return {
+      rules: Object.fromEntries(
+        addressFields.map(field => [
+          field.key,
+          [
+            {
+              required: true,
+              message: 'Please enter ' + field.label.toLowerCase() + '.',
+              trigger: 'blur',
+              ...(field.key === 'province' ? { type: 'array', min: 1 } : { whitespace: true })
+            }
+          ]
+        ])
+      )
+    }
+  },
+  methods: {
+    validate() {
+      this.$refs.form.validate(() => {})
+    }
+  }
+}
+</script>

+ 71 - 0
src/ui-refactor/views/guest-shop/GuestOrderPage.vue

@@ -0,0 +1,71 @@
+<template>
+  <section class="ng-guest-order">
+    <el-breadcrumb
+      separator="/"
+    ><el-breadcrumb-item :to="{ path: '/login' }">Home</el-breadcrumb-item><el-breadcrumb-item>Order details</el-breadcrumb-item></el-breadcrumb>
+    <h1>Order details</h1>
+    <el-card shadow="never">
+      <h2>{{ order ? 'Order ' + order : 'Order reference unavailable' }}</h2>
+      <el-alert
+        title="Order details are not available yet. Please keep your order number and contact customer service if you need assistance."
+        type="info"
+        :closable="false"
+        show-icon
+      />
+    </el-card>
+    <el-row :gutter="24" class="ng-guest-order-sections">
+      <el-col
+        v-for="section in sections"
+        :key="section"
+        :xs="24"
+        :md="section === 'Items' ? 16 : 8"
+      >
+        <el-card
+          shadow="never"
+        ><h2>{{ section }}</h2>
+          <p>—</p></el-card>
+      </el-col>
+    </el-row>
+    <el-button type="primary" @click="$router.push('/login')">Back to sign in</el-button>
+  </section>
+</template>
+
+<script>
+export default {
+  name: 'GuestOrderPage',
+  data() {
+    return { sections: ['Items', 'Order total', 'Delivery', 'Payment'] }
+  },
+  computed: {
+    order() {
+      return typeof this.$route.query.order === 'string' ? this.$route.query.order : ''
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.ng-guest-order h1 {
+  margin: 32px 0;
+  font-size: 32px;
+}
+.ng-guest-order h2 {
+  margin: 0 0 20px;
+  font-size: 18px;
+  overflow-wrap: anywhere;
+}
+.ng-guest-order .el-card {
+  margin-bottom: 24px;
+  border-radius: 10px;
+}
+.ng-guest-order-sections {
+  display: flex;
+  flex-wrap: wrap;
+}
+.ng-guest-order-sections .el-card {
+  min-height: 140px;
+}
+.ng-guest-order-sections p {
+  color: #909399;
+}
+</style>

+ 355 - 0
src/ui-refactor/views/guest-shop/GuestPurchaseFlow.vue

@@ -0,0 +1,355 @@
+<template>
+  <section class="ng-guest-shop">
+    <!--    <el-alert v-if="error" class="ng-guest-alert" :title="error" type="error" :closable="false" show-icon />-->
+    <!--    <el-button v-if="error && !locked" class="ng-guest-retry" :disabled="loading" @click="reload">Retry</el-button>-->
+    <template v-if="stage === 'catalog'">
+      <h2 class="ng-guest-range-title">Also in this range</h2>
+      <signup-products
+        v-loading="listLoading"
+        :items="displayProducts"
+        :quantities="quantities"
+        :selected-ids="selectedIds"
+        country-selected
+        :show-bv="false"
+        :subtotal="totals.subtotal"
+        :bv="0"
+        :tax="totals.tax"
+        :currency="currency"
+        add-label="Add to cart"
+        help-text="Guest checkout available on every item below."
+        :disabled="locked || loading"
+        :quantity-precision="0"
+        @toggle="toggleProduct"
+        @quantity="changeQuantity"
+        @details="showProduct"
+      ><template slot="footer"><span /></template></signup-products>
+      <el-pagination
+        v-if="total > limit"
+        :current-page="page"
+        :page-size="limit"
+        :total="total"
+        :pager-count="5"
+        layout="prev, pager, next"
+        :disabled="locked || loading"
+        @current-change="changePage"
+      />
+      <el-badge :value="count" :hidden="!count" class="ng-guest-fab">
+        <el-button
+          type="primary"
+          circle
+          icon="el-icon-shopping-cart-2"
+          :aria-label="'Open cart, ' + count + ' items'"
+          :disabled="locked"
+          @click="navigate('cart')"
+        />
+      </el-badge>
+    </template>
+    <template v-else>
+      <h1>{{ stage === 'cart' ? 'Your Cart' : 'Confirm your order' }}</h1>
+      <p v-if="stage === 'cart'" class="ng-guest-help">
+        Review your items, adjust quantities, then proceed to checkout.
+      </p>
+      <!--      <el-steps v-else :active="1" simple class="ng-guest-steps">-->
+      <!--        <el-step><template slot="title"><span class="ng-guest-step-long">Confirm your order</span><span class="ng-guest-step-short">Confirm</span></template></el-step>-->
+      <!--        <el-step><template slot="title"><span class="ng-guest-step-long">Payment method</span><span class="ng-guest-step-short">Payment</span></template></el-step>-->
+      <!--        <el-step><template slot="title"><span class="ng-guest-step-long">Review & place</span><span class="ng-guest-step-short">Review</span></template></el-step>-->
+      <!--      </el-steps>-->
+      <el-empty v-if="!items.length" description="Your cart is empty" class="ng-guest-empty">
+        <el-button type="primary" @click="navigate('catalog')">Continue shopping</el-button>
+      </el-empty>
+      <el-row v-else :gutter="24" type="flex" class="ng-guest-columns">
+        <el-col :xs="24" :lg="16" class="ng-guest-main">
+          <guest-cart
+            v-if="stage === 'cart'"
+            :items="cartRows"
+            :currency="currency"
+            :disabled="locked || loading"
+            @remove="toggleProduct"
+            @quantity="changeQuantity"
+          />
+          <template v-else>
+            <guest-checkout-fields
+              ref="shipping"
+              v-loading="regionLoading"
+              :model="form"
+              :country="country"
+              :regions="regions"
+              :disabled="locked"
+              @change="changeForm"
+            />
+            <payment-methods
+              :value="payType"
+              :options="payList"
+              :currency="currency"
+              :summary="{ amount: totals.total }"
+              guest
+              :disabled="locked || loading"
+              @input="changePaymentType"
+            />
+            <el-button
+              class="ng-guest-back"
+              :disabled="locked"
+              @click="navigate('cart')"
+            >Back to cart</el-button>
+          </template>
+        </el-col>
+        <el-col :xs="24" :lg="8" class="ng-guest-summary-column">
+          <order-summary
+            :checkout="stage === 'checkout'"
+            :show-bv="false"
+            :items="cartRows"
+            :payment-method="paymentMethod"
+            :channels="stage === 'checkout' && payType === 'pay_stack' ? channels : []"
+            :count="count"
+            :subtotal="totals.subtotal"
+            :total="totals.total"
+            :bv="0"
+            :tax="totals.tax"
+            :currency="currency"
+            :shipping="
+              stage === 'cart'
+                ? 'Calculated at checkout'
+                : currency + ' ' + totals.shipping.toFixed(2)
+            "
+          >
+            <template v-if="stage === 'cart'">
+              <el-button
+                type="primary"
+                class="ng-guest-action"
+                :disabled="!canCheckout || locked"
+                @click="navigate('checkout')"
+              >Proceed to checkout</el-button>
+              <el-button
+                type="text"
+                class="ng-guest-action"
+                :disabled="locked"
+                @click="clearCart"
+              >Clear cart</el-button>
+              <el-button
+                class="ng-guest-action"
+                :disabled="locked"
+                @click="navigate('catalog')"
+              >Continue shopping</el-button>
+            </template>
+            <template v-else>
+              <el-alert
+                v-if="pending"
+                class="ng-guest-pending"
+                title="Your unpaid order is saved for this visit. Continue payment to complete it."
+                type="info"
+                :closable="false"
+              />
+              <el-button
+                type="primary"
+                class="ng-purchase-place-order ng-guest-action"
+                :loading="submitting"
+                :disabled="sdkActive || (!pending && (!canCheckout || !regionsReady))"
+                @click="openEmail"
+              >{{ pending ? 'Continue payment' : 'Place order' }}</el-button>
+            </template>
+          </order-summary>
+        </el-col>
+      </el-row>
+    </template>
+    <paystack-payment-dialog
+      :visible="emailVisible"
+      :email="form.email"
+      :readonly-email="!!pending"
+      :amount="pending ? pending.amount : totals.total"
+      :currency="pending ? pending.currency : currency"
+      :loading="submitting || sdkActive"
+      :title="payType === 'pay_stack' ? 'Paystack Payment' : 'Confirm your email'"
+      :note="
+        payType === 'pay_stack'
+          ? 'Confirm your email to continue to the Paystack secure checkout.'
+          : 'Confirm your email to place your order.'
+      "
+      :continue-label="payType === 'pay_stack' ? 'Continue to Paystack' : 'Place order'"
+      :before-close="closePayment"
+      :close-on-press-escape="false"
+      @update:email="changeForm({ email: $event })"
+      @continue="continuePayment"
+    >
+      <order-paystack
+        v-if="sdkActive && pending"
+        :key="paymentAttempt"
+        ref="paystack"
+        :email="form.email.trim()"
+        :amount="pending.subunits"
+        :currency="pending.currency"
+        :reference="'guest-' + pending.SN + '-' + paymentAttempt"
+        :metadata="metadata"
+        :paystackkey="publicKey"
+        :channels="channels"
+        :callback="paymentCallback"
+        :close="paymentClosed"
+        @error="paymentError"
+      />
+    </paystack-payment-dialog>
+    <product-details-dialog
+      :visible.sync="productVisible"
+      :product="product"
+      :currency="currency"
+      :show-bv="false"
+    />
+  </section>
+</template>
+
+<script>
+import behavior from './guestPurchase'
+import SignupProducts from '../signup/components/SignupProducts.vue'
+import OrderSummary from '../purchase/OrderSummary.vue'
+import GuestCart from './GuestCart.vue'
+import GuestCheckoutFields from './GuestCheckoutFields.vue'
+import PaymentMethods from '@/ui-refactor/components/PaymentMethods.vue'
+import PaystackPaymentDialog from '@/ui-refactor/components/PaystackPaymentDialog.vue'
+import ProductDetailsDialog from '@/ui-refactor/components/ProductDetailsDialog.vue'
+import OrderPaystack from './OrderPaystack'
+
+export default {
+  name: 'GuestPurchaseFlow',
+  components: {
+    SignupProducts,
+    OrderSummary,
+    GuestCart,
+    GuestCheckoutFields,
+    PaymentMethods,
+    PaystackPaymentDialog,
+    ProductDetailsDialog,
+    OrderPaystack
+  },
+  mixins: [behavior],
+  data() {
+    return {
+      publicKey: process.env.VUE_APP_BASE_PAY_STACK_PUBLIC_KEY || '',
+      channels: ['card', 'bank', 'ussd', 'qr']
+    }
+  },
+  created() {
+    this.reload()
+  },
+  methods: {
+    changePage(page) {
+      if (!this.locked) {
+        this.page = page
+        this.loadProducts()
+      }
+    },
+    changePaymentType(type) {
+      if (!this.locked) this.payType = type
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.ng-guest-shop h1 {
+  margin: 0 0 24px;
+  font-size: 32px;
+  line-height: 1.3;
+}
+.ng-guest-range-title {
+  margin: 0 0 8px;
+  font-size: 28px;
+}
+.ng-guest-help {
+  margin: 0 0 24px;
+  color: #707780;
+  font-size: 14px;
+  line-height: 1.6;
+}
+.ng-guest-alert {
+  margin-bottom: 16px;
+}
+.ng-guest-retry {
+  margin-bottom: 24px;
+}
+.ng-guest-fab {
+  position: fixed;
+  z-index: 30;
+  right: 24px;
+  bottom: 24px;
+}
+.ng-guest-fab .el-button {
+  width: 52px;
+  height: 52px;
+  font-size: 22px;
+  box-shadow: 0 8px 28px #20225933;
+}
+.ng-guest-steps {
+  max-width: 680px;
+  margin: 0 0 32px;
+  padding: 0;
+  background: transparent;
+}
+.ng-guest-steps ::v-deep .el-step__title {
+  font-size: 12px;
+}
+.ng-guest-step-short {
+  display: none;
+}
+.ng-guest-columns {
+  flex-wrap: wrap;
+}
+.ng-guest-summary-column {
+  display: flex;
+  flex-direction: column;
+}
+.ng-guest-summary-column > .el-card {
+  flex: 1;
+}
+.ng-guest-action.el-button {
+  display: block;
+  width: 100%;
+  margin: 12px 0 0;
+  min-height: 44px;
+  white-space: normal;
+  line-height: 1.5;
+}
+.ng-guest-pending {
+  margin: 12px 0;
+}
+.ng-guest-back {
+  margin: 24px 0;
+}
+.ng-guest-empty {
+  min-height: 280px;
+  background: #fff;
+  border: 1px solid #ebeef5;
+  border-radius: 12px;
+}
+@media (max-width: 1199px) {
+  .ng-guest-summary-column {
+    margin-top: 16px;
+  }
+}
+@media (max-width: 767px) {
+  .ng-guest-shop h1 {
+    font-size: 26px;
+  }
+  .ng-guest-range-title {
+    font-size: 24px;
+  }
+  .ng-guest-steps {
+    padding: 12px;
+    background: #fff;
+  }
+  .ng-guest-steps ::v-deep .el-step__arrow {
+    display: none;
+  }
+  .ng-guest-steps ::v-deep .el-step__head {
+    padding-right: 5px;
+  }
+  .ng-guest-steps ::v-deep .el-step__title {
+    font-size: 12px;
+    white-space: nowrap;
+    word-break: normal;
+  }
+  .ng-guest-step-long {
+    display: none;
+  }
+  .ng-guest-step-short {
+    display: inline;
+  }
+}
+</style>

+ 36 - 0
src/ui-refactor/views/guest-shop/GuestShopPage.vue

@@ -0,0 +1,36 @@
+<template>
+  <guest-purchase-flow ref="flow" :key="market" :market="market" :stage="stage" />
+</template>
+
+<script>
+import GuestPurchaseFlow from './GuestPurchaseFlow.vue'
+export default {
+  name: 'GuestShopPage',
+  components: { GuestPurchaseFlow },
+  computed: {
+    market() {
+      return this.$route.meta.guestMarket
+    },
+    stage() {
+      return this.$route.meta.guestStage
+    }
+  },
+  beforeRouteUpdate(to, from, next) {
+    this.checkNavigation(next)
+  },
+  beforeRouteLeave(to, from, next) {
+    this.checkNavigation(next)
+  },
+  methods: {
+    checkNavigation(next) {
+      const flow = this.$refs.flow
+      if (flow && !flow.completed && (flow.locked || flow.emailVisible)) {
+        this.$message.info(
+          'Complete the current order or close the email dialog before leaving. An unpaid order can be resumed here.'
+        )
+        next(false)
+      } else next()
+    }
+  }
+}
+</script>

+ 133 - 0
src/ui-refactor/views/guest-shop/MarketDialog.vue

@@ -0,0 +1,133 @@
+<template>
+  <ui-dialog
+    class="ng-market-dialog"
+    title="Choose your market"
+    width="460px"
+    top="0"
+    :visible="visible"
+    @update:visible="$emit('update:visible', $event)"
+  >
+    <p class="ng-market-note">
+      Select the country you are shopping from. Products, prices and your order's home market all
+      follow this choice.
+    </p>
+    <el-button
+      v-for="market in markets"
+      :key="market.ID"
+      class="ng-market-option"
+      native-type="button"
+      @click="select(market)"
+    >
+      <span
+        class="ng-market-flag"
+        :class="'ng-market-flag--' + market.key"
+        aria-hidden="true"
+      ><i /></span>
+      <span
+        class="ng-market-copy"
+      ><strong>{{ market.NAME }}</strong><span>{{ market.currency }} · {{ market.currencyName }}</span></span>
+      <i class="el-icon-arrow-right" aria-hidden="true" />
+    </el-button>
+    <template slot="footer"><span /></template>
+  </ui-dialog>
+</template>
+
+<script>
+import UiDialog from '@/ui-refactor/components/UiDialog.vue'
+import { markets, marketPath } from './markets'
+
+export default {
+  name: 'MarketDialog',
+  components: { UiDialog },
+  props: { visible: Boolean },
+  data() {
+    return { markets }
+  },
+  methods: {
+    select(market) {
+      this.$emit('update:visible', false)
+      this.$router.push({ path: marketPath(market.key) })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.ng-market-dialog {
+  display: flex;
+  padding: 16px;
+}
+.ng-market-dialog ::v-deep .el-dialog {
+  flex-shrink: 0;
+  max-width: 100%;
+  margin: auto !important;
+}
+.ng-market-note {
+  margin: 0 0 20px;
+  font-size: 14px;
+  line-height: 1.7;
+  color: #606266;
+  word-break: normal;
+  overflow-wrap: break-word;
+}
+.ng-market-option.el-button {
+  display: flex;
+  width: 100%;
+  margin: 12px 0 0;
+  padding: 16px 20px;
+  text-align: left;
+  white-space: normal;
+}
+.ng-market-option ::v-deep > span {
+  display: flex;
+  width: 100%;
+  align-items: center;
+  gap: 16px;
+}
+.ng-market-copy {
+  display: flex;
+  flex: 1;
+  min-width: 0;
+  flex-direction: column;
+  gap: 8px;
+  line-height: 1.4;
+}
+.ng-market-copy > span {
+  color: #707780;
+  font-size: 12px;
+}
+.ng-market-flag {
+  position: relative;
+  display: block;
+  flex-shrink: 0;
+  width: 64px;
+  height: 36px;
+  overflow: hidden;
+  border: 1px solid #eee;
+  border-radius: 3px;
+}
+.ng-market-flag--ng {
+  background: linear-gradient(to right, #008751 33.33%, #fff 33.33%, #fff 66.66%, #008751 66.66%);
+}
+.ng-market-flag--ae {
+  background: linear-gradient(to bottom, #00732f 33.33%, #fff 33.33%, #fff 66.66%, #000 66.66%);
+}
+.ng-market-flag--ae i {
+  position: absolute;
+  inset: 0 auto 0 0;
+  width: 25%;
+  background: #f00;
+}
+.ng-market-dialog ::v-deep .el-dialog__footer {
+  display: none;
+}
+@media (max-width: 374px) {
+  .ng-market-option.el-button {
+    padding: 12px;
+  }
+  .ng-market-flag {
+    width: 48px;
+    height: 30px;
+  }
+}
+</style>

+ 55 - 0
src/ui-refactor/views/guest-shop/OrderPaystack.js

@@ -0,0 +1,55 @@
+import paystack from 'vue-paystack'
+
+// 保留复消 V1 的 SN/metadata 协议,补上脚本与 SDK 错误的恢复路径。
+export default {
+  name: 'OrderPaystack',
+  extends: paystack,
+  beforeDestroy() {
+    clearTimeout(this.loadTimer)
+  },
+  methods: {
+    loadScript(callback) {
+      if (window.PaystackPop?.setup) {
+        callback()
+        return
+      }
+      const script = document.createElement('script')
+      const finish = error => {
+        clearTimeout(this.loadTimer)
+        this.loadError = error
+        callback()
+      }
+      script.src = 'https://js.paystack.co/v1/inline.js'
+      script.onload = () => finish(null)
+      script.onerror = () => finish(new Error('Unable to load Paystack. Please try again.'))
+      this.loadTimer = setTimeout(script.onerror, 15000)
+      document.head.appendChild(script)
+    },
+    payWithPaystack() {
+      return this.scriptLoaded
+        .then(() => {
+          if (this._isDestroyed) return
+          if (this.loadError) throw this.loadError
+          if (!this.paystackkey) {
+            throw new Error('Paystack is unavailable. Please contact customer service.')
+          }
+          const callback = this.callback
+          const close = this.close
+          window.PaystackPop.setup({
+            key: this.paystackkey,
+            email: this.email,
+            amount: this.amount,
+            currency: this.currency,
+            ref: this.reference,
+            metadata: this.metadata,
+            channels: this.channels,
+            callback,
+            onClose: close
+          }).openIframe()
+        })
+        .catch(error => {
+          if (!this._isDestroyed) this.$emit('error', error)
+        })
+    }
+  }
+}

+ 79 - 0
src/ui-refactor/views/guest-shop/README.md

@@ -0,0 +1,79 @@
+# Shop now 游客购物
+
+2026-10-22,按用户确认方案接入。Vue 2 / JavaScript / Options API / Element UI 不变,不新增依赖。
+
+## 页面与组件
+
+- 登录页 Shop now 打开 `MarketDialog`,固定 Nigeria(`620699181539139625`)与 UAE(`620699181539139643`),不改变注册页选项。支持键盘 Enter / Space 打开、Escape 关闭与焦点恢复。
+- `GuestShopPage` 负责路由与离开保护;`GuestPurchaseFlow` 组合既有购买展示,`guestPurchase.js` 负责匿名请求与本次访问的业务状态。会员和游客的购物车列表统一使用 `purchase/CartItems.vue`,`GuestCart` 只转发属性与事件。收货地址统一由既有 `purchase/CheckoutFields.vue` 渲染,`GuestCheckoutFields` 只保留游客必填校验与表单封装;`guest` 展示参数隐藏会员信息并对应匿名地址字段。商品卡片、详情、订单摘要通过 `showBv=false` 隐藏游客 BV;会员默认行为保持。
+- `/guest-shop/ng`、`/guest-shop/ae` 各有 `/cart`、`/checkout`。六条购物路由与 `/guest-order` 精确加入匿名白名单,隐藏 header,保留原 footer;仅商品页展示国家 Hero。会员路径仍受保护。
+- 购物车跨分页和购物步骤保留;切换国家重建状态,刷新不恢复购物车。游客邮箱、地址、未付单不写入 LocalStorage、SessionStorage、Vuex 或会员缓存。
+
+## API 与支付约定
+
+三个方法追加到 `src/api/shop.js`:
+
+| 方法 | 请求 |
+| --- | --- |
+| fetchGuestProducts | GET `/v1/shop/no-login-reconsume`,countryId / categoryType / page / limit |
+| fetchGuestCart | GET `/v1/shop/no-login-show-cart`,countryId / categoryType |
+| createGuestOrder | POST `/v1/shop/no-login-reconsume-sure-order` |
+
+列表沿用 `list / totalCount`,结算沿用 `sellType / freight / freeShipping / coin`。支付选项及顺序取分类 1 的 `sell_type`,默认第一项;无可用结算配置时阻止提交并提供重试。游客地区通过 `src/api/config.js` 的 `getGuestRegionList({countryId})` 请求 `GET /v1/config/no-login-region-list`,Nigeria / UAE 均传对应字符串国家 ID;会员仍使用原 `getRegionList` 与 `/v1/config/region-list`,不写死地区数据。
+
+下单只发送 `countryId / goodsId / goodsNum / email / payType / consignee / acceptMobile / province / lgaName / cityName / detailaddress`。手机号区号跟随国家显示,`acceptMobile` 仍传用户输入值,`province` 保留地区路径数组。不发送会员编号、报单中心、支付密码或会员地址 ID。
+
+计价沿用复消的 SELL_PRICE × DISCOUNT / 100、含税展示及免运费阈值;税不再叠加到应付金额。商品、购物车和结账展示优先使用商品 `COIN`,结算配置的 `coin` 只作兜底;两个 GET 的返回顺序不影响商品币种。Place order 先验证地址,再确认邮箱,最后下单。非 Paystack 以匿名下单成功返回的 SN 进入公开详情骨架。
+
+匿名下单实际可返回订单记录 `SN / PAY_AMOUNT / ORDER_AMOUNT / COUNTRY_ID / CURRENCY_ID`:金额优先取 `PAY_AMOUNT`,缺失时取 `ORDER_AMOUNT`,不使用 `_STANDARD` 字段或 `EXCHANGE_RATE` 换算。订单未返回 ISO 币种代码时,必须先核对 `COUNTRY_ID` 和当前国家,再用 `CURRENCY_ID` 匹配所选商品的 `LOCAL_CURRENCY_ID`,使用该商品的 `COIN`;无法匹配时保留订单并阻止支付。继续兼容原 `SN / amount / currency`,显式返回的 `amount / currency` 优先。返回 `EMAIL` 为空时仍使用表单内已确认的邮箱。
+
+支付金额为主币单位,只在支付适配时转为最小单位一次。最终金额或币种变化时更新弹窗并要求再次确认,不自行换汇。`OrderPaystack` 继续复消 V1 的 SN / metadata 协议,并处理脚本失败、超时及 SDK 异常;不使用注册的 accessCode / draft 协议。响应中的 `STATUS / PAY_AT` 不作为前端确认已付款的依据。
+
+请求期间防止重复提交。关闭支付保留 SN,重开只恢复支付,不重复 POST,也不调用会员取消接口;未付单存在时锁定商品、地址、邮箱和支付方式。异步响应和 SDK 回调受组件生命周期与本次支付序号约束。
+
+`/guest-order?order=<SN>` 仅显示真实订单号和待接入提示,商品、金额、配送和支付为骨架。无详情请求、物流、下载、分享或本地收据;前端回调不作为服务端付款确认。
+
+## 验证
+
+失败模型:国家串用、金额换算错误、库存/数量越界、重复下单、取消后重建订单、过期响应覆盖、会员权限意外开放、伪造付款状态。
+
+用本机已有 Node 24 和 Playwright 运行一条命令重现游客测试、全量回归基线比较、lint、9 项故障注入、生产构建及浏览器检查:
+
+```powershell
+$env:NODE_PATH='C:\Users\Administrator\.cache\codex-runtimes\codex-primary-runtime\dependencies\node\node_modules'
+& 'C:\Users\Administrator\.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe' src/ui-refactor/views/guest-shop/__tests__/verify.cjs "$env:TEMP/ng-guest-shop-final"
+```
+
+浏览器只访问临时构建,所有商品、地区、订单和 SDK 都是显式隔离样例,未配置请求一律阻止。检查 320 / 375 / 768 / 1024 / 1440px 和短视口,共 66 组布局,并覆盖键盘、空购物车刷新、会员守卫、邮箱、支付金额变化、取消重开和成功跳转。
+
+实施前基线:305 项,293 通过、12 项既有失败。新增游客检查为 53 项;既有失败包含四项 Tax 展示、订单地址/分页、资料开关、网络深度、余额 Reset、主题约束和团队支持按钮断言。原断言未放宽;登录回归只新增 MarketDialog 的依赖隔离,不改变登录行为断言。
+
+新增模块与直接相关共用展示定向 lint 无新增问题。登录页原有 5 个 lint 错误、OrderSummary 原有 2 个格式警告保留;不更改根配置消除这些问题。JS 覆盖率随验证命令生成;手工编译的 SFC 不计入该覆盖率,UI 通过真实组件与浏览器检查补充。项目没有独立 TypeScript 检查,不新增类型工具;依赖未变,不做依赖审计。
+
+首次交付验证记录(Node 24.19.0,Jest 24.9.0):
+
+| 检查 | 结果 |
+| --- | --- |
+| 游客业务 / API / 路由 / 真实 SFC / SDK | 53 项通过 |
+| 全量 UI 回归 | 358 项:346 通过、原有 12 项失败;逐个匹配失败名称,无新增失败或加载错误 |
+| 定向 lint | 验证命令覆盖的新模块、共用展示及相关测试 0 错误、0 警告 |
+| 故障注入 | 6/6 被捕获,原始源码 SHA-256 恢复一致 |
+| 生产构建 | 通过,产物位于临时目录;保留工具链的资源体积、浏览器数据库等提示 |
+| 隔离浏览器 | 66 组布局通过,2 次样例订单,取消重开未增加订单,页面错误 / 未声明外网请求均为 0 |
+| 文件保护 | 469 个基线文件中仅 10 个本次授权文件变化,无文件缺失;环境、lockfile 与其他用户改动未变 |
+
+日志、JSON 报告和截图由上述命令生成在 `$env:TEMP/ng-guest-shop-final`。Git 基点为 `2f4db7e07d46fa84a34eb216c9f969dea09a2509`;本轮为未提交工作区,最终源码哈希清单另存 `source.json`。
+
+2026-10-22 地区接口调整:游客结算改用 `GET /v1/config/no-login-region-list` 并传 `countryId`,会员地区查询保持原接口。新增 Nigeria / UAE 的匿名请求参数检查及会员接口保留检查,游客相关 56 项测试与定向 lint 通过;本次未重跑构建、浏览器或真实接口联调。
+
+2026-10-22 按用户要求进一步复用已有购买展示:将原会员购物车商品行和局部样式提取到 `purchase/CartItems.vue`,会员与游客共用;游客地址表单复用原 `CheckoutFields`,只通过默认关闭的 `guest` 参数处理会员信息显隐、国家区号与匿名字段名。GuestCart / GuestCheckoutFields 保留为转发或校验适配层,不再各自维护重复的列表与地址模板。匿名/会员 API、路由、计价、密码及支付逻辑未变。
+
+本次验证:游客相关 56 项通过,购买与游客相关 166 项通过、原有 4 项 Tax 展示失败;全量 350 项通过、原有 12 项失败,无新增失败。构建、6/6 故障注入与 66 组隔离浏览器检查通过;新共用组件及表单 lint 无错误/警告,PurchaseFlow 原注释缩进警告保留。489 个本轮基线文件中仅 7 个相关展示/说明/测试变化,新增共用 CartItems,无文件删除。日志及截图位于 `$env:TEMP/ng-purchase-reuse-final`;未进行真实下单或支付。
+
+2026-10-23 按实际匿名下单订单记录适配支付字段。新增 5 项失败回归在修复后通过,另补 6 项金额及币种边界检查,游客相关共 72 项通过;全量 366 项通过、相同 12 项既有失败,无新增失败。定向 lint、生产构建、9/9 故障注入和 66 组隔离浏览器检查通过,样例确认 NGN 50,000 只转换为 5,000,000 最小单位一次,取消后复用 SN 且不重复下单。故障注入在临时源码副本执行;474 个基线文件仅 6 个相关源码/测试/说明变化,无文件删除,环境与依赖配置未变。未执行真实下单、真实 Paystack 扣款或服务端确认。
+
+## 交付限制
+
+- 未进行真实后端、真实订单、实际 Paystack 扣款或服务端付款确认。匿名 API、地区访问权限、可用支付方式、支付币种与 SN / amount / currency 响应仍须在授权测试环境联调。
+- 没有匿名详情或订单状态接口,刷新后不能恢复未付单,无法查询网络中断时的下单结果;接口须负责最终计价、库存与订单幂等,前端不宣称提供服务端幂等保证。
+- 本轮核对 469 个既有文件(包含未跟踪文件和根环境/lockfile);只改动登录入口、shop API、路由、白名单、四个共用展示组件、商品卡片和登录测试依赖设置,未删除文件。未提交、推送或部署。

+ 43 - 0
src/ui-refactor/views/guest-shop/__tests__/verify.cjs

@@ -0,0 +1,43 @@
+/* eslint-env node */
+// 用本机 Node 24 运行;不安装依赖,产物与日志均写入临时目录。
+const fs = require('fs')
+const path = require('path')
+const os = require('os')
+const assert = require('assert')
+const { spawnSync } = require('child_process')
+const output = path.resolve(process.argv[2] || path.join(os.tmpdir(), 'ng-guest-shop-verification'))
+fs.mkdirSync(output, { recursive: true })
+function run(name, args, env = process.env) {
+  const result = spawnSync(process.execPath, args, { encoding: 'utf8', env, maxBuffer: 30 * 1024 * 1024 })
+  fs.writeFileSync(path.join(output, name + '.log'), (result.stdout || '') + (result.stderr || ''))
+  assert(!result.error && result.status === 0, name + ' failed: ' + path.join(output, name + '.log'))
+  console.log(name + ': passed')
+}
+const root = 'src/ui-refactor/views/guest-shop'
+run('guest-tests', ['node_modules/jest/bin/jest.js', '--runInBand', '--testMatch', '**/tests/unit/ui-refactor/guestShop*.spec.js', '--coverage', '--collectCoverageFrom', root + '/*.js', '--coverageDirectory', path.join(output, 'coverage'), '--coverageReporters', 'json', '--coverageReporters', 'text', '--json', '--outputFile', path.join(output, 'guest-tests.json'), '--silent'])
+// 基线来自实施前的 305 项检查;逐个匹配既有失败,新增失败或加载错误都阻断验证。
+const expectedFailures = [
+  'new page theme preserves Element UI and legacy pages retains control geometry and confines table styles to new pages',
+  ...['repeat-purchase', 'member-repurchase', 'member-auto-maintenance', 'member-upgrade'].map(source => source + ' shows checkout product prices and BV using the existing purchase rules'),
+  'Profile UI preserves existing account, password and address behavior preserves edit row mapping, ID and default string values without changing other card actions',
+  'loads only the selected endpoint and displays multiple orders, all details and products',
+  'keeps separate pagination when switching categories and does not duplicate requests on return',
+  'shows an empty state and hides pagination after the original loading delay',
+  'Sponsor Network UI reuses both original network scripts keeps the original untrimmed username, unrestricted depth and period query on form submission',
+  'Team inquiry UI and its account shortcut opens Repeat Purchase from Browse products while preserving the current query',
+  'AccountBalances preserves the existing account logic resets page size to the original 5 and repeats the same request'
+]
+const regressionFile = path.join(output, 'regression.json')
+const regression = spawnSync(process.execPath, ['node_modules/jest/bin/jest.js', '--runInBand', '--testMatch', '**/__tests__/*.spec.js', '**/tests/unit/ui-refactor/*.spec.js', '--json', '--outputFile', regressionFile, '--silent'], { encoding: 'utf8', maxBuffer: 30 * 1024 * 1024 })
+fs.writeFileSync(path.join(output, 'regression.log'), (regression.stdout || '') + (regression.stderr || ''))
+assert(!regression.error && regression.status === 1 && fs.existsSync(regressionFile), 'Regression checks did not complete')
+const report = JSON.parse(fs.readFileSync(regressionFile, 'utf8'))
+assert.equal(report.numRuntimeErrorTestSuites, 0, 'A regression suite failed to load')
+const failures = report.testResults.flatMap(suite => suite.assertionResults.filter(test => test.status === 'failed').map(test => test.fullName))
+assert.deepEqual(failures.sort(), expectedFailures.sort(), 'Regression failures differ from the recorded baseline')
+console.log('regression: ' + report.numPassedTests + ' passed; ' + report.numFailedTests + ' recorded baseline failures; no new failures')
+run('lint', ['node_modules/eslint/bin/eslint.js', '--ext', '.js,.vue,.cjs', root, 'src/api/shop.js', 'src/permission.js', 'src/ui-refactor/components/PaymentMethods.vue', 'src/ui-refactor/components/PaystackPaymentDialog.vue', 'src/ui-refactor/components/ProductDetailsDialog.vue', 'src/ui-refactor/views/signup/components/SignupProducts.vue', ...fs.readdirSync('tests/unit/ui-refactor').filter(name => name.startsWith('guestShop') && name.endsWith('.spec.js')).map(name => 'tests/unit/ui-refactor/' + name), 'tests/unit/ui-refactor/loadGuestComponent.js', 'tests/unit/ui-refactor/loginRedirect.spec.js'])
+run('mutations', [root + '/__tests__/verifyMutations.cjs'])
+run('build', ['node_modules/@vue/cli-service/bin/vue-cli-service.js', 'build', '--dest', path.join(output, 'build'), '--no-clean'], { ...process.env, NODE_OPTIONS: '--openssl-legacy-provider' })
+run('browser', [root + '/__tests__/verifyBrowser.cjs', path.join(output, 'build'), path.join(output, 'browser')])
+console.log(JSON.stringify({ output }))

+ 165 - 0
src/ui-refactor/views/guest-shop/__tests__/verifyBrowser.cjs

@@ -0,0 +1,165 @@
+/* eslint-env node */
+// 仅本地构建与显式隔离样例;所有真实 API、支付脚本及未声明外网请求均不放行。
+const fs = require('fs')
+const path = require('path')
+const http = require('http')
+const assert = require('assert')
+const serve = require('serve-static')
+const { chromium } = require('playwright')
+const build = path.resolve(process.argv[2])
+const output = path.resolve(process.argv[3])
+fs.mkdirSync(output, { recursive: true })
+const staticFiles = serve(build)
+const server = http.createServer((req, res) => staticFiles(req, res, () => { res.writeHead(404); res.end() }))
+const viewports = [[320, 640], [375, 812], [768, 768], [1024, 768], [1440, 900], [375, 480]]
+const checks = []
+const errors = []
+const unknown = []
+const orders = []
+const ids = { ng: '620699181539139625', ae: '620699181539139643' }
+const art = '<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160"><rect width="160" height="160" fill="#f4f6f8"/><rect x="55" y="18" width="50" height="125" rx="8" fill="#e9f4f8"/><rect x="60" y="65" width="40" height="45" fill="#202259"/></svg>'
+
+async function main() {
+  await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
+  const origin = 'http://127.0.0.1:' + server.address().port
+  const browser = await chromium.launch({ channel: 'chrome', headless: true })
+  const context = await browser.newContext()
+  try {
+    await context.route('**/*', async route => {
+      const request = route.request()
+      const url = new URL(request.url())
+      if (url.href === 'https://js.paystack.co/v1/inline.js') return route.fulfill({ contentType: 'application/javascript', body: 'window.PaystackPop={setup:function(options){window.samplePayment=options;return{openIframe:function(){window.samplePaymentOpened=true;}}}};' })
+      if (url.pathname.endsWith('/v1/site/captcha')) return route.fulfill({ contentType: 'image/svg+xml', body: art })
+      if (url.pathname.includes('/v1/')) {
+        const api = url.pathname.slice(url.pathname.indexOf('/v1/'))
+        let data
+        if (api === '/v1/site/page-data') data = { pageId: 'sample-page' }
+        else if (api === '/v1/shop/no-login-reconsume' || api === '/v1/shop/no-login-show-cart' || api === '/v1/config/no-login-region-list') {
+          assert.equal(request.method(), 'GET')
+          const country = url.searchParams.get('countryId')
+          assert(Object.values(ids).includes(country), 'Expected a country string ID')
+          const currency = country === ids.ae ? 'AED' : 'NGN'
+          if (api.endsWith('no-login-reconsume')) data = { list: ['Calmag', 'Elken Spirulina', 'C-Joie Flex'].map((name, i) => ({ ID: 'P' + i, GOODS_NAME: name, GOODS_NO: 'SKU' + i, COVER: 'guest-fixture.png', CONTENT: 'Sample product for isolated verification.', SELL_PRICE: 100, MARKET_PRICE: 120, PRICE_PV: 20, DISCOUNT: 80, STORE_NUMS: 5, TAX_RATE: 7.5, COIN: currency, LOCAL_CURRENCY_ID: country === ids.ae ? '148' : '104' })), totalCount: 60 }
+          // 故意返回相反币种,验证商品 COIN 不被结算配置覆盖。
+          else if (api.endsWith('no-login-show-cart')) data = { sellType: [{ id: 1, sell_type: [{ label: 'pay_stack', name: 'Paystack' }] }], freight: 10, freeShipping: 500, coin: currency === 'NGN' ? 'AED' : 'NGN' }
+          else data = [{ value: 'R1', label: country === ids.ae ? 'Dubai' : 'Lagos' }]
+        } else if (api === '/v1/shop/no-login-reconsume-sure-order') {
+          assert.equal(request.method(), 'POST')
+          const params = request.postDataJSON()
+          assert(!('payPassword' in params) && !('userName' in params) && !('decUserName' in params))
+          assert.equal(params.email, 'visitor@example.test')
+          orders.push(params)
+          data = params.countryId === ids.ae
+            ? { SN: 'SAMPLE-ORDER-' + orders.length, amount: 10, currency: 'USD' }
+            : { SN: 'SAMPLE-ORDER-' + orders.length, DEC_SN: 'SAMPLE-DECLARATION', COUNTRY_ID: ids.ng, CURRENCY_ID: '104', ORDER_AMOUNT: 90, PAY_AMOUNT: 90, PAY_FREIGHT: 0, PAY_AMOUNT_STANDARD: 0.075, EXCHANGE_RATE: '1200', EMAIL: '', STATUS: 1 }
+        } else { unknown.push(api); return route.abort() }
+        return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ success: true, data: { data }}) })
+      }
+      if (request.resourceType() === 'image' && url.origin !== origin) return route.fulfill({ contentType: 'image/svg+xml', body: art })
+      if (url.origin === origin) return route.continue()
+      unknown.push(url.origin + url.pathname)
+      return route.abort()
+    })
+    const page = await context.newPage()
+    page.setDefaultTimeout(10000)
+    page.on('pageerror', error => errors.push(error.message))
+    page.on('console', message => { if (message.type() === 'error') errors.push(message.text()) })
+    const layouts = async(label, dialog = false) => {
+      for (const [width, height] of viewports) {
+        await page.setViewportSize({ width, height })
+        await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
+        assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), label + ' overflow at ' + width)
+        if (dialog) {
+          const box = await page.locator('.el-dialog:visible').boundingBox()
+          assert(box && box.x >= 0 && box.x + box.width <= width + 1, label + ' dialog overflow')
+        }
+        if (width === 375 && height === 812 && /checkout|email|market/.test(label)) await page.screenshot({ path: path.join(output, label + '-mobile.png'), fullPage: true, animations: 'disabled' })
+        checks.push({ label, width, height })
+      }
+      await page.setViewportSize({ width: 1440, height: 900 })
+      await page.screenshot({ path: path.join(output, label + '.png'), fullPage: true, animations: 'disabled' })
+    }
+    await page.goto(origin + '/#/login')
+    await page.locator('.ng-login-guest-entry').focus()
+    await page.keyboard.press('Enter')
+    await page.locator('.ng-market-option').first().waitFor()
+    await page.keyboard.press('Escape')
+    await page.locator('.ng-market-option').first().waitFor({ state: 'hidden' })
+    await page.waitForFunction(() => document.activeElement.classList.contains('ng-login-guest-entry'))
+    await page.keyboard.press('Enter')
+    await page.locator('.ng-market-option').first().waitFor()
+    await layouts('market', true)
+    await page.getByRole('button', { name: /UAE AED/ }).click()
+    await page.waitForURL(/guest-shop\/ae$/)
+    for (const market of ['ae', 'ng']) {
+      const currency = market === 'ae' ? 'AED' : 'NGN'
+      await page.goto(origin + '/#/guest-shop/' + market)
+      await page.locator('.ng-signup-product-add:not(.is-disabled)').first().waitFor()
+      assert.equal(await page.locator('.ng-public-header').count(), 0)
+      assert((await page.locator('.ng-public-hero-title').textContent()).includes(market === 'ae' ? 'UAE.' : 'Nigeria.'))
+      assert.equal(await page.locator('.ng-signup-product-bv').count(), 0)
+      assert((await page.locator('.ng-signup-product-price').first().textContent()).includes(currency + ' 80'))
+      await layouts(market + '-catalog')
+      await page.getByRole('button', { name: 'Add to cart', exact: true }).first().click()
+      await page.getByRole('button', { name: /Open cart/ }).click()
+      await page.waitForURL(/\/cart$/)
+      assert((await page.locator('.ng-purchase-cart-item').first().textContent()).includes(currency + ' 80 each'))
+      assert((await page.locator('.ng-purchase-summary').textContent()).includes(currency + ' 90'))
+      await layouts(market + '-cart')
+      await page.getByRole('button', { name: 'Proceed to checkout', exact: true }).click()
+      await page.waitForURL(/\/checkout$/)
+      assert((await page.locator('.ng-purchase-summary').textContent()).includes(currency + ' 90'))
+      await page.locator('#ng-guest-consignee').fill('Sample recipient')
+      await page.locator('#ng-guest-consignee').press('Tab')
+      assert.equal(await page.evaluate(() => document.activeElement.id), 'ng-guest-acceptMobile')
+      await page.locator('#ng-guest-acceptMobile').fill('123456789')
+      await page.locator('#ng-guest-province input').click()
+      await page.locator('.el-cascader-node:visible').first().click()
+      await page.locator('#ng-guest-lgaName').fill('Sample district')
+      await page.locator('#ng-guest-cityName').fill('Sample city')
+      await page.locator('#ng-guest-detailaddress').fill('Sample street')
+      await layouts(market + '-checkout')
+      await page.getByRole('button', { name: 'Place order', exact: true }).click()
+      await page.locator('#ng-paystack-payment-email').fill('visitor@example.test')
+      assert((await page.locator('.ng-paystack-payment-amount strong').textContent()).includes(currency + ' 90'))
+      await layouts(market + '-email', true)
+      await page.getByRole('button', { name: 'Continue to Paystack', exact: true }).click()
+      if (market === 'ae') {
+        await page.locator('.ng-paystack-payment-amount strong').filter({ hasText: 'USD 10' }).waitFor()
+        assert.equal(await page.evaluate(() => !!window.samplePaymentOpened), false)
+        await page.getByRole('button', { name: 'Continue to Paystack', exact: true }).click()
+      }
+      try { await page.waitForFunction(() => window.samplePaymentOpened === true, null, { timeout: 10000 }) } catch (error) {
+        await page.screenshot({ path: path.join(output, 'failure.png'), fullPage: true })
+        console.error(JSON.stringify({ errors, unknown, text: await page.locator('.ng-guest-shop').textContent(), messages: await page.locator('.el-message').allTextContents() }))
+        throw error
+      }
+      const payment = await page.evaluate(() => ({ amount: window.samplePayment.amount, currency: window.samplePayment.currency, cart: window.samplePayment.metadata.cart_id, email: window.samplePayment.email }))
+      assert.equal(payment.amount, market === 'ae' ? 1000 : 9000)
+      assert.equal(payment.currency, market === 'ae' ? 'USD' : 'NGN')
+      assert.equal(payment.cart, 'SAMPLE-ORDER-' + orders.length)
+      assert.equal(payment.email, 'visitor@example.test')
+      const before = orders.length
+      await page.evaluate(() => { window.samplePayment.onClose(); window.samplePaymentOpened = false })
+      await page.getByRole('button', { name: 'Continue payment', exact: true }).click()
+      await page.getByRole('button', { name: 'Continue to Paystack', exact: true }).click()
+      await page.waitForFunction(() => window.samplePaymentOpened === true)
+      assert.equal(orders.length, before, 'Resuming payment must not create another order')
+      await page.evaluate(() => window.samplePayment.callback({ reference: window.samplePayment.ref }))
+      await page.waitForURL(/guest-order\?order=SAMPLE-ORDER-/)
+      assert((await page.locator('.ng-guest-order').textContent()).includes('Order details are not available yet'))
+      await layouts(market + '-order')
+    }
+    await page.goto(origin + '/#/guest-shop/ae/checkout')
+    await page.waitForURL(/guest-shop\/ae\/cart$/)
+    await page.getByText('Your cart is empty', { exact: true }).waitFor()
+    await page.goto(origin + '/#/dashboard/repeat-purchase')
+    await page.waitForURL(/#\/login/)
+    assert.equal(await page.evaluate(() => localStorage.getItem('accessToken')), null)
+    assert.deepEqual(unknown, [])
+    assert.deepEqual(errors, [])
+    fs.writeFileSync(path.join(output, 'results.json'), JSON.stringify({ checks, orders: orders.length, errors, unknown }, null, 2))
+    console.log(JSON.stringify({ layouts: checks.length, orders: orders.length, errors, unknown, output }))
+  } finally { await browser.close(); server.close() }
+}
+main().catch(error => { console.error(error); server.close(); process.exitCode = 1 })

+ 41 - 0
src/ui-refactor/views/guest-shop/__tests__/verifyMutations.cjs

@@ -0,0 +1,41 @@
+/* eslint-env node */
+const fs = require('fs')
+const path = require('path')
+const os = require('os')
+const assert = require('assert')
+const crypto = require('crypto')
+const { spawnSync } = require('child_process')
+const file = path.resolve('src/ui-refactor/views/guest-shop/guestPurchase.js')
+const original = fs.readFileSync(file)
+const source = original.toString()
+const output = fs.mkdtempSync(path.join(os.tmpdir(), 'ng-guest-mutations-'))
+const mutants = [
+  ['wrong country', 'countryId: this.country.ID, categoryType: 1, page:', "countryId: '620699181539139625', categoryType: 1, page:"],
+  ['double amount conversion', 'Math.round(Number(data.amount) * 100)', 'Math.round(Number(data.amount) * 10000)'],
+  ['duplicate submit', 'if (this.submitting || this.sdkActive || !this.emailVisible || !this.current()) return', 'if (this.sdkActive || !this.emailVisible || !this.current()) return'],
+  ['recreate unpaid order', 'if (this.pending) { await this.startPayment(); return }', 'if (false) { await this.startPayment(); return }'],
+  ['stale product response', 'if (!this.current(no) || listNo !== this.listNo) return', 'if (!this.current(no)) return'],
+  ['accept missing currency', "|| !/^[A-Z]{3}$/.test(data.currency || '')", ''],
+  ['ignore payable amount', 'data.amount ?? data.PAY_AMOUNT ?? data.ORDER_AMOUNT', 'data.amount ?? data.ORDER_AMOUNT ?? data.PAY_AMOUNT'],
+  ['ignore order country', 'data.CURRENCY_ID && data.COUNTRY_ID === this.country.ID', 'data.CURRENCY_ID'],
+  ['ignore order currency ID', 'this.items.find(item => String(item.LOCAL_CURRENCY_ID) === String(data.CURRENCY_ID))', 'this.items[0]']
+]
+const results = []
+try {
+  for (const [name, before, after] of mutants) {
+    assert(source.includes(before), 'Mutation target is missing: ' + name)
+    const report = path.join(output, results.length + '.json')
+    fs.writeFileSync(file, source.replace(before, after))
+    const run = spawnSync(process.execPath, ['node_modules/jest/bin/jest.js', '--runInBand', '--runTestsByPath', 'tests/unit/ui-refactor/guestShop.spec.js', '--silent', '--json', '--outputFile', report], { encoding: 'utf8', timeout: 30000 })
+    assert(!run.error && fs.existsSync(report), 'Mutation checker did not run: ' + name)
+    const data = JSON.parse(fs.readFileSync(report, 'utf8'))
+    assert(run.status !== 0 && data.numFailedTests > 0 && data.numRuntimeErrorTestSuites === 0, 'Mutation survived or failed to load: ' + name)
+    results.push({ name, failedTests: data.numFailedTests })
+    fs.writeFileSync(file, original)
+  }
+} finally {
+  fs.writeFileSync(file, original)
+  assert(crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex') === crypto.createHash('sha256').update(original).digest('hex'), 'Source restoration failed')
+}
+fs.writeFileSync(path.join(output, 'results.json'), JSON.stringify(results, null, 2))
+console.log(JSON.stringify({ killed: results.length, total: mutants.length, output }))

+ 431 - 0
src/ui-refactor/views/guest-shop/guestPurchase.js

@@ -0,0 +1,431 @@
+import { fetchGuestProducts, fetchGuestCart, createGuestOrder } from '@/api/shop'
+import { getGuestRegionList } from '@/api/config'
+import tool from '@/utils/tool'
+import { markets, marketPath } from './markets'
+import { shippingFields, validShipping } from './shippingFields'
+
+const numeric = value =>
+  (typeof value === 'number' || (typeof value === 'string' && value.trim() !== '')) &&
+  Number.isFinite(Number(value))
+const money = value => Math.round(value * 100) / 100
+export const validItem = item =>
+  Number.isInteger(item.quantity) &&
+  item.quantity > 0 &&
+  item.quantity <= Number(item.STORE_NUMS) &&
+  ['SELL_PRICE', 'DISCOUNT', 'TAX_RATE'].every(key => numeric(item[key]) && Number(item[key]) >= 0)
+
+export function cartTotals(items, freeShipping, freight) {
+  const subtotal = money(
+    items.reduce(
+      (sum, item) => sum + (Number(item.SELL_PRICE) * item.quantity * Number(item.DISCOUNT)) / 100,
+      0
+    )
+  )
+  const shipping = items.length && subtotal < Number(freeShipping) ? Number(freight) : 0
+  const tax = money(
+    items.reduce(
+      (sum, item) =>
+        sum + tool.calculateTax(Number(item.SELL_PRICE), Number(item.TAX_RATE), item.quantity),
+      0
+    )
+  )
+  // 复消价格已含税;税额只展示,不再次叠加到应付金额。
+  return { subtotal, shipping, total: money(subtotal + shipping), tax }
+}
+
+export function paymentOrder(data) {
+  if (
+    !data ||
+    typeof data.SN !== 'string' ||
+    !data.SN.trim() ||
+    !numeric(data.amount) ||
+    Number(data.amount) <= 0 ||
+    !/^[A-Z]{3}$/.test(data.currency || '')
+  ) {
+    throw new Error(
+      'Payment details are missing or invalid. Please contact customer service with your order number.'
+    )
+  }
+  const subunits = Math.round(Number(data.amount) * 100)
+  if (!Number.isSafeInteger(subunits) || subunits <= 0) throw new Error('Invalid payment amount.')
+  return { SN: data.SN, amount: Number(data.amount), currency: data.currency, subunits }
+}
+
+export default {
+  props: { market: { type: String, required: true }, stage: { type: String, default: 'catalog' }},
+  data() {
+    return {
+      items: [],
+      products: [],
+      total: 0,
+      page: 1,
+      limit: 50,
+      currency: '',
+      freeShipping: 0,
+      freight: 0,
+      payList: [],
+      payType: '',
+      regions: [],
+      form: {
+        consignee: '',
+        acceptMobile: '',
+        province: [],
+        lgaName: '',
+        cityName: '',
+        detailaddress: '',
+        email: ''
+      },
+      settingsReady: false,
+      listReady: false,
+      regionsReady: false,
+      listLoading: false,
+      settingsLoading: false,
+      regionLoading: false,
+      error: '',
+      requestNo: 0,
+      listNo: 0,
+      submitting: false,
+      emailVisible: false,
+      pending: null,
+      sdkActive: false,
+      paymentAttempt: 0,
+      completed: false,
+      product: {},
+      productVisible: false
+    }
+  },
+  computed: {
+    country() {
+      return markets.find(item => item.key === this.market)
+    },
+    totals() {
+      return cartTotals(this.items, this.freeShipping, this.freight)
+    },
+    count() {
+      return this.items.reduce((sum, item) => sum + item.quantity, 0)
+    },
+    locked() {
+      return this.submitting || !!this.pending || this.sdkActive
+    },
+    loading() {
+      return this.listLoading || this.settingsLoading || this.regionLoading
+    },
+    canCheckout() {
+      return (
+        !!this.currency &&
+        this.items.length > 0 &&
+        this.items.every(validItem) &&
+        numeric(this.totals.total) &&
+        this.settingsReady &&
+        this.listReady &&
+        !this.loading
+      )
+    },
+    selectedIds() {
+      return this.items.map(item => item.ID)
+    },
+    quantities() {
+      return this.products.map(row => this.items.find(item => item.ID === row.ID)?.quantity || 1)
+    },
+    displayProducts() {
+      return this.products.map(item => ({
+        ...item,
+        CODE: this.currency,
+        SELL_PRICE: money((Number(item.SELL_PRICE) * Number(item.DISCOUNT)) / 100)
+      }))
+    },
+    cartRows() {
+      return this.items.map(item => ({
+        ...item,
+        image: tool.getArImage(item.COVER, '/files/'),
+        unitPrice: money((Number(item.SELL_PRICE) * Number(item.DISCOUNT)) / 100),
+        linePrice: money(((Number(item.SELL_PRICE) * Number(item.DISCOUNT)) / 100) * item.quantity)
+      }))
+    },
+    paymentMethod() {
+      return this.payList.find(item => item.label === this.payType)?.name || ''
+    },
+    paymentCallback() {
+      const attempt = this.paymentAttempt
+      return () => {
+        if (
+          this.current() &&
+          this.sdkActive &&
+          attempt === this.paymentAttempt &&
+          this.pending &&
+          !this.completed
+        ) {
+          this.finish(this.pending.SN)
+        }
+      }
+    },
+    paymentClosed() {
+      const attempt = this.paymentAttempt
+      return () => {
+        if (this.current() && attempt === this.paymentAttempt) this.closePayment()
+      }
+    },
+    metadata() {
+      const sn = this.pending?.SN || ''
+      return {
+        cart_id: sn,
+        custom_fields: [
+          { display_name: 'orderSn', variable_name: 'orderSn', value: sn },
+          { display_name: 'orderType', variable_name: 'orderType', value: 'userOrder' }
+        ]
+      }
+    }
+  },
+  watch: {
+    stage() {
+      this.prepareCheckout()
+    }
+  },
+  beforeDestroy() {
+    this.requestNo++
+    this.paymentAttempt++
+  },
+  methods: {
+    current(no = this.requestNo) {
+      return !this._isDestroyed && no === this.requestNo
+    },
+    fail(error) {
+      this.error =
+        typeof error === 'string'
+          ? error
+          : error?.message || 'Unable to load shopping information. Please try again.'
+      this.$message.error(this.error)
+    },
+    async reload() {
+      if (this.locked) return
+      this.error = ''
+      const no = ++this.requestNo
+      this.regionLoading = false
+      await Promise.all([this.loadProducts(no), this.loadSettings(no)])
+      if (this.current(no)) this.prepareCheckout()
+    },
+    async loadProducts(no = this.requestNo) {
+      if (this.locked) return
+      const listNo = ++this.listNo
+      this.listLoading = true
+      this.listReady = false
+      try {
+        const { data } = await fetchGuestProducts({
+          countryId: this.country.ID,
+          categoryType: 1,
+          page: this.page,
+          limit: this.limit
+        })
+        if (!this.current(no) || listNo !== this.listNo) return
+        this.products = Object.values(data.list || [])
+        this.total = Number(data.totalCount)
+        this.currency = this.products[0]?.COIN || this.currency
+        // 更新本页已选商品的价格和库存;其他页的选择仍然保留。
+        this.items = this.items.map(item => ({
+          ...item,
+          ...this.products.find(row => row.ID === item.ID)
+        }))
+        this.listReady = true
+      } catch (error) {
+        if (this.current(no) && listNo === this.listNo) this.fail(error)
+      } finally {
+        if (this.current(no) && listNo === this.listNo) this.listLoading = false
+      }
+    },
+    async loadSettings(no) {
+      this.settingsLoading = true
+      this.settingsReady = false
+      try {
+        const { data } = await fetchGuestCart({ countryId: this.country.ID, categoryType: 1 })
+        if (!this.current(no)) return
+        this.payList = Object.values(
+          data.sellType?.find(item => Number(item.id) === 1)?.sell_type || {}
+        )
+        this.payType = this.payList[0]?.label || ''
+        this.freeShipping = data.freeShipping
+        this.freight = data.freight
+        // 商品币种优先,结算配置只在商品尚未提供币种时兜底。
+        if (!this.currency) this.currency = data.coin || ''
+        this.settingsReady =
+          this.payList.length > 0 &&
+          [this.freeShipping, this.freight].every(value => numeric(value) && Number(value) >= 0)
+        if (!this.settingsReady) {
+          throw new Error('Shipping or payment information is unavailable. Please try again.')
+        }
+      } catch (error) {
+        if (this.current(no)) this.fail(error)
+      } finally {
+        if (this.current(no)) this.settingsLoading = false
+      }
+    },
+    async loadRegions() {
+      if (this.regionLoading || this.regionsReady) return
+      const no = this.requestNo
+      this.regionLoading = true
+      try {
+        const { data } = await getGuestRegionList({ countryId: this.country.ID })
+        if (!this.current(no)) return
+        this.regions = data
+        this.regionsReady = Array.isArray(data) && data.length > 0
+        if (!this.regionsReady) {
+          throw new Error('Delivery regions are unavailable. Please try again.')
+        }
+      } catch (error) {
+        if (this.current(no)) this.fail(error)
+      } finally {
+        if (this.current(no)) this.regionLoading = false
+      }
+    },
+    prepareCheckout() {
+      if (this.stage !== 'checkout') return
+      if (!this.items.length) {
+        this.navigate('cart', true)
+        return
+      }
+      this.loadRegions()
+    },
+    toggleProduct(row) {
+      if (this.locked || this.loading) return
+      const index = this.items.findIndex(item => item.ID === row.ID)
+      if (index >= 0) this.items.splice(index, 1)
+      else {
+        // 卡片传回的是展示行,业务价格始终取原始接口数据。
+        const item = { ...(this.products.find(item => item.ID === row.ID) || row), quantity: 1 }
+        if (!validItem(item)) {
+          this.fail('This product is unavailable.')
+          return
+        }
+        this.items.push(item)
+      }
+    },
+    changeQuantity({ row, value }) {
+      if (this.locked || this.loading) return
+      const item = this.items.find(item => item.ID === row.ID)
+      if (!item) return
+      if (!validItem({ ...item, quantity: value })) {
+        this.fail('Enter a whole quantity within the available stock.')
+        return
+      }
+      item.quantity = value
+    },
+    clearCart() {
+      if (!this.locked) this.items = []
+    },
+    changeForm(value) {
+      if (!this.locked) Object.assign(this.form, value)
+    },
+    navigate(stage, replace = false) {
+      if (this.locked) return
+      const path = marketPath(this.market) + (stage === 'catalog' ? '' : '/' + stage)
+      this.$router[replace ? 'replace' : 'push']({ path })
+    },
+    openEmail() {
+      if (this.submitting || this.sdkActive) return
+      if (!this.pending && (!this.canCheckout || !validShipping(this.form) || !this.regionsReady)) {
+        if (this.$refs.shipping) this.$refs.shipping.validate()
+        this.fail(
+          'Please complete your shipping address and load delivery regions before placing the order.'
+        )
+        return
+      }
+      this.emailVisible = true
+    },
+    async continuePayment() {
+      if (this.submitting || this.sdkActive || !this.emailVisible || !this.current()) return
+      if (this.pending) {
+        await this.startPayment()
+        return
+      }
+      if (!this.canCheckout || !this.regionsReady || !validShipping(this.form)) return
+      if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.form.email.trim())) {
+        this.fail('Please enter a valid email address.')
+        return
+      }
+      if (!this.payList.some(item => item.label === this.payType)) return
+      this.submitting = true
+      this.error = ''
+      const no = this.requestNo
+      const type = this.payType
+      try {
+        const params = {
+          countryId: this.country.ID,
+          goodsId: this.items.map(item => item.ID),
+          goodsNum: this.items.map(item => item.quantity),
+          email: this.form.email.trim(),
+          payType: type,
+          ...Object.fromEntries(shippingFields.map(key => [key, this.form[key].trim()])),
+          province: [...this.form.province]
+        }
+        const { data } = await createGuestOrder(params)
+        if (!this.current(no)) return
+        if (typeof data?.SN !== 'string' || !data.SN.trim()) {
+          throw new Error(
+            'The order number is missing. Please contact customer service before retrying.'
+          )
+        }
+        if (type !== 'pay_stack') {
+          this.finish(data.SN)
+          return
+        }
+        // 即便支付数据不完整,也保留已创建订单,防止重试时再次下单。
+        const currencyProduct =
+          data.CURRENCY_ID && data.COUNTRY_ID === this.country.ID
+            ? this.items.find(item => String(item.LOCAL_CURRENCY_ID) === String(data.CURRENCY_ID))
+            : null
+        // 订单记录没有 ISO 币种代码时,按国家及币种 ID 核对商品 COIN。
+        this.pending = {
+          SN: data.SN,
+          amount: data.amount ?? data.PAY_AMOUNT ?? data.ORDER_AMOUNT,
+          currency: data.currency ?? currencyProduct?.COIN
+        }
+        this.pending = paymentOrder(this.pending)
+        if (this.pending.amount !== this.totals.total || this.pending.currency !== this.currency) {
+          this.$message.info(
+            'The final payment amount has been updated. Please review it and continue to Paystack.'
+          )
+          return
+        }
+        await this.startPayment()
+      } catch (error) {
+        if (this.current(no)) this.fail(error)
+      } finally {
+        if (this.current(no)) this.submitting = false
+      }
+    },
+    async startPayment() {
+      try {
+        this.pending = paymentOrder(this.pending)
+        this.paymentAttempt++
+        this.sdkActive = true
+        await this.$nextTick()
+        if (!this.current() || !this.sdkActive) return
+        this.$refs.paystack.payWithPaystack()
+      } catch (error) {
+        this.sdkActive = false
+        this.fail(error)
+      }
+    },
+    closePayment() {
+      if (this.submitting) return
+      this.paymentAttempt++
+      this.sdkActive = false
+      this.emailVisible = false
+    },
+    paymentError(error) {
+      this.paymentAttempt++
+      this.sdkActive = false
+      this.fail(error)
+    },
+    finish(sn) {
+      this.completed = true
+      this.sdkActive = false
+      this.emailVisible = false
+      this.items = []
+      this.$router.push({ path: '/guest-order', query: { order: sn }})
+    },
+    showProduct(row) {
+      this.product = { ...row, taxAmount: tool.calculateTax(row.SELL_PRICE, row.TAX_RATE) }
+      this.productVisible = true
+    }
+  }
+}

+ 28 - 0
src/ui-refactor/views/guest-shop/markets.js

@@ -0,0 +1,28 @@
+export const markets = [
+  {
+    key: 'ng',
+    ID: '620699181539139625',
+    NAME: 'Nigeria',
+    CODE: 'NG',
+    ZONE_CODE: '234',
+    currency: 'NGN',
+    currencyName: 'Nigerian Naira'
+  },
+  {
+    key: 'ae',
+    ID: '620699181539139643',
+    NAME: 'UAE',
+    CODE: 'AE',
+    ZONE_CODE: '971',
+    LOCAL_CURRENCY_ID: '148',
+    DEFAULT_LANGUAGE_ID: '620713695718416385',
+    ACTIVE: '1',
+    LANGUAGE_KEY: 'dbCountriesae',
+    CREATED_AT: '2024-04-16 02:30:40',
+    UPDATED_AT: '2025-04-17 05:39:50',
+    currency: 'AED',
+    currencyName: 'UAE Dirham'
+  }
+]
+
+export const marketPath = key => '/guest-shop/' + key

+ 43 - 0
src/ui-refactor/views/guest-shop/routes.js

@@ -0,0 +1,43 @@
+import { markets, marketPath } from './markets'
+
+export const guestRoutes = markets.flatMap(market =>
+  ['catalog', 'cart', 'checkout'].map(stage => ({
+    path: marketPath(market.key) + (stage === 'catalog' ? '' : '/' + stage),
+    name: 'Guest-' + market.key + '-' + stage,
+    component: () => import('./GuestShopPage.vue'),
+    meta: {
+      title:
+        stage === 'catalog'
+          ? 'Shop Elken ' + market.NAME
+          : stage === 'cart'
+            ? 'Your Cart'
+            : 'Confirm your order',
+      guestMarket: market.key,
+      guestStage: stage,
+      publicLayout: {
+        showHeader: false,
+        ...(stage === 'catalog'
+          ? {
+            hero: {
+              titleParts: [
+                { text: 'Shop ' },
+                { text: 'Elken ' + market.NAME + '.', accent: true }
+              ],
+              subtitle: 'No account needed — browse, add to cart and check out as a guest.',
+              breadcrumbs: [{ text: 'Home', to: '/login' }, { text: 'Shop' }]
+            }
+          }
+          : {})
+      }
+    }
+  }))
+)
+
+guestRoutes.push({
+  path: '/guest-order',
+  name: 'GuestOrder',
+  component: () => import('./GuestOrderPage.vue'),
+  meta: { title: 'Order details', publicLayout: { showHeader: false }}
+})
+
+export const guestPaths = guestRoutes.map(route => route.path)

+ 17 - 0
src/ui-refactor/views/guest-shop/shippingFields.js

@@ -0,0 +1,17 @@
+export const addressFields = [
+  { key: 'consignee', label: 'Recipient' },
+  { key: 'acceptMobile', label: 'Phone' },
+  { key: 'province', label: 'Region / State' },
+  { key: 'lgaName', label: 'Area / District' },
+  { key: 'cityName', label: 'City' },
+  { key: 'detailaddress', label: 'Detailed address' }
+]
+
+export const shippingFields = addressFields
+  .filter(field => field.key !== 'province')
+  .map(field => field.key)
+
+export const validShipping = form =>
+  shippingFields.every(key => typeof form[key] === 'string' && form[key].trim()) &&
+  Array.isArray(form.province) &&
+  form.province.length > 0

+ 101 - 0
src/ui-refactor/views/purchase/CartItems.vue

@@ -0,0 +1,101 @@
+<template>
+  <div class="ng-purchase-cart-items">
+    <el-card v-for="item in items" :key="item.ID" shadow="never" class="ng-purchase-cart-item">
+      <div class="ng-purchase-cart-row">
+        <el-image
+          :src="tool.getArImage(item.COVER, '/files/')"
+          :alt="item.GOODS_NAME"
+          fit="contain"
+          class="ng-purchase-cart-image"
+        />
+        <div class="ng-purchase-cart-copy">
+          <h3>{{ item.GOODS_NAME }}</h3>
+          <p>{{ currency }} {{ item.unitPrice | toThousandFilter }} each</p>
+          <el-link
+            type="danger"
+            :disabled="disabled"
+            class="ng-purchase-remove"
+            role="button"
+            :tabindex="disabled ? -1 : 0"
+            @click="$emit('remove', item)"
+            @keydown.enter.native.prevent="!disabled && $emit('remove', item)"
+            @keydown.space.native.prevent="!disabled && $emit('remove', item)"
+          >Remove</el-link>
+        </div>
+        <el-input-number
+          :value="item.quantity"
+          :min="1"
+          :max="Number(item.STORE_NUMS)"
+          :precision="0"
+          size="small"
+          :disabled="disabled"
+          :label="'Quantity for ' + item.GOODS_NAME"
+          @change="$emit('quantity', { row: item, value: $event })"
+        />
+        <strong>{{ currency }} {{ item.linePrice | toThousandFilter }}</strong>
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import tool from '@/utils/tool'
+import { toThousandFilter } from '@/filters'
+
+export default {
+  name: 'PurchaseCartItems',
+  filters: { toThousandFilter },
+  props: {
+    items: { type: Array, required: true },
+    currency: { type: String, required: true },
+    disabled: Boolean
+  },
+  data() {
+    return { tool }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.ng-purchase-cart-item {
+  margin-bottom: 16px;
+  border-radius: 12px;
+}
+.ng-purchase-cart-row {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 16px;
+}
+.ng-purchase-cart-image {
+  width: 64px;
+  height: 64px;
+  flex-shrink: 0;
+}
+.ng-purchase-cart-copy {
+  flex: 1;
+  min-width: 120px;
+  overflow-wrap: anywhere;
+}
+.ng-purchase-cart-copy h3 {
+  margin: 0 0 8px;
+  font-size: 15px;
+}
+.ng-purchase-cart-copy p {
+  margin: 0;
+  font-size: 12px;
+  color: #606266;
+}
+.ng-purchase-cart-row > strong {
+  font-size: 14px;
+  overflow-wrap: anywhere;
+}
+.ng-purchase-remove {
+  color: #f56c6c;
+}
+@media (max-width: 500px) {
+  .ng-purchase-cart-item ::v-deep > .el-card__body {
+    padding: 16px;
+  }
+}
+</style>

+ 401 - 0
src/ui-refactor/views/purchase/CheckoutFields.vue

@@ -0,0 +1,401 @@
+<template>
+  <div class="ng-purchase-checkout" :class="{ 'ng-purchase-guest-checkout': guest }">
+    <el-card
+      v-if="!guest"
+      shadow="never"
+      class="ng-purchase-checkout-section"
+      :class="{ 'ng-purchase-member-information': !self }"
+    >
+      <h2 v-if="!self" class="ng-purchase-checkout-title">Member information</h2>
+      <el-form-item
+        v-if="!self"
+        label="Repeat-purchase member ID"
+        for="ng-purchase-member-readonly"
+      >
+        <el-input
+          id="ng-purchase-member-readonly"
+          :value="upgrade ? model.insertUserName : model.userName"
+          disabled
+        />
+        <span class="ng-purchase-help">【{{ memberName }}】</span>
+      </el-form-item>
+      <el-form-item
+        label="Reporting Center ID"
+        :prop="self ? 'recUserName' : 'decUserName'"
+        required
+        for="ng-purchase-stockist"
+      >
+        <el-input
+          id="ng-purchase-stockist"
+          :value="self ? model.recUserName : model.decUserName"
+          :disabled="disabled"
+          @input="change(self ? 'recUserName' : 'decUserName', $event)"
+        />
+      </el-form-item>
+    </el-card>
+    <el-card
+      v-if="!self"
+      shadow="never"
+      class="ng-purchase-checkout-section ng-purchase-shipping-address"
+    >
+      <h2 class="ng-purchase-checkout-title">Shipping address</h2>
+      <el-form-item
+        v-for="field in fields"
+        :key="field.key"
+        :label="field.label"
+        :prop="guest ? field.key : undefined"
+        :required="guest"
+        :for="inputId(field.key)"
+      >
+        <el-cascader
+          v-if="field.key === 'areaSelected' || field.key === 'province'"
+          :id="inputId(field.key)"
+          :value="model[field.key]"
+          :options="regions"
+          :disabled="disabled"
+          :placeholder="guest ? 'Select region' : undefined"
+          popper-class="ng-refactor-popper"
+          @input="change(field.key, $event)"
+        />
+        <el-input
+          v-else
+          :id="inputId(field.key)"
+          :value="model[field.key]"
+          :type="
+            field.key === 'remark'
+              ? 'textarea'
+              : guest && field.key === 'acceptMobile'
+                ? 'tel'
+                : 'text'
+          "
+          :disabled="disabled"
+          @input="change(field.key, $event)"
+        >
+          <template
+            v-if="guest && field.key === 'acceptMobile'"
+            slot="prepend"
+          >{{ country.NAME }} (+{{ country.ZONE_CODE }})</template>
+        </el-input>
+      </el-form-item>
+    </el-card>
+    <template v-else>
+      <el-card shadow="never" class="ng-purchase-checkout-section">
+        <h2 class="ng-purchase-checkout-title">Delivery method</h2>
+        <el-radio-group
+          :value="deliveryMethod"
+          :disabled="disabled"
+          class="ng-purchase-delivery"
+          aria-label="Delivery method"
+          @input="changeDeliveryMethod"
+        >
+          <el-row type="flex" :gutter="12" class="ng-purchase-delivery-grid">
+            <el-col :xs="24" :sm="12">
+              <el-radio label="delivery" border>
+                <el-avatar
+                  :size="40"
+                  shape="square"
+                  icon="el-icon-truck"
+                  class="ng-purchase-delivery-icon"
+                  aria-hidden="true"
+                />
+                <span class="ng-purchase-delivery-copy">
+                  <strong>Door delivery</strong>
+                  <span>We deliver to your address</span>
+                </span>
+              </el-radio>
+            </el-col>
+            <el-col :xs="24" :sm="12">
+              <el-radio label="pickup" border>
+                <el-avatar
+                  :size="40"
+                  shape="square"
+                  icon="el-icon-box"
+                  class="ng-purchase-delivery-icon"
+                  aria-hidden="true"
+                />
+                <span class="ng-purchase-delivery-copy">
+                  <strong>Self-pickup</strong>
+                  <span>Pick up at our office</span>
+                </span>
+              </el-radio>
+            </el-col>
+          </el-row>
+        </el-radio-group>
+      </el-card>
+      <el-card
+        v-if="deliveryMethod === 'delivery'"
+        shadow="never"
+        class="ng-purchase-checkout-section ng-purchase-address-details"
+      >
+        <h2 class="ng-purchase-checkout-title">Address details</h2>
+        <h3 class="ng-purchase-address-heading">Saved addresses</h3>
+        <el-radio-group
+          :value="addressId"
+          :disabled="disabled"
+          class="ng-purchase-addresses"
+          aria-label="Saved addresses"
+          @input="$emit('address', $event)"
+        >
+          <el-radio v-for="address in addresses" :key="address.ID" :label="address.ID" border>
+            <span class="ng-purchase-address-copy">
+              <span class="ng-purchase-address-title">
+                {{ address.IS_DEFAULT === '1' ? 'Default address' : 'Order address' }}
+                <el-tag v-if="address.IS_DEFAULT === '1'" size="mini" effect="dark">DEFAULT</el-tag>
+                <el-tag
+                  v-if="addressId === address.ID"
+                  size="mini"
+                  class="ng-purchase-address-selected"
+                >Selected</el-tag>
+              </span>
+              <span v-if="addressText(address)" class="ng-purchase-address-location">{{
+                addressText(address)
+              }}</span>
+              <span><strong>Recipient</strong> {{ address.CONSIGNEE || '—' }}</span>
+              <span><strong>Phone</strong> {{ address.MOBILE || '—' }}</span>
+            </span>
+          </el-radio>
+        </el-radio-group>
+        <p v-if="!addresses.length" class="ng-purchase-help">
+          No saved addresses. Please add an address in My Account.
+        </p>
+        <p class="ng-purchase-help ng-purchase-address-help">
+          Addresses are managed in My Account and can't be edited here.
+        </p>
+      </el-card>
+    </template>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'PurchaseCheckoutFields',
+  props: {
+    model: { type: Object, required: true },
+    self: Boolean,
+    upgrade: Boolean,
+    guest: Boolean,
+    country: { type: Object, default: () => ({}) },
+    disabled: Boolean,
+    memberName: { type: String, default: '' },
+    regions: { type: Array, default: () => [] },
+    addresses: { type: Array, default: () => [] },
+    addressId: { type: [Number, String], default: '' },
+    pickupId: { type: String, default: '100000000000000000' }
+  },
+  data() {
+    // 仅记住切换前的送货地址,父级仍用原 addressId 表示自提或送货。
+    return { deliveryAddressId: '' }
+  },
+  computed: {
+    deliveryMethod() {
+      return this.addressId === this.pickupId ? 'pickup' : 'delivery'
+    },
+    fields() {
+      const fields = [
+        { key: 'consignee', label: 'Recipient' },
+        { key: this.upgrade ? 'acceptMobile' : 'mobile', label: 'Phone' },
+        { key: 'areaSelected', label: 'Region / State' },
+        { key: 'lgaName', label: 'Area / District' },
+        { key: 'cityName', label: 'City' },
+        { key: 'address', label: 'Detailed address' },
+        ...(this.upgrade ? [{ key: 'remark', label: 'Note' }] : [])
+      ]
+      if (!this.guest) return fields
+      // 游客沿用匿名接口的字段名,会员表单与提交字段保持原样。
+      const guestKeys = {
+        mobile: 'acceptMobile',
+        areaSelected: 'province',
+        address: 'detailaddress'
+      }
+      return fields.map(field => ({ ...field, key: guestKeys[field.key] || field.key }))
+    }
+  },
+  watch: {
+    addressId: {
+      immediate: true,
+      handler(value) {
+        if (value !== this.pickupId) this.deliveryAddressId = value
+      }
+    }
+  },
+  methods: {
+    inputId(key) {
+      return (this.guest ? 'ng-guest-' : 'ng-purchase-') + key
+    },
+    changeDeliveryMethod(method) {
+      const address =
+        this.addresses.find(item => item.ID === this.deliveryAddressId) ||
+        this.addresses.find(item => item.IS_DEFAULT === '1')
+      this.$emit('address', method === 'pickup' ? this.pickupId : address ? address.ID : '')
+    },
+    addressText(address) {
+      return [address.ADDRESS, address.CITY_NAME, address.LGA_NAME, address.PROVINCE_NAME]
+        .filter(Boolean)
+        .join(', ')
+    },
+    change(key, value) {
+      this.$emit('change', { [key]: key === 'mobile' ? value.trim() : value })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.ng-purchase-help {
+  font-size: 12px;
+  color: #606266;
+}
+.el-cascader {
+  width: 100%;
+}
+.ng-purchase-checkout-section {
+  margin-bottom: 24px;
+  border-radius: 8px;
+}
+.ng-purchase-checkout-section ::v-deep > .el-card__body {
+  padding: 24px;
+}
+.ng-purchase-checkout-section .el-form-item:last-child {
+  margin-bottom: 0;
+}
+.ng-purchase-checkout-title {
+  margin: 0 0 16px;
+  color: #202259;
+  font-size: 18px;
+}
+.ng-purchase-guest-checkout ::v-deep .el-input-group__prepend {
+  padding: 0 12px;
+  font-size: 12px;
+}
+@media (max-width: 374px) {
+  .ng-purchase-guest-checkout ::v-deep .el-input-group__prepend {
+    padding: 0 6px;
+    font-size: 10px;
+  }
+}
+.ng-purchase-address-heading {
+  margin: 0 0 12px;
+  font-size: 14px;
+}
+.ng-purchase-delivery {
+  display: block;
+}
+.ng-purchase-delivery-grid {
+  flex-wrap: wrap;
+  row-gap: 12px;
+}
+.ng-purchase-delivery ::v-deep .el-radio {
+  display: flex;
+  flex-direction: row-reverse;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  width: 100%;
+  height: auto;
+  min-height: 76px;
+  margin: 0;
+  padding: 16px;
+  white-space: normal;
+}
+.ng-purchase-delivery ::v-deep .el-radio__label {
+  display: flex;
+  align-items: center;
+  flex: 1;
+  gap: 12px;
+  min-width: 0;
+  padding: 0;
+}
+.ng-purchase-delivery ::v-deep .el-radio__input,
+.ng-purchase-addresses ::v-deep .el-radio__input {
+  flex-shrink: 0;
+}
+.ng-purchase-delivery ::v-deep .el-radio.is-checked,
+.ng-purchase-addresses ::v-deep .el-radio.is-checked {
+  background: #e9f4f8;
+}
+.ng-purchase-delivery-icon {
+  flex: 0 0 40px;
+  background: #f6f7f8;
+  color: #25292c;
+}
+.ng-purchase-delivery ::v-deep .is-checked .ng-purchase-delivery-icon {
+  background: #202259;
+  color: #fff;
+}
+.ng-purchase-delivery-copy {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  color: #707780;
+  font-size: 13px;
+  line-height: 1.5;
+  overflow-wrap: anywhere;
+}
+.ng-purchase-delivery-copy strong {
+  color: #25292c;
+  font-size: 14px;
+  font-weight: 500;
+}
+.ng-purchase-addresses {
+  display: grid;
+  gap: 16px;
+  width: 100%;
+  ::v-deep .el-radio {
+    display: flex;
+    align-items: flex-start;
+    gap: 12px;
+    width: 100%;
+    height: auto;
+    margin: 0;
+    padding: 16px;
+    white-space: normal;
+    line-height: 1.6;
+  }
+  ::v-deep .el-radio__input {
+    margin-top: 4px;
+  }
+  ::v-deep .el-radio__label {
+    flex: 1;
+    min-width: 0;
+    padding: 0;
+  }
+}
+.ng-purchase-address-copy {
+  display: flex;
+  flex-direction: column;
+  color: #606266;
+  font-size: 13px;
+  overflow-wrap: anywhere;
+}
+.ng-purchase-addresses ::v-deep .el-radio.is-bordered + .el-radio.is-bordered {
+  margin-left: 0;
+}
+.ng-purchase-address-title {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin-bottom: 20px;
+  color: #202259;
+  font-size: 14px;
+}
+.ng-purchase-address-selected {
+  margin-left: auto;
+}
+.ng-purchase-address-location {
+  margin-bottom: 8px;
+}
+.ng-purchase-address-copy strong {
+  color: #25292c;
+  font-weight: 500;
+}
+.ng-purchase-address-help {
+  margin: 12px 0 0;
+  line-height: 1.6;
+}
+@media (max-width: 767px) {
+  .ng-purchase-checkout-section ::v-deep > .el-card__body {
+    padding: 20px 16px;
+  }
+}
+</style>

+ 407 - 0
src/ui-refactor/views/purchase/OrderSummary.vue

@@ -0,0 +1,407 @@
+<template>
+  <el-card shadow="never" class="ng-purchase-summary" :class="{ 'is-checkout': checkout }">
+    <h2>Order summary</h2>
+    <el-divider v-if="!checkout" />
+    <div
+      v-if="checkout"
+      class="ng-purchase-summary-products"
+      role="list"
+      aria-label="Order products"
+    >
+      <el-row
+        v-for="item in items"
+        :key="item.ID"
+        type="flex"
+        class="ng-purchase-summary-product"
+        role="listitem"
+      >
+        <el-col class="ng-purchase-summary-image-column">
+          <el-image
+            :src="item.image"
+            :alt="item.GOODS_NAME"
+            fit="contain"
+            class="ng-purchase-summary-image"
+          >
+            <i slot="error" class="el-icon-picture-outline" aria-hidden="true" />
+          </el-image>
+        </el-col>
+        <el-col class="ng-purchase-summary-content">
+          <el-row type="flex" class="ng-purchase-summary-product-copy">
+            <el-col class="ng-purchase-summary-details">
+              <h3>{{ item.GOODS_NAME }}</h3>
+              <p class="ng-purchase-summary-unit">
+                {{ item.GOODS_NO }} · {{ currency }} {{ item.unitPrice | toThousandFilter }}
+                <template v-if="showBv">({{ item.unitBv | toThousandFilter }} BV)</template> each
+              </p>
+            </el-col>
+            <el-col class="ng-purchase-summary-values">
+              <span class="ng-purchase-summary-quantity">Qty {{ item.quantity }}</span>
+              <strong class="ng-purchase-summary-line">
+                {{ currency }} {{ item.linePrice | toThousandFilter }}
+                <template v-if="showBv">({{ item.lineBv | toThousandFilter }} BV)</template>
+              </strong>
+            </el-col>
+          </el-row>
+        </el-col>
+      </el-row>
+    </div>
+    <el-descriptions v-if="checkout" :column="1" :colon="false" class="ng-purchase-checkout-totals">
+      <el-descriptions-item label="Total" content-class-name="ng-purchase-total">
+        {{ currency }} {{ total | toThousandFilter }}
+        <template v-if="showBv">({{ bv | toThousandFilter }} BV)</template>
+      </el-descriptions-item>
+      <el-descriptions-item label="Shipping">{{ shipping }}</el-descriptions-item>
+      <!-- <el-descriptions-item label="Tax">{{ currency }} {{ tax | toThousandFilter }}</el-descriptions-item> -->
+      <el-descriptions-item label="Method">{{ paymentMethod || '—' }}</el-descriptions-item>
+    </el-descriptions>
+    <el-descriptions v-else :column="1" :colon="false" class="ng-purchase-cart-totals">
+      <el-descriptions-item label="Items">{{ count }}</el-descriptions-item>
+      <el-descriptions-item
+        label="Subtotal"
+      >{{ currency }} {{ subtotal | toThousandFilter }}</el-descriptions-item>
+      <el-descriptions-item label="Shipping">{{ shipping }}</el-descriptions-item>
+      <el-descriptions-item
+        v-if="showBv"
+        label="Total BV"
+      >{{ bv | toThousandFilter }} BV</el-descriptions-item>
+      <!--      <el-descriptions-item label="Tax">{{ currency }} {{ tax | toThousandFilter }}</el-descriptions-item>-->
+      <el-descriptions-item label="Total" content-class-name="ng-purchase-total">
+        {{ currency }} {{ total | toThousandFilter }}
+      </el-descriptions-item>
+    </el-descriptions>
+    <el-card
+      v-if="maintenance"
+      shadow="never"
+      class="ng-purchase-requirement"
+      :class="{ 'is-sufficient': sufficient }"
+      role="status"
+      aria-live="polite"
+    >
+      <h3>Auto-Maintenance requirement</h3>
+      <el-descriptions :column="1" :colon="false">
+        <el-descriptions-item
+          label="Current BV"
+        >{{ bv | toThousandFilter }} BV</el-descriptions-item>
+        <el-descriptions-item label="Requirement">
+          {{ bv | toThousandFilter }} / {{ limit == null ? '—' : limit }} BV
+          {{ sufficient ? '✓' : '✕' }}
+        </el-descriptions-item>
+      </el-descriptions>
+    </el-card>
+    <el-alert
+      v-if="maintenance && !sufficient"
+      class="ng-purchase-requirement-warning"
+      type="error"
+      :closable="false"
+      :title="
+        limit == null
+          ? 'Waiting for the maintenance requirement.'
+          : 'Add more products — Auto-Maintenance needs at least ' + limit + ' BV.'
+      "
+    />
+    <slot />
+    <template v-if="checkout && channels.length">
+      <div class="ng-purchase-summary-channels" role="list" aria-label="Paystack payment channels">
+        <el-tag
+          v-for="badge in paymentBadges"
+          :key="badge.label"
+          size="mini"
+          effect="dark"
+          type="info"
+          role="listitem"
+        >{{ badge.label }}</el-tag>
+      </div>
+      <p class="ng-purchase-summary-note">
+        By placing this order you agree to our Terms of Service and Refund Policy. Our team will
+        contact you to confirm before dispatch.
+      </p>
+    </template>
+  </el-card>
+</template>
+
+<script>
+export default {
+  name: 'PurchaseOrderSummary',
+  props: {
+    checkout: { type: Boolean, default: false },
+    showBv: { type: Boolean, default: true },
+    items: { type: Array, default: () => [] },
+    paymentMethod: { type: String, default: '' },
+    channels: { type: Array, default: () => [] },
+    count: { type: Number, required: true },
+    subtotal: { type: [Number, String], required: true },
+    total: { type: [Number, String], required: true },
+    bv: { type: [Number, String], required: true },
+    tax: { type: [Number, String], required: true },
+    currency: { type: String, default: '' },
+    shipping: { type: String, required: true },
+    maintenance: { type: Boolean, default: false },
+    sufficient: { type: Boolean, default: true },
+    limit: { type: [Number, String], default: null }
+  },
+  computed: {
+    paymentBadges() {
+      return [
+        { channel: 'card', label: 'VISA' },
+        { channel: 'card', label: 'MC' },
+        { channel: 'card', label: 'VERVE' },
+        { channel: 'ussd', label: 'USSD' },
+        { channel: 'bank', label: 'BANK' },
+        { channel: 'qr', label: 'VISA QR' }
+      ].filter(badge => this.channels.includes(badge.channel))
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.ng-purchase-summary {
+  border-radius: 12px;
+  h2 {
+    margin: 0 0 20px;
+    color: #202259;
+    font-size: 18px;
+  }
+  > ::v-deep .el-card__body > .el-descriptions {
+    margin: 0 0 8px;
+  }
+  ::v-deep .el-descriptions {
+    color: inherit;
+    font-size: 13px;
+  }
+  ::v-deep .el-descriptions__body {
+    color: inherit;
+    background: transparent;
+  }
+  ::v-deep .el-descriptions-item__content {
+    min-width: 0;
+    justify-content: flex-end;
+    text-align: right;
+    overflow-wrap: anywhere;
+  }
+  .ng-purchase-cart-totals ::v-deep tbody:last-child .el-descriptions-item__cell {
+    padding-top: 20px;
+    border-top: 1px solid #ebeef5;
+    font-weight: 700;
+  }
+  ::v-deep .ng-purchase-total {
+    color: #202259;
+    font-size: 20px;
+    font-weight: 700;
+  }
+}
+.ng-purchase-requirement {
+  margin-bottom: 8px;
+  border: 1px solid #ebeef5;
+  border-left: 3px solid #f56c6c;
+  border-radius: 6px;
+  color: inherit;
+  background: #f4f6f8;
+  h3 {
+    margin: 0 0 16px;
+    font-size: 14px;
+  }
+  ::v-deep > .el-card__body {
+    padding: 16px;
+  }
+  ::v-deep .el-descriptions {
+    font-size: 12px;
+    line-height: 1.6;
+  }
+  ::v-deep .el-descriptions-item__cell {
+    padding-bottom: 20px;
+  }
+  ::v-deep tbody:last-child .el-descriptions-item__cell {
+    padding-bottom: 0;
+  }
+  ::v-deep .el-descriptions-item__label {
+    color: #707780;
+  }
+  &.is-sufficient {
+    border-left-color: #67c23a;
+  }
+}
+.ng-purchase-requirement-warning {
+  margin: 0 0 8px;
+}
+.ng-purchase-summary.is-checkout {
+  min-width: 0;
+  border-color: #393962;
+  border-radius: 8px;
+  color: #fff;
+  background: #101030;
+
+  ::v-deep > .el-card__body {
+    padding: 44px 24px 32px;
+  }
+  h2 {
+    margin-bottom: 24px;
+    color: #fff;
+  }
+  > ::v-deep .el-card__body > .el-descriptions {
+    margin-bottom: 0;
+  }
+  .ng-purchase-checkout-totals ::v-deep .el-descriptions-item__cell {
+    padding-bottom: 2px;
+    font-size: 14px;
+    line-height: 1.5;
+  }
+  ::v-deep .el-descriptions-item__label {
+    color: #d9daeb;
+  }
+  ::v-deep .ng-purchase-total {
+    color: #fff;
+    font-size: 14px;
+  }
+  .ng-purchase-requirement {
+    border-color: #494965;
+    border-left-color: #f56c6c;
+    background: #ffffff0d;
+
+    ::v-deep .el-descriptions-item__label {
+      color: #d9daeb;
+    }
+    &.is-sufficient {
+      border-left-color: #67c23a;
+    }
+  }
+  ::v-deep .ng-purchase-place-order.el-button {
+    width: 100%;
+    min-height: 52px;
+    padding: 14px 16px;
+    margin: 0;
+    border-color: #fff;
+    border-radius: 6px;
+    color: #202259;
+    background: #fff;
+    font-weight: 700;
+    white-space: normal;
+    line-height: 1.5;
+
+    &:not(.is-disabled):hover,
+    &:not(.is-disabled):focus {
+      border-color: #e9edf9;
+      color: #202259;
+      background: #e9edf9;
+    }
+    &:focus-visible {
+      outline: 2px solid #b5bcff;
+      outline-offset: 3px;
+    }
+    &.is-disabled {
+      border-color: #dcdde8;
+      color: #70718a;
+      background: #dcdde8;
+    }
+  }
+}
+.ng-purchase-summary-products {
+  margin: 0 0 24px;
+}
+.ng-purchase-summary-product {
+  gap: 12px;
+
+  + .ng-purchase-summary-product {
+    margin-top: 16px;
+  }
+}
+.ng-purchase-summary-image-column {
+  flex: 0 0 56px;
+  width: 56px;
+}
+.ng-purchase-summary-content {
+  flex: 1;
+  min-width: 0;
+  width: 0;
+}
+.ng-purchase-summary-image {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 56px;
+  height: 56px;
+  border: 2px solid #fff;
+  border-radius: 4px;
+  color: #606266;
+  background: #fff;
+}
+.ng-purchase-summary-product-copy {
+  gap: 12px;
+  font-size: 12px;
+  line-height: 1.5;
+  overflow-wrap: anywhere;
+
+  h3 {
+    margin: 0 0 4px;
+    font-size: 13px;
+    line-height: inherit;
+  }
+  p {
+    margin: 0;
+  }
+}
+.ng-purchase-summary-details {
+  flex: 1;
+  min-width: 0;
+  width: 0;
+}
+.ng-purchase-summary-values {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  flex: 1.15;
+  min-width: 0;
+  width: 0;
+}
+.ng-purchase-summary-quantity {
+  color: #d9daeb;
+  text-align: right;
+  white-space: nowrap;
+}
+.ng-purchase-summary-unit {
+  color: #d9daeb;
+}
+.ng-purchase-summary-line {
+  font-size: 14px;
+  text-align: right;
+}
+.ng-purchase-summary-channels {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin: 12px 0 0;
+
+  .el-tag {
+    border-radius: 20px;
+    border-color: transparent;
+    color: #fff;
+    background: #ffffff26;
+    font-size: 11px;
+    font-weight: 700;
+    text-transform: uppercase;
+  }
+}
+.ng-purchase-summary-note {
+  margin: 16px 0 0;
+  color: #d9daeb;
+  font-size: 13px;
+  line-height: 1.6;
+}
+@media (max-width: 500px) {
+  .ng-purchase-summary.is-checkout ::v-deep > .el-card__body {
+    padding: 24px 20px;
+  }
+  .ng-purchase-summary-product-copy {
+    flex-direction: column;
+  }
+  .ng-purchase-summary-details,
+  .ng-purchase-summary-values {
+    flex: auto;
+    width: 100%;
+  }
+  .ng-purchase-summary-values {
+    flex-direction: row;
+    justify-content: space-between;
+  }
+}
+</style>

+ 738 - 0
src/ui-refactor/views/purchase/PurchaseFlow.vue

@@ -0,0 +1,738 @@
+<template>
+  <section class="ng-purchase" :aria-label="title" lang="en">
+    <!--    <nav v-if="stage !== 'catalog'" class="ng-purchase-nav" aria-label="Purchase navigation">-->
+    <!--      &lt;!&ndash;      <el-button type="text" @click="$router.push('/dashboard/index')">My Account</el-button>&ndash;&gt;-->
+    <!--      &lt;!&ndash;      <span>&ndash;&gt;-->
+    <!--      &lt;!&ndash;        / {{ title }}&ndash;&gt;-->
+
+    <!--      <h3 v-if="stage === 'cart'">Shopping</h3>-->
+    <!--      &lt;!&ndash;        <template v-if="stage !== 'catalog'">&ndash;&gt;-->
+    <!--      &lt;!&ndash;&lt;!&ndash;          / {{ stage === 'cart' ? 'Shopping' : 'Confirm your order' }}&ndash;&gt;&ndash;&gt;-->
+    <!--      &lt;!&ndash;&lt;!&ndash;          <h2>Confirm your order</h2>&ndash;&gt;&ndash;&gt;-->
+    <!--      &lt;!&ndash;        </template>&ndash;&gt;-->
+    <!--&lt;!&ndash;      </span>&ndash;&gt;-->
+    <!--    </nav>-->
+    <!--    <el-alert-->
+    <!--      v-if="loadError"-->
+    <!--      :title="loadError"-->
+    <!--      type="error"-->
+    <!--      :closable="false"-->
+    <!--      show-icon-->
+    <!--      class="ng-purchase-alert"-->
+    <!--    />-->
+    <!--    <el-button-->
+    <!--      v-if="loadError"-->
+    <!--      :disabled="busy || fetching"-->
+    <!--      class="ng-purchase-retry"-->
+    <!--      @click="retry"-->
+    <!--    >-->
+    <!--      Retry-->
+    <!--    </el-button>-->
+    <template v-if="stage === 'catalog'">
+      <el-card v-if="!isSelf" shadow="never" class="ng-purchase-section ng-purchase-member-lookup">
+        <h2>Member information</h2>
+        <p id="ng-purchase-member-hint" class="ng-purchase-member-hint">
+          Enter the
+          {{ isUpgrade ? '' : isMaintenance ? 'auto-maintenance ' : 'repeat-purchase ' }}member's ID
+          (NG + 8 digits) to look up their profile. The product list below appears once the member
+          is found.
+        </p>
+        <el-form class="ng-purchase-search" @submit.native.prevent="searchMember">
+          <el-form-item label="Member ID" for="ng-purchase-search">
+            <el-input
+              id="ng-purchase-search"
+              v-model="memberSearch"
+              :disabled="busy || memberLoading || (isUpgrade && (!eligibilityReady || isDec))"
+              placeholder="NG00000000"
+              aria-describedby="ng-purchase-member-hint"
+              autocomplete="off"
+            />
+          </el-form-item>
+          <el-button
+            type="primary"
+            native-type="submit"
+            icon="el-icon-search"
+            :loading="memberLoading"
+            :disabled="busy || fetching || (isUpgrade && (!eligibilityReady || isDec))"
+          >
+            Search
+          </el-button>
+        </el-form>
+        <div v-if="memberKey && !(isUpgrade && isMax)" class="ng-purchase-member" role="status">
+          <el-avatar :size="40" class="ng-purchase-member-avatar">
+            <img src="@/assets/user_images/avatar.png" alt="">
+          </el-avatar>
+          <div class="ng-purchase-member-copy">
+            <strong class="ng-purchase-member-name">{{ memberName }}</strong>
+            <span class="ng-purchase-member-meta">
+              {{ memberKey }}<template v-if="isUpgrade"> · {{ form.userDecName }}</template>
+            </span>
+          </div>
+        </div>
+        <el-alert
+          v-if="isUpgrade && memberKey && isMax"
+          type="warning"
+          :closable="false"
+          class="ng-purchase-alert ng-purchase-max-level"
+        >
+          <template slot="title">
+            {{ memberName }} ({{ memberKey }}) is already {{ form.userDecName }} — the highest
+            upgradeable level is {{ form.userDecName }}, so no upgrade is available.
+          </template>
+        </el-alert>
+        <el-alert
+          v-if="loadError"
+          :title="loadError"
+          type="error"
+          :closable="false"
+          class="ng-purchase-alert"
+        />
+        <!--        <el-descriptions v-if="isUpgrade && memberKey" :column="1" :colon="false">-->
+        <!--          <el-descriptions-item label="Joining date">{{ form.addAt || '—' }}</el-descriptions-item>-->
+        <!--        </el-descriptions>-->
+      </el-card>
+      <component
+        :is="isSelf ? 'div' : 'UiSectionCard'"
+        v-if="catalogReady"
+        v-loading="listLoading"
+        class="ng-purchase-products"
+        :class="{ 'ng-purchase-range': isSelf }"
+        :title="isSelf ? undefined : isUpgrade ? 'Upgrade products' : 'Choose products'"
+      >
+        <h2 v-if="isSelf">Also in this range</h2>
+        <signup-products
+          :items="displayProducts"
+          :quantities="quantities"
+          :selected-ids="selectedIds"
+          country-selected
+          :subtotal="sellPriceSum"
+          :bv="pricePvSum"
+          :tax="taxSum"
+          :currency="currencyUnitCode"
+          add-label="Add to cart"
+          :help-text="productHelpText"
+          :disabled="busy || fetching"
+          :quantity-precision="0"
+          @toggle="toggleProduct"
+          @quantity="changeQuantity"
+          @details="handleProduct"
+        >
+          <template v-if="!isSelf" #footer>
+            <div class="ng-purchase-cart-hint" role="note">
+              <span class="ng-purchase-cart-hint-icon" aria-hidden="true">
+                <i class="el-icon-shopping-cart-2" />
+              </span>
+              <p><strong>Ready to check out?</strong> {{ checkoutHint }}</p>
+            </div>
+          </template>
+        </signup-products>
+        <!--        <pagination v-if="!isUpgrade && total > 0" :total="total" :page.sync="listQuery.page" :limit.sync="listQuery.limit" @pagination="getList" />-->
+      </component>
+      <el-badge :value="itemCount" :hidden="!itemCount" class="ng-purchase-fab">
+        <el-button
+          type="primary"
+          circle
+          icon="el-icon-shopping-cart-2"
+          :disabled="busy"
+          :aria-label="'Open cart, ' + itemCount + ' items'"
+          @click="navigate('cart')"
+        />
+      </el-badge>
+    </template>
+    <template v-else>
+      <h1>{{ stage === 'cart' ? 'Your Cart' : 'Confirm your order' }}</h1>
+      <p class="ng-purchase-help">
+        {{
+          stage === 'cart'
+            ? 'Review your items, adjust quantities, then proceed to checkout.'
+            : 'Confirm your details and choose a payment method.'
+        }}
+      </p>
+      <el-alert
+        v-if="cartItems.length && !itemsValid"
+        title="Some products are unavailable or have an invalid quantity. Please review your cart."
+        type="error"
+        :closable="false"
+      />
+      <div
+        class="ng-purchase-columns"
+        :class="{
+          'ng-purchase-columns-empty': isEmptyCart,
+          'ng-purchase-columns-checkout': stage === 'checkout'
+        }"
+      >
+        <div class="ng-purchase-main">
+          <template v-if="stage === 'cart'">
+            <el-card v-if="isEmptyCart" shadow="never" class="ng-purchase-empty">
+              <span class="ng-purchase-empty-icon" aria-hidden="true">
+                <i class="el-icon-shopping-cart-2" />
+              </span>
+              <h3>Your cart is empty</h3>
+              <p>Add products you love and they will appear here.</p>
+              <el-button type="primary" @click="navigate('catalog')">Browse products</el-button>
+            </el-card>
+            <cart-items
+              :items="cartRows"
+              :currency="currencyUnitCode"
+              :disabled="busy"
+              @remove="toggleProduct"
+              @quantity="changeQuantity"
+            />
+          </template>
+          <template v-else>
+            <el-form
+              ref="payForm"
+              :model="checkoutModel"
+              :rules="isSelf ? rules : {}"
+              label-position="top"
+              @submit.native.prevent="submitOrder"
+            >
+              <checkout-fields
+                :model="checkoutModel"
+                :self="isSelf"
+                :upgrade="isUpgrade"
+                :member-name="memberName || ''"
+                :regions="regionData || []"
+                :addresses="shippingAddressList"
+                :address-id="addressId"
+                :pickup-id="selfPickUpAddressId"
+                :disabled="busy"
+                @change="updateCheckout"
+                @address="choseAddress"
+              />
+            </el-form>
+            <upgrade-level-fields
+              v-if="isUpgrade && catalogReady"
+              v-model="form.upgradeValue"
+              :options="upgradeOption"
+              :current-level="form.userDecName"
+              :upgrade-method="form.upgradeFunc"
+              :difference="needDiffPv"
+              :bv="pricePvSum"
+              :disabled="busy"
+              @change="handleChangeLevel"
+            />
+            <el-card
+              v-if="isMaintenance"
+              shadow="never"
+              class="ng-purchase-section ng-purchase-maintenance"
+            >
+              <h2>Auto-Maintenance requirement</h2>
+              <p v-if="ampDivideLine != null" class="ng-purchase-help">
+                Auto-Maintenance requires the selected products' total BV to be at least
+                <strong>{{ ampDivideLine }} BV</strong>.
+              </p>
+              <dl class="ng-purchase-maintenance-values" role="status" aria-live="polite">
+                <div>
+                  <dt>Current BV</dt>
+                  <dd>{{ pricePvSum | toThousandFilter }} BV</dd>
+                </div>
+                <div>
+                  <dt>Requirement</dt>
+                  <dd :class="{ 'is-sufficient': maintenanceReady }">
+                    {{ pricePvSum | toThousandFilter }} / {{ maintenanceLimitLabel }} BV
+                    {{ maintenanceReady ? '✓' : '✕' }}
+                  </dd>
+                </div>
+              </dl>
+              <el-alert
+                v-if="!maintenanceReady"
+                :title="
+                  ampDivideLine == null
+                    ? 'Waiting for the maintenance requirement.'
+                    : 'Add more products — Auto-Maintenance needs at least ' +
+                      ampDivideLine +
+                      ' BV.'
+                "
+                type="danger"
+                :closable="false"
+                show-icon
+              />
+            </el-card>
+            <payment-methods
+              v-model="activePayType"
+              :options="payList"
+              :currency="currencyUnitCode"
+              :balances="userBalance"
+              :summary="paymentSummary"
+              :disabled="busy"
+            />
+            <el-button :disabled="busy" @click="navigate('cart')">Back to cart</el-button>
+          </template>
+        </div>
+        <order-summary
+          v-if="!isEmptyCart"
+          :checkout="stage === 'checkout'"
+          :items="summaryItems"
+          :payment-method="selectedPaymentName"
+          :channels="activePayType === 'pay_stack' ? channels : []"
+          :count="itemCount"
+          :subtotal="sellPriceSum"
+          :bv="pricePvSum"
+          :tax="taxSum"
+          :total="stage === 'cart' ? sellPriceSum : orderTotal"
+          :currency="currencyUnitCode"
+          :shipping="
+            stage === 'cart'
+              ? 'Calculated at checkout'
+              : currencyUnitCode + ' ' + (isUpgrade ? '0.00' : pointFreight)
+          "
+          :maintenance="isMaintenance && stage === 'cart'"
+          :sufficient="maintenanceReady"
+          :limit="ampDivideLine"
+        >
+          <template v-if="stage === 'cart'">
+            <upgrade-level-fields
+              v-if="isUpgrade && catalogReady"
+              v-model="form.upgradeValue"
+              :options="upgradeOption"
+              :current-level="form.userDecName"
+              :upgrade-method="form.upgradeFunc"
+              :difference="needDiffPv"
+              :bv="pricePvSum"
+              :disabled="busy"
+              @change="handleChangeLevel"
+            />
+            <el-button type="primary" :disabled="!canCheckout" @click="navigate('checkout')">
+              Proceed to checkout
+            </el-button>
+            <el-button type="text" :disabled="busy || !cartItems.length" @click="clearCart">
+              Clear cart
+            </el-button>
+            <el-button :disabled="busy" @click="navigate('catalog')">Continue shopping</el-button>
+          </template>
+          <template v-else>
+            <el-button
+              type="primary"
+              class="ng-purchase-place-order"
+              :disabled="!canCheckout"
+              :loading="submitButtonStat || submissionLocked"
+              @click="submitOrder"
+            >
+              {{ submitButtonStat || submissionLocked ? 'Processing...' : 'Place order' }}
+            </el-button>
+          </template>
+        </order-summary>
+      </div>
+    </template>
+    <paystack-payment-dialog
+      :visible="visible"
+      :email.sync="payment.email"
+      :amount="payment.amount"
+      :currency="payment.currency"
+      :loading="cancelLoading"
+      :before-close="handleClose"
+      :close-on-press-escape="false"
+      @continue="$refs.paystack.payWithPaystack()"
+    >
+      <paystack
+        v-if="visible"
+        :key="paymentAttempt"
+        ref="paystack"
+        :firstname="payment.firstname"
+        :lastname="payment.lastname"
+        :amount="payment.amount * 100"
+        :email="payment.email"
+        :metadata="payment.metadata"
+        :currency="payment.currency"
+        :paystackkey="payment.publicKey"
+        :reference="reference + paymentAttempt"
+        :channels="channels"
+        :callback="paymentCallback"
+        :close="paymentClosed"
+      />
+    </paystack-payment-dialog>
+    <order-success-dialog :visible="payDialog" :countdown="countdown" @orders="handleOrderList" />
+    <product-details-dialog
+      :visible.sync="visibleProduct"
+      :product="product"
+      :currency="currencyUnitCode"
+    />
+  </section>
+</template>
+
+<script>
+import behavior from './purchaseBehavior'
+import SignupProducts from '../signup/components/SignupProducts.vue'
+import OrderSummary from './OrderSummary.vue'
+import CheckoutFields from './CheckoutFields.vue'
+import CartItems from './CartItems.vue'
+import OrderSuccessDialog from '@/ui-refactor/components/OrderSuccessDialog.vue'
+import { getPaymentSummary } from '@/ui-refactor/utils/paymentSummary'
+import UiSectionCard from '@/components/ui/UiSectionCard.vue'
+import ProductDetailsDialog from '@/ui-refactor/components/ProductDetailsDialog.vue'
+import PaymentMethods from '@/ui-refactor/components/PaymentMethods.vue'
+import PaystackPaymentDialog from '@/ui-refactor/components/PaystackPaymentDialog.vue'
+import UpgradeLevelFields from './UpgradeLevelFields.vue'
+
+export default {
+  ...behavior,
+  components: {
+    SignupProducts,
+    OrderSummary,
+    CheckoutFields,
+    CartItems,
+    OrderSuccessDialog,
+    UiSectionCard,
+    ProductDetailsDialog,
+    PaymentMethods,
+    PaystackPaymentDialog,
+    UpgradeLevelFields
+  },
+  computed: {
+    ...behavior.computed,
+    isEmptyCart() {
+      return this.stage === 'cart' && !this.cartItems.length
+    },
+    maintenanceLimitLabel() {
+      return this.ampDivideLine == null ? '—' : this.ampDivideLine
+    },
+    checkoutHint() {
+      if (this.isUpgrade) {
+        return 'Tap the cart button in the bottom-right corner to pick the upgrade level and place the order.'
+      }
+      if (this.isMaintenance) {
+        return 'Tap the cart button in the bottom-right corner to review the order and place it.'
+      }
+      return 'Tap the cart button in the bottom-right corner to review your items and place the order.'
+    },
+    productHelpText() {
+      if (this.isSelf) return 'Member pricing and BV rewards are shown at checkout.'
+      if (this.isUpgrade) {
+        return "Tap a product to add it to the member's cart. Choose the upgrade level in your cart or at checkout. Prices shown are DP (distributor price) with BV, and the retail reference RP."
+      }
+      if (this.isMaintenance) {
+        return "Tap a product to add it to the member's cart. Prices shown are DP (distributor price) with BV, and the retail reference RP. Auto-Maintenance requires the selected products' total BV to be at least 90 BV."
+      }
+      return 'Tap a product to add it to your cart. Prices shown are DP (distributor price) with BV, and the retail reference RP.'
+    },
+    displayProducts() {
+      return (this.tableData || []).map(item => ({
+        ...item,
+        CODE: item.CODE || item.COIN || this.currencyUnitCode
+      }))
+    },
+    checkoutModel() {
+      // 升级的报单中心由旧页顶层字段提交,表单显示沿用同一来源。
+      return this.isSelf
+        ? this.payForm
+        : this.isUpgrade
+          ? { ...this.form, decUserName: this.decUserName }
+          : this.newOrder
+    },
+    selectedPaymentName() {
+      const selected = this.payList.find(option => option.label === this.activePayType)
+      return selected ? selected.name : ''
+    },
+    paymentSummary() {
+      const balance = this.userBalance[this.activePayType]
+      return getPaymentSummary(
+        this.orderTotal,
+        balance,
+        !this.isUpgrade || this.activePayType === 'cash'
+      )
+    },
+    summaryItems() {
+      return this.cartRows.map(item => {
+        const unitBv = Number(item.PRICE_PV) * (this.isUpgrade ? 1 : Number(item.DISCOUNT) / 100)
+        return {
+          ...item,
+          image: this.tool.getArImage(item.COVER, '/files/'),
+          unitBv: this.tool.formatPrice(unitBv),
+          lineBv: this.tool.formatPrice(unitBv * item.quantity)
+        }
+      })
+    }
+  },
+  methods: {
+    ...behavior.methods,
+    updateCheckout(patch) {
+      if (this.isUpgrade && 'decUserName' in patch) this.decUserName = patch.decUserName
+      else Object.assign(this.isUpgrade ? this.form : this.checkoutModel, patch)
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.ng-purchase {
+  min-width: 0;
+  color: #25292c;
+}
+.ng-purchase h1 {
+  margin: 16px 0;
+  font-size: 30px;
+  line-height: 1.3;
+}
+.ng-purchase h2 {
+  margin: 0 0 20px;
+  color: #202259;
+  font-size: 18px;
+}
+.ng-purchase .ng-purchase-range > h2 {
+  margin-bottom: 8px;
+  color: inherit;
+  font-size: 30px;
+  line-height: 1.3;
+}
+.ng-purchase-range ::v-deep .ng-signup-help {
+  margin-bottom: 32px;
+}
+.ng-purchase-cart-hint {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 16px;
+  margin-bottom: 20px;
+  border: 1px solid #dcdfe6;
+  border-left: 3px solid #202259;
+  border-radius: 6px;
+  color: #606266;
+  background: #f1f2f5;
+  font-size: 13px;
+  line-height: 1.6;
+}
+.ng-purchase-cart-hint-icon {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  flex: 0 0 36px;
+  width: 36px;
+  height: 36px;
+  border-radius: 50%;
+  color: #fff;
+  background: #202259;
+  font-size: 18px;
+}
+.ng-purchase-cart-hint p {
+  margin: 0;
+  min-width: 0;
+}
+.ng-purchase-cart-hint strong {
+  color: #202259;
+}
+.ng-purchase-nav {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 12px;
+  margin-bottom: 24px;
+  color: #606266;
+  font-size: 13px;
+}
+.ng-purchase-help {
+  //margin: 0 0 24px;
+  color: #606266;
+  font-size: 13px;
+  line-height: 1.8;
+}
+.ng-purchase-alert {
+  margin-top: 24px;
+}
+.ng-purchase-max-level {
+  padding: 12px 16px;
+  margin-top: 16px;
+  border-left: 3px solid #967322;
+  border-radius: 8px;
+  color: #202259;
+  background: #fcf6e3;
+}
+.ng-purchase-max-level ::v-deep .el-alert__content {
+  min-width: 0;
+  padding: 0;
+}
+.ng-purchase-max-level ::v-deep .el-alert__title {
+  font-size: 14px;
+  line-height: 1.6;
+  overflow-wrap: anywhere;
+}
+.ng-purchase-section {
+  margin-bottom: 24px;
+  border-radius: 12px;
+}
+.ng-purchase-retry {
+  margin-bottom: 24px;
+}
+.ng-purchase-search {
+  display: flex;
+  align-items: flex-end;
+  gap: 16px;
+  margin-bottom: 16px;
+}
+.ng-purchase-search .el-form-item {
+  flex: 1;
+  margin-bottom: 0;
+  min-width: 0;
+}
+.ng-purchase-search > .el-button {
+  min-height: 36px;
+}
+.ng-purchase-member-lookup h2 {
+  padding-bottom: 16px;
+  margin-bottom: 12px;
+  border-bottom: 1px solid #e4e7ed;
+  font-size: 16px;
+}
+.ng-purchase-member-hint {
+  margin: 0 0 16px;
+  color: #707780;
+  font-size: 14px;
+  line-height: 1.6;
+}
+.ng-purchase-member-lookup .ng-purchase-search {
+  gap: 12px;
+  margin-bottom: 0;
+}
+.ng-purchase-member-lookup .ng-purchase-search ::v-deep .el-form-item__label {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  padding: 0;
+  margin: -1px;
+  overflow: hidden;
+  clip: rect(0, 0, 0, 0);
+  white-space: nowrap;
+}
+.ng-purchase-member-lookup .ng-purchase-search ::v-deep .el-input__inner {
+  height: 42px;
+  line-height: 42px;
+}
+.ng-purchase-member-lookup .ng-purchase-search > .el-button {
+  min-height: 42px;
+}
+.ng-purchase-member {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin-top: 16px;
+  color: #202259;
+  font-size: 13px;
+  line-height: 20px;
+}
+.ng-purchase-member-avatar {
+  flex-shrink: 0;
+}
+.ng-purchase-member-copy {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+  min-width: 0;
+  overflow-wrap: anywhere;
+}
+.ng-purchase-member-name {
+  font-size: 14px;
+}
+.ng-purchase-member-meta {
+  color: #606266;
+}
+.ng-purchase .el-select {
+  width: 100%;
+}
+.ng-purchase-fab {
+  position: fixed;
+  right: 24px;
+  bottom: 28px;
+  z-index: 100;
+}
+.ng-purchase-fab .el-button {
+  width: 52px;
+  height: 52px;
+  font-size: 22px;
+  box-shadow: 0 6px 24px #20225926;
+}
+.ng-purchase-columns {
+  display: grid;
+  grid-template-columns: minmax(0, 2fr) minmax(300px, 1fr);
+  gap: 24px;
+  align-items: start;
+}
+.ng-purchase-columns-empty {
+  grid-template-columns: minmax(0, 1fr);
+}
+.ng-purchase-columns-checkout {
+  align-items: stretch;
+}
+.ng-purchase-empty {
+  padding: 32px 4px;
+  border-radius: 12px;
+  text-align: center;
+}
+.ng-purchase-empty-icon {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 64px;
+  height: 64px;
+  border-radius: 50%;
+  color: #909399;
+  background: #eaf5fa;
+  font-size: 30px;
+}
+.ng-purchase-empty h3 {
+  margin: 16px 0 12px;
+  font-size: 18px;
+  line-height: 1.5;
+}
+.ng-purchase-empty p {
+  margin: 0 0 20px;
+  color: #909399;
+  font-size: 13px;
+  line-height: 1.6;
+}
+.ng-purchase-main {
+  min-width: 0;
+}
+.ng-purchase-summary ::v-deep > .el-card__body > .el-button {
+  display: block;
+  width: 100%;
+  margin: 12px 0 0;
+  white-space: normal;
+  line-height: 1.5;
+}
+.ng-purchase-maintenance-values {
+  margin: 0;
+  font-size: 13px;
+  line-height: 1.8;
+
+  > div {
+    display: flex;
+    flex-wrap: wrap;
+    justify-content: space-between;
+    gap: 8px;
+  }
+  dd {
+    margin: 0;
+    overflow-wrap: anywhere;
+  }
+  .is-sufficient {
+    color: #1f6b3f;
+  }
+}
+.ng-purchase-maintenance-values + .el-alert {
+  margin-top: 16px;
+}
+@media (max-width: 900px) {
+  .ng-purchase-columns {
+    grid-template-columns: minmax(0, 1fr);
+  }
+}
+@media (max-width: 500px) {
+  .ng-purchase-search {
+    align-items: stretch;
+    flex-direction: column;
+  }
+  .ng-purchase-fab {
+    right: 16px;
+    bottom: 20px;
+  }
+  .ng-purchase h1 {
+    font-size: 26px;
+  }
+  .ng-purchase .ng-purchase-range > h2 {
+    font-size: 26px;
+  }
+  .ng-purchase-section ::v-deep > .el-card__body {
+    padding: 16px;
+  }
+}
+</style>

+ 58 - 0
src/ui-refactor/views/purchase/PurchasePage.vue

@@ -0,0 +1,58 @@
+<template>
+  <component :is="flow" v-if="flow" :key="source" ref="flow" :stage="stage" />
+</template>
+
+<script>
+import router from '@/router'
+import adapters from './adapters'
+import { isSource } from './routes'
+import { clearDrafts } from './drafts'
+
+// 包含退出登录和会话过期跳转,不更改原登录守卫。
+router.afterEach(to => {
+  if (to.path === '/login') clearDrafts()
+})
+
+export default {
+  name: 'MemberPurchasePage',
+  computed: {
+    source() {
+      return this.$route.meta.purchaseSource || this.$route.query.from
+    },
+    stage() {
+      return this.$route.meta.purchaseStage
+    },
+    flow() {
+      return isSource(this.source) ? adapters[this.source] : null
+    }
+  },
+  watch: {
+    source: {
+      immediate: true,
+      handler(source) {
+        if (!isSource(source)) this.$router.replace('/dashboard/index')
+      }
+    }
+  },
+  beforeRouteLeave(to, from, next) {
+    this.checkNavigation(to, next)
+  },
+  beforeRouteUpdate(to, from, next) {
+    this.checkNavigation(to, next)
+  },
+  methods: {
+    checkNavigation(to, next) {
+      if (this.$refs.flow && !this.$refs.flow.mayLeave(to)) {
+        this.$message.info('Complete or cancel the current payment before leaving.')
+        next(false)
+      } else if (['/shop/own-order', '/shop/member-order'].includes(to.path)) {
+        // 保留旧提交及支付回调的分类判断,仅在新页面转换成功后的去向。
+        next({
+          path: '/dashboard/index',
+          query: { tab: 'orders', orderCategory: to.path === '/shop/own-order' ? 'self' : 'member' }
+        })
+      } else next()
+    }
+  }
+}
+</script>

+ 82 - 0
src/ui-refactor/views/purchase/README.md

@@ -0,0 +1,82 @@
+# 会员购买流程
+
+2026-10-16,按用户确认的会员购买 UI 重构方案实施。Vue 2 / JavaScript / Options API / Element UI 保持不变。
+
+## 入口与复用
+
+会员首页四个快捷入口分别打开 `/dashboard/repeat-purchase`、`/dashboard/member-repurchase`、`/dashboard/member-auto-maintenance`、`/dashboard/member-upgrade`。购物车和结算共用 `/dashboard/cart?from=<来源>`、`/dashboard/checkout?from=<来源>`,来源仅允许上述四种标识。现有登录守卫继续保护这些页面。
+
+`PurchasePage` 根据来源选择四个薄适配器;适配器通过 `?vue&type=script&lang=js` 继承对应旧页的数据、计算、商品详情及原有业务方法,不引入旧模板和样式。初始化、购物车和异步提交由新适配层管理,避免恢复草稿时被旧 hooks 重置,以及旧提交方法将支付密码打印到控制台。四类实际旧提交方法与新提交的请求体已有等价测试,API 文件未修改。
+
+`PurchaseFlow` 组合现有 `SignupProducts`、`UiDialog`,以及共用订单摘要、结算字段。商品控件只增加提示/按钮文案、禁用状态和数量精度参数,默认值保留注册页现状。当前会员页头、页脚、字体、主题直接复用。
+
+### 2026-10-20 会员升级 UI
+
+购物车摘要和结算页共用 `UpgradeLevelFields.vue`,只使用 Element UI Card / Form / Select / Descriptions 展示当前等级、升级方式、目标等级、补差 BV 和当前商品 BV。两处绑定原 `form.upgradeValue`,选项继续取旧接口 `LEVEL_LIST` 的 `ID` / `LEVEL_NAME` / `REPAIR_PV`,change 事件直接调用继承的旧 `handleChangeLevel`,不重算补差值。会员查询处保留原 `ADD_AT` 的入会日期展示,结算会员编号使用升级语义。
+
+旧 `src/views/user/member-upgrade.vue`、查询适配、计价、提交、支付及草稿逻辑均不修改。旧提交没有以商品 BV 对比补差 BV 的前端拦截,本次不移植原型中的这条规则;等级与会员必填、余额检查、请求字段和原支付链路保持现有逻辑。
+
+## 状态和业务边界
+
+- 草稿仅在模块内存中保存,按购买来源隔离,每个来源只保留当前会员。页面往返及经过会员首页保留;更换会员清空该模块商品和结算字段。刷新、退出登录、登录会话变化后清空。
+- 草稿不保存支付密码、订单交易编号或 SDK 元数据。旧请求不能写入新登录会话。返回购买页使用来源白名单映射,不依赖历史返回或任意 URL。
+- 所有分页商品进入同一选择列表,再投影为旧代码使用的 `chose_num` / `goodsNum` 数据形状。数量为正整数且不得超过库存,未知价格/BV/税率/折扣不可结算。
+- 复消三类保留原 `SELL_PRICE × 数量 × DISCOUNT / 100`、BV 折扣、税额展示、自提及免运费逻辑;升级仍按原价格/BV计算。税额不再次加到总额。
+- 自动续期门槛来自 `ampDivideLine`,未取得有效门槛或不足时禁止结算和提交,支付方式继续排除 `prp`。
+- 升级保留会员查询、目标等级选择、商品选择的原顺序;保留满级/报单中心限制、等级及差额字段、原提交校验。未增加差额 BV 拦截。
+- 支付方式、支付密码提示、API、金额来源、Paystack channels、取消中间订单及成功后的订单分类沿用对应旧页。新页面将旧订单地址转为首页 Orders 对应分类;旧页面、旧路由及提交方法保留。新层锁定提交上下文,阻止重复提交、支付中切换和上一交易的迟到回调;取消失败保留支付窗口和商品,便于重试。
+
+## 验证证据
+
+| 验证 | 结果 |
+|---|---|
+| 新购买流程测试 | 45 项通过;覆盖草稿隔离/恢复/刷新、跨分页商品、数量及金额不变量、接口门槛、缺失会员、余额不足、新旧请求体等价、四类下单失败/取消、重复提交、旧回调及来源白名单 |
+| 新购买流程 + 注册/支付 + 首页入口回归 | 92 项通过 |
+| 现有会员端全部测试 | 158 项通过、7 项既有失败;改动前为 113 通过、同样 7 失败 |
+| 故障注入 | 5/5 被测试捕获:允许零数量、反转 BV 比较、遗漏前页商品、取消重复提交保护、放宽来源白名单;运行后恢复原文件 |
+| 定向 lint | 0 错误、0 警告 |
+| 完整生产构建 | 退出 0,输出到临时目录;仍有资源/入口体积及浏览器数据库过期提示 |
+| 浏览器样例 | 四种购买来源 × 商品/购物车/结算 × 六种视口,共 72 项布局检查通过;覆盖 320/375/768/1024/1440px、375×480、继续购物、键盘返回、刷新清空及未登录拦截;页面异常/console error 为 0 |
+| 存量文件保护 | 对任务开始时 434 个既有文件(含未跟踪文件、被忽略的 lockfile/环境文件)核对 SHA-256,仅路由、首页快捷入口、商品选择控件、直接相关入口测试发生本轮修改;无文件删除 |
+
+团队页入口测试原来要求四个购买按钮禁用;本轮随入口接入更新该相关断言,新增四个购买入口的目标路径及 query 传递检查。其他既有失败测试未改动:主题约束 1、资料开关 1、订单字段/分页 3、余额 Reset 定位 1、网络深度 1。
+
+路由文件沿用原 CRLF 换行,`git diff --check` 会对该文件的 CR 行尾给出尾空白提示,未为消除提示批量转换文件。去掉本轮新增的 import 和路由展开两行后,路由内容 SHA-256 与任务开始基线相同;新购买目录没有行尾空格。
+
+## 复现命令
+
+在项目根目录运行(PowerShell):
+
+```powershell
+node node_modules/eslint/bin/eslint.js --ext .js,.vue,.cjs src/ui-refactor/views/purchase src/ui-refactor/views/signup/components/SignupProducts.vue src/ui-refactor/views/account/components/AccountShortcuts.vue src/ui-refactor/views/team-inquiry/__tests__/TeamInquiryPage.spec.js
+node node_modules/jest/bin/jest.js --runInBand --testMatch '**/purchase/__tests__/*.spec.js' '**/tests/unit/ui-refactor/*.spec.js' '**/team-inquiry/__tests__/*.spec.js'
+node node_modules/jest/bin/jest.js --runInBand --testMatch '**/__tests__/*.spec.js' '**/tests/unit/ui-refactor/*.spec.js'
+node src/ui-refactor/views/purchase/__tests__/verifyMutations.cjs
+$env:NODE_OPTIONS='--openssl-legacy-provider'
+node node_modules/@vue/cli-service/bin/vue-cli-service.js build --dest "$env:TEMP/ng-purchase-build" --no-clean
+```
+
+浏览器脚本使用机器已有的 Playwright / Chrome,不在项目安装依赖。让 `NODE_PATH` 指向已安装 Playwright 的 `node_modules` 后运行:
+
+```powershell
+node src/ui-refactor/views/purchase/__tests__/verifyBrowser.cjs "$env:TEMP/ng-purchase-build" "$env:TEMP/ng-purchase-screenshots"
+```
+
+脚本只启动本地静态服务,所有 API 响应为明确的验证样例;未配置的远程请求不放行,不执行真实下单。截图和 `results.json` 写入指定临时输出目录。
+
+## 未验证与限制
+
+未进行真实账号、后端下单、实际 Paystack 或扣款联调;浏览器里的商品图和会员均为隔离样例,不能证明真实认证/支付成功。升级差额是否满足继续由旧后端规则处理。
+
+未测行覆盖率:现有测试直接编译 SFC 和旧脚本查询,不能用普通 vue-jest 覆盖率代表这些执行路径;本轮使用行为、真实旧请求等价、数量不变量和故障注入验证。项目没有独立 TypeScript typecheck,本轮未新增类型工具或依赖;依赖未变,因此未做依赖审计。未提交、推送或部署。
+
+## 2026-10-16 会员查询返回兼容修复
+
+旧会员复消和自动续期的 `memberDetails.number` 来自输入框;`full-info` 返回 `userId`、`REAL_NAME`、`countryId` 等字段,不要求包含 `number`。新适配层此前错误地校验返回的 `number`,且测试样例也带了该字段,导致有效会员被误判为未找到。
+
+修复后先用本次查询编号构造 `memberDetails.number`,再按旧页方式合并返回信息;订单使用该编号,商品及结算请求使用返回的 `userId`,地区查询使用返回的 `countryId`。升级仍独立调用 `upgrade-info` / `upgrade`,旧页面和 API 未修改。
+
+- 两个无 `number` 的查询回归在修复前均复现 `Member not found.`,修复后通过;测试继续验证商品、配送及提交会员编号。新增两个缺少 `userId` 的回归,确认旧会员及购物车清空且不请求结算。
+- 单元测试和浏览器样例均改用不含 `number` 的接口结构。购买相关 49 项通过;会员端全套 162 项通过、同样 7 项既有失败,没有新增失败。
+- 8/8 故障注入被捕获,包含误要求返回编号、丢失订单会员编号、遗漏会员身份校验;原文件已恢复。定向 lint、临时生产构建及 90 组浏览器布局/流程检查通过。
+- 未使用真实账号查询、下单或支付;覆盖率与工具限制仍见上一节。沿用上方复现命令。

+ 83 - 0
src/ui-refactor/views/purchase/UpgradeLevelFields.vue

@@ -0,0 +1,83 @@
+<template>
+  <el-card shadow="never" class="ng-purchase-upgrade">
+    <h3 class="ng-purchase-upgrade-title">Upgrade member</h3>
+    <p class="ng-purchase-upgrade-note">
+      Current level: <strong>{{ currentLevel || '—' }}</strong>. Select the level to upgrade to.
+    </p>
+    <el-form label-position="top" :disabled="disabled" @submit.native.prevent>
+      <el-form-item label="Upgrade method">
+        <el-input :value="upgradeMethod" disabled />
+      </el-form-item>
+      <el-form-item label="Upgrade to" for="ng-purchase-upgrade-level" required>
+        <el-select
+          id="ng-purchase-upgrade-level"
+          :value="value"
+          value-key="value"
+          placeholder="Select level"
+          popper-class="ng-refactor-popper"
+          @input="$emit('input', $event)"
+          @change="$emit('change', $event)"
+        >
+          <el-option
+            v-for="level in options"
+            :key="level.ID"
+            :label="level.LEVEL_NAME"
+            :value="{ value: level.ID, diffPv: level.REPAIR_PV }"
+          />
+        </el-select>
+      </el-form-item>
+    </el-form>
+    <el-descriptions :column="1" :colon="false">
+      <el-descriptions-item label="Upgrade difference">
+        <template
+          v-if="difference !== '' && difference != null"
+        >{{ difference | toThousandFilter }} BV</template>
+        <span v-else>—</span>
+      </el-descriptions-item>
+      <el-descriptions-item label="Current BV">{{ bv | toThousandFilter }} BV</el-descriptions-item>
+    </el-descriptions>
+  </el-card>
+</template>
+
+<script>
+import { toThousandFilter } from '@/filters'
+
+export default {
+  name: 'UpgradeLevelFields',
+  filters: { toThousandFilter },
+  props: {
+    value: { type: [Object, String], default: '' },
+    options: { type: Array, required: true },
+    currentLevel: { type: String, default: '' },
+    upgradeMethod: { type: String, default: '' },
+    difference: { type: [Number, String], default: '' },
+    bv: { type: [Number, String], default: 0 },
+    disabled: { type: Boolean, default: false }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.ng-purchase-upgrade {
+  min-width: 0;
+  margin-bottom: 16px;
+}
+
+.ng-purchase-upgrade-title {
+  margin: 0 0 12px;
+  font-size: 16px;
+  line-height: 1.5;
+}
+
+.ng-purchase-upgrade-note {
+  margin: 0 0 16px;
+  color: #606266;
+  font-size: 13px;
+  line-height: 1.6;
+  overflow-wrap: anywhere;
+}
+
+.ng-purchase-upgrade .el-select {
+  width: 100%;
+}
+</style>

+ 201 - 0
src/ui-refactor/views/purchase/__tests__/OrderNavigation.spec.js

@@ -0,0 +1,201 @@
+/* eslint-env jest */
+import Vue from 'vue'
+import Router from 'vue-router'
+import ElementUI from 'element-ui'
+import fs from 'fs'
+import path from 'path'
+import { transformSync } from '@babel/core'
+import { parseComponent, compileTemplate } from 'vue/compiler-sfc'
+import * as shop from '@/api/shop'
+import * as user from '@/api/user'
+import usersInfo from '@/utils/usersInfo'
+import { toThousandFilter } from '@/filters'
+import { load } from './loadComponent'
+
+jest.mock('@/api/shop', () => Object.fromEntries(['fetchProductList', 'fetchReconsumeProductList', 'fetchAutoMaintenanceProductList', 'fetchShoppingCart', 'createOrder', 'createApproachOrder', 'createReconsumeOrder', 'createApproachReconsumeOrder', 'createAutoMaintenanceOrder', 'createApproachAutoMaintenanceOrder', 'deleteApproachOrder', 'fetchOrderList', 'downloadOrder'].map(key => [key, jest.fn()])))
+jest.mock('@/api/user', () => Object.fromEntries(['fetchFullInfo', 'fetchUpgrade', 'fetchUpgradeInfo', 'fetchSendUpgrade', 'getUserInfo', 'fetchOrderList', 'downloadOrder'].map(key => [key, jest.fn()])))
+jest.mock('@/api/config', () => ({ getRegionList: jest.fn().mockResolvedValue({ data: [] }) }))
+jest.mock('@/utils/usersInfo', () => ({ userId: () => 'OP1', loginTimestamp: jest.fn(), userName: () => 'OP1', userEmail: () => 'operator@example.test' }))
+jest.mock('@/router', () => ({ afterEach: jest.fn() }))
+jest.mock('vue-paystack', () => ({ render: h => h('button') }))
+Vue.use(Router)
+Vue.use(ElementUI)
+Vue.filter('toThousandFilter', toThousandFilter)
+Vue.prototype.$t = key => key
+Vue.prototype.$message = Object.assign(jest.fn(), { error: jest.fn(), info: jest.fn() })
+Vue.prototype.$prompt = jest.fn().mockResolvedValue({ value: 'fixture-password' })
+Vue.prototype.$confirm = jest.fn().mockResolvedValue()
+
+const PurchasePage = load(path.resolve(__dirname, '../PurchasePage.vue'))
+const drafts = load(path.resolve(__dirname, '../drafts.js'))
+const purchaseRoutes = load(path.resolve(__dirname, '../routes.js')).default.map(route => ({ ...route, path: '/' + route.path, component: PurchasePage }))
+const AccountOrders = load(path.resolve('src/ui-refactor/views/account/components/AccountOrders.vue'))
+const accountFile = path.resolve('src/ui-refactor/views/account/AccountPage.vue')
+const parts = parseComponent(fs.readFileSync(accountFile, 'utf8'))
+const accountModule = { exports: {}}
+const script = transformSync(parts.script.content, { babelrc: false, configFile: false, plugins: [require.resolve('@babel/plugin-transform-modules-commonjs')] }).code
+// 保留真实主页签与订单分类,仅隔离本任务无关的账户区块。
+new Function('require', 'module', 'exports', script)(id => id.endsWith('/AccountOrders.vue') ? AccountOrders : { render: h => h('div') }, accountModule, accountModule.exports)
+const template = compileTemplate({ source: parts.template.content, filename: accountFile })
+if (template.errors.length) throw new Error(template.errors.join('\n'))
+const Account = Object.assign(accountModule.exports.default, new Function(template.code + '; return { render, staticRenderFns }')())
+const product = { ID: 'P1', GOODS_NAME: 'Sample', SELL_PRICE: 100, MARKET_PRICE: 120, PRICE_PV: 60, DISCOUNT: 100, STORE_NUMS: 10, TAX_RATE: 7.5, CODE: 'NGN', COIN: 'NGN' }
+const payList = [{ label: 'cash', name: 'Ecoin' }, { label: 'pay_stack', name: 'Paystack' }]
+const cases = [
+  ['repeat-purchase', 'self', shop.createOrder, shop.createApproachOrder],
+  ['member-repurchase', 'member', shop.createReconsumeOrder, shop.createApproachReconsumeOrder],
+  ['member-auto-maintenance', 'self', shop.createAutoMaintenanceOrder, shop.createApproachAutoMaintenanceOrder],
+  ['member-upgrade', 'member', user.fetchSendUpgrade, user.fetchSendUpgrade]
+]
+const instances = []
+async function settle() { for (let i = 0; i < 20; i++) { await Promise.resolve(); await Vue.nextTick() } }
+async function mount(location) {
+  const router = new Router({ mode: 'abstract', routes: [
+    ...purchaseRoutes,
+    { path: '/dashboard/index', component: Account },
+    { path: '/shop/own-order', component: { render: h => h('div', 'Legacy own orders') }},
+    { path: '/shop/member-order', component: { render: h => h('div', 'Legacy member orders') }}
+  ] })
+  const root = new Vue({ router, render: h => h('router-view') }).$mount()
+  document.body.appendChild(root.$el)
+  instances.push(root)
+  router.push(location)
+  await settle()
+  return { root, router }
+}
+async function checkout(source) {
+  const context = await mount('/dashboard/' + source)
+  const flow = context.root.$children[0].$refs.flow
+  if (!flow.isSelf) { flow.memberSearch = 'NG2'; await flow.searchMember(); await settle() }
+  if (flow.isUpgrade) { flow.form.upgradeValue = { value: 'L2', diffPv: 300 }; flow.handleChangeLevel(flow.form.upgradeValue) }
+  flow.toggleProduct(flow.tableData[0])
+  flow.changeQuantity({ row: product, value: 2 })
+  flow.navigate('checkout')
+  await settle()
+  if (flow.isSelf) flow.payForm.recUserName = 'RC1'
+  else if (!flow.isUpgrade) Object.assign(flow.newOrder, { decUserName: 'RC1', consignee: 'Recipient', mobile: '123', areaSelected: ['S1'], lgaName: 'District', cityName: 'City', address: 'Street' })
+  else Object.assign(flow.form, { consignee: 'Recipient', acceptMobile: '123', areaSelected: ['S1'], lgaName: 'District', cityName: 'City', address: 'Street' })
+  await settle()
+  return { ...context, flow }
+}
+function expectOrders({ root, router }, category) {
+  expect(router.currentRoute.path).toBe('/dashboard/index')
+  expect(router.currentRoute.query).toEqual({ tab: 'orders', orderCategory: category })
+  expect(root.$el.querySelector('#tab-orders').getAttribute('aria-selected')).toBe('true')
+  expect(root.$el.querySelector('#tab-' + category).getAttribute('aria-selected')).toBe('true')
+  expect((category === 'self' ? shop : user).fetchOrderList).toHaveBeenCalledWith({ v: 2, page: 1, pageSize: 20 })
+  expect((category === 'self' ? user : shop).fetchOrderList).not.toHaveBeenCalled()
+}
+beforeEach(() => {
+  jest.useFakeTimers()
+  jest.clearAllMocks()
+  drafts.clearDrafts()
+  usersInfo.loginTimestamp.mockReturnValue('session')
+  const list = { data: { list: [product], totalCount: 1 }}
+  for (const api of [shop.fetchProductList, shop.fetchReconsumeProductList, shop.fetchAutoMaintenanceProductList]) api.mockResolvedValue(list)
+  shop.fetchShoppingCart.mockResolvedValue({ data: { allAddress: [{ ID: 'A1', IS_DEFAULT: '1', ADDRESS: 'Street', MOBILE: '123', CONSIGNEE: 'Recipient' }], userBalance: { cash: 10000 }, payList: { cash: { name: 'Ecoin' }, pay_stack: { name: 'Paystack' }}, sellType: [{ id: 1, sell_type: payList }], freeShipping: 500, freight: 10, coin: 'NGN', isDec: '0', ampDivideLine: '90' }})
+  user.getUserInfo.mockResolvedValue({ data: { IS_DEC: '1' }})
+  user.fetchFullInfo.mockResolvedValue({ data: { userId: 'U2', countryId: '1', REAL_NAME: 'Target member' }})
+  user.fetchUpgradeInfo.mockResolvedValue({ data: { baseInfo: { REAL_NAME: 'Target member', DEC_NAME: 'Member', IS_MAX: false, LEVEL_LIST: [{ ID: 'L2', LEVEL_NAME: 'Silver', REPAIR_PV: 300 }], NOW_PERF: 0, NEXT_PERF: 300 }}})
+  user.fetchUpgrade.mockResolvedValue({ data: { userBalance: { cash: 10000 }, decUserName: 'RC1', allDecPackage: {}, allGoods: [product], region: [], sellType: [{ sell_type: payList }] }})
+  for (const [, , cash, paystack] of cases) for (const api of [cash, paystack]) api.mockResolvedValue({ data: { SN: 'ORDER1', ORDER_AMOUNT: 200 }})
+  shop.deleteApproachOrder.mockResolvedValue({})
+  for (const api of [shop, user]) api.fetchOrderList.mockResolvedValue({ data: { list: [], totalCount: '0' }})
+})
+afterEach(() => {
+  instances.splice(0).forEach(vm => { const element = vm.$el; vm.$destroy(); element.remove() })
+  jest.clearAllTimers()
+  jest.useRealTimers()
+})
+
+describe.each(['cash', 'paystack-countdown', 'paystack-button'])('%s success navigation', payment => {
+  it.each(cases)('opens the corresponding homepage order category for %s', async(source, category) => {
+    const context = await checkout(source)
+    const { flow } = context
+    expect(flow.canCheckout).toBe(true)
+    flow.activePayType = payment === 'cash' ? 'cash' : 'pay_stack'
+    flow.$el.querySelector('.ng-purchase-place-order').click()
+    await settle()
+    if (payment !== 'cash') {
+      expect(context.router.currentRoute.path).toBe('/dashboard/checkout')
+      flow.processPayment()
+      await settle()
+      if (payment === 'paystack-countdown') jest.advanceTimersByTime(5000)
+      else {
+        const dialog = Array.from(document.querySelectorAll('.el-dialog')).find(element => element.querySelector('.el-dialog__title')?.textContent === 'Order successful')
+        dialog.querySelector('.el-dialog__footer button').click()
+      }
+      await settle()
+    }
+    expectOrders(context, category)
+  })
+})
+
+it.each(['cash', 'pay_stack'])('preserves the alternate legacy auto-maintenance destination for %s', async payment => {
+  const context = await checkout('member-auto-maintenance')
+  context.flow.memberDetails.number = 'OTHER'
+  context.flow.activePayType = payment
+  context.flow.submitOrder()
+  await settle()
+  if (payment === 'pay_stack') { context.flow.processPayment(); jest.advanceTimersByTime(5000); await settle() }
+  expectOrders(context, 'member')
+})
+
+it('keeps failed and cancelled orders in checkout and blocks leaving during payment', async() => {
+  const { flow, router } = await checkout('repeat-purchase')
+  shop.createApproachOrder.mockRejectedValueOnce(new Error('Order failed'))
+  flow.activePayType = 'pay_stack'
+  flow.submitOrder()
+  await settle()
+  expect(router.currentRoute.path).toBe('/dashboard/checkout')
+  flow.submitOrder()
+  await settle()
+  router.push({ path: '/dashboard/index', query: { tab: 'orders', orderCategory: 'self' }})
+  await settle()
+  expect(router.currentRoute.path).toBe('/dashboard/checkout')
+  await flow.handleClose()
+  await settle()
+  expect(router.currentRoute.path).toBe('/dashboard/checkout')
+  expect(flow.cartItems).toHaveLength(1)
+  expect(shop.fetchOrderList).not.toHaveBeenCalled()
+})
+
+it.each([['self', 'self'], ['member', 'member'], ['invalid', 'self'], [undefined, 'self']])('opens the requested order category %s with a safe default', async(orderCategory, expected) => {
+  const { root } = await mount({ path: '/dashboard/index', query: { tab: 'orders', ...(orderCategory ? { orderCategory } : {}) }})
+  expect(root.$el.querySelector('#tab-' + expected).getAttribute('aria-selected')).toBe('true')
+  expect((expected === 'self' ? shop : user).fetchOrderList).toHaveBeenCalledTimes(1)
+  expect((expected === 'self' ? user : shop).fetchOrderList).not.toHaveBeenCalled()
+})
+
+it('reacts to same-page order category changes and browser history while allowing manual selection', async() => {
+  const { root, router } = await mount({ path: '/dashboard/index', query: { tab: 'orders', orderCategory: 'self' }})
+  const account = root.$children[0]
+  router.push({ path: '/dashboard/index', query: { tab: 'orders', orderCategory: 'member' }})
+  await settle()
+  expect(root.$children[0]).toBe(account)
+  expect(root.$el.querySelector('#tab-member').getAttribute('aria-selected')).toBe('true')
+  router.back()
+  await settle()
+  expect(root.$el.querySelector('#tab-self').getAttribute('aria-selected')).toBe('true')
+  root.$el.querySelector('#tab-member').click()
+  await settle()
+  expect(root.$el.querySelector('#tab-member').getAttribute('aria-selected')).toBe('true')
+})
+
+it.each([['self', 'repeat-purchase'], ['member', 'member-repurchase']])('opens the corresponding purchase page from the %s empty state', async(category, source) => {
+  const { root, router } = await mount({ path: '/dashboard/index', query: { tab: 'orders', orderCategory: category }})
+  jest.advanceTimersByTime(500)
+  await settle()
+  const button = root.$el.querySelector('.ng-order-empty button')
+  expect(button.disabled).toBe(false)
+  button.click()
+  await settle()
+  expect(router.currentRoute.path).toBe('/dashboard/' + source)
+  expect(root.$children[0].$refs.flow.source).toBe(source)
+})
+
+it.each(['/shop/own-order', '/shop/member-order'])('keeps the old order route available: %s', async route => {
+  const { root, router } = await mount(route)
+  expect(router.currentRoute.path).toBe(route)
+  expect(root.$el.textContent).toContain('Legacy')
+})

+ 820 - 0
src/ui-refactor/views/purchase/__tests__/PurchaseFlow.spec.js

@@ -0,0 +1,820 @@
+/* eslint-env jest */
+import Vue from 'vue'
+import ElementUI from 'element-ui'
+import path from 'path'
+import * as shop from '@/api/shop'
+import * as user from '@/api/user'
+import { getRegionList } from '@/api/config'
+import usersInfo from '@/utils/usersInfo'
+import router from '@/router'
+import { toThousandFilter } from '@/filters'
+import { load } from './loadComponent'
+
+jest.mock('@/api/shop', () => Object.fromEntries(['fetchProductList', 'fetchReconsumeProductList', 'fetchAutoMaintenanceProductList', 'fetchShoppingCart', 'createOrder', 'createApproachOrder', 'createReconsumeOrder', 'createApproachReconsumeOrder', 'createAutoMaintenanceOrder', 'createApproachAutoMaintenanceOrder', 'deleteApproachOrder'].map(key => [key, jest.fn()])))
+jest.mock('@/api/user', () => Object.fromEntries(['fetchFullInfo', 'fetchUpgrade', 'fetchUpgradeInfo', 'fetchSendUpgrade', 'getUserInfo'].map(key => [key, jest.fn()])))
+jest.mock('@/api/config', () => ({ getRegionList: jest.fn() }))
+jest.mock('@/utils/usersInfo', () => ({ userId: jest.fn(), loginTimestamp: jest.fn(), userName: () => 'OP1', userEmail: () => 'operator@example.test' }))
+jest.mock('@/router', () => ({ push: jest.fn(), replace: jest.fn(), afterEach: jest.fn() }))
+jest.mock('vue-paystack', () => ({
+  name: 'paystack',
+  props: ['amount', 'currency', 'email', 'metadata', 'reference', 'channels', 'callback', 'close'],
+  methods: { payWithPaystack: jest.fn() },
+  render: h => h('button')
+}))
+Vue.use(ElementUI)
+Vue.filter('toThousandFilter', toThousandFilter)
+Vue.prototype.$t = key => key
+Vue.prototype.$router = router
+Vue.prototype.$route = { query: {}}
+Vue.prototype.$message = Object.assign(jest.fn(), { error: jest.fn(), info: jest.fn() })
+Vue.prototype.$prompt = jest.fn()
+Vue.prototype.$confirm = jest.fn()
+
+const root = path.resolve(__dirname, '..')
+const adapters = load(path.join(root, 'adapters.js')).default
+const drafts = load(path.join(root, 'drafts.js'))
+const routes = load(path.join(root, 'routes.js'))
+const names = Object.keys(adapters)
+const product = { ID: 'P1', GOODS_NAME: 'Product one', GOODS_NO: 'SKU1', COVER: 'p1.png', SELL_PRICE: 100, MARKET_PRICE: 120, PRICE_PV: 60, TAX_RATE: 7.5, DISCOUNT: 80, STORE_NUMS: 9, COIN: 'NGN', CODE: 'NGN' }
+const payList = [{ label: 'cash', name: 'Ecoin' }, { label: 'pay_stack', name: 'Paystack' }, { label: 'prp', name: 'PRP' }]
+const settings = () => ({ data: { allAddress: [{ ID: 'A1', IS_DEFAULT: '1', ADDRESS: 'Street', MOBILE: '000', CONSIGNEE: 'Recipient' }], userBalance: { cash: 10000, prp: 10000 }, payList: Object.fromEntries(payList.map(item => [item.label, { name: item.name }])), sellType: [{ id: 1, sell_type: payList }], freeShipping: 500, freight: 10, coin: 'NGN', isDec: '0', ampDivideLine: '90' }})
+const instances = []
+const componentErrors = []
+Vue.config.errorHandler = error => componentErrors.push(error)
+async function settle() { for (let i = 0; i < 12; i++) { await Promise.resolve(); await Vue.nextTick() } }
+async function instance(source = names[0]) {
+  const vm = new (Vue.extend(adapters[source]))({ propsData: { stage: 'catalog' }}).$mount()
+  document.body.appendChild(vm.$el)
+  instances.push(vm)
+  await settle()
+  return vm
+}
+async function withProducts(source = names[0]) {
+  const vm = await instance(source)
+  if (!vm.isSelf) { vm.memberSearch = 'NG2'; await settle(); await vm.searchMember(); await settle() }
+  if (vm.isUpgrade) { vm.form.upgradeValue = { value: 'L2', diffPv: 999 }; vm.handleChangeLevel(vm.form.upgradeValue) }
+  vm.toggleProduct(vm.tableData[0])
+  vm.changeQuantity({ row: product, value: 2 })
+  vm.stage = 'checkout'
+  await settle()
+  if (vm.isSelf) vm.payForm.recUserName = 'RC1'
+  else if (!vm.isUpgrade) Object.assign(vm.newOrder, { decUserName: 'RC1', consignee: 'Recipient', mobile: '123', areaSelected: ['S1'], lgaName: 'District', cityName: 'City', address: 'Street' })
+  else Object.assign(vm.form, { consignee: 'Recipient', acceptMobile: '123', areaSelected: ['S1'], lgaName: 'District', cityName: 'City', address: 'Street', remark: 'Note' })
+  await settle()
+  return vm
+}
+
+it('uses the shared cart list and preserves quantity and remove events', async() => {
+  const vm = await withProducts('repeat-purchase')
+  vm.stage = 'cart'
+  await settle()
+  const cart = vm.$children.find(child => child.$options.name === 'PurchaseCartItems')
+  expect(cart).toBeDefined()
+  cart.$emit('quantity', { row: vm.cartRows[0], value: 3 })
+  await settle()
+  expect(vm.cartItems[0].quantity).toBe(3)
+  cart.$emit('remove', vm.cartRows[0])
+  await settle()
+  expect(vm.cartItems).toHaveLength(0)
+})
+
+beforeEach(() => {
+  jest.useFakeTimers()
+  jest.clearAllMocks()
+  componentErrors.length = 0
+  drafts.clearDrafts()
+  usersInfo.userId.mockReturnValue('OP1')
+  usersInfo.loginTimestamp.mockReturnValue('login-1')
+  router.push.mockResolvedValue()
+  router.replace.mockResolvedValue()
+  Vue.prototype.$prompt.mockResolvedValue({ value: 'fixture-password' })
+  Vue.prototype.$confirm.mockResolvedValue()
+  const list = { data: { list: [product], totalCount: 100 }}
+  ;[shop.fetchProductList, shop.fetchReconsumeProductList, shop.fetchAutoMaintenanceProductList].forEach(api => api.mockResolvedValue(list))
+  shop.fetchShoppingCart.mockImplementation(async() => settings())
+  user.getUserInfo.mockResolvedValue({ data: { IS_DEC: '1' }})
+  user.fetchFullInfo.mockResolvedValue({ data: { userId: 'U2', countryId: '1', REAL_NAME: 'Target member' }})
+  user.fetchUpgradeInfo.mockResolvedValue({ data: { baseInfo: { ADD_AT: '2020-01-01', REAL_NAME: 'Target member', DEC_NAME: 'Silver', IS_MAX: false, LEVEL_LIST: [{ ID: 'L2', LEVEL_NAME: 'Gold', REPAIR_PV: 999 }], UPGRADE_FUNC: 'Product', NOW_PERF: 10, NEXT_PERF: 1009 }}})
+  user.fetchUpgrade.mockResolvedValue({ data: { userBalance: { cash: 10000 }, decUserName: 'RC1', allDecPackage: {}, allGoods: [product], region: [], sellType: [{ sell_type: payList }] }})
+  getRegionList.mockResolvedValue({ data: [] })
+  ;[shop.createOrder, shop.createApproachOrder, shop.createReconsumeOrder, shop.createApproachReconsumeOrder, shop.createAutoMaintenanceOrder, shop.createApproachAutoMaintenanceOrder, user.fetchSendUpgrade].forEach(api => api.mockResolvedValue({ data: { SN: 'ORDER1', ORDER_AMOUNT: 200 }}))
+  shop.deleteApproachOrder.mockResolvedValue({})
+})
+afterEach(async() => {
+  await settle()
+  instances.splice(0).forEach(vm => { vm.$destroy(); vm.$el.remove() })
+  await settle()
+  jest.clearAllTimers()
+  jest.useRealTimers()
+  expect(componentErrors).toEqual([])
+})
+
+it('keeps selections from every product page in totals and checkout', async() => {
+  const vm = await instance()
+  vm.toggleProduct(product)
+  shop.fetchProductList.mockResolvedValue({ data: { list: [{ ...product, ID: 'P2' }], totalCount: 100 }})
+  vm.listQuery.page = 2
+  await vm.getList()
+  vm.toggleProduct(vm.tableData[0])
+  expect(vm.goodsId).toEqual(['P1', 'P2'])
+  expect(vm.goodsNum).toEqual([1, 1])
+  expect(vm.sellPriceSum).toBe('160.00')
+  expect(vm.pricePvSum).toBe('96.00')
+  expect(vm.cashSum).toBe('170.00')
+  vm.addressId = vm.selfPickUpAddressId
+  await settle()
+  expect(vm.cashSum).toBe('160.00')
+  vm.changeQuantity({ row: product, value: 7 })
+  expect(vm.cashSum).toBe('640.00')
+  expect(vm.pointFreight).toBe(0)
+})
+
+it('switches repeat-purchase delivery cards through the original address and freight logic', async() => {
+  const vm = await withProducts('repeat-purchase')
+  vm.shippingAddressList.push({ ID: 42, IS_DEFAULT: '0', ADDRESS: 'Second street', CITY_NAME: 'City', LGA_NAME: 'District', PROVINCE_NAME: 'State', MOBILE: '123', CONSIGNEE: 'Second recipient' })
+  await settle()
+  const fields = vm.$el.querySelector('.ng-purchase-checkout')
+  expect(fields.querySelectorAll('.ng-purchase-checkout-section')).toHaveLength(3)
+  expect(fields.querySelector('.ng-purchase-addresses .is-checked').textContent).toContain('DEFAULT')
+  expect(fields.querySelectorAll('.ng-purchase-addresses .el-radio')[1].textContent).toContain('Second street, City, District, State')
+  fields.querySelector('.ng-purchase-addresses input[value="42"]').click()
+  await settle()
+  expect(vm.addressId).toBe(42)
+  expect(fields.querySelector('.ng-purchase-addresses .is-checked').textContent).toContain('Second recipient')
+  expect(fields.querySelector('.ng-purchase-addresses .is-checked').textContent).toContain('Selected')
+  expect(vm.pointFreight).toBe(10)
+  fields.querySelector('.ng-purchase-delivery input[value="pickup"]').click()
+  await settle()
+  expect(vm.addressId).toBe(vm.selfPickUpAddressId)
+  expect(vm.pointFreight).toBe(0)
+  expect(vm.orderTotal).toBe('160.00')
+  expect(fields.querySelector('.ng-purchase-address-details')).toBeNull()
+  fields.querySelector('.ng-purchase-delivery input[value="delivery"]').click()
+  await settle()
+  expect(vm.addressId).toBe(42)
+  expect(vm.pointFreight).toBe(10)
+  expect(vm.orderTotal).toBe('170.00')
+  expect(fields.querySelector('.ng-purchase-addresses .is-checked').textContent).toContain('Second recipient')
+  expect(shop.createOrder).not.toHaveBeenCalled()
+})
+
+it('restores a pickup draft and uses the saved default when switching to delivery', async() => {
+  const vm = await withProducts('repeat-purchase')
+  vm.choseAddress(vm.selfPickUpAddressId)
+  vm.$destroy()
+  const restored = await instance('repeat-purchase')
+  restored.stage = 'checkout'
+  await settle()
+  expect(restored.$el.querySelector('.ng-purchase-address-details')).toBeNull()
+  expect(restored.$el.querySelector('.ng-purchase-delivery .is-checked').textContent).toContain('Self-pickup')
+  restored.$el.querySelector('.ng-purchase-delivery input[value="delivery"]').click()
+  await settle()
+  expect(restored.addressId).toBe('A1')
+  expect(restored.pointFreight).toBe(10)
+})
+
+it.each([
+  { addresses: [] },
+  { addresses: [{ ID: 'A2', IS_DEFAULT: '0', ADDRESS: 'Street' }] }
+])('does not invent a default delivery address when none is returned: %j', async({ addresses }) => {
+  const vm = await withProducts('repeat-purchase')
+  vm.shippingAddressList = addresses
+  vm.choseAddress(vm.selfPickUpAddressId)
+  await settle()
+  vm.$el.querySelector('.ng-purchase-delivery input[value="delivery"]').click()
+  await settle()
+  expect(vm.addressId).toBe('')
+  expect(vm.$el.querySelector('.ng-purchase-address-details')).not.toBeNull()
+  expect(vm.$el.querySelector('.ng-purchase-addresses .is-checked')).toBeNull()
+  if (!addresses.length) expect(vm.$el.querySelector('.ng-purchase-address-details').textContent).toContain('No saved addresses')
+})
+
+it('disables repeat-purchase delivery and address selection while processing', async() => {
+  const vm = await withProducts('repeat-purchase')
+  vm.submitButtonStat = true
+  await settle()
+  const radios = vm.$el.querySelectorAll('.ng-purchase-checkout input[type="radio"]')
+  expect(radios).toHaveLength(3)
+  radios.forEach(input => { expect(input.disabled).toBe(true); input.click() })
+  await settle()
+  expect(vm.addressId).toBe('A1')
+})
+
+it.each(names.filter(source => source !== 'repeat-purchase'))('separates %s shipping fields from member information without changing the fields', async source => {
+  const vm = await withProducts(source)
+  expect(vm.$el.querySelector('.ng-purchase-delivery')).toBeNull()
+  expect(vm.$el.querySelector('.ng-purchase-address-details')).toBeNull()
+  expect(vm.$el.querySelector('#ng-purchase-consignee').value).toBe('Recipient')
+  const memberCard = vm.$el.querySelector('.ng-purchase-member-information')
+  const shippingCard = vm.$el.querySelector('.ng-purchase-shipping-address')
+  expect(memberCard.nextElementSibling).toBe(shippingCard)
+  expect(memberCard.querySelector('#ng-purchase-member-readonly')).not.toBeNull()
+  expect(memberCard.querySelector('#ng-purchase-stockist')).not.toBeNull()
+  expect(memberCard.querySelector('#ng-purchase-consignee')).toBeNull()
+  expect(shippingCard.querySelector('h2').textContent).toBe('Shipping address')
+  expect(shippingCard.querySelector('#ng-purchase-consignee')).not.toBeNull()
+  expect(shippingCard.querySelector('#ng-purchase-areaSelected')).not.toBeNull()
+  expect(shippingCard.querySelector('#ng-purchase-address')).not.toBeNull()
+})
+
+it.each(names)('%s shows a full-width empty cart after the last product is removed', async source => {
+  const vm = await withProducts(source)
+  vm.stage = 'cart'
+  await settle()
+  expect(vm.$el.querySelector('.ng-purchase-summary')).not.toBeNull()
+  vm.$el.querySelector('.ng-purchase-remove').click()
+  await settle()
+  const empty = vm.$el.querySelector('.ng-purchase-empty')
+  expect(empty).not.toBeNull()
+  expect(empty.textContent).toContain('Your cart is empty')
+  expect(empty.textContent).toContain('Add products you love and they will appear here.')
+  expect(vm.$el.querySelector('.ng-purchase-columns-empty')).not.toBeNull()
+  expect(vm.$el.querySelector('.ng-purchase-summary')).toBeNull()
+  empty.querySelector('button').click()
+  expect(router.push).toHaveBeenLastCalledWith({ path: '/dashboard/' + source, query: { from: source }})
+  vm.toggleProduct(product)
+  await settle()
+  expect(vm.$el.querySelector('.ng-purchase-empty')).toBeNull()
+  expect(vm.$el.querySelector('.ng-purchase-columns-empty')).toBeNull()
+  expect(vm.$el.querySelector('.ng-purchase-summary')).not.toBeNull()
+})
+
+it.each(names)('returns %s to its explicit buying route and restores the draft', async source => {
+  const vm = await withProducts(source)
+  vm.navigate('catalog')
+  expect(router.push).toHaveBeenLastCalledWith({ path: '/dashboard/' + source, query: { from: source }})
+  vm.$destroy()
+  const restored = await instance(source)
+  expect(restored.cartItems[0].quantity).toBe(2)
+  expect(restored.selectedIds).toEqual(['P1'])
+  if (!vm.isSelf) expect(restored.memberKey).toBe('NG2')
+  if (vm.isSelf) expect(restored.payForm.recUserName).toBe('RC1')
+  expect(JSON.stringify(drafts.readDraft(source, 'OP1:login-1'))).not.toContain('fixture-password')
+})
+
+it('cannot retain a previous member or their products after a failed lookup', async() => {
+  const vm = await withProducts('member-repurchase')
+  vm.stage = 'catalog'
+  vm.memberSearch = 'NG3'
+  await settle()
+  expect(vm.cartItems).toEqual([])
+  expect(vm.newOrder.userName).toBe('')
+  user.fetchFullInfo.mockRejectedValue(new Error('Member not found'))
+  await vm.searchMember()
+  expect(vm.memberKey).toBe('')
+  expect(vm.tableData).toEqual([])
+  expect(vm.canCheckout).toBe(false)
+})
+
+it.each(['member-repurchase', 'member-auto-maintenance'])('%s accepts the legacy full-info response without a returned member number', async source => {
+  user.fetchFullInfo.mockResolvedValue({ data: { userId: 'U2', countryId: '1', REAL_NAME: 'Target member', isLocation: '(Left-Null,Right-Null)' }})
+  const vm = await instance(source)
+  vm.memberSearch = 'NG2'
+  await settle()
+  await vm.searchMember()
+  await settle()
+  expect(vm.loadError).toBe('')
+  expect(user.fetchFullInfo).toHaveBeenCalledWith({ userName: 'NG2' })
+  expect(vm.memberKey).toBe('NG2')
+  expect(vm.memberDetails.number).toBe('NG2')
+  expect(vm.newOrder.userName).toBe('NG2')
+  expect(vm.memberName).toBe('Target member')
+  const listApi = vm.isMaintenance ? shop.fetchAutoMaintenanceProductList : shop.fetchReconsumeProductList
+  expect(listApi).toHaveBeenCalledWith(expect.objectContaining({ userId: 'U2' }))
+  expect(shop.fetchShoppingCart).toHaveBeenCalledWith({ categoryType: vm.listQuery.categoryType, userId: 'U2' })
+  expect(vm.catalogReady).toBe(true)
+  vm.toggleProduct(vm.tableData[0])
+  vm.changeQuantity({ row: product, value: 2 })
+  vm.stage = 'checkout'
+  Object.assign(vm.newOrder, { decUserName: 'RC1', consignee: 'Recipient', mobile: '123', areaSelected: ['S1'], lgaName: 'District', cityName: 'City', address: 'Street' })
+  await settle()
+  expect(getRegionList).toHaveBeenCalledWith({ countryId: '1' })
+  vm.activePayType = 'cash'
+  vm.submitOrder()
+  await settle()
+  const orderApi = vm.isMaintenance ? shop.createAutoMaintenanceOrder : shop.createReconsumeOrder
+  expect(orderApi).toHaveBeenCalledWith(expect.objectContaining({ userName: 'NG2', goodsId: ['P1'], goodsNum: [2] }))
+})
+
+it.each(['member-repurchase', 'member-auto-maintenance'])('%s rejects a full-info response without userId and clears the previous order', async source => {
+  const vm = await withProducts(source)
+  vm.stage = 'catalog'
+  vm.memberSearch = 'NG3'
+  await settle()
+  user.fetchFullInfo.mockResolvedValue({ data: { REAL_NAME: 'Invalid member', countryId: '1' }})
+  shop.fetchShoppingCart.mockClear()
+  await vm.searchMember()
+  await settle()
+  expect(vm.memberKey).toBe('')
+  expect(vm.newOrder.userName).toBe('')
+  expect(vm.tableData).toEqual([])
+  expect(vm.cartItems).toEqual([])
+  expect(shop.fetchShoppingCart).not.toHaveBeenCalled()
+  expect(vm.canCheckout).toBe(false)
+})
+
+it.each([
+  ['member-repurchase', 'Choose products', 'Tap a product to add it to your cart. Prices shown are DP (distributor price) with BV, and the retail reference RP.', 'Tap the cart button in the bottom-right corner to review your items and place the order.'],
+  ['member-auto-maintenance', 'Choose products', "Tap a product to add it to the member's cart. Prices shown are DP (distributor price) with BV, and the retail reference RP. Auto-Maintenance requires the selected products' total BV to be at least 90 BV.", 'Tap the cart button in the bottom-right corner to review the order and place it.'],
+  ['member-upgrade', 'Upgrade products', "Tap a product to add it to the member's cart. Choose the upgrade level in your cart or at checkout. Prices shown are DP (distributor price) with BV, and the retail reference RP.", 'Tap the cart button in the bottom-right corner to pick the upgrade level and place the order.']
+])('%s uses the existing section card and requested product help', async(source, title, help, checkoutHint) => {
+  const vm = await withProducts(source)
+  vm.stage = 'catalog'
+  await settle()
+  const section = vm.$el.querySelector('.ng-purchase-products.ng-ui-section-card')
+  expect(section).not.toBeNull()
+  expect(section.querySelector('.ng-ui-section-card-title').textContent.trim()).toBe(title)
+  expect(section.querySelector('.ng-signup-help').textContent.trim()).toBe(help)
+  expect(section.querySelector('.ng-signup-totals')).toBeNull()
+  expect(section.querySelector('.ng-purchase-cart-hint p').textContent.trim()).toBe('Ready to check out? ' + checkoutHint)
+  expect(section.querySelector('.ng-purchase-cart-hint strong').textContent).toBe('Ready to check out?')
+  if (vm.isMaintenance) {
+    vm.ampDivideLine = '100'
+    await settle()
+    expect(section.querySelector('.ng-signup-help').textContent.trim()).toBe(help)
+  }
+})
+
+it('keeps the original totals footer for ordinary repeat purchases', async() => {
+  const vm = await instance('repeat-purchase')
+  expect(vm.$el.querySelector('.ng-signup-totals')).not.toBeNull()
+  expect(vm.$el.querySelector('.ng-purchase-cart-hint')).toBeNull()
+})
+
+it.each(names)('%s shows checkout product prices and BV using the existing purchase rules', async source => {
+  const vm = await withProducts(source)
+  vm.activePayType = 'cash'
+  await settle()
+  const summary = vm.$el.querySelector('.ng-purchase-summary')
+  expect(summary.classList.contains('is-checkout')).toBe(true)
+  expect(vm.$el.querySelector('.ng-purchase-maintenance') !== null).toBe(vm.isMaintenance)
+  expect(summary.querySelector('.ng-purchase-requirement')).toBeNull()
+  const row = summary.querySelector('.ng-purchase-summary-product')
+  const text = element => element.textContent.replace(/\s+/g, ' ').trim()
+  expect(row.querySelector('h3').textContent).toBe('Product one')
+  expect(row.querySelector('.ng-purchase-summary-image')).not.toBeNull()
+  expect(row.querySelector('.ng-purchase-summary-quantity').textContent).toBe('Qty 2')
+  expect(text(row.querySelector('.ng-purchase-summary-unit'))).toBe(vm.isUpgrade ? 'SKU1 · NGN 100 (60 BV) each' : 'SKU1 · NGN 80 (48 BV) each')
+  expect(text(row.querySelector('.ng-purchase-summary-line'))).toBe(vm.isUpgrade ? 'NGN 200 (120 BV)' : 'NGN 160 (96 BV)')
+  expect(text(summary.querySelector('.ng-purchase-total'))).toBe(vm.isUpgrade ? 'NGN 200 (120 BV)' : 'NGN 170 (96 BV)')
+  expect(text(summary.querySelector('.ng-purchase-checkout-totals'))).toContain('Tax NGN ' + Number(vm.taxSum))
+  expect(summary.querySelector('.ng-purchase-summary-channels')).toBeNull()
+  vm.activePayType = 'pay_stack'
+  await settle()
+  expect(text(summary.querySelector('.ng-purchase-checkout-totals'))).toContain('MethodPaystack')
+  expect(Array.from(summary.querySelectorAll('.ng-purchase-summary-channels .el-tag'), item => item.textContent)).toEqual(['VISA', 'MC', 'VERVE', 'USSD', 'BANK', 'VISA QR'])
+  vm.activePayType = 'cash'
+  await settle()
+  expect(text(summary.querySelector('.ng-purchase-checkout-totals'))).toContain('MethodEcoin')
+  expect(summary.querySelector('.ng-purchase-summary-channels')).toBeNull()
+  vm.changeQuantity({ row: product, value: 3 })
+  await settle()
+  expect(row.querySelector('.ng-purchase-summary-quantity').textContent).toBe('Qty 3')
+  expect(text(row.querySelector('.ng-purchase-summary-line'))).toBe(vm.isUpgrade ? 'NGN 300 (180 BV)' : 'NGN 240 (144 BV)')
+  expect(text(summary.querySelector('.ng-purchase-total'))).toBe(vm.isUpgrade ? 'NGN 300 (180 BV)' : 'NGN 250 (144 BV)')
+  vm.stage = 'cart'
+  await settle()
+  expect(summary.classList.contains('is-checkout')).toBe(false)
+  expect(summary.querySelector('.ng-purchase-summary-products')).toBeNull()
+  expect(summary.querySelector('.el-descriptions').textContent).toContain('Subtotal')
+})
+
+it('keeps the checkout order button bound to the maintenance requirement and original submission', async() => {
+  const vm = await withProducts('member-auto-maintenance')
+  vm.activePayType = 'cash'
+  vm.ampDivideLine = '100'
+  await settle()
+  const requirement = vm.$el.querySelector('.ng-purchase-main .ng-purchase-maintenance')
+  expect(requirement.previousElementSibling.querySelector('h2').textContent).toBe('Member information')
+  expect(requirement.nextElementSibling.querySelector('h2').textContent).toBe('Payment method')
+  expect(requirement.textContent).toContain('at least 100 BV')
+  expect(requirement.querySelector('.el-alert').textContent).toContain('at least 100 BV')
+  const button = vm.$el.querySelector('.ng-purchase-place-order')
+  expect(button.disabled).toBe(true)
+  button.click()
+  await settle()
+  expect(shop.createAutoMaintenanceOrder).not.toHaveBeenCalled()
+  vm.ampDivideLine = '90'
+  await settle()
+  const enabledButton = vm.$el.querySelector('.ng-purchase-place-order')
+  expect(enabledButton.disabled).toBe(false)
+  expect(requirement.querySelector('.el-alert')).toBeNull()
+  expect(requirement.querySelector('.is-sufficient').textContent).toContain('96 / 90 BV')
+  let confirmPassword
+  Vue.prototype.$prompt.mockReturnValue(new Promise(resolve => { confirmPassword = resolve }))
+  enabledButton.__vue__.$emit('click')
+  await settle()
+  expect(Vue.prototype.$prompt).toHaveBeenCalledTimes(1)
+  expect(vm.$el.querySelector('.ng-purchase-place-order').textContent.trim()).toBe('Processing...')
+  expect(vm.$el.querySelector('.ng-purchase-place-order').classList.contains('is-loading')).toBe(true)
+  confirmPassword({ value: 'fixture-password' })
+  await settle()
+  expect(shop.createAutoMaintenanceOrder).toHaveBeenCalledTimes(1)
+  expect(shop.createAutoMaintenanceOrder).toHaveBeenCalledWith(expect.objectContaining({ userName: 'NG2', goodsId: ['P1'], goodsNum: [2], payType: 'cash' }))
+})
+
+it('uses the returned maintenance threshold and excludes PRP', async() => {
+  const vm = await withProducts('member-auto-maintenance')
+  expect(vm.payList.map(item => item.label)).toEqual(['cash', 'pay_stack'])
+  expect(vm.maintenanceReady).toBe(true)
+  vm.stage = 'cart'
+  vm.ampDivideLine = '100'
+  await settle()
+  expect(vm.maintenanceReady).toBe(false)
+  const summary = vm.$el.querySelector('.ng-purchase-summary')
+  const requirement = summary.querySelector('.ng-purchase-requirement')
+  const warning = summary.querySelector('.ng-purchase-requirement-warning')
+  expect(requirement.parentElement).toBe(warning.parentElement)
+  expect(warning.textContent).toContain('at least 100 BV')
+  expect(summary.querySelector('.el-button--primary').disabled).toBe(true)
+  vm.ampDivideLine = '96'
+  await settle()
+  expect(vm.maintenanceReady).toBe(true)
+  expect(requirement.classList.contains('is-sufficient')).toBe(true)
+  expect(summary.querySelector('.ng-purchase-requirement-warning')).toBeNull()
+  expect(summary.querySelector('.el-button--primary').disabled).toBe(false)
+  vm.ampDivideLine = null
+  await settle()
+  expect(summary.querySelector('.ng-purchase-requirement-warning').textContent).toContain('Waiting for the maintenance requirement.')
+  expect(vm.canCheckout).toBe(false)
+  vm.submitOrder()
+  expect(shop.createAutoMaintenanceOrder).not.toHaveBeenCalled()
+})
+
+it('preserves upgrade information and does not introduce a difference-BV rejection', async() => {
+  const vm = await withProducts('member-upgrade')
+  expect(user.fetchUpgradeInfo).toHaveBeenCalledWith({ userName: 'NG2' })
+  expect(user.fetchUpgrade).toHaveBeenCalledWith({ userName: 'NG2' })
+  expect(vm.needDiffPv).toBe(999)
+  expect(vm.pricePvSum).toBe('120.00')
+  vm.activePayType = 'cash'
+  vm.submitOrder()
+  await settle()
+  expect(user.fetchSendUpgrade).toHaveBeenCalledWith(expect.objectContaining({ decLv: 'L2', insertUserName: 'NG2', goodsId: ['P1'], goodsNum: [2], province: 'S1', nowPerf: 10, nextPerf: 1009 }))
+})
+
+it.each([NaN, 0, -1, 1.5, 10, Infinity])('rejects invalid quantity %s without corrupting totals', async quantity => {
+  const vm = await withProducts()
+  vm.changeQuantity({ row: product, value: quantity })
+  expect(vm.cartItems[0].quantity).toBe(2)
+  expect(vm.cashSum).toBe('170.00')
+})
+
+const submissions = [
+  ['repeat-purchase', shop.createOrder, shop.createApproachOrder, '/shop/own-order'],
+  ['member-repurchase', shop.createReconsumeOrder, shop.createApproachReconsumeOrder, '/shop/member-order'],
+  ['member-auto-maintenance', shop.createAutoMaintenanceOrder, shop.createApproachAutoMaintenanceOrder, '/shop/own-order'],
+  ['member-upgrade', user.fetchSendUpgrade, user.fetchSendUpgrade, '/shop/member-order']
+]
+it.each(names)('%s keeps Reporting Center ID required and editable for every stockist status', async source => {
+  const vm = await withProducts(source)
+  for (const status of [0, '0', 1, '1', false, true]) {
+    vm.isDec = status
+    await settle()
+    const input = vm.$el.querySelector('#ng-purchase-stockist')
+    expect(input.disabled).toBe(false)
+    expect(input.closest('.el-form-item').classList.contains('is-required')).toBe(true)
+  }
+})
+
+it.each(submissions)('%s submits the Reporting Center ID entered in the form', async(source, api) => {
+  const vm = await withProducts(source)
+  vm.activePayType = 'cash'
+  const input = vm.$el.querySelector('#ng-purchase-stockist')
+  input.value = 'RC-EDITED'
+  input.dispatchEvent(new Event('input', { bubbles: true }))
+  if (vm.isUpgrade) {
+    const recipient = vm.$el.querySelector('#ng-purchase-consignee')
+    recipient.value = 'Edited recipient'
+    recipient.dispatchEvent(new Event('input', { bubbles: true }))
+  }
+  await settle()
+  expect(vm.$el.querySelector('#ng-purchase-stockist').value).toBe('RC-EDITED')
+  vm.submitOrder()
+  await settle()
+  expect(api).toHaveBeenCalledWith(expect.objectContaining({ [vm.isSelf ? 'userName' : 'decUserName']: 'RC-EDITED' }))
+  if (vm.isUpgrade) expect(api).toHaveBeenCalledWith(expect.objectContaining({ consignee: 'Edited recipient' }))
+})
+
+it.each(submissions)('%s blocks submission when the entered Reporting Center ID is empty', async(source, api) => {
+  const vm = await withProducts(source)
+  const input = vm.$el.querySelector('#ng-purchase-stockist')
+  input.value = ''
+  input.dispatchEvent(new Event('input', { bubbles: true }))
+  await settle()
+  vm.submitOrder()
+  await settle()
+  expect(api).not.toHaveBeenCalled()
+  expect(Vue.prototype.$prompt).not.toHaveBeenCalled()
+  expect(vm.busy).toBe(false)
+})
+
+it.each(submissions)('%s submits once, preserving the original member and delivery fields', async(source, api, unused, destination) => {
+  const vm = await withProducts(source)
+  vm.activePayType = 'cash'
+  vm.submitOrder()
+  vm.submitOrder()
+  await settle()
+  expect(api).toHaveBeenCalledTimes(1)
+  const sent = api.mock.calls[0][0]
+  expect(sent.goodsId).toEqual(['P1'])
+  expect(sent.goodsNum).toEqual([2])
+  expect(sent.payType).toBe('cash')
+  if (vm.isSelf) expect(sent).toEqual({ goodsId: ['P1'], goodsNum: [2], payPassword: 'fixture-password', email: 'operator@example.test', addressId: 'A1', address: undefined, payType: 'cash', userName: 'RC1' })
+  else if (!vm.isUpgrade) expect(sent).toEqual({ goodsId: ['P1'], goodsNum: [2], payPassword: 'fixture-password', email: 'operator@example.test', addressId: 'A1', payType: 'cash', userName: 'NG2', consignee: 'Recipient', acceptMobile: '123', province: ['S1'], lgaName: 'District', cityName: 'City', detailaddress: 'Street', decUserName: 'RC1' })
+  expect(router.push).toHaveBeenLastCalledWith({ path: destination })
+  expect(vm.cartItems).toEqual([])
+  expect(drafts.readDraft(source, 'OP1:login-1')).toBeNull()
+})
+
+it.each(submissions)('%s retains its draft on failed payment and cancels the correct Paystack order', async(source, unused, api) => {
+  const vm = await withProducts(source)
+  vm.activePayType = 'pay_stack'
+  api.mockRejectedValueOnce(new Error('Order failed'))
+  vm.submitOrder()
+  await settle()
+  expect(vm.cartItems).toHaveLength(1)
+  expect(vm.busy).toBe(false)
+  api.mockResolvedValueOnce({ data: { SN: 'ORDER1', ORDER_AMOUNT: 200 }})
+  vm.$el.querySelector('.ng-purchase-place-order').click()
+  await settle()
+  expect(vm.visible).toBe(true)
+  expect(vm.payment.orderSn).toBe('ORDER1')
+  expect(vm.payment.amount).toBe(vm.isUpgrade ? 200 : '170.00')
+  const dialog = vm.$children.find(child => child.$options.name === 'PaystackPaymentDialog').$el
+  expect(dialog.querySelector('.el-dialog__title').textContent).toBe('Paystack Payment')
+  expect(dialog.textContent).toContain('Confirm your email to continue to the Paystack secure checkout.')
+  expect(dialog.querySelector('.ng-paystack-payment-amount strong').textContent).toBe(vm.isUpgrade ? 'NGN 200' : 'NGN 170')
+  expect(vm.$refs.paystack.amount).toBe(Number(vm.payment.amount) * 100)
+  expect(vm.$refs.paystack.metadata).toBe(vm.payment.metadata)
+  expect(vm.$refs.paystack.reference).toBe(vm.reference + vm.paymentAttempt)
+  const email = dialog.querySelector('#ng-paystack-payment-email')
+  expect(email.readOnly).toBe(false)
+  email.value = 'confirmed@example.test'
+  email.dispatchEvent(new Event('input', { bubbles: true }))
+  await settle()
+  expect(vm.payment.email).toBe('confirmed@example.test')
+  expect(vm.$refs.paystack.email).toBe('confirmed@example.test')
+  dialog.querySelector('.ng-paystack-payment-continue').click()
+  expect(vm.$refs.paystack.$options.methods.payWithPaystack).toHaveBeenCalledTimes(1)
+  expect(vm.mayLeave({ path: '/dashboard/index' })).toBe(false)
+  expect(vm.mayLeave({ path: '/login' })).toBe(true)
+  vm.submitOrder()
+  expect(api).toHaveBeenCalledTimes(2)
+  dialog.querySelector('.el-dialog__headerbtn').click()
+  await settle()
+  expect(shop.deleteApproachOrder).toHaveBeenCalledWith({ orderSn: 'ORDER1' })
+  expect(vm.visible).toBe(false)
+  expect(vm.cartItems).toHaveLength(1)
+})
+
+it('handles cancellation failure and accepts a payment callback only once', async() => {
+  const vm = await withProducts('member-upgrade')
+  vm.activePayType = 'pay_stack'
+  vm.submitOrder()
+  await settle()
+  shop.deleteApproachOrder.mockRejectedValue(new Error('Cancellation failed'))
+  await vm.handleClose()
+  expect(vm.visible).toBe(true)
+  expect(vm.cartItems).toHaveLength(1)
+  vm.processPayment()
+  const timer = vm.countdownTimer
+  vm.processPayment()
+  expect(vm.countdownTimer).toBe(timer)
+  expect(vm.cartItems).toEqual([])
+  jest.advanceTimersByTime(5000)
+  expect(router.push).toHaveBeenLastCalledWith({ path: '/shop/member-order' })
+})
+
+it.each(submissions)('%s matches the actual legacy submission payload', async(source, api) => {
+  const vm = await withProducts(source)
+  vm.activePayType = 'cash'
+  vm.submitOrder()
+  await settle()
+  const actual = api.mock.calls[0][0]
+  const old = new Vue({ ...adapters[source].extends, render: h => h('div') })
+  const fields = ['multipleSelection', 'goods', 'goodsId', 'goodsNum', 'addressId', 'cashSum', 'payType', 'currency', 'payList', 'userBalance', 'newOrder', 'form', 'payForm', 'decUserName', 'toLevel', 'nowPerf', 'nextPerf', 'decWay']
+  fields.forEach(key => { if (vm[key] !== undefined) old[key] = JSON.parse(JSON.stringify(vm[key])) })
+  old.$refs.payForm = { validate: callback => callback(true) }
+  const debugLog = jest.spyOn(console, 'log').mockImplementation(() => {})
+  try {
+    old[vm.isUpgrade ? 'onSubmit' : 'orderSubmit']()
+    await settle()
+    expect(api.mock.calls[1][0]).toEqual(actual)
+  } finally { debugLog.mockRestore(); old.$destroy() }
+})
+
+it('rejects delayed callbacks from a previously cancelled transaction', async() => {
+  const vm = await withProducts()
+  vm.activePayType = 'pay_stack'
+  vm.submitOrder()
+  await settle()
+  const oldCallback = vm.paymentCallback
+  const oldClose = vm.paymentClosed
+  await vm.handleClose()
+  shop.createApproachOrder.mockResolvedValue({ data: { SN: 'ORDER2' }})
+  vm.submitOrder()
+  await settle()
+  oldCallback()
+  oldClose()
+  expect(vm.payment.orderSn).toBe('ORDER2')
+  expect(vm.visible).toBe(true)
+  expect(vm.cartItems).toHaveLength(1)
+  expect(shop.deleteApproachOrder).toHaveBeenCalledTimes(1)
+})
+
+it('preserves amount and BV invariants across stock quantities', async() => {
+  const vm = await withProducts()
+  for (let quantity = 1; quantity <= product.STORE_NUMS; quantity++) {
+    vm.changeQuantity({ row: product, value: quantity })
+    expect(Number(vm.sellPriceSum)).toBe(quantity * 80)
+    expect(Number(vm.pricePvSum)).toBe(quantity * 48)
+    expect(Number(vm.cashSum)).toBe(quantity * 80 + (quantity * 80 >= 500 ? 0 : 10))
+    expect(vm.goodsNum).toEqual([quantity])
+  }
+})
+
+it('fails closed on missing settings and on maximum-level or ineligible members', async() => {
+  shop.fetchShoppingCart.mockRejectedValue(new Error('Settings failed'))
+  const vm = await instance()
+  vm.toggleProduct(product)
+  expect(vm.canCheckout).toBe(false)
+  expect(vm.settingsLoading).toBe(false)
+  const upgrade = await instance('member-upgrade')
+  user.getUserInfo.mockResolvedValue({ data: { IS_DEC: '0' }})
+  await upgrade.fetchUserInfo()
+  expect(upgrade.catalogReady).toBe(false)
+  user.getUserInfo.mockResolvedValue({ data: { IS_DEC: '1' }})
+  await upgrade.fetchUserInfo()
+  const maximum = await user.fetchUpgradeInfo()
+  user.fetchUpgradeInfo.mockResolvedValue({ data: { baseInfo: { ...maximum.data.baseInfo, IS_MAX: true }}})
+  upgrade.memberSearch = 'MAX'
+  await upgrade.searchMember()
+  await settle()
+  expect(upgrade.catalogReady).toBe(false)
+  expect(upgrade.canCheckout).toBe(false)
+  expect(upgrade.$el.querySelector('.ng-purchase-max-level').textContent.replace(/\s+/g, ' ').trim()).toBe('Target member (MAX) is already Silver — the highest upgradeable level is Silver, so no upgrade is available.')
+  expect(upgrade.$el.querySelector('.ng-purchase-member')).toBeNull()
+  expect(upgrade.$el.querySelector('.ng-purchase-products')).toBeNull()
+})
+
+it.each(submissions)('%s blocks insufficient cash without losing the cart', async(source, api) => {
+  const vm = await withProducts(source)
+  vm.activePayType = 'cash'
+  vm.userBalance.cash = 0
+  vm.submitOrder()
+  await settle()
+  expect(api).not.toHaveBeenCalled()
+  expect(Vue.prototype.$prompt).not.toHaveBeenCalled()
+  expect(vm.cartItems).toHaveLength(1)
+  expect(vm.busy).toBe(false)
+})
+
+it('does not fall back to another member or a payment type when required data is absent', async() => {
+  const vm = await instance('member-repurchase')
+  user.fetchFullInfo.mockResolvedValue({ data: { REAL_NAME: 'Incomplete member' }})
+  vm.memberSearch = 'NG2'
+  await vm.searchMember()
+  expect(shop.fetchReconsumeProductList).not.toHaveBeenCalled()
+  expect(vm.memberKey).toBe('')
+  shop.fetchShoppingCart.mockResolvedValue({ data: { ...settings().data, sellType: [] }})
+  const own = await instance()
+  own.toggleProduct(product)
+  expect(own.payList).toEqual([])
+  expect(own.canCheckout).toBe(false)
+})
+
+it('keeps the legacy missing-member checks at submission', async() => {
+  const member = await withProducts('member-repurchase')
+  member.newOrder.recRealName = '-'
+  member.submitOrder()
+  await settle()
+  expect(shop.createReconsumeOrder).not.toHaveBeenCalled()
+  const upgrade = await withProducts('member-upgrade')
+  upgrade.form.insertUserName = ''
+  upgrade.submitOrder()
+  await settle()
+  expect(user.fetchSendUpgrade).not.toHaveBeenCalled()
+})
+
+it('ignores a product response arriving after the member context was cleared', async() => {
+  const vm = await withProducts('member-repurchase')
+  let resolve
+  shop.fetchReconsumeProductList.mockReturnValue(new Promise(done => { resolve = done }))
+  const loading = vm.getList()
+  vm.resetMember()
+  resolve({ data: { list: [product], totalCount: 1 }})
+  await loading
+  expect(vm.tableData).toEqual([])
+  expect(vm.cartItems).toEqual([])
+  expect(vm.fetching).toBe(false)
+})
+
+it('redirects an invalid origin and guards navigation during payment', async() => {
+  const Page = load(path.join(root, 'PurchasePage.vue'))
+  const invalid = new Vue({ ...Page, beforeCreate() { this.$route = { meta: { purchaseStage: 'cart' }, query: { from: '__proto__' }} } }).$mount()
+  expect(router.replace).toHaveBeenCalledWith('/dashboard/index')
+  invalid.$destroy()
+  const page = new Vue({ ...Page, beforeCreate() { this.$route = { meta: { purchaseStage: 'cart' }, query: { from: 'repeat-purchase' }} } }).$mount()
+  instances.push(page)
+  await settle()
+  page.$refs.flow.visible = true
+  const next = jest.fn()
+  page.checkNavigation({ path: '/dashboard/index' }, next)
+  expect(next).toHaveBeenLastCalledWith(false)
+  page.checkNavigation({ path: '/login' }, next)
+  expect(next).toHaveBeenLastCalledWith()
+})
+
+it('does not submit a password prompt resolved after the login session changed', async() => {
+  const vm = await withProducts()
+  let resolve
+  Vue.prototype.$prompt.mockReturnValue(new Promise(done => { resolve = done }))
+  vm.submitOrder()
+  await settle()
+  usersInfo.loginTimestamp.mockReturnValue('login-2')
+  resolve({ value: 'fixture-password' })
+  await settle()
+  expect(shop.createOrder).not.toHaveBeenCalled()
+  vm.$destroy()
+  expect(drafts.readDraft(names[0], 'OP1:login-2')).toBeNull()
+})
+
+it('restricts cart origins and keeps all six new routes out of the login whitelist', () => {
+  expect(routes.default).toHaveLength(6)
+  names.forEach(source => expect(routes.purchasePath(source)).toBe('/dashboard/' + source))
+  ;['__proto__', 'https://example.test', '', undefined].forEach(source => expect(routes.purchasePath(source)).toBe('/dashboard/index'))
+})
+
+it.each(['cart', 'checkout'])('selects the upgrade level through the %s UI and submits the original fields without a new BV gate', async stage => {
+  const vm = await withProducts('member-upgrade')
+  vm.form.upgradeValue = ''
+  vm.toLevel = ''
+  vm.needDiffPv = ''
+  vm.stage = stage
+  await settle()
+  expect(vm.$options.methods.handleChangeLevel).toBe(adapters['member-upgrade'].extends.methods.handleChangeLevel)
+  const input = vm.$el.querySelector('#ng-purchase-upgrade-level')
+  expect(input).not.toBeNull()
+  expect(vm.$el.querySelector('.ng-purchase-upgrade').textContent).toMatch(/Upgrade difference\s*—/)
+  input.click()
+  await settle()
+  const option = Array.from(document.querySelectorAll('.ng-refactor-popper .el-select-dropdown__item')).find(node => node.textContent.trim() === 'Gold')
+  expect(option).toBeDefined()
+  option.click()
+  await settle()
+  expect(vm.form.upgradeValue).toEqual({ value: 'L2', diffPv: 999 })
+  expect(vm.toLevel).toBe('L2')
+  expect(vm.needDiffPv).toBe(999)
+  expect(Number(vm.pricePvSum)).toBe(120)
+  expect(vm.canCheckout).toBe(true)
+  vm.stage = stage === 'cart' ? 'checkout' : 'cart'
+  await settle()
+  expect(vm.$el.querySelector('#ng-purchase-upgrade-level').value).toBe('Gold')
+  vm.submissionLocked = true
+  await settle()
+  expect(vm.$el.querySelector('#ng-purchase-upgrade-level').disabled).toBe(true)
+  vm.submissionLocked = false
+  vm.stage = 'checkout'
+  vm.activePayType = 'cash'
+  await settle()
+  vm.$el.querySelector('.ng-purchase-place-order').click()
+  await settle()
+  expect(user.fetchSendUpgrade).toHaveBeenCalledWith(expect.objectContaining({ decLv: 'L2', goodsId: ['P1'], goodsNum: [2], nowPerf: 10, nextPerf: 1009, province: 'S1', decWay: '2' }))
+})
+
+it('displays an interface-provided zero upgrade difference without replacing it or recalculating it', async() => {
+  const vm = await withProducts('member-upgrade')
+  vm.upgradeOption = [{ ID: 'ZERO', LEVEL_NAME: 'Zero difference', REPAIR_PV: 0 }]
+  vm.form.upgradeValue = ''
+  vm.stage = 'cart'
+  await settle()
+  vm.$el.querySelector('#ng-purchase-upgrade-level').click()
+  await settle()
+  const option = Array.from(document.querySelectorAll('.ng-refactor-popper .el-select-dropdown__item')).find(node => node.textContent.trim() === 'Zero difference')
+  option.click()
+  await settle()
+  expect(vm.toLevel).toBe('ZERO')
+  expect(vm.needDiffPv).toBe(0)
+  expect(vm.$el.querySelector('.ng-purchase-upgrade').textContent).toMatch(/Upgrade difference\s*0 BV/)
+})
+
+it.each(names)('%s connects the shared payment selection without changing totals or submitting', async source => {
+  const vm = await withProducts(source)
+  const radios = Array.from(vm.$el.querySelectorAll('.ng-payment-methods-options input'))
+  expect(radios.map(input => input.value)).toEqual(vm.payList.map(option => option.label))
+  const amount = vm.orderTotal
+  const balance = JSON.stringify(vm.userBalance)
+  radios.find(input => input.value === 'pay_stack').click()
+  await settle()
+  expect(vm.activePayType).toBe('pay_stack')
+  expect(vm.paymentSummary.amount).toBe(amount)
+  expect(JSON.stringify(vm.userBalance)).toBe(balance)
+  expect(shop.createApproachOrder).not.toHaveBeenCalled()
+  expect(shop.createApproachReconsumeOrder).not.toHaveBeenCalled()
+  expect(shop.createApproachAutoMaintenanceOrder).not.toHaveBeenCalled()
+  expect(user.fetchSendUpgrade).not.toHaveBeenCalled()
+  vm.submissionLocked = true
+  await settle()
+  expect(radios.every(input => input.disabled)).toBe(true)
+})

+ 41 - 0
src/ui-refactor/views/purchase/__tests__/drafts.spec.js

@@ -0,0 +1,41 @@
+/* eslint-env jest */
+import { readDraft, writeDraft, clearDraft, clearDrafts } from '../drafts'
+
+beforeEach(() => clearDrafts())
+
+it('keeps four sources independent and returns a copy of the draft', () => {
+  const sources = ['repeat-purchase', 'member-repurchase', 'member-auto-maintenance', 'member-upgrade']
+  sources.forEach((source, i) => writeDraft(source, 'login-1', { memberKey: 'NG1', cartItems: [{ ID: i, quantity: i + 1 }] }))
+  sources.forEach((source, i) => expect(readDraft(source, 'login-1').cartItems[0].ID).toBe(i))
+  const copy = readDraft(sources[0], 'login-1')
+  copy.cartItems[0].quantity = 99
+  expect(readDraft(sources[0], 'login-1').cartItems[0].quantity).toBe(1)
+})
+
+it('clears only the completed order', () => {
+  writeDraft('repeat-purchase', 'login-1', { cartItems: [1] })
+  writeDraft('member-repurchase', 'login-1', { cartItems: [2] })
+  clearDraft('repeat-purchase', 'login-1')
+  expect(readDraft('repeat-purchase', 'login-1')).toBeNull()
+  expect(readDraft('member-repurchase', 'login-1').cartItems).toEqual([2])
+})
+
+it('discards drafts on logout and on a new login by the same user', () => {
+  writeDraft('repeat-purchase', 'login-1', { cartItems: [1] })
+  expect(readDraft('repeat-purchase', 'login-2')).toBeNull()
+  writeDraft('repeat-purchase', 'login-2', { cartItems: [2] })
+  clearDrafts()
+  expect(readDraft('repeat-purchase', 'login-2')).toBeNull()
+})
+
+it('rejects late writes from a previous login', () => {
+  readDraft('repeat-purchase', 'login-2')
+  writeDraft('repeat-purchase', 'login-1', { cartItems: [1] })
+  expect(readDraft('repeat-purchase', 'login-2')).toBeNull()
+})
+
+it('keeps no data after a module reload (page refresh)', () => {
+  writeDraft('repeat-purchase', 'login-1', { cartItems: [1] })
+  jest.resetModules()
+  expect(require('../drafts').readDraft('repeat-purchase', 'login-1')).toBeNull()
+})

+ 37 - 0
src/ui-refactor/views/purchase/__tests__/loadComponent.js

@@ -0,0 +1,37 @@
+import fs from 'fs'
+import path from 'path'
+import { transformSync } from '@babel/core'
+import { parseComponent, compileTemplate } from 'vue/compiler-sfc'
+
+// 与现有会员端测试一致,编译真实 Vue 2.7 SFC,支持旧脚本查询。
+const cache = new Map()
+export function load(filename, scriptOnly = false) {
+  const key = filename + scriptOnly
+  if (cache.has(key)) return cache.get(key)
+  const sfc = filename.endsWith('.vue')
+  const source = fs.readFileSync(filename, 'utf8')
+  const parts = sfc ? parseComponent(source) : { script: { content: source }}
+  const code = transformSync(parts.script.content, {
+    babelrc: false, configFile: false,
+    plugins: [require.resolve('@babel/plugin-transform-modules-commonjs')]
+  }).code
+  const module = { exports: {}}
+  const localRequire = id => {
+    if (id === '@/components/Pagination') return { name: 'Pagination', render: h => h('div') }
+    const [file, query] = id.split('?')
+    if (file.startsWith('.') || file.startsWith('@/')) {
+      let resolved = file.startsWith('.') ? path.resolve(path.dirname(filename), file) : path.resolve(process.cwd(), 'src', file.slice(2))
+      if (!path.extname(resolved)) resolved += '.js'
+      if (resolved.endsWith('.vue') || resolved.includes(path.join('views', 'purchase'))) return load(resolved, !!query)
+    }
+    return require(id)
+  }
+  new Function('require', 'module', 'exports', code)(localRequire, module, module.exports)
+  if (sfc && !scriptOnly) {
+    const compiled = compileTemplate({ source: parts.template.content, filename })
+    if (compiled.errors.length) throw new Error(compiled.errors.join('\n'))
+    Object.assign(module.exports.default, new Function(compiled.code + ';return { render, staticRenderFns }')())
+  }
+  cache.set(key, sfc ? module.exports.default : module.exports)
+  return cache.get(key)
+}

+ 166 - 0
src/ui-refactor/views/purchase/__tests__/verifyBrowser.cjs

@@ -0,0 +1,166 @@
+/* eslint-env node */
+// 仅针对本地构建拦截样例响应;任何未声明的远程请求都不放行。
+const fs = require('fs')
+const path = require('path')
+const http = require('http')
+const assert = require('assert')
+const serve = require('serve-static')
+const { chromium } = require('playwright')
+const build = path.resolve(process.argv[2])
+const output = path.resolve(process.argv[3])
+fs.mkdirSync(output, { recursive: true })
+const staticFiles = serve(build)
+const server = http.createServer((req, res) => staticFiles(req, res, () => { res.writeHead(404); res.end() }))
+const sources = ['repeat-purchase', 'member-repurchase', 'member-auto-maintenance', 'member-upgrade']
+const heroTitles = { 'repeat-purchase': 'Repeat purchase.', 'member-repurchase': 'Member Repeat Purchase.', 'member-auto-maintenance': 'Member Auto-Maintenance.', 'member-upgrade': 'Member Upgrade.' }
+const products = ['Calmag', 'Elken Spirulina', 'C-Joie Flex'].map((name, index) => ({
+  ID: 'P' + index, GOODS_NAME: name, GOODS_NO: 'SKU' + index, COVER: 'fixture.png', CONTENT: 'Sample product for interface verification.',
+  SELL_PRICE: [38000, 52000, 45000][index], MARKET_PRICE: [42000, 56000, 48000][index], PRICE_PV: [35, 48, 42][index],
+  TAX_RATE: 7.5, DISCOUNT: 100, STORE_NUMS: 10, COIN: 'NGN', CODE: 'NGN'
+}))
+const payments = [{ label: 'cash', name: 'Account balance' }, { label: 'pay_stack', name: 'Paystack' }, { label: 'prp', name: 'PRP' }]
+const fixtures = {
+  '/v1/shop/index': { list: products, totalCount: 3 }, '/v1/shop/reconsume': { list: products, totalCount: 3 },
+  '/v1/shop/auto-maintenance': { list: products, totalCount: 3 },
+  '/v1/shop/show-cart': {
+    allAddress: [{ ID: 'A1', IS_DEFAULT: '1', ADDRESS: 'Sample address', LGA_NAME: 'District', PROVINCE_NAME: 'State', MOBILE: '0000000000', CONSIGNEE: 'Sample recipient' }],
+    userBalance: { cash: 1000000, prp: 1000000 }, payList: Object.fromEntries(payments.map(item => [item.label, { name: item.name }])),
+    sellType: [{ id: 1, sell_type: payments }], freeShipping: 200000, freight: 1000, coin: 'NGN', isDec: '1', ampDivideLine: 90
+  },
+  '/v1/user/full-info': { userId: 'SAMPLE-MEMBER', countryId: '1', REAL_NAME: 'Sample member', isLocation: '(Left-Null,Right-Null)' },
+  '/v1/oauth/info': { IS_DEC: '1' },
+  '/v1/user/upgrade-info': { baseInfo: { ADD_AT: '2020-01-01', REAL_NAME: 'Sample member', DEC_NAME: 'Silver', IS_MAX: false, LEVEL_LIST: [{ ID: 'L2', LEVEL_NAME: 'Gold', REPAIR_PV: 200 }], UPGRADE_FUNC: 'Product', NOW_PERF: 100, NEXT_PERF: 300 }},
+  '/v1/user/upgrade': { userBalance: { cash: 1000000 }, decUserName: 'SAMPLE-OP', allDecPackage: {}, allGoods: products, region: [], sellType: [{ sell_type: payments }] },
+  '/v1/config/region': []
+}
+const image = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160"><rect x="55" y="18" width="50" height="125" rx="8" fill="#e9f4f8"/><rect x="60" y="65" width="40" height="45" fill="#202259"/><path d="M65 18v-8h30v8" fill="#ddd"/></svg>')
+async function main() {
+  await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
+  const origin = 'http://127.0.0.1:' + server.address().port
+  const browser = await chromium.launch({ channel: 'chrome', headless: true })
+  const context = await browser.newContext({ viewport: { width: 1440, height: 900 }})
+  const errors = []
+  const unknown = []
+  try {
+    await context.route('**/*', async route => {
+      const request = route.request()
+      const url = new URL(request.url())
+      const api = url.pathname.substring(url.pathname.indexOf('/v1/'))
+      if (url.pathname.includes('/v1/')) {
+        assert.equal(request.method(), api === '/v1/user/upgrade-info' ? 'POST' : 'GET', 'Browser verification must not place orders: ' + api)
+        const value = fixtures[api] || (api.includes('region') ? [] : null)
+        if (value === null) { unknown.push(api); return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: false, message: 'Unconfigured sample endpoint' }) }) }
+        return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: { data: value }}) })
+      }
+      if (request.resourceType() === 'image' && url.origin !== origin) return route.fulfill({ contentType: 'image/svg+xml', body: image })
+      if (url.origin === origin) return route.continue()
+      return route.abort()
+    })
+    await context.addInitScript(() => {
+      const now = Math.floor(Date.now() / 1000)
+      const values = { userId: 'SAMPLE-OP', userName: 'SAMPLE-OP', userEmail: 'sample@example.test', loginTimestamp: 'sample-session', accessToken: 'sample-only', refreshToken: 'sample-only', accessTokenExpiresIn: '86400', refreshTokenExpiresIn: '86400', accessTokenUpdateAt: String(now), refreshTokenUpdateAt: String(now), baseInfo: JSON.stringify({ daysDiff: 0 }) }
+      Object.entries(values).forEach(([key, value]) => localStorage.setItem(key, value))
+      document.cookie = 'language=en;path=/'
+    })
+    const page = await context.newPage()
+    page.on('pageerror', error => errors.push(error.message))
+    page.on('console', message => { if (message.type() === 'error') errors.push(message.text()) })
+    const go = async source => {
+      await page.goto(origin + '/#/dashboard/' + source)
+      await page.locator('.ng-purchase').waitFor()
+      if (source !== sources[0]) {
+        assert.equal(await page.getByRole('textbox', { name: 'Member ID', exact: true }).getAttribute('placeholder'), 'NG00000000')
+        assert.equal(await page.getByRole('button', { name: 'Add to cart', exact: true }).count(), 0)
+        await layouts(source + '-lookup')
+        await page.locator('#ng-purchase-search').fill('NG00000001')
+        await page.locator('#ng-purchase-search').press('Enter')
+      }
+      await page.getByRole('button', { name: 'Add to cart', exact: true }).first().waitFor()
+    }
+    const checks = []
+    const layouts = async label => {
+      const source = sources.find(source => label.startsWith(source + '-'))
+      const hasHero = !!heroTitles[source] && (label.endsWith('-catalog') || label.endsWith('-lookup'))
+      const hasRequirement = source === 'member-auto-maintenance' && !hasHero
+      assert.equal(await page.locator('.ng-public-hero').count(), hasHero ? 1 : 0)
+      assert.equal(await page.locator('.ng-purchase-nav').count(), hasHero ? 0 : 1)
+      assert.equal(await page.locator('.ng-purchase-summary .ng-purchase-requirement').count(), hasRequirement ? 1 : 0)
+      if (hasHero) {
+        assert.equal((await page.locator('.ng-public-hero-title').textContent()).replace(/\s+/g, ' ').trim(), heroTitles[source])
+        assert.equal(await page.locator('h1').count(), 1)
+        assert.equal((await page.locator('.ng-public-hero-accent').textContent()).trim(), heroTitles[source].replace(/^(Repeat |Member )/, ''))
+      }
+      for (const [width, height] of [[320, 640], [375, 812], [768, 768], [1024, 768], [1440, 900], [375, 480]]) {
+        await page.setViewportSize({ width, height })
+        await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
+        const size = await page.evaluate(() => ({ width: innerWidth, scroll: document.documentElement.scrollWidth }))
+        assert(size.scroll <= width, label + ': ' + JSON.stringify(size))
+        checks.push({ label, width, height })
+        if (width === 320 || width === 1440) await page.screenshot({ path: path.join(output, label + '-' + width + '.png'), fullPage: true })
+        if (width === 1440 && label === 'member-auto-maintenance-cart') await page.locator('.ng-purchase-summary').screenshot({ path: path.join(output, 'maintenance-summary-insufficient.png') })
+        if (width === 1440 && label.endsWith('-catalog') && source !== 'repeat-purchase') await page.locator('.ng-purchase-cart-hint').screenshot({ path: path.join(output, source + '-cart-hint.png') })
+      }
+    }
+    for (const source of sources) {
+      await page.setViewportSize({ width: 1440, height: 900 })
+      await go(source)
+      if (source === 'member-upgrade') {
+        assert(await page.getByText('Upgrade method', { exact: true }).isVisible())
+        assert(await page.getByText('Upgrade to', { exact: true }).isVisible())
+        await page.locator('#ng-purchase-level').click()
+        await page.getByText('Gold', { exact: true }).click()
+        await page.locator('.el-select-dropdown:visible').waitFor({ state: 'hidden' })
+      }
+      if (source !== 'repeat-purchase') {
+        assert.equal(await page.locator('.ng-purchase-products.ng-ui-section-card').count(), 1)
+        assert(await page.getByRole('heading', { name: source === 'member-upgrade' ? 'Upgrade products' : 'Choose products', exact: true }).isVisible())
+      }
+      assert.equal(await page.locator('.ng-signup-totals').count(), source === 'repeat-purchase' ? 1 : 0)
+      assert.equal(await page.locator('.ng-purchase-cart-hint').count(), source === 'repeat-purchase' ? 0 : 1)
+      await layouts(source + '-catalog')
+      await page.getByRole('button', { name: 'Add to cart', exact: true }).first().click()
+      await page.getByRole('button', { name: /Open cart/ }).click()
+      await page.getByRole('heading', { name: 'Your Cart', exact: true }).waitFor()
+      await layouts(source + '-cart')
+      await page.getByRole('button', { name: 'Continue shopping', exact: true }).click()
+      assert(page.url().includes('/dashboard/' + source), 'Incorrect return page')
+      assert.equal(await page.getByRole('button', { name: 'Remove', exact: true }).count(), 1)
+      if (source === 'member-auto-maintenance') {
+        await page.getByRole('button', { name: /Open cart/ }).click()
+        assert(await page.getByRole('button', { name: 'Proceed to checkout', exact: true }).isDisabled())
+        assert((await page.locator('.ng-purchase-requirement-warning').textContent()).includes('at least 90 BV'))
+        await page.getByRole('button', { name: 'Continue shopping', exact: true }).click()
+        const quantity = page.locator('.ng-signup-product-selected-footer input')
+        await quantity.fill('3')
+        await quantity.press('Tab')
+      }
+      await page.getByRole('button', { name: /Open cart/ }).click()
+      if (source === 'member-auto-maintenance') {
+        await page.locator('.ng-purchase-requirement.is-sufficient').waitFor()
+        assert.equal(await page.locator('.ng-purchase-requirement-warning').count(), 0)
+        await page.setViewportSize({ width: 1440, height: 900 })
+        await page.locator('.ng-purchase-summary').screenshot({ path: path.join(output, 'maintenance-summary-sufficient.png') })
+      }
+      await page.getByRole('button', { name: 'Proceed to checkout', exact: true }).click()
+      await page.getByRole('heading', { name: 'Confirm your order', exact: true }).waitFor()
+      await page.waitForFunction(() => [...document.querySelectorAll('button')].some(button => button.textContent.trim() === 'Place order' && !button.disabled))
+      await layouts(source + '-checkout')
+      await page.getByRole('button', { name: 'Back to cart', exact: true }).focus()
+      await page.keyboard.press('Enter')
+      await page.getByRole('heading', { name: 'Your Cart', exact: true }).waitFor()
+      await page.reload()
+      await page.getByText('Your cart is empty', { exact: true }).waitFor()
+    }
+    assert.deepEqual(errors, [])
+    assert.deepEqual(unknown, [])
+    const guest = await browser.newContext()
+    const guestPage = await guest.newPage()
+    await guestPage.route('**/*', route => new URL(route.request().url()).origin === origin ? route.continue() : route.abort())
+    await guestPage.goto(origin + '/#/dashboard/repeat-purchase')
+    await guestPage.waitForURL(/#\/login/)
+    await guest.close()
+    fs.writeFileSync(path.join(output, 'results.json'), JSON.stringify({ checks, errors, unknown, loginGuard: 'passed', screenshots: output }, null, 2))
+    console.log(JSON.stringify({ layoutChecks: checks.length, errors, loginGuard: 'passed', screenshots: output }))
+  } finally { await browser.close(); server.close() }
+}
+main().catch(error => { console.error(error); server.close(); process.exitCode = 1 })

+ 30 - 0
src/ui-refactor/views/purchase/__tests__/verifyMutations.cjs

@@ -0,0 +1,30 @@
+/* eslint-env node */
+const fs = require('fs')
+const path = require('path')
+const { spawnSync } = require('child_process')
+const assert = require('assert')
+const root = path.resolve(__dirname, '../../../../..')
+const cases = [
+  ['purchaseBehavior.js', 'item.quantity > 0', 'item.quantity >= 0', 'zero quantity'],
+  ['purchaseBehavior.js', 'Number(this.pricePvSum) >= Number(this.ampDivideLine)', 'Number(this.pricePvSum) <= Number(this.ampDivideLine)', 'inverted BV threshold'],
+  ['purchaseBehavior.js', 'this.goods = rows', 'this.goods = rows.slice(-1)', 'missing earlier pages'],
+  ['purchaseBehavior.js', 'if (!this.canCheckout || !this.isCurrent()) return', 'if (!this.isCurrent()) return', 'duplicate submission'],
+  ['routes.js', 'Object.prototype.hasOwnProperty.call(sources, source)', 'source in sources', 'unsafe cart origin'],
+  ['purchaseBehavior.js', "if (!data.userId || !data.REAL_NAME) throw new Error('Member not found.')", "if (!data.userId || !data.number || !data.REAL_NAME) throw new Error('Member not found.')", 'incorrect full-info number requirement'],
+  ['purchaseBehavior.js', 'this.newOrder.userName = this.memberDetails.number', 'this.newOrder.userName = data.number', 'missing order member number'],
+  ['purchaseBehavior.js', "if (!data.userId || !data.REAL_NAME) throw new Error('Member not found.')", "if (!data.REAL_NAME) throw new Error('Member not found.')", 'missing member identity validation']
+]
+for (const [file, before, after, name] of cases) {
+  const filename = path.join(__dirname, '..', file)
+  const original = fs.readFileSync(filename, 'utf8')
+  assert(original.includes(before) && original.indexOf(before) === original.lastIndexOf(before), 'Mutation must match exactly once: ' + name)
+  try {
+    fs.writeFileSync(filename, original.replace(before, after))
+    const result = spawnSync(process.execPath, [path.join(root, 'node_modules/jest/bin/jest.js'), '--runInBand', '--testMatch', '**/purchase/__tests__/PurchaseFlow.spec.js'], { cwd: root, encoding: 'utf8', timeout: 60000 })
+    if (result.error) throw result.error
+    const output = result.stdout + result.stderr
+    assert(result.status === 1 && /Tests:.*failed/.test(output) && output.includes('expect('), 'Mutation survived or the test runner failed: ' + name + '\n' + output)
+    console.log('Killed: ' + name)
+  } finally { fs.writeFileSync(filename, original) }
+}
+console.log(cases.length + '/' + cases.length + ' mutation checks passed; original sources restored.')

+ 53 - 0
src/ui-refactor/views/purchase/adapters.js

@@ -0,0 +1,53 @@
+import Repeat from '@/views/shop/standard-products.vue?vue&type=script&lang=js'
+import MemberRepeat from '@/views/shop/reconsume.vue?vue&type=script&lang=js'
+import Maintenance from '@/views/user/member-auto-maintenance.vue?vue&type=script&lang=js'
+import Upgrade from '@/views/user/member-upgrade.vue?vue&type=script&lang=js'
+import PurchaseFlow from './PurchaseFlow.vue'
+import usersInfo from '@/utils/usersInfo'
+import submitPurchase from './submitPurchase'
+
+export function createAdapter(legacy, source) {
+  return {
+    ...PurchaseFlow,
+    name: 'Purchase-' + source,
+    // 初始化由适配层管理,防止恢复草稿时被旧 created/mounted 重置。
+    extends: { ...legacy, created: undefined, mounted: undefined },
+    data() {
+      return { ...PurchaseFlow.data.call(this), source }
+    },
+    methods: {
+      ...PurchaseFlow.methods,
+      resetBusinessMember() {
+        const fresh = legacy.data.call(this)
+        const fields = this.isUpgrade
+          ? [
+            'form',
+            'payForm',
+            'upgradeOption',
+            'toLevel',
+            'needDiffPv',
+            'isMax',
+            'nowPerf',
+            'nextPerf',
+            'decUserName',
+            'regionData'
+          ]
+          : ['newOrder', 'memberDetails', 'regionData']
+        fields.forEach(key => {
+          this[key] = fresh[key]
+        })
+        if (!this.isUpgrade && Number(this.isDec) === 1) {
+          this.newOrder.decUserName = usersInfo.userName()
+        }
+      },
+      submitLegacy: submitPurchase
+    }
+  }
+}
+
+export default {
+  'repeat-purchase': createAdapter(Repeat, 'repeat-purchase'),
+  'member-repurchase': createAdapter(MemberRepeat, 'member-repurchase'),
+  'member-auto-maintenance': createAdapter(Maintenance, 'member-auto-maintenance'),
+  'member-upgrade': createAdapter(Upgrade, 'member-upgrade')
+}

+ 26 - 0
src/ui-refactor/views/purchase/drafts.js

@@ -0,0 +1,26 @@
+let owner = null
+let drafts = Object.create(null)
+const copy = value => JSON.parse(JSON.stringify(value))
+
+export function readDraft(source, session) {
+  if (owner !== session) {
+    clearDrafts()
+    owner = session
+  }
+  return drafts[source] ? copy(drafts[source]) : null
+}
+
+export function writeDraft(source, session, draft) {
+  if (owner === null) owner = session
+  // 旧请求不能把上一登录会话的草稿写回来。
+  if (owner === session) drafts[source] = copy(draft)
+}
+
+export function clearDraft(source, session) {
+  if (owner === session) delete drafts[source]
+}
+
+export function clearDrafts() {
+  owner = null
+  drafts = Object.create(null)
+}

+ 565 - 0
src/ui-refactor/views/purchase/purchaseBehavior.js

@@ -0,0 +1,565 @@
+import * as shop from '@/api/shop'
+import { fetchFullInfo, fetchUpgrade, fetchUpgradeInfo, getUserInfo } from '@/api/user'
+import { getRegionList } from '@/api/config'
+import usersInfo from '@/utils/usersInfo'
+import { readDraft, writeDraft, clearDraft } from './drafts'
+import { sources, purchasePath } from './routes'
+
+export const loginSession = () => `${usersInfo.userId() || ''}:${usersInfo.loginTimestamp() || ''}`
+const draftFields = [
+  'cartItems',
+  'memberKey',
+  'memberSearch',
+  'memberDetails',
+  'newOrder',
+  'listQuery',
+  'tableData',
+  'total',
+  'shippingAddressList',
+  'addressId',
+  'regionData',
+  'payList',
+  'currency',
+  'userBalance',
+  'freeShipping',
+  'freight',
+  'currencyUnitCode',
+  'ampDivideLine',
+  'isDec',
+  'settingsReady',
+  'toLevel',
+  'needDiffPv',
+  'isMax',
+  'upgradeOption',
+  'nowPerf',
+  'nextPerf',
+  'decUserName'
+]
+const numeric = value => value !== null && value !== '' && Number.isFinite(Number(value))
+export function validItem(item, upgrade) {
+  return (
+    Number.isInteger(item.quantity) &&
+    item.quantity > 0 &&
+    item.quantity <= Number(item.STORE_NUMS) &&
+    ['SELL_PRICE', 'PRICE_PV', 'TAX_RATE'].every(
+      key => numeric(item[key]) && Number(item[key]) >= 0
+    ) &&
+    (upgrade || (numeric(item.DISCOUNT) && Number(item.DISCOUNT) >= 0))
+  )
+}
+
+export default {
+  props: { stage: { type: String, default: 'catalog' }},
+  data() {
+    return {
+      cartItems: [],
+      memberKey: '',
+      memberSearch: '',
+      memberLoading: false,
+      settingsReady: false,
+      settingsLoading: false,
+      eligibilityReady: false,
+      listLoading: false,
+      submissionLocked: false,
+      cancelLoading: false,
+      completed: false,
+      ampDivideLine: null,
+      regionData: [],
+      shippingAddressList: [],
+      total: 0,
+      sessionKey: '',
+      requestNo: 0,
+      listRequestNo: 0,
+      loadError: '',
+      countdownTimer: null,
+      paymentAttempt: 0
+    }
+  },
+  computed: {
+    isUpgrade() {
+      return this.source === 'member-upgrade'
+    },
+    isSelf() {
+      return this.source === 'repeat-purchase'
+    },
+    isMaintenance() {
+      return this.source === 'member-auto-maintenance'
+    },
+    title() {
+      return sources[this.source]
+    },
+    busy() {
+      return (
+        this.submissionLocked ||
+        this.submitButtonStat ||
+        this.visible ||
+        this.cancelLoading ||
+        this.payDialog
+      )
+    },
+    fetching() {
+      return this.memberLoading || this.listLoading || this.settingsLoading
+    },
+    catalogReady() {
+      return (
+        this.isSelf ||
+        (!!this.memberKey &&
+          (!this.isUpgrade || (this.eligibilityReady && !this.isDec && !this.isMax)))
+      )
+    },
+    itemsValid() {
+      return (
+        this.cartItems.length > 0 && this.cartItems.every(item => validItem(item, this.isUpgrade))
+      )
+    },
+    maintenanceReady() {
+      return (
+        !this.isMaintenance ||
+        (numeric(this.ampDivideLine) &&
+          Number(this.ampDivideLine) >= 0 &&
+          Number(this.pricePvSum) >= Number(this.ampDivideLine))
+      )
+    },
+    canCheckout() {
+      return (
+        this.catalogReady &&
+        this.itemsValid &&
+        this.settingsReady &&
+        this.maintenanceReady &&
+        [this.orderTotal, this.pricePvSum, this.taxSum].every(
+          value => numeric(value) && Number(value) >= 0
+        ) &&
+        !this.fetching &&
+        !this.busy
+      )
+    },
+    itemCount() {
+      return this.cartItems.reduce((sum, item) => sum + item.quantity, 0)
+    },
+    selectedIds() {
+      return this.cartItems.map(item => item.ID)
+    },
+    quantities() {
+      return (this.tableData || []).map(
+        row => this.cartItems.find(item => item.ID === row.ID)?.quantity || 1
+      )
+    },
+    payment() {
+      return this.isUpgrade ? this.payForm : this.form
+    },
+    paymentCallback() {
+      const attempt = this.paymentAttempt
+      return () => {
+        if (attempt === this.paymentAttempt) this.processPayment()
+      }
+    },
+    paymentClosed() {
+      const attempt = this.paymentAttempt
+      return () => {
+        if (attempt === this.paymentAttempt) this.handleClose()
+      }
+    },
+    activePayType: {
+      get() {
+        return this.isUpgrade ? this.form.payType : this.payType
+      },
+      set(value) {
+        if (this.isUpgrade) this.form.payType = value
+        else this.payType = value
+      }
+    },
+    memberName() {
+      return this.isUpgrade ? this.form.userRealName : this.newOrder?.recRealName
+    },
+    cartRows() {
+      return this.cartItems.map(item => {
+        const unit = Number(item.SELL_PRICE) * (this.isUpgrade ? 1 : Number(item.DISCOUNT) / 100)
+        return {
+          ...item,
+          unitPrice: this.tool.formatPrice(unit),
+          linePrice: this.tool.formatPrice(unit * item.quantity)
+        }
+      })
+    },
+    orderTotal() {
+      return this.isUpgrade ? this.sellPriceSum : this.cashSum
+    }
+  },
+  watch: {
+    stage() {
+      this.prepareCheckout()
+    },
+    memberSearch(value) {
+      if (this.memberKey && value !== this.memberKey && !this.busy) this.resetMember()
+    },
+    submitButtonStat(value) {
+      if (!value && !this.visible) {
+        this.submissionLocked = false
+        this.payPassword = ''
+      }
+    },
+    addressId() {
+      this.syncCart()
+    }
+  },
+  created() {
+    this.sessionKey = loginSession()
+    const draft = readDraft(this.source, this.sessionKey)
+    if (draft) {
+      draftFields.forEach(key => {
+        if (key in draft) this.$set(this, key, draft[key])
+      })
+      Object.assign(this.form, draft.checkoutForm)
+      if (this.payForm) Object.assign(this.payForm, draft.reportingForm)
+      this.activePayType = draft.activePayType
+      this.listLoading = false
+    } else {
+      this.listLoading = false
+      if (this.isSelf) this.reload()
+    }
+    if (this.isUpgrade) this.fetchUserInfo()
+    this.syncCart()
+    this.prepareCheckout()
+  },
+  beforeDestroy() {
+    clearInterval(this.countdownTimer)
+    if (!this.completed && this.isCurrent()) {
+      const draft = {}
+      draftFields.forEach(key => {
+        if (this[key] !== undefined) draft[key] = this[key]
+      })
+      // 只保留结算表单;支付密码、交易编号和 SDK 元数据不进入草稿。
+      draft.checkoutForm = this.isUpgrade
+        ? Object.fromEntries(
+          [
+            'insertUserName',
+            'userRealName',
+            'userDecName',
+            'addAt',
+            'upgradeFunc',
+            'upgradeValue',
+            'consignee',
+            'acceptMobile',
+            'areaSelected',
+            'lgaName',
+            'cityName',
+            'address',
+            'remark',
+            'packageId'
+          ].map(key => [key, this.form[key]])
+        )
+        : { email: this.form.email }
+      draft.reportingForm = this.isSelf ? { recUserName: this.payForm.recUserName } : {}
+      draft.activePayType = this.activePayType
+      writeDraft(this.source, this.sessionKey, draft)
+    }
+  },
+  methods: {
+    isCurrent(no = this.requestNo) {
+      return !this._isDestroyed && no === this.requestNo && loginSession() === this.sessionKey
+    },
+    fail(error) {
+      this.loadError =
+        typeof error === 'string'
+          ? error
+          : error?.message || 'Unable to load purchase information. Please try again.'
+      this.$message.error(this.loadError)
+    },
+    async reload() {
+      this.loadError = ''
+      await Promise.all([this.getList(), this.getShoppingCart()])
+    },
+    async retry() {
+      this.loadError = ''
+      if (this.isSelf) await this.reload()
+      else {
+        if (this.isUpgrade) await this.fetchUserInfo()
+        await this.searchMember()
+      }
+    },
+    async fetchUserInfo() {
+      try {
+        const response = await getUserInfo()
+        if (!this.isCurrent()) return
+        this.isDec = Number(response.data.IS_DEC) === 0
+        this.eligibilityReady = true
+        if (this.isDec) this.fail('Stockist does not exist')
+      } catch (error) {
+        if (this.isCurrent()) this.fail(error)
+      }
+    },
+    resetMember() {
+      this.requestNo++
+      this.memberKey = ''
+      this.cartItems = []
+      this.tableData = []
+      this.total = 0
+      this.settingsReady = false
+      this.memberLoading = false
+      this.listLoading = false
+      this.settingsLoading = false
+      this.resetBusinessMember()
+      this.syncCart()
+      clearDraft(this.source, this.sessionKey)
+    },
+    async searchMember() {
+      if (
+        this.busy ||
+        this.fetching ||
+        (this.isUpgrade && (!this.eligibilityReady || this.isDec))
+      ) {
+        return
+      }
+      const name = this.memberSearch
+      if (!name) {
+        this.fail(this.$t('user.enterMemberCode'))
+        return
+      }
+      this.resetMember()
+      this.loadError = ''
+      this.memberLoading = true
+      const no = this.requestNo
+      try {
+        if (this.isUpgrade) {
+          this.form.insertUserName = name
+          const { data } = await fetchUpgradeInfo({ userName: name })
+          if (!this.isCurrent(no)) return
+          const info = data.baseInfo
+          Object.assign(this.form, {
+            addAt: info.ADD_AT,
+            userRealName: info.REAL_NAME,
+            userDecName: info.DEC_NAME,
+            upgradeFunc: info.UPGRADE_FUNC
+          })
+          this.isMax = info.IS_MAX
+          this.upgradeOption = info.LEVEL_LIST
+          this.nowPerf = info.NOW_PERF
+          this.nextPerf = info.NEXT_PERF
+          this.memberKey = name
+          await this.getList()
+        } else {
+          const { data } = await fetchFullInfo({ userName: name })
+          if (!this.isCurrent(no)) return
+          if (!data.userId || !data.REAL_NAME) throw new Error('Member not found.')
+          // 旧页的 number 来自输入框,full-info 不要求返回会员编号。
+          this.memberDetails = { number: name, ...data }
+          this.newOrder.recRealName = data.REAL_NAME
+          this.newOrder.userName = this.memberDetails.number
+          this.listQuery.userId = data.userId
+          this.memberKey = name
+          await this.reload()
+        }
+      } catch (error) {
+        if (this.isCurrent(no)) {
+          this.resetMember()
+          this.fail(error)
+        }
+      } finally {
+        if (this.isCurrent(no)) this.memberLoading = false
+      }
+    },
+    async getList() {
+      const no = this.requestNo
+      const listNo = ++this.listRequestNo
+      this.listLoading = true
+      try {
+        const api = this.isUpgrade
+          ? fetchUpgrade
+          : this.isSelf
+            ? shop.fetchProductList
+            : this.isMaintenance
+              ? shop.fetchAutoMaintenanceProductList
+              : shop.fetchReconsumeProductList
+        const { data } = await api(
+          this.isUpgrade ? { userName: this.form.insertUserName } : { ...this.listQuery }
+        )
+        if (!this.isCurrent(no) || listNo !== this.listRequestNo) return
+        this.tableData = Object.values(this.isUpgrade ? data.allGoods || [] : data.list || [])
+        this.total = this.isUpgrade ? this.tableData.length : Number(data.totalCount)
+        if (this.isUpgrade) {
+          this.userBalance = data.userBalance
+          this.decUserName = data.decUserName
+          this.allDecPackage = data.allDecPackage
+          this.regionData = data.region
+          this.payList = Object.values(data.sellType?.[0]?.sell_type || {})
+          this.form.payType = (this.payList[1] || this.payList[0])?.label || ''
+          this.settingsReady = this.payList.length > 0
+        }
+        const first = this.tableData[0]
+        if (first) this.currencyUnitCode = this.isUpgrade ? first.CODE : first.COIN
+        this.syncCart()
+      } catch (error) {
+        if (this.isCurrent(no)) {
+          this.tableData = []
+          this.fail(error)
+        }
+      } finally {
+        if (this.isCurrent(no) && listNo === this.listRequestNo) this.listLoading = false
+      }
+    },
+    async getShoppingCart() {
+      const no = this.requestNo
+      this.settingsLoading = true
+      this.settingsReady = false
+      try {
+        const query = { categoryType: this.listQuery.categoryType }
+        if (!this.isSelf) query.userId = this.listQuery.userId
+        const { data } = await shop.fetchShoppingCart(query)
+        if (!this.isCurrent(no)) return
+        this.shippingAddressList = data.allAddress || []
+        this.addressId = this.shippingAddressList.find(item => item.IS_DEFAULT === '1')?.ID || ''
+        this.userBalance = data.userBalance || {}
+        this.currency = data.payList || {}
+        this.payList = Object.values(
+          data.sellType.find(item => item.id === this.listQuery.categoryType)?.sell_type || {}
+        ).filter(item => !this.isMaintenance || item.label !== 'prp')
+        this.payType = this.payList[0]?.label || ''
+        this.freeShipping = data.freeShipping
+        this.freight = data.freight
+        this.isDec = data.isDec
+        if (this.isSelf) this.currencyUnitCode = data.coin
+        if (this.isMaintenance) this.ampDivideLine = data.ampDivideLine
+        this.settingsReady =
+          this.payList.length > 0 && numeric(data.freight) && numeric(data.freeShipping)
+        this.syncCart()
+      } catch (error) {
+        if (this.isCurrent(no)) this.fail(error)
+      } finally {
+        if (this.isCurrent(no)) this.settingsLoading = false
+      }
+    },
+    async getRegion() {
+      const no = this.requestNo
+      try {
+        const { data } = await getRegionList({ countryId: this.memberDetails.countryId })
+        if (this.isCurrent(no)) this.regionData = data
+      } catch (error) {
+        if (this.isCurrent(no)) this.fail(error)
+      }
+    },
+    toggleProduct(row) {
+      if (this.busy || !this.catalogReady) return
+      const index = this.cartItems.findIndex(item => item.ID === row.ID)
+      if (index >= 0) this.cartItems.splice(index, 1)
+      else {
+        const item = { ...row, quantity: 1 }
+        if (!validItem(item, this.isUpgrade)) {
+          this.fail('This product is unavailable.')
+          return
+        }
+        this.cartItems.push(item)
+      }
+      this.completed = false
+      this.syncCart()
+    },
+    changeQuantity({ row, value }) {
+      if (this.busy) return
+      const item = this.cartItems.find(item => item.ID === row.ID)
+      if (!item) return
+      if (!validItem({ ...item, quantity: value }, this.isUpgrade)) {
+        this.fail('Enter a whole quantity within the available stock.')
+        return
+      }
+      item.quantity = value
+      this.syncCart()
+    },
+    syncCart() {
+      // 旧接口的数据形状保持不变,但投影来自全部分页的唯一选购列表。
+      const rows = this.cartItems.map(item => ({
+        ...item,
+        [this.isUpgrade ? 'goodsNum' : 'chose_num']: item.quantity
+      }))
+      this.multipleSelection = this.isUpgrade ? rows : [rows]
+      this.goods = rows
+      this.handleSureChange()
+      if (this.isUpgrade) this.selectProduct()
+      else this.getSumMoney()
+    },
+    clearCart() {
+      if (this.busy) return
+      this.cartItems = []
+      this.syncCart()
+      clearDraft(this.source, this.sessionKey)
+    },
+    navigate(stage) {
+      if (this.busy) return
+      const path = stage === 'catalog' ? purchasePath(this.source) : '/dashboard/' + stage
+      this.$router.push({ path, query: { ...this.$route.query, from: this.source }})
+    },
+    prepareCheckout() {
+      if (this.stage !== 'checkout') return
+      this.syncCart()
+      if (!this.itemsValid || !this.catalogReady || !this.maintenanceReady) {
+        this.navigate('cart')
+        return
+      }
+      if (!this.isSelf && !this.isUpgrade) this.getRegion()
+    },
+    submitOrder() {
+      if (!this.canCheckout || !this.isCurrent()) return
+      if (!this.payList.some(item => item.label === this.activePayType)) return
+      this.syncCart()
+      this.submissionLocked = true
+      this.submitLegacy()
+        .catch(error => {
+          if (this.isCurrent()) this.fail(error)
+        })
+        .finally(() => {
+          this.submitButtonStat = false
+          this.submissionLocked = false
+        })
+    },
+    finishOrder() {
+      this.completed = true
+      this.cartItems = []
+      clearDraft(this.source, this.sessionKey)
+    },
+    async handleClose() {
+      if (this.cancelLoading || !this.visible) return
+      this.cancelLoading = true
+      try {
+        try {
+          await this.$confirm(this.$t('shop.confirmClose'))
+        } catch (_) {
+          return
+        }
+        if (!this.isCurrent() || !this.visible) return
+        if (!this.payment.orderSn) throw new Error('Missing payment order number.')
+        await shop.deleteApproachOrder({ orderSn: this.payment.orderSn })
+        if (!this.isCurrent()) return
+        this.visible = false
+        this.submissionLocked = false
+        this.submitButtonStat = false
+        this.payment.orderSn = ''
+      } catch (error) {
+        if (this.isCurrent()) this.fail(error)
+      } finally {
+        this.cancelLoading = false
+      }
+    },
+    processPayment() {
+      if (!this.visible || !this.payment.orderSn || !this.isCurrent() || this.completed) return
+      this.visible = false
+      this.payStackLoading = false
+      this.finishOrder()
+      this.payDialog = true
+      this.handleCountdown()
+    },
+    handleCountdown() {
+      clearInterval(this.countdownTimer)
+      this.countdown = 5
+      this.countdownTimer = setInterval(() => {
+        if (--this.countdown <= 0) {
+          clearInterval(this.countdownTimer)
+          if (this.isCurrent()) this.handleOrderList()
+        }
+      }, 1000)
+    },
+    mayLeave(to) {
+      const orderList =
+        ['/shop/own-order', '/shop/member-order'].includes(to.path) ||
+        (to.path === '/dashboard/index' && to.query?.tab === 'orders')
+      return !this.busy || to.path === '/login' || (this.completed && orderList)
+    }
+  }
+}

+ 58 - 0
src/ui-refactor/views/purchase/routes.js

@@ -0,0 +1,58 @@
+export const sources = {
+  'repeat-purchase': 'Repeat Purchase',
+  'member-repurchase': 'Member Repeat Purchase',
+  'member-auto-maintenance': 'Member Auto-Maintenance',
+  'member-upgrade': 'Member Upgrade'
+}
+
+export const isSource = source => Object.prototype.hasOwnProperty.call(sources, source)
+export const purchasePath = source =>
+  isSource(source) ? '/dashboard/' + source : '/dashboard/index'
+
+const catalogHeroes = {
+  'repeat-purchase': {
+    titleParts: [{ text: 'Repeat ' }, { text: 'purchase.', accent: true }],
+    subtitle: 'Quickly reorder the products you already know and love.'
+  },
+  'member-repurchase': {
+    titleParts: [{ text: 'Member ' }, { text: 'Repeat Purchase.', accent: true }],
+    subtitle:
+      "Place a repeat-purchase order for a member in your network. Search the member ID, pick products, and check out through the member's own cart."
+  },
+  'member-auto-maintenance': {
+    titleParts: [{ text: 'Member ' }, { text: 'Auto-Maintenance.', accent: true }],
+    subtitle:
+      "Place an auto-maintenance order for a member in your network. Search the member ID, pick products, and check out through the member's own cart. The selected products must total at least 90 BV."
+  },
+  'member-upgrade': {
+    titleParts: [{ text: 'Member ' }, { text: 'Upgrade.', accent: true }],
+    subtitle:
+      "Look up a member, then choose the level to upgrade to on the checkout page and make up the upgrade difference with a product order. Search the member ID, add products to the member's cart, and check out through the member's own cart."
+  }
+}
+
+const component = () => import('./PurchasePage.vue')
+export default [
+  ...Object.keys(sources).map(source => ({
+    path: 'dashboard/' + source,
+    component,
+    name: 'Purchase-' + source,
+    meta: {
+      title: sources[source],
+      purchaseSource: source,
+      purchaseStage: 'catalog',
+      publicLayout: {
+        hero: {
+          ...catalogHeroes[source],
+          breadcrumbs: [{ text: 'My Account', to: '/dashboard/index' }, { text: sources[source] }]
+        }
+      }
+    }
+  })),
+  ...['cart', 'checkout'].map(stage => ({
+    path: 'dashboard/' + stage,
+    component,
+    name: 'Purchase-' + stage,
+    meta: { title: stage === 'cart' ? 'Your Cart' : 'Confirm your order', purchaseStage: stage }
+  }))
+]

+ 134 - 0
src/ui-refactor/views/purchase/submitPurchase.js

@@ -0,0 +1,134 @@
+import * as shop from '@/api/shop'
+import { fetchSendUpgrade } from '@/api/user'
+
+// 保持四个旧提交契约;新层统一锁定异步上下文,并避开旧方法的敏感参数日志。
+export default async function submitPurchase() {
+  const type = this.activePayType
+  if (this.isSelf && !(await new Promise(resolve => this.$refs.payForm.validate(resolve)))) return
+  if (!this.isSelf && !(this.isUpgrade ? this.decUserName : this.newOrder.decUserName)) {
+    this.$message.error(this.$t('shop.declarationCenterNumberTips'))
+    return
+  }
+  if (!this.isSelf && !this.isUpgrade && this.newOrder.recRealName === '-') {
+    this.$message.error(this.$t('shop.memberNotFound'))
+    return
+  }
+  if (this.isUpgrade && !this.form.insertUserName) {
+    this.$message.error(this.$t('user.enterMemberCode'))
+    return
+  }
+  if (this.isUpgrade && !this.toLevel) {
+    this.$message.error(this.$t('profile.selectLevel'))
+    return
+  }
+  const balance = this.userBalance[type]
+  const due = Number(this.isUpgrade ? this.payAmount : this.cashSum)
+  if (
+    type !== 'pay_stack' &&
+    (!this.isUpgrade || type === 'cash') &&
+    (balance == null ||
+      balance === '' ||
+      !Number.isFinite(Number(balance)) ||
+      Number(balance) < due)
+  ) {
+    this.$message.error(
+      (this.payList.find(item => item.label === type)?.name || '') + this.$t('shop.balanceNotAllow')
+    )
+    return
+  }
+  let params
+  if (this.isUpgrade) {
+    params = {
+      decUserName: this.decUserName,
+      packageId: this.form.packageId,
+      goodsId: [...this.form.goodsId],
+      goodsNum: [...this.form.goodsNum],
+      location: this.form.location,
+      decWay: this.decWay,
+      decLv: this.toLevel,
+      insertUserName: this.form.insertUserName,
+      remark: this.form.remark,
+      address: this.form.address,
+      consignee: this.form.consignee,
+      acceptMobile: this.form.acceptMobile,
+      province: this.form.areaSelected[0] || '',
+      lgaName: this.form.lgaName,
+      cityName: this.form.cityName,
+      nowPerf: this.nowPerf,
+      nextPerf: this.nextPerf,
+      payType: type
+    }
+  } else {
+    params = {
+      goodsId: [...this.goodsId],
+      goodsNum: [...this.goodsNum],
+      email: this.form.email,
+      addressId: this.addressId,
+      payType: type,
+      ...(this.isSelf
+        ? { address: this.address, userName: this.payForm.recUserName }
+        : {
+          userName: this.newOrder.userName,
+          consignee: this.newOrder.consignee,
+          acceptMobile: this.newOrder.mobile,
+          province: [...this.newOrder.areaSelected],
+          lgaName: this.newOrder.lgaName,
+          cityName: this.newOrder.cityName,
+          detailaddress: this.newOrder.address,
+          decUserName: this.newOrder.decUserName
+        })
+    }
+  }
+  this.submitButtonStat = true
+  if (!this.isUpgrade) {
+    let result
+    try {
+      result = await this.$prompt(this.$t('shop.inputPasswordTips'), this.$t('common.hint'), {
+        confirmButtonText: this.$t('common.confirm'),
+        cancelButtonText: this.$t('common.cancel'),
+        customClass: 'csClass',
+        type: 'info',
+        inputType: 'password',
+        inputPattern: /\S+/,
+        inputErrorMessage: this.$t('shop.inputPasswordTips')
+      })
+    } catch (_) {
+      return
+    }
+    if (!this.isCurrent()) return
+    params.payPassword = result.value
+  }
+  const api = this.isUpgrade
+    ? fetchSendUpgrade
+    : this.isSelf
+      ? type === 'pay_stack'
+        ? shop.createApproachOrder
+        : shop.createOrder
+      : this.isMaintenance
+        ? type === 'pay_stack'
+          ? shop.createApproachAutoMaintenanceOrder
+          : shop.createAutoMaintenanceOrder
+        : type === 'pay_stack'
+          ? shop.createApproachReconsumeOrder
+          : shop.createReconsumeOrder
+  const response = await api(params)
+  if (!this.isCurrent()) return
+  if (type === 'pay_stack') {
+    const amount = this.isUpgrade ? response.data.ORDER_AMOUNT : this.cashSum
+    if (!response.data.SN || !Number.isFinite(Number(amount)) || Number(amount) < 0) {
+      throw new Error('Invalid payment order returned. Please contact customer service.')
+    }
+    this.payment.orderSn = response.data.SN
+    this.payment.amount = amount
+    if (!this.isUpgrade) this.payment.metadata.cart_id = response.data.SN
+    this.payment.metadata.custom_fields[0].value = response.data.SN
+    this.paymentAttempt++
+    this.visible = true
+  } else {
+    const ownOrder =
+      this.isSelf || (this.isMaintenance && this.newOrder.userName === this.memberDetails.number)
+    this.finishOrder()
+    this.$message({ message: this.$t('common.successfully'), type: 'success', duration: 5000 })
+    this.$router.push({ path: ownOrder ? '/shop/own-order' : '/shop/member-order' })
+  }
+}

+ 59 - 68
src/ui-refactor/views/signup/SignupPage.vue

@@ -1,15 +1,40 @@
 <template>
   <div v-loading="loading" class="ng-public-signup">
     <el-form :model="form" label-position="top" @submit.native.prevent>
+      <ui-section-card title="Network Information">
+        <el-row :gutter="24">
+          <el-col :xs="24" :sm="12">
+            <el-form-item label="Referrer ID" required>
+              <el-input
+                v-model="form.recUserName"
+                label="Referrer ID"
+                aria-required="true"
+                @change="handleChkRecUser"
+              />
+            </el-form-item>
+          </el-col>
+          <el-col :xs="24" :sm="12">
+            <el-form-item label="Actual Referrer ID" required>
+              <el-input
+                v-model="form.conUserName"
+                label="Actual Referrer ID"
+                aria-required="true"
+                @change="handleChkRecommenderActualName"
+              />
+            </el-form-item>
+          </el-col>
+        </el-row>
+      </ui-section-card>
       <ui-section-card title="Basic Information">
         <el-row :gutter="24">
           <el-col :xs="24" :sm="12">
-            <el-form-item label="Country"  >
+            <el-form-item label="Country">
               <el-select
                 id="ng-signup-SignupPagevue-0"
                 v-model="form.countryId"
                 filterable
                 placeholder="Select country"
+                popper-class="ng-refactor-popper"
                 @change="countriesChange"
               >
                 <el-option
@@ -28,6 +53,7 @@
                 v-model="form.languageId"
                 filterable
                 placeholder="Select language"
+                popper-class="ng-refactor-popper"
               >
                 <el-option
                   v-for="item in languageList"
@@ -55,6 +81,7 @@
                 id="ng-signup-SignupPagevue-2"
                 v-model="form.decLv"
                 placeholder="Select level"
+                popper-class="ng-refactor-popper"
               >
                 <el-option
                   v-for="item in allDecLevel"
@@ -67,10 +94,7 @@
           </el-col>
           <el-col :span="24">
             <el-form-item label="Reporting Center ID">
-              <el-input
-                v-model="form.decUserName"
-                label="Reporting Center ID"
-              />
+              <el-input v-model="form.decUserName" label="Reporting Center ID" />
             </el-form-item>
           </el-col>
         </el-row>
@@ -92,31 +116,6 @@
         />
       </ui-section-card>
 
-      <ui-section-card title="Network Information">
-        <el-row :gutter="24">
-          <el-col :xs="24" :sm="12">
-            <el-form-item label="Referrer ID" required>
-              <el-input
-                v-model="form.recUserName"
-                label="Referrer ID"
-                aria-required="true"
-                @change="handleChkRecUser"
-              />
-            </el-form-item>
-          </el-col>
-          <el-col :xs="24" :sm="12">
-            <el-form-item label="Actual Referrer ID" required>
-              <el-input
-                v-model="form.conUserName"
-                label="Actual Referrer ID"
-                aria-required="true"
-                @change="handleChkRecommenderActualName"
-              />
-            </el-form-item>
-          </el-col>
-        </el-row>
-      </ui-section-card>
-
       <ui-section-card title="Personal Information">
         <el-row :gutter="24">
           <el-col :xs="24" :sm="12">
@@ -167,7 +166,7 @@
         </el-row>
       </ui-section-card>
 
-      <ui-section-card title="Delivery Method">
+      <ui-section-card class="ng-signup-delivery" title="Delivery Method">
         <el-form-item label="Delivery Method">
           <el-radio-group v-model="form.way" @change="wayChange">
             <el-radio label="express" border>Door Delivery</el-radio>
@@ -185,12 +184,18 @@
                 :disabled="areaDisabled"
                 :options="regionData"
                 placeholder="Select region"
+                popper-class="ng-refactor-popper"
               />
             </el-form-item>
           </el-col>
           <el-col :xs="24" :sm="12">
             <el-form-item label="City">
-              <el-input v-model="form.cityName" label="City" :disabled="addressDisabled" placeholder="City" />
+              <el-input
+                v-model="form.cityName"
+                label="City"
+                :disabled="addressDisabled"
+                placeholder="City"
+              />
             </el-form-item>
           </el-col>
           <el-col :span="24">
@@ -226,6 +231,7 @@
                 id="ng-signup-SignupPagevue-3"
                 v-model="form.openBank"
                 placeholder="Select bank"
+                popper-class="ng-refactor-popper"
               >
                 <el-option
                   v-for="item in allOpenBank"
@@ -249,28 +255,14 @@
         </el-row>
       </ui-section-card>
 
-      <ui-section-card title="Payment Information">
-        <el-form-item label="Payment Method" required>
-          <el-radio-group v-model="form.payType">
-            <el-radio v-for="item in payList" :key="item.label" border :label="item.label">
-              {{ item.name }}
-              <span v-if="item.label === 'cash'">
-                ({{ currencyUnitCode }} {{ userBalance.cash | toThousandFilter }})
-              </span>
-            </el-radio>
-          </el-radio-group>
-        </el-form-item>
-        <el-alert
-          v-if="form.payType === 'pay_stack'"
-          title="Paystack"
-          type="info"
-          :closable="false"
-        >
-          <p>
-            Submit the form, then continue to Paystack from the payment dialog.
-          </p>
-        </el-alert>
-      </ui-section-card>
+      <payment-methods
+        v-model="form.payType"
+        :options="payList"
+        :currency="currencyUnitCode"
+        :balances="userBalance"
+        :summary="paymentSummary"
+        :disabled="loading || submitButtonStat || visible || paymentComplete"
+      />
 
       <div class="ng-signup-actions">
         <el-button
@@ -285,9 +277,7 @@
         </el-button>
         <p class="ng-signup-login">
           Already have an account?
-          <router-link class="link-type" :to="loginRoute">
-            Sign in
-          </router-link>
+          <router-link class="link-type" :to="loginRoute"> Sign in </router-link>
         </p>
       </div>
     </el-form>
@@ -308,13 +298,15 @@
 
 <script>
 import UiSectionCard from '@/components/ui/UiSectionCard.vue'
+import PaymentMethods from '@/ui-refactor/components/PaymentMethods.vue'
+import { getPaymentSummary } from '@/ui-refactor/utils/paymentSummary'
 import registration from './components/welcomePackRegistration'
 import SignupProducts from './components/SignupProducts.vue'
 import SignupDialogs from './components/SignupDialogs.vue'
 
 export default {
   name: 'SignupPage',
-  components: { UiSectionCard, SignupProducts, SignupDialogs },
+  components: { UiSectionCard, SignupProducts, SignupDialogs, PaymentMethods },
   // 页面只适配商品卡片事件,提交、计价与支付逻辑继续由业务模块维护。
   mixins: [registration],
   computed: {
@@ -322,13 +314,17 @@ export default {
       return { path: '/login', query: this.$route.query }
     },
     selectedIds() {
-      return this.multipleSelection.map((item) => item.ID)
+      return this.multipleSelection.map(item => item.ID)
+    },
+    paymentSummary() {
+      const balance = this.userBalance[this.form.payType]
+      return getPaymentSummary(this.sellPriceSum, balance, this.form.payType === 'cash')
     }
   },
   methods: {
     toggleProduct(row) {
       const selection = this.selectedIds.includes(row.ID)
-        ? this.multipleSelection.filter((item) => item.ID !== row.ID)
+        ? this.multipleSelection.filter(item => item.ID !== row.ID)
         : this.multipleSelection.concat(row)
       this.handleSelectionChange(selection)
     },
@@ -342,8 +338,6 @@ export default {
 </script>
 
 <style lang="scss" scoped>
-@import '~element-ui/packages/theme-chalk/src/mixins/button';
-
 .ng-public-layout .ng-public-signup ::v-deep .el-select,
 .ng-public-layout .ng-public-signup ::v-deep .el-cascader {
   width: 100%;
@@ -353,20 +347,20 @@ export default {
   padding-bottom: 8px;
   line-height: 1.5;
 }
-.ng-public-layout .ng-public-signup ::v-deep .el-radio-group {
+.ng-public-layout .ng-public-signup .ng-signup-delivery ::v-deep .el-radio-group {
   display: flex;
   flex-wrap: wrap;
   gap: 12px;
   max-width: 100%;
 }
-.ng-public-layout .ng-public-signup ::v-deep .el-radio {
+.ng-public-layout .ng-public-signup .ng-signup-delivery ::v-deep .el-radio {
   height: auto;
   min-height: 40px;
   margin: 0;
   white-space: normal;
   line-height: 1.5;
 }
-.ng-public-layout .ng-public-signup ::v-deep .el-radio__label {
+.ng-public-layout .ng-public-signup .ng-signup-delivery ::v-deep .el-radio__label {
   white-space: normal;
   overflow-wrap: anywhere;
 }
@@ -376,9 +370,6 @@ export default {
 .ng-public-layout .ng-signup-save {
   width: 100%;
 }
-.ng-public-layout .ng-signup-save.el-button--primary {
-  @include button-variant(#fff, #202259, #202259);
-}
 .ng-public-layout .ng-signup-login {
   margin: 12px 0 0;
   text-align: center;

+ 16 - 193
src/ui-refactor/views/signup/components/SignupDialogs.vue

@@ -1,30 +1,17 @@
 <template>
   <div class="ng-signup-dialogs">
-    <el-dialog
+    <paystack-payment-dialog
       :visible="paymentVisible"
-      title="Paystack Payment"
-      width="420px"
-      top="0"
-      class="ng-signup-payment-dialog"
-      :close-on-click-modal="false"
+      :email="paymentEmail"
+      :amount="paymentOrder.amount"
+      :currency="paymentOrder.currency"
+      readonly-email
       :before-close="cancelPayment"
+      @continue="$refs.paystack.payWithPaystack()"
     >
-      <p class="ng-signup-payment-note">
-        Confirm your email to continue to the Paystack secure checkout.
-      </p>
-      <el-form class="ng-signup-payment-form" label-position="top" @submit.native.prevent>
-        <el-form-item label="Email" for="ng-signup-payment-email">
-          <el-input id="ng-signup-payment-email" :value="paymentEmail" readonly />
-        </el-form-item>
-      </el-form>
-      <div class="ng-signup-payment-amount">
-        <span>Amount</span>
-        <strong>{{ paymentOrder.currency }} {{ paymentOrder.amount | toThousandFilter }}</strong>
-      </div>
       <guest-paystack
         v-if="paymentVisible"
         ref="paystack"
-        class="ng-signup-paystack-trigger"
         :access-code="paymentOrder.accessCode"
         :reference="paymentOrder.reference"
         :amount="Number(paymentOrder.amount)"
@@ -35,62 +22,24 @@
         :close="cancelPayment"
         @error="$emit('payment-error', $event)"
       />
-      <el-button
-        class="ng-signup-payment-continue"
-        type="primary"
-        native-type="button"
-        @click="$refs.paystack.payWithPaystack()"
-      >
-        Continue to Paystack
-      </el-button>
-    </el-dialog>
-    <el-dialog
+    </paystack-payment-dialog>
+    <product-details-dialog
       :visible="productVisible"
-      :title="product.GOODS_NO"
-      width="800px"
-      class="ng-signup-product-dialog"
-      @close="$emit('close-product')"
-    >
-      <el-row :gutter="24">
-        <el-col :xs="24" :sm="12">
-          <el-image
-            class="ng-signup-detail-image"
-            :src="tool.getArImage(product.COVER, '/files/')"
-            :alt="product.GOODS_NAME"
-            fit="contain"
-          />
-        </el-col>
-        <el-col :xs="24" :sm="12">
-          <h3 class="ng-signup-detail-name">{{ product.GOODS_NAME }}</h3>
-          <dl class="ng-signup-detail-values">
-            <dt>Product Code</dt>
-            <dd>{{ product.GOODS_NO }}</dd>
-            <dt>Sales Price</dt>
-            <dd>{{ currency }} {{ product.SELL_PRICE | toThousandFilter }}</dd>
-            <dt>Market Price</dt>
-            <dd>{{ currency }} {{ product.MARKET_PRICE | toThousandFilter }}</dd>
-            <dt>BV</dt>
-            <dd>{{ product.PRICE_PV | toThousandFilter }}</dd>
-            <dt>Tax Rate</dt>
-            <dd>{{ product.TAX_RATE / 100 }}</dd>
-            <dt>Tax</dt>
-            <dd>{{ currency }} {{ product.taxAmount | toThousandFilter }}</dd>
-            <dt>Inventory</dt>
-            <dd>{{ product.STORE_NUMS }}</dd>
-          </dl>
-        </el-col>
-      </el-row>
-    </el-dialog>
+      :product="product"
+      :currency="currency"
+      @update:visible="$emit('close-product')"
+    />
   </div>
 </template>
 
 <script>
-import tool from '@/utils/tool'
+import ProductDetailsDialog from '@/ui-refactor/components/ProductDetailsDialog.vue'
 import GuestPaystack from './GuestPaystack'
+import PaystackPaymentDialog from '@/ui-refactor/components/PaystackPaymentDialog.vue'
 
 export default {
   name: 'SignupDialogs',
-  components: { GuestPaystack },
+  components: { GuestPaystack, ProductDetailsDialog, PaystackPaymentDialog },
   props: {
     paymentVisible: { type: Boolean, default: false },
     paymentOrder: { type: Object, required: true },
@@ -100,7 +49,7 @@ export default {
     currency: { type: String, default: '' }
   },
   data() {
-    return { tool, paystackKey: process.env.VUE_APP_BASE_PAY_STACK_PUBLIC_KEY || '' }
+    return { paystackKey: process.env.VUE_APP_BASE_PAY_STACK_PUBLIC_KEY || '' }
   },
   methods: {
     cancelPayment() {
@@ -114,8 +63,6 @@ export default {
 </script>
 
 <style lang="scss" scoped>
-@import '~element-ui/packages/theme-chalk/src/mixins/button';
-
 .ng-public-layout .ng-signup-dialogs ::v-deep .el-dialog {
   max-width: calc(100% - 32px);
 }
@@ -125,133 +72,9 @@ export default {
 .ng-public-layout .ng-signup-dialogs ::v-deep .el-dialog__body {
   padding: 24px;
 }
-.ng-public-layout .ng-signup-payment-dialog {
-  display: flex;
-  padding: 16px;
-}
-.ng-public-layout .ng-signup-payment-dialog ::v-deep .el-dialog {
-  flex-shrink: 0;
-  max-width: 100%;
-  margin: auto !important;
-  border-radius: 14px;
-}
-.ng-public-layout .ng-signup-payment-dialog ::v-deep .el-dialog__header {
-  padding: 24px 40px 0 24px;
-}
-.ng-public-layout .ng-signup-payment-dialog ::v-deep .el-dialog__title {
-  color: #25292c;
-  font-size: 24px;
-  font-weight: 700;
-  line-height: 1.3;
-}
-.ng-public-layout .ng-signup-payment-dialog ::v-deep .el-dialog__headerbtn {
-  top: 16px;
-  right: 16px;
-}
-.ng-public-layout .ng-signup-payment-dialog ::v-deep .el-dialog__body {
-  padding: 10px 24px 24px;
-}
-.ng-public-layout .ng-signup-payment-note {
-  margin: 0 0 16px;
-  color: #6b737a;
-  font-size: 14px;
-  line-height: 1.5;
-  word-break: normal;
-  overflow-wrap: break-word;
-}
-.ng-public-layout .ng-signup-payment-form ::v-deep .el-form-item {
-  margin-bottom: 0;
-}
-.ng-public-layout .ng-signup-payment-form ::v-deep .el-form-item__label {
-  padding-bottom: 8px;
-  color: #25292c;
-  font-weight: 700;
-  line-height: 1.5;
-}
-.ng-public-layout .ng-signup-payment-form ::v-deep .el-input__inner {
-  height: 54px;
-  border-radius: 8px;
-  font-size: 16px;
-  line-height: 54px;
-}
-.ng-public-layout .ng-signup-payment-amount {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 8px 16px;
-  margin: 16px 0;
-  padding: 12px 16px;
-  border-radius: 8px;
-  background: #f8f9fa;
-  color: #6b737a;
-  font-size: 14px;
-  line-height: 1.5;
-}
-.ng-public-layout .ng-signup-payment-amount strong {
-  min-width: 0;
-  color: #1f6b3f;
-  font-size: 18px;
-  font-variant-numeric: tabular-nums;
-  text-align: right;
-  overflow-wrap: anywhere;
-}
-.ng-public-layout .ng-signup-payment-continue.el-button--primary {
-  @include button-variant(#fff, #202259, #202259);
-
-  width: 100%;
-  min-height: 54px;
-  padding: 14px 16px;
-  border-radius: 8px;
-  font-size: 18px;
-  font-weight: 500;
-  line-height: 1.4;
-  white-space: normal;
-}
-.ng-public-layout .ng-signup-detail-image {
-  width: 100%;
-  height: 280px;
-}
-.ng-public-layout .ng-signup-paystack-trigger {
-  display: none;
-}
-.ng-public-layout .ng-signup-detail-name {
-  margin: 0 0 20px;
-  overflow-wrap: anywhere;
-  line-height: 1.5;
-}
-.ng-public-layout .ng-signup-detail-values {
-  display: grid;
-  grid-template-columns: auto 1fr;
-  gap: 12px;
-  margin: 0;
-  line-height: 1.6;
-}
-.ng-public-layout .ng-signup-detail-values dd {
-  margin: 0;
-  overflow-wrap: anywhere;
-}
 @media (max-width: 767px) {
   .ng-public-layout .ng-signup-dialogs ::v-deep .el-dialog__body {
     padding: 16px;
   }
-  .ng-public-layout .ng-signup-detail-image {
-    height: 180px;
-    margin-bottom: 20px;
-  }
-  .ng-public-layout .ng-signup-detail-values {
-    grid-template-columns: 1fr;
-    gap: 6px;
-  }
-  .ng-public-layout .ng-signup-detail-values dd {
-    margin-bottom: 8px;
-  }
-  .ng-public-layout .ng-signup-payment-dialog ::v-deep .el-dialog__body {
-    padding: 10px 24px 24px;
-  }
-}
-@media (max-width: 480px) {
-  .ng-public-layout .ng-signup-payment-dialog ::v-deep .el-dialog__title {
-    font-size: 22px;
-  }
 }
 </style>

+ 47 - 21
src/ui-refactor/views/signup/components/SignupProducts.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="ng-signup-products">
     <p class="ng-signup-help">
-      Tap a product to add it to your starter order, then set the quantity you need.
+      {{ helpText }}
     </p>
     <el-empty
       v-if="!items.length"
@@ -47,10 +47,16 @@
             <p class="ng-signup-product-code">{{ item.CONTENT }}</p>
             <div class="ng-signup-product-price">
               <strong>{{ item.CODE }} {{ item.SELL_PRICE | toThousandFilter }}</strong>
-              <el-tag class="ng-signup-product-bv" size="mini" color="#E9F4F8">
+              <el-tag v-if="showBv" class="ng-signup-product-bv" size="mini" color="#E9F4F8">
                 {{ item.PRICE_PV | toThousandFilter }} BV
               </el-tag>
             </div>
+            <p
+              class="ng-signup-product-stock"
+              :class="{ 'is-out-of-stock': item.STORE_NUMS === 0 || item.STORE_NUMS === '0' }"
+            >
+              Stock: {{ item.STORE_NUMS == null ? '—' : item.STORE_NUMS }}
+            </p>
             <el-button
               class="ng-signup-product-view"
               type="text"
@@ -72,6 +78,8 @@
               :max="Number(item.STORE_NUMS)"
               size="small"
               :label="'Quantity for ' + item.GOODS_NAME"
+              :disabled="disabled"
+              :precision="quantityPrecision"
               @change="$emit('quantity', { value: $event, row: item, index })"
             />
             <el-button
@@ -81,28 +89,31 @@
               :type="selectedIds.includes(item.ID) ? 'text' : 'primary'"
               :aria-pressed="selectedIds.includes(item.ID) ? 'true' : 'false'"
               native-type="button"
+              :disabled="disabled"
               @click="$emit('toggle', item)"
             >
-              {{ selectedIds.includes(item.ID) ? 'Remove' : '+ Add to registration' }}
+              {{ selectedIds.includes(item.ID) ? 'Remove' : addLabel }}
             </el-button>
           </div>
         </el-card>
       </el-col>
     </el-row>
-    <div class="ng-signup-totals" role="status" aria-live="polite">
-      <span>
-        Subtotal:
-        <strong>{{ currency }} {{ subtotal | toThousandFilter }}</strong>
-      </span>
-      <span>
-        BV:
-        <strong>{{ bv | toThousandFilter }}</strong>
-      </span>
-      <span>
-        Tax:
-        <strong>{{ currency }} {{ tax | toThousandFilter }}</strong>
-      </span>
-    </div>
+    <slot name="footer">
+      <div class="ng-signup-totals" role="status" aria-live="polite">
+        <span>
+          Subtotal:
+          <strong>{{ currency }} {{ subtotal | toThousandFilter }}</strong>
+        </span>
+        <span v-if="showBv">
+          BV:
+          <strong>{{ bv | toThousandFilter }}</strong>
+        </span>
+        <span>
+          Tax:
+          <strong>{{ currency }} {{ tax | toThousandFilter }}</strong>
+        </span>
+      </div>
+    </slot>
   </div>
 </template>
 
@@ -119,7 +130,15 @@ export default {
     subtotal: { type: [Number, String], required: true },
     bv: { type: [Number, String], required: true },
     tax: { type: [Number, String], required: true },
-    currency: { type: String, default: '' }
+    currency: { type: String, default: '' },
+    showBv: { type: Boolean, default: true },
+    helpText: {
+      type: String,
+      default: 'Tap a product to add it to your starter order, then set the quantity you need.'
+    },
+    addLabel: { type: String, default: '+ Add to registration' },
+    disabled: { type: Boolean, default: false },
+    quantityPrecision: { type: Number, default: undefined }
   },
   data() {
     return { tool }
@@ -245,6 +264,16 @@ export default {
   text-align: left;
   white-space: normal;
 }
+.ng-public-layout .ng-signup-product-stock {
+  margin: 0 0 8px;
+  color: #1f6b3f;
+  font-size: 12px;
+  font-weight: 700;
+  line-height: 1.6;
+}
+.ng-public-layout .ng-signup-product-stock.is-out-of-stock {
+  color: #f56c6c;
+}
 .ng-public-layout .ng-signup-product-footer {
   margin-top: auto;
 }
@@ -277,9 +306,6 @@ export default {
   white-space: normal;
   line-height: 1.5;
 }
-.ng-public-layout .ng-signup-product-add.el-button--primary {
-  @include button-variant(#fff, #202259, #202259);
-}
 .ng-public-layout .ng-signup-product ::v-deep .el-button:focus-visible {
   outline: 2px solid currentColor;
   outline-offset: -4px;

+ 11 - 0
src/ui-refactor/views/team-inquiry/README.md

@@ -0,0 +1,11 @@
+# 团队查询 UI
+
+入口是账户首页的 Team Inquiry 卡片,新路由为 `/dashboard/team-inquiry`,复用已有 `src/ui-refactor/index.vue` 布局与 PageHero。面包屑返回 `/dashboard/index`,往返保留查询参数;新路由不加入匿名白名单。旧 `/bonus/teams` 路由和旧页面保持原样。
+
+`TeamInquiryPage.vue` 通过 Vue Loader 的脚本查询继承旧 `src/views/bonus/team-inquiry.vue`,只替换模板和局部样式,不继承旧模板或样式。旧 data、created、请求、响应字段、编号与 WAT 时间方法均不覆盖;仍调用 `fetchTeamInquiry()`,展示服务端的 user / team / calcAt。BV 只使用已有 `toThousandFilter` 格式化,不重新计算。
+
+两个表格复用同一模板,保留原字段及状态名称;状态颜色改为局部标签展示。窄屏使用 Element UI 表格自身横向滚动。没有增加搜索、分页、成员详情或状态枚举,旧请求失败行为也保持不变。
+
+验证(2026-10-14):4 项定向 Jest 测试、定向 ESLint、实际路由解析和完整应用临时目录生产构建通过。浏览器隔离样例验证了卡片点击、Enter、面包屑返回、320 / 375 / 768 / 1024 / 1440px 以及 375×480 短视口;完整应用验证未登录访问新路由返回原登录页。旧团队页、布局、PageHero、API、权限守卫及环境/lockfile 无本轮修改。
+
+账户目录的既有余额测试仍用 `.ng-account-history-actions` 定位 Reset,但当前余额组件已没有该类,因此该项失败;本轮不修改该组件或其测试。真实登录后的团队接口联调未执行,浏览器截图使用临时 API 样例,不写入认证缓存。

+ 164 - 0
src/ui-refactor/views/team-inquiry/TeamInquiryPage.vue

@@ -0,0 +1,164 @@
+<template>
+  <section
+    v-loading="loading"
+    class="ng-team-inquiry"
+    aria-labelledby="ng-team-title"
+    :aria-busy="loading ? 'true' : 'false'"
+  >
+    <header class="ng-team-intro">
+      <div>
+        <p class="ng-team-eyebrow">Team Inquiry</p>
+        <h2 id="ng-team-title">Personal &amp; team details</h2>
+      </div>
+      <p class="ng-team-description">
+        A clear view of your own standing and the members in your direct team. This is the same data
+        your distributor sees when they register you.
+      </p>
+    </header>
+
+    <section
+      v-for="(rows, index) in [userData, tableData]"
+      :key="index"
+      class="ng-team-section"
+      :aria-labelledby="'ng-team-heading-' + index"
+    >
+      <h3 :id="'ng-team-heading-' + index">
+        {{ $t(index === 0 ? 'bonus.personalDetails' : 'bonus.teamDetails') }}
+      </h3>
+      <div
+        class="ng-team-table"
+        tabindex="0"
+        :aria-label="$t(index === 0 ? 'bonus.personalDetails' : 'bonus.teamDetails')"
+      >
+        <el-table :data="rows" style="width: 100%">
+          <el-table-column type="index" :index="indexMethod" width="60" label="#" />
+          <el-table-column min-width="160" prop="number" :label="$t('shop.memberCode')" />
+          <el-table-column min-width="210" prop="name" :label="$t('shop.memberName')" />
+          <el-table-column min-width="120" prop="perf_status_name" :label="$t('bonus.status')">
+            <template slot-scope="{ row }">
+              <el-tag size="mini" :type="row.perf_status === '1' ? 'success' : 'info'">{{
+                row.perf_status_name
+              }}</el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column min-width="150" prop="user_perf" :label="$t('bonus.personalBV')">
+            <template slot-scope="{ row }">{{ row.user_perf | toThousandFilter }}</template>
+          </el-table-column>
+          <el-table-column min-width="130" prop="team_perf" :label="$t('bonus.PGS')">
+            <template slot-scope="{ row }">{{ row.team_perf | toThousandFilter }}</template>
+          </el-table-column>
+          <el-table-column min-width="140" prop="total_perf" :label="$t('bonus.totalBV')">
+            <template slot-scope="{ row }">{{ row.total_perf | toThousandFilter }}</template>
+          </el-table-column>
+        </el-table>
+      </div>
+    </section>
+
+    <p class="ng-team-calculated">
+      {{ $t('bonus.latestCalculationTime') }}: {{ getWatTime(calcTime) }}
+    </p>
+  </section>
+</template>
+
+<script>
+import TeamInquiry from '@/views/bonus/team-inquiry.vue'
+import { toThousandFilter } from '@/filters'
+
+export default {
+  name: 'MemberTeamInquiry',
+  filters: { toThousandFilter },
+  extends: TeamInquiry
+}
+</script>
+
+<style lang="scss" scoped>
+.ng-team-inquiry {
+  min-width: 0;
+  padding: 32px 0;
+}
+
+.ng-team-intro {
+  display: grid;
+  grid-template-columns: minmax(240px, 1fr) minmax(0, 1.5fr);
+  gap: 48px;
+  align-items: center;
+  margin-bottom: 28px;
+}
+
+.ng-team-eyebrow {
+  margin: 0 0 14px;
+  color: #007fa3;
+  font-size: 12px;
+  font-weight: 700;
+  letter-spacing: 1.2px;
+  text-transform: uppercase;
+}
+
+.ng-team-intro h2 {
+  max-width: 340px;
+  margin: 0;
+  color: #202259;
+  font-size: 32px;
+  line-height: 1.25;
+}
+
+.ng-team-description {
+  max-width: 680px;
+  margin: 0;
+  color: #606266;
+  font-size: 14px;
+  line-height: 1.7;
+}
+
+.ng-team-section {
+  margin-bottom: 24px;
+}
+
+.ng-team-section h3 {
+  margin: 0 0 10px;
+  color: #202259;
+  font-size: 20px;
+  line-height: 1.4;
+}
+
+.ng-team-table {
+  overflow-x: auto;
+  border: 1px solid #e6ebf5;
+  border-radius: 10px;
+  background: #fff;
+}
+
+.ng-team-table:focus-visible {
+  outline: 2px solid #1890ff;
+  outline-offset: 3px;
+}
+
+.ng-team-table ::v-deep .el-tag {
+  max-width: 100%;
+  height: auto;
+  border-radius: 12px;
+  white-space: normal;
+}
+
+.ng-team-calculated {
+  margin: 0;
+  color: #909399;
+  font-size: 12px;
+  line-height: 1.6;
+}
+
+@media (max-width: 767px) {
+  .ng-team-inquiry {
+    padding: 8px 0;
+  }
+
+  .ng-team-intro {
+    grid-template-columns: minmax(0, 1fr);
+    gap: 16px;
+  }
+
+  .ng-team-intro h2 {
+    font-size: 26px;
+  }
+}
+</style>

+ 154 - 0
src/ui-refactor/views/team-inquiry/__tests__/TeamInquiryPage.spec.js

@@ -0,0 +1,154 @@
+/* eslint-env jest */
+import Vue from 'vue'
+import ElementUI from 'element-ui'
+import moment from 'moment'
+import fs from 'fs'
+import path from 'path'
+import { transformSync } from '@babel/core'
+import { parseComponent, compileTemplate } from 'vue/compiler-sfc'
+import { fetchTeamInquiry } from '@/api/bonus'
+
+jest.mock('@/api/bonus', () => ({ fetchTeamInquiry: jest.fn() }))
+jest.mock('@/utils/usersInfo', () => ({}))
+Vue.use(ElementUI)
+
+// 使用当前 Vue 2.7 编译真实新旧组件,避开 vue-jest 的旧编译器版本冲突。
+const components = new Map()
+function loadComponent(filename) {
+  if (components.has(filename)) return components.get(filename)
+  const { script, template } = parseComponent(fs.readFileSync(filename, 'utf8'))
+  const code = transformSync(script.content, {
+    babelrc: false, configFile: false,
+    plugins: [require.resolve('@babel/plugin-transform-modules-commonjs')]
+  }).code
+  const module = { exports: {}}
+  const localRequire = id => {
+    if (id === '@/components/Pagination') return loadComponent(path.resolve(process.cwd(), 'src/components/Pagination/index.vue'))
+    if (id.endsWith('.vue')) return loadComponent(id.startsWith('.') ? path.resolve(path.dirname(filename), id) : path.resolve(process.cwd(), 'src', id.slice(2)))
+    return require(id)
+  }
+  new Function('require', 'module', 'exports', code)(localRequire, module, module.exports)
+  const result = module.exports.default
+  const compiled = compileTemplate({ source: template.content, filename })
+  if (compiled.errors.length) throw new Error(compiled.errors.join('\n'))
+  Object.assign(result, new Function(compiled.code + '; return { render, staticRenderFns }')())
+  components.set(filename, result)
+  return result
+}
+
+const LegacyTeam = loadComponent(path.resolve(process.cwd(), 'src/views/bonus/team-inquiry.vue'))
+const TeamPage = loadComponent(path.resolve(__dirname, '../TeamInquiryPage.vue'))
+const Shortcuts = loadComponent(path.resolve(__dirname, '../../account/components/AccountShortcuts.vue'))
+const Activity = loadComponent(path.resolve(__dirname, '../../account/components/AccountActivity.vue'))
+const instances = []
+const user = [{ number: 'NG00001', name: 'Preview Member', perf_status: '1', perf_status_name: 'Active', user_perf: 0, team_perf: 1234, total_perf: 9999 }]
+const team = [{ number: 'NG00002', name: 'Preview Teammate', perf_status: '0', perf_status_name: 'Inactive', user_perf: 15, team_perf: 0, total_perf: 15 }]
+
+function instance(component = TeamPage, router = {}) {
+  const vm = new Vue({
+    ...component,
+    beforeCreate() {
+      this.$t = key => key
+      this.$moment = moment
+      this.$router = router
+      this.$route = { query: { ref: 'preview' }}
+    }
+  }).$mount()
+  instances.push(vm)
+  return vm
+}
+async function settle() {
+  await Promise.resolve()
+  await Vue.nextTick()
+  await Vue.nextTick()
+}
+
+describe('Team inquiry UI and its account shortcut', () => {
+  beforeEach(() => {
+    jest.clearAllMocks()
+    fetchTeamInquiry.mockResolvedValue({ data: { calcAt: '1791972000', user, team }})
+  })
+  afterEach(() => instances.splice(0).forEach(vm => vm.$destroy()))
+
+  it('inherits the original data, lifecycle and all methods without overrides', () => {
+    expect(TeamPage.extends).toBe(LegacyTeam)
+    expect(TeamPage.data).toBeUndefined()
+    expect(TeamPage.created).toBeUndefined()
+    expect(TeamPage.methods).toBeUndefined()
+    const vm = instance()
+    Object.keys(LegacyTeam.methods).forEach(name => {
+      expect(vm.$options.methods[name]).toBe(LegacyTeam.methods[name])
+    })
+  })
+
+  it('fetches once and displays the original response fields without recalculating BV', async() => {
+    const vm = instance()
+    await settle()
+    expect(fetchTeamInquiry).toHaveBeenCalledTimes(1)
+    expect(fetchTeamInquiry).toHaveBeenCalledWith()
+    expect(vm.userData).toBe(user)
+    expect(vm.tableData).toBe(team)
+    const rows = Array.from(vm.$el.querySelectorAll('.el-table__body tr')).map(row => Array.from(row.querySelectorAll('td')).map(cell => cell.textContent.trim()))
+    expect(rows).toEqual([
+      ['1', 'NG00001', 'Preview Member', 'Active', '0', '1,234', '9,999'],
+      ['1', 'NG00002', 'Preview Teammate', 'Inactive', '15', '0', '15']
+    ])
+    expect(vm.$el.querySelector('.ng-team-calculated').textContent).toContain(moment(1791972000000).utcOffset('+0100').format('YYYY-MM-DD HH:mm:ss'))
+    expect(vm.loading).toBe(false)
+    expect(vm.getWatTime('')).toBe('')
+  })
+
+  it('keeps loading until the original request resolves and renders both empty tables', async() => {
+    let resolveRequest
+    fetchTeamInquiry.mockReturnValue(new Promise(resolve => { resolveRequest = resolve }))
+    const vm = instance()
+    expect(vm.loading).toBe(true)
+    expect(vm.$el.getAttribute('aria-busy')).toBe('true')
+    resolveRequest({ data: { calcAt: '', user: [], team: [] }})
+    await settle()
+    expect(vm.loading).toBe(false)
+    expect(vm.$el.querySelectorAll('.el-table').length).toBe(2)
+    expect(vm.$el.querySelectorAll('.el-table__body tr').length).toBe(0)
+    expect(vm.$el.getAttribute('aria-busy')).toBe('false')
+  })
+
+  it('keeps Team Inquiry navigation separate from the Previous Bonus dialog', () => {
+    const push = jest.fn()
+    const vm = instance(Shortcuts, { push })
+    const buttons = Array.from(vm.$el.querySelectorAll('button'))
+    const teamButton = buttons.find(button => button.textContent.includes('Team Inquiry'))
+    expect(teamButton.disabled).toBe(false)
+    expect(buttons.find(button => button.textContent.includes('Historical Cumulative Bonus')).disabled).toBe(false)
+    expect(buttons.filter(button => button.disabled).length).toBe(0)
+    const purchases = {
+      'Repeat Purchase': 'repeat-purchase',
+      'Member Welcome Pack': 'member-welcome-pack',
+      'Member Repeat Purchase': 'member-repurchase',
+      'Member Auto-Maintenance': 'member-auto-maintenance',
+      'Member Upgrade': 'member-upgrade'
+    }
+    Object.entries(purchases).forEach(([label, source]) => {
+      const button = buttons.find(button => button.querySelector('.ng-account-shortcut-label').textContent === label)
+      expect(button.disabled).toBe(false)
+      button.click()
+      expect(push).toHaveBeenLastCalledWith({ path: '/dashboard/' + source, query: { ref: 'preview' }})
+    })
+    teamButton.click()
+    expect(push).toHaveBeenCalledWith({ path: '/dashboard/team-inquiry', query: { ref: 'preview' }})
+    expect(fetchTeamInquiry).not.toHaveBeenCalled()
+  })
+
+  it('opens Repeat Purchase from Browse products while preserving the current query', () => {
+    const push = jest.fn()
+    const vm = instance(Activity, { push })
+    const buttons = Array.from(vm.$el.querySelectorAll('button'))
+    const browse = buttons.find(button => button.textContent.includes('Browse products'))
+    const support = buttons.find(button => button.textContent.includes('Talk to support'))
+    expect(browse.disabled).toBe(false)
+    browse.click()
+    expect(push).toHaveBeenCalledWith({ path: '/dashboard/repeat-purchase', query: { ref: 'preview' }})
+    expect(support.disabled).toBe(true)
+    support.click()
+    expect(push).toHaveBeenCalledTimes(1)
+  })
+})

+ 292 - 0
src/ui-refactor/views/welcome-pack/MemberWelcomePack.vue

@@ -0,0 +1,292 @@
+<template>
+  <section v-loading="loading" class="ng-member-welcome" aria-label="Member Welcome Pack" lang="en">
+    <el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon />
+    <el-button
+      v-if="loadError"
+      class="ng-welcome-retry"
+      :disabled="busy"
+      @click="initialize"
+    >Retry</el-button>
+    <el-form
+      :model="form"
+      label-position="top"
+      :disabled="busy || !initialized"
+      @submit.native.prevent
+    >
+      <welcome-pack-fields
+        :form="form"
+        :country-name="countryName"
+        :languages="memberLanguages"
+        :levels="allDecLevel"
+        :banks="allOpenBank"
+        :regions="regionData"
+        :stockist="Number(isDec) === 1"
+        @change="updateForm"
+      >
+        <ui-section-card title="Products">
+          <signup-products
+            :items="tableData || []"
+            :quantities="storeNums"
+            :selected-ids="selectedIds"
+            :country-selected="!!form.countryId"
+            :subtotal="sellPriceSum"
+            :bv="pricePvSum"
+            :tax="taxSum"
+            :currency="currencyUnitCode"
+            :disabled="busy || !initialized"
+            @toggle="toggleProduct"
+            @quantity="changeProductQuantity"
+            @details="handleProduct"
+          />
+        </ui-section-card>
+      </welcome-pack-fields>
+      <welcome-pack-payment
+        :options="payList"
+        :value="form.payType"
+        :currency="currencyUnitCode"
+        :amount="sellPriceSum"
+        :balance="userBalance.cash"
+        @input="form.payType = $event"
+      />
+      <el-button
+        class="ng-welcome-save"
+        type="primary"
+        :loading="submitButtonStat"
+        :disabled="busy || !initialized"
+        @click="onSubmit"
+      >
+        Save
+      </el-button>
+    </el-form>
+    <welcome-pack-dialogs
+      :payment-visible="visible"
+      :payment="payForm"
+      :reference="reference"
+      :channels="channels"
+      :cancel-loading="cancelLoading"
+      :success-visible="payDialog"
+      :countdown="countdown"
+      :product-visible="visibleProduct"
+      :product="product"
+      :currency="currencyUnitCode"
+      @cancel="handleClose"
+      @paid="processPayment"
+      @email="payForm.email = $event"
+      @orders="handleOrderList"
+      @close-product="visibleProduct = false"
+    />
+  </section>
+</template>
+
+<script>
+import Legacy from '@/views/user/welcome-pack.vue?vue&type=script&lang=js'
+import { fetchWelcomePack } from '@/api/user'
+import { deleteApproachOrder } from '@/api/shop'
+import { getLanguages } from '@/api/setting'
+import usersInfo from '@/utils/usersInfo'
+import UiSectionCard from '@/components/ui/UiSectionCard.vue'
+import SignupProducts from '../signup/components/SignupProducts.vue'
+import WelcomePackFields from './WelcomePackFields.vue'
+import WelcomePackPayment from './WelcomePackPayment.vue'
+import WelcomePackDialogs from './WelcomePackDialogs.vue'
+
+export default {
+  name: 'MemberWelcomePack',
+  components: {
+    UiSectionCard,
+    SignupProducts,
+    WelcomePackFields,
+    WelcomePackPayment,
+    WelcomePackDialogs
+  },
+  // 只继承旧脚本,初始化由新页面管理,不加载旧模板和全局样式。
+  extends: { ...Legacy, created: undefined },
+  data() {
+    return {
+      form: { countryId: '', languageId: '' },
+      initialized: false,
+      loadError: '',
+      operatorId: '',
+      sessionId: '',
+      memberLanguages: [],
+      cancelLoading: false,
+      countdownTimer: null,
+      orderListOpened: false
+    }
+  },
+  computed: {
+    busy() {
+      return (
+        this.loading ||
+        this.submitButtonStat ||
+        this.visible ||
+        this.payDialog ||
+        this.cancelLoading
+      )
+    },
+    selectedIds() {
+      return this.multipleSelection.map(item => item.ID)
+    },
+    countryName() {
+      const country = this.countriesList.find(item => item.ID === this.form.countryId)
+      return country ? country.NAME : ''
+    }
+  },
+  created() {
+    this.initialize()
+  },
+  beforeDestroy() {
+    clearInterval(this.countdownTimer)
+  },
+  beforeRouteLeave(to, from, next) {
+    if (this.isCurrent() && (this.submitButtonStat || this.visible || this.cancelLoading)) {
+      this.$message.info('Complete or cancel the current payment before leaving.')
+      next(false)
+    } else if (to.path === '/shop/member-order') {
+      // 旧提交方法直接跳订单页,在新页面内统一转到首页 Orders 页签。
+      next({ path: '/dashboard/index', query: { tab: 'orders', orderCategory: 'member' }})
+    } else next()
+  },
+  methods: {
+    async initialize() {
+      this.loading = true
+      this.initialized = false
+      this.loadError = ''
+      try {
+        this.operatorId = usersInfo.userName()
+        this.sessionId = usersInfo.loginTimestamp()
+        const account = usersInfo.baseData()
+        if (!this.operatorId || !account || !account.COUNTRY_ID) {
+          throw new Error('Your account country or member ID is unavailable. Please sign in again.')
+        }
+        this.form.countryId = account.COUNTRY_ID
+        this.form.recUserName = this.form.conUserName = this.operatorId
+        const [countries, languages] = await Promise.all([
+          this.$store.dispatch('settings/getCountries'),
+          getLanguages()
+        ])
+        if (this._isDestroyed || !this.isCurrent()) return
+        this.memberLanguages = Object.values(languages)
+        const country = countries.find(item => item.ID === this.form.countryId)
+        if (!country) throw new Error('Your account country is unavailable in the country list.')
+        this.form.languageId = country.DEFAULT_LANGUAGE_ID || ''
+        await this.fetchWelcomePack()
+      } catch (error) {
+        this.loadError = String(error.message || error)
+      } finally {
+        this.loading = false
+      }
+    },
+    async fetchWelcomePack() {
+      const { data } = await fetchWelcomePack({ countryId: this.form.countryId })
+      if (this._isDestroyed || !this.isCurrent()) return
+      if (!data.userName) throw new Error('The new member ID is unavailable. Please retry.')
+      if (!data.userBalance || !data.sellType || !data.sellType[0]) {
+        throw new Error('Welcome Pack payment settings are unavailable. Please retry.')
+      }
+      this.form.insertUserName = data.userName
+      this.allOpenBank = data.allOpenBank || []
+      this.allGoods = data.allGoods || []
+      this.tableData = Object.values(this.allGoods)
+      this.userBalance = data.userBalance
+      this.regionData = data.region || []
+      this.allDecLevel = data.decLevel || []
+      this.isDec = data.isDec
+      this.categoryType = 1
+      this.currencyUnitCode = (this.tableData[0] || {}).CODE || ''
+      this.storeNums = this.tableData.map(() => 1)
+      this.tableData.forEach(item => {
+        this.$set(item, 'goodsNum', 0)
+      })
+      this.multipleSelection = []
+      this.handleSureChange()
+      this.payList = Object.values((data.sellType[0] || {}).sell_type || {})
+      this.form.payType = (this.payList[1] || this.payList[0] || {}).label || ''
+      this.initialized = true
+    },
+    isCurrent() {
+      return (
+        usersInfo.userName() === this.operatorId && usersInfo.loginTimestamp() === this.sessionId
+      )
+    },
+    updateForm(patch) {
+      if (this.busy) return
+      Object.assign(this.form, patch)
+      if ('way' in patch) this.wayChange()
+    },
+    toggleProduct(row) {
+      if (this.busy || !this.initialized) return
+      const selection = this.selectedIds.includes(row.ID)
+        ? this.multipleSelection.filter(item => item.ID !== row.ID)
+        : this.multipleSelection.concat(row)
+      this.handleSelectionChange(selection)
+    },
+    changeProductQuantity({ row, index, value }) {
+      if (this.busy) return
+      this.$set(this.storeNums, index, value)
+      this.handleInputNumber(value, row)
+    },
+    onSubmit() {
+      if (this.busy || !this.initialized) return
+      if (!this.isCurrent()) {
+        this.$message.error('Your account has changed. Please reload this page.')
+        return
+      }
+      this.form.recUserName = this.form.conUserName = this.operatorId
+      Legacy.methods.onSubmit.call(this)
+    },
+    async handleClose() {
+      if (this.cancelLoading || !this.visible || !this.isCurrent()) return
+      this.cancelLoading = true
+      try {
+        try {
+          await this.$confirm(this.$t('shop.confirmClose'))
+        } catch (_) {
+          return
+        }
+        if (!this.isCurrent()) return
+        // 旧响应将订单号写在 payForm,取消必须针对本次实际订单。
+        await deleteApproachOrder({ orderSn: this.payForm.orderSn })
+        this.visible = false
+        this.submitButtonStat = false
+      } catch (error) {
+        this.$message.error(String(error.message || error))
+      } finally {
+        this.cancelLoading = false
+      }
+    },
+    processPayment(event) {
+      if (!this.visible || this.cancelLoading || !this.isCurrent()) return
+      if (event && event.orderSn !== this.payForm.orderSn) return
+      Legacy.methods.processPayment.call(this)
+    },
+    handleCountdown() {
+      clearInterval(this.countdownTimer)
+      this.countdown = 5
+      this.countdownTimer = setInterval(() => {
+        if (--this.countdown === 0) this.handleOrderList()
+      }, 1000)
+    },
+    handleOrderList() {
+      clearInterval(this.countdownTimer)
+      if (this.orderListOpened) return
+      this.orderListOpened = true
+      Legacy.methods.handleOrderList.call(this)
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.ng-member-welcome {
+  min-width: 0;
+}
+
+.ng-welcome-retry {
+  margin: 16px 0 24px;
+}
+
+.ng-welcome-save {
+  width: 100%;
+}
+</style>

+ 70 - 0
src/ui-refactor/views/welcome-pack/README.md

@@ -0,0 +1,70 @@
+# Member Welcome Pack
+
+## 本轮范围与验收
+
+- 新页面沿用 SignupPage 的分组表单、SignupProducts 和现有公开布局;旧 Welcome Pack、游客注册和认证逻辑保留。
+- 首页 Member Welcome Pack 进入 `/dashboard/member-welcome-pack`,受现有登录守卫保护。
+- Network Information 两个 ID 固定为 `usersInfo.userName()`(会员编号,不是内部 userId),不读取邀请 query。
+- Country 使用登录缓存 `baseData.COUNTRY_ID`,只读;新会员编号继续取会员 `GET /v1/user/dec` 的 `userName`。
+- Reporting Center ID 可编辑;stockist 初始化时仍默认当前会员编号,保存提交用户修改后的值。加载或提交期间沿用外层表单的禁用状态,原必填校验保留。
+- 国家/语言列表使用现有接口;产品、等级、地区、银行、余额及支付方式取登录后的 Welcome Pack 接口。
+- 提交保持旧 `POST /v1/user/dec` 字段、MARKET_PRICE 计价、BV/含税算法与自动安置;成功后跳转首页 Orders 下的 Member orders(`/dashboard/index?tab=orders&orderCategory=member`)。保留银行信息,配送方式不额外加入旧请求体。
+- 支付展示按截图区分 Paystack 和 Account Balance,金额及余额来自实际状态,不移植演示数值或游客 accessCode 协议。
+- 请求中阻止重复提交和修改表单;失败恢复可操作。取消 Paystack 仍先确认并调用旧取消接口,使用本次响应中的 `payForm.orderSn`;取消失败保留支付窗口。
+- 测试覆盖身份/国家初始化、旧请求体等价、数量/金额、余额不足、缺失商品/等级/报单中心、两种支付成功/失败/取消以及入口。
+
+## 风险与验证计划
+
+本轮按 Old Coder Tier 3 验证支付边界。spec approval: not obtained (autonomous run),国家选择已单独经用户确认。
+
+主要失败模式:误用游客接口、混淆新老会员编号、金额/余额不一致、重复报单、取消错误订单、迟到响应和计时器泄漏。以新旧参数对比、失败/重复点击测试、隔离页面及故障注入验证。只使用现有工具,不新增依赖、根配置或环境文件,不进行真实报单和扣款。
+
+## 验证结果(2026-10-19)
+
+| 检查 | 实际结果 |
+| --- | --- |
+| 新模块测试 | 23 项通过,真实编译四个新 SFC 和继承的旧脚本;最初 16 项在未实现时均失败 |
+| 新旧报单请求 | cash / pay_stack 两类参数逐字段一致;响应的 SN、ORDER_AMOUNT、EMAIL、metadata 和 SDK 金额 ×100 保留 |
+| 金额不变量 | 库存内 1–10 件,金额为 MARKET_PRICE × 数量,BV 和原含税算法一致 |
+| 失败与交互 | 等级/报单中心/商品缺失、余额不足、初始化缺失、单项支付方式、stockist 默认中心、重复提交、失败恢复、取消失败/放弃、旧订单迟到回调、会话变化、倒计时清理均覆盖 |
+| 会员端回归 | 194 项通过,原有 7 项失败保持不变;任务前为 171 通过、同样 7 项失败 |
+| 故障注入 | 5/5 捕获:错误推荐人、取消重复提交保护、错误取消订单号、反转余额判断、取消迟到回调检查;逐次恢复原文件并核对内容 |
+| 校验器负对照 | 接受意外测试失败、把通过或 runner 崩溃当成 mutant 被捕获时,校验器均拒绝 |
+| 定向 lint | 0 错误、0 警告 |
+| 完整应用构建 | 退出 0,输出临时目录;保留既有体积和浏览器数据过期提示 |
+| 页面实跑 | 隔离样例完成 320/375/768/1024/1440px、375×480 检查,无横向溢出;检查数量键盘递增、两种支付显示、自提隐藏地址、语言选项、商品详情和 Escape 关闭。375×480 弹窗左右边界为 16/359px,底部按钮可达 |
+| 文件边界 | 任务开始的 449 个文件(含未跟踪文件和根目录忽略的环境/lockfile)核对,仅路由、首页入口及其相关入口测试有本轮变更;旧 Welcome Pack、SignupPage、API、Store、认证、依赖和环境未变 |
+
+已有 7 项失败分别位于主题几何约束、Profile 地址开关、余额 Reset、订单字段/分类分页/空状态(3 项)、网络深度。`verify.cjs` 保存完整测试名并严格比较,不能把新增失败当作基线。相关首页入口测试从“Welcome Pack 禁用”改为验证其已启用及准确目标路径,其他失败测试未修改。
+
+首次交付页面源码指纹(四个 SFC,SHA-256):`2ab517a03e30d8c573b37e0919e3f68c9443b70ceb191829f300deead0d6d1f7`。
+
+### 2026-10-19 成功后打开首页 Orders
+
+- 余额 Save 成功、Paystack 成功倒计时结束或点击返回按钮,均进入 `/dashboard/index?tab=orders`。
+- 旧提交方法有直接跳转,保留其接口及成功处理,在新页面的 `beforeRouteLeave` 内把旧订单目标统一转到首页。支付未结束时的离开保护继续生效。
+- 首页监听 `query.tab`,支持已有 overview/orders/profile 页签,非法或缺失值回到 Overview。手工点击页签保持正常,不额外写入浏览器历史。
+- 新增 10 项使用真实 Vue Router、真实 Welcome Pack 和首页页签模板的测试。余额、Paystack 自动/手动返回、失败不跳转、初次进入及同页 query 更新均覆盖。改动前 6 项失败、4 项既有默认/失败场景通过;修复后包含注册、库存及购买的相关 139 项全部通过,定向 lint 无错误/警告。
+- 浏览器直接访问该 URL,实际首页已选中 Orders,内层沿用 My orders 默认分类。未执行真实下单或扣款;本轮未重新运行完整应用构建。
+
+## 复现
+
+项目根目录运行:
+
+```powershell
+node src/ui-refactor/views/welcome-pack/__tests__/verify.cjs
+$env:NODE_OPTIONS='--openssl-legacy-provider'
+node src/ui-refactor/views/welcome-pack/__tests__/preview.cjs
+```
+
+第一条依次执行故障注入、会员端回归、lint 和完整构建,失败立即停止。每轮新建临时证据目录,打印日志及结果路径,不读取旧报告。第二条打印隔离预览 URL;真实 SFC、布局、主题和字体照常编译,接口及 SDK 替换为明确样例,不连接真实账号、报单或支付。预览不要作为生产入口。
+
+工具沿用现有 Node 24.14.0、Vue 2.7.16、Vue CLI 4.4.4、Jest 和 ESLint,不安装依赖或修改配置。测试通过项目已有的 `purchase/__tests__/loadComponent.js` 编译 Vue 2.7 SFC,避开旧 vue-jest 的编译器版本问题。
+
+## 未验证与限制
+
+- 未用真实账号联调初始化、报单、扣款、取消订单和 Paystack;不能以隔离样例证明真实认证或支付成功。页面通过现有 API 封装发请求,没有游客 accessCode/draft 协议。
+- 保留旧提交校验和后端业务规则,不新增身份证/手机号/密码等规则,也不重写旧金额算法。SDK 支付成功回调与订单最终状态仍依赖旧服务端流程。
+- 项目为 JavaScript,没有新增 TypeScript 检查。动态编译 SFC/旧脚本查询无法由现有 Jest 插桩准确给出源码行/分支覆盖率,本轮未声称覆盖率百分比;以契约对比、金额样例、故障注入和浏览器实跑形成证据。
+- 未引入属性测试依赖,使用库存范围枚举验证金额/BV/税额。现有 Jest 不提供测试顺序随机化,本轮未声称随机顺序测试;工具库和依赖未变,未额外执行依赖审计。
+- 浏览器捕获到一条 Vue Devtools 扩展版本冲突日志,来源为 Chrome 扩展;未发现页面自身异常。未提交、推送或部署。

+ 92 - 0
src/ui-refactor/views/welcome-pack/WelcomePackDialogs.vue

@@ -0,0 +1,92 @@
+<template>
+  <div>
+    <ui-dialog
+      title="Paystack Payment"
+      :visible="paymentVisible"
+      width="450px"
+      :before-close="cancel"
+      :close-on-click-modal="false"
+      :close-on-press-escape="false"
+      :show-close="!cancelLoading"
+    >
+      <el-form v-loading="cancelLoading" label-position="top" @submit.native.prevent>
+        <el-form-item label="Email" for="ng-welcome-payment-email">
+          <el-input
+            id="ng-welcome-payment-email"
+            :value="payment.email"
+            @input="$emit('email', $event)"
+          />
+        </el-form-item>
+        <el-form-item label="Amount">
+          <el-input :value="payment.currency + ' ' + payment.amount" readonly />
+        </el-form-item>
+      </el-form>
+      <template slot="footer">
+        <paystack
+          v-if="paymentVisible"
+          :key="payment.orderSn"
+          :firstname="payment.firstname"
+          :lastname="payment.lastname"
+          :amount="payment.amount * 100"
+          :email="payment.email"
+          :metadata="payment.metadata"
+          :currency="payment.currency"
+          :paystackkey="payment.publicKey"
+          :reference="reference"
+          :channels="channels"
+          :callback="paymentCallback"
+          :close="cancel"
+        >
+          <el-button type="primary" :disabled="cancelLoading">Continue to Paystack</el-button>
+        </paystack>
+        <el-button :loading="cancelLoading" @click="cancel">Cancel</el-button>
+      </template>
+    </ui-dialog>
+    <order-success-dialog
+      :visible="successVisible"
+      :countdown="countdown"
+      @orders="$emit('orders')"
+    />
+    <product-details-dialog
+      :visible="productVisible"
+      :product="product"
+      :currency="currency"
+      @update:visible="$emit('close-product')"
+    />
+  </div>
+</template>
+
+<script>
+import paystack from 'vue-paystack'
+import ProductDetailsDialog from '@/ui-refactor/components/ProductDetailsDialog.vue'
+import UiDialog from '../../components/UiDialog.vue'
+import OrderSuccessDialog from '@/ui-refactor/components/OrderSuccessDialog.vue'
+
+export default {
+  name: 'WelcomePackDialogs',
+  components: { UiDialog, paystack, ProductDetailsDialog, OrderSuccessDialog },
+  props: {
+    paymentVisible: { type: Boolean, default: false },
+    payment: { type: Object, required: true },
+    reference: { type: String, required: true },
+    channels: { type: Array, required: true },
+    cancelLoading: { type: Boolean, default: false },
+    successVisible: { type: Boolean, default: false },
+    countdown: { type: Number, required: true },
+    productVisible: { type: Boolean, default: false },
+    product: { type: Object, required: true },
+    currency: { type: String, default: '' }
+  },
+  computed: {
+    paymentCallback() {
+      const orderSn = this.payment.orderSn
+      return response => this.$emit('paid', { orderSn, response })
+    }
+  },
+  methods: {
+    cancel() {
+      this.$emit('cancel')
+    }
+  }
+}
+</script>

+ 270 - 0
src/ui-refactor/views/welcome-pack/WelcomePackFields.vue

@@ -0,0 +1,270 @@
+<template>
+  <div class="ng-welcome-fields">
+    <ui-section-card title="Network Information">
+      <div class="ng-welcome-grid">
+        <el-form-item label="Referrer ID" for="ng-welcome-referrer">
+          <el-input id="ng-welcome-referrer" :value="form.recUserName" readonly />
+        </el-form-item>
+        <el-form-item label="Actual Referrer ID" for="ng-welcome-actual-referrer">
+          <el-input id="ng-welcome-actual-referrer" :value="form.conUserName" readonly />
+        </el-form-item>
+      </div>
+    </ui-section-card>
+    <ui-section-card title="Basic Information">
+      <div class="ng-welcome-grid">
+        <el-form-item label="Country" for="ng-welcome-country">
+          <el-input id="ng-welcome-country" :value="countryName" readonly />
+          <p class="ng-welcome-note">Set by your account, read-only.</p>
+        </el-form-item>
+        <el-form-item label="Language" for="ng-welcome-language">
+          <el-select
+            id="ng-welcome-language"
+            :value="form.languageId"
+            popper-class="ng-refactor-popper"
+            @input="change('languageId', $event)"
+          >
+            <el-option
+              v-for="item in languages"
+              :key="item.ID"
+              :label="item.NAME"
+              :value="item.ID"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="Member ID" for="ng-welcome-member">
+          <el-input id="ng-welcome-member" :value="form.insertUserName" readonly />
+        </el-form-item>
+        <el-form-item label="Order Level" for="ng-welcome-level">
+          <el-select
+            id="ng-welcome-level"
+            :value="form.decLv"
+            placeholder="Select level"
+            popper-class="ng-refactor-popper"
+            @input="change('decLv', $event)"
+          >
+            <el-option
+              v-for="item in levels"
+              :key="item.ID"
+              :label="item.LEVEL_NAME"
+              :value="item.ID"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item
+          class="ng-welcome-full"
+          label="Reporting Center ID"
+          for="ng-welcome-center"
+          :required="!stockist"
+        >
+          <el-input
+            id="ng-welcome-center"
+            :value="form.decUserName"
+            @input="change('decUserName', $event)"
+          />
+        </el-form-item>
+      </div>
+    </ui-section-card>
+    <slot />
+    <ui-section-card title="Personal Information">
+      <div class="ng-welcome-grid">
+        <el-form-item
+          v-for="field in personalFields"
+          :key="field.key"
+          :label="field.label"
+          :for="'ng-welcome-' + field.key"
+        >
+          <el-input
+            :id="'ng-welcome-' + field.key"
+            :value="form[field.key]"
+            :type="field.password ? 'password' : 'text'"
+            :show-password="!!field.password"
+            :maxlength="field.password ? 32 : undefined"
+            :autocomplete="field.autocomplete || 'off'"
+            @input="change(field.key, $event)"
+          />
+        </el-form-item>
+      </div>
+    </ui-section-card>
+    <ui-section-card title="Delivery Method">
+      <el-form-item label="Delivery Method">
+        <el-radio-group
+          :value="form.way"
+          aria-label="Delivery Method"
+          @input="change('way', $event)"
+        >
+          <el-radio label="express" border>Door Delivery</el-radio>
+          <el-radio label="pickup" border>Pickup</el-radio>
+        </el-radio-group>
+      </el-form-item>
+    </ui-section-card>
+    <ui-section-card v-if="form.way !== 'pickup'" title="Address Details">
+      <div class="ng-welcome-grid">
+        <el-form-item label="Region / State" for="ng-welcome-region">
+          <el-cascader
+            id="ng-welcome-region"
+            :value="form.areaSelected"
+            :options="regions"
+            placeholder="Select region"
+            popper-class="ng-refactor-popper"
+            @input="change('areaSelected', $event)"
+          />
+        </el-form-item>
+        <el-form-item label="City" for="ng-welcome-city">
+          <el-input
+            id="ng-welcome-city"
+            :value="form.cityName"
+            placeholder="City"
+            @input="change('cityName', $event)"
+          />
+        </el-form-item>
+        <el-form-item class="ng-welcome-full" label="Detailed Address" for="ng-welcome-address">
+          <el-input
+            id="ng-welcome-address"
+            :value="form.address"
+            type="textarea"
+            :rows="3"
+            placeholder="Street address, building, landmark..."
+            @input="change('address', $event)"
+          />
+        </el-form-item>
+        <el-form-item label="Area / District" for="ng-welcome-district">
+          <el-input
+            id="ng-welcome-district"
+            :value="form.lgaName"
+            placeholder="Area or district"
+            @input="change('lgaName', $event)"
+          />
+        </el-form-item>
+      </div>
+    </ui-section-card>
+    <ui-section-card title="Bank Information">
+      <div class="ng-welcome-grid">
+        <el-form-item label="Bank Name" for="ng-welcome-bank">
+          <el-select
+            id="ng-welcome-bank"
+            :value="form.openBank"
+            placeholder="Select bank"
+            popper-class="ng-refactor-popper"
+            @input="change('openBank', $event)"
+          >
+            <el-option
+              v-for="item in banks"
+              :key="item.BANK_CODE"
+              :label="item.BANK_NAME"
+              :value="item.BANK_CODE"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="Account Name" for="ng-welcome-bank-name">
+          <el-input
+            id="ng-welcome-bank-name"
+            :value="form.bankAddress"
+            @input="change('bankAddress', $event)"
+          />
+        </el-form-item>
+        <el-form-item label="Bank Account" for="ng-welcome-bank-number">
+          <el-input
+            id="ng-welcome-bank-number"
+            :value="form.bankNo"
+            maxlength="50"
+            @input="change('bankNo', $event)"
+          />
+        </el-form-item>
+      </div>
+    </ui-section-card>
+  </div>
+</template>
+
+<script>
+import UiSectionCard from '@/components/ui/UiSectionCard.vue'
+
+export default {
+  name: 'WelcomePackFields',
+  components: { UiSectionCard },
+  props: {
+    form: { type: Object, required: true },
+    countryName: { type: String, default: '' },
+    languages: { type: Array, required: true },
+    levels: { type: Array, required: true },
+    banks: { type: Array, required: true },
+    regions: { type: Array, required: true },
+    stockist: { type: Boolean, default: false }
+  },
+  data() {
+    return {
+      personalFields: [
+        { key: 'realName', label: 'Member Name', autocomplete: 'name' },
+        { key: 'mobile', label: 'Phone', autocomplete: 'tel' },
+        { key: 'email', label: 'Email', autocomplete: 'email' },
+        { key: 'password', label: 'Login Password', password: true, autocomplete: 'new-password' },
+        {
+          key: 'payPassword',
+          label: 'Payment Password',
+          password: true,
+          autocomplete: 'new-password'
+        },
+        { key: 'consignee', label: 'Recipient' },
+        { key: 'acceptMobile', label: 'Recipient Phone' }
+      ]
+    }
+  },
+  methods: {
+    change(key, value) {
+      this.$emit('change', { [key]: value })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.ng-welcome-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  column-gap: 24px;
+}
+
+.ng-welcome-grid > * {
+  min-width: 0;
+}
+
+.ng-welcome-full {
+  grid-column: 1/-1;
+}
+
+.ng-welcome-note {
+  margin: 8px 0 0;
+  color: #707780;
+  font-size: 12px;
+  line-height: 1.5;
+}
+
+.ng-welcome-fields ::v-deep .el-select,
+.ng-welcome-fields ::v-deep .el-cascader {
+  width: 100%;
+}
+
+.ng-welcome-fields ::v-deep .el-form-item__label {
+  float: none;
+  padding-bottom: 8px;
+  line-height: 1.5;
+}
+
+//.ng-welcome-fields ::v-deep .el-radio-group {
+//  display: flex;
+//  flex-wrap: wrap;
+//  gap: 12px;
+//}
+//
+//.ng-welcome-fields ::v-deep .el-radio {
+//  height: auto;
+//  margin: 0;
+//  line-height: 1.5;
+//  white-space: normal;
+//}
+
+@media (max-width: 767px) {
+  .ng-welcome-grid {
+    grid-template-columns: minmax(0, 1fr);
+  }
+}
+</style>

+ 33 - 0
src/ui-refactor/views/welcome-pack/WelcomePackPayment.vue

@@ -0,0 +1,33 @@
+<template>
+  <payment-methods
+    :options="options"
+    :value="value"
+    :currency="currency"
+    :balances="{ cash: balance }"
+    :summary="paymentSummary"
+    @input="$emit('input', $event)"
+  />
+</template>
+
+<script>
+import PaymentMethods from '@/ui-refactor/components/PaymentMethods.vue'
+import { getPaymentSummary } from '@/ui-refactor/utils/paymentSummary'
+
+export default {
+  name: 'WelcomePackPayment',
+  components: { PaymentMethods },
+  props: {
+    options: { type: Array, required: true },
+    value: { type: String, default: '' },
+    currency: { type: String, default: '' },
+    amount: { type: [Number, String], required: true },
+    balance: { type: [Number, String], required: true }
+  },
+  computed: {
+    paymentSummary() {
+      const balance = this.value === 'cash' ? this.balance : undefined
+      return getPaymentSummary(this.amount, balance, this.value === 'cash')
+    }
+  }
+}
+</script>

+ 338 - 0
src/ui-refactor/views/welcome-pack/__tests__/MemberWelcomePack.spec.js

@@ -0,0 +1,338 @@
+/* eslint-env jest */
+import Vue from 'vue'
+import ElementUI from 'element-ui'
+import fs from 'fs'
+import path from 'path'
+import * as user from '@/api/user'
+import { deleteApproachOrder } from '@/api/shop'
+import { getLanguages } from '@/api/setting'
+import usersInfo from '@/utils/usersInfo'
+import { toThousandFilter } from '@/filters'
+import { load } from '../../purchase/__tests__/loadComponent'
+
+jest.mock('@/api/user', () => Object.fromEntries(['fetchWelcomePack', 'createWelcomePack', 'fetchFullInfo', 'fetchInspectNetwork', 'fetchAutoPlace', 'fetchBrandAmbassadorInfo', 'fetchGuestDec', 'createGuestDec'].map(key => [key, jest.fn()])))
+jest.mock('@/api/shop', () => ({ deleteApproachOrder: jest.fn() }))
+jest.mock('@/api/setting', () => ({ getLanguages: jest.fn() }))
+jest.mock('@/utils/usersInfo', () => ({ userName: jest.fn(), userId: jest.fn(), baseData: jest.fn(), userEmail: () => 'operator@example.test', loginTimestamp: () => 'test-session' }))
+jest.mock('vue-paystack', () => ({ name: 'Paystack', props: ['amount', 'currency', 'email', 'metadata', 'channels', 'callback', 'close'], render: h => h('button') }))
+Vue.use(ElementUI)
+Vue.filter('toThousandFilter', toThousandFilter)
+Vue.prototype.$t = key => key
+
+const root = path.resolve(__dirname, '..')
+const Page = load(path.join(root, 'MemberWelcomePack.vue'))
+const Legacy = load(path.resolve('src/views/user/welcome-pack.vue'), true)
+const product = { ID: 'P1', GOODS_NAME: 'Sample product', GOODS_NO: 'SKU1', COVER: '', SELL_PRICE: 100, MARKET_PRICE: 120, PRICE_PV: 60, TAX_RATE: 7.5, STORE_NUMS: 10, CODE: 'NGN' }
+const countries = [{ ID: 'C1', NAME: 'Nigeria', DEFAULT_LANGUAGE_ID: 'EN' }]
+const languages = [{ ID: 'EN', NAME: 'English' }, { ID: 'ZH', NAME: '中文' }]
+const options = [{ label: 'cash', name: 'Ecoin' }, { label: 'pay_stack', name: 'Paystack' }]
+const settings = () => ({ data: { userName: 'NG-NEW', allGoods: [{ ...product }], allOpenBank: [{ BANK_CODE: 'B1', BANK_NAME: 'Sample bank' }], allDecPackage: {}, region: [], decLevel: [{ ID: 'L1', LEVEL_NAME: 'Starter' }], userBalance: { cash: 1000 }, isDec: '0', sellType: [{ sell_type: options }] }})
+const instances = []
+const errors = []
+Vue.config.errorHandler = error => errors.push(error.message)
+async function settle() { for (let i = 0; i < 16; i++) { await Promise.resolve(); await Vue.nextTick() } }
+function mount(component = Page) {
+  const store = { getters: { getCountriesList: countries, languageList: [], language: 'en' }, dispatch: jest.fn().mockResolvedValue(countries) }
+  const vm = new Vue({
+    ...component,
+    beforeCreate() {
+      this.$store = store
+      this.$route = { query: { ref: 'UNTRUSTED' }}
+      this.$router = { push: jest.fn() }
+      this.$t = key => key
+      this.$message = Object.assign(jest.fn(), { error: jest.fn() })
+      this.$confirm = jest.fn().mockResolvedValue()
+    }
+  }).$mount()
+  document.body.appendChild(vm.$el)
+  instances.push(vm)
+  return vm
+}
+async function ready() { const vm = mount(); await settle(); return vm }
+async function order() {
+  const vm = await ready()
+  Object.assign(vm.form, { decLv: 'L1', decUserName: 'CENTER', realName: 'New member', mobile: '000', email: 'new@example.test', consignee: 'Recipient', acceptMobile: '111', areaSelected: ['R1', 'R2', 'R3'], cityName: 'City', lgaName: 'District', address: 'Street', openBank: 'B1', bankAddress: 'Account', bankNo: '123', bankAreaSelected: ['BR1', 'BR2', 'BR3'] })
+  vm.toggleProduct(vm.tableData[0])
+  await settle()
+  return vm
+}
+beforeEach(() => {
+  jest.clearAllMocks()
+  jest.useFakeTimers()
+  errors.length = 0
+  usersInfo.userName.mockReturnValue('NG-OPERATOR')
+  usersInfo.userId.mockReturnValue('INTERNAL-ID')
+  usersInfo.baseData.mockReturnValue({ COUNTRY_ID: 'C1' })
+  getLanguages.mockResolvedValue(languages)
+  user.fetchWelcomePack.mockImplementation(async() => settings())
+  user.createWelcomePack.mockResolvedValue({ data: { SN: 'ORDER-1', ORDER_AMOUNT: 120, EMAIL: 'pay@example.test' }})
+  deleteApproachOrder.mockResolvedValue({})
+})
+afterEach(async() => {
+  instances.splice(0).forEach(vm => { vm.$destroy(); vm.$el.remove() })
+  await settle()
+  expect(errors).toEqual([])
+  jest.clearAllTimers()
+  jest.useRealTimers()
+})
+
+it('initializes the logged-in APIs, fixed operator IDs and account country while keeping the new member ID', async() => {
+  const vm = await ready()
+  expect(user.fetchWelcomePack).toHaveBeenCalledWith({ countryId: 'C1' })
+  expect(vm.form.recUserName).toBe('NG-OPERATOR')
+  expect(vm.form.conUserName).toBe('NG-OPERATOR')
+  expect(vm.form.insertUserName).toBe('NG-NEW')
+  expect(vm.form.languageId).toBe('EN')
+  expect(vm.$el.querySelector('#ng-welcome-country').readOnly).toBe(true)
+  expect(vm.$el.querySelector('#ng-welcome-referrer').readOnly).toBe(true)
+  expect(vm.$el.querySelector('#ng-welcome-actual-referrer').value).toBe('NG-OPERATOR')
+  expect(vm.$el.textContent).toContain('Bank Information')
+  expect(user.fetchGuestDec).not.toHaveBeenCalled()
+  expect(user.createGuestDec).not.toHaveBeenCalled()
+})
+
+it.each(['cash', 'pay_stack'])('keeps the exact legacy %s request and payment response mapping', async payType => {
+  const vm = await order()
+  vm.form.payType = payType
+  const legacy = mount({ ...Legacy, created: undefined, render: h => h('div') })
+  Object.assign(legacy.form, JSON.parse(JSON.stringify(vm.form)))
+  legacy.multipleSelection = JSON.parse(JSON.stringify(vm.multipleSelection))
+  legacy.userBalance = { cash: 1000 }
+  legacy.isDec = '0'
+  legacy.onSubmit()
+  const expected = JSON.parse(JSON.stringify(user.createWelcomePack.mock.calls[0][0]))
+  user.createWelcomePack.mockClear()
+  vm.onSubmit()
+  expect(user.createWelcomePack).toHaveBeenCalledTimes(1)
+  expect(user.createWelcomePack).toHaveBeenCalledWith(expected)
+  expect(expected).toMatchObject({ countryId: 'C1', recUserName: 'NG-OPERATOR', conUserName: 'NG-OPERATOR', insertUserName: 'NG-NEW', goodsId: ['P1'], goodsNum: [1], location: 1, decWay: '2' })
+  expect(expected).not.toHaveProperty('draftId')
+  expect(expected).not.toHaveProperty('accessToken')
+  await settle()
+  if (payType === 'cash') expect(vm.$router.push).toHaveBeenCalledWith({ path: '/shop/member-order' })
+  else {
+    expect(vm.visible).toBe(true)
+    expect(vm.payForm).toMatchObject({ orderSn: 'ORDER-1', amount: 120, email: 'pay@example.test' })
+    expect(vm.payForm.metadata.custom_fields.map(field => field.value)).toEqual(['ORDER-1', 'userDec'])
+  }
+})
+
+it('keeps MARKET_PRICE totals, BV and legacy tax for a range of stock quantities', async() => {
+  const vm = await order()
+  for (let quantity = 1; quantity <= 10; quantity++) {
+    vm.changeProductQuantity({ row: vm.tableData[0], index: 0, value: quantity })
+    vm.selectProduct()
+    expect(vm.form.goodsNum).toEqual([quantity])
+    expect(Number(vm.sellPriceSum)).toBe(120 * quantity)
+    expect(Number(vm.pricePvSum)).toBe(60 * quantity)
+    expect(vm.payAmount).toBe(120 * quantity)
+    expect(Number(vm.taxSum)).toBe(Number((120 * quantity * 7.5 / 107.5).toFixed(2)))
+  }
+})
+
+it('shows balance shortage without changing the original balance or submitting', async() => {
+  const vm = await order()
+  vm.userBalance.cash = 119
+  vm.form.payType = 'cash'
+  await settle()
+  expect(vm.$el.textContent).toContain('not enough to cover this order')
+  expect(vm.$el.textContent).toContain('Balance after payment')
+  vm.onSubmit()
+  expect(user.createWelcomePack).not.toHaveBeenCalled()
+  expect(vm.userBalance.cash).toBe(119)
+})
+
+it('keeps editable fields and pickup events connected without changing the legacy request fields', async() => {
+  const vm = await order()
+  const fields = vm.$children[0].$children.find(child => child.$options.name === 'WelcomePackFields')
+  fields.$emit('change', { realName: 'Updated member', way: 'pickup' })
+  await settle()
+  expect(vm.form.realName).toBe('Updated member')
+  expect(vm.areaDisabled).toBe(true)
+  expect(vm.$el.textContent).not.toContain('Address Details')
+  vm.form.payType = 'cash'
+  vm.onSubmit()
+  expect(user.createWelcomePack.mock.calls[0][0]).not.toHaveProperty('way')
+  expect(user.createWelcomePack.mock.calls[0][0]).toMatchObject({ address: 'Street', bankNo: '123' })
+})
+
+it('keeps member Paystack amount conversion, email and channels without the guest protocol', async() => {
+  const vm = await order()
+  vm.form.payType = 'pay_stack'
+  vm.onSubmit()
+  await settle()
+  const descendants = component => component.$children.reduce((all, child) => all.concat(child, descendants(child)), [])
+  const sdk = descendants(vm).find(child => child.$options.name === 'Paystack')
+  expect(sdk.amount).toBe(12000)
+  expect(sdk.channels).toEqual(['card', 'bank', 'ussd', 'qr'])
+  expect(sdk.email).toBe('pay@example.test')
+  expect(sdk.currency).toBe('NGN')
+  const dialogs = descendants(vm).find(child => child.$options.name === 'WelcomePackDialogs')
+  dialogs.$emit('email', 'updated@example.test')
+  await settle()
+  expect(vm.payForm.email).toBe('updated@example.test')
+})
+
+it('ignores a delayed callback belonging to a cancelled order', async() => {
+  const vm = await order()
+  vm.form.payType = 'pay_stack'
+  vm.onSubmit()
+  await settle()
+  await vm.handleClose()
+  user.createWelcomePack.mockResolvedValue({ data: { SN: 'ORDER-2', ORDER_AMOUNT: 120, EMAIL: 'pay@example.test' }})
+  vm.onSubmit()
+  await settle()
+  vm.processPayment({ orderSn: 'ORDER-1' })
+  expect(vm.visible).toBe(true)
+  expect(vm.payDialog).toBe(false)
+  vm.processPayment({ orderSn: 'ORDER-2' })
+  expect(vm.visible).toBe(false)
+  expect(vm.payDialog).toBe(true)
+})
+
+it('does not submit or cancel against a changed login session', async() => {
+  const vm = await order()
+  usersInfo.userName.mockReturnValue('DIFFERENT-OPERATOR')
+  vm.onSubmit()
+  expect(user.createWelcomePack).not.toHaveBeenCalled()
+  vm.visible = true
+  await vm.handleClose()
+  expect(deleteApproachOrder).not.toHaveBeenCalled()
+})
+
+it.each(['level', 'center', 'products'])('preserves the legacy missing-%s validation', async missing => {
+  const vm = await order()
+  if (missing === 'level') vm.form.decLv = ''
+  if (missing === 'center') vm.form.decUserName = ''
+  if (missing === 'products') vm.toggleProduct(vm.tableData[0])
+  vm.onSubmit()
+  expect(user.createWelcomePack).not.toHaveBeenCalled()
+})
+
+it('uses only returned payment options and supports a one-option response', async() => {
+  user.fetchWelcomePack.mockImplementation(async() => { const response = settings(); response.data.sellType[0].sell_type = [options[0]]; return response })
+  const vm = await ready()
+  expect(vm.form.payType).toBe('cash')
+  expect(vm.$el.textContent).not.toContain('Pay via Paystack')
+})
+
+it('restores Save after submission failure and blocks duplicate clicks while a request is pending', async() => {
+  const vm = await order()
+  let reject
+  user.createWelcomePack.mockImplementation(() => new Promise((resolve, fail) => { reject = fail }))
+  vm.onSubmit()
+  vm.onSubmit()
+  expect(user.createWelcomePack).toHaveBeenCalledTimes(1)
+  reject(new Error('Order failed'))
+  await settle()
+  expect(vm.submitButtonStat).toBe(false)
+  expect(vm.visible).toBe(false)
+})
+
+it('cancels the current Paystack order, preserving the window on failure', async() => {
+  const vm = await order()
+  vm.form.payType = 'pay_stack'
+  vm.onSubmit()
+  await settle()
+  vm.onSubmit()
+  expect(user.createWelcomePack).toHaveBeenCalledTimes(1)
+  deleteApproachOrder.mockRejectedValueOnce(new Error('Cancel failed'))
+  await vm.handleClose()
+  expect(deleteApproachOrder).toHaveBeenCalledWith({ orderSn: 'ORDER-1' })
+  expect(vm.visible).toBe(true)
+  await vm.handleClose()
+  expect(vm.visible).toBe(false)
+  expect(vm.cancelLoading).toBe(false)
+})
+
+it('does not cancel an order when the confirmation is dismissed', async() => {
+  const vm = await order()
+  vm.form.payType = 'pay_stack'
+  vm.onSubmit()
+  await settle()
+  vm.$confirm.mockRejectedValue('cancel')
+  await vm.handleClose()
+  expect(deleteApproachOrder).not.toHaveBeenCalled()
+  expect(vm.visible).toBe(true)
+})
+
+it('cleans the successful-payment countdown on leaving and redirects only once', async() => {
+  const vm = await order()
+  vm.form.payType = 'pay_stack'
+  vm.onSubmit()
+  await settle()
+  vm.processPayment()
+  vm.processPayment()
+  expect(vm.payDialog).toBe(true)
+  jest.advanceTimersByTime(6000)
+  expect(vm.$router.push).toHaveBeenCalledTimes(1)
+  vm.$destroy()
+  jest.advanceTimersByTime(6000)
+  expect(vm.$router.push).toHaveBeenCalledTimes(1)
+})
+
+it('reports failed initialization and prevents submitting incomplete settings', async() => {
+  user.fetchWelcomePack.mockRejectedValue(new Error('Load failed'))
+  const vm = await ready()
+  expect(vm.$el.textContent).toContain('Load failed')
+  vm.onSubmit()
+  expect(user.createWelcomePack).not.toHaveBeenCalled()
+  expect(vm.loading).toBe(false)
+})
+
+it('does not request an order setup if account country or member ID is missing', async() => {
+  usersInfo.baseData.mockReturnValue(null)
+  const vm = await ready()
+  expect(user.fetchWelcomePack).not.toHaveBeenCalled()
+  expect(vm.$el.textContent).toContain('country')
+})
+
+it('defaults the stockist reporting center to the operator and submits the edited ID', async() => {
+  user.fetchWelcomePack.mockImplementation(async() => { const response = settings(); response.data.isDec = '1'; return response })
+  const vm = await ready()
+  expect(vm.form.decUserName).toBe('NG-OPERATOR')
+  const input = vm.$el.querySelector('#ng-welcome-center')
+  expect(input.disabled).toBe(false)
+  input.value = 'OTHER-CENTER'
+  input.dispatchEvent(new Event('input', { bubbles: true }))
+  await settle()
+  expect(vm.form.decUserName).toBe('OTHER-CENTER')
+  Object.assign(vm.form, { decLv: 'L1', payType: 'cash' })
+  vm.toggleProduct(vm.tableData[0])
+  vm.onSubmit()
+  expect(user.createWelcomePack).toHaveBeenCalledWith(expect.objectContaining({ decUserName: 'OTHER-CENTER' }))
+})
+
+it.each(['userBalance', 'sellType'])('rejects setup with missing %s instead of allowing an order', async field => {
+  user.fetchWelcomePack.mockImplementation(async() => { const response = settings(); delete response.data[field]; return response })
+  const vm = await ready()
+  vm.onSubmit()
+  expect(vm.initialized).toBe(false)
+  expect(vm.loadError).toContain('payment settings')
+  expect(user.createWelcomePack).not.toHaveBeenCalled()
+})
+
+it('connects the homepage module and keeps the route protected', () => {
+  const router = fs.readFileSync(path.resolve('src/router/index.js'), 'utf8')
+  const shortcuts = fs.readFileSync(path.resolve('src/ui-refactor/views/account/components/AccountShortcuts.vue'), 'utf8')
+  const permission = fs.readFileSync(path.resolve('src/permission.js'), 'utf8')
+  expect(router).toContain("path: 'dashboard/member-welcome-pack'")
+  expect(shortcuts).toContain("to: '/dashboard/member-welcome-pack'")
+  expect(permission.match(/const whiteList = (.*)/)[1]).not.toContain('member-welcome-pack')
+})
+
+it('connects shared payment selection and inherits the form lock without placing an order', async() => {
+  const vm = await order()
+  const radios = Array.from(vm.$el.querySelectorAll('.ng-payment-methods-options input'))
+  expect(radios.map(input => input.value)).toEqual(options.map(option => option.label))
+  const amount = vm.sellPriceSum
+  radios.find(input => input.value === 'cash').click()
+  await settle()
+  expect(vm.form.payType).toBe('cash')
+  expect(vm.sellPriceSum).toBe(amount)
+  expect(vm.userBalance.cash).toBe(1000)
+  expect(user.createWelcomePack).not.toHaveBeenCalled()
+  vm.submitButtonStat = true
+  await settle()
+  expect(radios.every(input => input.disabled)).toBe(true)
+})

+ 117 - 0
src/ui-refactor/views/welcome-pack/__tests__/WelcomePackNavigation.spec.js

@@ -0,0 +1,117 @@
+/* eslint-env jest */
+import Vue from 'vue'
+import Router from 'vue-router'
+import ElementUI from 'element-ui'
+import fs from 'fs'
+import path from 'path'
+import { transformSync } from '@babel/core'
+import { parseComponent, compileTemplate } from 'vue/compiler-sfc'
+import { fetchWelcomePack, createWelcomePack } from '@/api/user'
+import { getLanguages } from '@/api/setting'
+import { toThousandFilter } from '@/filters'
+import { load } from '../../purchase/__tests__/loadComponent'
+
+jest.mock('@/api/user', () => ({ fetchWelcomePack: jest.fn(), createWelcomePack: jest.fn() }))
+jest.mock('@/api/shop', () => ({ deleteApproachOrder: jest.fn() }))
+jest.mock('@/api/setting', () => ({ getLanguages: jest.fn() }))
+jest.mock('@/utils/usersInfo', () => ({ userName: () => 'OPERATOR', userEmail: () => 'operator@example.test', baseData: () => ({ COUNTRY_ID: 'C1' }), loginTimestamp: () => 'session' }))
+jest.mock('vue-paystack', () => ({ render: h => h('button') }))
+Vue.use(Router)
+Vue.use(ElementUI)
+Vue.filter('toThousandFilter', toThousandFilter)
+Vue.prototype.$t = key => key
+Vue.prototype.$message = Object.assign(jest.fn(), { error: jest.fn(), info: jest.fn() })
+Vue.prototype.$store = {
+  getters: { getCountriesList: [{ ID: 'C1', NAME: 'Nigeria' }] },
+  dispatch: () => Promise.resolve([{ ID: 'C1', NAME: 'Nigeria', DEFAULT_LANGUAGE_ID: 'EN' }])
+}
+const Page = load(path.resolve(__dirname, '../MemberWelcomePack.vue'))
+const accountFile = path.resolve('src/ui-refactor/views/account/AccountPage.vue')
+const accountParts = parseComponent(fs.readFileSync(accountFile, 'utf8'))
+const accountModule = { exports: {}}
+const accountScript = transformSync(accountParts.script.content, { babelrc: false, configFile: false, plugins: [require.resolve('@babel/plugin-transform-modules-commonjs')] }).code
+// 本次只测试首页页签组合;会员信息、订单数据及资料属于独立边界。
+new Function('require', 'module', 'exports', accountScript)(id => ({ name: path.basename(id, '.vue'), render: h => h('div', id) }), accountModule, accountModule.exports)
+const accountTemplate = compileTemplate({ source: accountParts.template.content, filename: accountFile })
+if (accountTemplate.errors.length) throw new Error(accountTemplate.errors.join('\n'))
+const Account = Object.assign(accountModule.exports.default, new Function(accountTemplate.code + '; return { render, staticRenderFns }')())
+const product = { ID: 'P1', GOODS_NAME: 'Sample', SELL_PRICE: 100, MARKET_PRICE: 120, PRICE_PV: 60, STORE_NUMS: 10, TAX_RATE: 7.5, CODE: 'NGN' }
+const instances = []
+async function settle() { for (let i = 0; i < 20; i++) { await Promise.resolve(); await Vue.nextTick() } }
+async function mount(location) {
+  const router = new Router({ mode: 'abstract', routes: [
+    { path: '/dashboard/member-welcome-pack', component: Page },
+    { path: '/dashboard/index', component: Account },
+    { path: '/shop/member-order', component: { render: h => h('div', 'Old orders') }}
+  ] })
+  const root = new Vue({ router, render: h => h('router-view') }).$mount()
+  document.body.appendChild(root.$el)
+  instances.push(root)
+  router.push(location)
+  await settle()
+  return { root, router }
+}
+beforeEach(() => {
+  jest.clearAllMocks()
+  jest.useFakeTimers()
+  getLanguages.mockResolvedValue([{ ID: 'EN', NAME: 'English' }])
+  fetchWelcomePack.mockResolvedValue({ data: { userName: 'NEW-MEMBER', allGoods: [{ ...product }], allOpenBank: [], region: [], decLevel: [{ ID: 'L1', LEVEL_NAME: 'Starter' }], userBalance: { cash: 1000 }, isDec: '0', sellType: [{ sell_type: [{ label: 'cash', name: 'Ecoin' }, { label: 'pay_stack', name: 'Paystack' }] }] }})
+  createWelcomePack.mockResolvedValue({ data: { SN: 'ORDER-1', ORDER_AMOUNT: 120, EMAIL: 'sample@example.test' }})
+})
+afterEach(() => {
+  instances.splice(0).forEach(vm => { const element = vm.$el; vm.$destroy(); element.remove() })
+  jest.clearAllTimers()
+  jest.useRealTimers()
+})
+
+it.each(['cash', 'paystack-countdown', 'paystack-button'])('opens the homepage Orders tab after successful %s payment', async payment => {
+  const { root, router } = await mount('/dashboard/member-welcome-pack')
+  const page = root.$children[0]
+  Object.assign(page.form, { decLv: 'L1', decUserName: 'CENTER', payType: payment === 'cash' ? 'cash' : 'pay_stack' })
+  page.toggleProduct(page.tableData[0])
+  root.$el.querySelector('.ng-welcome-save').click()
+  await settle()
+  if (payment !== 'cash') {
+    expect(router.currentRoute.path).toBe('/dashboard/member-welcome-pack')
+    page.processPayment({ orderSn: 'ORDER-1' })
+    if (payment === 'paystack-countdown') jest.advanceTimersByTime(5000)
+    else page.handleOrderList()
+    await settle()
+  }
+  expect(router.currentRoute.path).toBe('/dashboard/index')
+  expect(router.currentRoute.query.tab).toBe('orders')
+  expect(router.currentRoute.query.orderCategory).toBe('member')
+  expect(root.$el.querySelector('#tab-orders').getAttribute('aria-selected')).toBe('true')
+})
+
+it('stays on Welcome Pack when Save fails', async() => {
+  createWelcomePack.mockRejectedValue(new Error('Order failed'))
+  const { root, router } = await mount('/dashboard/member-welcome-pack')
+  const page = root.$children[0]
+  Object.assign(page.form, { decLv: 'L1', decUserName: 'CENTER', payType: 'cash' })
+  page.toggleProduct(page.tableData[0])
+  root.$el.querySelector('.ng-welcome-save').click()
+  await settle()
+  expect(router.currentRoute.path).toBe('/dashboard/member-welcome-pack')
+  expect(page.submitButtonStat).toBe(false)
+})
+
+it.each([
+  ['orders', 'orders'], ['profile', 'profile'], ['overview', 'overview'],
+  ['invalid', 'overview'], [undefined, 'overview']
+])('selects the supported homepage tab %s with a safe default', async(tab, expected) => {
+  const { root } = await mount({ path: '/dashboard/index', query: tab ? { tab } : {}})
+  expect(root.$el.querySelector('#tab-' + expected).getAttribute('aria-selected')).toBe('true')
+})
+
+it('reacts to tab query changes while the homepage is already open and preserves manual switching', async() => {
+  const { root, router } = await mount('/dashboard/index')
+  const page = root.$children[0]
+  router.push({ path: '/dashboard/index', query: { tab: 'orders' }})
+  await settle()
+  expect(root.$children[0]).toBe(page)
+  expect(root.$el.querySelector('#tab-orders').getAttribute('aria-selected')).toBe('true')
+  root.$el.querySelector('#tab-profile').click()
+  await settle()
+  expect(root.$el.querySelector('#tab-profile').getAttribute('aria-selected')).toBe('true')
+})

+ 67 - 0
src/ui-refactor/views/welcome-pack/__tests__/preview.cjs

@@ -0,0 +1,67 @@
+/* eslint-env node */
+// 隔离预览:真实 SFC/样式,所有接口与 SDK 由本地样例替代,禁止真实报单和支付。
+const fs = require('fs')
+const os = require('os')
+const path = require('path')
+const http = require('http')
+const Service = require('@vue/cli-service/lib/Service')
+const webpack = require('webpack')
+const serve = require('serve-static')
+const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'ng-member-welcome-preview-'))
+const local = name => path.join(temporary, name)
+const source = file => JSON.stringify(path.resolve(file).replace(/\\/g, '/'))
+const countries = [{ ID: 'PREVIEW', NAME: 'Nigeria', DEFAULT_LANGUAGE_ID: 'EN' }]
+const languages = [{ ID: 'EN', NAME: 'English' }, { ID: 'ZH', NAME: '中文' }]
+const products = ['Calmag', 'Elken Spirulina', 'C-Joie Flex'].map((name, index) => ({ ID: 'P' + index, GOODS_NAME: name, GOODS_NO: 'SKU' + index, COVER: 'preview-product.svg', CONTENT: 'Sample product for isolated UI verification.', SELL_PRICE: [38000, 52000, 45000][index], MARKET_PRICE: [45600, 62400, 54000][index], PRICE_PV: [35, 48, 42][index], TAX_RATE: 7.5, STORE_NUMS: 10, CODE: 'NGN' }))
+const data = { userName: 'NG00000002', allGoods: products, allOpenBank: [{ BANK_CODE: 'PREVIEW', BANK_NAME: 'Sample bank' }], region: [{ value: 'R1', label: 'Sample state' }], decLevel: [{ ID: 'L1', LEVEL_NAME: 'Starter' }], userBalance: { cash: 31000 }, isDec: '0', sellType: [{ sell_type: [{ label: 'pay_stack', name: 'Paystack' }, { label: 'cash', name: 'Ecoin' }] }] }
+fs.writeFileSync(local('user.js'), 'export const fetchWelcomePack = async () => ({ data: ' + JSON.stringify(data) + ' });\n' + ['createWelcomePack', 'fetchFullInfo', 'fetchInspectNetwork', 'fetchAutoPlace', 'fetchBrandAmbassadorInfo'].map(name => 'export const ' + name + ' = async () => { throw new Error("Isolated preview: no orders or member requests"); };').join('\n'))
+fs.writeFileSync(local('shop.js'), 'export const deleteApproachOrder = async () => { throw new Error("Isolated preview: no cancellations"); };')
+fs.writeFileSync(local('setting.js'), 'export const getLanguages = async () => ' + JSON.stringify(languages) + ';')
+fs.writeFileSync(local('identity.js'), 'export default { userName: () => "NG00000001", userEmail: () => "sample@example.test", baseData: () => ({COUNTRY_ID:"PREVIEW"}), loginTimestamp: () => "preview-session" };')
+fs.writeFileSync(local('paystack.js'), 'export default { render: h => h("button", {attrs:{disabled:true}}, "Isolated SDK preview") };')
+fs.writeFileSync(local('entry.js'), `
+import Vue from ${source('node_modules/vue')};
+import Vuex from ${source('node_modules/vuex')};
+import Router from ${source('node_modules/vue-router')};
+import Element from ${source('node_modules/element-ui')};
+import ${source('node_modules/normalize.css/normalize.css')};
+import ${source('node_modules/element-ui/lib/theme-chalk/index.css')};
+import ${source('src/styles/index.scss')};
+import Layout from ${source('src/ui-refactor/index.vue')};
+import Page from ${source('src/ui-refactor/views/welcome-pack/MemberWelcomePack.vue')};
+import { toThousandFilter } from ${source('src/filters/index.js')};
+Vue.use(Vuex); Vue.use(Router); Vue.use(Element, {size:'medium'});
+Vue.filter('toThousandFilter', toThousandFilter); Vue.prototype.$t = key => key;
+const store = new Vuex.Store({getters:{getCountriesList:()=>${JSON.stringify(countries)}}, actions:{'settings/getCountries':()=>${JSON.stringify(countries)}}});
+const hero = {breadcrumbs:[{text:'My Account',to:'/dashboard/index'},{text:'Member Welcome Pack'}],titleParts:[{text:'Member '},{text:'Welcome Pack.',accent:true}],subtitle:'Create a welcome-pack order for a new member using your current member ID and account country.'};
+const router = new Router({routes:[{path:'/',component:Layout,children:[{path:'*',component:Page,meta:{publicLayout:{hero}}}]}]});
+new Vue({store,router,render:h=>h('router-view')}).$mount('#app');
+document.title = 'Member Welcome Pack · isolated preview';
+`)
+process.env.NODE_ENV = 'development'
+process.env.VUE_APP_CDN_API = '.'
+const service = new Service(process.cwd())
+service.init('development')
+const chain = service.resolveChainableWebpackConfig()
+chain.module.rule('eslint').exclude.add(temporary)
+const config = service.resolveWebpackConfig(chain)
+config.entry = { app: local('entry.js') }
+config.output.path = local('dist')
+config.output.publicPath = '/'
+config.devtool = false
+config.resolve.alias = {
+  '@/api/user$': local('user.js'), '@/api/shop$': local('shop.js'), '@/api/setting$': local('setting.js'),
+  '@/utils/usersInfo$': local('identity.js'), 'vue-paystack$': local('paystack.js'), ...config.resolve.alias
+}
+config.resolve.modules.push(path.resolve('node_modules'))
+config.plugins = config.plugins.filter(plugin => !/WebUpdateNotificationPlugin/.test(plugin.constructor.name))
+webpack(config, (error, stats) => {
+  if (error || stats.hasErrors()) { console.error(error || stats.toString({ all: false, errors: true })); process.exitCode = 1; return }
+  const staticFiles = serve(config.output.path)
+  const image = '<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160"><rect x="48" y="12" width="64" height="136" rx="8" fill="#e9f4f8"/><rect x="54" y="65" width="52" height="45" fill="#202259"/></svg>'
+  const server = http.createServer((request, response) => {
+    if (request.url.includes('preview-product.svg')) { response.writeHead(200, { 'Content-Type': 'image/svg+xml' }); response.end(image); return }
+    staticFiles(request, response, () => { response.writeHead(404); response.end() })
+  })
+  server.listen(0, '127.0.0.1', () => console.log(JSON.stringify({ url: 'http://127.0.0.1:' + server.address().port + '/#/dashboard/member-welcome-pack', build: config.output.path })))
+})

+ 38 - 0
src/ui-refactor/views/welcome-pack/__tests__/verify.cjs

@@ -0,0 +1,38 @@
+/* eslint-env node */
+const fs = require('fs')
+const os = require('os')
+const path = require('path')
+const assert = require('assert')
+const crypto = require('crypto')
+const { spawnSync } = require('child_process')
+const output = fs.mkdtempSync(path.join(os.tmpdir(), 'ng-member-welcome-evidence-'))
+function run(script, args, name, expected = [0]) {
+  const result = spawnSync(process.execPath, [script, ...args], { encoding: 'utf8', env: { ...process.env, NODE_OPTIONS: '--openssl-legacy-provider' }})
+  fs.writeFileSync(path.join(output, name + '.log'), (result.stdout || '') + (result.stderr || ''))
+  assert(!result.error && expected.includes(result.status), name + ' failed; see ' + output)
+}
+run(path.join(__dirname, 'verifyMutations.cjs'), [], 'mutations')
+const report = path.join(output, 'tests.json')
+run('node_modules/jest/bin/jest.js', ['--runInBand', '--testMatch', '**/__tests__/*.spec.js', '**/tests/unit/ui-refactor/*.spec.js', '--json', '--outputFile=' + report], 'tests', [0, 1])
+const tests = JSON.parse(fs.readFileSync(report, 'utf8'))
+const failures = tests.testResults.flatMap(suite => suite.assertionResults.filter(test => test.status === 'failed').map(test => test.fullName)).sort()
+const baseline = [
+  'new page theme preserves Element UI and legacy pages retains control geometry and confines table styles to new pages',
+  'Profile UI preserves existing account, password and address behavior preserves edit row mapping, ID and default string values without changing other card actions',
+  'AccountBalances preserves the existing account logic resets page size to the original 5 and repeats the same request',
+  'loads only the selected endpoint and displays multiple orders, all details and products',
+  'keeps separate pagination when switching categories and does not duplicate requests on return',
+  'shows an empty state and hides pagination after the original loading delay',
+  'Sponsor Network UI reuses both original network scripts keeps the original untrimmed username, unrestricted depth and period query on form submission'
+].sort()
+const checkFailures = actual => assert.deepStrictEqual(actual, baseline, 'Test failures changed from baseline')
+assert.throws(() => checkFailures(baseline.concat('Unexpected failure')), 'Negative control must fail')
+checkFailures(failures)
+run('node_modules/eslint/bin/eslint.js', ['--max-warnings=0', '--ext', '.vue,.js,.cjs', 'src/ui-refactor/views/welcome-pack', 'src/ui-refactor/views/account/components/AccountShortcuts.vue', 'src/ui-refactor/views/team-inquiry/__tests__/TeamInquiryPage.spec.js', 'src/router/index.js'], 'lint')
+run('node_modules/@vue/cli-service/bin/vue-cli-service.js', ['build', '--dest', path.join(output, 'build'), '--no-clean'], 'build')
+const files = ['MemberWelcomePack.vue', 'WelcomePackFields.vue', 'WelcomePackPayment.vue', 'WelcomePackDialogs.vue'].map(file => path.join(__dirname, '..', file))
+const hash = crypto.createHash('sha256')
+files.forEach(file => hash.update(path.basename(file)).update(fs.readFileSync(file)))
+const result = { passed: tests.numPassedTests, existingFailures: failures, mutations: '5/5 killed', lint: '0 warnings', build: 'passed', sourceHash: hash.digest('hex'), output }
+fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2))
+console.log(JSON.stringify(result, null, 2))

+ 34 - 0
src/ui-refactor/views/welcome-pack/__tests__/verifyMutations.cjs

@@ -0,0 +1,34 @@
+/* eslint-env node */
+const fs = require('fs')
+const path = require('path')
+const assert = require('assert')
+const { spawnSync } = require('child_process')
+const root = path.resolve(__dirname, '..')
+function assertKilled(result, name) {
+  assert(!result.error && result.status === 1, 'Mutant survived or runner failed: ' + name)
+  assert(/Tests:\s+\d+ failed/.test(result.stdout + result.stderr), 'No assertion failure: ' + name)
+}
+assert.throws(() => assertKilled({ status: 0, stdout: '', stderr: '' }, 'negative control'))
+assert.throws(() => assertKilled({ status: 1, stdout: 'Runner crashed', stderr: '' }, 'negative control'))
+const mutations = [
+  ['operator identity', 'MemberWelcomePack.vue', 'this.form.recUserName = this.form.conUserName = this.operatorId', "this.form.recUserName = this.form.conUserName = 'WRONG-MEMBER'"],
+  ['duplicate submission', 'MemberWelcomePack.vue', 'onSubmit() {\n      if (this.busy || !this.initialized) return', 'onSubmit() {\n      if (!this.initialized) return'],
+  ['current cancellation order', 'MemberWelcomePack.vue', 'orderSn: this.payForm.orderSn', 'orderSn: this.form.orderSn'],
+  ['balance warning', 'WelcomePackPayment.vue', 'Number(this.balance) < Number(this.amount)', 'Number(this.balance) > Number(this.amount)'],
+  ['delayed callback', 'MemberWelcomePack.vue', 'if (event && event.orderSn !== this.payForm.orderSn) return', 'if (false) return']
+]
+for (const [name, file, from, to] of mutations) {
+  const target = path.join(root, file)
+  const original = fs.readFileSync(target, 'utf8')
+  assert(original.includes(from), 'Missing mutation target: ' + name)
+  try {
+    fs.writeFileSync(target, original.replace(from, to))
+    const result = spawnSync(process.execPath, ['node_modules/jest/bin/jest.js', '--runInBand', '--testMatch', '**/welcome-pack/__tests__/*.spec.js'], { encoding: 'utf8' })
+    assertKilled(result, name)
+    console.log('Killed: ' + name)
+  } finally {
+    fs.writeFileSync(target, original)
+    assert.strictEqual(fs.readFileSync(target, 'utf8'), original, 'Source restore failed')
+  }
+}
+console.log('Mutation verification: ' + mutations.length + '/' + mutations.length)

Некоторые файлы не были показаны из-за большого количества измененных файлов