@wix/himalaya-cli 0.1614.0 → 0.1615.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/dist/cli.mjs +1 -0
- package/package.json +2 -2
- package/template-icons/salon-spa.png +0 -0
- package/templates/beauty-salon/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/beauty-salon/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/beauty-salon/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/beauty-salon/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/beauty-salon/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/beauty-salon/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
- package/templates/branded-lite/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/branded-lite/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/branded-lite/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/branded-lite/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/branded-lite/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/branded-lite/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
- package/templates/branded-lite/tier5-src/himi-branded-lite-galleries/index.ts +104 -12
- package/templates/community-club/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/community-club/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/events-meetups/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/events-meetups/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/gym-fitness/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/gym-fitness/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/gym-fitness/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/gym-fitness/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/gym-fitness/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/gym-fitness/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
- package/templates/nonprofit-cause/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/nonprofit-cause/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/podcast-creator/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/podcast-creator/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/portfolio-creative/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/portfolio-creative/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/restaurant/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/restaurant/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/retail-store/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/retail-store/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/salon-spa/README.md +53 -0
- package/templates/salon-spa/config.ts +272 -0
- package/templates/salon-spa/dev/index.ts +19893 -0
- package/templates/salon-spa/himalaya.content.json +49 -0
- package/templates/salon-spa/himalaya.template.json +14 -0
- package/templates/salon-spa/icon.png +0 -0
- package/templates/salon-spa/platforms.json +1 -0
- package/templates/salon-spa/tier5-src/_shared/app-locale.ts +61 -0
- package/templates/salon-spa/tier5-src/_shared/booking-location.ts +147 -0
- package/templates/salon-spa/tier5-src/_shared/booking-session.ts +222 -0
- package/templates/salon-spa/tier5-src/_shared/branded-lite-hosted.ts +278 -0
- package/templates/salon-spa/tier5-src/_shared/branded-load-state.ts +109 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-copy.ts +40 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-history.ts +48 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-message-model.ts +177 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-model.ts +56 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-presenter.ts +581 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-transport.ts +148 -0
- package/templates/salon-spa/tier5-src/_shared/collection-contract.ts +230 -0
- package/templates/salon-spa/tier5-src/_shared/collection-query.ts +193 -0
- package/templates/salon-spa/tier5-src/_shared/collection-service.ts +400 -0
- package/templates/salon-spa/tier5-src/_shared/detail-presence.ts +51 -0
- package/templates/salon-spa/tier5-src/_shared/empty-flags.ts +33 -0
- package/templates/salon-spa/tier5-src/_shared/engine-intl.ts +508 -0
- package/templates/salon-spa/tier5-src/_shared/himi-account.ts +164 -0
- package/templates/salon-spa/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/salon-spa/tier5-src/_shared/himi-bookings.ts +318 -0
- package/templates/salon-spa/tier5-src/_shared/himi-copy.ts +89 -0
- package/templates/salon-spa/tier5-src/_shared/himi-store.ts +7 -0
- package/templates/salon-spa/tier5-src/_shared/himi-text.ts +24 -0
- package/templates/salon-spa/tier5-src/_shared/host-events.ts +66 -0
- package/templates/salon-spa/tier5-src/_shared/hosted-checkout.ts +70 -0
- package/templates/salon-spa/tier5-src/_shared/iap-attendance-form.ts +106 -0
- package/templates/salon-spa/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
- package/templates/salon-spa/tier5-src/_shared/iap-attendance.ts +38 -0
- package/templates/salon-spa/tier5-src/_shared/iap-worker.ts +125 -0
- package/templates/salon-spa/tier5-src/_shared/local-preferences.ts +38 -0
- package/templates/salon-spa/tier5-src/_shared/member-access.ts +52 -0
- package/templates/salon-spa/tier5-src/_shared/member-account-routes.ts +52 -0
- package/templates/salon-spa/tier5-src/_shared/member-bookings.ts +94 -0
- package/templates/salon-spa/tier5-src/_shared/offset-page.ts +10 -0
- package/templates/salon-spa/tier5-src/_shared/online-actions.ts +76 -0
- package/templates/salon-spa/tier5-src/_shared/owner-load-error.ts +348 -0
- package/templates/salon-spa/tier5-src/_shared/payment-setup.ts +6 -0
- package/templates/salon-spa/tier5-src/_shared/rich-body.ts +78 -0
- package/templates/salon-spa/tier5-src/_shared/rich-content-link.ts +34 -0
- package/templates/salon-spa/tier5-src/_shared/weekly-goal.ts +107 -0
- package/templates/salon-spa/tier5-src/_shared/wix-service-price.ts +146 -0
- package/templates/salon-spa/tier5-src/business-chat/index.ts +669 -0
- package/templates/salon-spa/tier5-src/himi-booking-account/index.ts +241 -0
- package/templates/salon-spa/tier5-src/himi-booking-bookings/index.ts +466 -0
- package/templates/salon-spa/tier5-src/himi-booking-saved/index.ts +103 -0
- package/templates/salon-spa/tier5-src/himi-booking-service/index.ts +1170 -0
- package/templates/salon-spa/tier5-src/himi-booking-services/index.ts +791 -0
- package/templates/salon-spa/tier5-src/himi-booking-time/index.ts +1712 -0
- package/templates/salon-spa/tier5-src/himi-branded-lite-agenda/index.ts +950 -0
- package/templates/salon-spa/tier5-src/himi-branded-lite-galleries/index.ts +282 -0
- package/templates/salon-spa/tier5-src/himi-salon-home/index.ts +544 -0
- package/templates/salon-spa/tier5-src/himi-store-account/index.ts +925 -0
- package/templates/salon-spa/tokens.json +341 -0
- package/templates/yoga-studio/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/yoga-studio/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/yoga-studio/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/yoga-studio/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/yoga-studio/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/yoga-studio/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
import { businessChatCopy } from "./business-chat-copy.js";
|
|
2
|
+
import { formatMonthDay } from "./engine-intl.js";
|
|
3
|
+
type RecordValue = Record<string, unknown>;
|
|
4
|
+
|
|
5
|
+
export type BusinessMessagePart = {
|
|
6
|
+
kind?: string;
|
|
7
|
+
text?: string;
|
|
8
|
+
fileName?: string;
|
|
9
|
+
caption?: string;
|
|
10
|
+
url?: string;
|
|
11
|
+
thumbnailUrl?: string;
|
|
12
|
+
fields?: { name: string; value: string }[];
|
|
13
|
+
buttons?: { id: string; label: string; url: string; interactionId?: string; enabled: boolean }[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type BusinessTextRun = { text: string; marks?: string[] };
|
|
17
|
+
export type BusinessSuggestion = { id: string; messageId: string; label: string; kind: "postback" | "visitor_option"; appId?: string; interactionId?: string; enabled: boolean; disabled: boolean };
|
|
18
|
+
/** `inApp` buttons open `screenId` with `targetId` inside the app; the rest open `url` in the browser. Only a
|
|
19
|
+
* `routable` one (a link in the text or a template button, never a file) may be opened in the app. */
|
|
20
|
+
export type BusinessArticleAction = { id: string; label: string; url: string; style: "primary" | "secondary"; screenId: string; targetId: string; inApp: boolean; web: boolean; routable: boolean };
|
|
21
|
+
/** The app's own screen for a site link, named by the slug in the link's path. */
|
|
22
|
+
export type BusinessInAppRoute = { kind: "product" | "service"; slug: string };
|
|
23
|
+
|
|
24
|
+
/** A store product page or a bookings service (its page or its booking calendar) on this app's own site has a screen
|
|
25
|
+
* in the app; another site's page (even one on the same host, as Wix sites share one), a pay link or a file has
|
|
26
|
+
* none, and with no site configured nothing is routed. The slug only names the item: the worker still has to find it. */
|
|
27
|
+
export function businessInAppRoute(url: string, siteUrl: string): BusinessInAppRoute | undefined {
|
|
28
|
+
const site = /^https:\/\/[^/?#\s]+(?:\/[^?#\s]*)?/i.exec(siteUrl.trim())?.[0].replace(/\/+$/, "") ?? "";
|
|
29
|
+
if (!site || !url.toLowerCase().startsWith(`${site.toLowerCase()}/`)) return undefined;
|
|
30
|
+
const match = /^\/(product-page|service-page|booking-calendar)\/([^/?#]+)\/?(?:[?#].*)?$/i.exec(url.slice(site.length));
|
|
31
|
+
if (!match) return undefined;
|
|
32
|
+
let slug: string;
|
|
33
|
+
// A malformed escape ("50%-off") names nothing the app can look up: the link stays a web link.
|
|
34
|
+
try { slug = decodeURIComponent(match[2]!); } catch { return undefined; }
|
|
35
|
+
return slug ? { kind: match[1]!.toLowerCase() === "product-page" ? "product" : "service", slug } : undefined;
|
|
36
|
+
}
|
|
37
|
+
const webAction = { screenId: "", targetId: "", inApp: false, web: true, routable: true };
|
|
38
|
+
export type BusinessVisitorOptions = { answerTo: string; labels: string[] };
|
|
39
|
+
|
|
40
|
+
export type BusinessMessage = {
|
|
41
|
+
id: string;
|
|
42
|
+
sender: string;
|
|
43
|
+
senderName: string;
|
|
44
|
+
senderId: string;
|
|
45
|
+
mine: boolean;
|
|
46
|
+
when: string;
|
|
47
|
+
createdDate: string;
|
|
48
|
+
plainText: string;
|
|
49
|
+
parts: BusinessMessagePart[];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type BusinessRoomSummary = {
|
|
53
|
+
id?: unknown;
|
|
54
|
+
participantId?: unknown;
|
|
55
|
+
participantCount?: unknown;
|
|
56
|
+
clientReferenceType?: unknown;
|
|
57
|
+
businessContext?: unknown;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Keep host capability failures visible without exposing runtime error details. */
|
|
61
|
+
export function businessRealtimeStatusLabel(state: string, reason = ""): string {
|
|
62
|
+
if (state === "connected") return "";
|
|
63
|
+
if (state === "connecting") return businessChatCopy('connecting');
|
|
64
|
+
if (state === "reconnecting") return businessChatCopy('reconnecting');
|
|
65
|
+
if (reason === "chat_setup_scope_unavailable") return businessChatCopy('liveSessionUnavailable');
|
|
66
|
+
if (reason === "runtime_unavailable") return businessChatCopy('liveHostUnavailable');
|
|
67
|
+
if (reason === "invalid_setup_response") return businessChatCopy('liveSetupFailed');
|
|
68
|
+
if (reason === "firebase_auth_failed") return businessChatCopy('liveAuthFailed');
|
|
69
|
+
if (reason === "firebase_connection_failed") return businessChatCopy('liveInterrupted');
|
|
70
|
+
if (reason === "invalid_event") return businessChatCopy('liveUnreadable');
|
|
71
|
+
if (reason === "event_overflow") return businessChatCopy('livePaused');
|
|
72
|
+
return businessChatCopy('liveUnavailable');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const object = (value: unknown): RecordValue =>
|
|
76
|
+
value !== null && typeof value === "object" && !Array.isArray(value) ? value as RecordValue : {};
|
|
77
|
+
|
|
78
|
+
/** Return a sole existing two-party business room, or a unique business-name match among several;
|
|
79
|
+
* never create a room or guess among remaining matches. */
|
|
80
|
+
export function selectExistingBusinessRoom(rooms: unknown[], businessName?: string, requireUnambiguous = false): { id: string; participantId: string } | undefined {
|
|
81
|
+
let candidates = rooms.filter((value) => {
|
|
82
|
+
const room = object(value);
|
|
83
|
+
return typeof room.id === "string" && !!room.id &&
|
|
84
|
+
typeof room.participantId === "string" && !!room.participantId &&
|
|
85
|
+
room.participantCount === 2 && room.clientReferenceType === "contactId" &&
|
|
86
|
+
Object.keys(object(room.businessContext)).length > 0;
|
|
87
|
+
});
|
|
88
|
+
const candidateCount = candidates.length;
|
|
89
|
+
if (candidateCount > 1 && businessName?.trim()) {
|
|
90
|
+
// Keep case significant so differently named businesses are not conflated.
|
|
91
|
+
const name = businessName.trim();
|
|
92
|
+
candidates = candidates.filter((value) => {
|
|
93
|
+
const roomName = object(object(value).businessContext).name;
|
|
94
|
+
return typeof roomName === "string" && roomName.trim() === name;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (candidates.length !== 1) {
|
|
98
|
+
if (requireUnambiguous && candidateCount > 1) throw new Error("business_room_selection_ambiguous");
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
const room = object(candidates[0]);
|
|
102
|
+
return { id: room.id as string, participantId: room.participantId as string };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The member's current AI quick replies: the newest assistant answer with real `visitorOptions` that no
|
|
106
|
+
* selection has answered yet (typed free text does not consume them, matching the website widget).
|
|
107
|
+
* Wix's AI `inboxId` is a different id namespace from the chat-web row id, so these are composer-level,
|
|
108
|
+
* not joined to a row. They must belong to the same AI conversation as the newest message in the list;
|
|
109
|
+
* `answerTo` is always the exact AI answer id. */
|
|
110
|
+
export function businessVisitorOptionsFromAiMessages(aiMessages: unknown[]): BusinessVisitorOptions | null {
|
|
111
|
+
const dated = aiMessages.map(object).map((message, index) => ({ message, index, at: Date.parse(String(message.createdDate)) }))
|
|
112
|
+
.filter(({ at }) => Number.isFinite(at))
|
|
113
|
+
.sort((left, right) => left.at - right.at || left.index - right.index)
|
|
114
|
+
.map(({ message }) => message);
|
|
115
|
+
const conversationId = dated.at(-1)?.conversationId;
|
|
116
|
+
if (typeof conversationId !== "string" || !conversationId) return null;
|
|
117
|
+
const answered = new Set(dated.filter((message) => message.messageType === "VISITOR_OPTION_SELECTED" && typeof message.answerTo === "string")
|
|
118
|
+
.map((message) => message.answerTo as string));
|
|
119
|
+
for (const message of dated.slice().reverse()) {
|
|
120
|
+
const id = typeof message.id === "string" ? message.id.trim() : "";
|
|
121
|
+
const labels = Array.isArray(message.visitorOptions)
|
|
122
|
+
? message.visitorOptions.filter((value): value is string => typeof value === "string" && !!value.trim()).map((value) => value.trim())
|
|
123
|
+
: [];
|
|
124
|
+
if (message.sender !== "ASSISTANT" || message.messageType !== "ANSWER" || !id || !labels.length) continue;
|
|
125
|
+
return message.conversationId === conversationId && !answered.has(id) ? { answerTo: id, labels } : null;
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Build the observed Wix Smart Chat visitor-option selection only from current,
|
|
131
|
+
* identity-scoped AI metadata. The ID is request-local; the answerTo anchors it
|
|
132
|
+
* to the exact assistant answer. */
|
|
133
|
+
export function businessVisitorOptionRequest(
|
|
134
|
+
options: BusinessVisitorOptions | null | undefined,
|
|
135
|
+
label: string,
|
|
136
|
+
selectionId: string,
|
|
137
|
+
): { message: { sender: "SITE_VISITOR"; messageType: "VISITOR_OPTION_SELECTED"; answerTo: string; body: { mainText: string; additionalData: { id: string; type: "select_option"; action: string }[] } } } | undefined {
|
|
138
|
+
if (!options || !options.answerTo || !options.labels.includes(label) || !/^[0-9a-f-]{36}$/i.test(selectionId)) return undefined;
|
|
139
|
+
return {
|
|
140
|
+
message: {
|
|
141
|
+
sender: "SITE_VISITOR",
|
|
142
|
+
messageType: "VISITOR_OPTION_SELECTED",
|
|
143
|
+
answerTo: options.answerTo,
|
|
144
|
+
body: { mainText: label, additionalData: [{ id: selectionId, type: "select_option", action: label }] },
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function newBusinessSelectionId(random: () => number = Math.random): string {
|
|
150
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (value) => {
|
|
151
|
+
const n = Math.floor(random() * 16);
|
|
152
|
+
return (value === "x" ? n : (n & 0x3) | 0x8).toString(16);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function businessVisitorOptionSuggestions(options: BusinessVisitorOptions | null | undefined): BusinessSuggestion[] {
|
|
157
|
+
if (!options?.answerTo) return [];
|
|
158
|
+
return options.labels.map((label, index) => ({
|
|
159
|
+
id: `${options.answerTo}:visitor-option-${index}`,
|
|
160
|
+
messageId: options.answerTo,
|
|
161
|
+
label,
|
|
162
|
+
kind: "visitor_option",
|
|
163
|
+
enabled: true,
|
|
164
|
+
disabled: false,
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Wix Chat realtime currently sends `{type, timestamp, summary, message?}` events. */
|
|
169
|
+
export function isBusinessMessageEvent(envelope: unknown, roomId: string, smartChat = false): boolean {
|
|
170
|
+
const event = object(object(envelope).event);
|
|
171
|
+
if ((!roomId && !smartChat) || typeof event.type !== "string" || !/^(message|messagesent)$/i.test(event.type)) return false;
|
|
172
|
+
const message = object(event.message);
|
|
173
|
+
const data = object(event.data);
|
|
174
|
+
const nested = object(data.message);
|
|
175
|
+
const eventRoomId = [event.chatroomId, event.chatRoomId, message.chatroomId, message.chatRoomId, data.chatroomId, nested.chatroomId]
|
|
176
|
+
.find((value): value is string => typeof value === "string" && value.length > 0);
|
|
177
|
+
// Smart Chat's room can differ from the Members room. The subsequent read is scoped by
|
|
178
|
+
// the current member/site identity, never by the event's untrusted room id.
|
|
179
|
+
return smartChat || !eventRoomId || eventRoomId === roomId;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function shouldRefreshBusinessHistory(
|
|
183
|
+
envelope: unknown,
|
|
184
|
+
roomId: string,
|
|
185
|
+
session: { member: boolean; principal: string; currentPrincipal: string; scope: string; currentScope: string },
|
|
186
|
+
smartChat = false,
|
|
187
|
+
): boolean {
|
|
188
|
+
return !!session.member && (!!roomId || smartChat) && !!session.principal && session.principal === session.currentPrincipal &&
|
|
189
|
+
session.scope === session.currentScope && isBusinessMessageEvent(envelope, roomId, smartChat);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function safeAvatar(value: unknown): string {
|
|
193
|
+
if (typeof value !== "string" || /[\s\\\u0000-\u001f\u007f-\u009f]/u.test(value)) return "";
|
|
194
|
+
const match = /^https:\/\/([^/?#]+)(?:[/?#].*)?$/iu.exec(value);
|
|
195
|
+
// No credentials, and no percent escapes in the host: a host is plain letters, digits, dots and hyphens.
|
|
196
|
+
return match && !/[@%]/.test(match[1]!) ? value : "";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** A card's picture, only a plain https URL. Wix sends a 200-point thumbnail; a card shows it about 270 wide on a
|
|
200
|
+
* dense screen, so a Wix media URL asks its image service for a larger copy instead of being blown up. */
|
|
201
|
+
function cardImage(value: unknown): string {
|
|
202
|
+
const url = safeAvatar(value);
|
|
203
|
+
return /^https:\/\/static\.wixstatic\.com\/media\//i.test(url) ? url.replace(/\/v1\/fit\/w_\d+,h_\d+,/i, "/v1/fit/w_800,h_800,") : url;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function initials(value: string): string {
|
|
207
|
+
const words = value.trim().split(/\s+/).filter(Boolean);
|
|
208
|
+
return words.length > 1 ? `${words[0]![0]}${words[words.length - 1]![0]}`.toUpperCase() : (words[0]?.slice(0, 2) ?? "").toUpperCase();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function decodeHtml(value: string): string {
|
|
212
|
+
return value.replace(/&(#x[0-9a-f]+|#[0-9]+|amp|lt|gt|quot|apos|nbsp);/gi, (whole, entity: string) => {
|
|
213
|
+
const names: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: "\"", apos: "'", nbsp: " " };
|
|
214
|
+
if (entity[0] !== "#") return names[entity.toLowerCase()] ?? whole;
|
|
215
|
+
const code = entity[1]?.toLowerCase() === "x" ? parseInt(entity.slice(2), 16) : Number(entity.slice(1));
|
|
216
|
+
return code > 0 && code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff) ? String.fromCodePoint(code) : whole;
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Convert Wix's text/html parts into safe inline text marks; HTML is never rendered. */
|
|
221
|
+
function htmlRuns(html: string): BusinessTextRun[] {
|
|
222
|
+
const source = html.replace(/<!--[\s\S]*?-->/g, "").replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, "");
|
|
223
|
+
const runs: BusinessTextRun[] = [];
|
|
224
|
+
const marks = new Set<string>();
|
|
225
|
+
const append = (text: string) => {
|
|
226
|
+
if (!text) return;
|
|
227
|
+
const active = [...marks].sort();
|
|
228
|
+
const last = runs[runs.length - 1];
|
|
229
|
+
if (last && JSON.stringify(last.marks ?? []) === JSON.stringify(active)) last.text += text;
|
|
230
|
+
else runs.push(active.length ? { text, marks: active } : { text });
|
|
231
|
+
};
|
|
232
|
+
const tokens = source.match(/<[^>]*>|[^<]+/g) ?? [];
|
|
233
|
+
for (const token of tokens) {
|
|
234
|
+
if (!token.startsWith("<")) { append(decodeHtml(token)); continue; }
|
|
235
|
+
const tag = /^<\s*(\/?)\s*([a-z0-9]+)/i.exec(token);
|
|
236
|
+
if (!tag) continue;
|
|
237
|
+
const closing = tag[1] === "/";
|
|
238
|
+
const name = tag[2]!.toLowerCase();
|
|
239
|
+
if (name === "br" || name === "p" || name === "div" || name === "li" || /^h[1-6]$/.test(name)) {
|
|
240
|
+
if (closing || name === "br" || name === "li") append("\n");
|
|
241
|
+
}
|
|
242
|
+
const mark = name === "b" || name === "strong" ? "bold" : name === "i" || name === "em" ? "italic" : name === "u" ? "underline" : name === "code" ? "code" : name === "a" ? "link" : "";
|
|
243
|
+
if (!mark) continue;
|
|
244
|
+
if (closing) marks.delete(mark); else marks.add(mark);
|
|
245
|
+
}
|
|
246
|
+
return runs.map((run) => ({ ...run, text: run.text.replace(/\n{3,}/g, "\n\n") })).filter((run) => run.text.length > 0);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function htmlMessageRuns(message: RecordValue): BusinessTextRun[] | undefined {
|
|
250
|
+
const parts = Array.isArray(message.data) ? message.data : [];
|
|
251
|
+
const htmlPart = parts.map(object).find((part) => typeof part.mimeType === "string" && part.mimeType.toLowerCase().split(";")[0] === "text/html");
|
|
252
|
+
if (!htmlPart) return undefined;
|
|
253
|
+
const data = typeof htmlPart.data === "string" ? htmlPart.data : object(htmlPart.data).html ?? object(htmlPart.data).text;
|
|
254
|
+
return typeof data === "string" ? htmlRuns(data) : undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Recognize the inline Markdown Wix AI emits in text/plain messages; all other markers stay escaped. */
|
|
258
|
+
function markdownRuns(value: string): BusinessTextRun[] {
|
|
259
|
+
const runs: BusinessTextRun[] = [];
|
|
260
|
+
const pattern = /(\*\*[^*\n]+?\*\*|__[^_\n]+?__|`[^`\n]+`|\*[^*\n]+?\*|_[^_\n]+?_)/g;
|
|
261
|
+
let cursor = 0;
|
|
262
|
+
for (const match of value.matchAll(pattern)) {
|
|
263
|
+
const index = match.index ?? 0;
|
|
264
|
+
const token = match[0];
|
|
265
|
+
// Underscore emphasis needs a word boundary, so `my_file_name` keeps its underscores (as on Android).
|
|
266
|
+
if (token.startsWith("_") && (/\w/.test(value[index - 1] ?? "") || /\w/.test(value[index + token.length] ?? ""))) continue;
|
|
267
|
+
if (index > cursor) runs.push({ text: value.slice(cursor, index) });
|
|
268
|
+
const double = token.startsWith("**") || token.startsWith("__");
|
|
269
|
+
const code = token.startsWith("`");
|
|
270
|
+
const mark = code ? "code" : double ? "bold" : token.startsWith("_") ? "italic" : token.startsWith("*") ? "italic" : "";
|
|
271
|
+
const delimiter = code ? 1 : double ? 2 : 1;
|
|
272
|
+
runs.push({ text: token.slice(delimiter, -delimiter), ...(mark ? { marks: [mark] } : {}) });
|
|
273
|
+
cursor = index + token.length;
|
|
274
|
+
}
|
|
275
|
+
if (cursor < value.length) runs.push({ text: value.slice(cursor) });
|
|
276
|
+
return runs;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function messageButtons(message: RecordValue, messageId: string): BusinessSuggestion[] {
|
|
280
|
+
const template = object(message.templateData);
|
|
281
|
+
const candidates: unknown[] = Array.isArray(template.buttons) ? template.buttons : [];
|
|
282
|
+
const content = object(message.content);
|
|
283
|
+
const cards = Array.isArray(object(content.template).buttons) ? object(content.template).buttons as unknown[] : [];
|
|
284
|
+
candidates.push(...cards);
|
|
285
|
+
for (const raw of Array.isArray(message.data) ? message.data : []) {
|
|
286
|
+
const data = object(object(raw).data);
|
|
287
|
+
for (const card of Array.isArray(data.cards) ? data.cards : []) candidates.push(...(Array.isArray(object(card).buttons) ? object(card).buttons as unknown[] : []));
|
|
288
|
+
}
|
|
289
|
+
return candidates.flatMap((value, index) => {
|
|
290
|
+
const button = object(value);
|
|
291
|
+
const label = [button.label, button.title, button.text].find((value): value is string => typeof value === "string" && !!value.trim())?.trim() ?? "";
|
|
292
|
+
const payload = object(button.payload);
|
|
293
|
+
const appId = typeof payload.appId === "string" ? payload.appId.trim() : "";
|
|
294
|
+
const interactionId = typeof payload.interactionId === "string" ? payload.interactionId.trim() : "";
|
|
295
|
+
if (button.cta && !appId && !interactionId) return [];
|
|
296
|
+
const enabled = !!label && !!appId && !!interactionId;
|
|
297
|
+
return label ? [{ id: `${messageId}:suggestion-${index}`, messageId, label, kind: "postback", ...(appId ? { appId } : {}), ...(interactionId ? { interactionId } : {}), enabled, disabled: !enabled }] : [];
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Article links and chat postbacks are different Wix button actions. Only expose
|
|
302
|
+
* payload-free HTTPS CTAs here; postbacks remain in messageButtons. */
|
|
303
|
+
function articleActions(message: RecordValue, messageId: string): BusinessArticleAction[] {
|
|
304
|
+
const buttons = object(message.templateData).buttons;
|
|
305
|
+
if (!Array.isArray(buttons)) return [];
|
|
306
|
+
return buttons.flatMap((value, index) => {
|
|
307
|
+
const button = object(value);
|
|
308
|
+
const label = typeof button.text === "string" ? button.text.trim() : "";
|
|
309
|
+
const rawUrl = typeof button.cta === "string" ? button.cta.trim() : "";
|
|
310
|
+
if (!label || !rawUrl || button.payload != null) return [];
|
|
311
|
+
// No `URL` global in a worker: accept only a plain https URL whose authority carries no credentials.
|
|
312
|
+
if (!isSafeLink(rawUrl)) return [];
|
|
313
|
+
return [{ id: `${messageId}:article-${index}`, label, url: rawUrl, style: "secondary" as const, ...webAction }];
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Text renders a limited inline Markdown subset, and Android and web ignore backslash escapes (they would
|
|
318
|
+
* print the backslash), so nothing is escaped that way. Link syntax is broken once, on the joined text so
|
|
319
|
+
* a `](` split across two runs is caught too, with an invisible character: message text can never become
|
|
320
|
+
* a tappable link. A lone marker stays literal on every renderer. */
|
|
321
|
+
function markdownFromRuns(runs: BusinessTextRun[]): string {
|
|
322
|
+
return runs.map((run) => {
|
|
323
|
+
let text = run.text;
|
|
324
|
+
const marks = run.marks ?? [];
|
|
325
|
+
if (marks.includes("code")) return `\`${text.replace(/`/g, "'")}\``;
|
|
326
|
+
if (marks.includes("bold")) text = `**${text}**`;
|
|
327
|
+
if (marks.includes("italic")) text = `*${text}*`;
|
|
328
|
+
return text;
|
|
329
|
+
}).join("").replace(/\]\(/g, "]\u200b(");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function hiddenInternalNotice(message: RecordValue): boolean {
|
|
333
|
+
const template = object(message.templateData);
|
|
334
|
+
const origin = typeof message.originMethod === "string" ? message.originMethod : "";
|
|
335
|
+
const summary = typeof message.summary === "string" ? message.summary.trim() : "";
|
|
336
|
+
const text = typeof template.text === "string" ? template.text.trim() : "";
|
|
337
|
+
const pauseNotice = "AI RESPONSES ARE PAUSED WHILE YOU TALK TO THIS VISITOR";
|
|
338
|
+
const isPauseNotice = summary.toUpperCase() === pauseNotice || text.toUpperCase() === pauseNotice;
|
|
339
|
+
const hasActionsOrMedia = !!template.image || !!template.icon || !!template.link ||
|
|
340
|
+
[template.buttons, template.fields, template.lines].some((value) => Array.isArray(value) && value.length > 0);
|
|
341
|
+
const content = object(message.content);
|
|
342
|
+
return message.messageType === "template/minimal" && !!message.appId && /:AI_SITE_CHAT$/i.test(origin) &&
|
|
343
|
+
isPauseNotice && (!Array.isArray(message.data) || message.data.length === 0) &&
|
|
344
|
+
Object.keys(content).length === 0 && !hasActionsOrMedia;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const SAFE_LINK = /^https:\/\/[^\s\\@/?#]+(?:[/?#]\S*)?$/;
|
|
348
|
+
/** Sentence punctuation after a link ("…/page." or "(see …/page)") is not part of it. */
|
|
349
|
+
function cleanLinkUrl(raw: string): string {
|
|
350
|
+
let url = raw;
|
|
351
|
+
for (;;) {
|
|
352
|
+
const last = url[url.length - 1];
|
|
353
|
+
if (last && (".,;:!?".includes(last) || (last === ")" && !url.includes("(")))) url = url.slice(0, -1); else break;
|
|
354
|
+
}
|
|
355
|
+
return url;
|
|
356
|
+
}
|
|
357
|
+
/** A plain https link with no control characters (a null byte would pass `\S`). */
|
|
358
|
+
const isSafeLink = (url: string): boolean => SAFE_LINK.test(url) && !/[\u0000-\u001f\u007f-\u009f]/.test(url);
|
|
359
|
+
|
|
360
|
+
/** Owner tools (a product, a coupon, a scheduling link) reach a member as plain text: a title, detail
|
|
361
|
+
* lines and `Label: https://…` lines. On a phone that is a wall of text with a URL nobody can tap, so a
|
|
362
|
+
* link line becomes a button and a coupon code its own block. Only plain https links are lifted. A message shown
|
|
363
|
+
* without a card (an AI answer) has no coupon block, so `liftCoupon: false` leaves its code in the text. */
|
|
364
|
+
export function extractBusinessMessageExtras(markdown: string, options: { liftCoupon?: boolean } = {}): { text: string; links: { label: string; url: string }[]; couponCode: string } {
|
|
365
|
+
const liftCoupon = options.liftCoupon ?? true;
|
|
366
|
+
const kept: string[] = [];
|
|
367
|
+
const links: { label: string; url: string }[] = [];
|
|
368
|
+
let couponCode = "";
|
|
369
|
+
// True only when a button was really added: a repeated link, or one past the sixth, stays in the text.
|
|
370
|
+
const addLink = (label: string, url: string): boolean => {
|
|
371
|
+
if (links.length >= 6 || links.some((link) => link.url === url)) return false;
|
|
372
|
+
links.push({ label: label.replace(/[*_`]/g, "").trim().slice(0, 40) || businessChatCopy('openLink'), url });
|
|
373
|
+
return true;
|
|
374
|
+
};
|
|
375
|
+
for (const line of markdown.split("\n")) {
|
|
376
|
+
const trimmed = line.trim();
|
|
377
|
+
const labelled = /^(?:[-*•]\s+)?([^:\n]{1,40}):\s*(https:\/\/\S+)$/.exec(trimmed);
|
|
378
|
+
const labelledUrl = labelled ? cleanLinkUrl(labelled[2]!) : "";
|
|
379
|
+
if (labelled && isSafeLink(labelledUrl) && addLink(labelled[1]!, labelledUrl)) continue;
|
|
380
|
+
const bare = /^(https:\/\/\S+)$/.exec(trimmed);
|
|
381
|
+
const bareUrl = bare ? cleanLinkUrl(bare[1]!) : "";
|
|
382
|
+
if (bare && isSafeLink(bareUrl)) {
|
|
383
|
+
// A bare link is named by the list item above it ("- Settle Into Sleep"), else by its site.
|
|
384
|
+
const item = /^[-*•]\s+(.{1,60})$/.exec((kept[kept.length - 1] ?? "").trim());
|
|
385
|
+
if (addLink(item?.[1] ?? `Open ${bareUrl.slice(8).split(/[/?#]/)[0]!.replace(/^www\./, "")}`, bareUrl)) {
|
|
386
|
+
if (item) kept.pop();
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const code = /^(.*?)[,;]?\s*\b[Cc]ode:\s*([A-Z0-9_-]{3,32})$/.exec(trimmed);
|
|
391
|
+
if (code && liftCoupon && !couponCode) {
|
|
392
|
+
couponCode = code[2]!;
|
|
393
|
+
if (code[1]!.trim()) kept.push(code[1]!.trim());
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
kept.push(line);
|
|
397
|
+
}
|
|
398
|
+
// Text with nothing lifted out is returned untouched, trailing newline included.
|
|
399
|
+
return { text: links.length || couponCode ? kept.join("\n").trim() : markdown, links, couponCode };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Adds only Business business-chat presentation fields to the canonical message rows. */
|
|
403
|
+
export function presentBusinessMessages<T extends Pick<BusinessMessage, "id" | "sender" | "mine" | "createdDate" | "plainText" | "parts">>(
|
|
404
|
+
rows: T[],
|
|
405
|
+
raw: unknown[],
|
|
406
|
+
participantId = "",
|
|
407
|
+
now = new Date(),
|
|
408
|
+
): (T & {
|
|
409
|
+
senderAvatar: string;
|
|
410
|
+
senderName: string;
|
|
411
|
+
senderInitials: string;
|
|
412
|
+
senderBadge: string;
|
|
413
|
+
imageUrl: string;
|
|
414
|
+
imageDescription: string;
|
|
415
|
+
noImage: boolean;
|
|
416
|
+
bodyText: string;
|
|
417
|
+
bodyRuns: BusinessTextRun[];
|
|
418
|
+
isAiGenerated: boolean;
|
|
419
|
+
suggestions: BusinessSuggestion[];
|
|
420
|
+
articleActions: BusinessArticleAction[];
|
|
421
|
+
bodyMarkdown: string;
|
|
422
|
+
dayLabel: string;
|
|
423
|
+
dayHidden: boolean;
|
|
424
|
+
startsDay: boolean;
|
|
425
|
+
scrollAnchor: string;
|
|
426
|
+
notMine: boolean;
|
|
427
|
+
couponCode: string;
|
|
428
|
+
noCoupon: boolean;
|
|
429
|
+
productSlug: string;
|
|
430
|
+
productName: string;
|
|
431
|
+
isCard: boolean;
|
|
432
|
+
notCard: boolean;
|
|
433
|
+
noSuggestions: boolean;
|
|
434
|
+
noActions: boolean;
|
|
435
|
+
cardTitle: string;
|
|
436
|
+
cardDetail: string;
|
|
437
|
+
/** A card with neither title nor detail (a file, a coupon with no line): its text block is hidden, not an empty gap. */
|
|
438
|
+
noCardText: boolean;
|
|
439
|
+
})[] {
|
|
440
|
+
const metadata = new Map<string, RecordValue>();
|
|
441
|
+
for (const value of raw) {
|
|
442
|
+
const message = object(value);
|
|
443
|
+
const id = typeof message.id === "string" ? message.id : typeof message._id === "string" ? message._id : "";
|
|
444
|
+
if (id) metadata.set(id, message);
|
|
445
|
+
}
|
|
446
|
+
const dateKey = (date: Date) => `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
|
|
447
|
+
const today = dateKey(now);
|
|
448
|
+
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
|
|
449
|
+
const yesterdayKey = dateKey(yesterday);
|
|
450
|
+
let previousDay = "";
|
|
451
|
+
|
|
452
|
+
return rows.filter((row) => !hiddenInternalNotice(metadata.get(row.id) ?? {})).map((row) => {
|
|
453
|
+
const message = metadata.get(row.id) ?? {};
|
|
454
|
+
const display = object(message.senderDisplayData);
|
|
455
|
+
const image = object(display.image);
|
|
456
|
+
const sender = object(message.sender);
|
|
457
|
+
const imagePart = row.parts.find((part) => part.kind === "image");
|
|
458
|
+
const timestamp = new Date(row.createdDate);
|
|
459
|
+
const key = Number.isFinite(timestamp.getTime()) ? dateKey(timestamp) : "";
|
|
460
|
+
const startsDay = !!key && key !== previousDay;
|
|
461
|
+
const dayLabel = startsDay
|
|
462
|
+
? key === today ? businessChatCopy('today') : key === yesterdayKey ? businessChatCopy('yesterday') : formatMonthDay(timestamp)
|
|
463
|
+
: "";
|
|
464
|
+
if (key) previousDay = key;
|
|
465
|
+
|
|
466
|
+
const template = object(message.templateData);
|
|
467
|
+
// A product the owner sends from the Inbox carries its picture on the card itself (`templateData.image`).
|
|
468
|
+
const imageUrl = imagePart?.url || cardImage(template.image);
|
|
469
|
+
const standardEventText = message.messageType === "template/standard-event"
|
|
470
|
+
? [template.title, ...(Array.isArray(template.lines) ? template.lines : [])]
|
|
471
|
+
.filter((value): value is string => typeof value === "string" && !!value.trim())
|
|
472
|
+
.join("\n")
|
|
473
|
+
: "";
|
|
474
|
+
const bodyText = imagePart?.url
|
|
475
|
+
? row.parts.filter((part) => part.kind === "text").map((part) => [part.text, ...(part.fields ?? []).map((field) => `${field.name}: ${field.value}`)].filter(Boolean).join("\n")).filter(Boolean).join("\n")
|
|
476
|
+
: standardEventText || row.plainText;
|
|
477
|
+
const senderAvatar = safeAvatar(image.url);
|
|
478
|
+
const mine = participantId && typeof sender.userId === "string" ? sender.userId === participantId : row.mine;
|
|
479
|
+
const isAiGenerated = message.aiSiteChatSender === "ASSISTANT" || (message.aiSiteChatSender !== "USER" && !!participantId && !mine && !!message.appId && typeof message.originMethod === "string" && /:AI_SITE_CHAT$/i.test(message.originMethod) &&
|
|
480
|
+
(message.messageType === "message" || message.messageType === "template/standard-event" || message.messageType === "template/card"));
|
|
481
|
+
const rawSenderName = typeof display.name === "string" && display.name.trim()
|
|
482
|
+
? display.name.trim()
|
|
483
|
+
: typeof display.displayName === "string" && display.displayName.trim() ? display.displayName.trim() : "";
|
|
484
|
+
const senderName = !mine && /^visitor(?:\s*#?\d+)?$/i.test(rawSenderName) ? "" : rawSenderName;
|
|
485
|
+
const plainTextRuns = standardEventText
|
|
486
|
+
? markdownRuns(standardEventText)
|
|
487
|
+
: row.parts.filter((part) => part.kind === "text" && typeof part.text === "string").flatMap((part, at) => [...(at > 0 ? [{ text: "\n" }] : []), ...markdownRuns(part.text as string)]);
|
|
488
|
+
const bodyRuns = htmlMessageRuns(message) ?? plainTextRuns;
|
|
489
|
+
const bodyMarkdown = markdownFromRuns(bodyRuns);
|
|
490
|
+
const buttons = messageButtons(message, row.id);
|
|
491
|
+
// An AI answer is never a card, and only a card shows the coupon block, so its code stays in its text.
|
|
492
|
+
const extras = mine ? { text: bodyMarkdown, links: [], couponCode: "" } : extractBusinessMessageExtras(bodyMarkdown, { liftCoupon: !isAiGenerated });
|
|
493
|
+
// A card is a title and detail lines: Wix sends events as one, and an owner's product, coupon or scheduling link
|
|
494
|
+
// arrives as plain text of the same shape. Its title leads in bold, and a coupon's title is its code, which
|
|
495
|
+
// the coupon block already shows.
|
|
496
|
+
const lines = extras.text.split("\n");
|
|
497
|
+
const first = lines[0]!.trim();
|
|
498
|
+
// A title is short, is not a sentence, and carries no markup of its own: bolding a whole paragraph would flip the
|
|
499
|
+
// meaning of the `**` already inside it.
|
|
500
|
+
const titleLike = lines.length > 1 && first.length <= 60 && !/[.!?:]$/.test(first) && !first.includes("*");
|
|
501
|
+
const cardLike = titleLike && (!!standardEventText || extras.links.length > 0 || !!extras.couponCode);
|
|
502
|
+
const titled = cardLike && extras.couponCode && first === extras.couponCode ? lines.slice(1).join("\n")
|
|
503
|
+
: cardLike && lines.length > 1 && !first.startsWith("**") ? `**${first}**\n${lines.slice(1).join("\n")}` : extras.text;
|
|
504
|
+
// A product card links to its store page; the slug lets the worker find the product and show its picture.
|
|
505
|
+
// The link is a line of the text or, from the Inbox's product tool, a template button.
|
|
506
|
+
// Only a card shows the picture, so an AI answer (never a card) does not look one up.
|
|
507
|
+
const productPage = cardLike && !isAiGenerated ? [...extras.links, ...articleActions(message, row.id)].map((link) => /^https:\/\/[^/?#]+(?:\/[^/?#]+)*?\/product-page\/([^/?#]+)/i.exec(link.url)?.[1]).find(Boolean) : undefined;
|
|
508
|
+
// A file, recording or video has no inline player here: it gets a button that opens it.
|
|
509
|
+
const attachmentLinks = row.parts.filter((part) => (part.kind === "file" || part.kind === "audio" || part.kind === "video") && !!part.url)
|
|
510
|
+
// A file, recording or video always opens as itself in the browser, wherever its URL lives.
|
|
511
|
+
.map((part, at) => ({ id: `${row.id}:attachment-${at}`, label: (part.fileName || part.caption || `Open ${part.kind}`).slice(0, 40), url: part.url as string, style: "secondary" as const, ...webAction, routable: false }));
|
|
512
|
+
// The card look (a framed box with a centred title) is for what an owner tool sends: a product, coupon, scheduling
|
|
513
|
+
// or pay link, an event, or a file. A coupon's title is its code, which the coupon block already shows.
|
|
514
|
+
// An AI answer is text with link pills, as on the site's Smart Chat, even when it is shaped like a card.
|
|
515
|
+
const isCard = !mine && !isAiGenerated && (cardLike || !!extras.couponCode || (attachmentLinks.length > 0 && !imageUrl));
|
|
516
|
+
const cardTitle = cardLike && !(extras.couponCode && first === extras.couponCode) ? first.replace(/^\*\*(.+)\*\*$/, "$1") : "";
|
|
517
|
+
// A product card shows its name and price, as on the site's Smart Chat; the "4 variants | In stock" summary the
|
|
518
|
+
// Inbox adds to the text is left out of it.
|
|
519
|
+
const cardLines = cardTitle ? lines.slice(1) : titled.split("\n");
|
|
520
|
+
const cardDetail = (productPage ? cardLines.filter((line) => !/^\s*\d+\s+variants?\b|\|\s*(?:in|out of)\s+stock\s*$/i.test(line)) : cardLines).join("\n");
|
|
521
|
+
const articles = [...articleActions(message, row.id), ...attachmentLinks, ...extras.links.map((link, at) => ({ id: `${row.id}:link-${at}`, label: link.label, url: link.url, style: /\b(view|buy|shop|book|pay|order|checkout|reserve|schedule)\b/i.test(link.label) ? "primary" as const : "secondary" as const, ...webAction }))]
|
|
522
|
+
.filter((action, at, all) => all.findIndex((other) => other.url === action.url) === at);
|
|
523
|
+
|
|
524
|
+
return {
|
|
525
|
+
...row,
|
|
526
|
+
mine,
|
|
527
|
+
side: mine ? "mine" : "other",
|
|
528
|
+
senderAvatar,
|
|
529
|
+
senderName,
|
|
530
|
+
senderInitials: typeof display.initials === "string" && display.initials.trim() ? display.initials.trim() : initials(senderName),
|
|
531
|
+
senderBadge: participantId && !mine ? isAiGenerated ? businessChatCopy('aiAssistant') : businessChatCopy('staffResponse') : "",
|
|
532
|
+
imageUrl,
|
|
533
|
+
// A card's own picture is named by the card (a product's name), not as an anonymous attachment.
|
|
534
|
+
imageDescription: imagePart?.caption || (imageUrl && !imagePart?.url && first ? first.replace(/\*/g, "") : businessChatCopy('imageAttachment')),
|
|
535
|
+
noImage: !imageUrl,
|
|
536
|
+
bodyText,
|
|
537
|
+
bodyRuns,
|
|
538
|
+
bodyMarkdown: titled,
|
|
539
|
+
couponCode: extras.couponCode,
|
|
540
|
+
noCoupon: !extras.couponCode,
|
|
541
|
+
productSlug: productPage ?? "",
|
|
542
|
+
productName: productPage ? first : "",
|
|
543
|
+
isCard,
|
|
544
|
+
notCard: !isCard,
|
|
545
|
+
noSuggestions: buttons.length === 0,
|
|
546
|
+
noActions: articles.length === 0,
|
|
547
|
+
cardTitle,
|
|
548
|
+
noCardText: !cardTitle && !cardDetail.trim(),
|
|
549
|
+
cardDetail,
|
|
550
|
+
isAiGenerated,
|
|
551
|
+
suggestions: buttons,
|
|
552
|
+
articleActions: articles,
|
|
553
|
+
dayLabel,
|
|
554
|
+
dayHidden: !dayLabel,
|
|
555
|
+
startsDay,
|
|
556
|
+
scrollAnchor: `himi_business_chat.message_${row.id}`,
|
|
557
|
+
notMine: !mine,
|
|
558
|
+
};
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Resolve a suggestion only from the currently normalized rows and only when the server supplied both postback fields. */
|
|
563
|
+
export function findBusinessSuggestion<T extends { suggestions?: BusinessSuggestion[] }>(rows: T[], suggestionId: string): BusinessSuggestion | undefined {
|
|
564
|
+
if (!suggestionId) return undefined;
|
|
565
|
+
return rows.flatMap((row) => row.suggestions ?? []).find((suggestion) => suggestion.id === suggestionId && suggestion.enabled &&
|
|
566
|
+
(suggestion.kind === "visitor_option" || (!!suggestion.appId && !!suggestion.interactionId)));
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Execute a row's verified semantic button only inside its still-current member room/session. */
|
|
570
|
+
export async function dispatchBusinessSuggestion<T extends { suggestions?: BusinessSuggestion[] }>(
|
|
571
|
+
rows: T[],
|
|
572
|
+
suggestionId: string,
|
|
573
|
+
session: { member: boolean; roomId: string; principal: string; currentPrincipal: string; scope: string; currentScope: string },
|
|
574
|
+
submit: (suggestion: BusinessSuggestion) => Promise<unknown>,
|
|
575
|
+
): Promise<boolean> {
|
|
576
|
+
if (!session.member || !session.principal || session.principal !== session.currentPrincipal || session.scope !== session.currentScope) return false;
|
|
577
|
+
const suggestion = findBusinessSuggestion(rows, suggestionId);
|
|
578
|
+
if (!suggestion || (suggestion.kind === "postback" && !session.roomId)) return false;
|
|
579
|
+
// A submit that reports `false` (a stale quick reply nobody posted) is not a send.
|
|
580
|
+
return (await submit(suggestion)) !== false;
|
|
581
|
+
}
|