@sansavision/yousaid-react 0.0.0-stage → 0.2.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/CHANGELOG.md +13 -0
- package/LICENSE +6 -0
- package/README.md +105 -2
- package/dist/context.d.ts +7 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +15 -0
- package/dist/index.d.ts +107 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +441 -0
- package/dist/proof-prompt.d.ts +57 -0
- package/dist/proof-prompt.d.ts.map +1 -0
- package/dist/proof-prompt.js +106 -0
- package/dist/proof.d.ts +113 -0
- package/dist/proof.d.ts.map +1 -0
- package/dist/proof.js +383 -0
- package/dist/rooms.d.ts +158 -0
- package/dist/rooms.d.ts.map +1 -0
- package/dist/rooms.js +442 -0
- package/package.json +48 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState, } from "react";
|
|
4
|
+
import { collectDiagnostics, SIGNAL_ATTACHMENT_LIMITS, mentionAtCaret, } from "@sansavision/yousaid";
|
|
5
|
+
import { useYouSaid } from "./context.js";
|
|
6
|
+
import { AgentMentionMenu, DEFAULT_MENTION_AGENTS, filterAgents, mentionMenuKey, RequestHumanButton, RoomCanvas, roomAgentStyles } from "./rooms.js";
|
|
7
|
+
export { YouSaidProvider, useYouSaid } from "./context.js";
|
|
8
|
+
export { AgentMentionMenu, canvasEntries, DEFAULT_CANVAS_LABELS, DEFAULT_MENTION_AGENTS, filterAgents, mentionMenuKey, RequestHumanButton, RoomCanvas, roomAgentStyles, useAgentRun, usePendingApprovals, useRequestHuman, useRoomCanvas, useRoomEvents, useRoomMembers, } from "./rooms.js";
|
|
9
|
+
export function YouSaidLauncher({ label = "Talk to us", defaultView = "feedback", appVersion, featureFlags, breadcrumbs, verifyInstall = true, }) {
|
|
10
|
+
const client = useYouSaid();
|
|
11
|
+
const [open, setOpen] = useState(false);
|
|
12
|
+
const [pending, setPending] = useState(false);
|
|
13
|
+
const [receipt, setReceipt] = useState(null);
|
|
14
|
+
const [error, setError] = useState("");
|
|
15
|
+
const [files, setFiles] = useState([]);
|
|
16
|
+
const [shareDiagnostics, setShareDiagnostics] = useState(false);
|
|
17
|
+
const preview = useMemo(() => shareDiagnostics ? collectDiagnostics({ consent: true, ...(appVersion ? { appVersion } : {}), ...(featureFlags ? { featureFlags } : {}), ...(breadcrumbs ? { breadcrumbs } : {}) }) : null, [shareDiagnostics, appVersion, featureFlags, breadcrumbs]);
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (!verifyInstall || installVerified.has(client))
|
|
20
|
+
return;
|
|
21
|
+
installVerified.add(client);
|
|
22
|
+
// Health reporting must never disturb the host app.
|
|
23
|
+
void client.verifyInstallation().catch(() => undefined);
|
|
24
|
+
}, [client, verifyInstall]);
|
|
25
|
+
function chooseFiles(list) {
|
|
26
|
+
const chosen = Array.from(list ?? []);
|
|
27
|
+
if (chosen.length > SIGNAL_ATTACHMENT_LIMITS.maxFiles) {
|
|
28
|
+
setError(`Attach at most ${SIGNAL_ATTACHMENT_LIMITS.maxFiles} files.`);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (chosen.some((file) => file.size > SIGNAL_ATTACHMENT_LIMITS.maxFileBytes)) {
|
|
32
|
+
setError("Each file must be 5 MB or smaller.");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (chosen.some((file) => !SIGNAL_ATTACHMENT_LIMITS.types.includes(file.type))) {
|
|
36
|
+
setError("Attach images, PDF, text, CSV, or JSON files.");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
setError("");
|
|
40
|
+
setFiles(chosen);
|
|
41
|
+
}
|
|
42
|
+
async function submit(event) {
|
|
43
|
+
event.preventDefault();
|
|
44
|
+
const form = event.currentTarget;
|
|
45
|
+
const data = new FormData(form);
|
|
46
|
+
const input = {
|
|
47
|
+
kind: data.get("kind"),
|
|
48
|
+
title: String(data.get("title") ?? ""),
|
|
49
|
+
body: String(data.get("body") ?? ""),
|
|
50
|
+
context: { route: location.pathname, ...(appVersion ? { appVersion } : {}) },
|
|
51
|
+
...(preview ? { diagnostics: preview } : {}),
|
|
52
|
+
...(files.length ? { attachments: files } : {}),
|
|
53
|
+
};
|
|
54
|
+
setPending(true);
|
|
55
|
+
setError("");
|
|
56
|
+
try {
|
|
57
|
+
setReceipt(await client.sendSignal(input));
|
|
58
|
+
form.reset();
|
|
59
|
+
setFiles([]);
|
|
60
|
+
setShareDiagnostics(false);
|
|
61
|
+
}
|
|
62
|
+
catch (reason) {
|
|
63
|
+
setError(reason instanceof Error
|
|
64
|
+
? reason.message
|
|
65
|
+
: "We could not send your message.");
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
setPending(false);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return (_jsxs("div", { "data-yousaid-root": "true", children: [_jsx("button", { type: "button", onClick: () => setOpen((value) => !value), "aria-expanded": open, children: label }), open ? (_jsx("section", { role: "dialog", "aria-label": defaultView === "rooms" ? "Customer Rooms" : "Send product feedback", children: defaultView === "rooms" ? (_jsx(YouSaidRooms, { embedded: true })) : receipt ? (_jsx("p", { role: "status", children: receipt.receipt })) : (_jsxs("form", { onSubmit: submit, children: [_jsxs("label", { children: ["Type", _jsxs("select", { name: "kind", defaultValue: "support", children: [_jsx("option", { value: "support", children: "Support" }), _jsx("option", { value: "bug", children: "Bug" }), _jsx("option", { value: "feature", children: "Feature" }), _jsx("option", { value: "ux", children: "Experience" }), _jsx("option", { value: "review", children: "Review" })] })] }), _jsxs("label", { children: ["Title", _jsx("input", { name: "title", required: true, minLength: 3, maxLength: 180 })] }), _jsxs("label", { children: ["Message", _jsx("textarea", { name: "body", required: true, minLength: 3, maxLength: 10000 })] }), _jsxs("label", { children: ["Attach files (optional)", _jsx("input", { type: "file", multiple: true, accept: SIGNAL_ATTACHMENT_LIMITS.types.join(","), onChange: (event) => chooseFiles(event.currentTarget.files) })] }), _jsxs("label", { children: [_jsx("input", { type: "checkbox", checked: shareDiagnostics, onChange: (event) => setShareDiagnostics(event.currentTarget.checked) }), "Share technical details to help fix this"] }), preview ? (_jsxs("details", { open: true, children: [_jsx("summary", { children: "What will be shared" }), _jsx("pre", { "data-yousaid-diagnostics-preview": "true", children: JSON.stringify({ ...preview, consent: undefined }, null, 2) }), _jsx("small", { children: "Page addresses are sent without query strings. Personal data and secrets are removed before storage." })] })) : null, error ? _jsx("p", { role: "alert", children: error }) : null, _jsx("button", { type: "submit", disabled: pending, children: pending ? "Sending" : "Send" })] })) })) : null] }));
|
|
72
|
+
}
|
|
73
|
+
const installVerified = new WeakSet();
|
|
74
|
+
/**
|
|
75
|
+
* Live in-app inbox for the signed-in end user: loads the latest items, keeps
|
|
76
|
+
* a Server-Sent Events stream open (with backoff reconnects), and marks items read.
|
|
77
|
+
*/
|
|
78
|
+
export function useInbox(options = {}) {
|
|
79
|
+
const client = useYouSaid();
|
|
80
|
+
const live = options.live ?? true;
|
|
81
|
+
const [items, setItems] = useState([]);
|
|
82
|
+
const [unread, setUnread] = useState(0);
|
|
83
|
+
const [loading, setLoading] = useState(true);
|
|
84
|
+
const [connected, setConnected] = useState(false);
|
|
85
|
+
const [error, setError] = useState("");
|
|
86
|
+
const refresh = useCallback(async () => {
|
|
87
|
+
setError("");
|
|
88
|
+
try {
|
|
89
|
+
const page = await client.listInbox({ limit: options.limit ?? 50 });
|
|
90
|
+
setItems(page.items);
|
|
91
|
+
setUnread(page.unread);
|
|
92
|
+
}
|
|
93
|
+
catch (reason) {
|
|
94
|
+
setError(reason instanceof Error ? reason.message : "The inbox could not be loaded.");
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
setLoading(false);
|
|
98
|
+
}
|
|
99
|
+
}, [client, options.limit]);
|
|
100
|
+
useEffect(() => { void refresh(); }, [refresh]);
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (!live)
|
|
103
|
+
return;
|
|
104
|
+
const controller = new AbortController();
|
|
105
|
+
let attempt = 0;
|
|
106
|
+
const apply = (event) => {
|
|
107
|
+
attempt = 0;
|
|
108
|
+
setConnected(true);
|
|
109
|
+
setUnread(event.unread);
|
|
110
|
+
if (event.type === "item")
|
|
111
|
+
setItems((current) => current.some((item) => item.id === event.item.id) ? current : [event.item, ...current]);
|
|
112
|
+
if (event.type === "unread") {
|
|
113
|
+
const readAt = new Date().toISOString();
|
|
114
|
+
setItems((current) => current.map((item) => item.readAt || !(event.all || event.readIds?.includes(item.id)) ? item : { ...item, readAt }));
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
const run = async () => {
|
|
118
|
+
while (!controller.signal.aborted) {
|
|
119
|
+
try {
|
|
120
|
+
await client.streamInbox(apply, controller.signal);
|
|
121
|
+
}
|
|
122
|
+
catch { /* reconnect below */ }
|
|
123
|
+
setConnected(false);
|
|
124
|
+
if (controller.signal.aborted)
|
|
125
|
+
return;
|
|
126
|
+
attempt += 1;
|
|
127
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(30_000, 1_000 * 2 ** Math.min(attempt, 5))));
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
void run();
|
|
131
|
+
return () => controller.abort();
|
|
132
|
+
}, [client, live]);
|
|
133
|
+
const markRead = useCallback(async (ids) => {
|
|
134
|
+
const readAt = new Date().toISOString();
|
|
135
|
+
setItems((current) => current.map((item) => !item.readAt && (!ids || ids.includes(item.id)) ? { ...item, readAt } : item));
|
|
136
|
+
try {
|
|
137
|
+
const result = await client.markInboxRead(ids);
|
|
138
|
+
setUnread(result.unread);
|
|
139
|
+
}
|
|
140
|
+
catch (reason) {
|
|
141
|
+
setError(reason instanceof Error ? reason.message : "The inbox could not be updated.");
|
|
142
|
+
void refresh();
|
|
143
|
+
}
|
|
144
|
+
}, [client, refresh]);
|
|
145
|
+
return { items, unread, loading, connected, error, refresh, markRead };
|
|
146
|
+
}
|
|
147
|
+
export function Inbox({ title = "Inbox", emptyMessage = "Replies from the team will appear here.", onOpen }) {
|
|
148
|
+
const { items, unread, loading, error, markRead } = useInbox();
|
|
149
|
+
return (_jsxs("section", { className: "ys-inbox", "aria-label": title, children: [_jsx("style", { children: inboxStyles }), _jsxs("header", { children: [_jsxs("h2", { children: [title, unread ? _jsx("span", { "aria-label": `${unread} unread`, children: unread }) : null] }), unread ? _jsx("button", { type: "button", onClick: () => void markRead(), children: "Mark all read" }) : null] }), error ? _jsx("p", { role: "alert", className: "ys-inbox__error", children: error }) : null, loading ? _jsx("p", { className: "ys-inbox__empty", children: "Loading\u2026" }) : items.length === 0 ? _jsx("p", { className: "ys-inbox__empty", children: emptyMessage }) : (_jsx("ul", { children: items.map((item) => (_jsx("li", { children: _jsxs("button", { type: "button", className: item.readAt ? "" : "is-unread", onClick: () => { if (!item.readAt)
|
|
150
|
+
void markRead([item.id]); onOpen?.(item); }, children: [_jsx("strong", { children: item.subject }), _jsx("small", { children: item.preview }), _jsx("time", { dateTime: item.createdAt, children: new Date(item.createdAt).toLocaleString() })] }) }, item.id))) }))] }));
|
|
151
|
+
}
|
|
152
|
+
const inboxStyles = `.ys-inbox{color:#f4f1eb;background:#111116;border:1px solid #2c2b34;border-radius:20px;overflow:hidden;font:500 14px/1.5 ui-sans-serif,system-ui,sans-serif}.ys-inbox header{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:16px 18px;border-bottom:1px solid #2c2b34}.ys-inbox h2{margin:0;font-size:18px;display:flex;gap:8px;align-items:center}.ys-inbox h2 span{min-width:22px;padding:1px 7px;border-radius:9px;background:#8878ff;font-size:11px;text-align:center}.ys-inbox header button{border:0;border-radius:10px;background:#212129;color:#f4f1eb;padding:7px 11px;cursor:pointer}.ys-inbox ul{list-style:none;margin:0;padding:6px}.ys-inbox li button{width:100%;display:grid;gap:3px;text-align:left;border:0;border-radius:12px;padding:12px;background:transparent;color:#aaa6b2;cursor:pointer}.ys-inbox li button:hover{background:#212129}.ys-inbox li button.is-unread{color:#f4f1eb}.ys-inbox li button.is-unread strong::before{content:"";display:inline-block;width:7px;height:7px;margin-right:7px;border-radius:50%;background:#8878ff;vertical-align:middle}.ys-inbox small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ys-inbox time{font-size:10px;color:#aaa6b2}.ys-inbox__empty{padding:28px 18px;text-align:center;color:#aaa6b2}.ys-inbox__error{margin:10px;padding:10px;border-radius:10px;background:#3b2023;color:#ffb6ae}.ys-inbox button:focus-visible{outline:2px solid #8878ff;outline-offset:2px}`;
|
|
153
|
+
/** @experimental */
|
|
154
|
+
export function useRooms() {
|
|
155
|
+
const client = useYouSaid();
|
|
156
|
+
const [rooms, setRooms] = useState([]);
|
|
157
|
+
const [loading, setLoading] = useState(true);
|
|
158
|
+
const [error, setError] = useState("");
|
|
159
|
+
const refresh = useCallback(async () => {
|
|
160
|
+
setLoading(true);
|
|
161
|
+
setError("");
|
|
162
|
+
try {
|
|
163
|
+
setRooms(await client.listRooms());
|
|
164
|
+
}
|
|
165
|
+
catch (reason) {
|
|
166
|
+
setError(reason instanceof Error ? reason.message : "Rooms could not be loaded.");
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
setLoading(false);
|
|
170
|
+
}
|
|
171
|
+
}, [client]);
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
void refresh();
|
|
174
|
+
}, [refresh]);
|
|
175
|
+
return { rooms, loading, error, refresh };
|
|
176
|
+
}
|
|
177
|
+
/** @experimental */
|
|
178
|
+
export function useRoom(roomId) {
|
|
179
|
+
const client = useYouSaid();
|
|
180
|
+
const [room, setRoom] = useState(null);
|
|
181
|
+
const [loading, setLoading] = useState(Boolean(roomId));
|
|
182
|
+
const [error, setError] = useState("");
|
|
183
|
+
const refresh = useCallback(async () => {
|
|
184
|
+
if (!roomId) {
|
|
185
|
+
setRoom(null);
|
|
186
|
+
setLoading(false);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
setLoading(true);
|
|
190
|
+
setError("");
|
|
191
|
+
try {
|
|
192
|
+
setRoom(await client.getRoom(roomId));
|
|
193
|
+
}
|
|
194
|
+
catch (reason) {
|
|
195
|
+
setError(reason instanceof Error
|
|
196
|
+
? reason.message
|
|
197
|
+
: "This Room could not be opened.");
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
setLoading(false);
|
|
201
|
+
}
|
|
202
|
+
}, [client, roomId]);
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
void refresh();
|
|
205
|
+
}, [refresh]);
|
|
206
|
+
return { room, loading, error, refresh };
|
|
207
|
+
}
|
|
208
|
+
/** @experimental */
|
|
209
|
+
export function useRoomComposer(roomId, onSent) {
|
|
210
|
+
const client = useYouSaid();
|
|
211
|
+
const [pending, setPending] = useState(false);
|
|
212
|
+
const [error, setError] = useState("");
|
|
213
|
+
const send = useCallback(async (body) => {
|
|
214
|
+
if (!roomId)
|
|
215
|
+
return false;
|
|
216
|
+
setPending(true);
|
|
217
|
+
setError("");
|
|
218
|
+
try {
|
|
219
|
+
await client.postRoomMessage(roomId, body);
|
|
220
|
+
await onSent?.();
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
catch (reason) {
|
|
224
|
+
setError(reason instanceof Error
|
|
225
|
+
? reason.message
|
|
226
|
+
: "The message could not be sent.");
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
setPending(false);
|
|
231
|
+
}
|
|
232
|
+
}, [client, roomId, onSent]);
|
|
233
|
+
return { send, pending, error };
|
|
234
|
+
}
|
|
235
|
+
/** @experimental */
|
|
236
|
+
export function YouSaidRooms({ embedded = false, initialRoomId = null, onSolveSession, }) {
|
|
237
|
+
const client = useYouSaid();
|
|
238
|
+
const roomsState = useRooms();
|
|
239
|
+
const [selectedId, setSelectedId] = useState(initialRoomId);
|
|
240
|
+
const [creating, setCreating] = useState(false);
|
|
241
|
+
const [createError, setCreateError] = useState("");
|
|
242
|
+
useEffect(() => {
|
|
243
|
+
if (!selectedId && roomsState.rooms[0])
|
|
244
|
+
setSelectedId(roomsState.rooms[0].id);
|
|
245
|
+
}, [roomsState.rooms, selectedId]);
|
|
246
|
+
const detail = useRoom(selectedId);
|
|
247
|
+
async function create(event) {
|
|
248
|
+
event.preventDefault();
|
|
249
|
+
const data = new FormData(event.currentTarget);
|
|
250
|
+
setCreating(true);
|
|
251
|
+
setCreateError("");
|
|
252
|
+
try {
|
|
253
|
+
const created = await client.createRoom({
|
|
254
|
+
template: data.get("template"),
|
|
255
|
+
title: String(data.get("title") ?? ""),
|
|
256
|
+
purpose: String(data.get("purpose") ?? "") || undefined,
|
|
257
|
+
});
|
|
258
|
+
setSelectedId(created.id);
|
|
259
|
+
event.currentTarget.reset();
|
|
260
|
+
await roomsState.refresh();
|
|
261
|
+
}
|
|
262
|
+
catch (reason) {
|
|
263
|
+
setCreateError(reason instanceof Error
|
|
264
|
+
? reason.message
|
|
265
|
+
: "The Room could not be opened.");
|
|
266
|
+
}
|
|
267
|
+
finally {
|
|
268
|
+
setCreating(false);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return (_jsxs("section", { className: `ys-rooms ${embedded ? "ys-rooms--embedded" : ""}`, "data-yousaid-rooms": "true", children: [_jsx("style", { children: roomStyles + communityStyles + roomAgentStyles }), _jsxs("header", { className: "ys-rooms__header", children: [_jsxs("div", { children: [_jsx("span", { className: "ys-rooms__eyebrow", children: "Shared with the product team" }), _jsx("h2", { children: "Rooms" }), _jsx("p", { children: "Follow an issue or idea all the way to an outcome." })] }), _jsx("span", { className: "ys-rooms__quiet", children: "Quiet by default" })] }), _jsxs("div", { className: "ys-rooms__grid", children: [_jsxs("aside", { className: "ys-rooms__list", "aria-label": "Your Rooms", children: [roomsState.loading ? (_jsx(RoomSkeleton, {})) : roomsState.error ? (_jsx("p", { role: "alert", className: "ys-rooms__error", children: roomsState.error })) : roomsState.rooms.length ? (roomsState.rooms.map((room) => (_jsxs("button", { type: "button", onClick: () => setSelectedId(room.id), className: selectedId === room.id ? "is-active" : "", children: [_jsx("span", { className: `ys-room-dot ys-room-dot--${room.template_key}` }), _jsxs("span", { children: [_jsx("strong", { children: room.title }), _jsxs("small", { children: [friendlyStatus(room.status), " \u00B7", " ", room.last_message || room.purpose] })] }), Number(room.unread_count) > 0 ? (_jsx("b", { children: room.unread_count })) : null] }, room.id)))) : (_jsxs("div", { className: "ys-rooms__empty", children: [_jsx("strong", { children: "No Rooms yet" }), _jsx("p", { children: "Open one when a question deserves a shared outcome, not another disappearing chat." })] })), _jsxs("details", { className: "ys-rooms__new", children: [_jsx("summary", { children: "Open a Room" }), _jsxs("form", { onSubmit: create, children: [_jsxs("label", { children: ["What is this about?", _jsx("input", { name: "title", required: true, minLength: 3, maxLength: 140, placeholder: "Login recovery on mobile" })] }), _jsxs("label", { children: ["Shape", _jsxs("select", { name: "template", defaultValue: "direct", children: [_jsx("option", { value: "direct", children: "Private question" }), _jsx("option", { value: "feature", children: "Feature discussion" }), _jsx("option", { value: "topic", children: "Product topic" }), _jsx("option", { value: "office_hours", children: "Office hours" })] })] }), _jsxs("label", { children: ["What would a useful outcome be?", _jsx("textarea", { name: "purpose", maxLength: 600, placeholder: "Optional. We will suggest a clear outcome." })] }), createError ? (_jsx("p", { role: "alert", className: "ys-rooms__error", children: createError })) : null, _jsx("button", { type: "submit", disabled: creating, children: creating ? "Opening…" : "Open Room" })] })] })] }), _jsx("div", { className: "ys-rooms__detail", children: detail.loading ? (_jsx(RoomSkeleton, { detail: true })) : detail.error ? (_jsx("p", { role: "alert", className: "ys-rooms__error", children: detail.error })) : detail.room ? (_jsx(RoomView, { detail: detail.room, onRefresh: detail.refresh, onSolveSession: onSolveSession })) : (_jsxs("div", { className: "ys-rooms__welcome", children: [_jsx("span", { children: "\u25CE" }), _jsx("h3", { children: "Choose a Room" }), _jsx("p", { children: "The current state and next step stay visible without reading every message." })] })) })] })] }));
|
|
272
|
+
}
|
|
273
|
+
/** @experimental */
|
|
274
|
+
export function RoomsList({ rooms, selectedId, onSelect, }) {
|
|
275
|
+
return (_jsx("div", { children: rooms.map((room) => (_jsx("button", { type: "button", "aria-pressed": selectedId === room.id, onClick: () => onSelect(room.id), children: room.title }, room.id))) }));
|
|
276
|
+
}
|
|
277
|
+
/** @experimental */
|
|
278
|
+
export function RoomView({ detail, onRefresh, onSolveSession, }) {
|
|
279
|
+
const client = useYouSaid();
|
|
280
|
+
const [message, setMessage] = useState("");
|
|
281
|
+
const [uploading, setUploading] = useState(false);
|
|
282
|
+
const [uploadError, setUploadError] = useState("");
|
|
283
|
+
const [safetyNote, setSafetyNote] = useState("");
|
|
284
|
+
const composer = useRoomComposer(detail.room.id, onRefresh);
|
|
285
|
+
const canvas = detail.canvas?.content ?? {};
|
|
286
|
+
const [caret, setCaret] = useState(0);
|
|
287
|
+
const [mentionIndex, setMentionIndex] = useState(0);
|
|
288
|
+
const [mentionDismissed, setMentionDismissed] = useState(false);
|
|
289
|
+
const mention = mentionDismissed ? null : mentionAtCaret(message, caret);
|
|
290
|
+
const mentionMatches = mention ? filterAgents(DEFAULT_MENTION_AGENTS, mention.query) : [];
|
|
291
|
+
function insertMention(alias) {
|
|
292
|
+
if (!mention)
|
|
293
|
+
return;
|
|
294
|
+
const next = `${message.slice(0, mention.start)}@${alias} ${message.slice(caret)}`;
|
|
295
|
+
setMessage(next);
|
|
296
|
+
setCaret(mention.start + alias.length + 2);
|
|
297
|
+
setMentionIndex(0);
|
|
298
|
+
}
|
|
299
|
+
async function submit(event) {
|
|
300
|
+
event.preventDefault();
|
|
301
|
+
if (await composer.send(message))
|
|
302
|
+
setMessage("");
|
|
303
|
+
}
|
|
304
|
+
async function upload(file) {
|
|
305
|
+
setUploading(true);
|
|
306
|
+
setUploadError("");
|
|
307
|
+
try {
|
|
308
|
+
await client.uploadRoomAttachment(detail.room.id, file);
|
|
309
|
+
await onRefresh();
|
|
310
|
+
}
|
|
311
|
+
catch (reason) {
|
|
312
|
+
setUploadError(reason instanceof Error
|
|
313
|
+
? reason.message
|
|
314
|
+
: "The file could not be attached.");
|
|
315
|
+
}
|
|
316
|
+
finally {
|
|
317
|
+
setUploading(false);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return (_jsxs("article", { className: "ys-room-view", children: [_jsxs("header", { children: [_jsxs("div", { children: [_jsxs("span", { className: "ys-room-view__type", children: [detail.room.channel_kind === "office_hours"
|
|
321
|
+
? "Office hours"
|
|
322
|
+
: `${detail.room.template_key} Room`, " ", "\u00B7 ", friendlyStatus(detail.room.status)] }), _jsx("h3", { children: detail.room.title }), _jsx("p", { children: detail.room.purpose }), detail.solveSessions
|
|
323
|
+
.filter((session) => ["scheduled", "live"].includes(session.state))
|
|
324
|
+
.map((session) => (_jsxs("button", { type: "button", className: "ys-room-view__live", onClick: () => void client
|
|
325
|
+
.joinSolveSession(detail.room.id, session.id)
|
|
326
|
+
.then(({ authToken, mode }) => onSolveSession?.({ authToken, mode }))
|
|
327
|
+
.catch((reason) => setUploadError(reason instanceof Error
|
|
328
|
+
? reason.message
|
|
329
|
+
: "The live session is unavailable.")), children: ["\u25CF", " ", session.state === "live"
|
|
330
|
+
? "Join live Solve Session"
|
|
331
|
+
: "Open scheduled session"] }, session.id)))] }), _jsxs("div", { children: [_jsxs("details", { className: "ys-room-view__canvas", children: [_jsx("summary", { children: String(canvas.currentState ?? "Waiting for the next useful response.") }), _jsx(RoomCanvas, { detail: detail, title: "Current outcome" })] }), _jsx(RequestHumanButton, { roomId: detail.room.id })] })] }), _jsxs("div", { className: "ys-room-view__messages", "aria-live": "polite", children: [detail.messages.map((item) => (_jsxs("article", { className: item.author_type === "end_user"
|
|
332
|
+
? "is-customer"
|
|
333
|
+
: item.author_type === "agent"
|
|
334
|
+
? "is-agent"
|
|
335
|
+
: "is-team", children: [_jsxs("div", { children: [_jsx("strong", { children: item.author_label }), item.agent_alias ? _jsx("span", { children: "Agent \u00B7 sources visible" }) : null, _jsx("time", { children: new Date(item.created_at).toLocaleString() })] }), _jsx("p", { children: item.body_plain }), _jsx("button", { className: "ys-room-view__report", type: "button", onClick: () => void client
|
|
336
|
+
.reportRoomContent(detail.room.id, {
|
|
337
|
+
messageId: item.id,
|
|
338
|
+
category: "other",
|
|
339
|
+
reason: "This message needs a human moderator to review it in context.",
|
|
340
|
+
})
|
|
341
|
+
.then((result) => setSafetyNote(result.message))
|
|
342
|
+
.catch((reason) => setSafetyNote(reason instanceof Error
|
|
343
|
+
? reason.message
|
|
344
|
+
: "The report could not be sent.")), children: "Report" })] }, item.id))), detail.polls
|
|
345
|
+
.filter((poll) => poll.status === "open")
|
|
346
|
+
.map((poll) => (_jsx(CustomerPoll, { roomId: detail.room.id, poll: poll, onSaved: onRefresh }, poll.id))), detail.attachments.length ? (_jsx("div", { className: "ys-room-view__files", children: detail.attachments.map((file) => (_jsxs("button", { type: "button", onClick: () => void client
|
|
347
|
+
.downloadRoomAttachment(detail.room.id, file.id)
|
|
348
|
+
.then((blob) => {
|
|
349
|
+
const url = URL.createObjectURL(blob);
|
|
350
|
+
const link = document.createElement("a");
|
|
351
|
+
link.href = url;
|
|
352
|
+
link.download = file.file_name;
|
|
353
|
+
link.click();
|
|
354
|
+
setTimeout(() => URL.revokeObjectURL(url), 1_000);
|
|
355
|
+
})
|
|
356
|
+
.catch((reason) => setUploadError(reason instanceof Error
|
|
357
|
+
? reason.message
|
|
358
|
+
: "The file is unavailable.")), children: [_jsx("span", { children: "\u2197" }), file.file_name, _jsxs("small", { children: [Math.max(1, Math.ceil(file.byte_size / 1024)), " KB"] })] }, file.id))) })) : null, safetyNote ? (_jsx("p", { role: "status", className: "ys-room-view__safety", children: safetyNote })) : null] }), _jsxs("form", { className: "ys-room-view__composer", onSubmit: submit, children: [_jsx("label", { htmlFor: `ys-room-message-${detail.room.id}`, children: "Reply to this Room" }), _jsx("textarea", { id: `ys-room-message-${detail.room.id}`, value: message, onChange: (event) => {
|
|
359
|
+
setMessage(event.target.value);
|
|
360
|
+
setCaret(event.target.selectionStart ?? event.target.value.length);
|
|
361
|
+
setMentionDismissed(false);
|
|
362
|
+
}, onSelect: (event) => setCaret(event.currentTarget.selectionStart ?? 0), onKeyDown: (event) => {
|
|
363
|
+
if (!mention || !mentionMatches.length)
|
|
364
|
+
return;
|
|
365
|
+
const result = mentionMenuKey(event.key, mentionIndex, mentionMatches.length);
|
|
366
|
+
if (!result.handled)
|
|
367
|
+
return;
|
|
368
|
+
event.preventDefault();
|
|
369
|
+
if (result.close)
|
|
370
|
+
setMentionDismissed(true);
|
|
371
|
+
else if (result.select)
|
|
372
|
+
insertMention(mentionMatches[mentionIndex]?.alias ?? mentionMatches[0].alias);
|
|
373
|
+
else
|
|
374
|
+
setMentionIndex(result.activeIndex);
|
|
375
|
+
}, "aria-controls": mention ? `ys-mentions-${detail.room.id}` : undefined, "aria-autocomplete": "list", required: true, maxLength: 20_000, placeholder: "Write a reply or type @ to ask an agent\u2026" }), mention ? (_jsx(AgentMentionMenu, { id: `ys-mentions-${detail.room.id}`, query: mention.query, activeIndex: mentionIndex, onActiveIndexChange: setMentionIndex, onSelect: (agent) => insertMention(agent.alias), onClose: () => setMentionDismissed(true) })) : null, _jsxs("div", { children: [_jsx("small", { children: "Agents are labeled and cannot make external changes without approval." }), _jsxs("span", { className: "ys-room-view__actions", children: [_jsxs("label", { className: "ys-room-view__attach", children: [uploading ? "Attaching…" : "Attach", _jsx("input", { type: "file", accept: ".pdf,.json,.txt,.md,.csv,.png,.jpg,.jpeg,.webp,.gif", disabled: uploading, onChange: (event) => {
|
|
376
|
+
const file = event.target.files?.[0];
|
|
377
|
+
if (file)
|
|
378
|
+
void upload(file);
|
|
379
|
+
event.currentTarget.value = "";
|
|
380
|
+
} })] }), _jsx("button", { type: "submit", disabled: composer.pending || !message.trim(), children: composer.pending ? "Sending…" : "Send" })] })] }), composer.error || uploadError ? (_jsx("p", { role: "alert", className: "ys-rooms__error", children: composer.error || uploadError })) : null] })] }));
|
|
381
|
+
}
|
|
382
|
+
function CustomerPoll({ roomId, poll, onSaved, }) {
|
|
383
|
+
const client = useYouSaid();
|
|
384
|
+
const [selected, setSelected] = useState("");
|
|
385
|
+
const [answer, setAnswer] = useState("");
|
|
386
|
+
const [pending, setPending] = useState(false);
|
|
387
|
+
return (_jsxs("section", { className: "ys-room-poll", children: [_jsxs("small", { children: ["Room poll \u00B7 ", poll.responses.length, " responses"] }), _jsx("strong", { children: poll.question }), poll.poll_type === "open_question" ? (_jsx("textarea", { value: answer, onChange: (event) => setAnswer(event.target.value), placeholder: "Your answer" })) : (poll.options.map((option) => (_jsx("button", { type: "button", "aria-pressed": selected === option.id, onClick: () => setSelected(option.id), children: option.label }, option.id)))), _jsx("button", { type: "button", disabled: pending ||
|
|
388
|
+
(poll.poll_type === "open_question" ? !answer.trim() : !selected), onClick: () => {
|
|
389
|
+
setPending(true);
|
|
390
|
+
void client
|
|
391
|
+
.respondToRoomPoll(roomId, poll.id, {
|
|
392
|
+
optionIds: selected ? [selected] : [],
|
|
393
|
+
answer: answer || null,
|
|
394
|
+
})
|
|
395
|
+
.then(onSaved)
|
|
396
|
+
.finally(() => setPending(false));
|
|
397
|
+
}, children: pending ? "Saving…" : "Record response" })] }));
|
|
398
|
+
}
|
|
399
|
+
/** @experimental */
|
|
400
|
+
export function RoomComposer({ roomId, onSent, }) {
|
|
401
|
+
const [body, setBody] = useState("");
|
|
402
|
+
const composer = useRoomComposer(roomId, onSent);
|
|
403
|
+
return (_jsxs("form", { onSubmit: (event) => {
|
|
404
|
+
event.preventDefault();
|
|
405
|
+
void composer.send(body).then((sent) => {
|
|
406
|
+
if (sent)
|
|
407
|
+
setBody("");
|
|
408
|
+
});
|
|
409
|
+
}, children: [_jsx("textarea", { value: body, onChange: (event) => setBody(event.target.value), required: true }), _jsx("button", { disabled: composer.pending, children: "Send" }), composer.error ? _jsx("p", { role: "alert", children: composer.error }) : null] }));
|
|
410
|
+
}
|
|
411
|
+
/** @experimental */
|
|
412
|
+
export function AgentActionCard({ state, children, }) {
|
|
413
|
+
return (_jsxs("section", { "data-agent-state": state, children: [_jsx("strong", { children: state.replace("_", " ") }), children] }));
|
|
414
|
+
}
|
|
415
|
+
/** @experimental */
|
|
416
|
+
export function ApprovalCard({ summary, onApprove, onReject, }) {
|
|
417
|
+
return (_jsxs("section", { children: [_jsx("p", { children: summary }), _jsx("button", { type: "button", onClick: onApprove, children: "Approve exact action" }), _jsx("button", { type: "button", onClick: onReject, children: "Reject" })] }));
|
|
418
|
+
}
|
|
419
|
+
export function LifecycleReceipt({ title, state, }) {
|
|
420
|
+
return (_jsxs("section", { role: "status", children: [_jsx("strong", { children: title }), _jsx("p", { children: state })] }));
|
|
421
|
+
}
|
|
422
|
+
function RoomSkeleton({ detail = false }) {
|
|
423
|
+
return (_jsxs("div", { className: `ys-skeleton ${detail ? "is-detail" : ""}`, "aria-label": "Loading Rooms", children: [_jsx("i", {}), _jsx("i", {}), _jsx("i", {})] }));
|
|
424
|
+
}
|
|
425
|
+
function friendlyStatus(status) {
|
|
426
|
+
return status
|
|
427
|
+
.replaceAll("_", " ")
|
|
428
|
+
.replace(/^./, (value) => value.toUpperCase());
|
|
429
|
+
}
|
|
430
|
+
const communityStyles = `.ys-room-view__live{margin-top:12px;border:0;border-radius:10px;background:#18342c;color:var(--ys-mint);padding:8px 11px;font-weight:700;cursor:pointer}.ys-room-view__report{margin-top:8px;border:0;background:transparent;color:var(--ys-muted);font-size:9px;cursor:pointer}.ys-room-view__safety{max-width:none!important;border-radius:10px;background:#18342c;padding:10px;color:var(--ys-mint)}.ys-room-poll{max-width:100%;display:grid;gap:8px;border:1px solid var(--ys-line);border-radius:16px;background:var(--ys-panel);padding:15px}.ys-room-poll small{color:var(--ys-accent)}.ys-room-poll>button{border:1px solid var(--ys-line);border-radius:10px;background:var(--ys-inset);color:var(--ys-text);padding:9px;text-align:left;cursor:pointer}.ys-room-poll>button[aria-pressed=true]{border-color:var(--ys-accent);background:#2b2746}.ys-room-poll textarea{border:1px solid var(--ys-line);border-radius:10px;background:#0e0e13;color:var(--ys-text);padding:10px}`;
|
|
431
|
+
const roomStyles = `.ys-rooms{--ys-bg:#111116;--ys-panel:#19191f;--ys-inset:#212129;--ys-text:#f4f1eb;--ys-muted:#aaa6b2;--ys-line:#2c2b34;--ys-accent:#8878ff;--ys-mint:#6eddbb;color:var(--ys-text);background:var(--ys-bg);border:1px solid var(--ys-line);border-radius:24px;overflow:hidden;font:500 14px/1.5 ui-sans-serif,system-ui,sans-serif}.ys-rooms *{box-sizing:border-box}.ys-rooms button,.ys-rooms input,.ys-rooms textarea,.ys-rooms select{font:inherit}.ys-rooms__header{display:flex;justify-content:space-between;gap:24px;padding:24px 26px;border-bottom:1px solid var(--ys-line)}.ys-rooms__header h2{margin:3px 0 0;font-size:28px;line-height:1;letter-spacing:-.045em}.ys-rooms__header p{margin:9px 0 0;color:var(--ys-muted)}.ys-rooms__eyebrow{color:var(--ys-accent);font-size:11px;font-weight:750}.ys-rooms__quiet{align-self:flex-start;padding:7px 10px;border-radius:10px;background:#18342c;color:var(--ys-mint);font-size:11px}.ys-rooms__grid{display:grid;grid-template-columns:minmax(220px,34%) minmax(0,1fr);min-height:520px}.ys-rooms__list{padding:10px;border-right:1px solid var(--ys-line);background:#141419}.ys-rooms__list>button{width:100%;display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:10px;text-align:left;border:0;border-radius:14px;padding:13px;background:transparent;color:var(--ys-text);cursor:pointer;transition:background .2s ease,transform .2s ease}.ys-rooms__list>button:hover,.ys-rooms__list>button.is-active{background:var(--ys-inset)}.ys-rooms__list>button:active{transform:scale(.985)}.ys-rooms__list strong,.ys-rooms__list small{display:block}.ys-rooms__list small{margin-top:4px;color:var(--ys-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ys-rooms__list b{min-width:20px;text-align:center;padding:2px 5px;border-radius:8px;background:var(--ys-accent);font-size:10px}.ys-room-dot{width:9px;height:9px;margin-top:5px;border-radius:3px;background:var(--ys-accent)}.ys-room-dot--incident{background:#ff8178}.ys-room-dot--topic{background:var(--ys-mint)}.ys-rooms__new{margin:8px}.ys-rooms__new summary{cursor:pointer;padding:11px;color:var(--ys-accent);font-weight:700}.ys-rooms__new form{display:grid;gap:10px;padding:11px}.ys-rooms__new label{display:grid;gap:5px;color:var(--ys-muted);font-size:12px}.ys-rooms__new input,.ys-rooms__new textarea,.ys-rooms__new select,.ys-room-view__composer textarea{width:100%;border:1px solid var(--ys-line);border-radius:11px;background:#0e0e13;color:var(--ys-text);padding:10px;outline:none}.ys-rooms input:focus,.ys-rooms textarea:focus,.ys-rooms select:focus,.ys-rooms button:focus-visible{outline:2px solid var(--ys-accent);outline-offset:2px}.ys-rooms__new button,.ys-room-view__composer button{border:0;border-radius:11px;background:var(--ys-text);color:var(--ys-bg);padding:10px 14px;font-weight:750;cursor:pointer}.ys-rooms__new button:disabled,.ys-room-view__composer button:disabled{opacity:.5}.ys-rooms__detail{min-width:0}.ys-room-view>header{padding:24px;border-bottom:1px solid var(--ys-line);display:grid;grid-template-columns:minmax(0,1fr) minmax(180px,34%);gap:20px}.ys-room-view h3{margin:4px 0;font-size:25px;letter-spacing:-.035em}.ys-room-view header p{margin:8px 0 0;max-width:55ch;color:var(--ys-muted)}.ys-room-view__type{color:var(--ys-accent);font-size:11px;font-weight:750;text-transform:capitalize}.ys-room-view__canvas{background:var(--ys-inset);border-radius:14px;padding:13px}.ys-room-view__canvas summary{cursor:pointer;font-weight:750}.ys-room-view__canvas dl{margin:10px 0 0}.ys-room-view__canvas dl div+div{margin-top:10px}.ys-room-view__canvas dt{color:var(--ys-muted);font-size:10px;text-transform:uppercase;letter-spacing:.08em}.ys-room-view__canvas dd{margin:3px 0 0}.ys-room-view__messages{padding:22px;display:grid;gap:11px;max-height:460px;overflow:auto}.ys-room-view__messages article{max-width:82%;padding:13px 15px;border-radius:16px;background:var(--ys-inset)}.ys-room-view__messages article.is-customer{margin-left:auto;background:#4539a3}.ys-room-view__messages article.is-agent{border:1px solid #3c3860;background:#1f1d2d}.ys-room-view__messages article>div{display:flex;gap:8px;align-items:center}.ys-room-view__messages article span{color:var(--ys-accent);font-size:10px}.ys-room-view__messages time{margin-left:auto;color:var(--ys-muted);font-size:9px}.ys-room-view__messages p{margin:8px 0 0;white-space:pre-wrap}.ys-room-view__composer{border-top:1px solid var(--ys-line);padding:16px 22px}.ys-room-view__composer label{display:block;margin-bottom:6px;font-size:11px;color:var(--ys-muted)}.ys-room-view__composer textarea{min-height:80px;resize:vertical}.ys-room-view__composer>div{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-top:8px}.ys-room-view__composer small{color:var(--ys-muted)}.ys-room-view__actions{display:flex;align-items:center;gap:8px}.ys-room-view__attach{position:relative;overflow:hidden;cursor:pointer;padding:9px 11px;border:1px solid var(--ys-line);border-radius:11px;font-weight:700}.ys-room-view__attach input{position:absolute;inset:0;opacity:0;cursor:pointer}.ys-room-view__files{display:flex;flex-wrap:wrap;gap:7px;margin-top:7px}.ys-room-view__files button{display:flex;align-items:center;gap:6px;padding:8px 10px;border:0;border-radius:11px;background:var(--ys-inset);color:var(--ys-text);font-size:12px;cursor:pointer}.ys-room-view__files small{color:var(--ys-muted)}.ys-rooms__welcome,.ys-rooms__empty{padding:40px 24px;text-align:center;color:var(--ys-muted)}.ys-rooms__welcome span{display:block;color:var(--ys-accent);font-size:38px}.ys-rooms__welcome h3,.ys-rooms__empty strong{color:var(--ys-text)}.ys-rooms__error{padding:10px;border-radius:10px;background:#3b2023;color:#ffb6ae}.ys-skeleton{display:grid;gap:10px;padding:14px}.ys-skeleton i{display:block;height:54px;border-radius:13px;background:linear-gradient(90deg,var(--ys-inset),#292832,var(--ys-inset));background-size:200% 100%;animation:ys-shimmer 1.4s infinite}.ys-skeleton.is-detail i:first-child{height:120px}.ys-skeleton.is-detail i:nth-child(2){height:220px}@keyframes ys-shimmer{to{background-position:-200% 0}}@media(max-width:700px){.ys-rooms__header{padding:20px}.ys-rooms__quiet{display:none}.ys-rooms__grid{grid-template-columns:1fr}.ys-rooms__list{border-right:0;border-bottom:1px solid var(--ys-line);max-height:250px;overflow:auto}.ys-room-view>header{grid-template-columns:1fr}.ys-room-view__messages article{max-width:92%}}@media(prefers-reduced-motion:reduce){.ys-rooms *{animation:none!important;scroll-behavior:auto!important;transition:none!important}}`;
|
|
432
|
+
export * from "./proof.js";
|
|
433
|
+
export { ProofPromptView, useProofPrompt } from "./proof-prompt.js";
|
|
434
|
+
/**
|
|
435
|
+
* Native in-app Proof prompt using the provider's signed identity. See ProofPromptView for props.
|
|
436
|
+
* @experimental
|
|
437
|
+
*/
|
|
438
|
+
export function ProofPrompt(props) {
|
|
439
|
+
return _jsx(ProofPromptViewInternal, { client: useYouSaid(), ...props });
|
|
440
|
+
}
|
|
441
|
+
import { ProofPromptView as ProofPromptViewInternal } from "./proof-prompt.js";
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ProofEligibility, ProofPromptAction, ProofPromptEvent, ProofPromptPresentation, YouSaidClient } from "@sansavision/yousaid";
|
|
2
|
+
/** @experimental */
|
|
3
|
+
export type ProofPromptOptions = {
|
|
4
|
+
client: YouSaidClient;
|
|
5
|
+
formId: string;
|
|
6
|
+
presentation?: ProofPromptPresentation;
|
|
7
|
+
/** Check server-side frequency caps / "never ask" before opening automatically. */
|
|
8
|
+
respectFrequency?: boolean;
|
|
9
|
+
onEvent?: (event: ProofPromptEvent) => void;
|
|
10
|
+
};
|
|
11
|
+
/** @experimental */
|
|
12
|
+
export type ProofPromptState = {
|
|
13
|
+
status: "idle" | "checking" | "ineligible" | "ready" | "open" | "completed" | "error";
|
|
14
|
+
eligibility: ProofEligibility | null;
|
|
15
|
+
frameUrl: string | null;
|
|
16
|
+
error: string | null;
|
|
17
|
+
open: () => Promise<void>;
|
|
18
|
+
close: (action?: ProofPromptAction) => Promise<void>;
|
|
19
|
+
frameRef: React.RefObject<HTMLIFrameElement | null>;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Headless hook behind <ProofPrompt>: eligibility, a signed known-customer
|
|
23
|
+
* session, the frame URL and lifecycle events (open, step, consent, complete,
|
|
24
|
+
* abandon) from the collection frame.
|
|
25
|
+
* @experimental
|
|
26
|
+
*/
|
|
27
|
+
export declare function useProofPrompt({ client, formId, presentation, respectFrequency, onEvent }: ProofPromptOptions): ProofPromptState;
|
|
28
|
+
/** @experimental */
|
|
29
|
+
export type ProofPromptProps = {
|
|
30
|
+
client: YouSaidClient;
|
|
31
|
+
formId: string;
|
|
32
|
+
presentation?: ProofPromptPresentation;
|
|
33
|
+
/** The product moment that justified the ask, e.g. { event: "release_outcome_verified", id: "rel_123" }. Passed to onEvent. */
|
|
34
|
+
trigger?: {
|
|
35
|
+
event: string;
|
|
36
|
+
id?: string;
|
|
37
|
+
};
|
|
38
|
+
/** Open as soon as the prompt is eligible (still subject to server frequency caps). */
|
|
39
|
+
autoOpen?: boolean;
|
|
40
|
+
label?: string;
|
|
41
|
+
className?: string;
|
|
42
|
+
onEvent?: (event: ProofPromptEvent & {
|
|
43
|
+
trigger?: {
|
|
44
|
+
event: string;
|
|
45
|
+
id?: string;
|
|
46
|
+
};
|
|
47
|
+
}) => void;
|
|
48
|
+
onComplete?: (event: ProofPromptEvent) => void;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Native in-app Proof prompt (PRD §13.1, §17.4). Inline, modal, drawer,
|
|
52
|
+
* popover or full-screen. Offers "Not now" and "Don't ask again", which are
|
|
53
|
+
* enforced server-side across devices and products.
|
|
54
|
+
* @experimental
|
|
55
|
+
*/
|
|
56
|
+
export declare function ProofPromptView({ client, formId, presentation, trigger, autoOpen, label, className, onEvent, onComplete }: ProofPromptProps): import("react").JSX.Element | null;
|
|
57
|
+
//# sourceMappingURL=proof-prompt.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"proof-prompt.d.ts","sourceRoot":"","sources":["../src/proof-prompt.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,uBAAuB,EAAgB,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAExJ,oBAAoB;AACpB,MAAM,MAAM,kBAAkB,GAAG;IAC/B,MAAM,EAAE,aAAa,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,uBAAuB,CAAC;IACvC,mFAAmF;IACnF,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,gBAAgB,KAAK,IAAI,CAAC;CAC7C,CAAC;AAEF,oBAAoB;AACpB,MAAM,MAAM,gBAAgB,GAAG;IAC7B,MAAM,EAAE,MAAM,GAAG,UAAU,GAAG,YAAY,GAAG,OAAO,GAAG,MAAM,GAAG,WAAW,GAAG,OAAO,CAAC;IACtF,WAAW,EAAE,gBAAgB,GAAG,IAAI,CAAC;IACrC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1B,KAAK,EAAE,CAAC,MAAM,CAAC,EAAE,iBAAiB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACrD,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAAC;CACrD,CAAC;AAEF;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,YAAuB,EAAE,gBAAuB,EAAE,OAAO,EAAE,EAAE,kBAAkB,GAAG,gBAAgB,CAiDlJ;AAWD,oBAAoB;AACpB,MAAM,MAAM,gBAAgB,GAAG;IAC7B,MAAM,EAAE,aAAa,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,uBAAuB,CAAC;IACvC,+HAA+H;IAC/H,OAAO,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,EAAE,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACzC,uFAAuF;IACvF,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,gBAAgB,GAAG;QAAE,OAAO,CAAC,EAAE;YAAE,KAAK,EAAE,MAAM,CAAC;YAAC,EAAE,CAAC,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,KAAK,IAAI,CAAC;IAC3F,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,gBAAgB,KAAK,IAAI,CAAC;CAChD,CAAC;AAEF;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,YAAuB,EAAE,OAAO,EAAE,QAAgB,EAAE,KAA0B,EAAE,SAAS,EAAE,OAAO,EAAE,UAAU,EAAE,EAAE,gBAAgB,sCA+BnL"}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
/**
|
|
5
|
+
* Headless hook behind <ProofPrompt>: eligibility, a signed known-customer
|
|
6
|
+
* session, the frame URL and lifecycle events (open, step, consent, complete,
|
|
7
|
+
* abandon) from the collection frame.
|
|
8
|
+
* @experimental
|
|
9
|
+
*/
|
|
10
|
+
export function useProofPrompt({ client, formId, presentation = "drawer", respectFrequency = true, onEvent }) {
|
|
11
|
+
const proof = useMemo(() => client.proof(), [client]);
|
|
12
|
+
const [status, setStatus] = useState("idle");
|
|
13
|
+
const [eligibility, setEligibility] = useState(null);
|
|
14
|
+
const [session, setSession] = useState(null);
|
|
15
|
+
const [error, setError] = useState(null);
|
|
16
|
+
const frameRef = useRef(null);
|
|
17
|
+
const eventRef = useRef(onEvent);
|
|
18
|
+
eventRef.current = onEvent;
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!respectFrequency) {
|
|
21
|
+
setStatus("ready");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
let cancelled = false;
|
|
25
|
+
setStatus("checking");
|
|
26
|
+
proof.eligibility(formId).then((result) => { if (cancelled)
|
|
27
|
+
return; setEligibility(result); setStatus(result.eligible ? "ready" : "ineligible"); })
|
|
28
|
+
.catch(() => { if (!cancelled)
|
|
29
|
+
setStatus("ready"); });
|
|
30
|
+
return () => { cancelled = true; };
|
|
31
|
+
}, [proof, formId, respectFrequency]);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
function listen(message) {
|
|
34
|
+
const event = proof.parseEvent(message, frameRef.current);
|
|
35
|
+
if (!event)
|
|
36
|
+
return;
|
|
37
|
+
if (event.type === "resize" && frameRef.current && presentation === "inline" && typeof event.height === "number")
|
|
38
|
+
frameRef.current.style.height = `${Math.min(4000, Math.max(400, event.height + 8))}px`;
|
|
39
|
+
if (event.type === "complete")
|
|
40
|
+
setStatus("completed");
|
|
41
|
+
eventRef.current?.(event);
|
|
42
|
+
}
|
|
43
|
+
window.addEventListener("message", listen);
|
|
44
|
+
return () => window.removeEventListener("message", listen);
|
|
45
|
+
}, [proof, presentation]);
|
|
46
|
+
const open = useCallback(async () => {
|
|
47
|
+
setError(null);
|
|
48
|
+
try {
|
|
49
|
+
const next = session ?? await proof.startSession(formId, presentation);
|
|
50
|
+
setSession(next);
|
|
51
|
+
setStatus("open");
|
|
52
|
+
}
|
|
53
|
+
catch (caught) {
|
|
54
|
+
setError(caught instanceof Error ? caught.message : "The story prompt could not open.");
|
|
55
|
+
setStatus("error");
|
|
56
|
+
}
|
|
57
|
+
}, [proof, formId, presentation, session]);
|
|
58
|
+
const close = useCallback(async (action) => {
|
|
59
|
+
setStatus((current) => (current === "completed" ? current : "ready"));
|
|
60
|
+
if (action)
|
|
61
|
+
await proof.recordPreference(formId, action).catch(() => undefined);
|
|
62
|
+
}, [proof, formId]);
|
|
63
|
+
return { status, eligibility, frameUrl: session ? proof.frameUrl(formId, session) : null, error, open, close, frameRef };
|
|
64
|
+
}
|
|
65
|
+
const overlayStyle = { position: "fixed", inset: 0, zIndex: 2147483000, background: "rgba(10,10,14,.55)", display: "flex" };
|
|
66
|
+
function panelStyle(presentation) {
|
|
67
|
+
const base = { background: "#fff", display: "flex", flexDirection: "column", overflow: "hidden", boxShadow: "0 24px 80px rgba(0,0,0,.3)" };
|
|
68
|
+
if (presentation === "drawer")
|
|
69
|
+
return { ...base, marginLeft: "auto", width: "min(520px, 100vw)", height: "100vh" };
|
|
70
|
+
if (presentation === "fullscreen")
|
|
71
|
+
return { ...base, width: "100vw", height: "100vh" };
|
|
72
|
+
if (presentation === "popover")
|
|
73
|
+
return { ...base, position: "fixed", right: 16, bottom: 16, width: "min(420px, calc(100vw - 32px))", height: "min(640px, calc(100vh - 32px))", borderRadius: 18 };
|
|
74
|
+
return { ...base, margin: "auto", width: "min(680px, calc(100vw - 24px))", height: "min(860px, calc(100vh - 24px))", borderRadius: 18 };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Native in-app Proof prompt (PRD §13.1, §17.4). Inline, modal, drawer,
|
|
78
|
+
* popover or full-screen. Offers "Not now" and "Don't ask again", which are
|
|
79
|
+
* enforced server-side across devices and products.
|
|
80
|
+
* @experimental
|
|
81
|
+
*/
|
|
82
|
+
export function ProofPromptView({ client, formId, presentation = "drawer", trigger, autoOpen = false, label = "Share your story", className, onEvent, onComplete }) {
|
|
83
|
+
const prompt = useProofPrompt({
|
|
84
|
+
client, formId, presentation, respectFrequency: presentation !== "inline",
|
|
85
|
+
onEvent: (event) => { onEvent?.({ ...event, ...(trigger ? { trigger } : {}) }); if (event.type === "complete")
|
|
86
|
+
onComplete?.(event); },
|
|
87
|
+
});
|
|
88
|
+
const { status, open } = prompt;
|
|
89
|
+
useEffect(() => { if ((autoOpen || presentation === "inline") && status === "ready")
|
|
90
|
+
void open(); }, [autoOpen, presentation, status, open]);
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (status !== "open" || presentation === "inline")
|
|
93
|
+
return;
|
|
94
|
+
const onKey = (event) => { if (event.key === "Escape")
|
|
95
|
+
void prompt.close("dismissed"); };
|
|
96
|
+
document.addEventListener("keydown", onKey);
|
|
97
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
98
|
+
}, [status, presentation, prompt]);
|
|
99
|
+
const frame = prompt.frameUrl ? _jsx("iframe", { ref: prompt.frameRef, src: prompt.frameUrl, title: label, allow: "camera; microphone; display-capture; clipboard-write", referrerPolicy: "no-referrer", style: { border: 0, width: "100%", flex: 1, minHeight: presentation === "inline" ? 560 : 0 } }) : null;
|
|
100
|
+
if (presentation === "inline")
|
|
101
|
+
return _jsx("div", { className: className, children: status === "error" ? _jsx("p", { role: "alert", children: prompt.error }) : frame });
|
|
102
|
+
if (status === "ineligible" && !autoOpen)
|
|
103
|
+
return null;
|
|
104
|
+
return _jsxs("div", { className: className, children: [status !== "open" ? _jsx("button", { type: "button", onClick: () => void open(), disabled: status === "checking", children: label }) : null, status === "error" ? _jsx("p", { role: "alert", children: prompt.error }) : null, status === "open" && frame ? _jsx("div", { role: "dialog", "aria-modal": "true", "aria-label": label, style: overlayStyle, onClick: (event) => { if (event.target === event.currentTarget)
|
|
105
|
+
void prompt.close("dismissed"); }, children: _jsxs("div", { style: panelStyle(presentation), children: [_jsxs("div", { style: { display: "flex", justifyContent: "flex-end", gap: 8, padding: "8px 12px", borderBottom: "1px solid rgba(0,0,0,.08)", fontSize: 13 }, children: [_jsx("button", { type: "button", onClick: () => void prompt.close("not_now"), children: "Not now" }), _jsx("button", { type: "button", onClick: () => void prompt.close("never"), children: "Don't ask again" }), _jsx("button", { type: "button", "aria-label": "Close", onClick: () => void prompt.close("dismissed"), children: "×" })] }), frame] }) }) : null] });
|
|
106
|
+
}
|