@anker-in/campaign-ui 0.4.5 → 0.4.7

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 (70) hide show
  1. package/dist/cjs/components/LiveChatWidget/LiveChatWidget.js +1 -1
  2. package/dist/cjs/components/LiveChatWidget/LiveChatWidget.js.map +3 -3
  3. package/dist/cjs/components/LiveChatWidget/api/chat.d.ts +1 -1
  4. package/dist/cjs/components/LiveChatWidget/api/chat.js +2 -2
  5. package/dist/cjs/components/LiveChatWidget/api/chat.js.map +3 -3
  6. package/dist/cjs/components/LiveChatWidget/components/MessageContent/CartCard.js +1 -1
  7. package/dist/cjs/components/LiveChatWidget/components/MessageContent/CartCard.js.map +3 -3
  8. package/dist/cjs/components/LiveChatWidget/components/MessageContent/ProductCard.js +1 -1
  9. package/dist/cjs/components/LiveChatWidget/components/MessageContent/ProductCard.js.map +2 -2
  10. package/dist/cjs/components/LiveChatWidget/components/MessageContent/ProductComparison.js +1 -1
  11. package/dist/cjs/components/LiveChatWidget/components/MessageContent/ProductComparison.js.map +2 -2
  12. package/dist/cjs/components/LiveChatWidget/components/MessageContent/ProductList.js +1 -1
  13. package/dist/cjs/components/LiveChatWidget/components/MessageContent/ProductList.js.map +2 -2
  14. package/dist/cjs/components/LiveChatWidget/components/MessageList.js +2 -2
  15. package/dist/cjs/components/LiveChatWidget/components/MessageList.js.map +3 -3
  16. package/dist/cjs/components/LiveChatWidget/constants.d.ts +1 -0
  17. package/dist/cjs/components/LiveChatWidget/constants.js +1 -1
  18. package/dist/cjs/components/LiveChatWidget/constants.js.map +3 -3
  19. package/dist/cjs/components/LiveChatWidget/hooks/useChatAPI.js +1 -1
  20. package/dist/cjs/components/LiveChatWidget/hooks/useChatAPI.js.map +3 -3
  21. package/dist/cjs/components/LiveChatWidget/hooks/useChatState.d.ts +5 -0
  22. package/dist/cjs/components/LiveChatWidget/hooks/useChatState.js +1 -1
  23. package/dist/cjs/components/LiveChatWidget/hooks/useChatState.js.map +3 -3
  24. package/dist/cjs/components/LiveChatWidget/utils/fetcher.d.ts +3 -1
  25. package/dist/cjs/components/LiveChatWidget/utils/fetcher.js +1 -1
  26. package/dist/cjs/components/LiveChatWidget/utils/fetcher.js.map +3 -3
  27. package/dist/cjs/stories/CartCard.stories.d.ts +33 -0
  28. package/dist/cjs/stories/CartCard.stories.js +21 -0
  29. package/dist/cjs/stories/CartCard.stories.js.map +7 -0
  30. package/dist/esm/components/LiveChatWidget/LiveChatWidget.js +1 -1
  31. package/dist/esm/components/LiveChatWidget/LiveChatWidget.js.map +3 -3
  32. package/dist/esm/components/LiveChatWidget/api/chat.d.ts +1 -1
  33. package/dist/esm/components/LiveChatWidget/api/chat.js +2 -2
  34. package/dist/esm/components/LiveChatWidget/api/chat.js.map +3 -3
  35. package/dist/esm/components/LiveChatWidget/components/MessageContent/CartCard.js +1 -1
  36. package/dist/esm/components/LiveChatWidget/components/MessageContent/CartCard.js.map +3 -3
  37. package/dist/esm/components/LiveChatWidget/components/MessageContent/ProductCard.js +1 -1
  38. package/dist/esm/components/LiveChatWidget/components/MessageContent/ProductCard.js.map +3 -3
  39. package/dist/esm/components/LiveChatWidget/components/MessageContent/ProductComparison.js +1 -1
  40. package/dist/esm/components/LiveChatWidget/components/MessageContent/ProductComparison.js.map +3 -3
  41. package/dist/esm/components/LiveChatWidget/components/MessageContent/ProductList.js +1 -1
  42. package/dist/esm/components/LiveChatWidget/components/MessageContent/ProductList.js.map +3 -3
  43. package/dist/esm/components/LiveChatWidget/components/MessageList.js +2 -2
  44. package/dist/esm/components/LiveChatWidget/components/MessageList.js.map +3 -3
  45. package/dist/esm/components/LiveChatWidget/constants.d.ts +1 -0
  46. package/dist/esm/components/LiveChatWidget/constants.js +1 -1
  47. package/dist/esm/components/LiveChatWidget/constants.js.map +3 -3
  48. package/dist/esm/components/LiveChatWidget/hooks/useChatAPI.js +1 -1
  49. package/dist/esm/components/LiveChatWidget/hooks/useChatAPI.js.map +3 -3
  50. package/dist/esm/components/LiveChatWidget/hooks/useChatState.d.ts +5 -0
  51. package/dist/esm/components/LiveChatWidget/hooks/useChatState.js +1 -1
  52. package/dist/esm/components/LiveChatWidget/hooks/useChatState.js.map +3 -3
  53. package/dist/esm/components/LiveChatWidget/utils/fetcher.d.ts +3 -1
  54. package/dist/esm/components/LiveChatWidget/utils/fetcher.js +1 -1
  55. package/dist/esm/components/LiveChatWidget/utils/fetcher.js.map +3 -3
  56. package/dist/esm/stories/CartCard.stories.d.ts +33 -0
  57. package/dist/esm/stories/CartCard.stories.js +21 -0
  58. package/dist/esm/stories/CartCard.stories.js.map +7 -0
  59. package/package.json +1 -1
  60. package/src/components/LiveChatWidget/LiveChatWidget.tsx +6 -4
  61. package/src/components/LiveChatWidget/api/chat.ts +3 -2
  62. package/src/components/LiveChatWidget/components/MessageContent/CartCard.tsx +2 -6
  63. package/src/components/LiveChatWidget/components/MessageContent/ProductCard.tsx +3 -6
  64. package/src/components/LiveChatWidget/components/MessageContent/ProductComparison.tsx +5 -5
  65. package/src/components/LiveChatWidget/components/MessageContent/ProductList.tsx +3 -6
  66. package/src/components/LiveChatWidget/components/MessageList.tsx +20 -4
  67. package/src/components/LiveChatWidget/constants.ts +9 -0
  68. package/src/components/LiveChatWidget/hooks/useChatAPI.ts +2 -3
  69. package/src/components/LiveChatWidget/hooks/useChatState.ts +27 -7
  70. package/src/components/LiveChatWidget/utils/fetcher.ts +15 -2
