@wix/himalaya-cli 0.1614.0 → 0.1615.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/cli.mjs +1 -0
  2. package/package.json +2 -2
  3. package/template-icons/salon-spa.png +0 -0
  4. package/templates/beauty-salon/tier5-src/_shared/branded-load-state.ts +5 -2
  5. package/templates/beauty-salon/tier5-src/_shared/himi-account.ts +31 -0
  6. package/templates/beauty-salon/tier5-src/_shared/himi-booking-saved.ts +88 -0
  7. package/templates/beauty-salon/tier5-src/himi-booking-service/index.ts +50 -3
  8. package/templates/beauty-salon/tier5-src/himi-booking-services/index.ts +70 -3
  9. package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +40 -8
  10. package/templates/beauty-salon/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  11. package/templates/branded-lite/tier5-src/_shared/branded-load-state.ts +5 -2
  12. package/templates/branded-lite/tier5-src/_shared/himi-account.ts +31 -0
  13. package/templates/branded-lite/tier5-src/_shared/himi-booking-saved.ts +88 -0
  14. package/templates/branded-lite/tier5-src/himi-booking-service/index.ts +50 -3
  15. package/templates/branded-lite/tier5-src/himi-booking-services/index.ts +70 -3
  16. package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +40 -8
  17. package/templates/branded-lite/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  18. package/templates/branded-lite/tier5-src/himi-branded-lite-galleries/index.ts +104 -12
  19. package/templates/community-club/tier5-src/_shared/branded-load-state.ts +5 -2
  20. package/templates/community-club/tier5-src/_shared/himi-account.ts +31 -0
  21. package/templates/events-meetups/tier5-src/_shared/branded-load-state.ts +5 -2
  22. package/templates/events-meetups/tier5-src/_shared/himi-account.ts +31 -0
  23. package/templates/gym-fitness/tier5-src/_shared/branded-load-state.ts +5 -2
  24. package/templates/gym-fitness/tier5-src/_shared/himi-account.ts +31 -0
  25. package/templates/gym-fitness/tier5-src/_shared/himi-booking-saved.ts +88 -0
  26. package/templates/gym-fitness/tier5-src/himi-booking-service/index.ts +50 -3
  27. package/templates/gym-fitness/tier5-src/himi-booking-services/index.ts +70 -3
  28. package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +40 -8
  29. package/templates/gym-fitness/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  30. package/templates/nonprofit-cause/tier5-src/_shared/branded-load-state.ts +5 -2
  31. package/templates/nonprofit-cause/tier5-src/_shared/himi-account.ts +31 -0
  32. package/templates/podcast-creator/tier5-src/_shared/branded-load-state.ts +5 -2
  33. package/templates/podcast-creator/tier5-src/_shared/himi-account.ts +31 -0
  34. package/templates/portfolio-creative/tier5-src/_shared/branded-load-state.ts +5 -2
  35. package/templates/portfolio-creative/tier5-src/_shared/himi-account.ts +31 -0
  36. package/templates/restaurant/tier5-src/_shared/branded-load-state.ts +5 -2
  37. package/templates/restaurant/tier5-src/_shared/himi-account.ts +31 -0
  38. package/templates/retail-store/tier5-src/_shared/branded-load-state.ts +5 -2
  39. package/templates/retail-store/tier5-src/_shared/himi-account.ts +31 -0
  40. package/templates/salon-spa/README.md +53 -0
  41. package/templates/salon-spa/config.ts +272 -0
  42. package/templates/salon-spa/dev/index.ts +19893 -0
  43. package/templates/salon-spa/himalaya.content.json +49 -0
  44. package/templates/salon-spa/himalaya.template.json +14 -0
  45. package/templates/salon-spa/icon.png +0 -0
  46. package/templates/salon-spa/platforms.json +1 -0
  47. package/templates/salon-spa/tier5-src/_shared/app-locale.ts +61 -0
  48. package/templates/salon-spa/tier5-src/_shared/booking-location.ts +147 -0
  49. package/templates/salon-spa/tier5-src/_shared/booking-session.ts +222 -0
  50. package/templates/salon-spa/tier5-src/_shared/branded-lite-hosted.ts +278 -0
  51. package/templates/salon-spa/tier5-src/_shared/branded-load-state.ts +109 -0
  52. package/templates/salon-spa/tier5-src/_shared/business-chat-copy.ts +40 -0
  53. package/templates/salon-spa/tier5-src/_shared/business-chat-history.ts +48 -0
  54. package/templates/salon-spa/tier5-src/_shared/business-chat-message-model.ts +177 -0
  55. package/templates/salon-spa/tier5-src/_shared/business-chat-model.ts +56 -0
  56. package/templates/salon-spa/tier5-src/_shared/business-chat-presenter.ts +581 -0
  57. package/templates/salon-spa/tier5-src/_shared/business-chat-transport.ts +148 -0
  58. package/templates/salon-spa/tier5-src/_shared/collection-contract.ts +230 -0
  59. package/templates/salon-spa/tier5-src/_shared/collection-query.ts +193 -0
  60. package/templates/salon-spa/tier5-src/_shared/collection-service.ts +400 -0
  61. package/templates/salon-spa/tier5-src/_shared/detail-presence.ts +51 -0
  62. package/templates/salon-spa/tier5-src/_shared/empty-flags.ts +33 -0
  63. package/templates/salon-spa/tier5-src/_shared/engine-intl.ts +508 -0
  64. package/templates/salon-spa/tier5-src/_shared/himi-account.ts +164 -0
  65. package/templates/salon-spa/tier5-src/_shared/himi-booking-saved.ts +88 -0
  66. package/templates/salon-spa/tier5-src/_shared/himi-bookings.ts +318 -0
  67. package/templates/salon-spa/tier5-src/_shared/himi-copy.ts +89 -0
  68. package/templates/salon-spa/tier5-src/_shared/himi-store.ts +7 -0
  69. package/templates/salon-spa/tier5-src/_shared/himi-text.ts +24 -0
  70. package/templates/salon-spa/tier5-src/_shared/host-events.ts +66 -0
  71. package/templates/salon-spa/tier5-src/_shared/hosted-checkout.ts +70 -0
  72. package/templates/salon-spa/tier5-src/_shared/iap-attendance-form.ts +106 -0
  73. package/templates/salon-spa/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
  74. package/templates/salon-spa/tier5-src/_shared/iap-attendance.ts +38 -0
  75. package/templates/salon-spa/tier5-src/_shared/iap-worker.ts +125 -0
  76. package/templates/salon-spa/tier5-src/_shared/local-preferences.ts +38 -0
  77. package/templates/salon-spa/tier5-src/_shared/member-access.ts +52 -0
  78. package/templates/salon-spa/tier5-src/_shared/member-account-routes.ts +52 -0
  79. package/templates/salon-spa/tier5-src/_shared/member-bookings.ts +94 -0
  80. package/templates/salon-spa/tier5-src/_shared/offset-page.ts +10 -0
  81. package/templates/salon-spa/tier5-src/_shared/online-actions.ts +76 -0
  82. package/templates/salon-spa/tier5-src/_shared/owner-load-error.ts +348 -0
  83. package/templates/salon-spa/tier5-src/_shared/payment-setup.ts +6 -0
  84. package/templates/salon-spa/tier5-src/_shared/rich-body.ts +78 -0
  85. package/templates/salon-spa/tier5-src/_shared/rich-content-link.ts +34 -0
  86. package/templates/salon-spa/tier5-src/_shared/weekly-goal.ts +107 -0
  87. package/templates/salon-spa/tier5-src/_shared/wix-service-price.ts +146 -0
  88. package/templates/salon-spa/tier5-src/business-chat/index.ts +669 -0
  89. package/templates/salon-spa/tier5-src/himi-booking-account/index.ts +241 -0
  90. package/templates/salon-spa/tier5-src/himi-booking-bookings/index.ts +466 -0
  91. package/templates/salon-spa/tier5-src/himi-booking-saved/index.ts +103 -0
  92. package/templates/salon-spa/tier5-src/himi-booking-service/index.ts +1170 -0
  93. package/templates/salon-spa/tier5-src/himi-booking-services/index.ts +791 -0
  94. package/templates/salon-spa/tier5-src/himi-booking-time/index.ts +1712 -0
  95. package/templates/salon-spa/tier5-src/himi-branded-lite-agenda/index.ts +950 -0
  96. package/templates/salon-spa/tier5-src/himi-branded-lite-galleries/index.ts +282 -0
  97. package/templates/salon-spa/tier5-src/himi-salon-home/index.ts +544 -0
  98. package/templates/salon-spa/tier5-src/himi-store-account/index.ts +925 -0
  99. package/templates/salon-spa/tokens.json +341 -0
  100. package/templates/yoga-studio/tier5-src/_shared/branded-load-state.ts +5 -2
  101. package/templates/yoga-studio/tier5-src/_shared/himi-account.ts +31 -0
  102. package/templates/yoga-studio/tier5-src/_shared/himi-booking-saved.ts +88 -0
  103. package/templates/yoga-studio/tier5-src/himi-booking-service/index.ts +50 -3
  104. package/templates/yoga-studio/tier5-src/himi-booking-services/index.ts +70 -3
  105. package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +40 -8
  106. package/templates/yoga-studio/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
