@wix/himalaya-cli 0.1588.0 → 0.1590.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/sdk-types/src/capabilities.generated.d.ts +2 -1
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/torch/src/index.d.ts +39 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/webview-page/src/capabilities.generated.d.ts +2 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/define-app.d.ts +13 -2
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/groups/src/extensions.d.ts +28 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/members-v1/src/index.d.ts +9 -5
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/audio-record.mjs +1 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/calendar.mjs +1 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/calls.mjs +1 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/contacts.mjs +1 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/location.mjs +1 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/motion.mjs +1 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/photos.mjs +1 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/sdk-types.mjs +1 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/tasks.mjs +1 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/torch.mjs +78 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-groups.mjs +14 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-members.mjs +9 -8
- package/dist/authoring-runtime/node_modules/@wix/himalaya/package.json +4 -0
- package/dist/cli.mjs +121 -50
- package/dist/preview-assets.generated.bin +0 -0
- package/fixtures/wix-canned.json +16 -0
- package/guides/app-config.md +1 -1
- package/guides/importable-sdks.md +1 -0
- package/guides/one-app-to-himi-app.md +18 -4
- package/guides/qa-builds.md +12 -0
- package/guides/sdk-reference.json +101 -2
- package/guides/web-page-sdk-reference.md +1 -0
- package/package.json +2 -2
- package/templates/beauty-salon/tier5-src/_shared/app-locale.ts +61 -0
- package/templates/beauty-salon/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/beauty-salon/tier5-src/_shared/engine-intl.ts +5 -3
- package/templates/beauty-salon/tier5-src/_shared/group-member-count.ts +15 -0
- package/templates/beauty-salon/tier5-src/_shared/himi-bookings.ts +54 -13
- package/templates/beauty-salon/tier5-src/_shared/himi-copy.ts +10 -2
- package/templates/beauty-salon/tier5-src/_shared/himi-restaurants.ts +2 -2
- package/templates/beauty-salon/tier5-src/_shared/restaurant-ordering.ts +1 -1
- package/templates/beauty-salon/tier5-src/_shared/wix-service-price.ts +2 -2
- package/templates/beauty-salon/tier5-src/himi-booking-services/index.ts +8 -3
- package/templates/beauty-salon/tier5-src/himi-branded-lite-cart/index.ts +1 -1
- package/templates/beauty-salon/tier5-src/himi-branded-lite-home/index.ts +2 -1
- package/templates/beauty-salon/tier5-src/himi-branded-lite-plans/index.ts +1 -1
- package/templates/beauty-salon-owner/tier5-src/_shared/app-locale.ts +61 -0
- package/templates/beauty-salon-owner/tier5-src/_shared/engine-intl.ts +5 -3
- package/templates/beauty-salon-owner/tier5-src/_shared/himi-copy.ts +10 -2
- package/templates/branded-lite/config.ts +7 -0
- package/templates/branded-lite/dev/index.ts +469 -0
- package/templates/branded-lite/himalaya.content.json +3 -0
- package/templates/branded-lite/tier5-src/_shared/app-locale.ts +61 -0
- package/templates/branded-lite/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/branded-lite/tier5-src/_shared/engine-intl.ts +5 -3
- package/templates/branded-lite/tier5-src/_shared/group-member-count.ts +15 -0
- package/templates/branded-lite/tier5-src/_shared/himi-bookings.ts +54 -13
- package/templates/branded-lite/tier5-src/_shared/himi-copy.ts +10 -2
- package/templates/branded-lite/tier5-src/_shared/himi-restaurants.ts +2 -2
- package/templates/branded-lite/tier5-src/_shared/restaurant-ordering.ts +1 -1
- package/templates/branded-lite/tier5-src/_shared/wix-service-price.ts +2 -2
- package/templates/branded-lite/tier5-src/himi-booking-services/index.ts +8 -3
- package/templates/branded-lite/tier5-src/himi-branded-lite-cart/index.ts +1 -1
- package/templates/branded-lite/tier5-src/himi-branded-lite-group/index.ts +9 -3
- package/templates/branded-lite/tier5-src/himi-branded-lite-group-about/index.ts +2 -1
- package/templates/branded-lite/tier5-src/himi-branded-lite-groups/index.ts +6 -2
- package/templates/branded-lite/tier5-src/himi-branded-lite-home/index.ts +2 -1
- package/templates/branded-lite/tier5-src/himi-branded-lite-loyalty/index.ts +3 -2
- package/templates/branded-lite/tier5-src/himi-branded-lite-my-groups/index.ts +237 -0
- package/templates/branded-lite/tier5-src/himi-branded-lite-plans/index.ts +1 -1
- package/templates/branded-lite/tier5-src/himi-branded-lite-program/index.ts +1 -1
- package/templates/branded-lite/tier5-src/himi-events-detail/index.ts +5 -5
- package/templates/branded-lite/tier5-src/wix-spaces-member-detail/index.ts +5 -1
- package/templates/brandy/tier5-src/_shared/app-locale.ts +61 -0
- package/templates/brandy/tier5-src/_shared/himi-copy.ts +10 -2
- package/templates/community-club/config.ts +7 -0
- package/templates/community-club/dev/index.ts +469 -0
- package/templates/community-club/himalaya.content.json +3 -0
- package/templates/community-club/tier5-src/_shared/app-locale.ts +61 -0
- package/templates/community-club/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/community-club/tier5-src/_shared/engine-intl.ts +5 -3
- package/templates/community-club/tier5-src/_shared/group-member-count.ts +15 -0
- package/templates/community-club/tier5-src/_shared/himi-copy.ts +10 -2
- package/templates/community-club/tier5-src/_shared/himi-restaurants.ts +2 -2
- package/templates/community-club/tier5-src/_shared/wix-service-price.ts +2 -2
- package/templates/community-club/tier5-src/himi-branded-lite-group/index.ts +9 -3
- package/templates/community-club/tier5-src/himi-branded-lite-group-about/index.ts +2 -1
- package/templates/community-club/tier5-src/himi-branded-lite-groups/index.ts +6 -2
- package/templates/community-club/tier5-src/himi-branded-lite-home/index.ts +2 -1
- package/templates/community-club/tier5-src/himi-branded-lite-my-groups/index.ts +237 -0
- package/templates/community-club/tier5-src/himi-events-detail/index.ts +5 -5
- package/templates/community-club-owner/tier5-src/_shared/app-locale.ts +61 -0
- package/templates/community-club-owner/tier5-src/_shared/engine-intl.ts +5 -3
- package/templates/community-club-owner/tier5-src/_shared/himi-copy.ts +10 -2
- package/templates/events-meetups/config.ts +7 -0
- package/templates/events-meetups/dev/index.ts +469 -0
- package/templates/events-meetups/himalaya.content.json +3 -0
- package/templates/events-meetups/tier5-src/_shared/app-locale.ts +61 -0
- package/templates/events-meetups/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/events-meetups/tier5-src/_shared/engine-intl.ts +5 -3
- package/templates/events-meetups/tier5-src/_shared/group-member-count.ts +15 -0
- package/templates/events-meetups/tier5-src/_shared/himi-copy.ts +10 -2
- package/templates/events-meetups/tier5-src/_shared/himi-restaurants.ts +2 -2
- package/templates/events-meetups/tier5-src/_shared/wix-service-price.ts +2 -2
- package/templates/events-meetups/tier5-src/himi-branded-lite-group/index.ts +9 -3
- package/templates/events-meetups/tier5-src/himi-branded-lite-group-about/index.ts +2 -1
- package/templates/events-meetups/tier5-src/himi-branded-lite-groups/index.ts +6 -2
- package/templates/events-meetups/tier5-src/himi-branded-lite-home/index.ts +2 -1
- package/templates/events-meetups/tier5-src/himi-branded-lite-my-groups/index.ts +237 -0
- package/templates/events-meetups/tier5-src/himi-events-detail/index.ts +5 -5
- package/templates/events-meetups-owner/tier5-src/_shared/app-locale.ts +61 -0
- package/templates/events-meetups-owner/tier5-src/_shared/engine-intl.ts +5 -3
- package/templates/events-meetups-owner/tier5-src/_shared/himi-copy.ts +10 -2
- package/templates/gym-fitness/tier5-src/_shared/app-locale.ts +61 -0
- package/templates/gym-fitness/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/gym-fitness/tier5-src/_shared/engine-intl.ts +5 -3
- package/templates/gym-fitness/tier5-src/_shared/group-member-count.ts +15 -0
- package/templates/gym-fitness/tier5-src/_shared/himi-bookings.ts +54 -13
- package/templates/gym-fitness/tier5-src/_shared/himi-copy.ts +10 -2
- package/templates/gym-fitness/tier5-src/_shared/himi-restaurants.ts +2 -2
- package/templates/gym-fitness/tier5-src/_shared/restaurant-ordering.ts +1 -1
- package/templates/gym-fitness/tier5-src/_shared/wix-service-price.ts +2 -2
- package/templates/gym-fitness/tier5-src/himi-booking-services/index.ts +8 -3
- package/templates/gym-fitness/tier5-src/himi-branded-lite-cart/index.ts +1 -1
- package/templates/gym-fitness/tier5-src/himi-branded-lite-home/index.ts +2 -1
- package/templates/gym-fitness/tier5-src/himi-branded-lite-plans/index.ts +1 -1
- package/templates/gym-fitness/tier5-src/himi-events-detail/index.ts +5 -5
- package/templates/gym-fitness-owner/tier5-src/_shared/app-locale.ts +61 -0
- package/templates/gym-fitness-owner/tier5-src/_shared/engine-intl.ts +5 -3
- package/templates/gym-fitness-owner/tier5-src/_shared/himi-copy.ts +10 -2
- package/templates/himi-chat/tier5-src/_shared/app-locale.ts +61 -0
- package/templates/himi-chat/tier5-src/_shared/himi-copy.ts +10 -2
- package/templates/nonprofit-cause/config.ts +7 -0
- package/templates/nonprofit-cause/dev/index.ts +469 -0
- package/templates/nonprofit-cause/himalaya.content.json +3 -0
- package/templates/nonprofit-cause/tier5-src/_shared/app-locale.ts +61 -0
- package/templates/nonprofit-cause/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/nonprofit-cause/tier5-src/_shared/engine-intl.ts +5 -3
- package/templates/nonprofit-cause/tier5-src/_shared/group-member-count.ts +15 -0
- package/templates/nonprofit-cause/tier5-src/_shared/himi-copy.ts +10 -2
- package/templates/nonprofit-cause/tier5-src/_shared/himi-restaurants.ts +2 -2
- package/templates/nonprofit-cause/tier5-src/_shared/wix-service-price.ts +2 -2
- package/templates/nonprofit-cause/tier5-src/himi-branded-lite-group/index.ts +9 -3
- package/templates/nonprofit-cause/tier5-src/himi-branded-lite-group-about/index.ts +2 -1
- package/templates/nonprofit-cause/tier5-src/himi-branded-lite-groups/index.ts +6 -2
- package/templates/nonprofit-cause/tier5-src/himi-branded-lite-home/index.ts +2 -1
- package/templates/nonprofit-cause/tier5-src/himi-branded-lite-my-groups/index.ts +237 -0
- package/templates/nonprofit-cause/tier5-src/himi-branded-lite-plans/index.ts +1 -1
- package/templates/nonprofit-cause/tier5-src/himi-events-detail/index.ts +5 -5
- package/templates/nonprofit-cause-owner/tier5-src/_shared/app-locale.ts +61 -0
- package/templates/nonprofit-cause-owner/tier5-src/_shared/engine-intl.ts +5 -3
- package/templates/nonprofit-cause-owner/tier5-src/_shared/himi-copy.ts +10 -2
- package/templates/owner-lite/tier5-src/_shared/app-locale.ts +61 -0
- package/templates/owner-lite/tier5-src/_shared/engine-intl.ts +5 -3
- package/templates/owner-lite/tier5-src/_shared/himi-copy.ts +10 -2
- package/templates/podcast-creator/tier5-src/_shared/app-locale.ts +61 -0
- package/templates/podcast-creator/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/podcast-creator/tier5-src/_shared/engine-intl.ts +5 -3
- package/templates/podcast-creator/tier5-src/_shared/group-member-count.ts +15 -0
- package/templates/podcast-creator/tier5-src/_shared/himi-copy.ts +10 -2
- package/templates/podcast-creator/tier5-src/_shared/himi-restaurants.ts +2 -2
- package/templates/podcast-creator/tier5-src/_shared/wix-service-price.ts +2 -2
- package/templates/podcast-creator/tier5-src/himi-branded-lite-home/index.ts +2 -1
- package/templates/podcast-creator/tier5-src/himi-branded-lite-plans/index.ts +1 -1
- package/templates/podcast-creator/tier5-src/himi-events-detail/index.ts +5 -5
- package/templates/podcast-creator-owner/tier5-src/_shared/app-locale.ts +61 -0
- package/templates/podcast-creator-owner/tier5-src/_shared/engine-intl.ts +5 -3
- package/templates/podcast-creator-owner/tier5-src/_shared/himi-copy.ts +10 -2
- package/templates/portfolio-creative/tier5-src/_shared/app-locale.ts +61 -0
- package/templates/portfolio-creative/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/portfolio-creative/tier5-src/_shared/engine-intl.ts +5 -3
- package/templates/portfolio-creative/tier5-src/_shared/group-member-count.ts +15 -0
- package/templates/portfolio-creative/tier5-src/_shared/himi-copy.ts +10 -2
- package/templates/portfolio-creative/tier5-src/_shared/himi-restaurants.ts +2 -2
- package/templates/portfolio-creative/tier5-src/_shared/restaurant-ordering.ts +1 -1
- package/templates/portfolio-creative/tier5-src/_shared/wix-service-price.ts +2 -2
- package/templates/portfolio-creative/tier5-src/himi-branded-lite-cart/index.ts +1 -1
- package/templates/portfolio-creative/tier5-src/himi-branded-lite-home/index.ts +2 -1
- package/templates/portfolio-creative/tier5-src/himi-events-detail/index.ts +5 -5
- package/templates/portfolio-creative-owner/tier5-src/_shared/app-locale.ts +61 -0
- package/templates/portfolio-creative-owner/tier5-src/_shared/engine-intl.ts +5 -3
- package/templates/portfolio-creative-owner/tier5-src/_shared/himi-copy.ts +10 -2
- package/templates/restaurant/tier5-src/_shared/app-locale.ts +61 -0
- package/templates/restaurant/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/restaurant/tier5-src/_shared/engine-intl.ts +5 -3
- package/templates/restaurant/tier5-src/_shared/group-member-count.ts +15 -0
- package/templates/restaurant/tier5-src/_shared/himi-copy.ts +10 -2
- package/templates/restaurant/tier5-src/_shared/himi-restaurants.ts +2 -2
- package/templates/restaurant/tier5-src/_shared/restaurant-ordering.ts +1 -1
- package/templates/restaurant/tier5-src/_shared/wix-service-price.ts +2 -2
- package/templates/restaurant/tier5-src/himi-branded-lite-home/index.ts +2 -1
- package/templates/restaurant/tier5-src/himi-events-detail/index.ts +5 -5
- package/templates/restaurant-owner/tier5-src/_shared/app-locale.ts +61 -0
- package/templates/restaurant-owner/tier5-src/_shared/engine-intl.ts +5 -3
- package/templates/restaurant-owner/tier5-src/_shared/himi-copy.ts +10 -2
- package/templates/retail-store/tier5-src/_shared/app-locale.ts +61 -0
- package/templates/retail-store/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/retail-store/tier5-src/_shared/engine-intl.ts +5 -3
- package/templates/retail-store/tier5-src/_shared/group-member-count.ts +15 -0
- package/templates/retail-store/tier5-src/_shared/himi-copy.ts +10 -2
- package/templates/retail-store/tier5-src/_shared/himi-restaurants.ts +2 -2
- package/templates/retail-store/tier5-src/_shared/restaurant-ordering.ts +1 -1
- package/templates/retail-store/tier5-src/_shared/wix-service-price.ts +2 -2
- package/templates/retail-store/tier5-src/himi-branded-lite-cart/index.ts +1 -1
- package/templates/retail-store/tier5-src/himi-branded-lite-home/index.ts +2 -1
- package/templates/retail-store-owner/tier5-src/_shared/app-locale.ts +61 -0
- package/templates/retail-store-owner/tier5-src/_shared/engine-intl.ts +5 -3
- package/templates/retail-store-owner/tier5-src/_shared/himi-copy.ts +10 -2
- package/templates/yoga-studio/tier5-src/_shared/app-locale.ts +61 -0
- package/templates/yoga-studio/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/yoga-studio/tier5-src/_shared/engine-intl.ts +5 -3
- package/templates/yoga-studio/tier5-src/_shared/group-member-count.ts +15 -0
- package/templates/yoga-studio/tier5-src/_shared/himi-bookings.ts +54 -13
- package/templates/yoga-studio/tier5-src/_shared/himi-copy.ts +10 -2
- package/templates/yoga-studio/tier5-src/_shared/himi-restaurants.ts +2 -2
- package/templates/yoga-studio/tier5-src/_shared/wix-service-price.ts +2 -2
- package/templates/yoga-studio/tier5-src/himi-booking-services/index.ts +8 -3
- package/templates/yoga-studio/tier5-src/himi-branded-lite-home/index.ts +2 -1
- package/templates/yoga-studio/tier5-src/himi-branded-lite-plans/index.ts +1 -1
- package/templates/yoga-studio/tier5-src/himi-branded-lite-program/index.ts +1 -1
- package/templates/yoga-studio/tier5-src/himi-events-detail/index.ts +5 -5
- package/templates/yoga-studio-owner/tier5-src/_shared/app-locale.ts +61 -0
- package/templates/yoga-studio-owner/tier5-src/_shared/engine-intl.ts +5 -3
- package/templates/yoga-studio-owner/tier5-src/_shared/himi-copy.ts +10 -2
|
@@ -111,7 +111,7 @@ export function formatServiceMoney(money: unknown, fallbackCurrency = "USD"): st
|
|
|
111
111
|
if (amount == null) return "";
|
|
112
112
|
const currency = stringOf(m.currency) || fallbackCurrency;
|
|
113
113
|
try {
|
|
114
|
-
return formatCurrency(amount, currency,
|
|
114
|
+
return formatCurrency(amount, currency, undefined, Number.isInteger(amount) ? 0 : undefined);
|
|
115
115
|
} catch {
|
|
116
116
|
return `${amount}`;
|
|
117
117
|
}
|
|
@@ -139,7 +139,7 @@ export function formatServicePrice(service: unknown): string {
|
|
|
139
139
|
try {
|
|
140
140
|
// A whole amount drops its cents ("₪70", not "₪70.00"), as formatServiceMoney and the service
|
|
141
141
|
// screen show it, so the same service reads the same price on every screen.
|
|
142
|
-
return formatCurrency(amount, currency,
|
|
142
|
+
return formatCurrency(amount, currency, undefined, Number.isInteger(amount) ? 0 : undefined);
|
|
143
143
|
} catch {
|
|
144
144
|
return `${amount}`;
|
|
145
145
|
}
|
|
@@ -7,6 +7,7 @@ import { initials } from "../_shared/himi-text.js";
|
|
|
7
7
|
import { groupImage } from "../_shared/himi-media.js";
|
|
8
8
|
import { WIX_DETAIL_IMAGE } from "@himalaya/wix-media";
|
|
9
9
|
import { copyParam, stashCopy } from "../_shared/himi-copy.js";
|
|
10
|
+
import { cachedMemberCount, groupMemberCount } from "../_shared/group-member-count.js";
|
|
10
11
|
import { get as dbGet, upsert as dbUpsert, type DbEntity } from "@himalaya/local-db";
|
|
11
12
|
import { isOfflineFailure, writeMayHaveLanded } from "../_shared/owner-load-error";
|
|
12
13
|
import { descriptionBody } from "../_shared/rich-body.js";
|
|
@@ -37,7 +38,7 @@ async function cachePutMembership(state: State, joined: boolean): Promise<void>
|
|
|
37
38
|
id: state.id,
|
|
38
39
|
title: state.name,
|
|
39
40
|
subtitle: state.description,
|
|
40
|
-
|
|
41
|
+
count: state.memberTotal,
|
|
41
42
|
image: state.image,
|
|
42
43
|
initial: state.initial,
|
|
43
44
|
hasImage: state.hasImage,
|
|
@@ -58,6 +59,8 @@ interface State {
|
|
|
58
59
|
hasDescriptionJson: boolean;
|
|
59
60
|
privacy: string;
|
|
60
61
|
memberCount: string;
|
|
62
|
+
/** The raw count behind `memberCount`, written to the offline cache instead of the label. */
|
|
63
|
+
memberTotal: number | null;
|
|
61
64
|
/** Member avatar strip (photo or initials per member) — best-effort: the
|
|
62
65
|
* per-group members query + member profile resolution both degrade to an
|
|
63
66
|
* empty (collapsed) strip, never a failed screen. */
|
|
@@ -141,6 +144,7 @@ const initial: State = {
|
|
|
141
144
|
hasDescriptionJson: false,
|
|
142
145
|
privacy: "",
|
|
143
146
|
memberCount: "",
|
|
147
|
+
memberTotal: null,
|
|
144
148
|
memberAvatars: [],
|
|
145
149
|
hasMemberAvatars: false,
|
|
146
150
|
noMemberAvatars: true,
|
|
@@ -198,7 +202,8 @@ const svc = defineService<State, {
|
|
|
198
202
|
descriptionJson: body.bodyJson,
|
|
199
203
|
hasDescriptionJson: body.bodyJson !== "",
|
|
200
204
|
privacy: privacyLabel,
|
|
201
|
-
memberCount: count == null ? "" :
|
|
205
|
+
memberCount: count == null ? "" : groupMemberCount(count),
|
|
206
|
+
memberTotal: count ?? null,
|
|
202
207
|
memberAvatars,
|
|
203
208
|
hasMemberAvatars: memberAvatars.length > 0,
|
|
204
209
|
noMemberAvatars: memberAvatars.length === 0,
|
|
@@ -232,7 +237,8 @@ const svc = defineService<State, {
|
|
|
232
237
|
descriptionJson: body.bodyJson,
|
|
233
238
|
hasDescriptionJson: body.bodyJson !== "",
|
|
234
239
|
privacy: "Unknown",
|
|
235
|
-
memberCount:
|
|
240
|
+
memberCount: cachedMemberCount(cached.count),
|
|
241
|
+
memberTotal: typeof cached.count === "number" ? cached.count : null,
|
|
236
242
|
image,
|
|
237
243
|
initial: (name[0] || "G").toUpperCase(),
|
|
238
244
|
message: "",
|
|
@@ -2,6 +2,7 @@ import { defineService } from "@himalaya/state";
|
|
|
2
2
|
import { getGroup, listRules, type WixGroup, type WixGroupRule } from "@himalaya/wix-groups";
|
|
3
3
|
import { WIX_DETAIL_IMAGE } from "@himalaya/wix-media";
|
|
4
4
|
import { copyParam, stashCopy } from "../_shared/himi-copy.js";
|
|
5
|
+
import { groupMemberCount } from "../_shared/group-member-count.js";
|
|
5
6
|
import { groupImage } from "../_shared/himi-media.js";
|
|
6
7
|
import { descriptionBody } from "../_shared/rich-body.js";
|
|
7
8
|
import { openRichLink } from "../_shared/rich-content-link.js";
|
|
@@ -171,7 +172,7 @@ function groupState(state: State, id: string, group: WixGroup): State {
|
|
|
171
172
|
hasDescriptionJson: body.bodyJson !== "",
|
|
172
173
|
privacy,
|
|
173
174
|
createdDate: created,
|
|
174
|
-
memberCount: typeof count === "number" ?
|
|
175
|
+
memberCount: typeof count === "number" ? groupMemberCount(count) : "",
|
|
175
176
|
image,
|
|
176
177
|
initial: (name[0] || "G").toUpperCase(),
|
|
177
178
|
errorText: "",
|
|
@@ -2,6 +2,7 @@ import { defineService } from "../_shared/collection-service.js";
|
|
|
2
2
|
import { queryGroups, type WixGroup } from "@himalaya/wix-groups";
|
|
3
3
|
import { groupImage } from "../_shared/himi-media.js";
|
|
4
4
|
import { copyParam, stashCopy } from "../_shared/himi-copy.js";
|
|
5
|
+
import { cachedMemberCount, groupMemberCount } from "../_shared/group-member-count.js";
|
|
5
6
|
import { getAll, upsert, type DbEntity } from "@himalaya/local-db";
|
|
6
7
|
import { descriptionBody, ROW_IMAGE_WIDTH } from "../_shared/rich-body.js";
|
|
7
8
|
|
|
@@ -16,7 +17,7 @@ const ENTITY = "groups";
|
|
|
16
17
|
async function readCached(): Promise<GroupRow[]> {
|
|
17
18
|
try {
|
|
18
19
|
const rows = (await getAll(ENTITY)) as unknown as GroupRow[];
|
|
19
|
-
return rows.map((row) => ({ ...row, subtitle: snippet(descriptionBody(row.subtitle).bodyText
|
|
20
|
+
return rows.map((row) => ({ ...row, meta: cachedMemberCount(row.count), subtitle: snippet(descriptionBody(row.subtitle).bodyText
|
|
20
21
|
|| descriptionBody(row.descriptionJson || row.description).bodyText) }));
|
|
21
22
|
} catch { return []; }
|
|
22
23
|
}
|
|
@@ -31,6 +32,8 @@ interface GroupRow {
|
|
|
31
32
|
description: string;
|
|
32
33
|
descriptionJson: string;
|
|
33
34
|
meta: string;
|
|
35
|
+
/** The raw member count, kept so the cached label is rebuilt in the current language. */
|
|
36
|
+
count: number | null;
|
|
34
37
|
image: string;
|
|
35
38
|
initial: string;
|
|
36
39
|
hasImage: boolean;
|
|
@@ -69,7 +72,8 @@ function groupRow(group: WixGroup): GroupRow {
|
|
|
69
72
|
subtitle: snippet(teaser) || snippet(body.bodyText) || copyParam("groups.subtitleFallback", "Join the conversation."),
|
|
70
73
|
description: body.bodyText,
|
|
71
74
|
descriptionJson: body.bodyJson,
|
|
72
|
-
meta: count == null ? "" :
|
|
75
|
+
meta: count == null ? "" : groupMemberCount(count),
|
|
76
|
+
count: count ?? null,
|
|
73
77
|
image,
|
|
74
78
|
initial: (title[0] || "G").toUpperCase(),
|
|
75
79
|
hasImage: Boolean(image),
|
|
@@ -19,6 +19,7 @@ import { formatServicePrice } from "../_shared/wix-service-price.js";
|
|
|
19
19
|
import { cleanTitle, initials } from "../_shared/himi-text.js";
|
|
20
20
|
import { LAYOUT_KEY, type LayoutId, recommendLayout, resolveLayout } from "../_shared/himi-layouts.js";
|
|
21
21
|
import { copyParam, copyTemplate, stashCopy } from "../_shared/himi-copy.js";
|
|
22
|
+
import { groupMemberCount } from "../_shared/group-member-count.js";
|
|
22
23
|
import { parseWeeklyGoal, weeklyGoalProgress, weeklySessionsFromPages } from "../_shared/weekly-goal.js";
|
|
23
24
|
import { isBridgeUnavailable } from "../_shared/owner-load-error.js";
|
|
24
25
|
import { formatDateMedium } from "../_shared/engine-intl.js";
|
|
@@ -736,7 +737,7 @@ function groupCard(group: WixGroup): GroupCard {
|
|
|
736
737
|
return {
|
|
737
738
|
id: group.id || title,
|
|
738
739
|
title,
|
|
739
|
-
meta: count == null ? "" :
|
|
740
|
+
meta: count == null ? "" : groupMemberCount(count),
|
|
740
741
|
image,
|
|
741
742
|
initial: (title[0] || "G").toUpperCase(),
|
|
742
743
|
hasImage: !!image,
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { defineService } from "@himalaya/state";
|
|
2
|
+
import { getGroup, listMemberships, type WixGroup, type WixGroupMembership } from "@himalaya/wix-groups";
|
|
3
|
+
import { getMyMember } from "@himalaya/wix-members";
|
|
4
|
+
import { copyParam, stashCopy } from "../_shared/himi-copy.js";
|
|
5
|
+
import { groupMemberCount } from "../_shared/group-member-count.js";
|
|
6
|
+
import { groupImage } from "../_shared/himi-media.js";
|
|
7
|
+
import { memberAccess, memberAccessDenied, type MemberAccess } from "../_shared/member-access.js";
|
|
8
|
+
|
|
9
|
+
// The groups the SIGNED-IN member belongs to (the One App "My groups" list). Two reads, both as the
|
|
10
|
+
// member: the memberships (GET /social-groups-proxy/members/v2/members/{id}/memberships, which Wix
|
|
11
|
+
// answers for the caller's own id only) and then one getGroup per membership, because a membership
|
|
12
|
+
// carries a group id and a role, never the group's name or cover.
|
|
13
|
+
//
|
|
14
|
+
// A visitor makes ZERO calls: the screen shows its sign-in card. The member id is the current
|
|
15
|
+
// member's (`getMyMember`), never taken from input. Read-only and online-only.
|
|
16
|
+
|
|
17
|
+
type Failure = "forbidden" | "failed";
|
|
18
|
+
|
|
19
|
+
/** Memberships read in one page. A member with more is told so, not shown a silently short list. */
|
|
20
|
+
const PAGE_LIMIT = 50;
|
|
21
|
+
const DETAIL_SCREEN = "himi_group";
|
|
22
|
+
|
|
23
|
+
interface MyGroupRow {
|
|
24
|
+
id: string;
|
|
25
|
+
title: string;
|
|
26
|
+
/** "Admin", "Member" or "Pending request" — the member's standing in the group. */
|
|
27
|
+
statusLabel: string;
|
|
28
|
+
meta: string;
|
|
29
|
+
image: string;
|
|
30
|
+
initial: string;
|
|
31
|
+
hasImage: boolean;
|
|
32
|
+
noImage: boolean;
|
|
33
|
+
detailScreenId: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface State {
|
|
37
|
+
status: "loading" | "ready" | "error" | "signedOut";
|
|
38
|
+
rows: MyGroupRow[];
|
|
39
|
+
emptyText: string;
|
|
40
|
+
errorText: string;
|
|
41
|
+
/** Some groups could not be read, or the member has more than one page: said above the list. */
|
|
42
|
+
partialText: string;
|
|
43
|
+
notLoading: boolean;
|
|
44
|
+
notErrored: boolean;
|
|
45
|
+
noRows: boolean;
|
|
46
|
+
noEmpty: boolean;
|
|
47
|
+
noPartial: boolean;
|
|
48
|
+
needsSignIn: boolean;
|
|
49
|
+
noSignIn: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const initial: State = {
|
|
53
|
+
status: "loading",
|
|
54
|
+
rows: [],
|
|
55
|
+
emptyText: "",
|
|
56
|
+
errorText: "",
|
|
57
|
+
partialText: "",
|
|
58
|
+
notLoading: false,
|
|
59
|
+
notErrored: true,
|
|
60
|
+
noRows: true,
|
|
61
|
+
noEmpty: true,
|
|
62
|
+
noPartial: true,
|
|
63
|
+
needsSignIn: false,
|
|
64
|
+
noSignIn: true,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Bumped by every load, so a response that arrives after a newer load began is dropped. */
|
|
68
|
+
let loadSeq = 0;
|
|
69
|
+
|
|
70
|
+
const signedOutState: State = { ...initial, status: "signedOut", notLoading: true, needsSignIn: true, noSignIn: false };
|
|
71
|
+
|
|
72
|
+
function errorState(failure: Failure): State {
|
|
73
|
+
return {
|
|
74
|
+
...initial,
|
|
75
|
+
status: "error",
|
|
76
|
+
notLoading: true,
|
|
77
|
+
notErrored: false,
|
|
78
|
+
errorText: failure === "forbidden"
|
|
79
|
+
? copyParam("myGroups.forbiddenText", "Your groups are unavailable for this account. Sign in again.")
|
|
80
|
+
: copyParam("myGroups.errorText", "Your groups could not be loaded."),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function failureOf(error: unknown): Failure {
|
|
85
|
+
return memberAccessDenied(error) ? "forbidden" : "failed";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isNotFound(error: unknown): boolean {
|
|
89
|
+
return /HTTP 404\b/.test(String((error as Error)?.message ?? error));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function statusLabelOf(membership: WixGroupMembership): string {
|
|
93
|
+
if ((membership.status ?? "").toUpperCase() === "PENDING") return copyParam("myGroups.status.pending", "Pending request");
|
|
94
|
+
return (membership.role?.value ?? "").toUpperCase() === "ADMIN"
|
|
95
|
+
? copyParam("myGroups.status.admin", "Admin")
|
|
96
|
+
: copyParam("myGroups.status.member", "Member");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function rowOf(group: WixGroup, membership: WixGroupMembership): MyGroupRow {
|
|
100
|
+
const title = (group.name || copyParam("groups.groupFallbackName", "Group")).trim();
|
|
101
|
+
const count = group.membersCount ?? group.memberCount;
|
|
102
|
+
const image = groupImage(group);
|
|
103
|
+
return {
|
|
104
|
+
id: String(membership.groupId),
|
|
105
|
+
title,
|
|
106
|
+
statusLabel: statusLabelOf(membership),
|
|
107
|
+
meta: typeof count === "number" ? groupMemberCount(count) : "",
|
|
108
|
+
image,
|
|
109
|
+
initial: (title[0] || "G").toUpperCase(),
|
|
110
|
+
hasImage: Boolean(image),
|
|
111
|
+
noImage: !image,
|
|
112
|
+
detailScreenId: DETAIL_SCREEN,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** JOINED and PENDING memberships with a group id; an UNKNOWN status is not a membership to show. */
|
|
117
|
+
function shownMemberships(response: unknown): WixGroupMembership[] | undefined {
|
|
118
|
+
const memberships = (response as { memberships?: unknown } | null | undefined)?.memberships;
|
|
119
|
+
if (!Array.isArray(memberships)) return undefined;
|
|
120
|
+
const seen = new Set<string>();
|
|
121
|
+
return (memberships as WixGroupMembership[]).filter((m) => {
|
|
122
|
+
const status = (m?.status ?? "").toUpperCase();
|
|
123
|
+
if (typeof m?.groupId !== "string" || !m.groupId || (status !== "JOINED" && status !== "PENDING") || seen.has(m.groupId)) return false;
|
|
124
|
+
seen.add(m.groupId);
|
|
125
|
+
return true;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function readyState(rows: MyGroupRow[], partialText: string): State {
|
|
130
|
+
return {
|
|
131
|
+
...initial,
|
|
132
|
+
status: "ready",
|
|
133
|
+
rows,
|
|
134
|
+
emptyText: copyParam("myGroups.emptyText", "You haven’t joined any groups yet."),
|
|
135
|
+
partialText,
|
|
136
|
+
notLoading: true,
|
|
137
|
+
noRows: rows.length === 0,
|
|
138
|
+
noEmpty: rows.length > 0,
|
|
139
|
+
noPartial: !partialText,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const sameMember = (a: MemberAccess, b: MemberAccess) => a.email === b.email && a.scope === b.scope;
|
|
144
|
+
|
|
145
|
+
/** Whose groups the rows on screen are. A load for anybody else clears them first. */
|
|
146
|
+
let rowsOwner: MemberAccess | undefined;
|
|
147
|
+
|
|
148
|
+
/** The sign-in card is what the screen shows. A load that finds a member now (they signed in and came back)
|
|
149
|
+
* shows the loading state at once instead of leaving the card up over the network calls. A module flag, not
|
|
150
|
+
* a read of the state: the reload this starts must not see the card again and restart forever. */
|
|
151
|
+
let showingSignIn = false;
|
|
152
|
+
|
|
153
|
+
/** The member changed while this load ran or the rows were on screen: show the loading state at once,
|
|
154
|
+
* so the previous member's group names and standing are never on screen, and load for the current one. */
|
|
155
|
+
function restart(): State {
|
|
156
|
+
rowsOwner = undefined;
|
|
157
|
+
showingSignIn = false;
|
|
158
|
+
loadSeq++;
|
|
159
|
+
void svc.load();
|
|
160
|
+
return { ...initial };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** An error for THIS member, unless a newer load began or the member left meanwhile: then it is stale. */
|
|
164
|
+
async function failureFor(seq: number, access: MemberAccess, failure: Failure): Promise<State | undefined> {
|
|
165
|
+
if (seq !== loadSeq) return undefined;
|
|
166
|
+
const now = await memberAccess();
|
|
167
|
+
// A newer load began while the identity was read: its answer is the fresher one.
|
|
168
|
+
if (seq !== loadSeq) return undefined;
|
|
169
|
+
if (!sameMember(access, now)) return restart();
|
|
170
|
+
return errorState(failure);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function loadOnce(access: MemberAccess): Promise<State | undefined> {
|
|
174
|
+
const seq = ++loadSeq;
|
|
175
|
+
if (!access.email) { showingSignIn = true; return signedOutState; }
|
|
176
|
+
try {
|
|
177
|
+
const me = await getMyMember();
|
|
178
|
+
const memberId = String(me?.member?.id ?? "");
|
|
179
|
+
if (!memberId) return failureFor(seq, access, "failed");
|
|
180
|
+
const response = await listMemberships(memberId, { limit: PAGE_LIMIT });
|
|
181
|
+
const memberships = shownMemberships(response);
|
|
182
|
+
if (!memberships) return failureFor(seq, access, "failed");
|
|
183
|
+
const reads = await Promise.allSettled(memberships.map((m) => getGroup(String(m.groupId))));
|
|
184
|
+
// Another load began, or the member signed out / switched account while these reads were in
|
|
185
|
+
// flight: these rows belong to somebody else now.
|
|
186
|
+
if (seq !== loadSeq) return undefined;
|
|
187
|
+
const now = await memberAccess();
|
|
188
|
+
// Re-checked after the await: a newer load may have committed while the identity was read.
|
|
189
|
+
if (seq !== loadSeq) return undefined;
|
|
190
|
+
if (!sameMember(access, now)) return restart();
|
|
191
|
+
const rows: MyGroupRow[] = [];
|
|
192
|
+
let failed = 0;
|
|
193
|
+
let firstFailure: unknown;
|
|
194
|
+
reads.forEach((read, index) => {
|
|
195
|
+
if (read.status === "fulfilled") {
|
|
196
|
+
// Social Groups wraps the entity as `{ group }`; the typed signature claims a bare group.
|
|
197
|
+
const group = ((read.value as unknown as { group?: WixGroup }).group ?? read.value) as WixGroup;
|
|
198
|
+
if (group && typeof group === "object") { rows.push(rowOf(group, memberships[index])); return; }
|
|
199
|
+
}
|
|
200
|
+
// A group deleted since the membership was listed is gone, not a failed read.
|
|
201
|
+
if (read.status === "rejected" && isNotFound(read.reason)) return;
|
|
202
|
+
failed++;
|
|
203
|
+
firstFailure ??= read.status === "rejected" ? read.reason : undefined;
|
|
204
|
+
});
|
|
205
|
+
// Memberships exist but not one group could be shown: that is unreadable (a pending request on a
|
|
206
|
+
// private group answers 404 until approved) or gone, never "you have no groups".
|
|
207
|
+
if (memberships.length > 0 && rows.length === 0) return errorState(failed > 0 ? failureOf(firstFailure) : "failed");
|
|
208
|
+
// Wix omits `total` when it is too many to count, and says so in `tooManyToCount`.
|
|
209
|
+
const truncated = response.metadata?.tooManyToCount === true || Number(response.metadata?.total ?? 0) > PAGE_LIMIT;
|
|
210
|
+
const partialText = [
|
|
211
|
+
failed > 0 ? copyParam("myGroups.partialText", "Some of your groups could not be loaded.") : "",
|
|
212
|
+
truncated ? copyParam("myGroups.truncatedText", "Showing your first groups only.") : "",
|
|
213
|
+
].filter(Boolean).join(" ");
|
|
214
|
+
rowsOwner = access;
|
|
215
|
+
return readyState(rows, partialText);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
// A request that rejects because the member left is the old member's error, not the current one's.
|
|
218
|
+
return failureFor(seq, access, failureOf(error));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const svc = defineService<State, {
|
|
223
|
+
load: (state: State, arg?: unknown) => Promise<State | undefined>;
|
|
224
|
+
}>("himiBrandedMyGroups", {
|
|
225
|
+
state: initial,
|
|
226
|
+
actions: {
|
|
227
|
+
load: async (_state, arg) => {
|
|
228
|
+
stashCopy(arg);
|
|
229
|
+
const access = await memberAccess();
|
|
230
|
+
if ((rowsOwner && !sameMember(rowsOwner, access)) || (showingSignIn && access.email)) return restart();
|
|
231
|
+
return loadOnce(access);
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
svc.load();
|
|
237
|
+
export { svc };
|
|
@@ -243,7 +243,7 @@ function tierPrice(t: WixAvailableTicket): string {
|
|
|
243
243
|
// as "25" — a price that reads as a quantity, and nothing downstream could tell it was a
|
|
244
244
|
// formatting failure. `formatCurrency` uses real Intl where it exists and a symbol-carrying
|
|
245
245
|
// fallback where it does not, diffed against ECMA-402 in engine-intl-fallback.test.ts.
|
|
246
|
-
return formatCurrency(Number(raw), t.price?.currency || "USD"
|
|
246
|
+
return formatCurrency(Number(raw), t.price?.currency || "USD");
|
|
247
247
|
}
|
|
248
248
|
|
|
249
249
|
function tierAvailable(t: WixAvailableTicket): boolean {
|
|
@@ -286,7 +286,7 @@ function tierRows(defs: WixAvailableTicket[], selectedId: string): TicketTierRow
|
|
|
286
286
|
priceAmount: Number(t.price?.amount ?? 0),
|
|
287
287
|
...(isGuestPrice ? { priceLine: detailCopy("event.payWhatYouWant", "Pay what you want") } : {}),
|
|
288
288
|
...(isGuestPrice ? { priceAmount: minPrice, isGuestPrice: true } : {}),
|
|
289
|
-
...(isGuestPrice ? { minimumPriceLabel: detailCopy("event.minPrice", "Minimum {price}").replace("{price}", formatCurrency(minPrice, t.price?.currency || "USD"
|
|
289
|
+
...(isGuestPrice ? { minimumPriceLabel: detailCopy("event.minPrice", "Minimum {price}").replace("{price}", formatCurrency(minPrice, t.price?.currency || "USD")) } : {}),
|
|
290
290
|
};
|
|
291
291
|
});
|
|
292
292
|
}
|
|
@@ -399,7 +399,7 @@ function derive(s: S): S {
|
|
|
399
399
|
noFooterCaption: !s.footerCaption,
|
|
400
400
|
hideFooter: s.status !== "ready" || s.errored || !s.footerLabel || s.rsvpState !== "none",
|
|
401
401
|
checkoutDisabled: count === 0 || count > s.ticketLimitPerOrder || ticketCheckoutInFlight || !ticketCheckoutAllowed || s.ticketTiers.some((row) => row.isGuestPrice && (row.quantity ?? 0) > 0 && (!String(row.guestPrice ?? "").trim() || Number(row.guestPrice) < (row.minPrice ?? 0) || !Number.isFinite(Number(row.guestPrice)))) || s.ticketTiers.some((row) => (row.quantity ?? 0) > (row.maxQuantity ?? 99)),
|
|
402
|
-
ticketTotal: formatCurrency(total, s.ticketCurrency || "USD"
|
|
402
|
+
ticketTotal: formatCurrency(total, s.ticketCurrency || "USD"),
|
|
403
403
|
ticketCountLabel: copyParam("event.ticketCount", "{n} tickets, plus fees").replace("{n}", String(count)),
|
|
404
404
|
noSchedule: s.schedule.length === 0,
|
|
405
405
|
noFullSchedule: s.fullSchedule.length === 0,
|
|
@@ -494,7 +494,7 @@ function detailMoney(money?: EventDetailMoney): string {
|
|
|
494
494
|
if (money?.formattedValue) return money.formattedValue;
|
|
495
495
|
const value = money?.value;
|
|
496
496
|
if (value == null || String(value).trim() === "" || !money?.currency || !Number.isFinite(Number(value))) return "";
|
|
497
|
-
return formatCurrency(Number(value), money.currency
|
|
497
|
+
return formatCurrency(Number(value), money.currency);
|
|
498
498
|
}
|
|
499
499
|
|
|
500
500
|
function detailPlace(e: WixEvent): { value: string; mapUrl: string; hideMapTap: boolean; icon: string } {
|
|
@@ -617,7 +617,7 @@ const svc: ReturnType<typeof defineService<S, DetailActions>> = defineService<S,
|
|
|
617
617
|
ticketSheetOpen: false,
|
|
618
618
|
rsvpActionsOpen: false,
|
|
619
619
|
cancelConfirmOpen: false,
|
|
620
|
-
ticketTotal: formatCurrency(0, "USD"
|
|
620
|
+
ticketTotal: formatCurrency(0, "USD"),
|
|
621
621
|
ticketCountLabel: copyParam("event.ticketCount", "{n} tickets, plus fees").replace("{n}", "0"),
|
|
622
622
|
checkoutDisabled: true,
|
|
623
623
|
schedule: [],
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// The language a worker formats numbers, money and plurals in, and the business's time zone. A worker has no host locale to read,
|
|
2
|
+
// so the screen's `onAppear` hands it over: `appear()` adds `locale` to the load params when the app
|
|
3
|
+
// pins its language, and `stashCopy` stores it here. Absent, every formatter stays on "en" — the
|
|
4
|
+
// house pattern every English app was approved with — so an unpinned app is unchanged.
|
|
5
|
+
//
|
|
6
|
+
// The business time zone comes the same way, from the app's provisioning (`businessTimeZone`, collected
|
|
7
|
+
// at migration time while the site's properties are readable): a worker cannot read it itself, because
|
|
8
|
+
// the site-properties route is owner-scoped. Absent, it is "" and no worker guesses one.
|
|
9
|
+
//
|
|
10
|
+
// Pure, no host globals: bundled into every worker that formats, copied into generated templates.
|
|
11
|
+
|
|
12
|
+
const TAG = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/;
|
|
13
|
+
let tag = "en";
|
|
14
|
+
|
|
15
|
+
/** Set from a load argument's `locale`; anything that is not a language tag resets to "en". */
|
|
16
|
+
export function setAppLocale(value: unknown): void {
|
|
17
|
+
tag = typeof value === "string" && TAG.test(value) ? value : "en";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function appLocale(): string {
|
|
21
|
+
return tag;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** An IANA zone such as "Asia/Jerusalem" (or "UTC") that this engine knows. A bare offset, a lowercase
|
|
25
|
+
* alias or an engine without ECMA-402 is not one: a zone is never guessed. */
|
|
26
|
+
export function isIanaTimeZone(value: unknown): value is string {
|
|
27
|
+
if (typeof value !== "string" || !/^(?:UTC|[A-Za-z]+(?:\/[A-Za-z0-9_+-]+)+)$/.test(value)) return false;
|
|
28
|
+
// An engine without ECMA-402 has no zone database: nothing it holds is a zone it knows.
|
|
29
|
+
if (typeof Intl === "object" && typeof Intl.DateTimeFormat === "function") {
|
|
30
|
+
try {
|
|
31
|
+
new Intl.DateTimeFormat("en-US", { timeZone: value });
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let zone = "";
|
|
41
|
+
|
|
42
|
+
/** Set from a load argument's `businessTimeZone`; anything that is not an IANA zone clears it. */
|
|
43
|
+
export function setBusinessTimeZone(value: unknown): void {
|
|
44
|
+
zone = isIanaTimeZone(value) ? value : "";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The business's IANA zone, or "" when the app has none provisioned. */
|
|
48
|
+
export function businessTimeZone(): string {
|
|
49
|
+
return zone;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Does `count` take the singular form in the app language? CLDR plural rules, so French "0 point"
|
|
53
|
+
* and "1,5 point" are singular and Hebrew "2" is not. An engine without PluralRules keeps the
|
|
54
|
+
* English rule (exactly one). */
|
|
55
|
+
export function isSingular(count: number, locale = appLocale()): boolean {
|
|
56
|
+
try {
|
|
57
|
+
// An engine without ECMA-402 has no `Intl` at all: it keeps the English rule.
|
|
58
|
+
if (typeof Intl === "object" && typeof Intl.PluralRules === "function") return new Intl.PluralRules(locale).select(count) === "one";
|
|
59
|
+
} catch { /* malformed tag — use the English rule */ }
|
|
60
|
+
return count === 1;
|
|
61
|
+
}
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
// These helpers are the sanctioned way to format across the whole fleet: real ECMA-402 where it
|
|
21
21
|
// exists, a deterministic en-US-shaped fallback where it does not, and never a throw.
|
|
22
22
|
|
|
23
|
+
import { appLocale } from "./app-locale.js";
|
|
24
|
+
|
|
23
25
|
/** Is ECMA-402 actually present in THIS engine? Cheap, and safe in a realm without `Intl`. */
|
|
24
26
|
export function hasIntl(): boolean {
|
|
25
27
|
return typeof Intl !== "undefined";
|
|
@@ -189,7 +191,7 @@ const CURRENCY_DIGITS: Record<string, number> = {
|
|
|
189
191
|
export function formatCurrency(
|
|
190
192
|
amount: number,
|
|
191
193
|
currency = "USD",
|
|
192
|
-
locale =
|
|
194
|
+
locale = appLocale(),
|
|
193
195
|
/** Omit for the currency's natural digits. Pass 0 to render a whole amount as "$25" rather
|
|
194
196
|
* than "$25.00" — Intl clamps the minimum down to match, so max<min does not throw here. */
|
|
195
197
|
maximumFractionDigits?: number,
|
|
@@ -258,7 +260,7 @@ function groupThousands(fixed: string): string {
|
|
|
258
260
|
* inserted commas into the exponent — `1e21` came out as "1e,+21" and `1e-7` as "1,e-7".
|
|
259
261
|
* Garbage, and silently so, because nothing in the corpus's tidy test numbers reached it.
|
|
260
262
|
*/
|
|
261
|
-
export function formatNumber(value: number, locale =
|
|
263
|
+
export function formatNumber(value: number, locale = appLocale()): string {
|
|
262
264
|
if (!Number.isFinite(value)) return "";
|
|
263
265
|
if (hasIntl()) return new Intl.NumberFormat(locale).format(value);
|
|
264
266
|
// `toFixed` itself returns exponential notation at >=1e21, so anything that big keeps the
|
|
@@ -409,7 +411,7 @@ export function compareDateDesc(a: string, b: string): number {
|
|
|
409
411
|
// Customers read that as sloppiness, and a whole "₪70" beside "₪69.50" reads as a different
|
|
410
412
|
// kind of number. So: one money pattern, one clock, one range.
|
|
411
413
|
|
|
412
|
-
// Money uses `formatCurrency(amount, currency
|
|
414
|
+
// Money uses `formatCurrency(amount, currency)` — English unless the app pins another language, see app-locale — the currency's NATURAL decimals
|
|
413
415
|
// ("₪70.00", "¥500") — for Shop, Menu and every other price. The one exception is a Bookings
|
|
414
416
|
// SERVICE price (`_shared/wix-service-price.ts`): it drops the cents of a whole amount ("₪70",
|
|
415
417
|
// "₪69.50"), the format the redesigned service screen and services list were approved with. It
|
|
@@ -12,10 +12,12 @@
|
|
|
12
12
|
// and every read returns `fallback` verbatim — routing a worker string through
|
|
13
13
|
// copyParam is byte-for-byte behavior-preserving.
|
|
14
14
|
//
|
|
15
|
-
// Pure + Hermes-safe (no host globals, no Node APIs
|
|
15
|
+
// Pure + Hermes-safe (no host globals, no Node APIs; its only import is the pure `app-locale` sibling): bundled into
|
|
16
16
|
// every worker that emits user-facing copy, and copied verbatim into the
|
|
17
17
|
// generated templates' tier5-src/_shared/.
|
|
18
18
|
|
|
19
|
+
import { isSingular, setAppLocale, setBusinessTimeZone } from "./app-locale.js";
|
|
20
|
+
|
|
19
21
|
/** Customer strings override every read; translated stock is valid only for its
|
|
20
22
|
* original English fallback. Some existing keys serve more than one message. */
|
|
21
23
|
export type CopyPackValue = string | { source: string; translation: string };
|
|
@@ -29,6 +31,12 @@ let copyReceived = false;
|
|
|
29
31
|
* stock fallbacks keep rendering. */
|
|
30
32
|
export function stashCopy(arg: unknown): void {
|
|
31
33
|
if (!arg || typeof arg !== "object") return;
|
|
34
|
+
// A param-carrying dispatch names the app language and business zone too (absent: the English house
|
|
35
|
+
// pattern, and no zone).
|
|
36
|
+
if ("copy" in arg || "locale" in arg || "businessTimeZone" in arg) {
|
|
37
|
+
setAppLocale((arg as Record<string, unknown>).locale);
|
|
38
|
+
setBusinessTimeZone((arg as Record<string, unknown>).businessTimeZone);
|
|
39
|
+
}
|
|
32
40
|
const raw = (arg as Record<string, unknown>).copy;
|
|
33
41
|
if (typeof raw !== "string" || !raw) return;
|
|
34
42
|
try {
|
|
@@ -77,5 +85,5 @@ export function copyPlural(key: string, fallback: string, count: number): string
|
|
|
77
85
|
const pattern = copyParam(key, fallback);
|
|
78
86
|
const match = /^\{n, plural, one \{([^{}]*)\} other \{([^{}]*)\}\}$/.exec(pattern);
|
|
79
87
|
if (!match) return copyTemplate(key, fallback, { n: count });
|
|
80
|
-
return (count
|
|
88
|
+
return (isSingular(count) ? match[1] : match[2])!.replace(/#/g, String(count));
|
|
81
89
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// The language a worker formats numbers, money and plurals in, and the business's time zone. A worker has no host locale to read,
|
|
2
|
+
// so the screen's `onAppear` hands it over: `appear()` adds `locale` to the load params when the app
|
|
3
|
+
// pins its language, and `stashCopy` stores it here. Absent, every formatter stays on "en" — the
|
|
4
|
+
// house pattern every English app was approved with — so an unpinned app is unchanged.
|
|
5
|
+
//
|
|
6
|
+
// The business time zone comes the same way, from the app's provisioning (`businessTimeZone`, collected
|
|
7
|
+
// at migration time while the site's properties are readable): a worker cannot read it itself, because
|
|
8
|
+
// the site-properties route is owner-scoped. Absent, it is "" and no worker guesses one.
|
|
9
|
+
//
|
|
10
|
+
// Pure, no host globals: bundled into every worker that formats, copied into generated templates.
|
|
11
|
+
|
|
12
|
+
const TAG = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/;
|
|
13
|
+
let tag = "en";
|
|
14
|
+
|
|
15
|
+
/** Set from a load argument's `locale`; anything that is not a language tag resets to "en". */
|
|
16
|
+
export function setAppLocale(value: unknown): void {
|
|
17
|
+
tag = typeof value === "string" && TAG.test(value) ? value : "en";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function appLocale(): string {
|
|
21
|
+
return tag;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** An IANA zone such as "Asia/Jerusalem" (or "UTC") that this engine knows. A bare offset, a lowercase
|
|
25
|
+
* alias or an engine without ECMA-402 is not one: a zone is never guessed. */
|
|
26
|
+
export function isIanaTimeZone(value: unknown): value is string {
|
|
27
|
+
if (typeof value !== "string" || !/^(?:UTC|[A-Za-z]+(?:\/[A-Za-z0-9_+-]+)+)$/.test(value)) return false;
|
|
28
|
+
// An engine without ECMA-402 has no zone database: nothing it holds is a zone it knows.
|
|
29
|
+
if (typeof Intl === "object" && typeof Intl.DateTimeFormat === "function") {
|
|
30
|
+
try {
|
|
31
|
+
new Intl.DateTimeFormat("en-US", { timeZone: value });
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let zone = "";
|
|
41
|
+
|
|
42
|
+
/** Set from a load argument's `businessTimeZone`; anything that is not an IANA zone clears it. */
|
|
43
|
+
export function setBusinessTimeZone(value: unknown): void {
|
|
44
|
+
zone = isIanaTimeZone(value) ? value : "";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The business's IANA zone, or "" when the app has none provisioned. */
|
|
48
|
+
export function businessTimeZone(): string {
|
|
49
|
+
return zone;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Does `count` take the singular form in the app language? CLDR plural rules, so French "0 point"
|
|
53
|
+
* and "1,5 point" are singular and Hebrew "2" is not. An engine without PluralRules keeps the
|
|
54
|
+
* English rule (exactly one). */
|
|
55
|
+
export function isSingular(count: number, locale = appLocale()): boolean {
|
|
56
|
+
try {
|
|
57
|
+
// An engine without ECMA-402 has no `Intl` at all: it keeps the English rule.
|
|
58
|
+
if (typeof Intl === "object" && typeof Intl.PluralRules === "function") return new Intl.PluralRules(locale).select(count) === "one";
|
|
59
|
+
} catch { /* malformed tag — use the English rule */ }
|
|
60
|
+
return count === 1;
|
|
61
|
+
}
|
|
@@ -98,6 +98,7 @@ export const HOSTED_SCREEN_VERTICALS: Readonly<Record<string, readonly BrandedLi
|
|
|
98
98
|
himi_gift_cards: ["giftCards"],
|
|
99
99
|
himi_groups: ["groups"],
|
|
100
100
|
himi_group_about: ["groups"],
|
|
101
|
+
himi_my_groups: ["groups"],
|
|
101
102
|
himi_invoice: ["invoices"],
|
|
102
103
|
himi_invoices: ["invoices"],
|
|
103
104
|
himi_loyalty: ["loyalty"],
|