@subiz/widget-v4 4.1.218 → 4.1.222

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 (128) hide show
  1. package/assets/img/bar_ho_tro.svg +3 -0
  2. package/{dist/assets/img/bubble_default.6f6eec8.svg → assets/img/bubble_11.svg} +0 -1
  3. package/assets/less/form-page.less +58 -0
  4. package/assets/less/form.less +279 -3
  5. package/assets/less/widget.less +6 -0
  6. package/compiled_src/chatbox/ChatButton.js +22 -3
  7. package/compiled_src/chatbox/Widget.js +13 -18
  8. package/compiled_src/form/Form.js +270 -71
  9. package/compiled_src/form/FormPage.js +17 -4
  10. package/compiled_src/form/form-fields.js +244 -0
  11. package/compiled_src/form/index.js +2 -0
  12. package/compiled_src/form/rating-stars.js +48 -88
  13. package/compiled_src/plugin.js +10 -0
  14. package/compiled_src/store/api.js +5 -7
  15. package/compiled_src/store/api_fake.js +3 -1
  16. package/compiled_src/store/localize.js +33 -0
  17. package/compiled_src/store/store.js +57 -1
  18. package/compiled_src/store/widget-settings.js +67 -0
  19. package/compiled_src/util/common.js +1 -1
  20. package/package.json +3 -3
  21. package/src/chatbox/ChatButton.js +25 -4
  22. package/src/chatbox/Widget.js +21 -21
  23. package/src/form/Form.js +335 -97
  24. package/src/form/FormPage.js +21 -8
  25. package/src/form/form-fields.js +224 -0
  26. package/src/form/index.js +6 -1
  27. package/src/form/rating-stars.js +48 -64
  28. package/src/plugin.js +10 -0
  29. package/src/store/api.js +9 -7
  30. package/src/store/api_fake.js +3 -1
  31. package/src/store/localize.js +30 -0
  32. package/src/store/store.js +64 -1
  33. package/src/store/widget-settings.js +46 -0
  34. package/src/util/common.js +1 -1
  35. package/test.cjs +3 -0
  36. package/webpack.dev.js +2 -0
  37. package/dist/127.js +0 -273
  38. package/dist/132.js +0 -2817
  39. package/dist/185.js +0 -1917
  40. package/dist/1a12ed33347.127.js +0 -1
  41. package/dist/1a12ed33347.132.js +0 -1
  42. package/dist/1a12ed33347.185.js +0 -1
  43. package/dist/1a12ed33347.27.js +0 -1
  44. package/dist/1a12ed33347.278.js +0 -1
  45. package/dist/1a12ed33347.339.js +0 -1
  46. package/dist/1a12ed33347.459.js +0 -1
  47. package/dist/1a12ed33347.490.js +0 -1
  48. package/dist/1a12ed33347.app.js +0 -1
  49. package/dist/1a12ed33347.chat.html +0 -1
  50. package/dist/1a12ed33347.chat.js +0 -1
  51. package/dist/1a12ed33347.form.html +0 -1
  52. package/dist/1a12ed33347.form.js +0 -1
  53. package/dist/1a12ed33347.index.html +0 -72
  54. package/dist/1a12ed33347.library.html +0 -72
  55. package/dist/1a12ed33347.library.js +0 -1
  56. package/dist/1a12ed33347.preview.html +0 -759
  57. package/dist/1a12ed33347.preview.js +0 -1
  58. package/dist/1a12ed33347.webview.js +0 -1
  59. package/dist/27.js +0 -37
  60. package/dist/459.js +0 -37
  61. package/dist/490.js +0 -305
  62. package/dist/HASH +0 -1
  63. package/dist/assets/img/Facebook.64607ec.png +0 -0
  64. package/dist/assets/img/Zalo.6845707.png +0 -0
  65. package/dist/assets/img/award1a.1a58e10.png +0 -0
  66. package/dist/assets/img/award3a.3b74423.png +0 -0
  67. package/dist/assets/img/award4a.ed6ccd4.png +0 -0
  68. package/dist/assets/img/award5.39eb7ae.png +0 -0
  69. package/dist/assets/img/caret.998eb8f.svg +0 -1
  70. package/dist/assets/img/convos_empty.2353007.svg +0 -72
  71. package/dist/assets/img/default.06e6ee0.jpg +0 -0
  72. package/dist/assets/img/default_avatar.e449b04.png +0 -0
  73. package/dist/assets/img/default_logo.dd13a2b.svg +0 -5
  74. package/dist/assets/img/done_gray.d7e8203.svg +0 -4
  75. package/dist/assets/img/done_green.b817fd4.svg +0 -4
  76. package/dist/assets/img/dot_chat.335fd8a.svg +0 -3
  77. package/dist/assets/img/emoji/messenger/sub_23.529_58.824.d5faf14.png +0 -0
  78. package/dist/assets/img/emoji/messenger/sub_23.529_60.784.1420241.png +0 -0
  79. package/dist/assets/img/emoji/messenger/sub_25.490_0.000.1e6b2c0.png +0 -0
  80. package/dist/assets/img/emoji/messenger/sub_25.490_45.098.3dfbc08.png +0 -0
  81. package/dist/assets/img/emoji/messenger/sub_25.490_5.882.4b49e94.png +0 -0
  82. package/dist/assets/img/emoji/messenger/sub_27.451_72.549.7114f58.png +0 -0
  83. package/dist/assets/img/emoji/messenger/sub_27.451_84.314.231e2b7.png +0 -0
  84. package/dist/assets/img/emoji/messenger/sub_27.451_96.078.ddbb008.png +0 -0
  85. package/dist/assets/img/emoji/messenger/sub_29.412_5.882.61f3359.png +0 -0
  86. package/dist/assets/img/emoji/messenger/sub_58.824_100.000.6df72bc.png +0 -0
  87. package/dist/assets/img/emoji/messenger/sub_58.824_45.098.3d7a7ad.png +0 -0
  88. package/dist/assets/img/emoji/messenger/sub_58.824_47.059.0f4d6e2.png +0 -0
  89. package/dist/assets/img/emoji/messenger/sub_58.824_54.902.65724c1.png +0 -0
  90. package/dist/assets/img/emoji/messenger/sub_58.824_64.706.9da3cba.png +0 -0
  91. package/dist/assets/img/emoji/messenger/sub_58.824_72.549.edca5cf.png +0 -0
  92. package/dist/assets/img/emoji/messenger/sub_58.824_78.431.f910f2a.png +0 -0
  93. package/dist/assets/img/emoji/messenger/sub_58.824_88.235.def4e71.png +0 -0
  94. package/dist/assets/img/emoji/messenger/sub_60.784_11.765.49c03d0.png +0 -0
  95. package/dist/assets/img/emoji/messenger/sub_60.784_27.451.46b54e5.png +0 -0
  96. package/dist/assets/img/emoji/messenger/sub_60.784_29.412.e36d284.png +0 -0
  97. package/dist/assets/img/emoji/messenger/sub_60.784_3.922.bb191b0.png +0 -0
  98. package/dist/assets/img/emoji/messenger/sub_60.784_35.294.af7b927.png +0 -0
  99. package/dist/assets/img/emoji/messenger/sub_60.784_7.843.98f2a99.png +0 -0
  100. package/dist/assets/img/empty.29bed2d.svg +0 -23
  101. package/dist/assets/img/empty_1.0b98af0.svg +0 -11
  102. package/dist/assets/img/examplte_logo.f86535f.svg +0 -1
  103. package/dist/assets/img/external-link.90577cc.svg +0 -1
  104. package/dist/assets/img/fb_inverse_circle.36c2cfc.png +0 -0
  105. package/dist/assets/img/ghn.070f8e3.svg +0 -17
  106. package/dist/assets/img/ghtk.326eac1.svg +0 -18
  107. package/dist/assets/img/image_default.be0698b.png +0 -0
  108. package/dist/assets/img/info.ebce7ea.svg +0 -1
  109. package/dist/assets/img/loader.37000a7.svg +0 -1
  110. package/dist/assets/img/loading_static.a27f0ac.svg +0 -10
  111. package/dist/assets/img/main-service1.d536cc2.png +0 -0
  112. package/dist/assets/img/main-service2.9b99549.png +0 -0
  113. package/dist/assets/img/not_found_search.2ddb8d1.png +0 -0
  114. package/dist/assets/img/package.8d37d0f.svg +0 -5
  115. package/dist/assets/img/package_fade.763c001.svg +0 -1
  116. package/dist/assets/img/rating--average.1ea492c.svg +0 -12
  117. package/dist/assets/img/rating--great.e3d712e.svg +0 -16
  118. package/dist/assets/img/rating--poor.2938a25.svg +0 -12
  119. package/dist/assets/img/search.f9705ff.svg +0 -1
  120. package/dist/assets/img/send.6db52a9.svg +0 -3
  121. package/dist/assets/img/shop.f7dc7d8.svg +0 -1
  122. package/dist/assets/img/shop_faded.df7845a.svg +0 -1
  123. package/dist/assets/img/stars_v2.79cf728.png +0 -0
  124. package/dist/assets/img/subiz-mono.8fbc067.svg +0 -3
  125. package/dist/assets/img/x.26fdfa3.svg +0 -5
  126. package/dist/malicious-app.js +0 -17
  127. package/dist/screenshoot.html +0 -7
  128. package/dist/screenshoot.js +0 -14454