@@ -0,0 +1,52 @@
1
+ // Can this host reach the member account routes My Addresses and My Wallet need?
2
+ //
3
+ // Both are served OFF www.wixapis.com (the address book on ecom.wix.com, saved cards on
4
+ // the member's own site), so only a native runtime whose WixApiCapability pins them can
5
+ // call them: HimalayaCore 1.4.0 and later. The web bridges refuse absolute URLs, and a
6
+ // hosted app (Spaces) signs its calls with a capability pinned to www.wixapis.com. Saved
7
+ // cards also need the shell to have bound its own site (app_info `memberSiteBound`): a
8
+ // multi-app host such as Himi Player binds none, so its wallet route has no host. The
9
+ // Account screen hides a link whose route is unreachable, and the screen says so instead
10
+ // of failing a load.
11
+ import { get as getAppInfo } from "@himalaya/app-info";
12
+ import { isHostedWorker } from "./hosted-checkout.js";
13
+
14
+ /** The HimalayaCore version whose WixApiCapability pins these routes. Checked here, at run
15
+ * time: the screens declare no `minBinaryVersion`, because a release takes its highest
16
+ * screen floor as its own and would then refuse every older binary. */
17
+ export const MEMBER_ACCOUNT_ROUTES_MIN_BINARY = "1.4.0";
18
+ export const MEMBER_MEDIA_FIELD_MIN_BINARY = "1.5.0";
19
+ export const MEMBER_PASSWORD_ACTIONS_MIN_BINARY = "1.5.0";
20
+
21
+ /** `version` (a HimalayaCore binary version, pre-release suffix ignored) is `floor` or later. */
22
+ export function atLeast(version: string, floor: string): boolean {
23
+ const parts = (v: string) => v.split("-")[0].split(".").map((p) => Number.parseInt(p, 10) || 0);
24
+ const a = parts(version), b = parts(floor);
25
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
26
+ const x = a[i] ?? 0, y = b[i] ?? 0;
27
+ if (x !== y) return x > y;
28
+ }
29
+ return true;
30
+ }
31
+
32
+ export interface MemberAccountRoutes {
33
+ /** The address book on ecom.wix.com (My Addresses). */
34
+ addresses: boolean;
35
+ /** Saved cards on the member's own site (My Wallet). */
36
+ wallet: boolean;
37
+ /** Native MediaField image picking and presigned PUT support. */
38
+ mediaField: boolean;
39
+ /** Password credential actions introduced in HimalayaCore 1.5.0. */
40
+ passwordCredentials: boolean;
41
+ }
42
+
43
+ export async function memberAccountRoutes(): Promise<MemberAccountRoutes> {
44
+ if (isHostedWorker()) return { addresses: false, wallet: false, mediaField: false, passwordCredentials: false };
45
+ const info = await getAppInfo().catch(() => undefined);
46
+ if (!info) return { addresses: false, wallet: false, mediaField: false, passwordCredentials: false };
47
+ const native = info.platform === "ios" || info.platform === "android" || info.platform === "macos";
48
+ const addresses = native && atLeast(info.binaryVersion ?? "", MEMBER_ACCOUNT_ROUTES_MIN_BINARY);
49
+ const mediaField = native && atLeast(info.binaryVersion ?? "", MEMBER_MEDIA_FIELD_MIN_BINARY);
50
+ const passwordCredentials = native && atLeast(info.binaryVersion ?? "", MEMBER_PASSWORD_ACTIONS_MIN_BINARY);
51
+ return { addresses, wallet: addresses && info.memberSiteBound === true, mediaField, passwordCredentials };
52
+ }
@@ -0,0 +1,94 @@
1
+ import { queryBookings, type WixBooking } from "@himalaya/wix-bookings";
2
+ import { memberAccess, assertMemberAccess, type MemberAccess } from "./member-access.js";
3
+ import { offsetPage } from "./offset-page.js";
4
+ import type { CollectionQuery } from "./collection-query.js";
5
+ import { copyParam } from "./himi-copy.js";
6
+ import { formatWeekdayDateTime } from "./engine-intl.js";
7
+ import { dayKey } from "@himalaya/time";
8
+
9
+ /** The statuses that put a booking in the Canceled tab. Upcoming and Past exclude them, so each
10
+ * booking is in exactly one tab. */
11
+ export const CANCELLED_STATUSES = ["CANCELED", "CANCELLED", "DECLINED"];
12
+
13
+ export interface MemberBookingRow {
14
+ id: string; title: string; subtitle: string; status: string; revision: string;
15
+ start: string; end: string; day: string; serviceId: string;
16
+ canCancel: boolean; cannotCancel: boolean; cannotRebook: boolean;
17
+ // Added for the My Bookings redesign. Each is "" / 0 / false when the record has nothing for it.
18
+ /** The staff member (the slot's resource), "" when the booking names none. */
19
+ staff: string;
20
+ /** The location's name, else its address. */
21
+ placeName: string;
22
+ /** The location's formatted address. */
23
+ placeAddress: string;
24
+ /** `BUSINESS`, `CUSTOM` or `CUSTOMER` (the client's place), upper case. */
25
+ placeType: string;
26
+ participants: number;
27
+ /** Wix's raw payment status, upper case (`PAID`, `NOT_PAID`, …). */
28
+ paymentStatus: string;
29
+ /** Wix's raw booking status, upper case (`CONFIRMED`, `WAITING_LIST`, …). */
30
+ statusKey: string;
31
+ /** A course booking: it books a schedule, not a slot. */
32
+ isCourse: boolean;
33
+ courseStart: string;
34
+ courseEnd: string;
35
+ }
36
+ export function bookingStart(booking: WixBooking): string {
37
+ // Top-level instants are UTC. Slot dates may be LOCAL to slot.timezone.
38
+ const value = booking.startDate || booking.bookedEntity?.slot?.startDate || "";
39
+ return /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value) && Number.isFinite(Date.parse(value)) ? value : "";
40
+ }
41
+ const instant = (value: unknown): string =>
42
+ typeof value === "string" && /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value) && Number.isFinite(Date.parse(value)) ? value : "";
43
+ const word = (value: unknown): string => typeof value === "string" ? value.trim() : "";
44
+ export function memberBookingRow(booking: WixBooking): MemberBookingRow {
45
+ const start = bookingStart(booking);
46
+ const entity = booking.bookedEntity;
47
+ const slot = entity?.slot, schedule = entity?.schedule;
48
+ const status = (booking.status || "").toUpperCase();
49
+ const revision = String((booking as WixBooking & { revision?: string | number }).revision ?? "");
50
+ // A course booking carries a schedule, not a slot: its service id is on the schedule.
51
+ const serviceId = slot?.serviceId || schedule?.serviceId || "";
52
+ const canCancel = !!booking.id && !!revision && Date.parse(start) > Date.now() && !CANCELLED_STATUSES.includes(status);
53
+ const location = slot?.location ?? schedule?.location;
54
+ const placeAddress = word(location?.formattedAddress);
55
+ const participants = Number(booking.totalParticipants);
56
+ return { id: booking.id, title: entity?.title || copyParam("bookings.booking", "Booking"),
57
+ subtitle: start ? formatWeekdayDateTime(new Date(start)) : copyParam("bookings.timeUnavailable", "Time unavailable"),
58
+ status: status.replace(/_/g, " "), revision, start,
59
+ // Wix may carry the end only on the slot (same instant rule as the start).
60
+ end: booking.endDate || instant(slot?.endDate), day: start ? dayKey(new Date(start)) : "",
61
+ serviceId, canCancel, cannotCancel: !canCancel, cannotRebook: !serviceId,
62
+ staff: word(slot?.resource?.name),
63
+ placeName: word(location?.name) || placeAddress,
64
+ placeAddress,
65
+ placeType: word(location?.locationType).toUpperCase(),
66
+ participants: Number.isFinite(participants) && participants > 0 ? Math.floor(participants) : 0,
67
+ paymentStatus: word(booking.paymentStatus).toUpperCase(),
68
+ statusKey: status,
69
+ isCourse: !slot && !!schedule,
70
+ courseStart: instant(schedule?.firstSessionStart),
71
+ courseEnd: instant(schedule?.lastSessionEnd) };
72
+ }
73
+ export async function fetchMemberBookings(criteria: CollectionQuery, cursor: string, now: string, expected?: MemberAccess) {
74
+ const member = expected ?? await memberAccess();
75
+ const metadata = { email: member.email, scope: member.scope, needsSignIn: !member.email };
76
+ if (!member.email) return { items: [] as MemberBookingRow[], nextCursor: "", metadata };
77
+ await assertMemberAccess(member);
78
+ if (criteria.filter === "saved" && !criteria.savedIds.length) return { items: [] as MemberBookingRow[], nextCursor: "", metadata };
79
+ const filter: Record<string, unknown> = { "contactDetails.email": { $eq: member.email } };
80
+ if (criteria.query) filter.id = { $eq: criteria.query };
81
+ if (criteria.filter === "saved") filter.id = criteria.query ? { $in: criteria.savedIds.filter(id => id === criteria.query) } : { $in: criteria.savedIds };
82
+ if (criteria.filter === "value:Upcoming") { filter.startDate = { $gte: now }; filter.status = { $nin: CANCELLED_STATUSES }; }
83
+ // Past is what took place: a canceled or declined booking is in the Canceled tab only.
84
+ if (criteria.filter === "value:Past") { filter.startDate = { $lt: now }; filter.status = { $nin: CANCELLED_STATUSES }; }
85
+ if (criteria.filter === "value:Cancelled") filter.status = { $in: CANCELLED_STATUSES };
86
+ const response = await queryBookings({ query: { filter, paging: { limit: 50, offset: Number(cursor || 0) },
87
+ sort: [{ fieldName: "startDate", order: criteria.sort === "za" ? "DESC" : "ASC" }, { fieldName: "id", order: "ASC" }] } });
88
+ await assertMemberAccess(member);
89
+ const raw = response.bookings ?? [];
90
+ // Server authorization remains authoritative. This guard also rejects a
91
+ // malformed/cross-member row before any UI projection. No private disk cache.
92
+ const items = raw.filter(booking => booking.id && booking.contactDetails?.email?.trim().toLowerCase() === member.email).map(memberBookingRow);
93
+ return { items, ...offsetPage(cursor, raw.length, 50, response.pagingMetadata), metadata };
94
+ }
@@ -0,0 +1,10 @@
1
+ /** Offset APIs keep the offset opaque to collection UI. Advance by raw rows,
2
+ * never by a member/status-filtered projection. A full page without a total is
3
+ * deliberately incomplete until a subsequent (possibly empty) page confirms it. */
4
+ export function offsetPage(cursor: string, count: number, limit: number, metadata?: { total?: number }) {
5
+ const offset = cursor ? Number(cursor) : 0;
6
+ if (!Number.isSafeInteger(offset) || offset < 0) throw new Error("Invalid page offset");
7
+ const total = typeof metadata?.total === "number" && Number.isFinite(metadata.total) && metadata.total >= 0 ? metadata.total : undefined;
8
+ const next = offset + count;
9
+ return { nextCursor: count > 0 && (total === undefined ? count >= limit : next < total) ? String(next) : "", ...(total === undefined ? {} : { total }) };
10
+ }
@@ -0,0 +1,76 @@
1
+ import { connectivity, defineService as register, type ServiceDefinition } from '@himalaya/state';
2
+
3
+ // Template generation enables this behavior only in the selected two starter families.
4
+ export const offlineWritesEnabled = false;
5
+
6
+ export const offlineWriteMessage = 'You’re offline. Connect to save this change, then submit it again.';
7
+
8
+ /** The guard runs before the reducer: entered values survive and no optimistic
9
+ * changes, success state, or queued writes can occur. It never submits on reconnect. */
10
+ export function offlineWriteState<S extends object>(state: S): S {
11
+ const next: Record<string, unknown> = { ...state, error: offlineWriteMessage, message: offlineWriteMessage };
12
+ for (const key of ['saving', 'isSaving', 'submitting', 'isSubmitting', 'adding', 'added', 'bookingInFlight']) {
13
+ if (key in next) next[key] = false;
14
+ }
15
+ for (const key of ['savedMessage', 'successMessage']) {
16
+ if (key in next) next[key] = '';
17
+ }
18
+ // A saved/favourited item is business state, not a submission-success flag.
19
+ return next as S;
20
+ }
21
+
22
+ interface OfflineWriteOptions<S> {
23
+ derive?: (state: S) => S;
24
+ /** Exempt only non-mutating branches of a registered action. */
25
+ requiresConnectivity?: (action: string, state: S, argument?: unknown) => boolean;
26
+ messageFields?: readonly string[] | ((action: string) => readonly string[]);
27
+ draftFields?: readonly string[] | Readonly<Record<string, string>>;
28
+ secretFields?: readonly string[];
29
+ }
30
+
31
+ export function withOnlineWrites<S extends object>(registerService: typeof register, names: readonly string[], options: OfflineWriteOptions<S> = {}): typeof register {
32
+ if (!offlineWritesEnabled) return registerService;
33
+ return ((name: string, definition: ServiceDefinition<Record<string, unknown>>) => {
34
+ let currentState: (() => Record<string, unknown>) | undefined;
35
+ const handle = registerService(name, {
36
+ ...definition,
37
+ notHydrated: [...new Set([...(definition.notHydrated ?? []), ...(options.secretFields ?? [])])],
38
+ actions: Object.fromEntries(Object.entries(definition.actions ?? {}).map(([action, reduce]) => [action,
39
+ (state: Record<string, unknown>, ...args: never[]) => {
40
+ if (!names.includes(action)) return reduce(state, ...args);
41
+ if (options.requiresConnectivity?.(action, state as S, args[0]) === false) return reduce(state, ...args);
42
+ const fields = typeof options.messageFields === 'function' ? options.messageFields(action) : options.messageFields ?? ['message'];
43
+ if (connectivity().online) {
44
+ let cleared = false;
45
+ for (const field of ['error', 'message', ...fields]) {
46
+ if (state[field] === offlineWriteMessage) { state[field] = ''; cleared = true; }
47
+ }
48
+ // Keep the runtime's state object: reducers may mutate it and return undefined.
49
+ const result = reduce(state, ...args);
50
+ if (!cleared) return result;
51
+ // Publish cleared feedback even for a void reducer. An async completion must
52
+ // keep any newer state published by another action while it was awaiting.
53
+ const finish = (next: Record<string, unknown> | undefined) => next === undefined ? currentState?.() ?? state : next;
54
+ return result && typeof (result as Promise<unknown>).then === 'function'
55
+ ? Promise.resolve(result).then(finish)
56
+ : finish(result as Record<string, unknown> | undefined);
57
+ }
58
+ const draft = { ...state };
59
+ const argument = args[0] as Record<string, unknown> | undefined;
60
+ const draftFields = options.draftFields ?? [];
61
+ const entries = Array.isArray(draftFields) ? draftFields.map(key => [key, key]) : Object.entries(draftFields);
62
+ for (const [key, source] of entries) {
63
+ if (argument && key in state && typeof argument[source] === typeof state[key]) draft[key] = argument[source];
64
+ }
65
+ for (const key of options.secretFields ?? []) draft[key] = '';
66
+ const feedback = Object.fromEntries(fields.map(key => [key, offlineWriteMessage]));
67
+ let next = { ...offlineWriteState(draft), ...feedback };
68
+ if (options.derive) next = options.derive(next as S) as Record<string, unknown>;
69
+ return { ...next, ...feedback };
70
+ },
71
+ ])),
72
+ });
73
+ if (typeof handle.getState === 'function') currentState = () => handle.getState();
74
+ return handle;
75
+ }) as typeof register;
76
+ }
@@ -0,0 +1,348 @@
1
+ import { copyParam } from "./himi-copy.js";
2
+
3
+ /** Host-owned preview input. Absent on native and older hosts.
4
+ *
5
+ * `"sample"` — the built-in demo records; `"live"` — the viewer's own business over the
6
+ * owner rung; `"fixtures"` — a DEV web host over a sealed hermetic gateway, where
7
+ * the dev fixtures are the sample data. `"fixtures"` is neither live nor demo, so a
8
+ * worker READS through the bridge as it does on a device; it is still a preview host
9
+ * (`isOwnerPreviewHost`), so failure copy may name the web preview. */
10
+ function ownerPreviewMode(): unknown {
11
+ return (globalThis as unknown as { himalaya?: { input?: Record<string, unknown> } }).himalaya?.input?.__himiOwnerPreview;
12
+ }
13
+
14
+ export function isOwnerPreviewLive(): boolean {
15
+ return ownerPreviewMode() === "live";
16
+ }
17
+
18
+ /**
19
+ * Whether we are in the web preview AT ALL — i.e. the host declared a mode.
20
+ *
21
+ * `isBridgeUnavailable` cannot answer this: it reads "not live" as "preview",
22
+ * and on NATIVE there is no input at all, so `undefined` satisfies it. That is
23
+ * left alone for the other apps that use it to pick a demo fallback or to queue
24
+ * a write. Owner-lite reads ask `ownerDemoFallback` instead. It is NOT
25
+ * harmless for choosing COPY: native workers format their failures the same way (`"HTTP \(code): \(body)"`, JSWorker.swift;
26
+ * `"HTTP ${e.code}: ..."`, WebViewJSWorker.kt), so a transient `HTTP 503` on a
27
+ * phone would otherwise be answered with "open this app on a device" — on the
28
+ * device. Callers that pick a SENTENCE ask this instead.
29
+ */
30
+ export function isOwnerPreviewHost(): boolean {
31
+ return ownerPreviewMode() !== undefined;
32
+ }
33
+
34
+ export function isOwnerPreviewDemo(): boolean {
35
+ return ownerPreviewMode() === "sample";
36
+ }
37
+
38
+ /** Legacy hosts can still request the old demo fallback. An explicit live
39
+ * preview must never replace an authentication failure or outage with samples. */
40
+ export function isBridgeUnavailable(e: unknown): boolean {
41
+ if (ownerPreviewMode() === "live") return false;
42
+ return /HTTP (405|501|502|503)\b/.test(String((e as Error)?.message ?? e));
43
+ }
44
+
45
+ /**
46
+ * Whether a failed owner-lite READ may answer with the built-in demo records.
47
+ *
48
+ * Only where a demo was asked for: a web preview host that is not live. On a phone no
49
+ * mode is injected, so `isBridgeUnavailable` alone is `true` for a transient Wix
50
+ * `HTTP 503` — and the owner was shown sample appointments, orders and sites as if
51
+ * they were their business. Native falls through to the screen's error state.
52
+ * `isBridgeUnavailable` keeps its semantics: other apps' callers depend on it.
53
+ */
54
+ export function ownerDemoFallback(e: unknown): boolean {
55
+ return isOwnerPreviewHost() && !isOwnerPreviewLive() && isBridgeUnavailable(e);
56
+ }
57
+
58
+ /**
59
+ * The sentence for an owner WRITE that failed because the device could not reach Wix at
60
+ * all (a transport failure). Nothing replays a queued write on any platform — every host's
61
+ * `drain` passes an empty closure — so the honest answer is that nothing was saved.
62
+ * Copy-as-configuration under `ownerCommon.`, which every owner screen forwards.
63
+ */
64
+ export function ownerOfflineNotSaved(): string {
65
+ return copyParam(
66
+ "ownerCommon.offlineNotSaved",
67
+ "You’re offline — nothing was saved. Try again when you reconnect.",
68
+ );
69
+ }
70
+
71
+ /** The sentence for an owner WRITE that Wix answered with an unavailable status: the
72
+ * device is online, so it is not "offline" — but nothing was saved all the same. */
73
+ /**
74
+ * Whether a failed WRITE may have landed on Wix anyway, so the user must not be told it
75
+ * was not saved: a transport failure mid-request (timeout, reset, fetch rejection), the web
76
+ * host's after-send envelope (`HTTP 502 {…"sent":true}`), the serve bridge's mid-request
77
+ * `webwix_bridge_upstream_failed`, or a gateway status (502/503/504) that can arrive after
78
+ * the backend already ran. Only connect-time failures (`isOfflineFailure`) and the designed
79
+ * 405/501 bridge refusals prove nothing happened.
80
+ */
81
+ export function writeMayHaveLanded(e: unknown): boolean {
82
+ if (isOfflineFailure(e)) return false;
83
+ if (isTransportFailure(e)) return true;
84
+ const msg = String((e as Error)?.message ?? e ?? "");
85
+ if (/"sent"\s*:\s*true/.test(msg)) return true;
86
+ // The web host's own envelope WITHOUT `sent: true` failed before the request left it, and
87
+ // a disabled owner rung / bridge refuses by design without sending anything.
88
+ if (/\bwebwix_(?:direct_failed|owner_disabled|bridge_disabled)\b/.test(msg)) return false;
89
+ // `webwix_bridge_upstream_failed` stays ambiguous: the same code covers a lookup that failed
90
+ // before sending and a serve-bridge failure mid-request, and nothing in it says which.
91
+ return /\bwebwix_bridge_upstream_failed\b/.test(msg) || /HTTP (502|503|504)\b/.test(msg);
92
+ }
93
+
94
+ /** Wix answered that the record does not exist (`HTTP 404`). A detail must not fall back to
95
+ * its cached row then: that row is what a delete whose reply was lost leaves behind. */
96
+ export function isNotFound(e: unknown): boolean {
97
+ if (/HTTP 404\b/.test(String((e as Error)?.message ?? e ?? ""))) return true;
98
+ // An SDK that answers a 404 with its own "not found" error keeps the HTTP one as `cause`
99
+ // (the blog SDK's getPost, after its query fallback also finds nothing).
100
+ const cause = (e as { cause?: unknown } | null | undefined)?.cause;
101
+ return cause !== undefined && cause !== e && /HTTP 404\b/.test(String((cause as Error)?.message ?? cause));
102
+ }
103
+
104
+ export function ownerUnavailableNotSaved(): string {
105
+ return copyParam(
106
+ "ownerCommon.unavailableNotSaved",
107
+ "Wix isn’t available right now — nothing was saved. Try again in a moment.",
108
+ );
109
+ }
110
+
111
+ /** The sentence for an owner WRITE that may have landed (`writeMayHaveLanded`): it can't
112
+ * be called saved or not saved. */
113
+ export function ownerWriteUnconfirmed(): string {
114
+ return copyParam(
115
+ "ownerCommon.writeUnconfirmed",
116
+ "Wix didn’t confirm this, so it may or may not have been saved. Check before trying again.",
117
+ );
118
+ }
119
+
120
+ /** Display copy for a failed owner write: `ownerOfflineNotSaved` when the request never
121
+ * left the device, `ownerWriteUnconfirmed` when it may have landed, `ownerUnavailableNotSaved`
122
+ * for the designed 405/501 bridge refusals outside a live preview, else `ownerLoadError`. */
123
+ export function ownerWriteError(e: unknown): string {
124
+ if (isOfflineFailure(e)) return ownerOfflineNotSaved();
125
+ if (writeMayHaveLanded(e)) return ownerWriteUnconfirmed();
126
+ if (!isOwnerPreviewLive() && /HTTP (405|501)\b/.test(String((e as Error)?.message ?? e))) return ownerUnavailableNotSaved();
127
+ return ownerLoadError(e);
128
+ }
129
+
130
+ /** `HTTP 403: …` → "403", when the message carries a status at all. */
131
+ const STATUS = /HTTP (\d{3})\b/;
132
+ /** A wire body that is an HTML DOCUMENT, not a message. */
133
+ const HTML_BODY = /<!DOCTYPE html|<html[\s>]|<head[\s>]/i;
134
+ /** A wire body that is a JSON ENVELOPE, not a message. The serve bridge answers
135
+ * its typed refusals this way — `HTTP 501: {"error":"webwix_owner_no_session",
136
+ * "app":"…"}` — and it is short enough to slip under MAX_RAW, so truncation
137
+ * never caught it and the owner read the machine's word for it. */
138
+ const JSON_BODY = /HTTP \d{3}:\s*[[{]/;
139
+ /** `HTTP <status> [SERVICE_CODE]: message` — the shape a Wix service uses when
140
+ * it refuses with a named reason. */
141
+ const CODED = /HTTP (\d{3}) \[([A-Z0-9_]+)\]/;
142
+ /**
143
+ * The codes that all mean one thing to an owner: the session cannot address this
144
+ * business. Found on a real session whose account did not own the business the app
145
+ * had persisted — Home rendered
146
+ * `HTTP 428 [MISSING_METASITE_ID]: Couldn't extract metasiteId from the request
147
+ * context` where "6 orders" belongs.
148
+ */
149
+ const SITE_CONTEXT_CODES = ["MISSING_METASITE_ID", "MISSING_REQUEST_SITE_CONTEXT", "MISSING_SITE_CONTEXT"];
150
+ /** Longest raw upstream text worth showing. Beyond this it stops being a
151
+ * message and starts being a payload dump. */
152
+ const MAX_RAW = 200;
153
+
154
+ /**
155
+ * A TRANSPORT failure: the request never got an HTTP answer at all.
156
+ *
157
+ * Each host describes one in its own platform's words, and every one of them is a
158
+ * diagnostic dump rather than a message. iOS's JSWorker hands a worker the Swift
159
+ * description of the `NSError` —
160
+ * `Error Domain=NSURLErrorDomain Code=-1004 "Could not connect to the server."
161
+ * UserInfo={… NSErrorFailingURLStringKey=http://127.0.0.1:8178/__dev/wix/proxy, …}`
162
+ * — which Aria painted verbatim: the domain, the code, and the local server's
163
+ * own URL. Android's bridge carries the `java.net` exception text, and a browser's
164
+ * fetch rejects with "Failed to fetch" / "Load failed" / "NetworkError …".
165
+ *
166
+ * Only a message with NO HTTP status qualifies: a status means Wix answered, and
167
+ * that case keeps its own reductions below.
168
+ */
169
+ const TRANSPORT = [
170
+ /\bError Domain=\w+/, // any Cocoa NSError description
171
+ /\bNSURLErrorDomain\b/,
172
+ /\bjava\.(?:net|io)\.\w+(?:Exception|Error)\b/,
173
+ /\b(?:UnknownHost|Connect|SocketTimeout|NoRouteToHost|SSLHandshake)Exception\b/,
174
+ // Android's WebViewJSWorker bridges `e.message ?: e.javaClass.simpleName`, so
175
+ // the class name is usually absent and only OkHttp's own message arrives.
176
+ /^Failed to connect to \S/,
177
+ /^Unable to resolve host\b/,
178
+ /\b(?:Connection refused|Network is unreachable|Software caused connection abort)\b/,
179
+ // OkHttp / HttpURLConnection messages a dropped connection produces mid-request, which
180
+ // Android's bridge forwards without the exception class.
181
+ /\b(?:Connection reset|ECONNRESET|ETIMEDOUT|EPIPE|Broken pipe|stream was reset)\b/,
182
+ /^unexpected end of stream\b/i,
183
+ /^(?:timeout|Read timed out|connect timed out)$/i,
184
+ /\bFailed to fetch\b/,
185
+ /\bNetworkError when attempting to fetch\b/,
186
+ /^(?:TypeError: )?Load failed$/,
187
+ /\bnet::ERR_[A-Z_]+/,
188
+ ];
189
+
190
+ /** Did this failure happen below HTTP — no answer from Wix at all? */
191
+ export function isTransportFailure(e: unknown): boolean {
192
+ const msg = String((e as Error)?.message ?? e ?? "");
193
+ if (STATUS.test(msg)) return false;
194
+ return TRANSPORT.some((re) => re.test(msg));
195
+ }
196
+
197
+ /** A failed READ says nothing about the record's existence when Wix is
198
+ * unreachable, overloaded or failing. Unlike the legacy bridge predicate this
199
+ * is independent of preview mode; a live preview's gateway failure is one too. */
200
+ export function readWasUnreachable(e: unknown): boolean {
201
+ const msg = String((e as Error)?.message ?? e ?? "");
202
+ return isTransportFailure(e) || /HTTP (?:408|429|5\d{2})\b/.test(msg);
203
+ }
204
+
205
+ /** Transport failures that happen while CONNECTING — no connection, no route, no such host,
206
+ * connection refused — so the request provably never left the device. Every other
207
+ * transport failure (a timeout, a reset, a bad or truncated response, a browser's fetch
208
+ * rejection, which says nothing about when it failed) may come after Wix took the write. */
209
+ const NEVER_SENT = [
210
+ /\bNSURLErrorDomain Code=-10(?:09|04|03|06|20|18)\b/, // not connected / cannot connect / no host / DNS / data not allowed / roaming off
211
+ /\b(?:UnknownHost|Connect|NoRouteToHost)Exception\b/,
212
+ /^Failed to connect to \S/,
213
+ /^Unable to resolve host\b/,
214
+ /\b(?:Connection refused|Network is unreachable)\b/,
215
+ /\bnet::ERR_(?:INTERNET_DISCONNECTED|NAME_NOT_RESOLVED|CONNECTION_REFUSED|ADDRESS_UNREACHABLE|NETWORK_ACCESS_DENIED)\b/,
216
+ ];
217
+
218
+ /** No HTTP answer, and the request provably never left the device: the user is offline, and
219
+ * a write certainly did not happen. Anything else may have landed (see `NEVER_SENT`). */
220
+ export function isOfflineFailure(e: unknown): boolean {
221
+ if (!isTransportFailure(e)) return false;
222
+ const msg = String((e as Error)?.message ?? e ?? "");
223
+ return NEVER_SENT.some((re) => re.test(msg));
224
+ }
225
+
226
+ /** The one sentence for a transport failure. Copy-as-configuration: the key is
227
+ * under `ownerCommon.`, which every owner screen (Aria included) forwards. */
228
+ export function ownerTransportFailureText(): string {
229
+ return copyParam(
230
+ "ownerCommon.networkUnreachable",
231
+ "Couldn’t reach Wix. Check your connection and try again.",
232
+ );
233
+ }
234
+
235
+ /**
236
+ * Reduce a wire failure to something a PERSON can read.
237
+ *
238
+ * Wix's edge answers some failures with a full HTML error page rather than JSON
239
+ * — a `403 Error: Forbidden | Wix.com` document. `ownerLoadError` used to return
240
+ * the raw body, so the Orders screen rendered an entire `<!DOCTYPE html>…<head>…`
241
+ * document as its error message (seen on iOS, and the same worker code runs on
242
+ * every surface). Keep the STATUS, which is the diagnosable part, and drop the
243
+ * markup; a long non-HTML body is truncated for the same reason.
244
+ */
245
+ function humanWireError(msg: string): string {
246
+ if (isTransportFailure(msg)) return ownerTransportFailureText();
247
+ const status = STATUS.exec(msg)?.[1];
248
+ // A JSON ENVELOPE or a NAMED REASON CODE is the machine's word for it, and the
249
+ // owner gets neither. Both reductions used to live in `ownerDisplayError` only,
250
+ // on the belief that this function's output was parsed downstream — see the note
251
+ // on `ownerLoadError`, which records why that was wrong and how it was checked.
252
+ if (JSON_BODY.test(msg) && status) {
253
+ return copyParam(
254
+ "ownerCommon.upstreamStatus",
255
+ `Wix returned an error (HTTP ${status}). Try again in a moment.`,
256
+ );
257
+ }
258
+ const coded = CODED.exec(msg);
259
+ if (coded) {
260
+ if (SITE_CONTEXT_CODES.includes(coded[2])) {
261
+ return copyParam(
262
+ "ownerCommon.noSiteContext",
263
+ "This business isn’t available to your Wix account right now. Switch business, or sign in again as its owner.",
264
+ );
265
+ }
266
+ return copyParam(
267
+ "ownerCommon.upstreamStatus",
268
+ `Wix returned an error (HTTP ${coded[1]}). Try again in a moment.`,
269
+ );
270
+ }
271
+ if (HTML_BODY.test(msg)) {
272
+ if (status === "403") {
273
+ return copyParam(
274
+ "ownerCommon.forbidden",
275
+ "This account isn’t permitted to read that. Check the site’s permissions for your Wix user, then try again.",
276
+ );
277
+ }
278
+ return copyParam(
279
+ "ownerCommon.upstreamHtml",
280
+ status ? `Wix returned an error (HTTP ${status}). Try again in a moment.` : "Wix returned an unreadable error. Try again in a moment.",
281
+ );
282
+ }
283
+ return msg.length > MAX_RAW ? `${msg.slice(0, MAX_RAW).trimEnd()}…` : msg;
284
+ }
285
+
286
+ /**
287
+ * The one sentence every owner screen gives for the DESIGNED bridge boundary.
288
+ *
289
+ * Copy-as-configuration (himi-copy): the key lives under the `ownerCommon.`
290
+ * prefix the dev module forwards to all owner screens, so one pack retitles it
291
+ * everywhere. With no pack this returns the stock copy verbatim.
292
+ */
293
+ export function ownerPreviewUnavailable(): string {
294
+ return copyParam(
295
+ "ownerCommon.bridgeUnavailable",
296
+ "Owner data isn’t available in the web preview — open this app on a device (▦ Open on phone) and sign in as the site owner.",
297
+ );
298
+ }
299
+
300
+ /**
301
+ * `ownerLoadError` for a screen that does nothing but RENDER the string.
302
+ *
303
+ * One difference left, and it is about WHERE the app is rather than what the
304
+ * error says: this names the web preview when there IS one. `isBridgeUnavailable`
305
+ * reads "not live" as "preview", and native injects no mode at all, so the
306
+ * default would answer a device-side `HTTP 503` with "open this app on a device"
307
+ * — on the device. That predicate is deliberately left alone: other apps'
308
+ * demo and write-queue decisions depend on exactly it.
309
+ *
310
+ * The reductions this used to add are now in `humanWireError`, so every owner
311
+ * screen gets them rather than the two that happened to call this.
312
+ */
313
+ export function ownerDisplayError(e: unknown): string {
314
+ if (isOwnerPreviewHost() && isBridgeUnavailable(e)) return ownerPreviewUnavailable();
315
+ return humanWireError(String((e as Error)?.message ?? e));
316
+ }
317
+
318
+ /**
319
+ * The default. Reduces the same shapes as `ownerDisplayError`.
320
+ *
321
+ * It used to reduce LESS, on the stated grounds that its output is parsed
322
+ * downstream — "the products worker matches `CATALOG_V3_CALLING_CATALOG_V1_API`
323
+ * in it to choose its v1 fallback". **That was not true**, and it cost 17 owner
324
+ * screens their error copy: they printed `HTTP 428 [MISSING_METASITE_ID]: …`
325
+ * verbatim for a contract that does not exist.
326
+ *
327
+ * Checked before changing it, and worth recording so nobody restores the belief:
328
+ *
329
+ * • the catalog v1/v3 decision is made INSIDE the Wix stores SDK, on the RAW
330
+ * thrown error — its `catalogVersionFromMismatch` reads the error's own
331
+ * `message`, and only that module calls it. It never sees this function's
332
+ * output.
333
+ * • `CATALOG_V3_CALLING_CATALOG_V1_API` appears nowhere else in the codebase
334
+ * outside comments, tests and that one SDK module.
335
+ * • the collection framework's `clearOnError` is handed the RAW error
336
+ * (`error => /401|403|forbidden/i.test(String(error))`), separately from
337
+ * `errorMessage: ownerLoadError` — so the one place that does match on a
338
+ * failure is unaffected by what this returns.
339
+ *
340
+ * So: the raw error remains available to logic everywhere it was; this function's
341
+ * result is display copy, and now reads like it.
342
+ */
343
+ export function ownerLoadError(e: unknown): string {
344
+ const msg = String((e as Error)?.message ?? e);
345
+ // Same host guard as `ownerDisplayError`: on a phone, "open this app on a device" is false.
346
+ if (isOwnerPreviewHost() && isBridgeUnavailable(e)) return ownerPreviewUnavailable();
347
+ return humanWireError(msg);
348
+ }
@@ -0,0 +1,6 @@
1
+ import { copyParam } from "./himi-copy.js";
2
+
3
+ /** No provider or country guess: Wix only established that online payment is unavailable. */
4
+ export function paymentSetupMessage(prefix: string): string {
5
+ return copyParam(`${prefix}.paymentSetupUnavailable`, "This site is not accepting online payments. Go back or contact the business for other payment options.");
6
+ }