@giovchat/react-native 0.2.0 → 0.3.0
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/README.md +66 -9
- package/dist/GiovChat.d.ts +35 -0
- package/dist/GiovChat.d.ts.map +1 -1
- package/dist/GiovChat.js +45 -8
- package/dist/GiovChat.js.map +1 -1
- package/dist/brandingStyle.d.ts +38 -0
- package/dist/brandingStyle.d.ts.map +1 -0
- package/dist/brandingStyle.js +71 -0
- package/dist/brandingStyle.js.map +1 -0
- package/dist/client.d.ts +38 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +91 -1
- package/dist/client.js.map +1 -1
- package/dist/components/Branding.d.ts +24 -1
- package/dist/components/Branding.d.ts.map +1 -1
- package/dist/components/Branding.js +49 -6
- package/dist/components/Branding.js.map +1 -1
- package/dist/components/MessageBubble.d.ts +8 -1
- package/dist/components/MessageBubble.d.ts.map +1 -1
- package/dist/components/MessageBubble.js +19 -17
- package/dist/components/MessageBubble.js.map +1 -1
- package/dist/components/RatingCard.d.ts +23 -0
- package/dist/components/RatingCard.d.ts.map +1 -0
- package/dist/components/RatingCard.js +107 -0
- package/dist/components/RatingCard.js.map +1 -0
- package/dist/errors.d.ts +3 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/i18n.d.ts +23 -0
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +100 -0
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +12 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -1
- package/dist/index.js.map +1 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +5 -0
- package/dist/protocol.js.map +1 -1
- package/dist/rating.d.ts +41 -0
- package/dist/rating.d.ts.map +1 -0
- package/dist/rating.js +165 -0
- package/dist/rating.js.map +1 -0
- package/dist/ratingStore.d.ts +36 -0
- package/dist/ratingStore.d.ts.map +1 -0
- package/dist/ratingStore.js +55 -0
- package/dist/ratingStore.js.map +1 -0
- package/dist/styleOverrides.d.ts +10 -2
- package/dist/styleOverrides.d.ts.map +1 -1
- package/dist/styleOverrides.js +9 -0
- package/dist/styleOverrides.js.map +1 -1
- package/dist/types.d.ts +45 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/useChatSession.d.ts +18 -1
- package/dist/useChatSession.d.ts.map +1 -1
- package/dist/useChatSession.js +178 -9
- package/dist/useChatSession.js.map +1 -1
- package/package.json +3 -2
- package/src/GiovChat.tsx +121 -11
- package/src/brandingStyle.ts +87 -0
- package/src/client.ts +92 -2
- package/src/components/Branding.tsx +66 -7
- package/src/components/MessageBubble.tsx +17 -1
- package/src/components/RatingCard.tsx +219 -0
- package/src/errors.ts +4 -1
- package/src/i18n.ts +123 -0
- package/src/index.ts +17 -0
- package/src/protocol.ts +5 -0
- package/src/rating.ts +169 -0
- package/src/ratingStore.ts +65 -0
- package/src/styleOverrides.ts +15 -2
- package/src/types.ts +51 -0
- package/src/useChatSession.ts +152 -6
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { Text, TextInput, TouchableOpacity, View } from "react-native";
|
|
3
|
+
import type { GiovFeedbackValue, GiovRatingBlock } from "../types";
|
|
4
|
+
import { useGiovChatStrings } from "../i18n";
|
|
5
|
+
import { GiovChatError } from "../errors";
|
|
6
|
+
import { MAX_FEEDBACK_REASON, MAX_RATING_COMMENT, nextFeedback, ratingFailure, ratingPhase } from "../rating";
|
|
7
|
+
import { textOn } from "../color";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The conversation rating card (a `rating` block) and the thumbs under GiovBot answers. The card keeps
|
|
11
|
+
* the visitor's pick on screen while the answer is on its way and takes it back, with a short message,
|
|
12
|
+
* when it fails. Faces are emoji, so nothing native is needed (Expo Go included).
|
|
13
|
+
*/
|
|
14
|
+
const FACES = ["😞", "🙁", "😐", "🙂", "😄"];
|
|
15
|
+
|
|
16
|
+
export type RateFn = (requestId: string, score: number, comment: string) => Promise<void>;
|
|
17
|
+
export type VoteFn = (messageId: string, value: GiovFeedbackValue, reason?: string) => Promise<void>;
|
|
18
|
+
|
|
19
|
+
export interface RatingCardProps {
|
|
20
|
+
block: GiovRatingBlock;
|
|
21
|
+
primaryColor: string;
|
|
22
|
+
dark: boolean;
|
|
23
|
+
/** Without it the card is read-only (a hidden or unsupported feature). */
|
|
24
|
+
onRate?: RateFn;
|
|
25
|
+
/** Where the card was drawn; sets the test ids. */
|
|
26
|
+
testIDPrefix?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function RatingCard({ block, primaryColor, dark, onRate, testIDPrefix = "giov-rating" }: RatingCardProps) {
|
|
30
|
+
const strings = useGiovChatStrings();
|
|
31
|
+
const [now, setNow] = useState(() => Date.now());
|
|
32
|
+
const [pick, setPick] = useState(0);
|
|
33
|
+
const [comment, setComment] = useState("");
|
|
34
|
+
const [editing, setEditing] = useState(false);
|
|
35
|
+
const [pending, setPending] = useState<{ score: number; comment: string } | null>(null);
|
|
36
|
+
const [error, setError] = useState("");
|
|
37
|
+
const [lock, setLock] = useState<"" | "closed" | "replaced">("");
|
|
38
|
+
|
|
39
|
+
// Read-only from `closesAt` on, without waiting for another render.
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const wait = block.closesAt - Date.now();
|
|
42
|
+
if (wait <= 0) return;
|
|
43
|
+
const timer = setTimeout(() => setNow(Date.now()), Math.min(wait, 2 ** 31 - 1));
|
|
44
|
+
return () => clearTimeout(timer);
|
|
45
|
+
}, [block.closesAt, now]);
|
|
46
|
+
|
|
47
|
+
const ink = dark ? "#f0f0f0" : "#111";
|
|
48
|
+
const quiet = dark ? "#aaa" : "#555";
|
|
49
|
+
const border = dark ? "#3a3a3a" : "#d4d4d4";
|
|
50
|
+
const label = (score: number) => strings.ratingFace(score);
|
|
51
|
+
|
|
52
|
+
if (lock === "replaced" || ratingPhase(block, now) === "hidden") return null;
|
|
53
|
+
const closed = lock === "closed" || ratingPhase(block, now) === "closed";
|
|
54
|
+
const score = pending?.score ?? block.score;
|
|
55
|
+
const text = pending ? pending.comment : block.comment;
|
|
56
|
+
|
|
57
|
+
if (score && !editing) {
|
|
58
|
+
return (
|
|
59
|
+
<View testID={`${testIDPrefix}-done`} accessibilityRole="summary" accessibilityLabel={`${strings.yourRating}: ${label(score)}`} style={{ marginTop: 8, gap: 6 }}>
|
|
60
|
+
<Text style={{ color: ink, fontSize: 14, fontWeight: "600" }}>
|
|
61
|
+
<Text accessibilityElementsHidden importantForAccessibility="no-hide-descendants">{FACES[score - 1]} </Text>
|
|
62
|
+
{strings.ratingThanks} · {label(score)}
|
|
63
|
+
</Text>
|
|
64
|
+
{text ? <Text style={{ color: quiet, fontSize: 13 }}>{text}</Text> : null}
|
|
65
|
+
{!closed && onRate ? (
|
|
66
|
+
<TouchableOpacity
|
|
67
|
+
testID={`${testIDPrefix}-change`}
|
|
68
|
+
accessibilityRole="button"
|
|
69
|
+
accessibilityLabel={strings.ratingChange}
|
|
70
|
+
disabled={!!pending}
|
|
71
|
+
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
|
72
|
+
onPress={() => { setPick(score); setComment(text ?? ""); setError(""); setEditing(true); }}
|
|
73
|
+
>
|
|
74
|
+
<Text style={{ color: primaryColor, fontSize: 13, fontWeight: "600" }}>{strings.ratingChange}</Text>
|
|
75
|
+
</TouchableOpacity>
|
|
76
|
+
) : null}
|
|
77
|
+
</View>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
if (closed) return <Text testID={`${testIDPrefix}-closed`} style={{ color: quiet, fontSize: 13, marginTop: 8 }}>{strings.ratingClosed}</Text>;
|
|
81
|
+
|
|
82
|
+
const submit = async () => {
|
|
83
|
+
if (!pick || pending || !onRate) return;
|
|
84
|
+
const next = { score: pick, comment: comment.trim() };
|
|
85
|
+
setError("");
|
|
86
|
+
setPending(next);
|
|
87
|
+
setEditing(false);
|
|
88
|
+
try {
|
|
89
|
+
await onRate(block.requestId, next.score, next.comment);
|
|
90
|
+
} catch (failure) {
|
|
91
|
+
const code = failure instanceof GiovChatError ? failure.code : "network";
|
|
92
|
+
const kind = ratingFailure(code);
|
|
93
|
+
if (kind === "retry") {
|
|
94
|
+
setEditing(true);
|
|
95
|
+
setError(code === "too_many_changes" ? strings.ratingTooMany
|
|
96
|
+
: code === "rate_limited" ? strings.ratingWait(failure instanceof GiovChatError ? failure.retryAfterSeconds ?? 30 : 30)
|
|
97
|
+
: strings.ratingFailed);
|
|
98
|
+
} else setLock(kind);
|
|
99
|
+
} finally {
|
|
100
|
+
setPending(null);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<View testID={`${testIDPrefix}-form`} accessibilityLabel={strings.rateTitle} style={{ marginTop: 8, gap: 8 }}>
|
|
106
|
+
<View accessibilityRole="radiogroup" accessibilityLabel={strings.yourRating} style={{ flexDirection: "row", gap: 6 }}>
|
|
107
|
+
{FACES.map((face, index) => {
|
|
108
|
+
const n = index + 1;
|
|
109
|
+
return (
|
|
110
|
+
<TouchableOpacity
|
|
111
|
+
key={n}
|
|
112
|
+
testID={`${testIDPrefix}-face-${n}`}
|
|
113
|
+
accessibilityRole="radio"
|
|
114
|
+
accessibilityLabel={label(n)}
|
|
115
|
+
accessibilityState={{ checked: pick === n }}
|
|
116
|
+
onPress={() => setPick(n)}
|
|
117
|
+
style={{ width: 44, height: 44, borderRadius: 22, alignItems: "center", justifyContent: "center", borderWidth: pick === n ? 2 : 1, borderColor: pick === n ? primaryColor : border }}
|
|
118
|
+
>
|
|
119
|
+
<Text style={{ fontSize: 22 }}>{face}</Text>
|
|
120
|
+
</TouchableOpacity>
|
|
121
|
+
);
|
|
122
|
+
})}
|
|
123
|
+
</View>
|
|
124
|
+
<TextInput
|
|
125
|
+
testID={`${testIDPrefix}-comment`}
|
|
126
|
+
value={comment}
|
|
127
|
+
onChangeText={setComment}
|
|
128
|
+
maxLength={MAX_RATING_COMMENT}
|
|
129
|
+
multiline
|
|
130
|
+
placeholder={strings.ratingComment}
|
|
131
|
+
placeholderTextColor={dark ? "#777" : "#999"}
|
|
132
|
+
accessibilityLabel={strings.ratingComment}
|
|
133
|
+
style={{ color: ink, borderWidth: 1, borderColor: border, borderRadius: 10, paddingHorizontal: 10, paddingVertical: 8, fontSize: 14, minHeight: 56, maxHeight: 120 }}
|
|
134
|
+
/>
|
|
135
|
+
{error ? <Text accessibilityRole="alert" style={{ color: "#dc2626", fontSize: 13 }}>{error}</Text> : null}
|
|
136
|
+
<View style={{ flexDirection: "row", gap: 12, alignItems: "center" }}>
|
|
137
|
+
<TouchableOpacity
|
|
138
|
+
testID={`${testIDPrefix}-send`}
|
|
139
|
+
accessibilityRole="button"
|
|
140
|
+
accessibilityLabel={strings.ratingSend}
|
|
141
|
+
accessibilityState={{ disabled: !pick || !!pending || !onRate }}
|
|
142
|
+
disabled={!pick || !!pending || !onRate}
|
|
143
|
+
onPress={submit}
|
|
144
|
+
style={{ backgroundColor: primaryColor, borderRadius: 8, paddingHorizontal: 18, paddingVertical: 9, opacity: !pick || !onRate ? 0.4 : 1 }}
|
|
145
|
+
>
|
|
146
|
+
<Text style={{ color: textOn(primaryColor), fontSize: 14, fontWeight: "600" }}>{strings.ratingSend}</Text>
|
|
147
|
+
</TouchableOpacity>
|
|
148
|
+
{block.score ? (
|
|
149
|
+
<TouchableOpacity testID={`${testIDPrefix}-cancel`} accessibilityRole="button" accessibilityLabel={strings.ratingCancel} onPress={() => setEditing(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
|
150
|
+
<Text style={{ color: quiet, fontSize: 14 }}>{strings.ratingCancel}</Text>
|
|
151
|
+
</TouchableOpacity>
|
|
152
|
+
) : null}
|
|
153
|
+
</View>
|
|
154
|
+
</View>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Thumbs under a GiovBot answer: one vote, changeable; a thumbs-down offers an optional reason. */
|
|
159
|
+
export function AnswerFeedback({ messageId, value, dark, primaryColor, onVote }: {
|
|
160
|
+
messageId: string; value?: "up" | "down"; dark: boolean; primaryColor: string; onVote: VoteFn;
|
|
161
|
+
}) {
|
|
162
|
+
const strings = useGiovChatStrings();
|
|
163
|
+
const [asking, setAsking] = useState(false);
|
|
164
|
+
const [reason, setReason] = useState("");
|
|
165
|
+
const [failed, setFailed] = useState(false);
|
|
166
|
+
const quiet = dark ? "#aaa" : "#555";
|
|
167
|
+
const tap = async (tapped: "up" | "down") => {
|
|
168
|
+
const next = nextFeedback(value, tapped);
|
|
169
|
+
setFailed(false);
|
|
170
|
+
setReason("");
|
|
171
|
+
setAsking(next === "down");
|
|
172
|
+
try { await onVote(messageId, next); } catch { setAsking(false); setFailed(true); }
|
|
173
|
+
};
|
|
174
|
+
const sendReason = async () => {
|
|
175
|
+
setAsking(false);
|
|
176
|
+
if (reason.trim()) await onVote(messageId, "down", reason.trim()).catch(() => setFailed(true));
|
|
177
|
+
};
|
|
178
|
+
return (
|
|
179
|
+
<View testID={`giov-feedback-${messageId}`} accessibilityRole="toolbar" accessibilityLabel={strings.answerGroup} style={{ marginTop: 2, marginLeft: 52 }}>
|
|
180
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
|
|
181
|
+
{(["up", "down"] as const).map((kind) => (
|
|
182
|
+
<TouchableOpacity
|
|
183
|
+
key={kind}
|
|
184
|
+
testID={`giov-feedback-${kind}`}
|
|
185
|
+
accessibilityRole="button"
|
|
186
|
+
accessibilityLabel={kind === "up" ? strings.answerGood : strings.answerBad}
|
|
187
|
+
accessibilityState={{ selected: value === kind }}
|
|
188
|
+
onPress={() => tap(kind)}
|
|
189
|
+
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
|
|
190
|
+
style={{ width: 36, height: 36, borderRadius: 18, alignItems: "center", justifyContent: "center", backgroundColor: value === kind ? primaryColor : "transparent", opacity: value && value !== kind ? 0.4 : 1 }}
|
|
191
|
+
>
|
|
192
|
+
<Text style={{ fontSize: 16 }}>{kind === "up" ? "👍" : "👎"}</Text>
|
|
193
|
+
</TouchableOpacity>
|
|
194
|
+
))}
|
|
195
|
+
{failed ? <Text accessibilityRole="alert" style={{ color: "#dc2626", fontSize: 12 }}>{strings.answerFailed}</Text> : null}
|
|
196
|
+
</View>
|
|
197
|
+
{asking ? (
|
|
198
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, marginTop: 4 }}>
|
|
199
|
+
<TextInput
|
|
200
|
+
testID="giov-feedback-reason"
|
|
201
|
+
value={reason}
|
|
202
|
+
onChangeText={setReason}
|
|
203
|
+
maxLength={MAX_FEEDBACK_REASON}
|
|
204
|
+
placeholder={strings.answerReason}
|
|
205
|
+
placeholderTextColor={dark ? "#777" : "#999"}
|
|
206
|
+
accessibilityLabel={strings.answerReason}
|
|
207
|
+
style={{ flex: 1, color: dark ? "#f0f0f0" : "#111", borderWidth: 1, borderColor: dark ? "#3a3a3a" : "#d4d4d4", borderRadius: 10, paddingHorizontal: 10, paddingVertical: 6, fontSize: 13 }}
|
|
208
|
+
/>
|
|
209
|
+
<TouchableOpacity testID="giov-feedback-send" accessibilityRole="button" accessibilityLabel={strings.ratingSend} onPress={sendReason} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
|
210
|
+
<Text style={{ color: primaryColor, fontSize: 13, fontWeight: "600" }}>{strings.ratingSend}</Text>
|
|
211
|
+
</TouchableOpacity>
|
|
212
|
+
<TouchableOpacity testID="giov-feedback-skip" accessibilityRole="button" accessibilityLabel={strings.answerSkip} onPress={() => setAsking(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
|
213
|
+
<Text style={{ color: quiet, fontSize: 13 }}>{strings.answerSkip}</Text>
|
|
214
|
+
</TouchableOpacity>
|
|
215
|
+
</View>
|
|
216
|
+
) : null}
|
|
217
|
+
</View>
|
|
218
|
+
);
|
|
219
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -4,7 +4,10 @@ export type GiovChatErrorCode =
|
|
|
4
4
|
/** `401 invalid_user_hash`: the workspace requires a signed identity and `userHash` is wrong or missing. */
|
|
5
5
|
| "identity_rejected"
|
|
6
6
|
/** `429 rate_limited`: wait `retryAfterSeconds` before trying again. */
|
|
7
|
-
| "rate_limited"
|
|
7
|
+
| "rate_limited"
|
|
8
|
+
/** Ratings and answer feedback (docs/protocol, "Conversation ratings"). `unsupported`: the server has no such routes. */
|
|
9
|
+
| "unsupported" | "invalid_rating" | "invalid_feedback" | "request_not_found" | "message_not_found"
|
|
10
|
+
| "request_replaced" | "rating_closed" | "too_many_changes";
|
|
8
11
|
|
|
9
12
|
export class GiovChatError extends Error {
|
|
10
13
|
constructor(
|
package/src/i18n.ts
CHANGED
|
@@ -61,6 +61,29 @@ export interface GiovChatStrings {
|
|
|
61
61
|
phone: string;
|
|
62
62
|
required: (label: string) => string;
|
|
63
63
|
startChat: string;
|
|
64
|
+
/** Conversation ratings (the card, the thumbs under GiovBot answers): visible text and screen-reader labels. */
|
|
65
|
+
rateTitle: string;
|
|
66
|
+
yourRating: string;
|
|
67
|
+
/** The five faces, from 1 (Very bad) to 5 (Great). */
|
|
68
|
+
ratingFace: (score: number) => string;
|
|
69
|
+
ratingComment: string;
|
|
70
|
+
ratingSend: string;
|
|
71
|
+
ratingThanks: string;
|
|
72
|
+
ratingChange: string;
|
|
73
|
+
ratingCancel: string;
|
|
74
|
+
ratingClosed: string;
|
|
75
|
+
ratingFailed: string;
|
|
76
|
+
ratingTooMany: string;
|
|
77
|
+
ratingWait: (seconds: number) => string;
|
|
78
|
+
answerGroup: string;
|
|
79
|
+
answerGood: string;
|
|
80
|
+
answerBad: string;
|
|
81
|
+
answerReason: string;
|
|
82
|
+
answerSkip: string;
|
|
83
|
+
answerFailed: string;
|
|
84
|
+
closeRating: string;
|
|
85
|
+
/** Replaces the composer once the team closed the conversation. */
|
|
86
|
+
startNewConversation: string;
|
|
64
87
|
}
|
|
65
88
|
|
|
66
89
|
export type GiovChatLocale = "en" | "fr" | "es" | "pt" | "de";
|
|
@@ -114,6 +137,26 @@ const en: GiovChatStrings = {
|
|
|
114
137
|
phone: "Phone",
|
|
115
138
|
required: (label) => `${label} *`,
|
|
116
139
|
startChat: "Start chat",
|
|
140
|
+
rateTitle: "Rate this conversation",
|
|
141
|
+
yourRating: "Your rating",
|
|
142
|
+
ratingFace: (score) => ["Very bad", "Bad", "Okay", "Good", "Great"][score - 1] ?? String(score),
|
|
143
|
+
ratingComment: "Add a comment (optional)",
|
|
144
|
+
ratingSend: "Send",
|
|
145
|
+
ratingThanks: "Thanks",
|
|
146
|
+
ratingChange: "Change",
|
|
147
|
+
ratingCancel: "Cancel",
|
|
148
|
+
ratingClosed: "This rating request has closed.",
|
|
149
|
+
ratingFailed: "Couldn't send. Please try again.",
|
|
150
|
+
ratingTooMany: "You have changed this rating too many times.",
|
|
151
|
+
ratingWait: (seconds) => `Too many requests. Please wait ${seconds} s and try again.`,
|
|
152
|
+
answerGroup: "Rate this answer",
|
|
153
|
+
answerGood: "Good answer",
|
|
154
|
+
answerBad: "Bad answer",
|
|
155
|
+
answerReason: "What went wrong? (optional)",
|
|
156
|
+
answerSkip: "Skip",
|
|
157
|
+
answerFailed: "Couldn't save",
|
|
158
|
+
closeRating: "Close",
|
|
159
|
+
startNewConversation: "Start a new conversation",
|
|
117
160
|
};
|
|
118
161
|
|
|
119
162
|
const fr: GiovChatStrings = {
|
|
@@ -165,6 +208,26 @@ const fr: GiovChatStrings = {
|
|
|
165
208
|
phone: "Téléphone",
|
|
166
209
|
required: (label) => `${label} *`,
|
|
167
210
|
startChat: "Démarrer le chat",
|
|
211
|
+
rateTitle: "Évaluer cette conversation",
|
|
212
|
+
yourRating: "Votre note",
|
|
213
|
+
ratingFace: (score) => ["Très mauvais", "Mauvais", "Moyen", "Bon", "Excellent"][score - 1] ?? String(score),
|
|
214
|
+
ratingComment: "Ajouter un commentaire (facultatif)",
|
|
215
|
+
ratingSend: "Envoyer",
|
|
216
|
+
ratingThanks: "Merci",
|
|
217
|
+
ratingChange: "Modifier",
|
|
218
|
+
ratingCancel: "Annuler",
|
|
219
|
+
ratingClosed: "Cette demande d'évaluation est terminée.",
|
|
220
|
+
ratingFailed: "Envoi impossible. Réessayez.",
|
|
221
|
+
ratingTooMany: "Vous avez trop souvent modifié cette note.",
|
|
222
|
+
ratingWait: (seconds) => `Trop de demandes. Patientez ${seconds} s puis réessayez.`,
|
|
223
|
+
answerGroup: "Évaluer cette réponse",
|
|
224
|
+
answerGood: "Bonne réponse",
|
|
225
|
+
answerBad: "Mauvaise réponse",
|
|
226
|
+
answerReason: "Qu'est-ce qui n'allait pas ? (facultatif)",
|
|
227
|
+
answerSkip: "Passer",
|
|
228
|
+
answerFailed: "Enregistrement impossible",
|
|
229
|
+
closeRating: "Fermer",
|
|
230
|
+
startNewConversation: "Démarrer une nouvelle conversation",
|
|
168
231
|
};
|
|
169
232
|
|
|
170
233
|
const es: GiovChatStrings = {
|
|
@@ -216,6 +279,26 @@ const es: GiovChatStrings = {
|
|
|
216
279
|
phone: "Teléfono",
|
|
217
280
|
required: (label) => `${label} *`,
|
|
218
281
|
startChat: "Iniciar chat",
|
|
282
|
+
rateTitle: "Valora esta conversación",
|
|
283
|
+
yourRating: "Tu valoración",
|
|
284
|
+
ratingFace: (score) => ["Muy mala", "Mala", "Regular", "Buena", "Excelente"][score - 1] ?? String(score),
|
|
285
|
+
ratingComment: "Añade un comentario (opcional)",
|
|
286
|
+
ratingSend: "Enviar",
|
|
287
|
+
ratingThanks: "Gracias",
|
|
288
|
+
ratingChange: "Cambiar",
|
|
289
|
+
ratingCancel: "Cancelar",
|
|
290
|
+
ratingClosed: "Esta solicitud de valoración ha terminado.",
|
|
291
|
+
ratingFailed: "No se pudo enviar. Inténtalo de nuevo.",
|
|
292
|
+
ratingTooMany: "Has cambiado esta valoración demasiadas veces.",
|
|
293
|
+
ratingWait: (seconds) => `Demasiadas solicitudes. Espera ${seconds} s e inténtalo de nuevo.`,
|
|
294
|
+
answerGroup: "Valora esta respuesta",
|
|
295
|
+
answerGood: "Buena respuesta",
|
|
296
|
+
answerBad: "Mala respuesta",
|
|
297
|
+
answerReason: "¿Qué salió mal? (opcional)",
|
|
298
|
+
answerSkip: "Omitir",
|
|
299
|
+
answerFailed: "No se pudo guardar",
|
|
300
|
+
closeRating: "Cerrar",
|
|
301
|
+
startNewConversation: "Iniciar una conversación nueva",
|
|
219
302
|
};
|
|
220
303
|
|
|
221
304
|
const pt: GiovChatStrings = {
|
|
@@ -267,6 +350,26 @@ const pt: GiovChatStrings = {
|
|
|
267
350
|
phone: "Telefone",
|
|
268
351
|
required: (label) => `${label} *`,
|
|
269
352
|
startChat: "Iniciar chat",
|
|
353
|
+
rateTitle: "Avalie esta conversa",
|
|
354
|
+
yourRating: "Sua avaliação",
|
|
355
|
+
ratingFace: (score) => ["Muito ruim", "Ruim", "Regular", "Boa", "Ótima"][score - 1] ?? String(score),
|
|
356
|
+
ratingComment: "Adicione um comentário (opcional)",
|
|
357
|
+
ratingSend: "Enviar",
|
|
358
|
+
ratingThanks: "Obrigado",
|
|
359
|
+
ratingChange: "Alterar",
|
|
360
|
+
ratingCancel: "Cancelar",
|
|
361
|
+
ratingClosed: "Este pedido de avaliação foi encerrado.",
|
|
362
|
+
ratingFailed: "Não foi possível enviar. Tente novamente.",
|
|
363
|
+
ratingTooMany: "Você alterou esta avaliação vezes demais.",
|
|
364
|
+
ratingWait: (seconds) => `Muitas solicitações. Aguarde ${seconds} s e tente novamente.`,
|
|
365
|
+
answerGroup: "Avalie esta resposta",
|
|
366
|
+
answerGood: "Boa resposta",
|
|
367
|
+
answerBad: "Resposta ruim",
|
|
368
|
+
answerReason: "O que deu errado? (opcional)",
|
|
369
|
+
answerSkip: "Pular",
|
|
370
|
+
answerFailed: "Não foi possível salvar",
|
|
371
|
+
closeRating: "Fechar",
|
|
372
|
+
startNewConversation: "Iniciar uma nova conversa",
|
|
270
373
|
};
|
|
271
374
|
|
|
272
375
|
const de: GiovChatStrings = {
|
|
@@ -318,6 +421,26 @@ const de: GiovChatStrings = {
|
|
|
318
421
|
phone: "Telefon",
|
|
319
422
|
required: (label) => `${label} *`,
|
|
320
423
|
startChat: "Chat starten",
|
|
424
|
+
rateTitle: "Bewerte diese Unterhaltung",
|
|
425
|
+
yourRating: "Deine Bewertung",
|
|
426
|
+
ratingFace: (score) => ["Sehr schlecht", "Schlecht", "Okay", "Gut", "Sehr gut"][score - 1] ?? String(score),
|
|
427
|
+
ratingComment: "Kommentar hinzufügen (optional)",
|
|
428
|
+
ratingSend: "Senden",
|
|
429
|
+
ratingThanks: "Danke",
|
|
430
|
+
ratingChange: "Ändern",
|
|
431
|
+
ratingCancel: "Abbrechen",
|
|
432
|
+
ratingClosed: "Diese Bewertungsanfrage ist beendet.",
|
|
433
|
+
ratingFailed: "Senden fehlgeschlagen. Bitte versuche es erneut.",
|
|
434
|
+
ratingTooMany: "Du hast diese Bewertung zu oft geändert.",
|
|
435
|
+
ratingWait: (seconds) => `Zu viele Anfragen. Warte ${seconds} s und versuche es erneut.`,
|
|
436
|
+
answerGroup: "Bewerte diese Antwort",
|
|
437
|
+
answerGood: "Gute Antwort",
|
|
438
|
+
answerBad: "Schlechte Antwort",
|
|
439
|
+
answerReason: "Was ist schiefgelaufen? (optional)",
|
|
440
|
+
answerSkip: "Überspringen",
|
|
441
|
+
answerFailed: "Speichern fehlgeschlagen",
|
|
442
|
+
closeRating: "Schließen",
|
|
443
|
+
startNewConversation: "Neue Unterhaltung starten",
|
|
321
444
|
};
|
|
322
445
|
|
|
323
446
|
export const GIOVCHAT_STRINGS: Readonly<Record<GiovChatLocale, GiovChatStrings>> = { en, fr, es, pt, de };
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,13 @@ export type {
|
|
|
4
4
|
GiovChatSession,
|
|
5
5
|
GiovChatSessionStorage,
|
|
6
6
|
GiovChatConnectionStatus,
|
|
7
|
+
GiovAnswerFeedback,
|
|
8
|
+
GiovFeedbackValue,
|
|
7
9
|
GiovMessage,
|
|
10
|
+
GiovRatingBlock,
|
|
11
|
+
GiovRatingRequest,
|
|
12
|
+
GiovRatingResult,
|
|
13
|
+
GiovRatingScore,
|
|
8
14
|
GiovWidgetConfig,
|
|
9
15
|
MessageHandler,
|
|
10
16
|
RequestUserInfo,
|
|
@@ -26,6 +32,7 @@ export {
|
|
|
26
32
|
export type {
|
|
27
33
|
ChatClientOptions,
|
|
28
34
|
GiovConversationHistory,
|
|
35
|
+
GiovConversationState,
|
|
29
36
|
GiovPushOptions,
|
|
30
37
|
GiovPushPlatform,
|
|
31
38
|
GiovPushRegistration,
|
|
@@ -59,8 +66,18 @@ export type { GiovChatProps, GiovChatHandle } from "./GiovChat";
|
|
|
59
66
|
export { useGiovChatUnreadCount } from "./unread";
|
|
60
67
|
export { GIOVCHAT_STRINGS, resolveLocale, resolveStrings } from "./i18n";
|
|
61
68
|
export type { GiovChatLocale, GiovChatStrings } from "./i18n";
|
|
69
|
+
export { useGiovRating } from "./ratingStore";
|
|
70
|
+
export type { GiovRatingController } from "./ratingStore";
|
|
71
|
+
export { MAX_FEEDBACK_REASON, MAX_RATING_COMMENT, nextFeedback, parseRatingFrame, patchRating, pendingRatingRequest, ratingPhase, supersedeRatings } from "./rating";
|
|
72
|
+
export type { GiovRatingFrame } from "./rating";
|
|
73
|
+
export { RatingCard, AnswerFeedback } from "./components/RatingCard";
|
|
74
|
+
export type { RatingCardProps, RateFn, VoteFn } from "./components/RatingCard";
|
|
62
75
|
export { GiovChatError } from "./errors";
|
|
63
76
|
export type { GiovChatErrorCode } from "./errors";
|
|
77
|
+
export { GiovBranding } from "./components/Branding";
|
|
78
|
+
export type { GiovBrandingProps } from "./components/Branding";
|
|
79
|
+
export { FOOTER_MIN_CONTRAST } from "./brandingStyle";
|
|
80
|
+
export type { GiovChatBrandingStyle } from "./brandingStyle";
|
|
64
81
|
export type {
|
|
65
82
|
GiovChatStyleOverrides,
|
|
66
83
|
GiovChatTextStyle,
|
package/src/protocol.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { GiovMessage } from "./types";
|
|
2
|
+
import { parseRatingBlock } from "./rating";
|
|
2
3
|
import { parseAttachments } from "./attachments";
|
|
3
4
|
import { safeImageUrl } from "./identity";
|
|
4
5
|
|
|
@@ -22,10 +23,14 @@ export function parseMessage(value: unknown): GiovMessage | null {
|
|
|
22
23
|
const isAutomated = role === "agent" && (typeof m.isAutomated === "boolean" ? m.isAutomated : m.is_automated === 1);
|
|
23
24
|
const human = role === "agent" && !isBot && !isAutomated;
|
|
24
25
|
const attachments = parseAttachments(m.attachmentId ?? m.attachment_id, m.attachmentPreviews ?? m.attachment_previews);
|
|
26
|
+
// Unknown block types are ignored (protocol versioning); only the rating card is drawn.
|
|
27
|
+
const rating = Array.isArray(m.blocks) ? m.blocks.map(parseRatingBlock).find((block) => block !== null) : undefined;
|
|
25
28
|
return {
|
|
26
29
|
id: m.id, role, content: m.content, createdAt, isBot, isAutomated,
|
|
27
30
|
senderName: role === "agent" && typeof m.senderName === "string" ? m.senderName : undefined,
|
|
28
31
|
...(human && safeImageUrl(m.senderAvatar) ? { senderAvatar: safeImageUrl(m.senderAvatar) } : {}),
|
|
29
32
|
...(attachments.length ? { attachments } : {}),
|
|
33
|
+
...(rating ? { rating } : {}),
|
|
34
|
+
...(isBot && (m.feedback === "up" || m.feedback === "down") ? { feedback: m.feedback } : {}),
|
|
30
35
|
};
|
|
31
36
|
}
|
package/src/rating.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
GiovFeedbackValue, GiovMessage, GiovRatingBlock, GiovRatingRequest, GiovRatingResult, GiovRatingScore,
|
|
3
|
+
} from "./types";
|
|
4
|
+
import { GiovChatError, retryAfterSeconds } from "./errors";
|
|
5
|
+
|
|
6
|
+
/** Pure rules for conversation ratings and the thumbs under GiovBot answers (docs/protocol, "Conversation ratings"). */
|
|
7
|
+
|
|
8
|
+
export const MAX_RATING_COMMENT = 1000;
|
|
9
|
+
export const MAX_FEEDBACK_REASON = 300;
|
|
10
|
+
|
|
11
|
+
const obj = (value: unknown): Record<string, unknown> =>
|
|
12
|
+
value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
13
|
+
|
|
14
|
+
const isScore = (value: unknown): value is GiovRatingScore =>
|
|
15
|
+
typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 5;
|
|
16
|
+
|
|
17
|
+
/** A `rating` block from a message; null for any other (or malformed) block. */
|
|
18
|
+
export function parseRatingBlock(value: unknown): GiovRatingBlock | null {
|
|
19
|
+
const b = obj(value);
|
|
20
|
+
if (b.type !== "rating" || typeof b.requestId !== "string" || !b.requestId ||
|
|
21
|
+
typeof b.closesAt !== "number" || !Number.isFinite(b.closesAt)) return null;
|
|
22
|
+
const score = isScore(b.score) ? b.score : undefined;
|
|
23
|
+
return {
|
|
24
|
+
requestId: b.requestId, closesAt: b.closesAt,
|
|
25
|
+
...(typeof b.url === "string" && /^https:\/\//i.test(b.url) ? { url: b.url } : {}),
|
|
26
|
+
...(score ? { score } : {}),
|
|
27
|
+
...(score && typeof b.comment === "string" && b.comment ? { comment: b.comment } : {}),
|
|
28
|
+
...(b.state === "replaced" ? { state: "replaced" as const } : {}),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The `rating` socket frame: the card changed (answered, changed or replaced). */
|
|
33
|
+
export interface GiovRatingFrame {
|
|
34
|
+
requestId: string;
|
|
35
|
+
messageId?: string;
|
|
36
|
+
closesAt: number;
|
|
37
|
+
score?: GiovRatingScore;
|
|
38
|
+
comment?: string;
|
|
39
|
+
state?: "replaced";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function parseRatingFrame(value: unknown): GiovRatingFrame | null {
|
|
43
|
+
const f = obj(value);
|
|
44
|
+
if (f.type !== "rating" || typeof f.requestId !== "string" || !f.requestId ||
|
|
45
|
+
typeof f.closesAt !== "number" || !Number.isFinite(f.closesAt)) return null;
|
|
46
|
+
const score = isScore(f.score) ? f.score : undefined;
|
|
47
|
+
return {
|
|
48
|
+
requestId: f.requestId, closesAt: f.closesAt,
|
|
49
|
+
...(typeof f.messageId === "string" && f.messageId ? { messageId: f.messageId } : {}),
|
|
50
|
+
...(score ? { score } : {}),
|
|
51
|
+
...(score && typeof f.comment === "string" && f.comment ? { comment: f.comment } : {}),
|
|
52
|
+
...(f.state === "replaced" ? { state: "replaced" as const } : {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Sets the answer, deadline and state on the card a frame (or an answer) names; other messages stay as they are. */
|
|
57
|
+
export function patchRating(messages: GiovMessage[], frame: GiovRatingFrame): GiovMessage[] {
|
|
58
|
+
let changed = false;
|
|
59
|
+
const next = messages.map((message) => {
|
|
60
|
+
if ((frame.messageId && message.id !== frame.messageId) || message.rating?.requestId !== frame.requestId) return message;
|
|
61
|
+
changed = true;
|
|
62
|
+
const { score: _s, comment: _c, state: _t, ...rest } = message.rating;
|
|
63
|
+
return {
|
|
64
|
+
...message,
|
|
65
|
+
rating: {
|
|
66
|
+
...rest, closesAt: frame.closesAt,
|
|
67
|
+
...(frame.score ? { score: frame.score } : {}),
|
|
68
|
+
...(frame.score && frame.comment ? { comment: frame.comment } : {}),
|
|
69
|
+
...(frame.state ? { state: frame.state } : message.rating.state ? { state: message.rating.state } : {}),
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
return changed ? next : messages;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* One open card per conversation: an older card the visitor never answered is replaced by a newer
|
|
78
|
+
* one (also before the server's own `replaced` mark arrives). Answered cards keep their answer.
|
|
79
|
+
*/
|
|
80
|
+
export function supersedeRatings(messages: GiovMessage[]): GiovMessage[] {
|
|
81
|
+
let latest = -1;
|
|
82
|
+
messages.forEach((message, index) => { if (message.rating && message.rating.state !== "replaced") latest = index; });
|
|
83
|
+
let changed = false;
|
|
84
|
+
const next = messages.map((message, index) => {
|
|
85
|
+
const card = message.rating;
|
|
86
|
+
if (!card || card.state === "replaced" || index === latest || card.score) return message;
|
|
87
|
+
changed = true;
|
|
88
|
+
return { ...message, rating: { ...card, state: "replaced" as const } };
|
|
89
|
+
});
|
|
90
|
+
return changed ? next : messages;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** "hidden" (replaced), "open" (can be answered or changed) or "closed" (read-only: the clock reached `closesAt`; the server decides). */
|
|
94
|
+
export function ratingPhase(block: Pick<GiovRatingBlock, "closesAt" | "state">, now: number): "hidden" | "open" | "closed" {
|
|
95
|
+
return block.state === "replaced" ? "hidden" : now < block.closesAt ? "open" : "closed";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The card the visitor can answer or change now, or null. */
|
|
99
|
+
export function pendingRatingRequest(messages: GiovMessage[], now: number): GiovRatingRequest | null {
|
|
100
|
+
const cards = supersedeRatings(messages).filter((message) => message.rating && ratingPhase(message.rating, now) === "open");
|
|
101
|
+
const message = cards[cards.length - 1];
|
|
102
|
+
const card = message?.rating;
|
|
103
|
+
if (!message || !card) return null;
|
|
104
|
+
return {
|
|
105
|
+
requestId: card.requestId, messageId: message.id, closesAt: card.closesAt, answered: !!card.score,
|
|
106
|
+
...(card.url ? { url: card.url } : {}),
|
|
107
|
+
...(card.score ? { score: card.score } : {}),
|
|
108
|
+
...(card.comment ? { comment: card.comment } : {}),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A refusal for one card: `closed` makes it read-only now, `replaced` hides it. */
|
|
113
|
+
export function markRating(messages: GiovMessage[], requestId: string, kind: "closed" | "replaced", now = Date.now()): GiovMessage[] {
|
|
114
|
+
return messages.map((message) => {
|
|
115
|
+
const card = message.rating;
|
|
116
|
+
if (!card || card.requestId !== requestId) return message;
|
|
117
|
+
return { ...message, rating: kind === "replaced" ? { ...card, state: "replaced" as const } : { ...card, closesAt: Math.min(card.closesAt, now) } };
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** What a failed answer means for the card: read-only states, or a message to retry. */
|
|
122
|
+
export function ratingFailure(code: string): "closed" | "replaced" | "retry" {
|
|
123
|
+
return code === "rating_closed" ? "closed" : code === "request_replaced" ? "replaced" : "retry";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** What tapping a thumb sends: the same thumb again takes the vote back. */
|
|
127
|
+
export const nextFeedback = (current: "up" | "down" | undefined, tapped: "up" | "down"): GiovFeedbackValue => (current === tapped ? "none" : tapped);
|
|
128
|
+
|
|
129
|
+
export function applyFeedback(messages: GiovMessage[], id: string, value: GiovFeedbackValue): GiovMessage[] {
|
|
130
|
+
return messages.map((message) => {
|
|
131
|
+
if (message.id !== id) return message;
|
|
132
|
+
const { feedback: _f, ...rest } = message;
|
|
133
|
+
return value === "none" ? rest : { ...rest, feedback: value };
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Thumbs go under finished GiovBot answers, never under the rating request itself. */
|
|
138
|
+
export const canVoteOn = (message: GiovMessage): boolean =>
|
|
139
|
+
message.role === "agent" && message.isBot === true && !message.isStreaming && !message.id.startsWith("stream:") && !message.rating;
|
|
140
|
+
|
|
141
|
+
const CODES: Record<string, GiovChatError["code"]> = {
|
|
142
|
+
invalid_rating: "invalid_rating", invalid_feedback: "invalid_feedback", request_not_found: "request_not_found",
|
|
143
|
+
message_not_found: "message_not_found", request_replaced: "request_replaced", rating_closed: "rating_closed",
|
|
144
|
+
too_many_changes: "too_many_changes",
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** The documented refusals of the rating and feedback routes, as typed errors. A 404 without a code is a server that lacks the routes. */
|
|
148
|
+
export function ratingError(status: number, body: unknown, retryAfter?: string | null): GiovChatError {
|
|
149
|
+
const data = obj(body);
|
|
150
|
+
const server = typeof data.error === "string" ? data.error : "";
|
|
151
|
+
const known = CODES[server];
|
|
152
|
+
const wait = retryAfterSeconds(retryAfter);
|
|
153
|
+
if (known) return new GiovChatError(known, "Rating was refused.", status, wait);
|
|
154
|
+
if (status === 404 || status === 405 || status === 501) return new GiovChatError("unsupported", "This chat does not take ratings.", status);
|
|
155
|
+
if (status === 401 || status === 403) return new GiovChatError("invalid_session", "Chat session expired. Please start a new chat.", status);
|
|
156
|
+
if (status === 429) return new GiovChatError("rate_limited", "Too many requests. Please wait a moment.", status, wait);
|
|
157
|
+
return new GiovChatError("server", "Chat is unavailable. Please try again.", status, wait);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function parseRatingResult(body: unknown, fallbackRequestId?: string): GiovRatingResult | null {
|
|
161
|
+
const b = obj(body);
|
|
162
|
+
const requestId = typeof b.requestId === "string" ? b.requestId : fallbackRequestId;
|
|
163
|
+
if (!requestId || !isScore(b.score) || typeof b.closesAt !== "number" || !Number.isFinite(b.closesAt)) return null;
|
|
164
|
+
return {
|
|
165
|
+
requestId, score: b.score, closesAt: b.closesAt,
|
|
166
|
+
...(typeof b.comment === "string" && b.comment ? { comment: b.comment } : {}),
|
|
167
|
+
...(typeof b.ratedAt === "number" ? { ratedAt: b.ratedAt } : {}),
|
|
168
|
+
};
|
|
169
|
+
}
|