@wix/himalaya-cli 0.1614.0 → 0.1616.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 +2 -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,148 @@
|
|
|
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
|
+
import { assertMemberAccess, memberAccess } from "./member-access.js";
|
|
5
|
+
|
|
6
|
+
const MANAGE = "https://manage.wix.com";
|
|
7
|
+
const MEMBER = { role: "member" } as const;
|
|
8
|
+
type Raw = Record<string, unknown>;
|
|
9
|
+
type Bridge = { call(method: string, path: string, body?: unknown, headers?: Record<string, string>): Promise<unknown> };
|
|
10
|
+
|
|
11
|
+
/** Who is signed in. `userId` is empty until the host reports it; the chat then tells the business
|
|
12
|
+
* side apart by the room's participant id instead. */
|
|
13
|
+
export interface ChatIdentity { authenticated: boolean; userId: string; email: string; scope: string }
|
|
14
|
+
export async function chatIdentity(): Promise<ChatIdentity> {
|
|
15
|
+
const member = await memberAccess();
|
|
16
|
+
return { authenticated: !!member.email, userId: "", email: member.email, scope: member.scope };
|
|
17
|
+
}
|
|
18
|
+
export const assertChatIdentity = (identity: ChatIdentity): Promise<void> => assertMemberAccess(identity);
|
|
19
|
+
|
|
20
|
+
/** Every request is fenced: the member is re-checked before it goes out and again before its response is
|
|
21
|
+
* used, so a sign-out or account switch mid-request never leaks one member's data to the next. */
|
|
22
|
+
async function fenced<T>(identity: ChatIdentity, run: () => Promise<T>): Promise<T> {
|
|
23
|
+
await assertChatIdentity(identity);
|
|
24
|
+
const result = await run();
|
|
25
|
+
await assertChatIdentity(identity);
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Any other member-scoped call (the AI chat's) goes through the same fence. */
|
|
30
|
+
export const fence = fenced;
|
|
31
|
+
|
|
32
|
+
// Minting a chat token is a network round trip, and every chat call used to mint its own. One token
|
|
33
|
+
// per member is reused until the member changes or a request using it is refused.
|
|
34
|
+
let cachedToken: { key: string; token: string } | null = null;
|
|
35
|
+
const tokenKey = (identity: ChatIdentity): string => `${identity.email}|${identity.scope}`;
|
|
36
|
+
async function chatTokenFor(identity: ChatIdentity): Promise<string> {
|
|
37
|
+
const key = tokenKey(identity);
|
|
38
|
+
if (cachedToken?.key === key) return cachedToken.token;
|
|
39
|
+
const { chatToken } = await getChatToken(MEMBER);
|
|
40
|
+
// The host mints for whoever is signed in NOW: a token minted while the member switched belongs to the
|
|
41
|
+
// new member and must not be cached under the old one.
|
|
42
|
+
await assertChatIdentity(identity);
|
|
43
|
+
cachedToken = { key, token: chatToken };
|
|
44
|
+
return chatToken;
|
|
45
|
+
}
|
|
46
|
+
/** Runs `run` with the member's cached token; a refused request mints a fresh token and tries once more. */
|
|
47
|
+
async function withToken<T>(identity: ChatIdentity, run: (chatToken: string) => Promise<T>): Promise<T> {
|
|
48
|
+
const reused = cachedToken?.key === tokenKey(identity);
|
|
49
|
+
try {
|
|
50
|
+
return await run(await chatTokenFor(identity));
|
|
51
|
+
} catch (error) {
|
|
52
|
+
cachedToken = null;
|
|
53
|
+
if (!reused || !/\b(?:401|403)\b|token/i.test(String((error as Error)?.message ?? error))) throw error;
|
|
54
|
+
return run(await chatTokenFor(identity));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const MAX_ROOM_PAGES = 20;
|
|
59
|
+
/** Every chatroom the member can see: the cursor is followed so the business room is found on any page. */
|
|
60
|
+
export const listRooms = (identity: ChatIdentity): Promise<{ items: Raw[]; more: boolean }> => fenced(identity, async () => {
|
|
61
|
+
const items: Raw[] = [];
|
|
62
|
+
let nextPointer: number | string | undefined;
|
|
63
|
+
for (let page = 0; page < MAX_ROOM_PAGES; page++) {
|
|
64
|
+
const result = await withToken(identity, (chatToken) => listChatroomsPage({ ...MEMBER, chatToken, pageSize: 50, ...(nextPointer !== undefined ? { nextPointer } : {}) }));
|
|
65
|
+
items.push(...(result.items as unknown as Raw[]));
|
|
66
|
+
nextPointer = result.nextPointer;
|
|
67
|
+
if (nextPointer === undefined) return { items, more: false };
|
|
68
|
+
}
|
|
69
|
+
return { items, more: true };
|
|
70
|
+
});
|
|
71
|
+
/** How many of the member's BUSINESS chatrooms have unread messages, as the server counts them (capped at 100).
|
|
72
|
+
* Not the rooms themselves: the header badge needs only the total. A response without a valid count is
|
|
73
|
+
* unavailable, never zero. */
|
|
74
|
+
export const unreadRoomCount = (identity: ChatIdentity): Promise<number> => fenced(identity, async () => {
|
|
75
|
+
const page = await withToken(identity, (chatToken) => listChatroomsPage({ ...MEMBER, chatToken, pageSize: 1, onlyUnread: true, participantType: "business" }));
|
|
76
|
+
const count = page.unreadChatroomCount;
|
|
77
|
+
if (typeof count !== "number" || !Number.isSafeInteger(count) || count < 0) throw new Error("unavailable unread chat count");
|
|
78
|
+
return count;
|
|
79
|
+
});
|
|
80
|
+
export const messagesPage = (identity: ChatIdentity, roomId: string): Promise<Raw[]> => fenced(identity, async () =>
|
|
81
|
+
(await withToken(identity, (chatToken) => getMessagesPage(roomId, { ...MEMBER, chatToken, pageSize: 50, ordering: "Desc" }))).items as unknown as Raw[]);
|
|
82
|
+
|
|
83
|
+
function bridge(): Bridge {
|
|
84
|
+
const api = (globalThis as unknown as { himalaya?: { wixApi?: Bridge } }).himalaya?.wixApi;
|
|
85
|
+
if (!api) throw new Error("wix_api_bridge_unavailable");
|
|
86
|
+
return api;
|
|
87
|
+
}
|
|
88
|
+
/** A typed message goes through Smart Chat, exactly as the site's chat widget sends it. The Inbox then files it under
|
|
89
|
+
* Wix Smart Chat and the AI can answer it; a message posted straight into the room has no channel, so the AI never
|
|
90
|
+
* saw it and the owner's reply went out by email. Smart Chat chooses the conversation and stored message id.
|
|
91
|
+
* The bridge resolves once the AI has finished answering, not when the message is stored. */
|
|
92
|
+
export const sendMessage = (identity: ChatIdentity, text: string): Promise<string | undefined> => fenced(identity, async () => {
|
|
93
|
+
const response = await bridge().call("POST", "/wix-assistant-widget/v1/messages/stream", {
|
|
94
|
+
message: { sender: "SITE_VISITOR", messageType: "QUESTION", body: { mainText: text } },
|
|
95
|
+
});
|
|
96
|
+
// The first stream frame names the persisted question. Keep its exact id so a send
|
|
97
|
+
// that moves from an old Members transcript to Smart Chat never relies on a text match.
|
|
98
|
+
let frames: unknown = response;
|
|
99
|
+
if (typeof frames === "string") {
|
|
100
|
+
const raw = frames;
|
|
101
|
+
try { frames = JSON.parse(raw); }
|
|
102
|
+
catch {
|
|
103
|
+
frames = [];
|
|
104
|
+
// The bridge buffers SSE verbatim. Ignore event/comment lines and decode the
|
|
105
|
+
// data of each frame; some hosts instead hand over NDJSON or parsed frames.
|
|
106
|
+
for (const block of raw.split(/\r?\n\r?\n/)) {
|
|
107
|
+
const lines = block.split(/\r?\n/);
|
|
108
|
+
const event = lines.find(line => line.startsWith('event:'))?.slice(6).trim();
|
|
109
|
+
const data = lines.filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n');
|
|
110
|
+
try {
|
|
111
|
+
const value = JSON.parse(data || block);
|
|
112
|
+
(frames as unknown[]).push(event === 'inputMessage' && !value?.inputMessage ? { inputMessage: value } : value);
|
|
113
|
+
} catch { /* Comments and other non-JSON frames carry no stored question id. */ }
|
|
114
|
+
}
|
|
115
|
+
// A buffered NDJSON response has no SSE separators.
|
|
116
|
+
if (!(frames as unknown[]).length) {
|
|
117
|
+
try { frames = [JSON.parse(raw.split('\n', 1)[0]!)]; } catch { return undefined; }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
for (const frame of Array.isArray(frames) ? frames : [frames]) {
|
|
122
|
+
const input = (frame as { inputMessage?: { id?: unknown; sender?: unknown; messageType?: unknown } } | null)?.inputMessage;
|
|
123
|
+
if (input?.sender === "SITE_VISITOR" && input.messageType === "QUESTION" && typeof input.id === "string" && input.id) return `smart-chat:${input.id}`;
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
});
|
|
127
|
+
/** A tapped button is a silent, ephemeral message carrying the button's chat action. */
|
|
128
|
+
export const sendButton = (identity: ChatIdentity, roomId: string, input: { id: string; text: string; appId: string; interactionId: string }): Promise<void> => fenced(identity, async () => {
|
|
129
|
+
await withToken(identity, (chatToken) => bridge().call("POST", `${MANAGE}/_api/chat-web/v2/chatrooms/${encodeURIComponent(roomId)}/messages`, {
|
|
130
|
+
sendMessageBody: { id: input.id, messageType: "message", messageTypeVersion: 1, summary: input.text, isEphemeral: true, isSilent: true, data: [{ mimeType: "text/plain", data: { text: input.text } }] },
|
|
131
|
+
sendMessageOptions: { chatAction: { actionType: "BUTTON_INTERACTION", appId: input.appId, interactionId: input.interactionId } },
|
|
132
|
+
}, { ChatToken: chatToken }));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const https = (value: unknown): string => (typeof value === "string" && /^https:\/\/[^\s\\]+$/.test(value) ? value : "");
|
|
136
|
+
/** A fresh, authorized URL for an attachment. A private file is always fetched by its id. */
|
|
137
|
+
export const attachmentUrl = (identity: ChatIdentity, media: { mediaId?: string; mediaProvider?: string; url?: string; fileName?: string }): Promise<string> => fenced(identity, async () => {
|
|
138
|
+
if (media.mediaProvider === "WixMediaPrivate") {
|
|
139
|
+
if (!media.mediaId) throw new Error("attachment_has_no_media_id");
|
|
140
|
+
const response = await bridge().call("POST", "/communication-channels-media/v1/download-url", {
|
|
141
|
+
fileId: media.mediaId, ...(media.fileName ? { downloadFileName: media.fileName } : {}),
|
|
142
|
+
}) as Raw | null;
|
|
143
|
+
const url = https(response?.downloadUrl);
|
|
144
|
+
if (!url) throw new Error("attachment_download_url_invalid");
|
|
145
|
+
return url;
|
|
146
|
+
}
|
|
147
|
+
return https(media.url);
|
|
148
|
+
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { compareText, compareIso } from "./engine-intl.js";
|
|
2
|
+
/** Shared OTA collection contract. No host APIs: used by workers and builders. */
|
|
3
|
+
export interface CollectionConfig {
|
|
4
|
+
rows: string;
|
|
5
|
+
/** Flat customer history rows, divided rather than boxed. */
|
|
6
|
+
rowStyle?: "flat";
|
|
7
|
+
instanceInput?: string;
|
|
8
|
+
title: string;
|
|
9
|
+
subtitle?: string;
|
|
10
|
+
image?: string;
|
|
11
|
+
filter?: string;
|
|
12
|
+
filterValues?: string[];
|
|
13
|
+
/** A state key holding `{ label }` rows (e.g. a worker's full category list) whose
|
|
14
|
+
* labels are filter choices even when no loaded row carries them yet. */
|
|
15
|
+
filterValuesFrom?: string;
|
|
16
|
+
searchPlaceholder?: string;
|
|
17
|
+
sortLabels?: { default: string; az: string; za: string };
|
|
18
|
+
metadata?: string[];
|
|
19
|
+
/** Numeric 0–5 row field rendered as a Rating (accessible "N out of 5") instead of
|
|
20
|
+
* folding a "★★★★☆" glyph string into the metadata line, so screen readers get a value. */
|
|
21
|
+
rating?: string;
|
|
22
|
+
/** Domain-provided calendar text and its unavailable flag, kept separate from media. */
|
|
23
|
+
dateBadge?: { text: string; hidden: string };
|
|
24
|
+
sectioned?: boolean;
|
|
25
|
+
view: 'list' | 'grid';
|
|
26
|
+
backendQuery?: boolean;
|
|
27
|
+
paged?: boolean;
|
|
28
|
+
defaultFilter?: string;
|
|
29
|
+
reviewAction?: string;
|
|
30
|
+
reviewSelectedAction?: string;
|
|
31
|
+
/** Optional comfortable grid media height for immersive visual catalogs. */
|
|
32
|
+
gridImageHeight?: number;
|
|
33
|
+
/** What the count summary counts — ["dish", "dishes"] reads "12 dishes". Default "item(s)". */
|
|
34
|
+
countNoun?: [string, string];
|
|
35
|
+
/** Phone grid columns (default 2). A card carrying a paragraph of benefits and a CTA — a
|
|
36
|
+
* pricing plan — is unreadable at half width, so it opts into one. */
|
|
37
|
+
gridColumns?: number;
|
|
38
|
+
/** Lines of the meta caption a comfortable row may use (default 2). */
|
|
39
|
+
metaMaxLines?: number;
|
|
40
|
+
/** The card's primary verb, shown as a visible button beside the overflow.
|
|
41
|
+
* `screenId` opens that screen with the row id; without it the verb reuses the
|
|
42
|
+
* row's own tap action (the detail screen that owns the verb). */
|
|
43
|
+
primaryVerb?: { label: string; screenId?: string;
|
|
44
|
+
/** A row field holding THIS row's verb label (Register, RSVP, Buy Tickets…); `label` stays the fallback copy. */
|
|
45
|
+
labelPath?: string;
|
|
46
|
+
/** A row boolean that hides the verb on rows that offer no action (an event with no registration). */
|
|
47
|
+
hiddenPath?: string };
|
|
48
|
+
/** The worker action that shares a row (`tier5.<service>.<name>` with `{ id }`): adds Share to the card's overflow menu. */
|
|
49
|
+
shareAction?: string;
|
|
50
|
+
/** A TIME-BOUND entity (an event, a booking, a reservation): the boolean row field the
|
|
51
|
+
* DOMAIN sets once the record has finished — computed by the same rule as the row's own
|
|
52
|
+
* "Past" label, in the same place, so the two cannot disagree. A finished
|
|
53
|
+
* record's `primaryVerb` (Register, Book…) is transactional and would act on something
|
|
54
|
+
* that is over, so it is swapped for a non-transactional "View details" that opens the
|
|
55
|
+
* row's own detail. Without a row tap action the verb is simply hidden. */
|
|
56
|
+
endedWhen?: string;
|
|
57
|
+
/** A catalog card whose one expected per-item action is "favorite".
|
|
58
|
+
* The ellipsis overflow is replaced by an always-visible heart toggle wired to
|
|
59
|
+
* the same `collectionSave` reducer. A dropdown `Menu` anchored inside a
|
|
60
|
+
* list/grid row renders clipped inside the row's own bounds, so on a product
|
|
61
|
+
* card the overflow's items were unreachable AND the wrong pattern: shoppers
|
|
62
|
+
* expect a heart, not a 3-item menu two taps deep. */
|
|
63
|
+
favoriteToggle?: boolean;
|
|
64
|
+
/** A state field the DOMAIN narrows `rows` by (e.g. a category picker). While it is set,
|
|
65
|
+
* zero rows is a filtered result, not an empty source. */
|
|
66
|
+
narrowedBy?: string;
|
|
67
|
+
}
|
|
68
|
+
/** At or below this many records, with no query/filter narrowing them and no further
|
|
69
|
+
* page, the list/grid toggle, Refine, chips, search and count are noise. */
|
|
70
|
+
export const COLLECTION_CHROME_MIN_ITEMS = 4;
|
|
71
|
+
/** The accessible name of a row's overflow menu: `template` with `{title}` filled, or the
|
|
72
|
+
* generic `fallback` for a record with no title. */
|
|
73
|
+
export function collectionActionsLabel(title: string, template: string, fallback: string): string {
|
|
74
|
+
const t = title.trim();
|
|
75
|
+
return t ? template.replace('{title}', () => t) : fallback;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const COLLECTIONS: Record<string, CollectionConfig> = {
|
|
79
|
+
himiStoreShop: { rows: 'results', title: 'name', subtitle: 'subtitle', image: 'image', view: 'grid', backendQuery: true, countNoun: ['product', 'products'], favoriteToggle: true },
|
|
80
|
+
himiBookingServices: { rows: 'results', title: 'name', subtitle: 'subtitle', image: 'image', filter: 'category', view: 'grid', backendQuery: true, searchPlaceholder: 'Search services', countNoun: ['service', 'services'], primaryVerb: { label: 'Book', screenId: 'himi_time' } },
|
|
81
|
+
himiBookingBookings: { rows: 'rows', title: 'title', subtitle: 'subtitle', metadata: ['status'], view: 'list', backendQuery: true,
|
|
82
|
+
defaultFilter: 'value:Upcoming', searchPlaceholder: 'Search by booking ID', countNoun: ['booking', 'bookings'], filterValues: ['Upcoming', 'Past', 'Cancelled'],
|
|
83
|
+
sortLabels: { default: 'Soonest', az: 'Oldest', za: 'Latest' } },
|
|
84
|
+
himiEvents: { rows: 'results', title: 'title', subtitle: 'dateLine', image: 'image', dateBadge: { text: 'dateBadge', hidden: 'noDateBadge' }, gridColumns: 1, view: 'grid', backendQuery: true, defaultFilter: 'value:Upcoming', searchPlaceholder: 'Search events', countNoun: ['event', 'events'], filterValues: ['Upcoming', 'Past'], sortLabels: { default: 'Date', az: 'Title A–Z', za: 'Title Z–A' }, primaryVerb: { label: 'Register', labelPath: 'actionLabel', hiddenPath: 'noAction' }, shareAction: 'share', endedWhen: 'isPast' },
|
|
85
|
+
himiBlogFeed: { rows: 'posts', title: 'title', subtitle: 'excerpt', image: 'image', filter: 'category', filterValuesFrom: 'categories', view: 'grid', narrowedBy: 'selectedCategoryId' },
|
|
86
|
+
himiOwnerLiteProducts: { rows: 'products', title: 'name', subtitle: 'price', image: 'image', view: 'grid', backendQuery: true, countNoun: ['product', 'products'] },
|
|
87
|
+
himiOwnerModules: { instanceInput: 'module', rows: 'rows', title: 'title', subtitle: 'subtitle', image: 'image', filter: 'statusLabel', view: 'list', paged: true, reviewAction: 'prepareRecord', reviewSelectedAction: 'prepareSelected' },
|
|
88
|
+
himiOwnerLiteServices: { rows: 'services', title: 'name', subtitle: 'subtitle', view: 'list', backendQuery: true, searchPlaceholder: 'Service name starts with…' },
|
|
89
|
+
himiOwnerLiteEvents: { rows: 'events', title: 'title', subtitle: 'dateLine', view: 'grid', backendQuery: true, searchPlaceholder: 'Exact event title', filterValues: ['Upcoming'], sortLabels: { default: 'Date', az: 'Title A–Z', za: 'Title Z–A' } },
|
|
90
|
+
himiOwnerLitePosts: { rows: 'posts', title: 'title', subtitle: 'excerpt', image: 'image', view: 'grid' },
|
|
91
|
+
himiOwnerLiteOrders: { rows: 'orders', title: 'buyer', subtitle: 'meta', filter: 'statusLabel', view: 'list', backendQuery: true, countNoun: ['order', 'orders'],
|
|
92
|
+
searchPlaceholder: 'Customer name, email or order #', sortLabels: { default: 'Newest', az: 'Customer A–Z', za: 'Customer Z–A' },
|
|
93
|
+
filterValues: ['Recent 7 days', 'Paid', 'Unpaid', 'Partially paid', 'Pending', 'Refunded', 'Partly refunded'] },
|
|
94
|
+
himiBrandedGroups: { rows: 'rows', title: 'name', subtitle: 'description', image: 'image', view: 'grid' },
|
|
95
|
+
himiBrandedCms: { rows: 'rows', title: 'name', subtitle: 'subtitle', view: 'grid' },
|
|
96
|
+
himiBrandedCmsCollection: { instanceInput: 'collection', rows: 'rows', title: 'title', subtitle: 'subtitle', view: 'list' },
|
|
97
|
+
himiBrandedOrders: { rows: 'rows', title: 'title', subtitle: 'subtitle', image: 'image', metadata: ['status', 'meta'], rowStyle: 'flat', view: 'list', backendQuery: true,
|
|
98
|
+
searchPlaceholder: 'Search by order number', countNoun: ['order', 'orders'], filterValues: ['Not fulfilled', 'Partially fulfilled', 'Fulfilled'],
|
|
99
|
+
sortLabels: { default: 'Newest', az: 'Oldest', za: 'Order # ↓' } },
|
|
100
|
+
himiBrandedPlans: { rows: 'rows', title: 'name', subtitle: 'description', metadata: ['badge', 'priceLine', 'periodLine', 'trialBadge', 'perk1', 'perk2', 'perk3', 'joinNote'], view: 'grid', gridColumns: 1, metaMaxLines: 8, countNoun: ['plan', 'plans'] },
|
|
101
|
+
himiBrandedPrograms: { rows: 'rows', title: 'name', subtitle: 'description', image: 'image', view: 'grid' },
|
|
102
|
+
himiBrandedCoupons: { rows: 'coupons', title: 'name', subtitle: 'subtitle', view: 'list', primaryVerb: { label: 'Get code' } },
|
|
103
|
+
himiBrandedNotifications: { rows: 'items', title: 'name', subtitle: 'subtitle', image: 'photo', metadata: ['unreadDot'], view: 'list' },
|
|
104
|
+
himiBrandedLoyalty: { rows: 'rewards', title: 'name', subtitle: 'description', view: 'grid', primaryVerb: { label: 'Redeem' } },
|
|
105
|
+
himiBrandedReviews: { rows: 'reviews', title: 'title', subtitle: 'body', metadata: ['author'], rating: 'rating', view: 'list' },
|
|
106
|
+
himiBrandedPortfolio: { rows: 'projects', title: 'title', subtitle: 'subtitle', image: 'image', view: 'grid', gridImageHeight: 180 },
|
|
107
|
+
himiBrandedInvoices: { rows: 'invoices', title: 'title', subtitle: 'subtitle', view: 'list' },
|
|
108
|
+
himiBrandedBadges: { rows: 'badges', title: 'name', subtitle: 'description', view: 'grid' },
|
|
109
|
+
himiBrandedDonations: { rows: 'campaigns', title: 'name', subtitle: 'description', view: 'grid', primaryVerb: { label: 'Donate' } },
|
|
110
|
+
himiBrandedGiftCards: { rows: 'cards', title: 'name', subtitle: 'description', view: 'grid' },
|
|
111
|
+
himiBrandedVideos: { rows: 'channels', title: 'title', subtitle: 'subtitle', image: 'coverUrl', view: 'grid', searchPlaceholder: 'Search channels', countNoun: ['channel', 'channels'] },
|
|
112
|
+
himiBrandedPodcasts: { rows: 'episodes', title: 'title', subtitle: 'subtitle', image: 'image', metadata: ['progressLabel'], view: 'list' },
|
|
113
|
+
himiBrandedForms: { rows: 'forms', title: 'name', subtitle: 'description', view: 'list' },
|
|
114
|
+
himiBrandedGalleries: { rows: 'galleries', title: 'name', subtitle: 'description', image: 'image', view: 'grid' },
|
|
115
|
+
himiBrandedMenus: { rows: 'rows', title: 'name', subtitle: 'description', image: 'image', sectioned: true, filter: 'collectionCategory', metadata: ['collectionCategory', 'priceLine', 'label1', 'label2', 'label3'], view: 'grid', searchPlaceholder: 'Search the menu', countNoun: ['dish', 'dishes'] },
|
|
116
|
+
};
|
|
117
|
+
export interface CollectionPreferences {
|
|
118
|
+
view: 'list' | 'grid';
|
|
119
|
+
density: 'comfortable' | 'compact';
|
|
120
|
+
sort: 'default' | 'az' | 'za';
|
|
121
|
+
filter: string;
|
|
122
|
+
query: string;
|
|
123
|
+
savedIds: string[];
|
|
124
|
+
selectedIds: string[];
|
|
125
|
+
selecting: boolean;
|
|
126
|
+
savedViews: SavedCollectionView[];
|
|
127
|
+
}
|
|
128
|
+
export interface SavedCollectionView {
|
|
129
|
+
id: string; name: string; view: 'list' | 'grid'; density: 'comfortable' | 'compact';
|
|
130
|
+
sort: 'default' | 'az' | 'za'; query: string; filter: string;
|
|
131
|
+
}
|
|
132
|
+
export interface CollectionItemAction {
|
|
133
|
+
id: string;
|
|
134
|
+
label: string;
|
|
135
|
+
destructive: boolean;
|
|
136
|
+
confirmationRequired: boolean;
|
|
137
|
+
}
|
|
138
|
+
export type CollectionRow = Record<string, unknown>;
|
|
139
|
+
export function initialCollection(config: CollectionConfig): CollectionPreferences {
|
|
140
|
+
return { view: config.view, density: 'comfortable', sort: 'default', filter: config.defaultFilter ?? 'all', query: '', savedIds: [], selectedIds: [], selecting: false, savedViews: [] };
|
|
141
|
+
}
|
|
142
|
+
/** Accept only small presentation preferences from local storage. Selection is
|
|
143
|
+
* transient and never restored as an armed bulk operation. */
|
|
144
|
+
export function normalizeCollection(config: CollectionConfig, value: unknown): CollectionPreferences {
|
|
145
|
+
const s = value && typeof value === 'object' ? value as Partial<CollectionPreferences> : {};
|
|
146
|
+
const base = initialCollection(config);
|
|
147
|
+
return { ...base,
|
|
148
|
+
view: s.view === 'list' || s.view === 'grid' ? s.view : base.view,
|
|
149
|
+
density: s.density === 'compact' ? 'compact' : 'comfortable',
|
|
150
|
+
sort: s.sort === 'az' || s.sort === 'za' ? s.sort : 'default',
|
|
151
|
+
query: typeof s.query === 'string' ? s.query.slice(0, 256) : '',
|
|
152
|
+
// Flat collections no longer offer Saved. Migrate its persisted value before
|
|
153
|
+
// the first backend request so an invisible old filter cannot empty the list.
|
|
154
|
+
filter: config.rowStyle === 'flat' && s.filter === 'saved' ? 'all'
|
|
155
|
+
: typeof s.filter === 'string' ? s.filter.slice(0, 256) : config.defaultFilter ?? 'all',
|
|
156
|
+
savedIds: Array.isArray(s.savedIds) ? [...new Set(s.savedIds.filter((id): id is string => typeof id === 'string' && id.length > 0 && id.length <= 256))].slice(0, 500) : [],
|
|
157
|
+
savedViews: Array.isArray(s.savedViews) ? s.savedViews.filter((v): v is SavedCollectionView => !!v && typeof v === 'object' && typeof v.name === 'string' && !!v.name.trim()).slice(0, 8).map((v, i) => ({
|
|
158
|
+
id: `view-${i}`, name: v.name.trim().slice(0, 40),
|
|
159
|
+
view: v.view === 'list' || v.view === 'grid' ? v.view : config.view,
|
|
160
|
+
density: v.density === 'compact' ? 'compact' : 'comfortable',
|
|
161
|
+
sort: v.sort === 'az' || v.sort === 'za' ? v.sort : 'default',
|
|
162
|
+
query: typeof v.query === 'string' ? v.query.slice(0, 256) : '',
|
|
163
|
+
filter: config.rowStyle === 'flat' && v.filter === 'saved' ? 'all'
|
|
164
|
+
: typeof v.filter === 'string' ? v.filter.slice(0, 256) : 'all',
|
|
165
|
+
})) : [],
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
const scalar = (value: unknown): string => typeof value === 'string' || typeof value === 'number' ? String(value) : '';
|
|
169
|
+
export function collectionId(row: CollectionRow): string { return scalar(row.id) || scalar(row._id) || scalar(row.key); }
|
|
170
|
+
export function collectionTitle(row: CollectionRow, config: CollectionConfig): string {
|
|
171
|
+
return scalar(row[config.title]) || scalar(row.title) || scalar(row.name) || scalar(row.display) || scalar(row.label);
|
|
172
|
+
}
|
|
173
|
+
/** Does not mutate domain rows, paging, or domain-derived state. */
|
|
174
|
+
export function projectCollection(source: CollectionRow[], config: CollectionConfig, prefs: CollectionPreferences, backendQuery = false, knownFilters: string[] = []) {
|
|
175
|
+
const query = prefs.query.trim().toLocaleLowerCase();
|
|
176
|
+
const unique = new Set<string>();
|
|
177
|
+
// Menu sections describe their following items; they are not actionable
|
|
178
|
+
// records. Carry the category onto each item before sorting or filtering.
|
|
179
|
+
let category = '';
|
|
180
|
+
const records: CollectionRow[] = config.sectioned ? source.flatMap(row => {
|
|
181
|
+
if (row.isHeader === true) { category = scalar(row.title); return []; }
|
|
182
|
+
return [{ ...row, collectionCategory: category }];
|
|
183
|
+
}) : source;
|
|
184
|
+
const rows = records.filter(row => {
|
|
185
|
+
const id = collectionId(row);
|
|
186
|
+
if (!id) return true; // render anonymous records; identity actions stay disabled
|
|
187
|
+
if (unique.has(id)) return false;
|
|
188
|
+
unique.add(id); return true;
|
|
189
|
+
});
|
|
190
|
+
const filters = config.filterValues ?? [...new Set([...knownFilters, ...rows.map(row => config.filter ? scalar(row[config.filter]) : '')].filter(Boolean))].sort();
|
|
191
|
+
const visible = rows.filter(row => {
|
|
192
|
+
if (backendQuery) return true;
|
|
193
|
+
if (prefs.filter === 'saved' && !prefs.savedIds.includes(collectionId(row))) return false;
|
|
194
|
+
if (prefs.filter.startsWith('value:') && (!config.filter || scalar(row[config.filter]) !== prefs.filter.slice(6))) return false;
|
|
195
|
+
return !query || Object.values(row).some(value => scalar(value).toLocaleLowerCase().includes(query));
|
|
196
|
+
});
|
|
197
|
+
if (!backendQuery && prefs.sort !== 'default') visible.sort((a, b) => {
|
|
198
|
+
const order = compareText(collectionTitle(a, config), collectionTitle(b, config));
|
|
199
|
+
return (prefs.sort === 'az' ? order : -order) || compareIso(collectionId(a), collectionId(b));
|
|
200
|
+
});
|
|
201
|
+
return {
|
|
202
|
+
items: visible.map(row => ({ ...row, collectionId: collectionId(row), collectionTitle: collectionTitle(row, config),
|
|
203
|
+
collectionMeta: [...new Set((config.metadata ?? ['total', 'statusLabel', 'price', 'priceLine', 'dateLine', 'meta']).filter(key => key !== config.subtitle).map(key => scalar(row[key])).filter(Boolean))].join(' · '),
|
|
204
|
+
collectionSelectLabel: prefs.selectedIds.includes(collectionId(row)) ? 'Selected' : 'Select',
|
|
205
|
+
// The save toggle names the action it WILL take (the service localizes it).
|
|
206
|
+
collectionSaveLabel: prefs.savedIds.includes(collectionId(row)) ? 'Remove from saved' : 'Save',
|
|
207
|
+
// The row's overflow menu, named for its record: out of context (a rotor, a voice
|
|
208
|
+
// command) a list of identical "Item actions" cannot be told apart.
|
|
209
|
+
collectionActionsLabel: collectionActionsLabel(collectionTitle(row, config), 'Actions for {title}', 'Item actions'),
|
|
210
|
+
collectionSubtitle: scalar(row[config.subtitle ?? '']) || scalar(row.subtitle) || scalar(row.description) || scalar(row.meta),
|
|
211
|
+
collectionImage: scalar(row[config.image ?? '']), collectionNoImage: !scalar(row[config.image ?? '']), collectionHasImage: !!scalar(row[config.image ?? '']),
|
|
212
|
+
collectionSaved: prefs.savedIds.includes(collectionId(row)), collectionNotSaved: !prefs.savedIds.includes(collectionId(row)), collectionSelected: prefs.selectedIds.includes(collectionId(row)),
|
|
213
|
+
collectionNoIdentity: !collectionId(row),
|
|
214
|
+
// Older cached EventRows predate `isPast` but already stored `statusLabel: "Past"`.
|
|
215
|
+
// Keep their ended state through an offline render until the row is refreshed.
|
|
216
|
+
...(config.endedWhen ? (() => {
|
|
217
|
+
const ended = row[config.endedWhen] === true || (config.endedWhen === 'isPast' && row.statusLabel === 'Past');
|
|
218
|
+
return { collectionEnded: ended, collectionNotEnded: !ended };
|
|
219
|
+
})() : {}),
|
|
220
|
+
// A row restored from state saved by an older build has no badge flag. An unset hidden flag
|
|
221
|
+
// fails open and would paint an empty chip, so it reads as "no badge" until a refresh.
|
|
222
|
+
...(config.dateBadge && typeof row[config.dateBadge.hidden] !== 'boolean'
|
|
223
|
+
? { [config.dateBadge.text]: '', [config.dateBadge.hidden]: true } : {}),
|
|
224
|
+
// Same for a per-row verb: an unset hidden flag fails open and would show a verb with an unresolved
|
|
225
|
+
// label, so a row without the flag offers no action until a refresh establishes one.
|
|
226
|
+
...(config.primaryVerb?.hiddenPath && typeof row[config.primaryVerb.hiddenPath] !== 'boolean'
|
|
227
|
+
? { ...(config.primaryVerb.labelPath ? { [config.primaryVerb.labelPath]: '' } : {}), [config.primaryVerb.hiddenPath]: true } : {}) })),
|
|
228
|
+
filters, loaded: rows.length,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/** Backend collection lifecycle shared by domain workers. The domain supplies
|
|
2
|
+
* the query and row mapper; this layer never calls a domain API or mutates it. */
|
|
3
|
+
export interface CollectionQuery {
|
|
4
|
+
query: string;
|
|
5
|
+
filter: string;
|
|
6
|
+
sort: 'default' | 'az' | 'za';
|
|
7
|
+
savedIds: string[];
|
|
8
|
+
}
|
|
9
|
+
export interface CollectionPage<Row> {
|
|
10
|
+
items: Row[];
|
|
11
|
+
nextCursor: string;
|
|
12
|
+
total?: number;
|
|
13
|
+
metadata?: Record<string, unknown>;
|
|
14
|
+
complete?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface CollectionQueryState<Row> extends CollectionPage<Row> {
|
|
17
|
+
criteria: CollectionQuery;
|
|
18
|
+
status: 'idle' | 'loading' | 'ready' | 'error';
|
|
19
|
+
loadingMore: boolean;
|
|
20
|
+
pageError: boolean;
|
|
21
|
+
complete: boolean;
|
|
22
|
+
error: string;
|
|
23
|
+
/** The rows are the source's `cached` page, shown while the live read is still out. */
|
|
24
|
+
provisional?: boolean;
|
|
25
|
+
}
|
|
26
|
+
/** What a collection showed, as `shownPage` hands it out and `seed` takes it back on a restored boot. */
|
|
27
|
+
export interface CollectionShownPage<Row> {
|
|
28
|
+
state: CollectionQueryState<Row>;
|
|
29
|
+
loadedPages: number;
|
|
30
|
+
}
|
|
31
|
+
export interface CollectionQuerySource<Row> {
|
|
32
|
+
fetch(criteria: CollectionQuery, cursor: string): Promise<CollectionPage<Row>>;
|
|
33
|
+
/** Optional public cache for immediate paint; always incomplete, never a paging authority. */
|
|
34
|
+
cached?(criteria: CollectionQuery): Promise<CollectionPage<Row> | undefined>;
|
|
35
|
+
id(row: Row): string;
|
|
36
|
+
errorMessage?(error: unknown): string;
|
|
37
|
+
/** Private collections must discard prior rows when access or identity changes. A revalidation keeps the
|
|
38
|
+
* rows it shows through any other failure: safe because one worker never serves two identities (its pool
|
|
39
|
+
* key carries the account, and a purge tears parked workers down). */
|
|
40
|
+
clearOnError?(error: unknown): boolean;
|
|
41
|
+
}
|
|
42
|
+
const normalize = (q: Partial<CollectionQuery>): CollectionQuery => ({
|
|
43
|
+
query: typeof q.query === 'string' ? q.query.trim().slice(0, 256) : '',
|
|
44
|
+
filter: typeof q.filter === 'string' ? q.filter.slice(0, 256) : 'all',
|
|
45
|
+
sort: q.sort === 'az' || q.sort === 'za' ? q.sort : 'default',
|
|
46
|
+
savedIds: Array.isArray(q.savedIds) ? [...new Set(q.savedIds.filter(id => typeof id === 'string' && id.length > 0 && id.length <= 256))].slice(0, 500) : [],
|
|
47
|
+
});
|
|
48
|
+
const copyQuery = (q: CollectionQuery): CollectionQuery => ({ ...q, savedIds: [...q.savedIds] });
|
|
49
|
+
|
|
50
|
+
/** New criteria cancel the old result logically, including an in-flight next
|
|
51
|
+
* page. Failures preserve the cursor and rows for an explicit retry. Callers
|
|
52
|
+
* publish each snapshot through their ordinary service reducer, so loading and
|
|
53
|
+
* filtered-empty never masquerade as a successfully completed query. */
|
|
54
|
+
export function createCollectionQuery<Row>(source: CollectionQuerySource<Row>, publish: (state: CollectionQueryState<Row>) => void) {
|
|
55
|
+
let generation = 0, disposed = false;
|
|
56
|
+
let loadedPages = 0, restoringPages = 0;
|
|
57
|
+
let state: CollectionQueryState<Row> = { criteria: normalize({}), items: [], nextCursor: '', status: 'idle', loadingMore: false, pageError: false, complete: false, error: '' };
|
|
58
|
+
/** The query a revalidation is running under: its snapshots are held back until it is done. */
|
|
59
|
+
let revalidating = -1;
|
|
60
|
+
/** The last page the screen showed whole (rows ready, nothing paging, failing or standing in): what a revalidation
|
|
61
|
+
* keeps on screen and restores when it fails, and what a restored boot shows. A next page loading or failing after
|
|
62
|
+
* it leaves those rows on screen, so it is still this. */
|
|
63
|
+
let lastShown: { state: CollectionQueryState<Row>; loadedPages: number } | undefined;
|
|
64
|
+
const sameCriteria = (a: CollectionQuery, b: CollectionQuery) => JSON.stringify(a) === JSON.stringify(b);
|
|
65
|
+
let clearedByError = false;
|
|
66
|
+
const snapshot = (): CollectionQueryState<Row> => ({ ...state, criteria: copyQuery(state.criteria), items: [...state.items] });
|
|
67
|
+
const emit = () => {
|
|
68
|
+
if (disposed || revalidating === generation) return;
|
|
69
|
+
// A settled answer replaces it, an empty one included: rows the live query proved gone are never kept, and a list
|
|
70
|
+
// with nothing in it comes back as its empty state, never its loading look.
|
|
71
|
+
if (state.status === 'ready' && !state.pageError && !state.loadingMore && !state.provisional) {
|
|
72
|
+
lastShown = { state: snapshot(), loadedPages };
|
|
73
|
+
}
|
|
74
|
+
publish(snapshot());
|
|
75
|
+
};
|
|
76
|
+
const merge = (old: Row[], page: Row[]): Row[] => {
|
|
77
|
+
const positions = new Map<string, number>(), result: Row[] = [];
|
|
78
|
+
for (const item of [...old, ...page]) {
|
|
79
|
+
const id = source.id(item);
|
|
80
|
+
if (!id) throw new Error('A backend collection row requires an identity');
|
|
81
|
+
const index = positions.get(id);
|
|
82
|
+
if (index === undefined) { positions.set(id, result.length); result.push(item); }
|
|
83
|
+
else result[index] = item;
|
|
84
|
+
}
|
|
85
|
+
return result;
|
|
86
|
+
};
|
|
87
|
+
/** [keepRows]: a search typed over the rows on screen leaves them there until its answer arrives (status stays
|
|
88
|
+
* `loading`, and nothing pages under the old answer), so the list is not blanked on each keystroke. */
|
|
89
|
+
const fetch = async (append: boolean, keepRows = false): Promise<void> => {
|
|
90
|
+
if (disposed || (append && (state.loadingMore || state.status === 'loading' || !state.nextCursor))) return;
|
|
91
|
+
const revision = append ? generation : ++generation;
|
|
92
|
+
const cursor = append ? state.nextCursor : '';
|
|
93
|
+
const criteria = copyQuery(state.criteria);
|
|
94
|
+
if (!append) loadedPages = 0;
|
|
95
|
+
state = { ...state, status: append ? 'ready' : 'loading', loadingMore: append, pageError: false, error: '',
|
|
96
|
+
...(append ? {} : keepRows ? { nextCursor: '', complete: false, provisional: false }
|
|
97
|
+
: { items: [], nextCursor: '', total: undefined, metadata: undefined, complete: false, provisional: false }) };
|
|
98
|
+
emit();
|
|
99
|
+
try {
|
|
100
|
+
if (!append && source.cached) {
|
|
101
|
+
const cached = await source.cached(criteria).catch(() => undefined);
|
|
102
|
+
if (disposed || generation !== revision) return;
|
|
103
|
+
if (cached?.items.length) {
|
|
104
|
+
state = { ...state, items: merge([], cached.items), metadata: cached.metadata, status: 'ready', complete: false, provisional: true };
|
|
105
|
+
emit();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const page = await source.fetch(criteria, cursor);
|
|
109
|
+
if (disposed || generation !== revision) return;
|
|
110
|
+
if (!Array.isArray(page.items) || typeof page.nextCursor !== 'string' || (cursor && page.nextCursor === cursor)) throw new Error('Invalid collection page');
|
|
111
|
+
state = { ...state, items: merge(append ? state.items : [], page.items), nextCursor: page.nextCursor,
|
|
112
|
+
total: typeof page.total === 'number' && Number.isFinite(page.total) && page.total >= 0 ? page.total : (append ? state.total : undefined),
|
|
113
|
+
metadata: page.metadata ?? (append ? state.metadata : undefined),
|
|
114
|
+
status: 'ready', loadingMore: false, pageError: false, complete: page.complete ?? !page.nextCursor, provisional: false };
|
|
115
|
+
loadedPages++;
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (disposed || generation !== revision) return;
|
|
118
|
+
const clear = source.clearOnError?.(error) === true;
|
|
119
|
+
// Revoked access always ends what a revalidation showed, whether or not the source clears its rows.
|
|
120
|
+
clearedByError = clear || /HTTP 40[13]\b/.test(String(error));
|
|
121
|
+
if (clearedByError) lastShown = undefined;
|
|
122
|
+
if (clear) loadedPages = 0;
|
|
123
|
+
// The rows a typed search kept on screen while it ran were never its answer: a failed one shows none of them.
|
|
124
|
+
const drop = clear || keepRows;
|
|
125
|
+
state = { ...state, ...(drop ? { items: [], nextCursor: '', total: undefined, metadata: undefined } : {}),
|
|
126
|
+
status: append && !clear ? 'ready' : 'error', loadingMore: false, pageError: append && !clear, complete: false,
|
|
127
|
+
provisional: false, error: source.errorMessage?.(error) ?? '' };
|
|
128
|
+
}
|
|
129
|
+
emit();
|
|
130
|
+
};
|
|
131
|
+
return {
|
|
132
|
+
getState: snapshot,
|
|
133
|
+
/** [revalidate]: a screen coming back re-queries the rows it shows. They stay on screen until every
|
|
134
|
+
* page is in, and a failure keeps them; revoked access still clears them, and other criteria reset. */
|
|
135
|
+
async query(criteria: Partial<CollectionQuery>, restorePages = false, revalidate = false): Promise<void> {
|
|
136
|
+
if (disposed) return Promise.resolve();
|
|
137
|
+
const next = normalize(criteria);
|
|
138
|
+
const same = JSON.stringify(next) === JSON.stringify(state.criteria);
|
|
139
|
+
const count = restorePages && same ? Math.max(loadedPages, restoringPages, 1) : 1;
|
|
140
|
+
const showing = lastShown && sameCriteria(lastShown.state.criteria, next) ? lastShown : undefined;
|
|
141
|
+
restoringPages = count;
|
|
142
|
+
// Only the search text changed: the user is typing over the rows on screen, also while the last keystroke's
|
|
143
|
+
// search is still out.
|
|
144
|
+
const typing = !same && (state.status === 'ready' || state.status === 'loading')
|
|
145
|
+
&& sameCriteria({ ...state.criteria, query: '' }, { ...next, query: '' });
|
|
146
|
+
state = { ...state, criteria: next };
|
|
147
|
+
if (revalidate && showing) { revalidating = generation + 1; clearedByError = false; }
|
|
148
|
+
const pending = fetch(false, typing);
|
|
149
|
+
const revision = generation;
|
|
150
|
+
await pending;
|
|
151
|
+
// Re-appearance revalidates every previously opened page using fresh
|
|
152
|
+
// cursors. A changed query, error or revoked access stops the restoration.
|
|
153
|
+
while (!disposed && generation === revision && loadedPages < count && state.status === 'ready'
|
|
154
|
+
&& !state.pageError && !state.loadingMore && state.nextCursor) await fetch(true);
|
|
155
|
+
if (generation === revision) restoringPages = 0;
|
|
156
|
+
if (revalidating === revision) {
|
|
157
|
+
revalidating = -1;
|
|
158
|
+
if (generation !== revision) return;
|
|
159
|
+
if ((state.status !== 'ready' || state.pageError) && showing && !clearedByError) {
|
|
160
|
+
state = { ...showing.state, criteria: next };
|
|
161
|
+
loadedPages = showing.loadedPages;
|
|
162
|
+
}
|
|
163
|
+
emit();
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
/** The page the screen shows (rows ready, nothing failing or still paging), for the service to keep with its
|
|
167
|
+
* state; undefined when it shows none. */
|
|
168
|
+
shownPage(): CollectionShownPage<Row> | undefined {
|
|
169
|
+
if (!lastShown || !sameCriteria(lastShown.state.criteria, state.criteria)) return undefined;
|
|
170
|
+
const page = lastShown.state;
|
|
171
|
+
return { state: { ...page, criteria: copyQuery(page.criteria), items: [...page.items] }, loadedPages: lastShown.loadedPages };
|
|
172
|
+
},
|
|
173
|
+
/** A cold boot restored from disk: what the last run showed becomes what this one shows, so its first `load`
|
|
174
|
+
* revalidates behind it instead of starting empty. Only before the first query, and only a well-formed page; an
|
|
175
|
+
* empty one is the list's empty state. */
|
|
176
|
+
seed(page: unknown): boolean {
|
|
177
|
+
const candidate = page && typeof page === 'object' ? page as Partial<CollectionShownPage<Row>> : undefined;
|
|
178
|
+
const restored = candidate?.state;
|
|
179
|
+
if (generation !== 0 || state.status !== 'idle' || !restored || restored.status !== 'ready'
|
|
180
|
+
|| !Array.isArray(restored.items) || typeof restored.nextCursor !== 'string') return false;
|
|
181
|
+
try { restored.items.forEach(item => { if (!source.id(item)) throw new Error('A backend collection row requires an identity'); }); } catch { return false; }
|
|
182
|
+
state = { ...state, ...restored, criteria: normalize(restored.criteria ?? {}), items: [...restored.items], status: 'ready',
|
|
183
|
+
loadingMore: false, pageError: false, provisional: false, error: '' };
|
|
184
|
+
loadedPages = Math.max(1, Math.floor(Number(candidate?.loadedPages) || 1));
|
|
185
|
+
lastShown = { state: snapshot(), loadedPages };
|
|
186
|
+
return true;
|
|
187
|
+
},
|
|
188
|
+
refresh: () => { restoringPages = 0; return fetch(false); },
|
|
189
|
+
loadMore: () => fetch(true),
|
|
190
|
+
retry: () => fetch(state.pageError),
|
|
191
|
+
dispose() { disposed = true; generation++; },
|
|
192
|
+
};
|
|
193
|
+
}
|