@seasonkoh/webaz 0.1.26 → 0.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/LICENSE +2 -2
  2. package/NOTICE +24 -3
  3. package/README.md +74 -330
  4. package/README.zh-CN.md +419 -0
  5. package/dist/layer0-foundation/L0-2-state-machine/genuine-sale.js +21 -0
  6. package/dist/layer0-foundation/L0-5-manifest/manifest.js +8 -3
  7. package/dist/layer1-agent/L1-1-mcp-server/auth.js +13 -1
  8. package/dist/layer1-agent/L1-1-mcp-server/network-mode.js +69 -0
  9. package/dist/layer1-agent/L1-1-mcp-server/server.js +270 -82
  10. package/dist/layer2-business/L2-9-contribution/admin-coordination-ingestion-engine.js +181 -0
  11. package/dist/layer2-business/L2-9-contribution/admin-coordination-resolver.js +114 -0
  12. package/dist/layer2-business/L2-9-contribution/admin-coordination-store.js +251 -0
  13. package/dist/layer2-business/L2-9-contribution/admin-operator-claim-workflow.js +390 -0
  14. package/dist/layer2-business/L2-9-contribution/build-task-agent-metadata-store.js +24 -0
  15. package/dist/layer2-business/L2-9-contribution/build-task-participation.js +6 -2
  16. package/dist/layer2-business/L2-9-contribution/build-task-quota.js +337 -0
  17. package/dist/layer2-business/L2-9-contribution/build-task-read.js +25 -2
  18. package/dist/layer2-business/L2-9-contribution/build-tasks-engine.js +57 -7
  19. package/dist/layer2-business/L2-9-contribution/canonical-contribution-target.js +1 -1
  20. package/dist/layer2-business/L2-9-contribution/contribution-facts-read.js +66 -0
  21. package/dist/layer2-business/L2-9-contribution/task-proposal-draft.js +187 -18
  22. package/dist/layer2-business/L2-9-contribution/task-proposal-store.js +29 -4
  23. package/dist/ledger.js +1 -1
  24. package/dist/pwa/admin-audit.js +38 -0
  25. package/dist/pwa/anti-abuse-thresholds.js +135 -0
  26. package/dist/pwa/cf-origin-guard.js +33 -0
  27. package/dist/pwa/contract-fingerprint.js +1 -0
  28. package/dist/pwa/data/onboarding-cases.js +2 -2
  29. package/dist/pwa/data/onboarding-quiz.js +1 -1
  30. package/dist/pwa/economic-participation.js +2 -2
  31. package/dist/pwa/integration-contract.js +46 -4
  32. package/dist/pwa/internal/pv-settlement.js +12 -0
  33. package/dist/pwa/internal/wallet-signer.js +26 -0
  34. package/dist/pwa/public/app-account.js +977 -0
  35. package/dist/pwa/public/app-admin.js +608 -0
  36. package/dist/pwa/public/app-agents.js +63 -0
  37. package/dist/pwa/public/app-ai.js +2162 -0
  38. package/dist/pwa/public/app-contribution.js +836 -0
  39. package/dist/pwa/public/app-discover.js +1296 -0
  40. package/dist/pwa/public/app-listings.js +226 -0
  41. package/dist/pwa/public/app-profile.js +1692 -0
  42. package/dist/pwa/public/app-seller.js +199 -0
  43. package/dist/pwa/public/app-shop.js +1145 -0
  44. package/dist/pwa/public/app.js +15075 -23960
  45. package/dist/pwa/public/i18n.js +31 -28
  46. package/dist/pwa/public/index.html +11 -1
  47. package/dist/pwa/public/openapi.json +4851 -2776
  48. package/dist/pwa/pv-kill-switch.js +31 -0
  49. package/dist/pwa/routes/admin-admins.js +48 -1
  50. package/dist/pwa/routes/admin-analytics.js +1 -10
  51. package/dist/pwa/routes/admin-atomic.js +4 -17
  52. package/dist/pwa/routes/admin-operator-claims.js +280 -0
  53. package/dist/pwa/routes/admin-reports.js +4 -26
  54. package/dist/pwa/routes/admin-tokenomics.js +2 -76
  55. package/dist/pwa/routes/admin-users-lifecycle.js +1 -14
  56. package/dist/pwa/routes/admin-users-query.js +23 -1
  57. package/dist/pwa/routes/admin-wallet-ops.js +1 -1
  58. package/dist/pwa/routes/agent-grants.js +255 -0
  59. package/dist/pwa/routes/auth-read.js +1 -5
  60. package/dist/pwa/routes/auth-register.js +3 -13
  61. package/dist/pwa/routes/build-task-quota.js +113 -0
  62. package/dist/pwa/routes/claim-verify.js +15 -11
  63. package/dist/pwa/routes/contribution-facts.js +18 -0
  64. package/dist/pwa/routes/dispute-cases.js +5 -4
  65. package/dist/pwa/routes/growth.js +3 -3
  66. package/dist/pwa/routes/orders-action.js +27 -10
  67. package/dist/pwa/routes/orders-create.js +1 -1
  68. package/dist/pwa/routes/products-meta.js +19 -6
  69. package/dist/pwa/routes/profile-placement.js +1 -1
  70. package/dist/pwa/routes/promoter.js +10 -29
  71. package/dist/pwa/routes/public-build-tasks.js +5 -1
  72. package/dist/pwa/routes/public-utils.js +9 -12
  73. package/dist/pwa/routes/referral.js +5 -26
  74. package/dist/pwa/routes/rewards-apply.js +3 -2
  75. package/dist/pwa/routes/share-redirects.js +1 -1
  76. package/dist/pwa/routes/shareables-interactions.js +2 -1
  77. package/dist/pwa/routes/task-proposals.js +85 -9
  78. package/dist/pwa/routes/users-public.js +1 -4
  79. package/dist/pwa/routes/wallet-read.js +2 -14
  80. package/dist/pwa/routes/webauthn.js +7 -2
  81. package/dist/pwa/server-schema.js +9 -0
  82. package/dist/pwa/server.js +319 -2034
  83. package/dist/runtime/agent-grant-scopes.js +128 -0
  84. package/dist/runtime/agent-grant-verifier.js +67 -0
  85. package/dist/runtime/agent-pairing.js +60 -0
  86. package/dist/runtime/apply-webaz-runtime-schema.js +15 -0
  87. package/dist/runtime/webaz-schema-helpers.js +1848 -0
  88. package/dist/settlement-math.js +3 -3
  89. package/dist/version.js +6 -4
  90. package/package.json +43 -8
  91. package/dist/index.js +0 -182
  92. package/dist/pwa/public/docs/ECONOMIC-MODEL.md +0 -287
  93. package/dist/pwa/public/docs/INTEGRATOR.md +0 -67
  94. package/dist/pwa/public/docs/META-RULES-FULL.md +0 -543
  95. package/dist/test-dispute.js +0 -153
  96. package/dist/test-manifest.js +0 -61
  97. package/dist/test-mcp-tools.js +0 -135
  98. package/dist/test-reputation.js +0 -116
  99. package/dist/test-skill-market.js +0 -101
