@wix/himalaya-cli 0.1495.0 → 0.1497.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/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/attendance-classifier.d.ts +37 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/catalog-pricing-contract.d.ts +1 -1
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/index.d.ts +2 -1
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/wix-payments-contract.d.ts +11 -3
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/apple-iap-contract.d.ts +27 -3
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/commerce-contract.d.ts +1 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/iap-verticals/attendance.d.ts +10 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/iap-verticals/index.d.ts +1 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/index.d.ts +1 -1
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/events/src/index.d.ts +33 -2
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap-checkout.mjs +346 -26
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap.mjs +132 -3
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/screens.mjs +366 -48
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-events.mjs +5 -0
- package/dist/cli.mjs +816 -667
- package/guides/iap-products.md +7 -1
- package/guides/sdk-reference.json +376 -27
- package/package.json +2 -2
- package/templates/beauty-salon/dev/index.ts +394 -0
- package/templates/beauty-salon/tier5-src/_shared/iap-attendance-form.ts +106 -0
- package/templates/beauty-salon/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
- package/templates/beauty-salon/tier5-src/_shared/iap-attendance.ts +38 -0
- package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +52 -7
- package/templates/branded-lite/dev/index.ts +1073 -284
- package/templates/branded-lite/tier5-src/_shared/business-chat-model.ts +35 -4
- package/templates/branded-lite/tier5-src/_shared/business-chat-transport.ts +12 -5
- package/templates/branded-lite/tier5-src/_shared/iap-attendance-form.ts +106 -0
- package/templates/branded-lite/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
- package/templates/branded-lite/tier5-src/_shared/iap-attendance.ts +38 -0
- package/templates/branded-lite/tier5-src/business-chat/index.ts +50 -17
- package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +52 -7
- package/templates/branded-lite/tier5-src/himi-events-detail/index.ts +112 -21
- package/templates/community-club/dev/index.ts +397 -2
- package/templates/community-club/tier5-src/_shared/iap-attendance-form.ts +106 -0
- package/templates/community-club/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
- package/templates/community-club/tier5-src/_shared/iap-attendance.ts +38 -0
- package/templates/community-club/tier5-src/himi-events-detail/index.ts +112 -21
- package/templates/events-meetups/dev/index.ts +397 -2
- package/templates/events-meetups/tier5-src/_shared/iap-attendance-form.ts +106 -0
- package/templates/events-meetups/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
- package/templates/events-meetups/tier5-src/_shared/iap-attendance.ts +38 -0
- package/templates/events-meetups/tier5-src/himi-events-detail/index.ts +112 -21
- package/templates/gym-fitness/dev/index.ts +828 -39
- package/templates/gym-fitness/tier5-src/_shared/iap-attendance-form.ts +106 -0
- package/templates/gym-fitness/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
- package/templates/gym-fitness/tier5-src/_shared/iap-attendance.ts +38 -0
- package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +52 -7
- package/templates/gym-fitness/tier5-src/himi-events-detail/index.ts +112 -21
- package/templates/nonprofit-cause/dev/index.ts +397 -2
- package/templates/nonprofit-cause/tier5-src/_shared/iap-attendance-form.ts +106 -0
- package/templates/nonprofit-cause/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
- package/templates/nonprofit-cause/tier5-src/_shared/iap-attendance.ts +38 -0
- package/templates/nonprofit-cause/tier5-src/himi-events-detail/index.ts +112 -21
- package/templates/podcast-creator/dev/index.ts +397 -2
- package/templates/podcast-creator/tier5-src/_shared/iap-attendance-form.ts +106 -0
- package/templates/podcast-creator/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
- package/templates/podcast-creator/tier5-src/_shared/iap-attendance.ts +38 -0
- package/templates/podcast-creator/tier5-src/himi-events-detail/index.ts +112 -21
- package/templates/portfolio-creative/dev/index.ts +397 -2
- package/templates/portfolio-creative/tier5-src/_shared/iap-attendance-form.ts +106 -0
- package/templates/portfolio-creative/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
- package/templates/portfolio-creative/tier5-src/_shared/iap-attendance.ts +38 -0
- package/templates/portfolio-creative/tier5-src/himi-events-detail/index.ts +112 -21
- package/templates/restaurant/dev/index.ts +397 -2
- package/templates/restaurant/tier5-src/_shared/iap-attendance-form.ts +106 -0
- package/templates/restaurant/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
- package/templates/restaurant/tier5-src/_shared/iap-attendance.ts +38 -0
- package/templates/restaurant/tier5-src/himi-events-detail/index.ts +112 -21
- package/templates/yoga-studio/dev/index.ts +828 -39
- package/templates/yoga-studio/tier5-src/_shared/iap-attendance-form.ts +106 -0
- package/templates/yoga-studio/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
- package/templates/yoga-studio/tier5-src/_shared/iap-attendance.ts +38 -0
- package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +52 -7
- package/templates/yoga-studio/tier5-src/himi-events-detail/index.ts +112 -21
|
@@ -2,8 +2,11 @@ import { formatClock } from './engine-intl.js';
|
|
|
2
2
|
import { mergeMessages, normalizeMessages, type MessageRow } from './business-chat-message-model.js';
|
|
3
3
|
|
|
4
4
|
/** `delivery` is what the composer's own row shows while the server catches up: sending, accepted (shown with its
|
|
5
|
-
* time) or unconfirmed (the request failed without a definite answer).
|
|
6
|
-
|
|
5
|
+
* time) or unconfirmed (the request failed without a definite answer). The server names the stored message, so the
|
|
6
|
+
* new one is recognised by its text, among the messages after `afterId` (the newest stored message on screen when it
|
|
7
|
+
* was sent), or among all of them when the room was loaded and empty (`fromStart`); `priorCount` is how many earlier
|
|
8
|
+
* sends with the same text were still waiting for their stored copy. */
|
|
9
|
+
export interface BusinessChatIntent { id:string; text:string; roomId:string; principal:string; createdDate:string; delivery:'sending'|'sent'|'unconfirmed'; senderName?:string; afterId?:string; fromStart?:boolean; priorCount?:number }
|
|
7
10
|
|
|
8
11
|
export function newBusinessChatIntent(text:string,roomId:string,principal:string,random=Math.random):BusinessChatIntent{
|
|
9
12
|
return {id:'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g,c=>{const r=Math.floor(random()*16);return(c==='x'?r:(r&3)|8).toString(16);}),text,roomId,principal,createdDate:new Date().toISOString(),delivery:'sending'};
|
|
@@ -12,8 +15,36 @@ export function businessChatSendRejected(error:unknown):boolean{
|
|
|
12
15
|
return /\b(400|401|403|404|409|413|422|429)\b|approved first-party|webwix_owner_only|sign_in_required|owner_preview_read_only/i.test(String((error as Error)?.message||error));
|
|
13
16
|
}
|
|
14
17
|
export function draftAfterAcceptedSend(currentDraft:string,sentDraft:string):string{return currentDraft===sentDraft?'':currentDraft;}
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
type Row={id:string;mine?:boolean;bodyText?:string;pending?:boolean};
|
|
19
|
+
const sameOwnText=(message:Row,text:string)=>!!message.mine&&(message.bodyText??'').trim()===text.trim();
|
|
20
|
+
/** What a send records about the screen it was sent from: the newest stored message (history is one page in time
|
|
21
|
+
* order, so counting only after it ignores old messages, even one that drops off the page), and how many sends with
|
|
22
|
+
* the same words are still waiting for their stored copies. No clock is involved: the phone's and the server's differ. */
|
|
23
|
+
export function sendAnchor(messages:Row[],text:string,roomLoaded:boolean):{afterId?:string;fromStart?:boolean;priorCount:number}{
|
|
24
|
+
const stored=messages.filter(message=>!message.pending);
|
|
25
|
+
const priorCount=messages.filter(message=>message.pending&&sameOwnText(message,text)).length;
|
|
26
|
+
// A room that loaded with nothing in it: every stored message that appears later is newer than this send's screen.
|
|
27
|
+
// One that failed to load has no anchor at all, as it may hold older messages with the same words.
|
|
28
|
+
if(!stored.length)return roomLoaded?{fromStart:true,priorCount}:{priorCount};
|
|
29
|
+
return {afterId:stored[stored.length-1]!.id,priorCount};
|
|
30
|
+
}
|
|
31
|
+
/** Whether history already holds the member's sent message: by its own id, or as one more message of theirs with the
|
|
32
|
+
* same text than there were when it was sent (the Smart Chat send does not let the app choose the stored id). This
|
|
33
|
+
* only places the message on screen: the send itself is settled by its own request, never by what history shows, so
|
|
34
|
+
* a coincidence (the same words sent from the website meanwhile) cannot hide a failure. */
|
|
35
|
+
export function pendingIntentWasObserved(messages:Row[],intent:BusinessChatIntent|null):boolean{
|
|
36
|
+
if(!intent)return false;
|
|
37
|
+
const stored=messages.filter(message=>!message.pending);
|
|
38
|
+
if(stored.some(message=>message.id===intent.id))return true;
|
|
39
|
+
let newer:Row[];
|
|
40
|
+
if(intent.fromStart)newer=stored;
|
|
41
|
+
else{
|
|
42
|
+
// With no anchor (the room had not loaded) or the anchor gone, an older message with these words cannot be told apart.
|
|
43
|
+
const after=intent.afterId?stored.findIndex(message=>message.id===intent.afterId):-1;
|
|
44
|
+
if(after<0)return false;
|
|
45
|
+
newer=stored.slice(after+1);
|
|
46
|
+
}
|
|
47
|
+
return newer.filter(message=>sameOwnText(message,intent.text)).length>(intent.priorCount??0);
|
|
17
48
|
}
|
|
18
49
|
export function messageTimeLabel(value:string):string{
|
|
19
50
|
const date=new Date(value);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// The business chat's Wix calls: the member-role chat-web SDK for rooms
|
|
2
|
-
//
|
|
3
|
-
import { getChatToken, getMessagesPage, listChatroomsPage
|
|
1
|
+
// The business chat's Wix calls: the member-role chat-web SDK for rooms and history, plus the calls the SDKs have
|
|
2
|
+
// no helper for (a typed message sent through Smart Chat, a tapped button, a private attachment's download URL).
|
|
3
|
+
import { getChatToken, getMessagesPage, listChatroomsPage } from "@himalaya/wix-chat-web";
|
|
4
4
|
import { assertMemberAccess, memberAccess } from "./member-access.js";
|
|
5
5
|
|
|
6
6
|
const MANAGE = "https://manage.wix.com";
|
|
@@ -70,14 +70,21 @@ export const listRooms = (identity: ChatIdentity): Promise<{ items: Raw[]; more:
|
|
|
70
70
|
});
|
|
71
71
|
export const messagesPage = (identity: ChatIdentity, roomId: string): Promise<Raw[]> => fenced(identity, async () =>
|
|
72
72
|
(await withToken(identity, (chatToken) => getMessagesPage(roomId, { ...MEMBER, chatToken, pageSize: 50, ordering: "Desc" }))).items as unknown as Raw[]);
|
|
73
|
-
export const sendMessage = (identity: ChatIdentity, roomId: string, intent: { id: string; text: string }): Promise<void> =>
|
|
74
|
-
fenced(identity, () => withToken(identity, (chatToken) => sendChatMessage(roomId, intent.text, { ...MEMBER, chatToken, messageId: intent.id })));
|
|
75
73
|
|
|
76
74
|
function bridge(): Bridge {
|
|
77
75
|
const api = (globalThis as unknown as { himalaya?: { wixApi?: Bridge } }).himalaya?.wixApi;
|
|
78
76
|
if (!api) throw new Error("wix_api_bridge_unavailable");
|
|
79
77
|
return api;
|
|
80
78
|
}
|
|
79
|
+
/** A typed message goes through Smart Chat, exactly as the site's chat widget sends it. The Inbox then files it under
|
|
80
|
+
* Wix Smart Chat and the AI can answer it; a message posted straight into the room has no channel, so the AI never
|
|
81
|
+
* saw it and the owner's reply went out by email. It lands in the same room, under an id the server chooses.
|
|
82
|
+
* The bridge resolves once the AI has finished answering, not when the message is stored. */
|
|
83
|
+
export const sendMessage = (identity: ChatIdentity, text: string): Promise<void> => fenced(identity, async () => {
|
|
84
|
+
await bridge().call("POST", "/wix-assistant-widget/v1/messages/stream", {
|
|
85
|
+
message: { sender: "SITE_VISITOR", messageType: "QUESTION", body: { mainText: text } },
|
|
86
|
+
});
|
|
87
|
+
});
|
|
81
88
|
/** A tapped button is a silent, ephemeral message carrying the button's chat action. */
|
|
82
89
|
export const sendButton = (identity: ChatIdentity, roomId: string, input: { id: string; text: string; appId: string; interactionId: string }): Promise<void> => fenced(identity, async () => {
|
|
83
90
|
await withToken(identity, (chatToken) => bridge().call("POST", `${MANAGE}/_api/chat-web/v2/chatrooms/${encodeURIComponent(roomId)}/messages`, {
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { copyParam } from "./himi-copy.js";
|
|
2
|
+
|
|
3
|
+
export interface AttendanceField {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
kind: string;
|
|
7
|
+
required: boolean;
|
|
8
|
+
value: string;
|
|
9
|
+
selectedIds: string[];
|
|
10
|
+
options: { id: string; label: string }[];
|
|
11
|
+
error: string;
|
|
12
|
+
maxLength?: number;
|
|
13
|
+
maxSize?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const object = (v: unknown): Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Record<string, unknown> : {};
|
|
17
|
+
const text = (v: unknown): string => typeof v === "string" ? v : "";
|
|
18
|
+
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
19
|
+
|
|
20
|
+
export function createAttendanceForm(prefix: string) {
|
|
21
|
+
let fields: AttendanceField[] = [];
|
|
22
|
+
let supported = false;
|
|
23
|
+
const label = (name: string) => copyParam(`${prefix}.${name}Label`, name === "firstName" ? "First name" : name === "lastName" ? "Last name" : "Email");
|
|
24
|
+
const field = (scope: string, name: string, input: Record<string, unknown> = {}): AttendanceField => ({
|
|
25
|
+
id: `${scope}:${name}`, label: text(input.label) || label(name), required: input.mandatory === true,
|
|
26
|
+
kind: name === "email" ? "email" : input.type === "NUMBER" ? "number" : input.type === "TEXT_ARRAY" ? "multiple" : Array.isArray(input.options) && input.options.length ? "choice" : "text",
|
|
27
|
+
value: "", selectedIds: [], error: "",
|
|
28
|
+
options: Array.isArray(input.options) ? input.options.filter((o): o is string => typeof o === "string").map((o) => ({ id: o, label: o })) : [],
|
|
29
|
+
...(typeof input.maxLength === "number" ? { maxLength: input.maxLength } : {}),
|
|
30
|
+
...(typeof input.maxSize === "number" ? { maxSize: input.maxSize } : {}),
|
|
31
|
+
});
|
|
32
|
+
const contact = (scope: string) => ["firstName", "lastName", "email"].map((name) => field(scope, name, { mandatory: true }));
|
|
33
|
+
|
|
34
|
+
function event(raw: unknown): void {
|
|
35
|
+
fields = contact("buyer");
|
|
36
|
+
const controls = object(raw).controls;
|
|
37
|
+
supported = Array.isArray(controls) && controls.length > 0;
|
|
38
|
+
const names = new Set<string>();
|
|
39
|
+
for (const control of Array.isArray(controls) ? controls : []) {
|
|
40
|
+
const c = object(control);
|
|
41
|
+
if (c.deleted === true) continue;
|
|
42
|
+
if (!Array.isArray(c.inputs) || !c.inputs.length) { supported = false; continue; }
|
|
43
|
+
for (const rawInput of c.inputs) {
|
|
44
|
+
const input = object(rawInput);
|
|
45
|
+
const name = text(input.name);
|
|
46
|
+
if (!name || names.has(name) || !["TEXT", "NUMBER", "TEXT_ARRAY"].includes(text(input.type)) || (input.type === "TEXT_ARRAY" && (!Array.isArray(input.options) || !input.options.length))) { supported = false; continue; }
|
|
47
|
+
names.add(name);
|
|
48
|
+
fields.push(field("guest", name, { ...input, mandatory: input.mandatory === true || ["firstName", "lastName", "email"].includes(name) }));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (!["firstName", "lastName", "email"].every((name) => names.has(name))) supported = false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function booking(service: unknown): void {
|
|
55
|
+
fields = contact("buyer");
|
|
56
|
+
const id = text(object(object(service).form).id);
|
|
57
|
+
// Custom intake schemas need their own reader; never silently omit their required fields.
|
|
58
|
+
supported = id === "00000000-0000-0000-0000-000000000000";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function change(arg: unknown): void {
|
|
62
|
+
const a = object(arg);
|
|
63
|
+
const f = fields.find((candidate) => candidate.id === a.id);
|
|
64
|
+
if (!f) return;
|
|
65
|
+
if (typeof a.option === "string") {
|
|
66
|
+
if (f.kind === "multiple") f.selectedIds = f.selectedIds.includes(a.option) ? f.selectedIds.filter((v) => v !== a.option) : [...f.selectedIds, a.option];
|
|
67
|
+
else f.value = a.option;
|
|
68
|
+
f.error = "";
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
f.value = text(a.value ?? a.text ?? a.selectedId);
|
|
72
|
+
f.selectedIds = Array.isArray(a.selectedIds) ? a.selectedIds.filter((v): v is string => typeof v === "string") : [];
|
|
73
|
+
f.error = "";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function valid(f: AttendanceField): boolean {
|
|
77
|
+
const value = f.value.trim();
|
|
78
|
+
const values = f.kind === "multiple" ? f.selectedIds : value ? [value] : [];
|
|
79
|
+
if (!values.length) return !f.required;
|
|
80
|
+
if (f.options.length && values.some((v) => !f.options.some((o) => o.id === v))) return false;
|
|
81
|
+
if (f.maxSize !== undefined && values.length > f.maxSize) return false;
|
|
82
|
+
if (f.maxLength !== undefined && values.some((v) => v.length > f.maxLength!)) return false;
|
|
83
|
+
return f.kind === "email" ? EMAIL.test(value) : f.kind !== "number" || /^-?\d+(?:\.\d+)?$/.test(value);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function validate(): boolean {
|
|
87
|
+
for (const f of fields) f.error = valid(f) ? "" : copyParam(`${prefix}.formInvalidText`, "Check this field.");
|
|
88
|
+
return supported && fields.every((f) => !f.error);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function buyer(): Record<string, string> {
|
|
92
|
+
return Object.fromEntries(fields.filter((f) => f.id.startsWith("buyer:")).map((f) => [f.id.slice(6), f.value.trim()]));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function ticket(definitionId: string) {
|
|
96
|
+
const inputValues = fields.filter((f) => f.id.startsWith("guest:") && (f.value.trim() || f.selectedIds.length)).map((f) => ({ inputName: f.id.slice(6), ...(f.kind === "multiple" ? { values: f.selectedIds.slice() } : { value: f.value.trim() }) }));
|
|
97
|
+
return { buyer: buyer(), guests: [{ ticketDefinitionId: definitionId, form: { inputValues } }] };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
event, booking, change, validate, buyer, ticket,
|
|
102
|
+
supported: () => supported,
|
|
103
|
+
rows: () => fields.map((f) => ({ ...f, selectedIds: f.selectedIds.slice(), kind: f.kind === "text" && f.required ? "requiredText" : f.kind })),
|
|
104
|
+
clear: () => { for (const f of fields) { f.value = ""; f.selectedIds = []; f.error = ""; } },
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { attendanceCheckoutRoute, availableTicketAttendanceOf, bookingAttendanceOf, classifyForApple, hostSessionFromGlobal, iapPriceFields, type BookingAttendance, type CatalogReference, type IapPollDirective, type TicketAttendance } from "@himalaya/iap";
|
|
2
|
+
import { bookingOrders, createIapCommerce, eventTicketOrders, type IapOrderItem } from "@himalaya/iap-checkout";
|
|
3
|
+
import { copyParam, copyTemplate } from "./himi-copy.js";
|
|
4
|
+
import { createAttendanceForm, type AttendanceField } from "./iap-attendance-form.js";
|
|
5
|
+
import { attendancePlatform } from "./iap-attendance.js";
|
|
6
|
+
import { iapPriceCopy, iapPurchaseCopy } from "./iap-worker.js";
|
|
7
|
+
|
|
8
|
+
export interface AttendancePurchaseFields {
|
|
9
|
+
iapBuyHidden: boolean;
|
|
10
|
+
iapBuyDisabled: boolean;
|
|
11
|
+
iapBuyLabel: string;
|
|
12
|
+
iapBusy: boolean;
|
|
13
|
+
iapPrice: string;
|
|
14
|
+
iapNote: string;
|
|
15
|
+
iapRetryHidden: boolean;
|
|
16
|
+
iapFormHidden: boolean;
|
|
17
|
+
iapFormError: string;
|
|
18
|
+
iapSignInHidden: boolean;
|
|
19
|
+
iapFields: AttendanceField[];
|
|
20
|
+
_poll?: IapPollDirective;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const ATTENDANCE_INITIAL: AttendancePurchaseFields = {
|
|
24
|
+
iapBuyHidden: true, iapBuyDisabled: true, iapBuyLabel: "", iapBusy: false,
|
|
25
|
+
iapPrice: "", iapNote: "", iapRetryHidden: true, iapFormHidden: true, iapFormError: "", iapSignInHidden: true, iapFields: [],
|
|
26
|
+
};
|
|
27
|
+
export const ATTENDANCE_NOT_HYDRATED = [...Object.keys(ATTENDANCE_INITIAL), "_poll"] as (keyof AttendancePurchaseFields & string)[];
|
|
28
|
+
|
|
29
|
+
export function createAttendancePurchase(options: { kind: "bookingsService" | "eventsTicket"; prefix: string; pollAction: string; onChange(): void }) {
|
|
30
|
+
const { prefix } = options;
|
|
31
|
+
let form = createAttendanceForm(prefix);
|
|
32
|
+
const submittedForms = new Map<string, ReturnType<typeof createAttendanceForm>>();
|
|
33
|
+
const session = hostSessionFromGlobal();
|
|
34
|
+
const commerce = createIapCommerce({ adapters: [options.kind === "bookingsService" ? bookingOrders : eventTicketOrders], onChange: options.onChange });
|
|
35
|
+
let generation = session.current().generation;
|
|
36
|
+
let reference: CatalogReference | undefined;
|
|
37
|
+
let metadata: BookingAttendance | TicketAttendance | undefined;
|
|
38
|
+
let platform = "unknown";
|
|
39
|
+
let started = false;
|
|
40
|
+
let inFlight = false;
|
|
41
|
+
let formError = "";
|
|
42
|
+
let sequence = 0;
|
|
43
|
+
let formRevision = 0;
|
|
44
|
+
const attempts = new Map<string, string>();
|
|
45
|
+
session.subscribe(() => { syncSession(); if (reference) options.onChange(); });
|
|
46
|
+
|
|
47
|
+
function syncSession(): void {
|
|
48
|
+
const next = session.current().generation;
|
|
49
|
+
if (next === generation) return;
|
|
50
|
+
generation = next;
|
|
51
|
+
form.clear();
|
|
52
|
+
submittedForms.clear();
|
|
53
|
+
formError = "";
|
|
54
|
+
attempts.clear();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function refresh(): Promise<void> {
|
|
58
|
+
const next = await attendancePlatform(publishPlatform);
|
|
59
|
+
if (next !== "unknown" || platform === "unknown") platform = next;
|
|
60
|
+
await resume();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function resume(): Promise<void> {
|
|
64
|
+
if (reference) await commerce.loadPrices([{ reference }]);
|
|
65
|
+
const first = !started;
|
|
66
|
+
started = true;
|
|
67
|
+
await (first ? commerce.start() : commerce.reconcile());
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function publishPlatform(next: string): void {
|
|
71
|
+
if (next === "unknown" && platform !== "unknown") return;
|
|
72
|
+
platform = next;
|
|
73
|
+
void resume().then(options.onChange, options.onChange);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function show(): void {
|
|
77
|
+
void attendancePlatform(publishPlatform).then(publishPlatform, options.onChange);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function booking(id: string, service: unknown): Promise<void> {
|
|
81
|
+
syncSession();
|
|
82
|
+
reference = { kind: options.kind, id };
|
|
83
|
+
metadata = bookingAttendanceOf(service);
|
|
84
|
+
selectForm(id, (next) => next.booking(service));
|
|
85
|
+
formRevision++;
|
|
86
|
+
formError = "";
|
|
87
|
+
await show();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function ticket(id: string, event: unknown, available: unknown): Promise<void> {
|
|
91
|
+
syncSession();
|
|
92
|
+
reference = id ? { kind: options.kind, id } : undefined;
|
|
93
|
+
metadata = availableTicketAttendanceOf(event, available);
|
|
94
|
+
selectForm(id, (next) => next.event((event as { form?: unknown } | undefined)?.form));
|
|
95
|
+
formRevision++;
|
|
96
|
+
formError = "";
|
|
97
|
+
await show();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function selectForm(id: string, initialize: (next: ReturnType<typeof createAttendanceForm>) => void): void {
|
|
101
|
+
const key = `${options.kind}:${id}`;
|
|
102
|
+
const retained = submittedForms.get(key);
|
|
103
|
+
form = retained ?? createAttendanceForm(prefix);
|
|
104
|
+
if (!retained) initialize(form);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function fields(selected = true, publishPoll = true): AttendancePurchaseFields {
|
|
108
|
+
syncSession();
|
|
109
|
+
if (!reference || !metadata) return ATTENDANCE_INITIAL;
|
|
110
|
+
const currency = metadata.currency ?? "";
|
|
111
|
+
const classified = classifyForApple(metadata, currency);
|
|
112
|
+
const candidate = platform === "ios" && classified === "appleIap";
|
|
113
|
+
const price = iapPriceFields(commerce.pricing.view(reference), iapPriceCopy(prefix));
|
|
114
|
+
const copy = { ...iapPurchaseCopy(prefix), buy: copyParam(`${prefix}.appleBuyCta`, options.kind === "bookingsService" ? "Book with Apple" : "Buy ticket with Apple"), buyAnother: copyParam(`${prefix}.buyAnotherCta`, options.kind === "bookingsService" ? "Book again" : "Buy another ticket") };
|
|
115
|
+
const purchase = commerce.purchaseFields(reference, copy);
|
|
116
|
+
const apple = candidate || (platform === "ios" && classified !== "wixPayments" && purchase.appleRoute);
|
|
117
|
+
const enabled = attendanceCheckoutRoute(metadata, currency, platform) === "appleIap";
|
|
118
|
+
const poll = publishPoll ? commerce.pollDirective(options.pollAction) : undefined;
|
|
119
|
+
const state = commerce.state();
|
|
120
|
+
for (const [target, attempt] of attempts) {
|
|
121
|
+
const key = `${reference.kind}:${reference.id}#${encodeURIComponent(attempt)}`;
|
|
122
|
+
if ((state.phase === "accessReady" && state.item === key) || state.payments.some((p) => p.item === key && p.access === "granted")) attempts.delete(target);
|
|
123
|
+
}
|
|
124
|
+
const formKey = `${reference.kind}:${reference.id}`;
|
|
125
|
+
if (![...attempts.keys()].some((target) => target.startsWith(`${formKey}:`))) submittedForms.delete(formKey);
|
|
126
|
+
const own = state.item?.startsWith(`${reference.kind}:${reference.id}#`);
|
|
127
|
+
const completed = state.phase === "accessReady" && own;
|
|
128
|
+
const member = session.current().kind === "member";
|
|
129
|
+
let note = "";
|
|
130
|
+
if (apple && !enabled) note = copy.unavailable;
|
|
131
|
+
else if (apple && !member) note = copy.signInRequired;
|
|
132
|
+
else if (apple && purchase.buyNote) note = purchase.buyNote;
|
|
133
|
+
else if (apple && !form.supported()) note = copyParam(`${prefix}.formUnavailableText`, "This registration form is not supported yet.");
|
|
134
|
+
else if (apple && completed) note = copyParam(`${prefix}.purchaseCompleteText`, options.kind === "bookingsService" ? "Your booking is confirmed." : "Your ticket is confirmed.");
|
|
135
|
+
else if (apple && own && state.phase === "cancelled") note = copyParam(`${prefix}.purchaseCancelledText`, "Purchase cancelled. Your place is not confirmed.");
|
|
136
|
+
return {
|
|
137
|
+
iapBuyHidden: !apple, iapBuyDisabled: !enabled || !selected || !member || !form.supported() || purchase.buyDisabled || inFlight,
|
|
138
|
+
iapBuyLabel: purchase.buyLabel || copy.buy, iapBusy: purchase.buyBusy || inFlight,
|
|
139
|
+
iapPrice: apple ? price.priceText : "", iapRetryHidden: !apple || (price.hidePriceRetry && commerce.scopeComplete()),
|
|
140
|
+
iapNote: note,
|
|
141
|
+
iapFormHidden: !apple || !selected || !form.supported(), iapFormError: formError,
|
|
142
|
+
iapSignInHidden: !apple || member || options.kind === "bookingsService",
|
|
143
|
+
iapFields: form.rows().map((f, index, rows) => ({ ...f,
|
|
144
|
+
id: `${generation}:${formRevision}:${f.id}`, fieldId: f.id,
|
|
145
|
+
errorHidden: !f.error,
|
|
146
|
+
options: f.options.map((o) => ({ ...o, fieldId: f.id, displayLabel: (f.kind === "multiple" ? f.selectedIds.includes(o.id) : f.value === o.id) ? `${o.label} ✓` : o.label })),
|
|
147
|
+
label: f.required ? `${f.label} *` : f.label,
|
|
148
|
+
inputLabel: copyTemplate(`${prefix}.fieldAccessibilityLabel`, "{section}: {field}", { section: f.id.startsWith("buyer:") ? copyParam(`${prefix}.buyerDetailsLabel`, "Buyer details") : copyParam(`${prefix}.guestDetailsLabel`, "Ticket holder details"), field: f.label }),
|
|
149
|
+
sectionLabel: index === 0 ? copyParam(`${prefix}.buyerDetailsLabel`, "Buyer details") : f.id.startsWith("guest:") && rows[index - 1].id.startsWith("buyer:") ? copyParam(`${prefix}.guestDetailsLabel`, "Ticket holder details") : "",
|
|
150
|
+
})), ...(poll ? { _poll: poll } : {}),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function buy(selection: { key: string; slot?: Record<string, unknown>; eventId?: string }): Promise<void> {
|
|
155
|
+
syncSession();
|
|
156
|
+
if (!reference || !metadata || fields(true, false).iapBuyDisabled || inFlight) return;
|
|
157
|
+
if (options.kind === "eventsTicket" && reference.id !== selection.key) return;
|
|
158
|
+
if (!form.validate()) { formError = copyParam(`${prefix}.formRequiredText`, "Complete the required details before purchasing."); return; }
|
|
159
|
+
if (session.current().kind !== "member") { formError = iapPurchaseCopy(prefix).signInRequired; return; }
|
|
160
|
+
const currentGeneration = generation;
|
|
161
|
+
const ref = reference;
|
|
162
|
+
submittedForms.set(`${ref.kind}:${ref.id}`, form);
|
|
163
|
+
const target = `${ref.kind}:${ref.id}:${selection.key}`;
|
|
164
|
+
let attemptId = attempts.get(target);
|
|
165
|
+
const state = commerce.state();
|
|
166
|
+
const attemptKey = attemptId ? `${ref.kind}:${ref.id}#${encodeURIComponent(attemptId)}` : "";
|
|
167
|
+
const completed = attemptId && ((state.phase === "accessReady" && state.item === attemptKey) || state.payments.some((p) => p.item === attemptKey && p.access === "granted"));
|
|
168
|
+
if (!attemptId || completed) { attemptId = `${Date.now().toString(36)}-${++sequence}-${Math.random().toString(36).slice(2)}`; attempts.set(target, attemptId); }
|
|
169
|
+
const item: IapOrderItem = { reference: ref, attemptId, quantity: 1, attendance: {
|
|
170
|
+
amount: metadata.amount!, currency: metadata.currency!,
|
|
171
|
+
...(options.kind === "bookingsService" ? { booking: { slot: selection.slot ?? {}, contactDetails: form.buyer() } } : { ticket: { eventId: selection.eventId ?? "", ...form.ticket(ref.id) } }),
|
|
172
|
+
} };
|
|
173
|
+
inFlight = true;
|
|
174
|
+
formError = "";
|
|
175
|
+
options.onChange();
|
|
176
|
+
try {
|
|
177
|
+
await commerce.buy(item);
|
|
178
|
+
const after = commerce.state();
|
|
179
|
+
if (after.phase === "accessReady" && after.item === `${ref.kind}:${ref.id}#${encodeURIComponent(attemptId)}`) attempts.delete(target);
|
|
180
|
+
}
|
|
181
|
+
finally { inFlight = false; syncSession(); if (generation !== currentGeneration) form.clear(); options.onChange(); }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function change(arg: unknown): void {
|
|
185
|
+
syncSession();
|
|
186
|
+
const state = commerce.state();
|
|
187
|
+
if (reference) {
|
|
188
|
+
for (const [target, attempt] of attempts) {
|
|
189
|
+
const item = `${reference.kind}:${reference.id}#${encodeURIComponent(attempt)}`;
|
|
190
|
+
const canCorrect = state.phase === "cancelled" || (state.phase === "refused" && (state.failure === "orderRefused" || state.failure === "productChanged"));
|
|
191
|
+
if (canCorrect && state.item === item) { attempts.delete(target); submittedForms.delete(`${reference.kind}:${reference.id}`); }
|
|
192
|
+
}
|
|
193
|
+
const referencePrefix = `${reference.kind}:${reference.id}:`;
|
|
194
|
+
if (inFlight || [...attempts.keys()].some((target) => target.startsWith(referencePrefix))) {
|
|
195
|
+
formError = copyParam(`${prefix}.detailsLockedText`, "Finish the current purchase before changing registration details.");
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
form.change(arg);
|
|
200
|
+
formError = "";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
booking, ticket, fields, buy,
|
|
205
|
+
usesRegularPlatform: () => platform === "android" || platform === "web",
|
|
206
|
+
invalidate: () => { reference = undefined; metadata = undefined; options.onChange(); },
|
|
207
|
+
change,
|
|
208
|
+
refresh,
|
|
209
|
+
poll: async () => { syncSession(); await commerce.poll(); },
|
|
210
|
+
};
|
|
211
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { get as appInfo } from "@himalaya/app-info";
|
|
2
|
+
import { attendanceCheckoutRoute, bookingAttendanceOf, ticketAttendanceOf } from "@himalaya/iap";
|
|
3
|
+
|
|
4
|
+
let knownPlatform: string | undefined;
|
|
5
|
+
|
|
6
|
+
export function attendancePlatform(onResolved?: (platform: string) => void): Promise<string> {
|
|
7
|
+
if (knownPlatform) return Promise.resolve(knownPlatform);
|
|
8
|
+
const info = appInfo().then((value) => value.platform, () => "unknown").then((platform) => {
|
|
9
|
+
if (platform !== "unknown") knownPlatform = platform;
|
|
10
|
+
return platform;
|
|
11
|
+
});
|
|
12
|
+
const host = globalThis as { setTimeout?: (fn: () => void, ms: number) => unknown; clearTimeout?: (timer: unknown) => void };
|
|
13
|
+
if (!host.setTimeout) {
|
|
14
|
+
void info.then((platform) => onResolved?.(platform));
|
|
15
|
+
return Promise.resolve("unknown");
|
|
16
|
+
}
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
let timedOut = false;
|
|
19
|
+
const timer = host.setTimeout!(() => { timedOut = true; resolve("unknown"); }, 1500);
|
|
20
|
+
void info.then((platform) => { host.clearTimeout?.(timer); resolve(platform); if (timedOut) onResolved?.(platform); });
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function mayUseRegularBookingCheckout(service: unknown): Promise<boolean> {
|
|
25
|
+
const metadata = bookingAttendanceOf(service);
|
|
26
|
+
if (metadata.priceType === "NO_FEE" || isZero(metadata.amount)) return true;
|
|
27
|
+
return attendanceCheckoutRoute(metadata, metadata.currency ?? "", await attendancePlatform()) === "regular";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function mayUseRegularTicketCheckout(event: unknown, ticket: { free?: boolean; price?: { amount?: string; currency?: string }; saleStatus?: string } | undefined): Promise<boolean> {
|
|
31
|
+
if (ticket?.free === true || isZero(ticket?.price?.amount)) return true;
|
|
32
|
+
const metadata = ticketAttendanceOf(event, { hidden: false, saleStatus: ticket?.saleStatus });
|
|
33
|
+
return attendanceCheckoutRoute(metadata, ticket?.price?.currency ?? "", await attendancePlatform()) === "regular";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isZero(value: unknown): boolean {
|
|
37
|
+
return typeof value === "string" && /^0+(?:\.0+)?$/.test(value);
|
|
38
|
+
}
|
|
@@ -2,7 +2,7 @@ import { defineService } from '@himalaya/state';
|
|
|
2
2
|
import * as chat from '../_shared/business-chat-transport.js';
|
|
3
3
|
import type { ChatIdentity } from '../_shared/business-chat-transport.js';
|
|
4
4
|
import { type MessageRow } from '../_shared/business-chat-message-model.js';
|
|
5
|
-
import { businessChatSendRejected, messageTimeLabel, pendingIntentWasObserved, chronologicalMessages, newBusinessChatIntent, type BusinessChatIntent } from '../_shared/business-chat-model.js';
|
|
5
|
+
import { businessChatSendRejected, messageTimeLabel, sendAnchor, pendingIntentWasObserved, chronologicalMessages, newBusinessChatIntent, type BusinessChatIntent } from '../_shared/business-chat-model.js';
|
|
6
6
|
import { subscribeRealtime, unsubscribeRealtime, getRealtimeStatus, type RealtimeStatus, type RealtimeEventEnvelope } from '@himalaya/wix-chat';
|
|
7
7
|
import { dispatchBusinessSuggestion, businessRealtimeStatusLabel, businessVisitorOptionSuggestions, presentBusinessMessages, selectExistingBusinessRoom, shouldRefreshBusinessHistory, type BusinessSuggestion, type BusinessVisitorOptions, businessVisitorOptionRequest, businessVisitorOptionsFromAiMessages, newBusinessSelectionId} from '../_shared/business-chat-presenter.js';
|
|
8
8
|
import { listMessages, streamMessages } from '@himalaya/wix-ai-site-chat';
|
|
@@ -33,7 +33,6 @@ const initial:State={hostedName,hasHostedName:!!hostedName,noHostedName:!hostedN
|
|
|
33
33
|
let currentIdentity:ChatIdentity|null=null;
|
|
34
34
|
let subscriptionId='';
|
|
35
35
|
let initFlight=false;
|
|
36
|
-
let sendFlight=false;
|
|
37
36
|
let suggestionFlight=false;
|
|
38
37
|
let refreshFlight=false;
|
|
39
38
|
let refreshQueued=false;
|
|
@@ -71,7 +70,10 @@ function pendingRow(intent:BusinessChatIntent):BusinessMessageRow{
|
|
|
71
70
|
suggestions:[],articleActions:[],couponCode:'',noCoupon:true,notMine:false,dayLabel:'',dayHidden:true,startsDay:false,scrollAnchor:`himi_business_chat.message_${intent.id}`,pending:true};
|
|
72
71
|
}
|
|
73
72
|
function shape(s:State):State{const used=new Set(s.usedSuggestionIds);const messages=s.messages.map(row=>({...row,suggestions:(row.suggestions||[]).map(button=>used.has(button.id)?{...button,enabled:false,disabled:true}:button)}));const quickReplies=(s.quickReplies||[]).map(button=>used.has(button.id)?{...button,enabled:false,disabled:true}:button);const quickEnabled=quickReplies.some(button=>button.enabled);return {...s,messages,quickReplies,hasQuickReplies:quickEnabled,noQuickReplies:!quickEnabled,scrollAnchor:messages[messages.length-1]?.scrollAnchor||'',liveLabel:liveDescription(s.liveState,s.liveReason),hasActiveRoom:!!s.roomId,noActiveRoom:!s.roomId,hasHostedName:!!s.hostedName,noHostedName:!s.hostedName,isGuest:s.phase==='guest',notGuest:s.phase!=='guest',realtimeDiagnostic:`state=${s.liveState}; reason=${s.liveReason||'none'}; events=${s.realtimeEventCount}; lastType=${/^(message|messagesent)$/i.test(s.lastRealtimeEventType)?s.lastRealtimeEventType:'none'}`,errorText:s.error,loadingHidden:s.phase!=='loading'||!!s.error,noError:!s.error,noNotice:!s.notice,noMessages:!messages.length,hasMessages:!!messages.length,noEmpty:s.phase!=='ready'||!!messages.length,
|
|
74
|
-
notLoading:s.phase!=='loading',sendDisabled:!s
|
|
73
|
+
notLoading:s.phase!=='loading',sendDisabled:!canSend(s,s.draft),sendEnabled:canSend(s,s.draft),noLive:!s.roomId||s.liveState==='connected'};}
|
|
74
|
+
/** Send is offered only once the room's history has loaded: a send needs it as its anchor, the Smart Chat send does
|
|
75
|
+
* not let the app name the stored message, and history is how the app finds it. */
|
|
76
|
+
function canSend(s:State,draft:string):boolean{return !!s.member&&!!s.roomId&&s.phase==='ready'&&!s.busy&&!!draft.trim()&&!s.pendingIntent;}
|
|
75
77
|
function safeLoadFailure(error:unknown):string{
|
|
76
78
|
const name=error instanceof Error?error.name:'UnknownError';
|
|
77
79
|
const code=(error as {code?:unknown}|null)?.code;
|
|
@@ -215,9 +217,12 @@ async function refreshHistory(s:State):Promise<State|undefined>{
|
|
|
215
217
|
const base:State=same?withLiveFields(s,latest,liveDraft):{...s,draft:liveDraft};
|
|
216
218
|
const intentObserved=pendingIntentWasObserved(messages,base.pendingIntent);
|
|
217
219
|
const draft=intentObserved&&base.pendingIntent&&base.draft.trim()===base.pendingIntent.text?'':base.draft;
|
|
218
|
-
// A send the server has not returned yet stays on screen until history does.
|
|
220
|
+
// A send the server has not returned yet stays on screen until history does. Seeing it in history only replaces the
|
|
221
|
+
// placeholder: a send still in flight stays pending until its own request settles it, so its outcome is never lost.
|
|
222
|
+
// An unconfirmed one (its request is over) no longer holds Send once its words show up; its notice stays.
|
|
219
223
|
const shown=!intentObserved&&base.pendingIntent?[...messages,pendingRow(base.pendingIntent)]:messages;
|
|
220
|
-
|
|
224
|
+
const pendingIntent=intentObserved&&base.pendingIntent?.delivery==='unconfirmed'?null:base.pendingIntent;
|
|
225
|
+
return shape({...base,messages:shown,pendingIntent,visitorOptions,quickReplies:businessVisitorOptionSuggestions(visitorOptions),draft,phase:'ready',error:'',loadFailure:''});
|
|
221
226
|
}catch(error){
|
|
222
227
|
const wrapped=error instanceof Error?error:new Error('load_failed');
|
|
223
228
|
Object.assign(wrapped,{businessLoadFailure:safeLoadFailure(error)});
|
|
@@ -227,24 +232,27 @@ async function refreshHistory(s:State):Promise<State|undefined>{
|
|
|
227
232
|
/** What other actions own and may have changed while this one awaited, taken from the live state. A state
|
|
228
233
|
* built from a snapshot taken before an await must carry these forward or it rolls them back. */
|
|
229
234
|
function withLiveFields(st:State,latest:State,draft:string):State{
|
|
230
|
-
|
|
235
|
+
// The pending intent is always the live one: a send settled meanwhile (null now) must not come back from a snapshot.
|
|
236
|
+
return {...st,draft,pendingIntent:latest.pendingIntent,
|
|
231
237
|
usedSuggestionIds:[...new Set([...st.usedSuggestionIds,...latest.usedSuggestionIds])],
|
|
232
238
|
liveState:latest.liveState,liveReason:latest.liveReason,realtimeEventCount:Math.max(st.realtimeEventCount,latest.realtimeEventCount),lastRealtimeEventType:latest.lastRealtimeEventType,
|
|
233
239
|
scrollSend:Math.max(st.scrollSend,latest.scrollSend)};
|
|
234
240
|
}
|
|
235
241
|
function withLiveDraft(st:State):State{const latest=readServiceState();return withLiveFields(st,latest,latest.draft);}
|
|
236
242
|
type SendResult={id:string;outcome:'sent'|'rejected'|'unconfirmed'|'signed_out';draft:string;error?:unknown};
|
|
237
|
-
/** Posts one message and reports how it ended; never throws. The caller already showed the message.
|
|
243
|
+
/** Posts one message and reports how it ended; never throws. The caller already showed the message. The call lasts
|
|
244
|
+
* until the AI has answered: history usually shows the stored copy first, which replaces the placeholder, while Send
|
|
245
|
+
* stays held until this settles (the site's widget also waits for the answer). */
|
|
238
246
|
async function deliver(pending:BusinessChatIntent,draft:string):Promise<void>{
|
|
239
247
|
const sendEpoch=epoch;
|
|
240
248
|
let result:SendResult={id:pending.id,outcome:'sent',draft};
|
|
241
249
|
try{
|
|
242
250
|
await Promise.resolve();// the action that started this has returned its state by now
|
|
243
|
-
await chat.sendMessage(me(),pending.
|
|
251
|
+
await chat.sendMessage(me(),pending.text);
|
|
244
252
|
}catch(error){
|
|
245
253
|
const lost=/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error));
|
|
246
254
|
result={id:pending.id,draft,error,outcome:lost?'signed_out':businessChatSendRejected(error)?'rejected':'unconfirmed'};
|
|
247
|
-
}
|
|
255
|
+
}
|
|
248
256
|
if(sendEpoch===epoch)void svc.settleSend(result);
|
|
249
257
|
}
|
|
250
258
|
const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefined|Promise<State|undefined>>>('businessChat',{
|
|
@@ -254,15 +262,32 @@ const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefine
|
|
|
254
262
|
if(initFlight)return s;initFlight=true;eventDuringInit=false;statusDuringInit=null;
|
|
255
263
|
const oldPrincipal=s.principal;
|
|
256
264
|
const initRevision=draftRevision;
|
|
257
|
-
// Text typed at any point of the load, subscribe step included, outlives the snapshot this started from.
|
|
258
|
-
|
|
265
|
+
// Text typed at any point of the load, subscribe step included, outlives the snapshot this started from. So does the
|
|
266
|
+
// live pending send, but only the same member's and only one still in flight: reopening is how a member checks an
|
|
267
|
+
// unconfirmed send (its notice says so), so it does not carry over and cannot hold Send for good.
|
|
268
|
+
// A reset for another member or scope (clearPrivate) ends the old send's settling, so nothing of it is kept.
|
|
269
|
+
let reset=false;
|
|
270
|
+
// Only an unconfirmed send the member reopened the chat to check is dropped. A send still in flight when this load
|
|
271
|
+
// began that settles during it (rejected, or turned unconfirmed) keeps what its settling told the member: its
|
|
272
|
+
// notice, its error and the draft it gave back.
|
|
273
|
+
const checkedId=s.pendingIntent?.delivery==='unconfirmed'?s.pendingIntent.id:'';
|
|
274
|
+
const inFlightId=s.pendingIntent&&s.pendingIntent.delivery!=='unconfirmed'?s.pendingIntent.id:'';
|
|
275
|
+
const keepTyped=(st:State):State=>{
|
|
276
|
+
const live=readServiceState();
|
|
277
|
+
const sameMember=!reset&&live.principal===st.principal;
|
|
278
|
+
const own=sameMember?live.pendingIntent:null;
|
|
279
|
+
const intent=own&&!(own.delivery==='unconfirmed'&&own.id===checkedId)?own:null;
|
|
280
|
+
const settledMeanwhile=sameMember&&!!inFlightId&&!(live.pendingIntent?.id===inFlightId&&live.pendingIntent.delivery!=='unconfirmed');
|
|
281
|
+
return {...st,pendingIntent:intent,messages:st.messages.filter(row=>!row.pending||row.id===intent?.id),
|
|
282
|
+
...(settledMeanwhile?{notice:live.notice,error:live.error,draft:live.draft}:draftRevision!==initRevision?{draft:live.draft}:{})};
|
|
283
|
+
};
|
|
259
284
|
let myEpoch=epoch;
|
|
260
285
|
try{
|
|
261
286
|
loadStage='room_selection';
|
|
262
287
|
const identity=await getCurrentMember();
|
|
263
288
|
if(myEpoch!==epoch)return undefined;
|
|
264
289
|
if(!identity.authenticated)throw new Error('member_sign_in_required');
|
|
265
|
-
if(oldPrincipal&&(oldPrincipal!==(identity.userId||identity.email)||s.scope!==identity.scope)){s=clearPrivate(s,'guest');myEpoch=epoch;}
|
|
290
|
+
if(oldPrincipal&&(oldPrincipal!==(identity.userId||identity.email)||s.scope!==identity.scope)){s=clearPrivate(s,'guest');myEpoch=epoch;reset=true;}
|
|
266
291
|
currentIdentity=identity;
|
|
267
292
|
s=shape({...s,phase:'loading',member:true,principal:identity.userId||identity.email,scope:identity.scope,error:'',notice:''});
|
|
268
293
|
const existingRoom=await resolveExistingBusinessRoom(myEpoch);
|
|
@@ -284,15 +309,16 @@ const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefine
|
|
|
284
309
|
}finally{initFlight=false;}
|
|
285
310
|
},
|
|
286
311
|
// Runs on every keystroke, so it must not rebuild the message list the way `shape` does.
|
|
287
|
-
setDraft:(s,a)=>{draftRevision++;const draft=value(a);const sendEnabled
|
|
312
|
+
setDraft:(s,a)=>{draftRevision++;const draft=value(a);const sendEnabled=canSend(s,draft);return {...s,draft,sendEnabled,sendDisabled:!sendEnabled};},
|
|
288
313
|
// Shows the message and clears the box at once; the network round trip and the history reload that follow
|
|
289
314
|
// run in the background and settle through `settleSend`.
|
|
290
315
|
send:s=>{
|
|
291
316
|
const text=s.draft.trim();
|
|
292
|
-
|
|
317
|
+
// One send at a time: the pending intent, set right here, holds the next one until this one is settled or seen.
|
|
318
|
+
if(!text||!canSend(s,text))return s;
|
|
293
319
|
// The member's name as their earlier messages show it, so the row keeps its size when the real message replaces it.
|
|
294
320
|
const ownName=[...s.messages].reverse().find(row=>row.mine&&row.senderName)?.senderName??'';
|
|
295
|
-
|
|
321
|
+
const pending={...newBusinessChatIntent(text,s.roomId,s.principal),senderName:ownName,...sendAnchor(s.messages,text,s.phase==='ready')};
|
|
296
322
|
draftRevision++;// a refresh that began before this send must not bring the sent text back into the composer
|
|
297
323
|
void deliver(pending,s.draft);
|
|
298
324
|
return shape({...s,draft:'',pendingIntent:pending,messages:[...s.messages,pendingRow(pending)],error:'',notice:'',scrollSend:s.scrollSend+1});
|
|
@@ -306,16 +332,23 @@ const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefine
|
|
|
306
332
|
// The row goes, and the text comes back unless the member has already started something new.
|
|
307
333
|
return shape({...s,messages:s.messages.filter(row=>row.id!==pending.id),pendingIntent:null,draft:s.draft.trim()?s.draft:result.draft,error:safeError(result.error,'send'),notice:'The message was rejected. Your draft is retained.'});
|
|
308
334
|
}
|
|
335
|
+
// History cannot tell this send from the same words written elsewhere (the website), so an indeterminate failure is
|
|
336
|
+
// never reported as sent. If matching words are already there it does not hold Send, and the notice still asks to check.
|
|
309
337
|
if(result.outcome==='unconfirmed'){
|
|
310
338
|
const unconfirmed={...pending,delivery:'unconfirmed' as const};
|
|
311
|
-
|
|
339
|
+
const seen=pendingIntentWasObserved(s.messages,pending);
|
|
340
|
+
return shape({...s,pendingIntent:seen?null:unconfirmed,messages:replaceRow(unconfirmed),error:safeError(result.error,'send'),notice:'No automatic retry was made. Reopen the conversation to check whether it arrived.'});
|
|
312
341
|
}
|
|
313
342
|
const sent={...pending,delivery:'sent' as const};
|
|
314
343
|
const accepted=shape({...s,pendingIntent:sent,messages:replaceRow(sent),error:'',notice:''});
|
|
315
344
|
const sendEpoch=epoch;
|
|
316
345
|
// The server accepted it, so Send is free again whatever history says; the row stays until history returns it.
|
|
317
346
|
// A refresh that ran while the committed intent still said sending leaves a 'Sending…' row: swap it for the accepted one.
|
|
318
|
-
|
|
347
|
+
// History that already holds the message under the server's id needs no placeholder for it. Only this send's intent
|
|
348
|
+
// is cleared: a newer one the live state carried in is that send's to settle.
|
|
349
|
+
const ownIntent=(st:State)=>st.pendingIntent&&st.pendingIntent.id!==sent.id?st.pendingIntent:null;
|
|
350
|
+
const settled=(st:State):State=>pendingIntentWasObserved(st.messages,sent)&&!st.messages.some(row=>row.id===sent.id)?shape({...st,pendingIntent:ownIntent(st),notice:''}):
|
|
351
|
+
shape({...st,pendingIntent:ownIntent(st),notice:'',messages:st.messages.some(row=>row.id===sent.id)?st.messages.map(row=>row.id===sent.id&&row.pending?pendingRow(sent):row):[...st.messages,pendingRow(sent)]});
|
|
319
352
|
// While live updates are connected the server's own event brings the message back through one refresh; a second
|
|
320
353
|
// one here would only queue behind it on the worker, delaying the member's next keystroke or send.
|
|
321
354
|
if(s.liveState==='connected')return settled(accepted);
|