@@ -6,7 +6,7 @@
6
6
 
7
7
  import React, { useState } from 'react'
8
8
  import type { Product, MessageRenderer, CommonText } from '../../types'
9
- import { DEFAULT_COMMON_TEXT, CURRENCY_SYMBOLS } from '../../constants'
9
+ import { DEFAULT_COMMON_TEXT, formatCurrency } from '../../constants'
10
10
 
11
11
  /**
12
12
  * 对比维度数据结构
@@ -65,10 +65,10 @@ export interface ProductComparisonProps {
65
65
 
66
66
  /**
67
67
  * 格式化价格显示
68
+ * 根据货币类型自动决定符号位置(前置或后置)
68
69
  */
69
70
  const formatPrice = (amount: number, currency: string = 'USD'): string => {
70
- const symbol = CURRENCY_SYMBOLS[currency] || currency
71
- return `${symbol}${amount}`
71
+ return formatCurrency(amount, currency)
72
72
  }
73
73
 
74
74
  /**
@@ -298,7 +298,7 @@ export const ProductComparison: React.FC<ProductComparisonProps> = ({ data, onAd
298
298
 
299
299
  {/* 价格展示(带划线价) */}
300
300
  <div className="mb-4 flex flex-col items-center gap-1">
301
- <div className="flex items-center gap-2">
301
+ <div className="flex items-center gap-2 justify-center flex-wrap">
302
302
  {/* 当前价格(折扣价或原价) */}