@@ -0,0 +1,1145 @@
1
+ // WebAZ — Shop Utilities / Storefront domain (classic multi-script split, slice I / app-shop.js)
2
+ //
3
+ // Loaded as a CLASSIC script in this order (index.html):
4
+ // i18n → app-admin → app-contribution → app-ai → app-discover → app-profile → app-account → app-shop → app-listings → app-seller → app.js (source of truth: index.html)
5
+ // Top-level functions / window.* handlers are global; pages run on route/click
6
+ // (after app.js loads), so cross-file globals (GET/POST/PATCH/DELETE/state/shell/
7
+ // escHtml/navigate/t/toast$/skeleton$/productCardHtml/card/pageHeader/
8
+ // refreshAnnouncementsBadge/...) resolve at call time. No import/export.
9
+ //
10
+ // Pure relocation of low-risk storefront utilities: announcements, wishlist,
11
+ // waitlist, address book, my-coupons, push settings, daily check-in/task claim,
12
+ // for-you + product compare, public shop page + shop-edit, editor-picks (public +
13
+ // admin mgmt), and flash-sale (modal/submit/live).
14
+ //
15
+ // Flash-sale create/list only write pricing config (flash-sales.ts); the only
16
+ // order/wallet coupling lives server-side in orders-create.ts (at purchase time),
17
+ // not in these moved handlers — so they are storefront utilities, not the
18
+ // order/settlement/inventory state machine.
19
+ //
20
+ // INTENTIONALLY LEFT in app.js:
21
+ // - group-buy (renderGroupBuysLive/renderGroupBuyDetail/openJoinGroupBuy/
22
+ // submitJoinGroupBuy/leaveGroupBuy): join/leave touch escrow/order/refund
23
+ // (group-buys.ts creates a paid order + wallet escrow on join, refunds on
24
+ // leave) — a money/order/status path, NOT a plain shop utility. Defer to a
25
+ // dedicated group-buy / money-path PR.
26
+ // - cart/orders/order-detail/payment/wallet/dispute/return/status, auth boot/
27
+ // login/register/recover, the seller workbench (renderSeller/renderEditProduct)
28
+ // incl. renderSellerFlashSales, and the non-listed renderReviewsFeed/
29
+ // renderAnchorEntry/lookupAnchorAction + refreshAnnouncementsBadge (core-nav
30
+ // badge). No UI/behavior change.
31
+
32
+ async function renderAnnouncements(app) {
33
+ if (!state.user) { renderLogin(); return }
34
+ app.innerHTML = shell(loading$(), 'me')
35
+ await refreshAnnouncementsBadge()
36
+ const items = state.announcements || []
37
+ const severityStyle = {
38
+ info: { bg:'#eef2ff', border:'#c7d2fe', color:'#3730a3', icon:'ℹ️' },
39
+ warning: { bg:'#fef3c7', border:'#fbbf24', color:'#92400e', icon:'⚠️' },
40
+ critical: { bg:'#fef2f2', border:'#fecaca', color:'#991b1b', icon:'🚨' },
41
+ }
42
+ const html = items.length === 0
43
+ ? `<div style="text-align:center;padding:40px;color:#9ca3af"><div style="font-size:42px;margin-bottom:8px">📢</div><div style="font-size:13px">${t('暂无公告')}</div></div>`
44
+ : items.map(a => {
45
+ const st = severityStyle[a.severity] || severityStyle.info
46
+ return `
47
+ <div class="card" style="padding:12px;margin-bottom:8px;background:${st.bg};border-color:${st.border}">
48
+ <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
49
+ <div style="flex:1;min-width:0">
50
+ <div style="font-size:14px;font-weight:700;color:${st.color}">${st.icon} ${escHtml(a.title)}</div>
51
+ <div style="font-size:12px;color:${st.color};opacity:0.85;margin-top:4px;white-space:pre-wrap;line-height:1.5">${escHtml(a.body)}</div>
52
+ <div style="font-size:10px;color:${st.color};opacity:0.65;margin-top:4px">${fmtTime(a.created_at)}</div>
53
+ </div>
54
+ ${!a.is_read ? `<button class="btn btn-sm" style="background:rgba(255,255,255,0.6);color:${st.color};border:1px solid ${st.border};font-size:10px;padding:4px 8px;white-space:nowrap" onclick="markAnnouncementRead('${a.id}', this)">${t('知道了')}</button>` : `<span style="font-size:10px;color:${st.color};opacity:0.6;padding:4px 8px">✓ ${t('已读')}</span>`}
55
+ </div>
56
+ </div>`
57
+ }).join('')
58
+ app.innerHTML = shell(`
59
+ <h1 class="page-title">📢 ${t('平台公告')}</h1>
60
+ <div style="font-size:12px;color:#6b7280;margin-bottom:14px">${t('admin 发布的平台规则 / 活动 / 风险提醒')}</div>
61
+ ${html}
62
+ `, 'me')
63
+ }
64
+
65
+ window.markAnnouncementRead = async (id, btn) => {
66
+ await POST(`/announcements/${id}/read`, {})
67
+ if (btn) {
68
+ btn.outerHTML = `<span style="font-size:10px;opacity:0.6;padding:4px 8px">✓ ${t('已读')}</span>`
69
+ }
70
+ if (state.announcementsUnread > 0) state.announcementsUnread--
71
+ if (typeof updateAggregateChatsBadge === 'function') updateAggregateChatsBadge()
72
+ }
73
+
74
+ // Wave A-1 wishlist helpers
75
+ window.toggleWishlist = async (productId, btn) => {
76
+ const inWl = btn.getAttribute('data-in-wl') === '1'
77
+ btn.disabled = true
78
+ const res = inWl
79
+ ? await fetch('/api/wishlist/' + productId, { method: 'DELETE', headers: { Authorization: 'Bearer ' + state.apiKey } }).then(r => r.json())
80
+ : await POST(`/wishlist/${productId}`, {})
81
+ btn.disabled = false
82
+ if (res.error) { alert(res.error); return }
83
+ if (inWl) {
84
+ btn.setAttribute('data-in-wl', '0')
85
+ btn.innerHTML = '❤ ' + t('加入心愿单')
86
+ btn.style.color = ''; btn.style.borderColor = ''
87
+ } else {
88
+ btn.setAttribute('data-in-wl', '1')
89
+ btn.innerHTML = '💗 ' + t('已在心愿单')
90
+ btn.style.color = '#dc2626'; btn.style.borderColor = '#fecaca'
91
+ }
92
+ }
93
+
94
+ async function renderWishlist(app) {
95
+ if (!state.user) { renderLogin(); return }
96
+ app.innerHTML = shell(loading$(), 'me')
97
+ const r = await GET('/wishlist')
98
+ const items = r?.items || []
99
+ const rows = items.length === 0
100
+ ? `<div style="text-align:center;padding:40px;color:#9ca3af"><div style="font-size:48px;margin-bottom:8px">❤</div><div style="font-size:13px">${t('心愿单为空 — 在商品页点 ❤ 加入')}</div></div>`
101
+ : items.map(it => {
102
+ const cur = Number(it.current_price), delta = Number(it.price_delta || 0), pct = Number(it.price_delta_pct || 0)
103
+ const priceTag = delta < 0
104
+ ? `<span style="color:#dc2626;font-weight:700">${cur} WAZ <span style="font-size:11px;background:#fef2f2;color:#991b1b;padding:1px 6px;border-radius:99px;font-weight:600;margin-left:4px">↓${Math.abs(pct).toFixed(0)}%</span></span>`
105
+ : delta > 0
106
+ ? `<span style="color:#374151;font-weight:600">${cur} WAZ <span style="font-size:11px;color:#9ca3af">(${t('已涨')} ${pct.toFixed(0)}%)</span></span>`
107
+ : `<span style="color:#374151;font-weight:600">${cur} WAZ</span>`
108
+ const stockTag = Number(it.stock) === 0
109
+ ? `<span style="background:#f3f4f6;color:#6b7280;font-size:10px;padding:1px 6px;border-radius:4px">${t('缺货')}</span>`
110
+ : ''
111
+ const claimTag = Number(it.claim_loss_count) > 0
112
+ ? `<span style="background:#fef2f2;color:#991b1b;font-size:10px;padding:1px 6px;border-radius:4px">⚠ ${it.claim_loss_count}</span>`
113
+ : ''
114
+ return `
115
+ <div class="card" style="padding:10px 12px;margin-bottom:8px;display:flex;gap:10px;align-items:center;cursor:pointer" onclick="location.hash='#order-product/${it.product_id}'">
116
+ <div style="font-size:32px">${getCategoryIcon(it.category) || '📦'}</div>
117
+ <div style="flex:1;min-width:0">
118
+ <div style="font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(it.title)}</div>
119
+ <div style="display:flex;gap:6px;align-items:center;margin-top:4px;font-size:12px">${priceTag} ${stockTag} ${claimTag}</div>
120
+ ${it.note ? `<div style="font-size:11px;color:#9ca3af;margin-top:2px">📝 ${escHtml(it.note)}</div>` : ''}
121
+ <div style="font-size:10px;color:#9ca3af;margin-top:2px">@${escHtml(it.seller_handle || '')} · ${t('收藏于')} ${fmtTime(it.created_at)}</div>
122
+ </div>
123
+ <button class="btn btn-sm" style="background:none;border:none;color:#dc2626;font-size:16px" onclick="event.stopPropagation(); removeFromWishlist('${it.product_id}', this)" title="${t('移除')}">×</button>
124
+ </div>`
125
+ }).join('')
126
+ app.innerHTML = shell(`
127
+ <h1 class="page-title">❤ ${t('我的心愿单')} <span style="font-size:13px;color:#9ca3af;font-weight:400">(${items.length})</span></h1>
128
+ <div style="font-size:12px;color:#6b7280;margin-bottom:14px">${t('收藏感兴趣的商品 — 价格变动 / 重新有货时会通知你')}</div>
129
+ ${rows}
130
+ `, 'me')
131
+ }
132
+
133
+ window.removeFromWishlist = async (productId, btn) => {
134
+ if (!confirm(t('从心愿单移除?'))) return
135
+ const res = await fetch('/api/wishlist/' + productId, { method: 'DELETE', headers: { Authorization: 'Bearer ' + state.apiKey } }).then(r => r.json())
136
+ if (res.error) { alert(res.error); return }
137
+ btn.closest('.card').remove()
138
+ }
139
+
140
+ // Wave B-2 waitlist helpers
141
+ window.toggleWaitlist = async (productId, btn) => {
142
+ const inWait = btn.getAttribute('data-in-wait') === '1'
143
+ btn.disabled = true
144
+ const res = inWait
145
+ ? await fetch('/api/products/' + productId + '/waitlist', { method: 'DELETE', headers: { Authorization: 'Bearer ' + state.apiKey } }).then(r => r.json())
146
+ : await POST(`/products/${productId}/waitlist`, {})
147
+ btn.disabled = false
148
+ if (res.error) { alert(res.error); return }
149
+ if (inWait) {
150
+ btn.setAttribute('data-in-wait', '0')
151
+ btn.innerHTML = '⏰ ' + t('到货通知我')
152
+ btn.style.color = ''; btn.style.borderColor = ''
153
+ } else {
154
+ btn.setAttribute('data-in-wait', '1')
155
+ btn.innerHTML = '⏰ ' + t('已加入补货提醒')
156
+ btn.style.color = '#0369a1'; btn.style.borderColor = '#bae6fd'
157
+ }
158
+ }
159
+
160
+ async function renderWaitlist(app) {
161
+ if (!state.user) { renderLogin(); return }
162
+ app.innerHTML = shell(loading$(), 'me')
163
+ const r = await GET('/waitlist')
164
+ const items = r?.items || []
165
+ const rows = items.length === 0
166
+ ? `<div style="text-align:center;padding:40px;color:#9ca3af"><div style="font-size:48px;margin-bottom:8px">⏰</div><div style="font-size:13px">${t('补货提醒列表为空 — 在缺货商品页点「到货通知我」加入')}</div></div>`
167
+ : items.map(it => {
168
+ const inStock = Number(it.stock) > 0
169
+ const stockTag = inStock
170
+ ? `<span style="background:#dcfce7;color:#15803d;font-size:10px;padding:1px 6px;border-radius:4px;font-weight:600">${t('已到货')} · ${it.stock}</span>`
171
+ : `<span style="background:#f3f4f6;color:#6b7280;font-size:10px;padding:1px 6px;border-radius:4px">${t('仍缺货')}</span>`
172
+ const notifiedTag = it.notified_at
173
+ ? `<span style="background:#dbeafe;color:#1e40af;font-size:10px;padding:1px 6px;border-radius:4px">✓ ${t('已通知')}</span>`
174
+ : ''
175
+ return `
176
+ <div class="card" style="padding:10px 12px;margin-bottom:8px;display:flex;gap:10px;align-items:center;cursor:pointer" onclick="location.hash='#order-product/${it.product_id}'">
177
+ <div style="font-size:32px">${getCategoryIcon(it.category) || '📦'}</div>
178
+ <div style="flex:1;min-width:0">
179
+ <div style="font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(it.title)}</div>
180
+ <div style="display:flex;gap:6px;align-items:center;margin-top:4px;font-size:12px">
181
+ <span style="color:#374151;font-weight:600">${it.price} WAZ</span>
182
+ ${stockTag}
183
+ ${notifiedTag}
184
+ </div>
185
+ <div style="font-size:10px;color:#9ca3af;margin-top:2px">@${escHtml(it.seller_handle || '')} · ${t('加入于')} ${fmtTime(it.created_at)}</div>
186
+ </div>
187
+ <button class="btn btn-sm" style="background:none;border:none;color:#dc2626;font-size:16px" onclick="event.stopPropagation(); removeFromWaitlist('${it.product_id}', this)" title="${t('移除')}">×</button>
188
+ </div>`
189
+ }).join('')
190
+ app.innerHTML = shell(`
191
+ <h1 class="page-title">⏰ ${t('补货提醒')} <span style="font-size:13px;color:#9ca3af;font-weight:400">(${items.length})</span></h1>
192
+ <div style="font-size:12px;color:#6b7280;margin-bottom:14px">${t('排队等缺货商品回归 — 卖家补货时第一时间通知你')}</div>
193
+ ${rows}
194
+ `, 'me')
195
+ }
196
+
197
+ window.removeFromWaitlist = async (productId, btn) => {
198
+ if (!confirm(t('从补货提醒移除?'))) return
199
+ const res = await fetch('/api/products/' + productId + '/waitlist', { method: 'DELETE', headers: { Authorization: 'Bearer ' + state.apiKey } }).then(r => r.json())
200
+ if (res.error) { alert(res.error); return }
201
+ btn.closest('.card').remove()
202
+ }
203
+
204
+ // Wave C-2: 收货地址簿
205
+ async function renderAddresses(app) {
206
+ if (!state.user) { renderLogin(); return }
207
+ app.innerHTML = shell(loading$(), 'me')
208
+ const r = await GET('/addresses')
209
+ const items = r?.items || []
210
+ const rows = items.length === 0
211
+ ? `<div style="text-align:center;padding:40px;color:#9ca3af"><div style="font-size:48px;margin-bottom:8px">📍</div><div style="font-size:13px">${t('地址簿为空 — 添加常用地址,下单更省事')}</div></div>`
212
+ : items.map(it => `
213
+ <div class="card" style="padding:12px;margin-bottom:8px${it.is_default ? ';border:1px solid #6366f1;background:#eef2ff' : ''}">
214
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
215
+ <div style="display:flex;gap:6px;align-items:center">
216
+ <span style="font-size:13px;font-weight:600">${escHtml(it.label)}</span>
217
+ ${it.is_default ? `<span style="font-size:10px;background:#6366f1;color:#fff;padding:1px 6px;border-radius:99px;font-weight:600">${t('默认')}</span>` : ''}
218
+ </div>
219
+ <div style="display:flex;gap:4px">
220
+ ${!it.is_default ? `<button class="btn btn-outline btn-sm" style="font-size:10px;padding:3px 8px" onclick="setDefaultAddress('${it.id}')">${t('设为默认')}</button>` : ''}
221
+ <button class="btn btn-outline btn-sm" style="font-size:10px;padding:3px 8px" onclick="openAddressModal('${it.id}')">${t('编辑')}</button>
222
+ <button class="btn btn-sm" style="background:none;border:none;color:#dc2626;font-size:14px" onclick="deleteAddress('${it.id}')" title="${t('删除')}">×</button>
223
+ </div>
224
+ </div>
225
+ <div style="font-size:13px;color:#374151">${escHtml(it.recipient)}${it.phone ? ` · ${escHtml(it.phone)}` : ''}</div>
226
+ <div style="font-size:12px;color:#6b7280;margin-top:2px">${it.region ? escHtml(it.region) + ' · ' : ''}${escHtml(it.detail)}</div>
227
+ </div>`).join('')
228
+ app.innerHTML = shell(`
229
+ <h1 class="page-title">📍 ${t('收货地址簿')} <span style="font-size:13px;color:#9ca3af;font-weight:400">(${items.length}/20)</span></h1>
230
+ <div style="font-size:12px;color:#6b7280;margin-bottom:14px">${t('保存常用地址,下单时一键选择')}</div>
231
+ <button class="btn btn-primary btn-sm" style="margin-bottom:12px" onclick="openAddressModal()">+ ${t('添加地址')}</button>
232
+ ${rows}
233
+ `, 'me')
234
+ }
235
+
236
+ window.openAddressModal = async (id) => {
237
+ let cur = { label: '', recipient: '', phone: '', region: '', detail: '', is_default: false }
238
+ if (id) {
239
+ const r = await GET('/addresses')
240
+ cur = (r?.items || []).find(x => x.id === id) || cur
241
+ }
242
+ const html = `
243
+ <div class="js-modal" style="background:rgba(0,0,0,0.6);position:fixed;inset:0;z-index:1000;display:flex;align-items:flex-end;justify-content:center" onclick="this.remove()">
244
+ <div style="background:#fff;width:100%;max-width:560px;border-radius:16px 16px 0 0;padding:20px;max-height:80vh;overflow-y:auto" onclick="event.stopPropagation()">
245
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
246
+ <h2 style="font-size:16px;font-weight:700;margin:0">📍 ${id ? t('编辑地址') : t('添加地址')}</h2>
247
+ <button type="button" aria-label="${t('关闭')}" onclick="this.closest('.js-modal').remove()" style="background:none;border:none;font-size:22px;line-height:1;color:#9ca3af;cursor:pointer;padding:4px 8px">×</button>
248
+ </div>
249
+ <div class="form-group"><label class="form-label">${t('标签')} *</label><input class="form-control" id="adr-label" maxlength="30" value="${escHtml(cur.label)}" placeholder="${t('家 / 公司 / 父母家')}"></div>
250
+ <div class="form-group"><label class="form-label">${t('收件人')} *</label><input class="form-control" id="adr-recipient" maxlength="60" value="${escHtml(cur.recipient)}"></div>
251
+ <div class="form-group"><label class="form-label">${t('电话')}</label><input class="form-control" id="adr-phone" maxlength="30" value="${escHtml(cur.phone || '')}"></div>
252
+ <div class="form-group"><label class="form-label">${t('省/市/区')}</label><input class="form-control" id="adr-region" maxlength="60" value="${escHtml(cur.region || '')}"></div>
253
+ <div class="form-group"><label class="form-label">${t('详细地址')} *</label><input class="form-control" id="adr-detail" maxlength="200" value="${escHtml(cur.detail)}"></div>
254
+ ${!id ? `<label style="font-size:12px;color:#6b7280;display:flex;align-items:center;gap:4px;margin-bottom:8px"><input type="checkbox" id="adr-default" ${cur.is_default ? 'checked' : ''}> ${t('设为默认地址')}</label>` : ''}
255
+ <div id="adr-msg" style="margin:8px 0"></div>
256
+ <div style="display:flex;gap:8px">
257
+ <button class="btn btn-gray" style="flex:1" onclick="this.closest('[style*=position]').remove()">${t('取消')}</button>
258
+ <button class="btn btn-primary" style="flex:1" onclick="submitAddress('${id || ''}')">${t('保存')}</button>
259
+ </div>
260
+ </div>
261
+ </div>
262
+ `
263
+ const div = document.createElement('div')
264
+ div.innerHTML = html
265
+ document.body.appendChild(div.firstElementChild)
266
+ }
267
+
268
+ window.submitAddress = async (id) => {
269
+ const saveBtn = document.querySelector('.js-modal button.btn-primary')
270
+ if (saveBtn?.disabled) return // 防双击 / 防 modal 残留时再次点击
271
+ if (saveBtn) saveBtn.disabled = true
272
+ const body = {
273
+ label: document.getElementById('adr-label').value.trim(),
274
+ recipient: document.getElementById('adr-recipient').value.trim(),
275
+ phone: document.getElementById('adr-phone').value.trim(),
276
+ region: document.getElementById('adr-region').value.trim(),
277
+ detail: document.getElementById('adr-detail').value.trim(),
278
+ }
279
+ if (!body.label || !body.recipient || !body.detail) {
280
+ document.getElementById('adr-msg').innerHTML = alert$('error', t('标签 / 收件人 / 详细地址必填'))
281
+ if (saveBtn) saveBtn.disabled = false
282
+ return
283
+ }
284
+ if (!id) body.is_default = document.getElementById('adr-default')?.checked || false
285
+ const res = id
286
+ ? await fetch('/api/addresses/' + id, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + state.apiKey }, body: JSON.stringify(body) }).then(r => r.json())
287
+ : await POST('/addresses', body)
288
+ if (res.error) {
289
+ document.getElementById('adr-msg').innerHTML = alert$('error', res.error)
290
+ if (saveBtn) saveBtn.disabled = false
291
+ return
292
+ }
293
+ document.querySelector('.js-modal')?.remove()
294
+ toast$(t('已保存'))
295
+ // 不同上下文复用同一个 modal:根据当前路由决定刷新方式
296
+ const h = location.hash || ''
297
+ if (h.startsWith('#order-product/')) {
298
+ // 下单页:仅刷新地址数据 + 重渲染本页(保持折叠态)
299
+ try {
300
+ const r = await GET('/addresses')
301
+ state._addresses = r?.items || []
302
+ const pid = h.split('/')[1]
303
+ if (pid) renderBuyPage(document.getElementById('app'), pid)
304
+ } catch {}
305
+ } else if (h.startsWith('#addresses') || !h) {
306
+ renderAddresses(document.getElementById('app'))
307
+ } else {
308
+ // 其他页面(如 profile)— 仅刷新地址数据,避免改变用户当前位置
309
+ try { const r = await GET('/addresses'); state._addresses = r?.items || [] } catch {}
310
+ }
311
+ }
312
+
313
+ window.setDefaultAddress = async (id) => {
314
+ const res = await fetch('/api/addresses/' + id, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + state.apiKey }, body: JSON.stringify({ is_default: true }) }).then(r => r.json())
315
+ if (res.error) { alert(res.error); return }
316
+ renderAddresses(document.getElementById('app'))
317
+ }
318
+
319
+ window.deleteAddress = async (id) => {
320
+ if (!confirm(t('删除该地址?'))) return
321
+ const res = await fetch('/api/addresses/' + id, { method: 'DELETE', headers: { Authorization: 'Bearer ' + state.apiKey } }).then(r => r.json())
322
+ if (res.error) { alert(res.error); return }
323
+ renderAddresses(document.getElementById('app'))
324
+ }
325
+
326
+ // Wave C-4: 我的优惠券
327
+ async function renderMyCoupons(app) {
328
+ if (!state.user) { renderLogin(); return }
329
+ app.innerHTML = shell(loading$(), 'me')
330
+ const r = await GET('/coupons/available')
331
+ const available = r?.available || []
332
+ const history = r?.history || []
333
+ const fmtDiscount = (c) => c.discount_type === 'percentage'
334
+ ? `${(Number(c.discount_value) * 100).toFixed(0)}% OFF`
335
+ : `-${c.discount_value} WAZ`
336
+ const scopeLabel = (c) => c.scope === 'all'
337
+ ? `🌐 ${t('全平台')}`
338
+ : c.scope === 'shop'
339
+ ? `🏪 @${escHtml(c.seller_handle || '')}`
340
+ : `📦 ${escHtml(c.product_title || '')}`
341
+ const expiry = (c) => c.expires_at
342
+ ? `<span style="color:#d97706">${t('截止')} ${fmtTime(c.expires_at)}</span>`
343
+ : `<span style="color:#16a34a">${t('长期有效')}</span>`
344
+ const usesLeft = (c) => c.max_uses > 0
345
+ ? `<span style="color:#6b7280">${t('剩')} ${Math.max(0, c.max_uses - c.uses_count)} / ${c.max_uses} ${t('次')}</span>`
346
+ : `<span style="color:#6b7280">${t('无限')}</span>`
347
+ const availRows = available.length === 0
348
+ ? `<div style="text-align:center;padding:30px;color:#9ca3af"><div style="font-size:36px">🎟️</div><div style="font-size:13px;margin-top:6px">${t('暂无可用优惠券')}</div></div>`
349
+ : available.map(c => `
350
+ <div class="card" style="padding:12px;margin-bottom:8px;border-left:3px solid #6366f1">
351
+ <div style="display:flex;justify-content:space-between;align-items:center">
352
+ <div style="font-size:18px;font-weight:700;color:#4f46e5">${fmtDiscount(c)}</div>
353
+ <button class="btn btn-outline btn-sm" style="font-size:11px;padding:4px 10px" onclick="copyCouponCode('${escHtml(c.code).replace(/'/g,"&#39;")}')">📋 ${t('复制')}</button>
354
+ </div>
355
+ <div style="font-family:monospace;font-size:14px;font-weight:700;color:#374151;margin-top:4px">${escHtml(c.code)}</div>
356
+ <div style="font-size:12px;color:#6b7280;margin-top:4px">${scopeLabel(c)}</div>
357
+ <div style="font-size:11px;color:#9ca3af;margin-top:4px;display:flex;justify-content:space-between">
358
+ <span>${expiry(c)} · ${usesLeft(c)}</span>
359
+ ${Number(c.min_order_amount) > 0 ? `<span>${t('满')} ${c.min_order_amount} WAZ ${t('可用')}</span>` : ''}
360
+ </div>
361
+ </div>
362
+ `).join('')
363
+ const histRows = history.length === 0
364
+ ? `<div style="text-align:center;padding:20px;color:#9ca3af;font-size:12px">${t('暂无使用记录')}</div>`
365
+ : history.map(h => `
366
+ <div class="card" style="padding:10px 12px;margin-bottom:6px;display:flex;justify-content:space-between;align-items:center;cursor:pointer" onclick="location.hash='#order/${h.order_id}'">
367
+ <div style="flex:1;min-width:0">
368
+ <div style="font-size:13px;color:#374151;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(h.product_title)}</div>
369
+ <div style="font-size:11px;color:#9ca3af">${fmtTime(h.created_at)} · <span style="color:#16a34a">-${h.coupon_discount} WAZ</span></div>
370
+ </div>
371
+ <div style="font-family:monospace;font-size:11px;color:#6b7280">${escHtml(h.code)}</div>
372
+ </div>
373
+ `).join('')
374
+ app.innerHTML = shell(`
375
+ <h1 class="page-title">🎟️ ${t('我的优惠券')}</h1>
376
+ <div style="font-size:12px;color:#6b7280;margin-bottom:14px">${t('点复制后在下单页粘贴优惠码即可使用')}</div>
377
+ <div style="font-size:12px;color:#6b7280;font-weight:600;margin:8px 0 6px">${t('可用优惠')} (${available.length})</div>
378
+ ${availRows}
379
+ <div style="font-size:12px;color:#6b7280;font-weight:600;margin:14px 0 6px">${t('使用历史')} (${history.length})</div>
380
+ ${histRows}
381
+ `, 'me')
382
+ }
383
+
384
+ window.copyCouponCode = async (code) => {
385
+ try { await navigator.clipboard.writeText(code); toast$(t('已复制') + ': ' + code) }
386
+ catch { toast$(t('复制失败'), 'error') }
387
+ }
388
+
389
+ // Wave E-5: PWA Push 订阅设置
390
+ async function renderPushSettings(app) {
391
+ if (!state.user) { renderLogin(); return }
392
+ app.innerHTML = shell(loading$(), 'me')
393
+ const supported = 'serviceWorker' in navigator && 'PushManager' in window
394
+ const permission = supported ? Notification.permission : 'denied'
395
+ const status = await GET('/push/status').catch(() => ({ subscribed: false, vapid_configured: false }))
396
+ const reg = supported ? await navigator.serviceWorker.getRegistration().catch(() => null) : null
397
+ const sub = reg ? await reg.pushManager.getSubscription().catch(() => null) : null
398
+ const localSubscribed = !!sub
399
+
400
+ const supportNote = !supported
401
+ ? `<div style="background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;padding:10px;border-radius:6px;font-size:12px">⚠ ${t('当前浏览器不支持推送通知')}</div>`
402
+ : !status.vapid_configured
403
+ ? `<div style="background:#fef3c7;border:1px solid #fcd34d;color:#92400e;padding:10px;border-radius:6px;font-size:12px">⚠ ${t('管理员尚未配置 VAPID 密钥,推送功能未启用')}</div>`
404
+ : permission === 'denied'
405
+ ? `<div style="background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;padding:10px;border-radius:6px;font-size:12px">⚠ ${t('浏览器已禁用通知 — 需在设置中手动启用')}</div>`
406
+ : ''
407
+
408
+ const canSubscribe = supported && status.vapid_configured && permission !== 'denied'
409
+
410
+ const toggle = `
411
+ <div style="display:flex;justify-content:space-between;align-items:center;padding:14px;background:#fff;border-radius:8px;border:1px solid #e5e7eb">
412
+ <div>
413
+ <div style="font-size:14px;font-weight:600">📲 ${t('启用推送通知')}</div>
414
+ <div style="font-size:11px;color:#6b7280;margin-top:2px">${t('订单更新 / 评价回复 / 心愿单降价 等会在浏览器关闭时也能收到')}</div>
415
+ </div>
416
+ ${localSubscribed
417
+ ? `<button class="btn btn-gray btn-sm" onclick="unsubscribePush()">${t('取消订阅')}</button>`
418
+ : `<button class="btn btn-primary btn-sm" ${canSubscribe ? '' : 'disabled'} onclick="subscribePush()">${t('开启')}</button>`
419
+ }
420
+ </div>
421
+ `
422
+
423
+ app.innerHTML = shell(`
424
+ <button class="btn btn-gray btn-sm" style="width:auto;margin-bottom:10px" onclick="history.back()">${t('← 返回')}</button>
425
+ <h1 class="page-title">🔔 ${t('推送通知设置')}</h1>
426
+ ${supportNote}
427
+ <div style="margin-top:12px">${toggle}</div>
428
+ <div style="margin-top:14px;padding:10px;background:#f9fafb;border-radius:6px;font-size:11px;color:#6b7280;line-height:1.6">
429
+ <div><strong>${t('订阅状态')}</strong>: ${localSubscribed ? '✓ ' + t('已订阅') : t('未订阅')}</div>
430
+ <div><strong>${t('浏览器权限')}</strong>: ${permission}</div>
431
+ <div><strong>${t('当前订阅数')}</strong>: ${status.count || 0} ${t('个设备')}</div>
432
+ </div>
433
+ `, 'me')
434
+ }
435
+
436
+ // urlBase64ToUint8Array — VAPID 公钥编码转换
437
+ function urlBase64ToUint8Array(base64) {
438
+ const padding = '='.repeat((4 - base64.length % 4) % 4)
439
+ const base64Std = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/')
440
+ const raw = atob(base64Std)
441
+ const arr = new Uint8Array(raw.length)
442
+ for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i)
443
+ return arr
444
+ }
445
+
446
+ window.subscribePush = async () => {
447
+ if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
448
+ alert(t('当前浏览器不支持推送通知')); return
449
+ }
450
+ // 申请权限
451
+ const permission = await Notification.requestPermission()
452
+ if (permission !== 'granted') {
453
+ alert(t('需要授权通知权限才能订阅')); return
454
+ }
455
+ // 拿 VAPID 公钥
456
+ const vapidRes = await GET('/push/vapid-public-key')
457
+ if (vapidRes.error) { alert(vapidRes.error); return }
458
+ // 拿 SW registration + subscribe
459
+ const reg = await navigator.serviceWorker.ready
460
+ const sub = await reg.pushManager.subscribe({
461
+ userVisibleOnly: true,
462
+ applicationServerKey: urlBase64ToUint8Array(vapidRes.key),
463
+ }).catch(e => { alert(t('订阅失败') + ': ' + e.message); return null })
464
+ if (!sub) return
465
+ // 发到服务端
466
+ const json = sub.toJSON()
467
+ const res = await POST('/push/subscribe', {
468
+ endpoint: json.endpoint,
469
+ keys: json.keys,
470
+ user_agent: navigator.userAgent.slice(0, 200),
471
+ })
472
+ if (res.error) { alert(res.error); return }
473
+ toast$(t('推送已开启'))
474
+ renderPushSettings(document.getElementById('app'))
475
+ }
476
+
477
+ window.unsubscribePush = async () => {
478
+ if (!confirm(t('确认取消推送订阅?'))) return
479
+ const reg = await navigator.serviceWorker.getRegistration().catch(() => null)
480
+ const sub = reg ? await reg.pushManager.getSubscription().catch(() => null) : null
481
+ if (sub) {
482
+ await fetch('/api/push/subscribe', {
483
+ method: 'DELETE',
484
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + state.apiKey },
485
+ body: JSON.stringify({ endpoint: sub.endpoint }),
486
+ }).catch(() => {})
487
+ await sub.unsubscribe().catch(() => {})
488
+ }
489
+ toast$(t('已取消订阅'))
490
+ renderPushSettings(document.getElementById('app'))
491
+ }
492
+
493
+ // Wave E-4: 签到 / 每日任务
494
+ const TASK_LABEL = () => ({
495
+ first_order: t('首次完成订单'),
496
+ five_orders: t('完成 5 单'),
497
+ first_rating: t('首次提交评价'),
498
+ follow_three: t('关注 3 个卖家'),
499
+ first_review_received: t('收到首条评价'),
500
+ })
501
+
502
+ async function renderCheckin(app) {
503
+ if (!state.user) { renderLogin(); return }
504
+ app.innerHTML = shell(loading$(), 'me')
505
+ // P0-1: 把客户端本地日期传给服务端,避免 UTC 错位
506
+ const localDate = (() => {
507
+ const d = new Date()
508
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
509
+ })()
510
+ const r = await GET(`/checkin/status?local_date=${localDate}`)
511
+ if (r.error) { app.innerHTML = shell(alert$('error', r.error), 'me'); return }
512
+ const labels = TASK_LABEL()
513
+ // 签到卡
514
+ const streakDots = [...Array(7)].map((_, i) => {
515
+ const day = i + 1
516
+ const active = r.current_streak >= day || (r.today_checked_in && r.current_streak >= day)
517
+ return `<div style="flex:1;text-align:center">
518
+ <div style="width:24px;height:24px;border-radius:50%;background:${active ? '#fbbf24' : '#f3f4f6'};color:${active ? '#fff' : '#9ca3af'};font-size:11px;font-weight:600;line-height:24px;margin:0 auto">${day}</div>
519
+ <div style="font-size:9px;color:#9ca3af;margin-top:2px">${day === 7 ? '+5' : '+0.5'}</div>
520
+ </div>`
521
+ }).join('')
522
+ const checkinBtn = r.today_checked_in
523
+ ? `<button class="btn btn-gray" disabled style="width:100%">✓ ${t('今日已签到')} (+${r.today_reward} WAZ)</button>`
524
+ : `<button class="btn btn-primary" style="width:100%" onclick="doCheckin()">📅 ${t('签到')} (+${r.next_reward} WAZ)</button>`
525
+
526
+ // 任务列表
527
+ const taskCards = (r.tasks || []).map(task => {
528
+ const pct = Math.min(100, (task.progress / task.goal) * 100)
529
+ const claimed = !!task.claimed_at
530
+ const canClaim = task.eligible && !claimed
531
+ return `
532
+ <div class="card" style="padding:10px 12px;margin-bottom:6px;${claimed ? 'opacity:0.6' : ''}">
533
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
534
+ <div style="font-size:13px;font-weight:600">${labels[task.key] || task.key}</div>
535
+ ${claimed
536
+ ? `<span style="font-size:11px;color:#16a34a">✓ ${t('已领取')}</span>`
537
+ : canClaim
538
+ ? `<button class="btn btn-primary btn-sm" style="font-size:11px;padding:4px 10px" onclick="claimTask('${task.key}')">${t('领取')} +${task.reward} WAZ</button>`
539
+ : `<span style="font-size:11px;color:#9ca3af">+${task.reward} WAZ</span>`
540
+ }
541
+ </div>
542
+ <div style="height:4px;background:#f3f4f6;border-radius:2px;overflow:hidden">
543
+ <div style="height:100%;width:${pct}%;background:${pct >= 100 ? '#16a34a' : '#6366f1'};transition:width 0.3s"></div>
544
+ </div>
545
+ <div style="font-size:10px;color:#9ca3af;margin-top:2px">${task.progress} / ${task.goal}</div>
546
+ </div>`
547
+ }).join('')
548
+
549
+ app.innerHTML = shell(`
550
+ <h1 class="page-title">🎁 ${t('签到 / 任务')}</h1>
551
+ <div class="card" style="background:linear-gradient(135deg,#fef3c7,#fde68a);padding:16px;margin-bottom:14px">
552
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
553
+ <div style="font-size:14px;font-weight:700;color:#92400e">${t('连续签到')} <span style="font-size:20px">${r.current_streak}</span> ${t('天')}</div>
554
+ <div style="font-size:11px;color:#92400e">${t('每日 +0.5 WAZ · 7 天里程碑额外 +5')}</div>
555
+ </div>
556
+ <div style="display:flex;gap:4px;margin:10px 0">${streakDots}</div>
557
+ ${checkinBtn}
558
+ </div>
559
+
560
+ <div style="font-size:13px;font-weight:600;margin:14px 0 8px">📋 ${t('成长任务')}</div>
561
+ ${taskCards}
562
+ `, 'me')
563
+ }
564
+
565
+ window.doCheckin = async () => {
566
+ // P0-1: 客户端本地日期
567
+ const d = new Date()
568
+ const local_date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
569
+ const res = await POST('/checkin', { local_date })
570
+ if (res.error) { alert(res.error); return }
571
+ const bonus = res.milestone_bonus > 0 ? ` (${t('里程碑')} +${res.milestone_bonus})` : ''
572
+ toast$(`✨ ${t('签到成功')} +${res.reward} WAZ${bonus}`)
573
+ renderCheckin(document.getElementById('app'))
574
+ }
575
+
576
+ window.claimTask = async (key) => {
577
+ const res = await POST(`/tasks/${key}/claim`, {})
578
+ if (res.error) { alert(res.error); return }
579
+ toast$(`🎁 +${res.reward} WAZ`)
580
+ renderCheckin(document.getElementById('app'))
581
+ }
582
+
583
+ // Wave E-3: 为你推荐
584
+ async function renderForYou(app) {
585
+ if (!state.user) { renderLogin(); return }
586
+ app.innerHTML = shell(loading$(), 'discover')
587
+ const r = await GET('/recommendations/me')
588
+ if (r.error) { app.innerHTML = shell(alert$('error', r.error), 'discover'); return }
589
+ const items = r?.items || []
590
+ const sig = r?.signals || {}
591
+ const BUCKET_LABEL = () => ({
592
+ followed: { icon: '👀', label: t('你关注的卖家'), color: '#6366f1' },
593
+ category: { icon: '🎯', label: t('基于你的心愿单'), color: '#dc2626' },
594
+ past_seller: { icon: '🔁', label: t('已购卖家其它商品'), color: '#16a34a' },
595
+ trending: { icon: '🔥', label: t('热门兜底'), color: '#d97706' },
596
+ })
597
+ const labels = BUCKET_LABEL()
598
+ // 按 bucket 分组
599
+ const grouped = {}
600
+ for (const it of items) {
601
+ const b = it._bucket || 'trending'
602
+ if (!grouped[b]) grouped[b] = []
603
+ grouped[b].push(it)
604
+ }
605
+ const order = ['followed', 'category', 'past_seller', 'trending']
606
+ const sections = order.filter(b => grouped[b]?.length > 0).map(b => {
607
+ const meta = labels[b]
608
+ const cards = grouped[b].map(p => {
609
+ let imageUrl = ''
610
+ try { const imgs = typeof p.images === 'string' ? JSON.parse(p.images) : p.images; if (Array.isArray(imgs) && imgs[0]) imageUrl = imgs[0] } catch {}
611
+ return `
612
+ <div class="card" style="padding:8px 10px;margin-bottom:6px;cursor:pointer" onclick="location.hash='#order-product/${p.id}'">
613
+ <div style="display:flex;gap:10px;align-items:center">
614
+ <div style="font-size:24px;flex-shrink:0">${imageUrl ? `<img src="${escHtml(imageUrl)}" style="width:42px;height:42px;border-radius:6px;object-fit:cover">` : getCategoryIcon(p.category) || '📦'}</div>
615
+ <div style="flex:1;min-width:0">
616
+ <div style="font-size:13px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(p.title)}</div>
617
+ <div style="font-size:12px;color:#4f46e5;font-weight:600">${p.price} WAZ <span style="font-size:10px;color:#9ca3af;font-weight:400">· @${escHtml(p.seller_handle || '')}${p.sales_count > 0 ? ' · 🛒 ' + p.sales_count : ''}</span></div>
618
+ </div>
619
+ </div>
620
+ </div>`
621
+ }).join('')
622
+ return `
623
+ <div style="margin-bottom:14px">
624
+ <div style="font-size:12px;color:${meta.color};font-weight:600;margin-bottom:6px">${meta.icon} ${meta.label} (${grouped[b].length})</div>
625
+ ${cards}
626
+ </div>
627
+ `
628
+ }).join('')
629
+ const signalSummary = `<div style="font-size:11px;color:#9ca3af;margin-bottom:12px">📡 ${t('信号')}: ${t('心愿单类目')} ${sig.wishlist_categories?.length || 0} · ${t('关注卖家')} ${sig.followed_sellers || 0} · ${t('历史购买')} ${sig.past_purchases || 0}</div>`
630
+ app.innerHTML = shell(`
631
+ <h1 class="page-title">✨ ${t('为你推荐')}</h1>
632
+ ${signalSummary}
633
+ ${items.length === 0 ? emptyState('✨', t('暂无推荐 — 浏览商品后系统会学习你的喜好'), { label: t('去逛逛'), hash: '#discover' }) : sections}
634
+ `, 'discover')
635
+ }
636
+
637
+ // Wave E-2: 商品对比 — buyer 选 2-4 件并排对比
638
+ function getCompareList() {
639
+ try { return JSON.parse(localStorage.getItem('webaz_compare') || '[]') } catch { return [] }
640
+ }
641
+ function setCompareList(arr) {
642
+ try { localStorage.setItem('webaz_compare', JSON.stringify(arr.slice(0, 4))) } catch {}
643
+ refreshCompareBadge()
644
+ }
645
+ function refreshCompareBadge() {
646
+ const list = getCompareList()
647
+ const badge = document.getElementById('compare-fab')
648
+ if (!badge) return
649
+ if (list.length === 0) { badge.style.display = 'none'; return }
650
+ badge.style.display = 'flex'
651
+ badge.innerHTML = `📊 ${t('对比')} (${list.length})`
652
+ }
653
+
654
+ window.toggleCompare = (productId, btn) => {
655
+ const list = getCompareList()
656
+ const idx = list.indexOf(productId)
657
+ if (idx >= 0) {
658
+ list.splice(idx, 1)
659
+ setCompareList(list)
660
+ if (btn) { btn.innerHTML = '📊 ' + t('加入对比'); btn.dataset.added = '0'; btn.style.color = ''; btn.style.borderColor = '' }
661
+ } else {
662
+ if (list.length >= 4) { toast$(t('最多对比 4 件商品')); return }
663
+ list.push(productId)
664
+ setCompareList(list)
665
+ if (btn) { btn.innerHTML = '✓ ' + t('已加入对比'); btn.dataset.added = '1'; btn.style.color = '#4f46e5'; btn.style.borderColor = '#c7d2fe' }
666
+ toast$(t('已加入对比') + ` (${list.length}/4)`)
667
+ }
668
+ }
669
+
670
+ window.openCompare = () => {
671
+ const list = getCompareList()
672
+ if (list.length === 0) { toast$(t('请先加入商品到对比')); return }
673
+ if (list.length === 1) { toast$(t('至少 2 件商品才能对比')); return }
674
+ navigate('#compare/' + list.join(','))
675
+ }
676
+
677
+ window.clearCompare = () => {
678
+ setCompareList([])
679
+ navigate('#discover')
680
+ }
681
+
682
+ async function renderCompare(app, ids) {
683
+ const idList = (ids || '').split(',').filter(Boolean).slice(0, 4)
684
+ if (idList.length < 2) { app.innerHTML = shell(`<div class="empty">${t('至少 2 件商品才能对比')}</div>`, 'discover'); return }
685
+ app.innerHTML = shell(loading$(), 'discover')
686
+ // 拉所有商品 + 评价 + flash sale
687
+ const products = await GET('/products')
688
+ const items = idList.map(id => products.find(p => p.id === id)).filter(Boolean)
689
+ if (items.length < 2) { app.innerHTML = shell(alert$('error', t('部分商品不存在或已下架')), 'discover'); return }
690
+ const ratings = await Promise.all(items.map(p => GET(`/products/${p.id}/ratings?limit=1`).catch(() => ({ agg: null }))))
691
+ const flashes = await Promise.all(items.map(p => GET(`/products/${p.id}/flash-sale`).catch(() => ({ sale: null }))))
692
+
693
+ const rows = [
694
+ { label: t('商品'), render: (p) => `<a href="#order-product/${p.id}" style="font-size:13px;font-weight:600;color:#374151;display:block;overflow:hidden;text-overflow:ellipsis">${escHtml(p.title)}</a>` },
695
+ { label: t('价格'), render: (p, i) => {
696
+ const sale = flashes[i]?.sale
697
+ if (sale) return `<span style="color:#dc2626;font-weight:700">${sale.sale_price}</span> WAZ<br><span style="font-size:10px;color:#9ca3af;text-decoration:line-through">${p.price}</span>`
698
+ return `<span style="color:#4f46e5;font-weight:700">${p.price}</span> WAZ`
699
+ } },
700
+ { label: t('库存'), render: (p) => `<span style="color:${Number(p.stock) === 0 ? '#dc2626' : Number(p.stock) <= 3 ? '#d97706' : '#374151'}">${p.stock}</span>` },
701
+ { label: t('卖家'), render: (p) => `<a href="#shop/${p.seller_id}" style="font-size:11px;color:#6366f1">@${escHtml(p.seller_name || '')}</a>` },
702
+ { label: t('评价'), render: (p, i) => {
703
+ const agg = ratings[i]?.agg
704
+ if (!agg || Number(agg.cnt) === 0) return `<span style="color:#9ca3af">—</span>`
705
+ return `${Number(agg.avg_stars).toFixed(1)} ⭐<br><span style="font-size:10px;color:#9ca3af">(${agg.cnt})</span>`
706
+ } },
707
+ { label: t('类目'), render: (p) => `<span style="font-size:11px;color:#6b7280">${escHtml(p.category || '—')}</span>` },
708
+ { label: t('退货天数'), render: (p) => Number(p.return_days || 0) > 0 ? `${p.return_days} ${t('天')}` : `<span style="color:#9ca3af">${t('不支持')}</span>` },
709
+ { label: t('发货时效'), render: (p) => Number(p.handling_hours || 0) > 0 ? `${p.handling_hours}h` : '—' },
710
+ { label: t('质保'), render: (p) => Number(p.warranty_days || 0) > 0 ? `${p.warranty_days} ${t('天')}` : '—' },
711
+ { label: t('规格'), render: (p) => Number(p.has_variants) === 1 ? `✓ ${t('多规格')}` : `—` },
712
+ { label: t('佣金'), render: (p) => Number(p.commission_rate) > 0 ? `${(Number(p.commission_rate) * 100).toFixed(0)}%` : '—' },
713
+ ]
714
+
715
+ const colCount = items.length
716
+ const colWidth = `${Math.floor(100 / (colCount + 1))}%`
717
+ const tableHtml = `
718
+ <div style="overflow-x:auto;background:#fff;border-radius:8px;border:1px solid #e5e7eb">
719
+ <table style="width:100%;border-collapse:collapse;font-size:12px;table-layout:fixed">
720
+ <thead>
721
+ <tr style="background:#f9fafb;border-bottom:1px solid #e5e7eb">
722
+ <th style="padding:8px;text-align:left;width:${colWidth};font-weight:600;color:#6b7280">${t('属性')}</th>
723
+ ${items.map(p => `<th style="padding:8px;text-align:left;width:${colWidth};border-left:1px solid #e5e7eb">
724
+ <button onclick="removeFromCompare('${p.id}')" style="background:none;border:none;color:#9ca3af;font-size:14px;float:right;cursor:pointer" title="${t('移除')}">×</button>
725
+ </th>`).join('')}
726
+ </tr>
727
+ </thead>
728
+ <tbody>
729
+ ${rows.map(r => `
730
+ <tr style="border-bottom:1px solid #f3f4f6">
731
+ <td style="padding:8px;color:#6b7280;font-weight:600">${r.label}</td>
732
+ ${items.map((p, i) => `<td style="padding:8px;border-left:1px solid #f3f4f6;word-wrap:break-word">${r.render(p, i)}</td>`).join('')}
733
+ </tr>`).join('')}
734
+ </tbody>
735
+ </table>
736
+ </div>
737
+ `
738
+
739
+ app.innerHTML = shell(`
740
+ <button class="btn btn-gray btn-sm" style="width:auto;margin-bottom:10px" onclick="history.back()">${t('← 返回')}</button>
741
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
742
+ <h1 class="page-title" style="margin:0">📊 ${t('商品对比')}</h1>
743
+ <div style="display:flex;gap:6px">
744
+ <button class="btn btn-outline btn-sm" style="font-size:11px;padding:4px 10px" onclick="shareCompareUrl('${idList.join(',')}')">🔗 ${t('分享对比')}</button>
745
+ <button class="btn btn-outline btn-sm" style="font-size:11px;padding:4px 10px;color:#dc2626" onclick="clearCompare()">${t('清空')}</button>
746
+ </div>
747
+ </div>
748
+ ${tableHtml}
749
+ `, 'discover')
750
+ }
751
+
752
+ window.removeFromCompare = (productId) => {
753
+ const list = getCompareList().filter(id => id !== productId)
754
+ setCompareList(list)
755
+ if (list.length < 2) { toast$(t('至少 2 件商品')); navigate('#discover'); return }
756
+ navigate('#compare/' + list.join(','))
757
+ }
758
+
759
+ window.shareCompareUrl = async (ids) => {
760
+ const url = `${location.origin}/#compare/${ids}`
761
+ try { await navigator.clipboard.writeText(url); toast$(t('链接已复制')) }
762
+ catch { toast$(t('复制失败'), 'error') }
763
+ }
764
+
765
+ // Wave E-1: 商家店铺主页
766
+ async function renderShopPage(app, identifier) {
767
+ if (!identifier) { navigate('#discover'); return }
768
+ app.innerHTML = shell(loading$(), 'discover')
769
+ const r = await GET(`/shops/${encodeURIComponent(identifier)}`)
770
+ if (r.error) { app.innerHTML = shell(alert$('error', r.error), 'discover'); return }
771
+ const { seller, stats, products, recent_ratings, is_following } = r
772
+ const isOwnShop = state.user?.id === seller.id
773
+ const ratingDisplay = stats.rating_avg != null
774
+ ? `⭐ ${Number(stats.rating_avg).toFixed(1)} <span style="font-size:11px;color:#9ca3af">(${stats.rating_count})</span>`
775
+ : `<span style="font-size:11px;color:#9ca3af">${t('暂无评价')}</span>`
776
+ const banner = seller.shop_banner_url
777
+ ? `<div style="height:140px;background:url('${escHtml(seller.shop_banner_url)}') center/cover;border-radius:8px;margin-bottom:12px"></div>`
778
+ : `<div style="height:80px;background:linear-gradient(135deg,#7c2d12,#9a3412);border-radius:8px;margin-bottom:12px"></div>`
779
+ const followBtn = state.user && !isOwnShop && state.user.role === 'buyer'
780
+ ? `<button class="btn btn-${is_following ? 'gray' : 'primary'} btn-sm" style="width:auto" id="shop-follow-btn" data-following="${is_following ? '1' : '0'}" onclick="toggleShopFollow('${seller.id}', this)">${is_following ? '✓ ' + t('已关注') : '+ ' + t('关注')}</button>`
781
+ : ''
782
+ // 推荐店铺:只锚定推荐关系/二叉树位置/店铺来源 —— 不是全店佣金权;商品分润仍要求推荐人真实成交过同款
783
+ const shopReferralBtn = state.user?.permanent_code
784
+ ? `<button class="btn btn-outline btn-sm" style="width:auto;font-size:11px" title="${t('店铺推荐只锚定推荐关系;只有你真实成交过的同款商品,后续成交才可能形成商品推荐关系')}" onclick="copyShopReferralLink('${seller.id}')">🔗 ${t('推荐店铺')}</button>`
785
+ : ''
786
+ const productCards = products.length === 0
787
+ ? `<div style="text-align:center;padding:30px;color:#9ca3af;font-size:13px">${t('该卖家暂无商品')}</div>`
788
+ : products.map(p => {
789
+ let imageUrl = ''
790
+ try { const imgs = typeof p.images === 'string' ? JSON.parse(p.images) : p.images; if (Array.isArray(imgs) && imgs[0]) imageUrl = imgs[0] } catch {}
791
+ return `
792
+ <div class="card" style="padding:10px 12px;cursor:pointer" onclick="location.hash='#order-product/${p.id}'">
793
+ <div style="display:flex;gap:10px;align-items:center">
794
+ <div style="font-size:28px;flex-shrink:0">${imageUrl ? `<img src="${escHtml(imageUrl)}" style="width:42px;height:42px;border-radius:6px;object-fit:cover">` : getCategoryIcon(p.category) || '📦'}</div>
795
+ <div style="flex:1;min-width:0">
796
+ <div style="font-size:13px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(p.title)}</div>
797
+ <div style="font-size:12px;color:#4f46e5;font-weight:600">${p.price} WAZ <span style="font-size:10px;color:#9ca3af;font-weight:400">· ${stockBadgeHtml(p)}${p.sales_count > 0 ? ' · 🛒 ' + p.sales_count : ''}</span></div>
798
+ </div>
799
+ </div>
800
+ </div>`
801
+ }).join('')
802
+ const ratingItems = recent_ratings.length === 0
803
+ ? ''
804
+ : `<details style="margin-top:14px;background:#fff;border:1px solid #e5e7eb;border-radius:8px">
805
+ <summary style="padding:10px 12px;font-size:13px;font-weight:600;cursor:pointer">⭐ ${t('最近评价')} (${recent_ratings.length})</summary>
806
+ <div style="padding:0 12px 12px">
807
+ ${recent_ratings.map(rr => `
808
+ <div style="padding:8px 0;border-bottom:1px solid #f3f4f6">
809
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:2px">
810
+ <span style="font-size:13px">${'⭐'.repeat(Number(rr.stars))}${'☆'.repeat(5 - Number(rr.stars))}</span>
811
+ <span style="font-size:11px;color:#9ca3af">${fmtTime(rr.created_at)}</span>
812
+ </div>
813
+ <div style="font-size:11px;color:#9ca3af;margin-bottom:2px">${escHtml(rr.product_title)} · @${escHtml(rr.buyer_handle || '')}</div>
814
+ ${rr.comment ? `<div style="font-size:12px;color:#374151">${escHtml(rr.comment)}</div>` : ''}
815
+ </div>`).join('')}
816
+ </div>
817
+ </details>`
818
+ app.innerHTML = shell(`
819
+ <button class="btn btn-gray btn-sm" style="width:auto;margin-bottom:10px" onclick="history.back()">${t('← 返回')}</button>
820
+ ${banner}
821
+ <div class="card" style="padding:14px;margin-bottom:12px">
822
+ <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px">
823
+ <div style="flex:1;min-width:0">
824
+ <div style="font-size:18px;font-weight:700">${escHtml(seller.name || seller.handle)}</div>
825
+ <div style="font-size:12px;color:#6b7280">@${escHtml(seller.handle || '')} · ${ratingDisplay}</div>
826
+ <a href="#u/${seller.id}" style="font-size:12px;color:#6366f1;text-decoration:none;display:inline-block;margin-top:4px">${t('完整主页 · 笔记 / 测评 / 二手 / 拍卖')} →</a>
827
+ ${seller.bio ? `<div style="font-size:13px;color:#374151;margin-top:6px">${escHtml(seller.bio)}</div>` : ''}
828
+ </div>
829
+ <div style="display:flex;flex-direction:column;gap:6px;align-items:flex-end">
830
+ ${followBtn}
831
+ ${shopReferralBtn}
832
+ ${isOwnShop ? `<a href="#shop-edit" style="font-size:11px;color:#6366f1">${t('编辑店铺')} →</a>` : ''}
833
+ </div>
834
+ </div>
835
+ <div style="display:flex;gap:12px;margin-top:10px;padding-top:10px;border-top:1px solid #f3f4f6;font-size:11px;color:#6b7280">
836
+ <span><strong style="color:#374151">${stats.products}</strong> ${t('商品')}</span>
837
+ <span><strong style="color:#374151">${stats.followers}</strong> ${t('关注者')}</span>
838
+ <span><strong style="color:#374151">${stats.completed_orders}</strong> ${t('已成交')}</span>
839
+ </div>
840
+ </div>
841
+ ${seller.shop_intro ? `<div class="card" style="padding:12px;margin-bottom:12px;font-size:13px;color:#374151;white-space:pre-wrap">${escHtml(seller.shop_intro)}</div>` : ''}
842
+ <div style="font-size:13px;font-weight:600;margin:14px 0 8px">📦 ${t('店内商品')}</div>
843
+ <div style="display:grid;gap:6px">${productCards}</div>
844
+ ${ratingItems}
845
+ `, 'discover')
846
+ }
847
+
848
+ // 复制店铺推荐链接 — /?ref=CODE#shop/<seller>(target URL 形态:ref 在 query,目标页在 hash,服务端可见 ref)。
849
+ // 只用 permanent_code,绝不用 usr_xxx;诚实文案:不暗示"分享店铺即可获得全店佣金"。
850
+ window.copyShopReferralLink = (sellerId) => {
851
+ const code = state.user?.permanent_code
852
+ if (!code) return alert(t('邀请码暂不可用,请刷新或联系支持'))
853
+ const link = `${location.origin}/?ref=${code}#shop/${sellerId}`
854
+ copyText(link).then(ok => toast$(ok
855
+ ? t('店铺推荐链接已复制 — 商品分润仍需你真实成交过同款并 opt-in')
856
+ : t('复制失败,请手动复制'), ok ? 'success' : 'error'))
857
+ }
858
+
859
+ window.toggleShopFollow = async (sellerId, btn) => {
860
+ const following = btn.dataset.following === '1'
861
+ btn.disabled = true
862
+ const res = following
863
+ ? await fetch('/api/follows/' + sellerId, { method: 'DELETE', headers: { Authorization: 'Bearer ' + state.apiKey } }).then(r => r.json())
864
+ : await POST(`/follows/${sellerId}`, {})
865
+ btn.disabled = false
866
+ if (res.error) { alert(res.error); return }
867
+ if (following) {
868
+ btn.dataset.following = '0'; btn.className = 'btn btn-primary btn-sm'
869
+ btn.innerHTML = '+ ' + t('关注'); btn.style.width = 'auto'
870
+ } else {
871
+ btn.dataset.following = '1'; btn.className = 'btn btn-gray btn-sm'
872
+ btn.innerHTML = '✓ ' + t('已关注'); btn.style.width = 'auto'
873
+ }
874
+ }
875
+
876
+ // 卖家编辑自己店铺
877
+ async function renderShopEdit(app) {
878
+ if (!state.user || state.user.role !== 'seller') { app.innerHTML = shell(`<div class="empty">${t('仅卖家可访问')}</div>`, 'me'); return }
879
+ app.innerHTML = shell(loading$(), 'me')
880
+ const r = await GET(`/shops/@${state.user.handle}`)
881
+ if (r.error) { app.innerHTML = shell(alert$('error', r.error), 'me'); return }
882
+ const s = r.seller
883
+ app.innerHTML = shell(`
884
+ <button class="btn btn-gray btn-sm" style="width:auto;margin-bottom:10px" onclick="history.back()">${t('← 返回')}</button>
885
+ <h1 class="page-title">🏪 ${t('编辑店铺')}</h1>
886
+ <div class="card">
887
+ <div class="form-group">
888
+ <label class="form-label">${t('一句话简介')} <span style="font-size:10px;color:#9ca3af">(bio · ${t('最多 200 字')})</span></label>
889
+ <input class="form-control" id="shop-bio" maxlength="200" value="${escHtml(s.bio || '')}" placeholder="${t('告诉买家你是谁,卖什么')}">
890
+ </div>
891
+ <div class="form-group">
892
+ <label class="form-label">${t('店铺横幅图片 URL')} <span style="font-size:10px;color:#9ca3af">(${t('可选')})</span></label>
893
+ <input class="form-control" id="shop-banner" maxlength="500" value="${escHtml(s.shop_banner_url || '')}" placeholder="https://...">
894
+ <div style="font-size:10px;color:#9ca3af;margin-top:2px">${t('建议比例 16:9,宽度 ≥ 600px')}</div>
895
+ </div>
896
+ <div class="form-group">
897
+ <label class="form-label">${t('详细店铺介绍')} <span style="font-size:10px;color:#9ca3af">(${t('多段,最多 2000 字')})</span></label>
898
+ <textarea class="form-control" id="shop-intro" maxlength="2000" rows="6" placeholder="${t('品牌故事 / 履约承诺 / 退换说明...')}">${escHtml(s.shop_intro || '')}</textarea>
899
+ </div>
900
+ <div id="shop-msg" style="margin:8px 0"></div>
901
+ <button class="btn btn-primary" onclick="submitShopEdit()">${t('保存')}</button>
902
+ </div>
903
+ <div style="margin-top:10px;font-size:11px;color:#9ca3af;text-align:center">
904
+ ${t('店铺主页公开链接')}: <a href="#shop/@${state.user.handle}" style="color:#6366f1">#shop/@${state.user.handle}</a>
905
+ </div>
906
+ `, 'me')
907
+ }
908
+
909
+ window.submitShopEdit = async () => {
910
+ const bio = document.getElementById('shop-bio').value.trim()
911
+ const shop_banner_url = document.getElementById('shop-banner').value.trim()
912
+ const shop_intro = document.getElementById('shop-intro').value.trim()
913
+ const msg = document.getElementById('shop-msg')
914
+ msg.innerHTML = loading$()
915
+ const res = await fetch('/api/shops/me', {
916
+ method: 'PATCH',
917
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + state.apiKey },
918
+ body: JSON.stringify({ bio, shop_banner_url, shop_intro }),
919
+ }).then(r => r.json())
920
+ if (res.error) { msg.innerHTML = alert$('error', res.error); return }
921
+ msg.innerHTML = alert$('success', t('已保存'))
922
+ setTimeout(() => navigate(`#shop/@${state.user.handle}`), 800)
923
+ }
924
+
925
+ // B-4: 编辑精选 — 公开
926
+ async function renderEditorPicks(app) {
927
+ app.innerHTML = shell(loading$(), 'discover')
928
+ const r = await GET('/editor-picks')
929
+ const products = r?.products || []
930
+ const sellers = r?.sellers || []
931
+ const productCards = products.map(p => {
932
+ let imageUrl = ''
933
+ try { const imgs = typeof p.images === 'string' ? JSON.parse(p.images) : p.images; if (Array.isArray(imgs) && imgs[0]) imageUrl = imgs[0] } catch {}
934
+ return `
935
+ <div class="card" style="padding:12px;margin-bottom:8px;cursor:pointer;border-left:3px solid #d97706" onclick="location.hash='#order-product/${p.target_id}'">
936
+ ${p.title ? `<div style="font-size:11px;color:#d97706;font-weight:600;margin-bottom:4px">📌 ${escHtml(p.title)}</div>` : ''}
937
+ <div style="display:flex;gap:10px;align-items:center">
938
+ <div style="font-size:32px;flex-shrink:0">${imageUrl ? `<img src="${escHtml(imageUrl)}" style="width:48px;height:48px;border-radius:6px;object-fit:cover">` : getCategoryIcon(p.category) || '📦'}</div>
939
+ <div style="flex:1;min-width:0">
940
+ <div style="font-size:13px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(p.product_title)}</div>
941
+ <div style="font-size:12px;color:#4f46e5;font-weight:600">${p.price} WAZ · @${escHtml(p.seller_handle || '')}</div>
942
+ </div>
943
+ </div>
944
+ ${p.note ? `<div style="font-size:11px;color:#374151;margin-top:6px;padding:6px 8px;background:#fef3c7;border-radius:6px">${escHtml(p.note)}</div>` : ''}
945
+ </div>`
946
+ }).join('')
947
+ const sellerCards = sellers.map(s => `
948
+ <div class="card" style="padding:12px;margin-bottom:8px;cursor:pointer;border-left:3px solid #6366f1" onclick="location.hash='#shop/${s.target_id}'">
949
+ ${s.title ? `<div style="font-size:11px;color:#6366f1;font-weight:600;margin-bottom:4px">⭐ ${escHtml(s.title)}</div>` : ''}
950
+ <div style="font-size:13px;font-weight:600">@${escHtml(s.handle || '')}</div>
951
+ ${s.bio ? `<div style="font-size:12px;color:#6b7280;margin-top:2px">${escHtml(s.bio)}</div>` : ''}
952
+ ${s.note ? `<div style="font-size:11px;color:#374151;margin-top:6px;padding:6px 8px;background:#eef2ff;border-radius:6px">${escHtml(s.note)}</div>` : ''}
953
+ </div>
954
+ `).join('')
955
+
956
+ app.innerHTML = shell(`
957
+ <h1 class="page-title">📌 ${t('每周精选')}</h1>
958
+ <div style="font-size:12px;color:#6b7280;margin-bottom:14px">${t('编辑团手挑 · 好物 / 优秀卖家')}</div>
959
+ ${products.length === 0 && sellers.length === 0 ? `<div style="text-align:center;padding:40px;color:#9ca3af"><div style="font-size:48px">📌</div><div style="font-size:13px;margin-top:8px">${t('本期暂无精选')}</div></div>` : ''}
960
+ ${products.length > 0 ? `<div style="font-size:13px;font-weight:600;margin:14px 0 8px">📦 ${t('精选商品')}</div>${productCards}` : ''}
961
+ ${sellers.length > 0 ? `<div style="font-size:13px;font-weight:600;margin:14px 0 8px">🏪 ${t('精选卖家')}</div>${sellerCards}` : ''}
962
+ `, 'discover')
963
+ }
964
+
965
+ // B-4: admin 管理精选
966
+ async function renderAdminEditorPicks(app) {
967
+ if (!isAdmin()) { app.innerHTML = shell(`<div class="alert alert-info">${t('仅限管理员')}</div>`, 'admin'); return }
968
+ app.innerHTML = shell(loading$(), 'admin')
969
+ const r = await GET('/admin/editor-picks')
970
+ const items = r?.items || []
971
+ const rows = items.length === 0
972
+ ? `<div style="color:#9ca3af;text-align:center;padding:30px">${t('暂无精选记录')}</div>`
973
+ : items.map(it => {
974
+ const active = new Date(it.starts_at) <= new Date() && new Date(it.ends_at) > new Date()
975
+ return `
976
+ <div class="card" style="padding:10px 12px;margin-bottom:6px;${active ? 'border-left:3px solid #16a34a' : ''}">
977
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">
978
+ <div style="font-size:13px;font-weight:600">${it.kind === 'product' ? '📦' : '🏪'} ${escHtml(it.title || it.target_id)}</div>
979
+ <button class="btn btn-sm" style="background:none;border:none;color:#dc2626;font-size:14px" onclick="deleteEditorPick('${it.id}')" title="${t('删除')}">×</button>
980
+ </div>
981
+ <div style="font-size:11px;color:#9ca3af">${it.kind} · ${it.target_id.slice(0, 12)}… · sort ${it.sort_order} · ${fmtTime(it.starts_at)} → ${fmtTime(it.ends_at)}</div>
982
+ ${it.note ? `<div style="font-size:11px;color:#374151;margin-top:4px">${escHtml(it.note)}</div>` : ''}
983
+ </div>`
984
+ }).join('')
985
+ app.innerHTML = shell(`
986
+ <button class="btn btn-gray btn-sm" style="width:auto;margin-bottom:10px" onclick="history.back()">${t('← 返回')}</button>
987
+ <h1 class="page-title">📌 ${t('编辑精选管理')}</h1>
988
+ <button class="btn btn-primary btn-sm" style="margin-bottom:12px" onclick="openAddEditorPick()">+ ${t('添加精选')}</button>
989
+ ${rows}
990
+ `, 'admin')
991
+ }
992
+
993
+ window.openAddEditorPick = () => {
994
+ const now = new Date()
995
+ const defaultEnd = new Date(now.getTime() + 7 * 86400_000).toISOString().slice(0, 16)
996
+ const defaultStart = now.toISOString().slice(0, 16)
997
+ const html = `
998
+ <div class="js-modal" style="background:rgba(0,0,0,0.6);position:fixed;inset:0;z-index:1000;display:flex;align-items:flex-end;justify-content:center" onclick="this.remove()">
999
+ <div style="background:#fff;width:100%;max-width:560px;border-radius:16px 16px 0 0;padding:20px;max-height:80vh;overflow-y:auto" onclick="event.stopPropagation()">
1000
+ <h2 style="font-size:16px;font-weight:700;margin-bottom:12px">📌 ${t('添加精选')}</h2>
1001
+ <div class="form-group">
1002
+ <label class="form-label">${t('类型')} *</label>
1003
+ <select class="form-control" id="ep-kind">
1004
+ <option value="product">📦 ${t('商品')}</option>
1005
+ <option value="seller">🏪 ${t('卖家')}</option>
1006
+ </select>
1007
+ </div>
1008
+ <div class="form-group"><label class="form-label">${t('目标 ID')} *</label><input class="form-control" id="ep-target" placeholder="p_xxx 或 u_xxx"></div>
1009
+ <div class="form-group"><label class="form-label">${t('推荐语')}</label><input class="form-control" id="ep-title" maxlength="100"></div>
1010
+ <div class="form-group"><label class="form-label">${t('详细说明')}</label><textarea class="form-control" id="ep-note" rows="2" maxlength="500"></textarea></div>
1011
+ <div class="form-group"><label class="form-label">${t('开始时间')}</label><input class="form-control" id="ep-start" type="datetime-local" value="${defaultStart}"></div>
1012
+ <div class="form-group"><label class="form-label">${t('结束时间')}</label><input class="form-control" id="ep-end" type="datetime-local" value="${defaultEnd}"></div>
1013
+ <div class="form-group"><label class="form-label">${t('排序值')} <span style="font-size:10px;color:#9ca3af">${t('小的在前')}</span></label><input class="form-control" id="ep-sort" type="number" value="0"></div>
1014
+ <div id="ep-msg" style="margin:8px 0"></div>
1015
+ <div style="display:flex;gap:8px">
1016
+ <button class="btn btn-gray" style="flex:1" onclick="this.closest('[style*=position]').remove()">${t('取消')}</button>
1017
+ <button class="btn btn-primary" style="flex:1" onclick="submitEditorPick()">${t('保存')}</button>
1018
+ </div>
1019
+ </div>
1020
+ </div>
1021
+ `
1022
+ const div = document.createElement('div')
1023
+ div.innerHTML = html
1024
+ document.body.appendChild(div.firstElementChild)
1025
+ }
1026
+
1027
+ window.submitEditorPick = async () => {
1028
+ const body = {
1029
+ kind: document.getElementById('ep-kind').value,
1030
+ target_id: document.getElementById('ep-target').value.trim(),
1031
+ title: document.getElementById('ep-title').value.trim(),
1032
+ note: document.getElementById('ep-note').value.trim(),
1033
+ starts_at: new Date(document.getElementById('ep-start').value).toISOString(),
1034
+ ends_at: new Date(document.getElementById('ep-end').value).toISOString(),
1035
+ sort_order: Number(document.getElementById('ep-sort').value) || 0,
1036
+ }
1037
+ if (!body.target_id) { document.getElementById('ep-msg').innerHTML = alert$('error', t('目标 ID 必填')); return }
1038
+ const res = await POST('/admin/editor-picks', body)
1039
+ if (res.error) { document.getElementById('ep-msg').innerHTML = alert$('error', res.error); return }
1040
+ document.querySelector('.js-modal')?.remove()
1041
+ toast$(t('已添加'))
1042
+ renderAdminEditorPicks(document.getElementById('app'))
1043
+ }
1044
+
1045
+ window.deleteEditorPick = async (id) => {
1046
+ if (!confirm(t('删除该精选?'))) return
1047
+ const res = await fetch('/api/admin/editor-picks/' + id, { method: 'DELETE', headers: { Authorization: 'Bearer ' + state.apiKey } }).then(r => r.json())
1048
+ if (res.error) { alert(res.error); return }
1049
+ toast$(t('已删除'))
1050
+ renderAdminEditorPicks(document.getElementById('app'))
1051
+ }
1052
+
1053
+
1054
+ // Wave D-4: 限时促销 — 卖家创建 modal
1055
+ window.openFlashSaleModal = (productId, basePrice) => {
1056
+ const now = new Date()
1057
+ const defaultStart = new Date(now.getTime() + 60000).toISOString().slice(0, 16)
1058
+ const defaultEnd = new Date(now.getTime() + 24 * 3600 * 1000).toISOString().slice(0, 16)
1059
+ const html = `
1060
+ <div class="js-modal" style="background:rgba(0,0,0,0.6);position:fixed;inset:0;z-index:1000;display:flex;align-items:flex-end;justify-content:center" onclick="this.remove()">
1061
+ <div style="background:#fff;width:100%;max-width:560px;border-radius:16px 16px 0 0;padding:20px;max-height:80vh;overflow-y:auto" onclick="event.stopPropagation()">
1062
+ <h2 style="font-size:16px;font-weight:700;margin-bottom:8px">⚡ ${t('创建限时促销')}</h2>
1063
+ <div style="font-size:12px;color:#6b7280;margin-bottom:12px">${t('原价')} ${basePrice} WAZ · ${t('单次促销最多 30 天')}</div>
1064
+ <div class="form-group">
1065
+ <label class="form-label">${t('促销价')} (WAZ) *</label>
1066
+ <input class="form-control" id="fls-price" type="number" min="0.01" step="0.01" max="${basePrice - 0.01}" placeholder="${t('必须低于原价')}">
1067
+ </div>
1068
+ <div class="form-group">
1069
+ <label class="form-label">${t('开始时间')} *</label>
1070
+ <input class="form-control" id="fls-start" type="datetime-local" value="${defaultStart}">
1071
+ </div>
1072
+ <div class="form-group">
1073
+ <label class="form-label">${t('结束时间')} *</label>
1074
+ <input class="form-control" id="fls-end" type="datetime-local" value="${defaultEnd}">
1075
+ </div>
1076
+ <div class="form-group">
1077
+ <label class="form-label">${t('数量限制')} <span style="font-size:10px;color:#9ca3af">(${t('可选 · 0 = 不限')})</span></label>
1078
+ <input class="form-control" id="fls-qty" type="number" min="0" value="0" placeholder="0">
1079
+ </div>
1080
+ <div id="fls-msg" style="margin:8px 0"></div>
1081
+ <div style="display:flex;gap:8px">
1082
+ <button class="btn btn-gray" style="flex:1" onclick="this.closest('[style*=position]').remove()">${t('取消')}</button>
1083
+ <button class="btn btn-primary" style="flex:1" onclick="submitFlashSale('${productId}', ${basePrice})">${t('创建')}</button>
1084
+ </div>
1085
+ </div>
1086
+ </div>
1087
+ `
1088
+ const div = document.createElement('div')
1089
+ div.innerHTML = html
1090
+ document.body.appendChild(div.firstElementChild)
1091
+ }
1092
+
1093
+ window.submitFlashSale = async (productId, basePrice) => {
1094
+ const sale_price = Number(document.getElementById('fls-price').value)
1095
+ const starts_at = document.getElementById('fls-start').value
1096
+ const ends_at = document.getElementById('fls-end').value
1097
+ const max_qty = Number(document.getElementById('fls-qty').value) || 0
1098
+ const msg = document.getElementById('fls-msg')
1099
+ if (!sale_price || sale_price <= 0 || sale_price >= basePrice) {
1100
+ msg.innerHTML = alert$('error', t('促销价必须低于原价')); return
1101
+ }
1102
+ if (!starts_at || !ends_at) { msg.innerHTML = alert$('error', t('请填写起止时间')); return }
1103
+ msg.innerHTML = loading$()
1104
+ const res = await POST(`/products/${productId}/flash-sale`, {
1105
+ sale_price, starts_at: new Date(starts_at).toISOString(), ends_at: new Date(ends_at).toISOString(), max_qty,
1106
+ })
1107
+ if (res.error) { msg.innerHTML = alert$('error', res.error); return }
1108
+ document.querySelector('.js-modal')?.remove()
1109
+ toast$(t('已创建'))
1110
+ }
1111
+
1112
+ // buyer 视角:全平台正在进行的促销
1113
+ async function renderFlashSalesLive(app) {
1114
+ app.innerHTML = shell(loading$(), 'discover')
1115
+ const r = await GET('/flash-sales/live')
1116
+ const items = r?.items || []
1117
+ const rows = items.length === 0
1118
+ ? `<div style="text-align:center;padding:40px;color:#9ca3af"><div style="font-size:48px">⚡</div><div style="font-size:13px;margin-top:8px">${t('暂无进行中的限时促销')}</div></div>`
1119
+ : items.map(it => {
1120
+ let imageUrl = ''
1121
+ try { const imgs = typeof it.images === 'string' ? JSON.parse(it.images) : it.images; if (Array.isArray(imgs) && imgs[0]) imageUrl = imgs[0] } catch {}
1122
+ const save = (Number(it.original_price) - Number(it.sale_price)).toFixed(2)
1123
+ const pct = ((1 - Number(it.sale_price) / Number(it.original_price)) * 100).toFixed(0)
1124
+ return `
1125
+ <div class="card" style="padding:10px 12px;margin-bottom:8px;cursor:pointer;border-left:3px solid #dc2626" onclick="location.hash='#order-product/${it.product_id}'">
1126
+ <div style="display:flex;gap:10px;align-items:center">
1127
+ <div style="font-size:32px;flex-shrink:0">${imageUrl ? `<img src="${escHtml(imageUrl)}" style="width:48px;height:48px;border-radius:6px;object-fit:cover">` : getCategoryIcon(it.category) || '📦'}</div>
1128
+ <div style="flex:1;min-width:0">
1129
+ <div style="font-size:13px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(it.title)}</div>
1130
+ <div style="font-size:12px;margin-top:2px">
1131
+ <span style="color:#dc2626;font-weight:700">${it.sale_price} WAZ</span>
1132
+ <span style="font-size:11px;color:#9ca3af;text-decoration:line-through;margin-left:4px">${it.original_price}</span>
1133
+ <span style="font-size:10px;color:#dc2626;background:#fee2e2;padding:1px 5px;border-radius:99px;margin-left:4px;font-weight:600">-${pct}%</span>
1134
+ </div>
1135
+ <div style="font-size:10px;color:#9ca3af;margin-top:2px">@${escHtml(it.seller_handle || '')} · ${t('截止')} ${fmtTime(it.ends_at)}${it.max_qty > 0 ? ' · ' + t('限') + ' ' + it.max_qty + ' / ' + t('已售') + ' ' + it.sold_count : ''}</div>
1136
+ </div>
1137
+ </div>
1138
+ </div>`
1139
+ }).join('')
1140
+ app.innerHTML = shell(`
1141
+ <h1 class="page-title">⚡ ${t('限时促销')} <span style="font-size:13px;color:#9ca3af;font-weight:400">(${items.length})</span></h1>
1142
+ <div style="font-size:12px;color:#6b7280;margin-bottom:14px">${t('限时降价 — 先到先得')}</div>
1143
+ ${rows}
1144
+ `, 'discover')
1145
+ }