@@ -0,0 +1,224 @@
1
+ import {h} from 'preact'
2
+ import sb from '../util/index.js'
3
+
4
+ // Helpers and small presentational pieces for the new Form block fields
5
+ // (rating stars/faces, linear_scale, checkbox chips, "Other" option).
6
+
7
+ export const OTHER_OPTION_VALUE = '__other__'
8
+
9
+ // Defaults for i18n keys that may not exist yet in the backend locale files.
10
+ // t(key) returns the key itself when the message is missing.
11
+ const DEFAULT_MESSAGES = {
12
+ vi: {
13
+ form_other_option: 'Khác…',
14
+ form_other_option_placeholder: 'Nhập câu trả lời của bạn',
15
+ form_other_option_label: 'Khác',
16
+ },
17
+ en: {
18
+ form_other_option: 'Other…',
19
+ form_other_option_placeholder: 'Type your answer',
20
+ form_other_option_label: 'Other',
21
+ },
22
+ }
23
+
24
+ export function translate(t, key) {
25
+ let msg = typeof t === 'function' ? t(key) : ''
26
+ if (msg && msg !== key) return msg
27
+ let locale = ''
28
+ try {
29
+ if (t && typeof t.locale === 'function') locale = String(t.locale() || '')
30
+ } catch (e) {}
31
+ // unknown locale falls back to vi, same as the widget's fallback locale
32
+ let lang = locale && !locale.toLowerCase().startsWith('vi') ? 'en' : 'vi'
33
+ return DEFAULT_MESSAGES[lang][key] || key
34
+ }
35
+
36
+ export function isChips(com) {
37
+ return String(sb.get(com, 'class') || '')
38
+ .split(/\s+/)
39
+ .includes('chips')
40
+ }
41
+
42
+ export function getPlaceholder(com) {
43
+ return sb.get(com, 'place_holder') || sb.get(com, 'placeholder') || ''
44
+ }
45
+
46
+ export function getDescription(com) {
47
+ return sb.get(com, 'description') || sb.get(com, 'label_description') || ''
48
+ }
49
+
50
+ // numeric option values ("1".."N") are stored as numbers, same as the legacy rating field
51
+ export function toStoredValue(v) {
52
+ if (typeof v === 'number') return v
53
+ let s = String(v === undefined || v === null ? '' : v).trim()
54
+ if (s !== '' && /^-?\d+(\.\d+)?$/.test(s)) return Number(s)
55
+ return s
56
+ }
57
+
58
+ export function sameValue(a, b) {
59
+ if (a === undefined || a === null || a === '') return false
60
+ return String(a) === String(b)
61
+ }
62
+
63
+ const FACES_5 = ['face_very_sad', 'face_sad', 'face_neutral', 'face_happy', 'face_very_happy']
64
+ const FACES_3 = ['face_sad', 'face_neutral', 'face_happy']
65
+
66
+ // returns {kind: 'star'|'face', levels: [{value, icon}]}
67
+ export function getRatingConfig(com) {
68
+ let options = sb.filter(sb.get(com, 'input_options') || [], (o) => o && o.value !== undefined && o.value !== '')
69
+ if (sb.size(options) > 0) {
70
+ let isFace = sb.find(options, (o) => String(o.icon || '').startsWith('face'))
71
+ let faces = options.length === 3 ? FACES_3 : FACES_5
72
+ let levels = sb.map(options, (o, i) => {
73
+ let icon = String(o.icon || '')
74
+ if (isFace && !icon.startsWith('face')) icon = faces[i] || 'face_neutral'
75
+ if (!isFace) icon = 'star'
76
+ return {value: String(o.value), icon}
77
+ })
78
+ return {kind: isFace ? 'face' : 'star', levels}
79
+ }
80
+ let max = parseInt(sb.get(com, 'scale_max'), 10)
81
+ if (max !== 3) max = 5
82
+ let levels = []
83
+ for (let i = 1; i <= max; i++) levels.push({value: String(i), icon: 'star'})
84
+ return {kind: 'star', levels}
85
+ }
86
+
87
+ export function getScaleRange(com) {
88
+ let min = parseInt(sb.get(com, 'scale_min'), 10) === 0 ? 0 : 1
89
+ let max = parseInt(sb.get(com, 'scale_max'), 10)
90
+ if (!max || isNaN(max)) max = 5
91
+ if (max < 2) max = 2
92
+ if (max > 10) max = 10
93
+ let values = []
94
+ for (let i = min; i <= max; i++) values.push(i)
95
+ return {min, max, values}
96
+ }
97
+
98
+ // label shown for a stored option value; values not in the option list
99
+ // (old free text or an "Other" answer) are shown as-is
100
+ export function optionLabel(com, v) {
101
+ let option = sb.find(sb.get(com, 'input_options') || [], (o) => sameValue(o.value, v))
102
+ if (option) return option.label || option.value
103
+ return v
104
+ }
105
+
106
+ export function isEmptyValue(val) {
107
+ if (val === undefined || val === null) return true
108
+ if (typeof val === 'string' && !val.trim()) return true
109
+ if (Array.isArray(val) && val.length === 0) return true
110
+ return false
111
+ }
112
+
113
+ const MOUTHS = {
114
+ face_very_sad: 'M7.5 17.5 Q12 11.5 16.5 17.5',
115
+ face_sad: 'M8.5 16.5 Q12 13.5 15.5 16.5',
116
+ face_neutral: 'M8.5 15.5 L15.5 15.5',
117
+ face_happy: 'M8.5 14 Q12 17.5 15.5 14',
118
+ face_very_happy: 'M7.5 13 Q12 20 16.5 13 Z',
119
+ }
120
+
121
+ export function FaceIcon({icon, size}) {
122
+ let mouth = MOUTHS[icon] || MOUTHS.face_neutral
123
+ let s = size || 28
124
+ return (
125
+ <svg
126
+ class='form-face-icon'
127
+ width={s}
128
+ height={s}
129
+ viewBox='0 0 24 24'
130
+ fill='none'
131
+ stroke='currentColor'
132
+ stroke-width='1.6'
133
+ stroke-linecap='round'
134
+ stroke-linejoin='round'
135
+ aria-hidden='true'
136
+ >
137
+ <circle cx='12' cy='12' r='10' />
138
+ <circle cx='8.8' cy='9.6' r='0.6' fill='currentColor' stroke-width='1.2' />
139
+ <circle cx='15.2' cy='9.6' r='0.6' fill='currentColor' stroke-width='1.2' />
140
+ <path d={mouth} fill={icon === 'face_very_happy' ? 'currentColor' : 'none'} fill-opacity='0.15' />
141
+ </svg>
142
+ )
143
+ }
144
+
145
+ export function RatingFaces({levels, value, onChange, readonly}) {
146
+ return (
147
+ <div class={'form-rating-faces' + (readonly ? ' readonly' : '')} role={readonly ? undefined : 'radiogroup'}>
148
+ {sb.map(levels, (level) => {
149
+ let selected = sameValue(value, level.value)
150
+ let cls = 'form-rating-face'
151
+ if (selected) cls += ' selected'
152
+ if (readonly) {
153
+ return (
154
+ <span class={cls} key={level.value}>
155
+ <FaceIcon icon={level.icon} />
156
+ </span>
157
+ )
158
+ }
159
+ return (
160
+ <button
161
+ type='button'
162
+ class={cls}
163
+ key={level.value}
164
+ role='radio'
165
+ aria-checked={selected ? 'true' : 'false'}
166
+ aria-label={level.value}
167
+ title={level.value}
168
+ onClick={() => onChange(selected ? '' : toStoredValue(level.value))}
169
+ >
170
+ <FaceIcon icon={level.icon} />
171
+ </button>
172
+ )
173
+ })}
174
+ </div>
175
+ )
176
+ }
177
+
178
+ // numberedLabels: prefix min/max labels with their number ("1 · …"), used in the chat box only
179
+ export function LinearScale({com, value, onChange, numberedLabels}) {
180
+ let {min, max, values} = getScaleRange(com)
181
+ let cols = Math.ceil(values.length / 2)
182
+ let minLabel = sb.get(com, 'scale_min_label') || ''
183
+ let maxLabel = sb.get(com, 'scale_max_label') || ''
184
+ return (
185
+ <div
186
+ class={'form-linear-scale' + (values.length >= 8 ? ' many' : '')}
187
+ style={`--scale-count: ${values.length}; --scale-cols: ${cols}`}
188
+ >
189
+ <div class='form-linear-scale-items' role='radiogroup'>
190
+ {sb.map(values, (v) => {
191
+ let selected = sameValue(value, v)
192
+ // odd count on two rows (e.g. 0..10 -> 6 + 5): start the 2nd row one column
193
+ // later so the max value ends bottom-right, above the max label.
194
+ // A class, not an inline style: the chatbox has `* {all: unset !important}`.
195
+ let cls = 'form-linear-scale-item'
196
+ if (selected) cls += ' selected'
197
+ if (values.length % 2 && v === values[cols]) cls += ' row2-start'
198
+ return (
199
+ <button
200
+ type='button'
201
+ key={v}
202
+ class={cls}
203
+ role='radio'
204
+ aria-checked={selected ? 'true' : 'false'}
205
+ onClick={() => onChange(v)}
206
+ >
207
+ {v}
208
+ </button>
209
+ )
210
+ })}
211
+ </div>
212
+ {(minLabel || maxLabel) && (
213
+ <div class='form-linear-scale-labels'>
214
+ <span class='form-linear-scale-label min'>
215
+ {minLabel && numberedLabels ? `${min} · ${minLabel}` : minLabel}
216
+ </span>
217
+ <span class='form-linear-scale-label max'>
218
+ {maxLabel && numberedLabels ? `${max} · ${maxLabel}` : maxLabel}
219
+ </span>
220
+ </div>
221
+ )}
222
+ </div>
223
+ )
224
+ }
package/src/form/index.js CHANGED
@@ -40,6 +40,8 @@ async function load() {
40
40
  getUrlParameter('id') || getUrlParameter('accid') || getUrlParameter('account-id') || 'acqsulrowbxiugvginhw'
41
41
  let mask = accid + '@' + 'abcd'
42
42
  let token = getUrlParameter('token')
43
+ // public link: ?account-id=...&form-id=...
44
+ let formId = getUrlParameter('form-id') || getUrlParameter('formid')
43
45
  let previewJson = getUrlParameter('preview')
44
46
  //previewJson = PREVIEW_B64
45
47
  await store.init(accid)
@@ -62,7 +64,10 @@ async function load() {
62
64
  }
63
65
  t.locale = () => _locale
64
66
  common.downloadLanguage(store, _locale).then(() => {
65
- render(<FormPage store={store} t={t} token={token} previewJson={previewJson} />, document.getElementById('form'))
67
+ render(
68
+ <FormPage store={store} t={t} token={token} formId={formId} previewJson={previewJson} />,
69
+ document.getElementById('form'),
70
+ )
66
71
  })
67
72
  }