303
303
  <span className="text-base font-bold leading-[1.2] tracking-[-0.02em] text-[#1D1D1F]">
304
304
  {formatPrice(currentPrice, priceInfo?.currency || product.price.currency)}
@@ -317,7 +317,7 @@ export const ProductComparison: React.FC<ProductComparisonProps> = ({ data, onAd
317
317
  <button
318
318
  type="button"
319
319
  onClick={handleAddToCart}
320
- className="livechat-btn-primary mb-3 w-fit rounded-full px-[20px] py-[10px] text-center text-sm font-bold leading-[1.2] tracking-[-0.04em] text-white"
320
+ className="livechat-btn-primary mb-3 mt-auto w-fit rounded-full px-[20px] py-[10px] text-center text-sm font-bold leading-[1.2] tracking-[-0.04em] text-white"
321
321
  style={{ backgroundColor: '#1D1D1F' }}
322
322
  >
323
323
  {mergedText.addToCart}
@@ -6,16 +6,14 @@
6
6
 
7
7
  import React, { useState } from 'react'
8
8
  import type { MessageRenderer, ProductListContent, Product, CommonText } from '../../types'
9
- import { CURRENCY_SYMBOLS, DEFAULT_COMMON_TEXT } from '../../constants.js'
9
+ import { formatCurrency, DEFAULT_COMMON_TEXT } from '../../constants.js'
10
10
 
11
11
  /**
12
12
  * 格式化价格
13
13
  */
14
14
  function formatPrice(price: Product['price']): string {
15
15
  const { amount, currency } = price
16
-
17
- const symbol = CURRENCY_SYMBOLS[currency] || currency
18
- return `${symbol}${amount.toFixed(2)}`
16
+ return formatCurrency(amount, currency)
19
17
  }
20
18
 
21
19
  /**
@@ -47,8 +45,7 @@ function formatDiscountLabel(
47
45
  }
48
46
 
49
47
  if (discount.discount_type === 'fixed_amount') {
50
- const symbol = CURRENCY_SYMBOLS[currency] || currency
51
- return `${symbol}${Math.round(value)} ${offText}`
48
+ return `${formatCurrency(Math.round(value), currency)} ${offText}`
52
49
  }
53
50
 
54
51
  return ''
@@ -124,6 +124,8 @@ export const MessageList: React.FC<MessageListProps> = ({
124
124
  }, [])
125
125
  const [showScrollButton, setShowScrollButton] = useState(false)
126
126
 
127
+ const [showEmpty, setShowEmpty] = useState(false)
128
+
127
129
  // 检查是否接近底部
128
130
  const isNearBottom = useCallback(
129
131
  (threshold = 100) => {
@@ -187,17 +189,31 @@ export const MessageList: React.FC<MessageListProps> = ({
187
189
  return () => clearTimeout(timeoutId)
188
190
  }, [messages, autoScroll, listElement])
189
191
 
190
- // 空状态 + 加载中
192
+ useEffect(() => {
193
+ if (!isLoadingHistory && messages.length === 0) {
194
+ const timer = setTimeout(() => setShowEmpty(true), 150)
195
+ return () => clearTimeout(timer)
196
+ } else {
197
+ setShowEmpty(false)
198
+ }
199
+ }, [isLoadingHistory, messages.length])
200
+
201
+ if (isLoadingHistory) {
202
+ return (
203
+ <div className={`flex flex-1 items-center justify-center overflow-hidden ${className}`}>
204
+ <LoadingIndicator />
205
+ </div>
206
+ )
207
+ }
208
+
191
209
  if (messages.length === 0) {
192
- if (isLoadingHistory) {
193
- // 加载中,显示 loading
210
+ if (!showEmpty) {
194
211
  return (
195
212
  <div className={`flex flex-1 items-center justify-center overflow-hidden ${className}`}>
196
213
  <LoadingIndicator />
197
214
  </div>
198
215
  )
199
216
  }
200
- // 空状态
201
217
  return (
202
218
  <div className={`flex-1 overflow-hidden ${className}`}>{emptyPlaceholder || <DefaultEmptyPlaceholder />}</div>
203
219
  )
@@ -23,6 +23,15 @@ export const CURRENCY_SYMBOLS: Record<string, string> = {
23
23
  RON: "Lei"
24
24
  }
25
25
 
26
+ const SUFFIX_CURRENCIES = new Set(["VND", "RON", "PLN"])
27
+
28
+ export function formatCurrency(amount: number | string, currency: string = 'USD'): string {
29
+ const symbol = CURRENCY_SYMBOLS[currency] || currency
30
+ const value = typeof amount === 'string' ? parseFloat(amount) : amount
31
+ const formatted = Number.isInteger(value) ? String(value) : value.toFixed(2)
32
+ return SUFFIX_CURRENCIES.has(currency) ? `${formatted} ${symbol}` : `${symbol}${formatted}`
33
+ }
34
+
26
35
  /**
27
36
  * 默认文案配置
28
37
  */
@@ -82,17 +82,16 @@ export function useChatAPI(options: UseChatAPIOptions): UseChatAPIReturn {
82
82
  */
83
83
  const sendMessageStream = useCallback(
84
84
  async (request: ChatStreamRequest, onEvent: (event: SSEEvent) => void) => {
85
- // 如果正在发送,先中断之前的请求
86
85
  if (abortControllerRef.current) {
87
86
  abortControllerRef.current.abort()
88
87
  }
89
88
 
90
- // 创建新的 AbortController
91
89
  abortControllerRef.current = new AbortController()
92
90
  isSendingRef.current = true
91
+ const { signal } = abortControllerRef.current
93
92
 
94
93
  try {
95
- await sendMessage(apiBaseUrl, request, onEvent, headers, recaptchaConfig)
94
+ await sendMessage(apiBaseUrl, request, onEvent, headers, recaptchaConfig, signal)
96
95
  } catch (error) {
97
96
  // 忽略手动中断的错误
98
97
  if (error instanceof Error && error.name === 'AbortError') {
@@ -461,6 +461,12 @@ export interface UseChatStateReturn {
461
461
  */
462
462
  handleSSEEvent: (event: SSEEvent) => void
463
463
 
464
+ /**
465
+ * 开始新的 stream 会话,返回绑定了当前 streamId 的事件处理器。
466
+ * 调用此方法会使之前所有 stream 的事件处理器失效,防止并发 stream 污染共享 buffer。
467
+ */
468
+ startStream: () => (event: SSEEvent) => void
469
+
464
470
  /**
465
471
  * 保存会话 ID
466
472
  */
@@ -560,6 +566,9 @@ export function useChatState(options: UseChatStateOptions = {}): UseChatStateRet
560
566
  // 卡片缓存队列,用于存储需要延迟显示的卡片(在文本完成后显示)
561
567
  const pendingCardsRef = useRef<MessageContent[]>([])
562
568
 
569
+ // 当前活跃 stream 的唯一 ID,用于丢弃过期 stream 的事件,防止并发 stream 共享 buffer
570
+ const activeStreamIdRef = useRef<string | null>(null)
571
+
563
572
  /**
564
573
  * 打开聊天窗口
565
574
  */
@@ -641,21 +650,20 @@ export function useChatState(options: UseChatStateOptions = {}): UseChatStateRet
641
650
  * 根据事件类型进行不同的处理
642
651
  */
643
652
  const handleSSEEvent = useCallback(
644
- (event: SSEEvent) => {
653
+ (event: SSEEvent, streamId?: string) => {
645
654
  const { event: eventType, data } = event
646
655
 
656
+ // 并发 stream 防护:除 message_start 外的所有事件必须匹配当前活跃 streamId
657
+ if (eventType !== 'message_start' && streamId !== activeStreamIdRef.current) {
658
+ return
659
+ }
660
+
647
661
  switch (eventType) {
648
662
  case 'message_start': {
649
- // 开始接收新消息
650
663
  setIsStreaming(true)
651
664
 
652
- // 重置文本消息回调标记
653
665
  textMessageCallbackTriggeredRef.current = false
654
-
655
- // 重置文本缓冲区
656
666
  textBufferRef.current = ''
657
-
658
- // 重置卡片缓存队列
659
667
  pendingCardsRef.current = []
660
668
 
661
669
  // T039: 保存 sessionId 和 userId(如果后端返回)
@@ -1088,6 +1096,17 @@ export function useChatState(options: UseChatStateOptions = {}): UseChatStateRet
1088
1096
  )
1089
1097
 
1090
1098
 
1099
+ const startStream = useCallback((): ((event: SSEEvent) => void) => {
1100
+ const streamId = `stream-${Date.now()}-${Math.random().toString(36).slice(2)}`
1101
+ activeStreamIdRef.current = streamId
1102
+ textBufferRef.current = ''
1103
+ pendingCardsRef.current = []
1104
+ productMapRef.current.clear()
1105
+ rawProductMapRef.current.clear()
1106
+ currentMessageRef.current = null
1107
+ return (event: SSEEvent) => handleSSEEvent(event, streamId)
1108
+ }, [handleSSEEvent])
1109
+
1091
1110
  return {
1092
1111
  messages,
1093
1112
  isOpen,
@@ -1104,6 +1123,7 @@ export function useChatState(options: UseChatStateOptions = {}): UseChatStateRet
1104
1123
  setMessages,
1105
1124
  clearMessages,
1106
1125
  handleSSEEvent,
1126
+ startStream,
1107
1127
  saveSession,
1108
1128
  clearSession,
1109
1129
  }
@@ -60,6 +60,8 @@ export interface FetcherOptions {
60
60
  recaptchaSitekey?: string
61
61
  recaptchaAction?: string
62
62
  recaptchaHeaderKey?: string
63
+ /** 外部传入的 AbortSignal,用于提前中止请求 */
64
+ signal?: AbortSignal
63
65
  }
64
66
 
65
67
  /**
@@ -76,8 +78,8 @@ export const fetcher = async ({
76
78
  recaptchaSitekey,
77
79
  recaptchaAction = '',
78
80
  recaptchaHeaderKey = 'X-Recaptcha-Token',
81
+ signal: externalSignal,
79
82
  }: FetcherOptions): Promise<Response> => {
80
- // 获取 reCAPTCHA headers(如果需要)
81
83
  let recaptchaHeaders: Record<string, string> = {}
82
84
  if (needRecaptcha) {
83
85
  if (!recaptchaSitekey) {
@@ -87,14 +89,23 @@ export const fetcher = async ({
87
89
  }
88
90
  }
89
91
 
90
- // 准备请求体
91
92
  const bodyData = body ? JSON.stringify(body) : undefined
92
93
 
94
+ // 合并外部 signal(用于手动中止)和超时 signal
93
95
  const controller = new AbortController()
94
96
  let timeoutTimer: NodeJS.Timeout | undefined
95
97
  if (timeout) {
96
98
  timeoutTimer = setTimeout(() => controller.abort(), timeout)
97
99
  }
100
+ // 如果外部 signal 已中止或在请求期间中止,同步到内部 controller
101
+ const onExternalAbort = () => controller.abort()
102
+ if (externalSignal) {
103
+ if (externalSignal.aborted) {
104
+ controller.abort()
105
+ } else {
106
+ externalSignal.addEventListener('abort', onExternalAbort, { once: true })
107
+ }
108
+ }
98
109
 
99
110
  try {
100
111
  const response = await fetch(url, {
@@ -119,6 +130,8 @@ export const fetcher = async ({
119
130
  clearTimeout(timeoutTimer)
120
131
  }
121
132
  throw error
133
+ } finally {
134
+ externalSignal?.removeEventListener('abort', onExternalAbort)
122
135
  }
123
136
  }
124
137