@repzo/widget-react-native 1.0.4 → 1.0.6
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.
- package/lib/commonjs/api/client.js +8 -0
- package/lib/commonjs/api/client.js.map +1 -1
- package/lib/commonjs/i18n.js +14 -0
- package/lib/commonjs/i18n.js.map +1 -1
- package/lib/commonjs/locales.js +13 -0
- package/lib/commonjs/locales.js.map +1 -1
- package/lib/commonjs/state/widget-store.js +44 -4
- package/lib/commonjs/state/widget-store.js.map +1 -1
- package/lib/commonjs/types.js +3 -0
- package/lib/commonjs/types.js.map +1 -1
- package/lib/commonjs/ui/WidgetProvider.js +93 -64
- package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
- package/lib/commonjs/ui/components/Gradient.js +18 -2
- package/lib/commonjs/ui/components/Gradient.js.map +1 -1
- package/lib/commonjs/ui/components/RatingCard.js +347 -0
- package/lib/commonjs/ui/components/RatingCard.js.map +1 -0
- package/lib/commonjs/ui/hooks/use-top-inset.js +22 -0
- package/lib/commonjs/ui/hooks/use-top-inset.js.map +1 -0
- package/lib/commonjs/ui/screens/chat/ChatView.js +30 -2
- package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/commonjs/ui/screens/home/HomeView.js +42 -9
- package/lib/commonjs/ui/screens/home/HomeView.js.map +1 -1
- package/lib/commonjs/ui/screens/home/HomeView.styles.js +5 -3
- package/lib/commonjs/ui/screens/home/HomeView.styles.js.map +1 -1
- package/lib/module/api/client.js +8 -0
- package/lib/module/api/client.js.map +1 -1
- package/lib/module/i18n.js +14 -0
- package/lib/module/i18n.js.map +1 -1
- package/lib/module/locales.js +13 -0
- package/lib/module/locales.js.map +1 -1
- package/lib/module/state/widget-store.js +44 -4
- package/lib/module/state/widget-store.js.map +1 -1
- package/lib/module/types.js +3 -0
- package/lib/module/types.js.map +1 -1
- package/lib/module/ui/WidgetProvider.js +96 -67
- package/lib/module/ui/WidgetProvider.js.map +1 -1
- package/lib/module/ui/components/Gradient.js +18 -2
- package/lib/module/ui/components/Gradient.js.map +1 -1
- package/lib/module/ui/components/RatingCard.js +341 -0
- package/lib/module/ui/components/RatingCard.js.map +1 -0
- package/lib/module/ui/hooks/use-top-inset.js +17 -0
- package/lib/module/ui/hooks/use-top-inset.js.map +1 -0
- package/lib/module/ui/screens/chat/ChatView.js +31 -3
- package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/module/ui/screens/home/HomeView.js +44 -12
- package/lib/module/ui/screens/home/HomeView.js.map +1 -1
- package/lib/module/ui/screens/home/HomeView.styles.js +4 -2
- package/lib/module/ui/screens/home/HomeView.styles.js.map +1 -1
- package/lib/typescript/commonjs/api/client.d.ts +7 -1
- package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
- package/lib/typescript/commonjs/i18n.d.ts +13 -0
- package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
- package/lib/typescript/commonjs/locales.d.ts.map +1 -1
- package/lib/typescript/commonjs/state/widget-store.d.ts +18 -1
- package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/commonjs/types.d.ts +17 -0
- package/lib/typescript/commonjs/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/Gradient.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/RatingCard.d.ts +57 -0
- package/lib/typescript/commonjs/ui/components/RatingCard.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/hooks/use-top-inset.d.ts +11 -0
- package/lib/typescript/commonjs/ui/hooks/use-top-inset.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +11 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/home/HomeView.d.ts +7 -0
- package/lib/typescript/commonjs/ui/screens/home/HomeView.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/home/HomeView.styles.d.ts +3 -1
- package/lib/typescript/commonjs/ui/screens/home/HomeView.styles.d.ts.map +1 -1
- package/lib/typescript/module/api/client.d.ts +7 -1
- package/lib/typescript/module/api/client.d.ts.map +1 -1
- package/lib/typescript/module/i18n.d.ts +13 -0
- package/lib/typescript/module/i18n.d.ts.map +1 -1
- package/lib/typescript/module/locales.d.ts.map +1 -1
- package/lib/typescript/module/state/widget-store.d.ts +18 -1
- package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/module/types.d.ts +17 -0
- package/lib/typescript/module/types.d.ts.map +1 -1
- package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/Gradient.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/RatingCard.d.ts +57 -0
- package/lib/typescript/module/ui/components/RatingCard.d.ts.map +1 -0
- package/lib/typescript/module/ui/hooks/use-top-inset.d.ts +11 -0
- package/lib/typescript/module/ui/hooks/use-top-inset.d.ts.map +1 -0
- package/lib/typescript/module/ui/screens/chat/ChatView.d.ts +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +11 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/home/HomeView.d.ts +7 -0
- package/lib/typescript/module/ui/screens/home/HomeView.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/home/HomeView.styles.d.ts +3 -1
- package/lib/typescript/module/ui/screens/home/HomeView.styles.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/api/client.ts +13 -0
- package/src/i18n.ts +14 -0
- package/src/locales.ts +13 -0
- package/src/state/widget-store.ts +53 -4
- package/src/types.ts +19 -0
- package/src/ui/WidgetProvider.tsx +54 -4
- package/src/ui/components/Gradient.tsx +19 -2
- package/src/ui/components/RatingCard.tsx +355 -0
- package/src/ui/hooks/use-top-inset.ts +16 -0
- package/src/ui/screens/chat/ChatView.tsx +35 -2
- package/src/ui/screens/chat/ChatView.types.ts +11 -0
- package/src/ui/screens/home/HomeView.styles.ts +4 -2
- package/src/ui/screens/home/HomeView.tsx +36 -8
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversation rating card (CSAT), posted two minutes after a teammate closes
|
|
3
|
+
* the chat. Port of `client/src/widget/components/RatingCard.tsx`.
|
|
4
|
+
*
|
|
5
|
+
* Tapping a face saves it right away; after 😠/🙁 the visitor can also tap a
|
|
6
|
+
* reason, labelled in their language. A comment locks the rating. The server enforces the change window and answers
|
|
7
|
+
* 409 (locked) or 410 (expired), which the card shows instead of the faces'
|
|
8
|
+
* actions.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useState } from 'react';
|
|
12
|
+
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
|
|
13
|
+
|
|
14
|
+
import type { ConversationRatingResult, RatingReason } from '../../types';
|
|
15
|
+
import { contrastingTextColor, fontSize, palette, radius, spacing, withAlpha } from '../styles/theme';
|
|
16
|
+
|
|
17
|
+
/** 1..5, worst to best — Intercom's five faces. */
|
|
18
|
+
const RATING_SCALE = [
|
|
19
|
+
{ value: 1, emoji: '😠' },
|
|
20
|
+
{ value: 2, emoji: '🙁' },
|
|
21
|
+
{ value: 3, emoji: '😐' },
|
|
22
|
+
{ value: 4, emoji: '🙂' },
|
|
23
|
+
{ value: 5, emoji: '🤩' },
|
|
24
|
+
] as const;
|
|
25
|
+
|
|
26
|
+
export interface RatingCardLabels {
|
|
27
|
+
question: string;
|
|
28
|
+
reasonPrompt: string;
|
|
29
|
+
commentPlaceholder: string;
|
|
30
|
+
submit: string;
|
|
31
|
+
thanks: string;
|
|
32
|
+
locked: string;
|
|
33
|
+
expired: string;
|
|
34
|
+
failed: string;
|
|
35
|
+
/** Accessibility names for the faces, worst to best. */
|
|
36
|
+
scale: [string, string, string, string, string];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 1–2 asks for a reason; 3 is neutral, 4–5 good. */
|
|
40
|
+
export const isBadRating = (rating: number | null) => rating != null && rating <= 2;
|
|
41
|
+
|
|
42
|
+
const isRatingReason = (value: unknown): value is RatingReason =>
|
|
43
|
+
!!value &&
|
|
44
|
+
typeof value === 'object' &&
|
|
45
|
+
typeof (value as RatingReason).id === 'string' &&
|
|
46
|
+
!!(value as RatingReason).labels &&
|
|
47
|
+
typeof (value as RatingReason).labels === 'object';
|
|
48
|
+
|
|
49
|
+
/** The rating card carried by a system message's metadata, or null. */
|
|
50
|
+
export function getRatingRequest(metadata: Record<string, unknown> | null | undefined) {
|
|
51
|
+
if (!metadata || metadata.event !== 'rating_request' || typeof metadata.ratingId !== 'string') return null;
|
|
52
|
+
const rating = typeof metadata.rating === 'number' && metadata.rating >= 1 && metadata.rating <= 5 ? metadata.rating : null;
|
|
53
|
+
const comment = typeof metadata.ratingComment === 'string' && metadata.ratingComment ? metadata.ratingComment : null;
|
|
54
|
+
const reason = typeof metadata.ratingReason === 'string' && metadata.ratingReason ? metadata.ratingReason : null;
|
|
55
|
+
const reasons = Array.isArray(metadata.ratingReasons) ? metadata.ratingReasons.filter(isRatingReason) : [];
|
|
56
|
+
return { ratingId: metadata.ratingId, rating, comment, reason, reasons };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A reason's label in the first of `locales` it has (full code, then its base
|
|
61
|
+
* language: "ar-SA" → "ar"), else English, else whatever it has.
|
|
62
|
+
*/
|
|
63
|
+
export function reasonLabel(reason: RatingReason, locales: Array<string | null | undefined>): string {
|
|
64
|
+
for (const locale of [...locales, 'en']) {
|
|
65
|
+
if (!locale) continue;
|
|
66
|
+
const label = reason.labels[locale] || reason.labels[locale.split('-')[0]!];
|
|
67
|
+
if (label) return label;
|
|
68
|
+
}
|
|
69
|
+
return Object.values(reason.labels).find(Boolean) ?? reason.id;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface RatingCardProps {
|
|
73
|
+
ratingId: string;
|
|
74
|
+
initialRating: number | null;
|
|
75
|
+
initialComment: string | null;
|
|
76
|
+
initialReason: string | null;
|
|
77
|
+
/** Offered after a bad rating (1–2). */
|
|
78
|
+
reasons: RatingReason[];
|
|
79
|
+
/** The visitor's language first, then the widget's default. */
|
|
80
|
+
locales: Array<string | null | undefined>;
|
|
81
|
+
color: string;
|
|
82
|
+
labels: RatingCardLabels;
|
|
83
|
+
onRate?: (
|
|
84
|
+
ratingId: string,
|
|
85
|
+
data: { rating: number; reason?: string; comment?: string }
|
|
86
|
+
) => Promise<ConversationRatingResult>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function RatingCard({
|
|
90
|
+
ratingId,
|
|
91
|
+
initialRating,
|
|
92
|
+
initialComment,
|
|
93
|
+
initialReason,
|
|
94
|
+
reasons,
|
|
95
|
+
locales,
|
|
96
|
+
color,
|
|
97
|
+
labels,
|
|
98
|
+
onRate,
|
|
99
|
+
}: RatingCardProps) {
|
|
100
|
+
const [rating, setRating] = useState<number | null>(initialRating);
|
|
101
|
+
const [reason, setReason] = useState<string | null>(initialReason);
|
|
102
|
+
const [comment, setComment] = useState<string | null>(initialComment);
|
|
103
|
+
const [draft, setDraft] = useState('');
|
|
104
|
+
const [pending, setPending] = useState(false);
|
|
105
|
+
const [closedReason, setClosedReason] = useState<'locked' | 'expired' | null>(initialComment ? 'locked' : null);
|
|
106
|
+
const [failed, setFailed] = useState(false);
|
|
107
|
+
const interactive = !!onRate && !closedReason && !pending;
|
|
108
|
+
const canSubmit = !pending && draft.trim().length > 0;
|
|
109
|
+
|
|
110
|
+
const submit = async (next: { rating: number; reason?: string; comment?: string }) => {
|
|
111
|
+
if (!onRate || pending) return;
|
|
112
|
+
setPending(true);
|
|
113
|
+
setFailed(false);
|
|
114
|
+
try {
|
|
115
|
+
const result = await onRate(ratingId, next);
|
|
116
|
+
setRating(result.rating);
|
|
117
|
+
setReason(result.reason ?? null);
|
|
118
|
+
setComment(result.comment);
|
|
119
|
+
if (result.locked) setClosedReason('locked');
|
|
120
|
+
setDraft('');
|
|
121
|
+
} catch (error) {
|
|
122
|
+
const status = (error as { status?: number }).status;
|
|
123
|
+
if (status === 409) setClosedReason('locked');
|
|
124
|
+
else if (status === 410) setClosedReason('expired');
|
|
125
|
+
else setFailed(true);
|
|
126
|
+
} finally {
|
|
127
|
+
setPending(false);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const status =
|
|
132
|
+
closedReason === 'expired'
|
|
133
|
+
? labels.expired
|
|
134
|
+
: closedReason === 'locked' && !comment
|
|
135
|
+
? labels.locked
|
|
136
|
+
: failed
|
|
137
|
+
? labels.failed
|
|
138
|
+
: rating != null
|
|
139
|
+
? labels.thanks
|
|
140
|
+
: null;
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<View style={styles.row}>
|
|
144
|
+
<View style={styles.card} testID="repzo-rating-card">
|
|
145
|
+
<Text style={styles.question}>{labels.question}</Text>
|
|
146
|
+
<View style={styles.faces} accessibilityRole="radiogroup">
|
|
147
|
+
{RATING_SCALE.map((entry, index) => {
|
|
148
|
+
const selected = rating === entry.value;
|
|
149
|
+
return (
|
|
150
|
+
<Pressable
|
|
151
|
+
key={entry.value}
|
|
152
|
+
accessibilityRole="radio"
|
|
153
|
+
accessibilityLabel={labels.scale[index]}
|
|
154
|
+
accessibilityState={{ checked: selected, disabled: !interactive }}
|
|
155
|
+
disabled={!interactive}
|
|
156
|
+
hitSlop={4}
|
|
157
|
+
onPress={() => {
|
|
158
|
+
if (!selected) void submit({ rating: entry.value });
|
|
159
|
+
}}
|
|
160
|
+
style={({ pressed }) => [
|
|
161
|
+
styles.face,
|
|
162
|
+
selected && { borderColor: color, backgroundColor: withAlpha(color, 0.08) },
|
|
163
|
+
rating != null && !selected && styles.faceDimmed,
|
|
164
|
+
pressed && styles.facePressed,
|
|
165
|
+
]}>
|
|
166
|
+
<Text style={styles.emoji}>{entry.emoji}</Text>
|
|
167
|
+
</Pressable>
|
|
168
|
+
);
|
|
169
|
+
})}
|
|
170
|
+
</View>
|
|
171
|
+
|
|
172
|
+
{status ? <Text style={[styles.status, failed && styles.statusFailed]}>{status}</Text> : null}
|
|
173
|
+
|
|
174
|
+
{isBadRating(rating) && reasons.length > 0 && (interactive || reason) ? (
|
|
175
|
+
<View style={styles.reasons}>
|
|
176
|
+
{interactive ? <Text style={styles.reasonPrompt}>{labels.reasonPrompt}</Text> : null}
|
|
177
|
+
<View style={styles.chips} accessibilityRole="radiogroup" accessibilityLabel={labels.reasonPrompt}>
|
|
178
|
+
{reasons
|
|
179
|
+
.filter((item) => interactive || item.id === reason)
|
|
180
|
+
.map((item) => {
|
|
181
|
+
const selected = item.id === reason;
|
|
182
|
+
return (
|
|
183
|
+
<Pressable
|
|
184
|
+
key={item.id}
|
|
185
|
+
accessibilityRole="radio"
|
|
186
|
+
accessibilityState={{ checked: selected, disabled: !interactive }}
|
|
187
|
+
disabled={!interactive}
|
|
188
|
+
onPress={() => {
|
|
189
|
+
if (!selected && rating != null) void submit({ rating, reason: item.id });
|
|
190
|
+
}}
|
|
191
|
+
style={({ pressed }) => [
|
|
192
|
+
styles.chip,
|
|
193
|
+
selected && { backgroundColor: color, borderColor: color },
|
|
194
|
+
pressed && styles.facePressed,
|
|
195
|
+
]}>
|
|
196
|
+
<Text style={[styles.chipText, selected && { color: contrastingTextColor(color) }]}>
|
|
197
|
+
{reasonLabel(item, locales)}
|
|
198
|
+
</Text>
|
|
199
|
+
</Pressable>
|
|
200
|
+
);
|
|
201
|
+
})}
|
|
202
|
+
</View>
|
|
203
|
+
</View>
|
|
204
|
+
) : null}
|
|
205
|
+
|
|
206
|
+
{comment ? (
|
|
207
|
+
<Text style={styles.comment}>{comment}</Text>
|
|
208
|
+
) : rating != null && !closedReason ? (
|
|
209
|
+
<View style={styles.form}>
|
|
210
|
+
<TextInput
|
|
211
|
+
value={draft}
|
|
212
|
+
onChangeText={setDraft}
|
|
213
|
+
editable={!pending}
|
|
214
|
+
multiline
|
|
215
|
+
maxLength={2000}
|
|
216
|
+
placeholder={labels.commentPlaceholder}
|
|
217
|
+
placeholderTextColor={palette.textSubtle}
|
|
218
|
+
style={styles.input}
|
|
219
|
+
/>
|
|
220
|
+
<Pressable
|
|
221
|
+
accessibilityRole="button"
|
|
222
|
+
disabled={!canSubmit}
|
|
223
|
+
onPress={() => {
|
|
224
|
+
const text = draft.trim();
|
|
225
|
+
if (text && rating != null) void submit({ rating, comment: text });
|
|
226
|
+
}}
|
|
227
|
+
style={({ pressed }) => [
|
|
228
|
+
styles.submit,
|
|
229
|
+
{ backgroundColor: color },
|
|
230
|
+
!canSubmit && styles.submitDisabled,
|
|
231
|
+
pressed && canSubmit && styles.facePressed,
|
|
232
|
+
]}>
|
|
233
|
+
<Text style={[styles.submitText, { color: contrastingTextColor(color) }]}>{labels.submit}</Text>
|
|
234
|
+
</Pressable>
|
|
235
|
+
</View>
|
|
236
|
+
) : null}
|
|
237
|
+
</View>
|
|
238
|
+
</View>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const styles = StyleSheet.create({
|
|
243
|
+
row: {
|
|
244
|
+
alignItems: 'center',
|
|
245
|
+
paddingVertical: spacing.md,
|
|
246
|
+
},
|
|
247
|
+
card: {
|
|
248
|
+
width: '100%',
|
|
249
|
+
maxWidth: 340,
|
|
250
|
+
gap: spacing.md,
|
|
251
|
+
padding: spacing.lg,
|
|
252
|
+
borderRadius: radius.xl,
|
|
253
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
254
|
+
borderColor: palette.border,
|
|
255
|
+
backgroundColor: palette.white,
|
|
256
|
+
},
|
|
257
|
+
question: {
|
|
258
|
+
textAlign: 'center',
|
|
259
|
+
color: palette.ink,
|
|
260
|
+
fontSize: fontSize.body,
|
|
261
|
+
fontWeight: '600',
|
|
262
|
+
},
|
|
263
|
+
faces: {
|
|
264
|
+
flexDirection: 'row',
|
|
265
|
+
justifyContent: 'center',
|
|
266
|
+
gap: spacing.sm,
|
|
267
|
+
},
|
|
268
|
+
face: {
|
|
269
|
+
width: 48,
|
|
270
|
+
height: 48,
|
|
271
|
+
borderRadius: 24,
|
|
272
|
+
borderWidth: 2,
|
|
273
|
+
borderColor: 'transparent',
|
|
274
|
+
alignItems: 'center',
|
|
275
|
+
justifyContent: 'center',
|
|
276
|
+
},
|
|
277
|
+
faceDimmed: {
|
|
278
|
+
opacity: 0.45,
|
|
279
|
+
},
|
|
280
|
+
facePressed: {
|
|
281
|
+
opacity: 0.7,
|
|
282
|
+
},
|
|
283
|
+
emoji: {
|
|
284
|
+
fontSize: 28,
|
|
285
|
+
},
|
|
286
|
+
status: {
|
|
287
|
+
textAlign: 'center',
|
|
288
|
+
color: palette.textMuted,
|
|
289
|
+
fontSize: fontSize.meta,
|
|
290
|
+
},
|
|
291
|
+
statusFailed: {
|
|
292
|
+
color: palette.danger,
|
|
293
|
+
},
|
|
294
|
+
reasons: {
|
|
295
|
+
gap: spacing.sm,
|
|
296
|
+
},
|
|
297
|
+
reasonPrompt: {
|
|
298
|
+
color: palette.ink,
|
|
299
|
+
fontSize: fontSize.meta,
|
|
300
|
+
fontWeight: '600',
|
|
301
|
+
},
|
|
302
|
+
chips: {
|
|
303
|
+
flexDirection: 'row',
|
|
304
|
+
flexWrap: 'wrap',
|
|
305
|
+
gap: spacing.sm,
|
|
306
|
+
},
|
|
307
|
+
chip: {
|
|
308
|
+
paddingHorizontal: spacing.md,
|
|
309
|
+
paddingVertical: spacing.sm,
|
|
310
|
+
borderRadius: radius.pill,
|
|
311
|
+
borderWidth: 1,
|
|
312
|
+
borderColor: palette.border,
|
|
313
|
+
backgroundColor: palette.white,
|
|
314
|
+
},
|
|
315
|
+
chipText: {
|
|
316
|
+
color: palette.ink,
|
|
317
|
+
fontSize: fontSize.meta,
|
|
318
|
+
},
|
|
319
|
+
comment: {
|
|
320
|
+
paddingHorizontal: spacing.md,
|
|
321
|
+
paddingVertical: spacing.sm,
|
|
322
|
+
borderRadius: radius.md,
|
|
323
|
+
backgroundColor: palette.surfaceAlt,
|
|
324
|
+
color: palette.ink,
|
|
325
|
+
fontSize: fontSize.meta,
|
|
326
|
+
writingDirection: 'auto',
|
|
327
|
+
},
|
|
328
|
+
form: {
|
|
329
|
+
gap: spacing.sm,
|
|
330
|
+
},
|
|
331
|
+
input: {
|
|
332
|
+
minHeight: 64,
|
|
333
|
+
paddingHorizontal: spacing.md,
|
|
334
|
+
paddingVertical: spacing.sm,
|
|
335
|
+
borderRadius: radius.md,
|
|
336
|
+
borderWidth: 1,
|
|
337
|
+
borderColor: palette.border,
|
|
338
|
+
color: palette.ink,
|
|
339
|
+
fontSize: fontSize.input,
|
|
340
|
+
textAlignVertical: 'top',
|
|
341
|
+
},
|
|
342
|
+
submit: {
|
|
343
|
+
alignSelf: 'flex-end',
|
|
344
|
+
paddingHorizontal: spacing.lg,
|
|
345
|
+
paddingVertical: spacing.sm,
|
|
346
|
+
borderRadius: radius.pill,
|
|
347
|
+
},
|
|
348
|
+
submitDisabled: {
|
|
349
|
+
opacity: 0.5,
|
|
350
|
+
},
|
|
351
|
+
submitText: {
|
|
352
|
+
fontSize: fontSize.meta,
|
|
353
|
+
fontWeight: '600',
|
|
354
|
+
},
|
|
355
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How far a screen's header must reach up to sit under the status bar.
|
|
3
|
+
*
|
|
4
|
+
* The messenger draws edge to edge. Screens with a brand header (home, chat)
|
|
5
|
+
* extend that header's background into the status-bar area instead of leaving
|
|
6
|
+
* a white strip above it; the provider gives them the inset through this
|
|
7
|
+
* context and pads every other screen itself, so this is 0 for them.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createContext, useContext } from 'react';
|
|
11
|
+
|
|
12
|
+
export const TopInsetContext = createContext(0);
|
|
13
|
+
|
|
14
|
+
export function useTopInset(): number {
|
|
15
|
+
return useContext(TopInsetContext);
|
|
16
|
+
}
|
|
@@ -45,14 +45,16 @@ import { AttachmentChips } from '../../components/AttachmentChips';
|
|
|
45
45
|
import { AttachmentMenu, showNativeAttachmentMenu } from '../../components/AttachmentMenu';
|
|
46
46
|
import { Avatar } from '../../components/Avatar';
|
|
47
47
|
import { ImageViewer } from '../../components/ImageViewer';
|
|
48
|
+
import { getRatingRequest, RatingCard } from '../../components/RatingCard';
|
|
48
49
|
import { WidgetErrorBoundary } from '../../components/ErrorBoundary';
|
|
49
50
|
import { GradientBackground } from '../../components/Gradient';
|
|
50
51
|
import { ArrowUpIcon, ChatOutlineIcon, ChevronIcon, ClockIcon, PaperclipIcon } from '../../components/icons';
|
|
51
52
|
import { TeamAvatars } from '../../components/TeamAvatars';
|
|
52
53
|
import { Markdown } from '../../components/Markdown';
|
|
53
54
|
import { useReduceMotion } from '../../hooks/use-reduce-motion';
|
|
55
|
+
import { useTopInset } from '../../hooks/use-top-inset';
|
|
54
56
|
import { globalStyles } from '../../styles/global';
|
|
55
|
-
import { contrastingTextColor, mixHex, palette, withAlpha } from '../../styles/theme';
|
|
57
|
+
import { contrastingTextColor, mixHex, palette, spacing, withAlpha } from '../../styles/theme';
|
|
56
58
|
import { formatClockTime, formatDayLabel, isSameDay } from '../../utils/conversations';
|
|
57
59
|
import { stripHtml } from '../../utils/html';
|
|
58
60
|
import { isHttpUrl, openExternalUrl } from '../../utils/safe-url';
|
|
@@ -81,6 +83,8 @@ export function ChatView({
|
|
|
81
83
|
onPickAttachments,
|
|
82
84
|
onSendMessage,
|
|
83
85
|
onIdentifyVisitor,
|
|
86
|
+
onRateConversation,
|
|
87
|
+
defaultLocale,
|
|
84
88
|
onLoadMore,
|
|
85
89
|
onTyping,
|
|
86
90
|
onBack,
|
|
@@ -327,6 +331,7 @@ export function ChatView({
|
|
|
327
331
|
.find((message) => message.direction === 'inbound' && (message.messageType ?? 'message') === 'message')?.id;
|
|
328
332
|
// Two stops off the brand color: flat fill looked like an unstyled block.
|
|
329
333
|
// A non-hex brand color falls through mixHex unchanged and renders solid.
|
|
334
|
+
const topInset = useTopInset();
|
|
330
335
|
const headerGradient = [color, mixHex(color, '#000000', 0.16)];
|
|
331
336
|
const headerControl = { backgroundColor: withAlpha(headerTextColor, 0.16) };
|
|
332
337
|
|
|
@@ -336,7 +341,12 @@ export function ChatView({
|
|
|
336
341
|
was empty whenever no agent was assigned yet. */}
|
|
337
342
|
<GradientBackground
|
|
338
343
|
colors={headerGradient}
|
|
339
|
-
style={[
|
|
344
|
+
style={[
|
|
345
|
+
styles.header,
|
|
346
|
+
{ borderBottomColor: mixHex(color, '#000000', 0.26) },
|
|
347
|
+
// Reaches under the status bar so the brand colour runs to the top.
|
|
348
|
+
topInset > 0 ? { paddingTop: spacing.md + topInset, minHeight: 72 + topInset } : null,
|
|
349
|
+
]}>
|
|
340
350
|
<Pressable
|
|
341
351
|
onPress={onBack}
|
|
342
352
|
accessibilityRole="button"
|
|
@@ -482,6 +492,8 @@ export function ChatView({
|
|
|
482
492
|
color={color}
|
|
483
493
|
labels={labels}
|
|
484
494
|
onOpenImage={setViewerImage}
|
|
495
|
+
onRateConversation={onRateConversation}
|
|
496
|
+
locales={[locale, defaultLocale]}
|
|
485
497
|
senderLabel={
|
|
486
498
|
isAgentMessage && (startsRun || startsDay)
|
|
487
499
|
? message.senderName || agentName || labels.team
|
|
@@ -633,6 +645,8 @@ function MessageBubble({
|
|
|
633
645
|
onSubmitProcedureForm,
|
|
634
646
|
onAgentUiAction,
|
|
635
647
|
onOpenImage,
|
|
648
|
+
onRateConversation,
|
|
649
|
+
locales,
|
|
636
650
|
}: {
|
|
637
651
|
message: Message;
|
|
638
652
|
color: string;
|
|
@@ -643,6 +657,8 @@ function MessageBubble({
|
|
|
643
657
|
onSubmitProcedureForm?: (body: string) => void;
|
|
644
658
|
onAgentUiAction?: (action: AgentUiAction) => void | Promise<void>;
|
|
645
659
|
onOpenImage: (image: { url: string; name: string }) => void;
|
|
660
|
+
onRateConversation?: ChatViewProps['onRateConversation'];
|
|
661
|
+
locales: Array<string | null | undefined>;
|
|
646
662
|
}) {
|
|
647
663
|
const joinedName = message.metadata?.event === 'agent_joined' ? message.metadata?.agentName : null;
|
|
648
664
|
if (message.messageType === 'system' && typeof joinedName === 'string' && joinedName) {
|
|
@@ -661,6 +677,23 @@ function MessageBubble({
|
|
|
661
677
|
);
|
|
662
678
|
}
|
|
663
679
|
|
|
680
|
+
const ratingRequest = message.messageType === 'system' ? getRatingRequest(message.metadata) : null;
|
|
681
|
+
if (ratingRequest) {
|
|
682
|
+
return (
|
|
683
|
+
<RatingCard
|
|
684
|
+
ratingId={ratingRequest.ratingId}
|
|
685
|
+
initialRating={ratingRequest.rating}
|
|
686
|
+
initialComment={ratingRequest.comment}
|
|
687
|
+
initialReason={ratingRequest.reason}
|
|
688
|
+
reasons={ratingRequest.reasons}
|
|
689
|
+
locales={locales}
|
|
690
|
+
color={color}
|
|
691
|
+
labels={labels.rating}
|
|
692
|
+
onRate={onRateConversation ? (ratingId, data) => onRateConversation(message.id, ratingId, data) : undefined}
|
|
693
|
+
/>
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
|
|
664
697
|
if (message.messageType === 'system') {
|
|
665
698
|
const text = message.bodyPlain || stripHtml(message.body || '') || labels.conversationUpdated;
|
|
666
699
|
return (
|
|
@@ -5,11 +5,13 @@ import type { DraftAttachment, PickResult } from '../../../attachments/pickers';
|
|
|
5
5
|
import type {
|
|
6
6
|
AgentUiAction,
|
|
7
7
|
AttachmentSource,
|
|
8
|
+
ConversationRatingResult,
|
|
8
9
|
Message,
|
|
9
10
|
OutgoingAttachment,
|
|
10
11
|
} from '../../../types';
|
|
11
12
|
import type { AgentUiBlockLabels } from '../../components/AgentUiBlocks';
|
|
12
13
|
import type { ImageViewerLabels } from '../../components/ImageViewer';
|
|
14
|
+
import type { RatingCardLabels } from '../../components/RatingCard';
|
|
13
15
|
|
|
14
16
|
export interface ChatLabels {
|
|
15
17
|
back: string;
|
|
@@ -34,6 +36,7 @@ export interface ChatLabels {
|
|
|
34
36
|
procedureSend: string;
|
|
35
37
|
procedureSent: string;
|
|
36
38
|
blocks: AgentUiBlockLabels;
|
|
39
|
+
rating: RatingCardLabels;
|
|
37
40
|
attach: AttachLabels;
|
|
38
41
|
viewer: ImageViewerLabels;
|
|
39
42
|
}
|
|
@@ -88,6 +91,14 @@ export interface ChatViewProps {
|
|
|
88
91
|
action?: AgentUiAction
|
|
89
92
|
) => Promise<void>;
|
|
90
93
|
onIdentifyVisitor?: (data: { email?: string; name?: string; phone?: string }) => Promise<void>;
|
|
94
|
+
/** Answers a rating card; rejects with the server error (409 locked, 410 expired). */
|
|
95
|
+
onRateConversation?: (
|
|
96
|
+
messageId: string,
|
|
97
|
+
ratingId: string,
|
|
98
|
+
data: { rating: number; reason?: string; comment?: string }
|
|
99
|
+
) => Promise<ConversationRatingResult>;
|
|
100
|
+
/** The widget's default language — rating reasons fall back to it. */
|
|
101
|
+
defaultLocale?: string;
|
|
91
102
|
onLoadMore: () => void;
|
|
92
103
|
onTyping: (isTyping: boolean) => void;
|
|
93
104
|
onBack: () => void;
|
|
@@ -23,6 +23,9 @@ export const START_CARD_LIFT = 28;
|
|
|
23
23
|
export const START_CARD_MIN_HEIGHT = 76;
|
|
24
24
|
export const HEADER_FADE_HEIGHT = 90;
|
|
25
25
|
export const CLOSE_BUTTON_SIZE = 28;
|
|
26
|
+
/** The logo keeps its own shape at this height, up to the web widget's max width. */
|
|
27
|
+
export const LOGO_HEIGHT = 40;
|
|
28
|
+
export const LOGO_MAX_WIDTH = 152;
|
|
26
29
|
export const CLOSE_ICON_SIZE = 14;
|
|
27
30
|
|
|
28
31
|
export const styles = StyleSheet.create({
|
|
@@ -58,8 +61,7 @@ export const styles = StyleSheet.create({
|
|
|
58
61
|
minHeight: 40,
|
|
59
62
|
},
|
|
60
63
|
logo: {
|
|
61
|
-
|
|
62
|
-
height: 40,
|
|
64
|
+
height: LOGO_HEIGHT,
|
|
63
65
|
},
|
|
64
66
|
closeButton: {
|
|
65
67
|
marginStart: 'auto',
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* send today.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { Fragment, useEffect, useRef, type ReactNode } from 'react';
|
|
16
|
+
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react';
|
|
17
17
|
import {
|
|
18
18
|
Animated,
|
|
19
19
|
Easing,
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
CloseIcon,
|
|
38
38
|
} from '../../components/icons';
|
|
39
39
|
import { useReduceMotion } from '../../hooks/use-reduce-motion';
|
|
40
|
+
import { useTopInset } from '../../hooks/use-top-inset';
|
|
40
41
|
import { globalStyles } from '../../styles/global';
|
|
41
42
|
import { contrastingTextColor, palette, spacing, withAlpha } from '../../styles/theme';
|
|
42
43
|
import { flipForRTL } from '../../utils/rtl';
|
|
@@ -45,6 +46,9 @@ import {
|
|
|
45
46
|
CLOSE_ICON_SIZE,
|
|
46
47
|
HEADER_BOTTOM_PADDING,
|
|
47
48
|
HEADER_FADE_HEIGHT,
|
|
49
|
+
HEADER_MIN_HEIGHT,
|
|
50
|
+
LOGO_HEIGHT,
|
|
51
|
+
LOGO_MAX_WIDTH,
|
|
48
52
|
START_CARD_LIFT,
|
|
49
53
|
styles,
|
|
50
54
|
} from './HomeView.styles';
|
|
@@ -55,7 +59,7 @@ import type { HomeViewProps } from './HomeView.types';
|
|
|
55
59
|
* it, and defaulting to dark text over a dark brand header is unreadable, so
|
|
56
60
|
* derive something legible from the background instead.
|
|
57
61
|
*/
|
|
58
|
-
function resolveHeaderTextColor(config: WidgetConfig | null, fallbackColor: string): string {
|
|
62
|
+
export function resolveHeaderTextColor(config: WidgetConfig | null, fallbackColor: string): string {
|
|
59
63
|
if (config?.homeHeaderTextColor === 'white') return palette.white;
|
|
60
64
|
if (config?.homeHeaderTextColor === 'black') return palette.ink;
|
|
61
65
|
if (config?.homeBackgroundType === 'image') return palette.white;
|
|
@@ -116,8 +120,14 @@ export function HomeView({
|
|
|
116
120
|
|
|
117
121
|
// The start card is pulled up over the seam by a fixed amount, so the header
|
|
118
122
|
// pads its bottom by that same amount and the greeting never slides under it.
|
|
123
|
+
// The header reaches up under the status bar (no white strip above it), so
|
|
124
|
+
// it grows by that inset and keeps its content where it was.
|
|
125
|
+
const topInset = useTopInset();
|
|
119
126
|
const headerStyle = [
|
|
120
127
|
styles.header,
|
|
128
|
+
topInset > 0
|
|
129
|
+
? { paddingTop: spacing.xl + topInset, minHeight: HEADER_MIN_HEIGHT + topInset }
|
|
130
|
+
: null,
|
|
121
131
|
allowNewConversations ? { paddingBottom: HEADER_BOTTOM_PADDING + START_CARD_LIFT } : null,
|
|
122
132
|
];
|
|
123
133
|
|
|
@@ -125,12 +135,7 @@ export function HomeView({
|
|
|
125
135
|
<>
|
|
126
136
|
<View style={styles.headerTop}>
|
|
127
137
|
{config?.homeLogoUrl ? (
|
|
128
|
-
<
|
|
129
|
-
source={{ uri: config.homeLogoUrl }}
|
|
130
|
-
style={styles.logo}
|
|
131
|
-
resizeMode="contain"
|
|
132
|
-
accessibilityIgnoresInvertColors
|
|
133
|
-
/>
|
|
138
|
+
<HomeLogo key={config.homeLogoUrl} uri={config.homeLogoUrl} />
|
|
134
139
|
) : (
|
|
135
140
|
<View />
|
|
136
141
|
)}
|
|
@@ -296,6 +301,29 @@ export function HomeView({
|
|
|
296
301
|
);
|
|
297
302
|
}
|
|
298
303
|
|
|
304
|
+
/**
|
|
305
|
+
* The workspace logo at its own shape, flush with the greeting's left edge.
|
|
306
|
+
* A fixed-width box with `contain` centred a square logo inside it, so it
|
|
307
|
+
* floated in from the edge; the web widget sizes it to the image instead.
|
|
308
|
+
* Square until the image reports its size, then as wide as its ratio needs,
|
|
309
|
+
* capped like the web widget.
|
|
310
|
+
*/
|
|
311
|
+
function HomeLogo({ uri }: { uri: string }) {
|
|
312
|
+
const [aspectRatio, setAspectRatio] = useState(1);
|
|
313
|
+
return (
|
|
314
|
+
<Image
|
|
315
|
+
source={{ uri }}
|
|
316
|
+
style={[styles.logo, { width: Math.min(LOGO_MAX_WIDTH, LOGO_HEIGHT * aspectRatio) }]}
|
|
317
|
+
resizeMode="contain"
|
|
318
|
+
accessibilityIgnoresInvertColors
|
|
319
|
+
onLoad={(event) => {
|
|
320
|
+
const { width, height } = event.nativeEvent.source ?? {};
|
|
321
|
+
if (width && height) setAspectRatio(width / height);
|
|
322
|
+
}}
|
|
323
|
+
/>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
299
327
|
/**
|
|
300
328
|
* Staggered entrance for the body sections. Opacity and translate only, so the
|
|
301
329
|
* whole thing runs on the native driver and never touches layout.
|