68
73
 
@@ -1,88 +1,72 @@
1
1
  import {Com} from '../common.js'
2
- import {h, createRef, Fragment} from 'preact'
3
- import sb from '../util/index.js'
4
-
5
- export default class Form2 extends Com {
6
- state = {
7
- hovering: 0,
8
- selected: 0,
9
- }
2
+ import {h} from 'preact'
10
3
 
4
+ // Star rating. `count` stars (default 5), selecting level N highlights stars 1..N.
5
+ // Clicking the selected star again clears the value (onChange(0)).
6
+ export default class RatingStars extends Com {
11
7
  constructor(props) {
12
8
  super(props)
9
+ this.state = {hovering: 0, selected: Number(props.value) || 0}
13
10
  }
14
11
 
15
12
  onMouseEnter = (v) => {
16
- let {selected, hovering} = this.state
17
- if (v <= selected) {
18
- this.setState({hovering: selected})
19
- return
20
- }
21
- this.setState({hovering: v})
13
+ let {selected} = this.state
14
+ this.setState({hovering: v <= selected ? selected : v})
22
15
  }
23
16
 
24
17
  onMouseLeave = () => {
25
- let {selected, hovering} = this.state
26
18
  this.setState({hovering: 0})
27
19
  }
28
20
 
29
21
  onToggleValue(v) {
30
- let {selected} = this.state
31
- let newVal = v
32
- if (v === selected) newVal = 0
22
+ let newVal = v === this.state.selected ? 0 : v
33
23
  this.setState({selected: newVal, hovering: newVal})
34
- this.props.onChange(newVal)
24
+ if (this.props.onChange) this.props.onChange(newVal)
35
25
  }
36
26
 
37
27
  render() {
38
28
  let {selected, hovering} = this.state
39
- let star1Cls = 'star-wrapper'
40
- if (selected >= 1) {
41
- star1Cls += ' selected'
42
- } else if (hovering >= 1) {
43
- star1Cls += ' hovering'
44
- }
45
- let star2Cls = 'star-wrapper'
46
- if (selected >= 2) {
47
- star2Cls += ' selected'
48
- } else if (hovering >= 2) {
49
- star2Cls += ' hovering'
50
- }
51
- let star3Cls = 'star-wrapper'
52
- if (selected >= 3) {
53
- star3Cls += ' selected'
54
- } else if (hovering >= 3) {
55
- star3Cls += ' hovering'
56
- }
57
- let star4Cls = 'star-wrapper'
58
- if (selected >= 4) {
59
- star4Cls += ' selected'
60
- } else if (hovering >= 4) {
61
- star4Cls += ' hovering'
62
- }
63
- let star5Cls = 'star-wrapper'
64
- if (selected >= 5) {
65
- star5Cls += ' selected'
66
- } else if (hovering >= 5) {
67
- star5Cls += ' hovering'
29
+ let count = Number(this.props.count) || 5
30
+ let readonly = !!this.props.readonly
31
+ let $stars = []
32
+ for (let i = 1; i <= count; i++) {
33
+ let cls = 'star-wrapper'
34
+ if (selected >= i) cls += ' selected'
35
+ else if (hovering >= i) cls += ' hovering'
36
+ if (readonly) {
37
+ $stars.push(
38
+ <div class={cls} key={i}>
39
+ <div class='star' />
40
+ </div>,
41
+ )
42
+ continue
43
+ }
44
+ $stars.push(
45
+ <div
46
+ class={cls}
47
+ key={i}
48
+ role='radio'
49
+ aria-checked={selected === i ? 'true' : 'false'}
50
+ aria-label={String(i)}
51
+ tabIndex={0}
52
+ onClick={() => this.onToggleValue(i)}
53
+ onKeyDown={(e) => {
54
+ if (e.key === 'Enter' || e.key === ' ') {
55
+ e.preventDefault()
56
+ this.onToggleValue(i)
57
+ }
58
+ }}
59
+ onMouseEnter={() => this.onMouseEnter(i)}
60
+ >
61
+ <div class='star' />
62
+ </div>,
63
+ )
68
64
  }
65
+ let cls = 'form-ratings-wrapper'
66
+ if (readonly) cls += ' readonly'
69
67
  return (
70
- <div class='form-ratings-wrapper' onMouseLeave={() => this.onMouseLeave()}>
71
- <div class={star1Cls} onClick={() => this.onToggleValue(1)} onMouseEnter={() => this.onMouseEnter(1)}>
72
- <div class='star' />
73
- </div>
74
- <div class={star2Cls} onClick={() => this.onToggleValue(2)} onMouseEnter={() => this.onMouseEnter(2)}>
75
- <div class='star' />
76
- </div>
77
- <div class={star3Cls} onClick={() => this.onToggleValue(3)} onMouseEnter={() => this.onMouseEnter(3)}>
78
- <div class='star' />
79
- </div>
80
- <div class={star4Cls} onClick={() => this.onToggleValue(4)} onMouseEnter={() => this.onMouseEnter(4)}>
81
- <div class='star' />
82
- </div>
83
- <div class={star5Cls} onClick={() => this.onToggleValue(5)} onMouseEnter={() => this.onMouseEnter(5)}>
84
- <div class='star' />
85
- </div>
68
+ <div class={cls} role={readonly ? undefined : 'radiogroup'} onMouseLeave={() => this.onMouseLeave()}>
69
+ {$stars}
86
70
  </div>
87
71
  )
88
72
  }
package/src/plugin.js CHANGED
@@ -4,6 +4,7 @@ import {h, render, createRef} from 'preact'
4
4
 
5
5
  // const LoadChatbox = () => import(/*webpackChunkName: "chatbox" */ './chatbox/main.js')
6
6
  import Chatbox from './chatbox/main.js'
7
+ import {widgetGreeting} from './chatbox/greeting.js'
7
8
  const LoadPopup = () => import(/*webpackChunkName: "popup" */ './popup/popup.js')
8
9
  const LoadNotif = () => import(/*webpackChunkName: "notif" */ './notif/notif.js')
9
10
 
@@ -172,6 +173,15 @@ function Plugin(plugin, store, $container, lib) {
172
173
  _locale = common.getLocale(plugin, locale)
173
174
  await common.downloadLanguage(store, _locale)
174
175
  this.t = t
176
+ if (plugin.type === 'chatbox') {
177
+ store.relocalizeLightConvos(_locale, t.i18n, {
178
+ widgetGreeting: () => widgetGreeting(plugin.chatbox, t),
179
+ previewGreetingText: () => {
180
+ let greeting = sb.get(plugin, 'chatbox.greeting', {})
181
+ return t.i18n(sb.get(greeting, 'greeting', {})) || greeting.message
182
+ },
183
+ })
184
+ }
175
185
  _render()
176
186
  return _locale
177
187
  }
package/src/store/api.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import sb from '../util/index.js'
2
+ import {loadWidgetSettings} from './widget-settings.js'
2
3
  const ajax = require('@subiz/ajax/src/ajax.js')
3
4
 
4
5
  function jsonifyTextPlain(param, done) {
@@ -309,8 +310,9 @@ export default class {
309
310
  })
310
311
  }
311
312
 
313
+ // Embedded widgets can use a relative API base, such as /4.0/ on the dashboard.
312
314
  attachmentRequest = () => this.apireq
313
- .setBaseUrl(new URL('/4.1', this.ApiURL).href)
315
+ .setBaseUrl(new URL('/4.1', new URL(this.ApiURL, document.location.href)).href)
314
316
  .addQuery('x-account-id', this.accid)
315
317
 
316
318
  attachmentJSON = async (path, body, method = 'post') => {
@@ -387,12 +389,7 @@ export default class {
387
389
  getUser = (_) => this.apireq.get(`/accounts/${this.accid}/users/${this.uid}`)
388
390
  getAccountSetting = async (p) => {
389
391
  let href = sb.get(document, 'location.href') // send full href, this could be used to select which bot (ai-agent) should be returned
390
- let res = {}
391
- if (p && p.bot_id) {
392
- res = await this.apireq.addQuery('url', href).addQuery('bot_id', p.bot_id).get(`/accounts/${this.accid}/settings`)
393
- } else {
394
- res = await this.apireq.addQuery('url', href).get(`/accounts/${this.accid}/settings`)
395
- }
392
+ const res = await loadWidgetSettings(this.apireq, this.ApiURL, this.accid, href, p && p.bot_id)
396
393
  if (res.error) return res
397
394
 
398
395
  // fix image error image url link in popup
@@ -441,6 +438,11 @@ export default class {
441
438
 
442
439
  checkFormToken = (accid, token) => this.api41.addQuery('account-id', accid).get(`/submissions/${token}`)
443
440
  submitForm2 = (accid, token, data) => this.api41.addQuery('account-id', accid).post(`/submissions/${token}`, data)
441
+ // public form (no token): submitter is the current visitor
442
+ getPublicForm = (formid) =>
443
+ this.websiteFormRequest().get(`/accounts/${this.accid}/public-forms/${encodeURIComponent(formid)}`)
444
+ submitPublicForm = (formid, data) =>
445
+ this.websiteFormRequest().post(`/accounts/${this.accid}/public-forms/${encodeURIComponent(formid)}/submissions`, data)
444
446
  suggest_address = (keyword) => this.apireq.setQuery({keyword: keyword}).get(`/accounts/${this.accid}/addresses`)
445
447
  create_ticket = (ticket) => this.apireq.post(`/accounts/${this.accid}/tickets`, ticket)
446
448
  }
@@ -1,4 +1,5 @@
1
1
  import sb from '../util/index.js'
2
+ import {loadWidgetSettings} from './widget-settings.js'
2
3
 
3
4
  const ajax = require('@subiz/ajax/src/ajax.js')
4
5
 
@@ -13,6 +14,7 @@ const OK200 = Promise.resolve({code: 200})
13
14
  export default class {
14
15
  constructor(ApiURL, FileURL) {
15
16
  this.FileURL = FileURL
17
+ this.ApiURL = ApiURL
16
18
  this.apireq = ajax.setBaseUrl(ApiURL).setContentType('text/plain').beforeHook(jsonifyTextPlain).setParser('json')
17
19
  }
18
20
 
@@ -84,7 +86,7 @@ export default class {
84
86
  ],
85
87
  },
86
88
  })
87
- getAccountSetting = () => this.apireq.get(`/accounts/${this.accid}/settings`)
89
+ getAccountSetting = (p) => loadWidgetSettings(this.apireq, this.ApiURL, this.accid, document.location.href, p && p.bot_id)
88
90
 
89
91
  sendImpression = (impression) => OK200
90
92
  sendConversion = (conversion) => OK200
@@ -0,0 +1,30 @@
1
+ import sb from '../util/index.js'
2
+
3
+ // pick(i18nObject) returns the localized value or a falsy value when none matches.
4
+ export const pickLocale = (locale) => (i18n) => {
5
+ if (!i18n || !locale) return undefined
6
+ return i18n[locale.replace('-', '_')] || i18n[locale]
7
+ }
8
+
9
+ // Rewrites the visible fields of a message from its i18n fields, in place.
10
+ // Fields without a matching translation keep their current value.
11
+ // Returns true when anything changed.
12
+ export function localizeMessage(message, pick) {
13
+ if (!message) return false
14
+ let changed = false
15
+ const block = message.i18n_block && pick(message.i18n_block)
16
+ if (block) {
17
+ message.block = sb.cloneDeep(block)
18
+ message.text = sb.blockToText(block)
19
+ changed = true
20
+ }
21
+ sb.map(message.attachments, (att) => {
22
+ sb.map(att && att.quick_replies, (qr) => {
23
+ const title = qr && qr.i18n_title && pick(qr.i18n_title)
24
+ if (!title) return
25
+ qr.title = title
26
+ changed = true
27
+ })
28
+ })
29
+ return changed
30
+ }
@@ -7,6 +7,7 @@ import KV from './kv.js'
7
7
  import APIFake from './api_fake.js'
8
8
  import RealtimeFake from './realtime_fake.js'
9
9
  import {uploadAttachments} from './attachments.js'
10
+ import {localizeMessage, pickLocale} from './localize.js'
10
11
  var Realtime = require('@subiz/wsclient')
11
12
  var cookie = require('cookie_js')
12
13
  cookie.defaults.path = '/'
@@ -637,6 +638,8 @@ export default class Store {
637
638
 
638
639
  checkFormToken = (accid, token) => this.api.checkFormToken(accid, token)
639
640
  submitForm2 = (accid, token, data) => this.api.submitForm2(accid, token, data)
641
+ getPublicForm = (formid) => this.api.getPublicForm(formid)
642
+ submitPublicForm = (formid, data) => this.api.submitPublicForm(formid, data)
640
643
  sendPresence = sb.throttle(
641
644
  async () => {
642
645
  if (!this._track_page_view) return
@@ -1311,6 +1314,65 @@ export default class Store {
1311
1314
 
1312
1315
  _typing = sb.throttle((convoid) => this.api.sendTyping(convoid), 3000, {trailing: false})
1313
1316
 
1317
+ // Re-translates stored light convos and the button preview after the chatbox locale changes.
1318
+ // pick(i18nObject) returns the localized value.
1319
+ // opts.widgetGreeting() rebuilds the widget greeting event,
1320
+ // opts.previewGreetingText() returns the chatbox greeting text shown in the button preview.
1321
+ relocalizeLightConvos(locale, pick, opts = {}) {
1322
+ let changed = []
1323
+ sb.map(this.lightConvos, (convo) => {
1324
+ if (!convo) return
1325
+ sb.map(convo.events, (ev) => {
1326
+ let message = sb.get(ev, 'data.message')
1327
+ if (!message) return
1328
+ let ok = false
1329
+ if (convo.light_type === 'widget' && sb.get(ev, 'id', '').startsWith('widget-greeting-')) {
1330
+ let text = opts.widgetGreeting && sb.get(opts.widgetGreeting(), 'data.message.text')
1331
+ if (text) {
1332
+ message.text = text
1333
+ ok = true
1334
+ }
1335
+ } else {
1336
+ ok = localizeMessage(message, pick)
1337
+ }
1338
+ if (!ok) return
1339
+ if (sb.get(convo, 'last_message_sent.id') === ev.id) convo.last_message_sent = sb.cloneDeep(ev)
1340
+ changed.push(ev)
1341
+ })
1342
+ convo.locale = locale
1343
+ })
1344
+ if (sb.size(this.lightConvos)) this.kv.put('light_convos', this.lightConvos)
1345
+ this.relocalizePreview(pick, opts.previewGreetingText)
1346
+ // re-render only: publishing 'events' would re-trigger greeting previews and subiz('on') callbacks
1347
+ if (changed.length) this.pubsub.publish('account')
1348
+ }
1349
+
1350
+ // lastview.previewEvent is a snapshot (persisted separately from light_convos), so it must be rebuilt.
1351
+ relocalizePreview(pick, previewGreetingText) {
1352
+ let previewEvent = sb.get(this.matchLastview(), 'previewEvent')
1353
+ if (!sb.size(previewEvent)) return
1354
+ let cid = sb.get(previewEvent, 'data.message.conversation_id')
1355
+ let next = sb.cloneDeep(previewEvent)
1356
+ let ok = false
1357
+ if (!cid) {
1358
+ // greeting built from chatbox setting (ChatButton.pickBestGreeting)
1359
+ let text = previewGreetingText && previewGreetingText()
1360
+ if (text) {
1361
+ next.data.message.text = text
1362
+ ok = true
1363
+ }
1364
+ } else {
1365
+ let ev = previewEvent.id && sb.find(sb.get(this.lightConvos, [cid, 'events']), (e) => e && e.id === previewEvent.id)
1366
+ if (ev) {
1367
+ next = sb.cloneDeep(ev)
1368
+ ok = true
1369
+ } else {
1370
+ ok = localizeMessage(sb.get(next, 'data.message'), pick)
1371
+ }
1372
+ }
1373
+ if (ok) this.updateLastview({previewEvent: next})
1374
+ }
1375
+
1314
1376
  deleteLightConvo(id) {
1315
1377
  delete this.lightConvos[id]
1316
1378
  this.kv.put('light_convos', this.lightConvos)
@@ -1365,8 +1427,9 @@ export default class Store {
1365
1427
  ev.id = 'evfake' + now + idx
1366
1428
  ev.type = 'message_sent'
1367
1429
  ev.by = {id: botid, type: 'bot'}
1368
- ev.data = {message: message}
1430
+ ev.data = {message: sb.cloneDeep(message)}
1369
1431
  ev.data.message.conversation_id = convoid
1432
+ localizeMessage(ev.data.message, pickLocale(locale))
1370
1433
  lastev = ev
1371
1434
  return ev
1372
1435
  })
@@ -0,0 +1,46 @@
1
+ const ajax = require('@subiz/ajax/src/ajax.js')
2
+
3
+ function failed(response) {
4
+ return !response || response.error || response.body?.error || response.code < 200 || response.code >= 300 || !response.body || typeof response.body !== 'object'
5
+ }
6
+
7
+ // A separate client keeps visitor identity, authentication hooks and cookies
8
+ // out of the shared Cloudflare cache key.
9
+ async function readConfig(query) {
10
+ const request = ajax.withCredentials(false).setParser('json').setQuery(query)
11
+ let response
12
+ try {
13
+ response = await request.setBaseUrl('https://api2.sbz.vn/4.1').get('/widget-config')
14
+ if (!failed(response)) return response
15
+ } catch (_) {
16
+ // CSP and network failures can reject instead of returning an error envelope.
17
+ }
18
+ return request.setBaseUrl('https://api.sbz.vn/4.1').get('/widget-config')
19
+ }
20
+
21
+ export async function loadWidgetSettings(contextRequest, apiURL, accountId, pageURL, botId) {
22
+ const query = {'account-id': accountId, url: pageURL, v: '6'}
23
+ if (botId) query.bot_id = botId
24
+ const contextBase = new URL('/4.1', new URL(apiURL, document.location.href)).href
25
+ const [config, context] = await Promise.all([
26
+ readConfig(query),
27
+ contextRequest.setBaseUrl(contextBase).setQuery(query).get('/widget-context'),
28
+ ])
29
+ // Preserve the API error body for the existing banned/locked-account handling.
30
+ if (failed(context)) return {...context, error: context?.error || true}
31
+ if (failed(config)) return {...config, error: config?.error || true}
32
+ return {...config, body: mergeWidgetSettings(config.body, context.body)}
33
+ }
34
+
35
+ export function mergeWidgetSettings(config, context) {
36
+ const dynamicPlugins = new Map((context.plugins || []).map(plugin => [plugin.id, plugin]))
37
+ const plugins = (config.plugins || []).map(plugin => {
38
+ const dynamic = dynamicPlugins.get(plugin.id)
39
+ if (!dynamic) return plugin
40
+ const merged = {...plugin, ...dynamic}
41
+ if (dynamic.chatbox) merged.chatbox = {...plugin.chatbox, ...dynamic.chatbox}
42
+ if (dynamic.notification) merged.notification = {...plugin.notification, ...dynamic.notification}
43
+ return merged
44
+ })
45
+ return {...config, ...context, plugins}
46
+ }
@@ -836,7 +836,7 @@ export function setCss(id, $css, css) {
836
836
  }
837
837
  }
838
838
 
839
- export const DEF_CHAT_BUTTON_BACKGROUND_IMAGE_URL = 'https://vcdn.subiz-cdn.com/file/bubble_11.svg?v=5' // v=5 trick to clear cache
839
+ export const DEF_CHAT_BUTTON_BACKGROUND_IMAGE_URL = require('../../assets/img/bubble_11.svg?inline')
840
840
 
841
841
  export const DEFVAL = {
842
842
  primary_color: '#0265FF',
package/test.cjs CHANGED
@@ -34,6 +34,9 @@ Module._extensions['.js'] = (module, filename) => {
34
34
  const originalLoad = Module._load
35
35
  Module._load = function(request, parent, isMain) {
36
36
  if (request.startsWith('css-loader!')) return {default: {toString: () => ''}}
37
+ if (request.endsWith('.svg?inline')) {
38
+ return 'data:image/svg+xml;base64,' + fs.readFileSync(path.resolve(path.dirname(parent.filename), request.slice(0, -7))).toString('base64')
39
+ }
37
40
  return originalLoad.call(this, request, parent, isMain)
38
41
  }
39
42
  for (const extension of ['.svg', '.png', '.jpg', '.gif', '.mp3', '.ogg']) {
package/webpack.dev.js CHANGED
@@ -34,8 +34,10 @@ module.exports = {
34
34
  module: {
35
35
  rules: [
36
36
  {test: /\.less$/, loader: 'less-loader'},
37
+ {test: /\.svg$/i, resourceQuery: /inline/, type: 'asset/inline'},
37
38
  {
38
39
  test: /\.(svg|png|jpe?g|gif|ogg|mp3)$/i,
40
+ resourceQuery: {not: [/inline/]},
39
41
  use: [
40
42
  {
41
43
  loader: 'file-loader',