@wix/himalaya-cli 0.1498.0 → 0.1500.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/stdlib/wix-sdk/stores-collections/src/display.d.ts +1 -1
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/stores-collections/src/index.d.ts +2 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/stores-v1/src/display.d.ts +4 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-stores-collections.mjs +2 -2
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-stores.mjs +3 -2
- package/dist/cli.mjs +30 -31
- package/dist/preview-assets.generated.bin +0 -0
- package/guides/trim-a-template.md +1 -1
- package/package.json +2 -2
- package/templates/beauty-salon/dev/index.ts +2465 -2035
- package/templates/beauty-salon/tier5-src/_shared/himi-store-categories.ts +102 -0
- package/templates/beauty-salon/tier5-src/_shared/himi-store-facets.ts +478 -0
- package/templates/beauty-salon/tier5-src/_shared/iap-presentation.ts +158 -15
- package/templates/beauty-salon/tier5-src/himi-branded-lite-home/index.ts +42 -2
- package/templates/beauty-salon/tier5-src/himi-branded-lite-shop/index.ts +691 -0
- package/templates/beauty-salon/tier5-src/himi-store-product/index.ts +2 -1
- package/templates/beauty-salon/tier5-src/himi-store-saved/index.ts +1 -0
- package/templates/branded-lite/dev/index.ts +2593 -2163
- package/templates/branded-lite/tier5-src/_shared/himi-store-categories.ts +102 -0
- package/templates/branded-lite/tier5-src/_shared/himi-store-facets.ts +478 -0
- package/templates/branded-lite/tier5-src/_shared/iap-presentation.ts +158 -15
- package/templates/branded-lite/tier5-src/himi-branded-lite-home/index.ts +42 -2
- package/templates/branded-lite/tier5-src/himi-branded-lite-shop/index.ts +691 -0
- package/templates/branded-lite/tier5-src/himi-store-product/index.ts +2 -1
- package/templates/branded-lite/tier5-src/himi-store-saved/index.ts +1 -0
- package/templates/community-club/tier5-src/_shared/himi-store-categories.ts +102 -0
- package/templates/community-club/tier5-src/_shared/iap-presentation.ts +158 -15
- package/templates/community-club/tier5-src/himi-branded-lite-home/index.ts +42 -2
- package/templates/events-meetups/tier5-src/_shared/himi-store-categories.ts +102 -0
- package/templates/events-meetups/tier5-src/_shared/iap-presentation.ts +158 -15
- package/templates/events-meetups/tier5-src/himi-branded-lite-home/index.ts +42 -2
- package/templates/gym-fitness/dev/index.ts +2600 -2170
- package/templates/gym-fitness/tier5-src/_shared/himi-store-categories.ts +102 -0
- package/templates/gym-fitness/tier5-src/_shared/himi-store-facets.ts +478 -0
- package/templates/gym-fitness/tier5-src/_shared/iap-presentation.ts +158 -15
- package/templates/gym-fitness/tier5-src/himi-branded-lite-home/index.ts +42 -2
- package/templates/gym-fitness/tier5-src/himi-branded-lite-shop/index.ts +691 -0
- package/templates/gym-fitness/tier5-src/himi-store-product/index.ts +2 -1
- package/templates/gym-fitness/tier5-src/himi-store-saved/index.ts +1 -0
- package/templates/nonprofit-cause/tier5-src/_shared/himi-store-categories.ts +102 -0
- package/templates/nonprofit-cause/tier5-src/_shared/iap-presentation.ts +158 -15
- package/templates/nonprofit-cause/tier5-src/himi-branded-lite-home/index.ts +42 -2
- package/templates/podcast-creator/tier5-src/_shared/himi-store-categories.ts +102 -0
- package/templates/podcast-creator/tier5-src/_shared/iap-presentation.ts +158 -15
- package/templates/podcast-creator/tier5-src/himi-branded-lite-home/index.ts +42 -2
- package/templates/portfolio-creative/dev/index.ts +2470 -2040
- package/templates/portfolio-creative/tier5-src/_shared/himi-store-categories.ts +102 -0
- package/templates/portfolio-creative/tier5-src/_shared/himi-store-facets.ts +478 -0
- package/templates/portfolio-creative/tier5-src/_shared/iap-presentation.ts +158 -15
- package/templates/portfolio-creative/tier5-src/himi-branded-lite-home/index.ts +42 -2
- package/templates/portfolio-creative/tier5-src/himi-branded-lite-shop/index.ts +691 -0
- package/templates/portfolio-creative/tier5-src/himi-store-product/index.ts +2 -1
- package/templates/portfolio-creative/tier5-src/himi-store-saved/index.ts +1 -0
- package/templates/restaurant/tier5-src/_shared/himi-store-categories.ts +102 -0
- package/templates/restaurant/tier5-src/_shared/iap-presentation.ts +158 -15
- package/templates/restaurant/tier5-src/himi-branded-lite-home/index.ts +42 -2
- package/templates/retail-store/dev/index.ts +2583 -2137
- package/templates/retail-store/tier5-src/_shared/himi-store-categories.ts +102 -0
- package/templates/retail-store/tier5-src/_shared/himi-store-facets.ts +478 -0
- package/templates/retail-store/tier5-src/_shared/iap-presentation.ts +158 -15
- package/templates/retail-store/tier5-src/himi-branded-lite-home/index.ts +42 -2
- package/templates/retail-store/tier5-src/himi-branded-lite-shop/index.ts +691 -0
- package/templates/retail-store/tier5-src/himi-store-product/index.ts +2 -1
- package/templates/retail-store/tier5-src/himi-store-saved/index.ts +1 -0
- package/templates/yoga-studio/tier5-src/_shared/himi-store-categories.ts +102 -0
- package/templates/yoga-studio/tier5-src/_shared/iap-presentation.ts +158 -15
- package/templates/yoga-studio/tier5-src/himi-branded-lite-home/index.ts +42 -2
- package/templates/beauty-salon/tier5-src/_shared/himi-store-collection.ts +0 -22
- package/templates/beauty-salon/tier5-src/himi-store-shop/index.ts +0 -173
- package/templates/branded-lite/tier5-src/_shared/himi-store-collection.ts +0 -22
- package/templates/branded-lite/tier5-src/himi-store-shop/index.ts +0 -173
- package/templates/gym-fitness/tier5-src/_shared/himi-store-collection.ts +0 -22
- package/templates/gym-fitness/tier5-src/himi-store-shop/index.ts +0 -173
- package/templates/portfolio-creative/tier5-src/_shared/himi-store-collection.ts +0 -22
- package/templates/portfolio-creative/tier5-src/himi-store-shop/index.ts +0 -173
- package/templates/retail-store/tier5-src/_shared/himi-store-collection.ts +0 -22
- package/templates/retail-store/tier5-src/himi-store-shop/index.ts +0 -173
|
@@ -442,7 +442,7 @@ let checkoutEntered = false;
|
|
|
442
442
|
const defineService = withIapPresentation<S>({
|
|
443
443
|
adapters: SELLS_THROUGH_APPLE ? [storesProductOrders] : [],
|
|
444
444
|
onCreate: commerce => { productPricing = commerce; }, prefix: "product",
|
|
445
|
-
transientKeys: ["price", "optionsIncomplete", "cartHint", "cartHidden", "buyHidden", "buyLabel", "buyDisabled", "buyNote", "purchasedNote", "addedSheetPresented", "shareHint"], contentActions: ["load", "paintCached"],
|
|
445
|
+
transientKeys: ["price", "optionsIncomplete", "cartHint", "cartHidden", "buyHidden", "buyLabel", "buyDisabled", "buyNote", "purchasedNote", "addedSheetPresented", "shareHint"], contentActions: ["load", "paintCached"], concurrentPublishActions: ["buy"],
|
|
446
446
|
items: state => inputId() ? [{ reference: { kind: "storesProduct", id: inputId() }, commerceDisplayPrice: state.price }] : [],
|
|
447
447
|
present: (state, price, allowed) => {
|
|
448
448
|
const purchase = applePurchase(state);
|
|
@@ -885,5 +885,6 @@ const svc = defineService<S, {
|
|
|
885
885
|
},
|
|
886
886
|
});
|
|
887
887
|
|
|
888
|
+
// The boot sweep loads the bound product; root onAppear refreshes it when the screen returns.
|
|
888
889
|
svc.load();
|
|
889
890
|
export { svc };
|
|
@@ -36,6 +36,7 @@ function failed(s: S): S {
|
|
|
36
36
|
return shape({ ...s, status: "error", error: copyParam("savedProducts.error", "Could not update your wishlist. Try again.") });
|
|
37
37
|
}
|
|
38
38
|
const defineService = withIapPresentation<S>({
|
|
39
|
+
forcePriceRefreshActions: ["load", "remove"],
|
|
39
40
|
prefix: "savedProducts", transientKeys: ["rows"], contentActions: ["load", "remove"],
|
|
40
41
|
items: state => state.rows.map(row => ({ reference: { kind: "storesProduct", id: row.id }, commerceDisplayPrice: row.commercePrice ?? row.price })),
|
|
41
42
|
present: (state, price) => ({ ...state, rows: state.rows.map(row => {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Store categories for the Branded Lite Shop chip row and the Home "Shop by category"
|
|
2
|
+
// rail — one reader, so both list the same categories in the same order.
|
|
3
|
+
//
|
|
4
|
+
// Catalog V3 Categories first, V1 collections only when V3 THROWS (a legacy V1 site):
|
|
5
|
+
// a V3 success with zero rows is an empty catalogue, and V1 on a V3 site answers 428.
|
|
6
|
+
// Same rule as wix-spaces-collections; see the reasoning there.
|
|
7
|
+
import { categoryImage, queryCollections, queryStoreCategories, type StoreCategoriesResponse, type StoreCategory, type WixCollection } from "@himalaya/wix-stores-collections";
|
|
8
|
+
import { wixMediaUrl } from "@himalaya/wix-media";
|
|
9
|
+
|
|
10
|
+
export interface StoreCategoryCard {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
image: string;
|
|
14
|
+
hasImage: boolean;
|
|
15
|
+
noImage: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** "All Products" is a Wix system collection — every product is in it, so it is
|
|
19
|
+
* the Shop's own "All" chip, not a category a shopper picks. */
|
|
20
|
+
const SYSTEM_NAMES = ["all products"];
|
|
21
|
+
const SYSTEM_COLLECTION_IDS = ["00000000-000000-000000-000000000001"];
|
|
22
|
+
|
|
23
|
+
function card(id: string, name: string, image: string): StoreCategoryCard {
|
|
24
|
+
return { id, name, image, hasImage: !!image, noImage: !image };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function fromCategory(c: StoreCategory): StoreCategoryCard {
|
|
28
|
+
return card(c.id ?? "", (c.name ?? "").trim(), categoryImage(c));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function fromCollection(c: WixCollection): StoreCategoryCard {
|
|
32
|
+
const raw = c as unknown as { id?: string; name?: string; media?: { mainMedia?: { image?: { url?: string }; thumbnail?: { url?: string } } } };
|
|
33
|
+
const media = raw.media?.mainMedia;
|
|
34
|
+
return card(raw.id ?? "", (raw.name ?? "").trim(), wixMediaUrl(media?.image?.url ?? media?.thumbnail?.url ?? ""));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function visible(rows: StoreCategoryCard[]): StoreCategoryCard[] {
|
|
38
|
+
const seen = new Set<string>();
|
|
39
|
+
return rows.filter((r) => {
|
|
40
|
+
if (!r.id || !r.name || SYSTEM_COLLECTION_IDS.includes(r.id) || SYSTEM_NAMES.includes(r.name.toLowerCase()) || seen.has(r.id)) return false;
|
|
41
|
+
seen.add(r.id);
|
|
42
|
+
return true;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const PAGE_SIZE = 50;
|
|
47
|
+
const MAX_PAGES = 100;
|
|
48
|
+
|
|
49
|
+
async function loadLegacyCategories(): Promise<StoreCategoryCard[]> {
|
|
50
|
+
const cards: StoreCategoryCard[] = [];
|
|
51
|
+
const seen = new Set<string>();
|
|
52
|
+
let offset = 0;
|
|
53
|
+
let total: number | undefined;
|
|
54
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
55
|
+
const r = await queryCollections({ query: { paging: { limit: PAGE_SIZE, offset } } });
|
|
56
|
+
const rows = r.collections ?? [];
|
|
57
|
+
const reportedTotal = r.totalResults ?? r.metadata?.total;
|
|
58
|
+
if (reportedTotal != null) total = Math.max(total ?? 0, reportedTotal);
|
|
59
|
+
if (!rows.length) {
|
|
60
|
+
if (total != null && seen.size < total) throw new Error("Category pagination is incomplete");
|
|
61
|
+
return visible(cards);
|
|
62
|
+
}
|
|
63
|
+
const priorCount = seen.size;
|
|
64
|
+
rows.forEach(row => { if (row.id) seen.add(row.id); });
|
|
65
|
+
if (page > 0 && seen.size === priorCount) throw new Error("Category pagination made no progress");
|
|
66
|
+
cards.push(...rows.filter(c => c.visible !== false).map(fromCollection));
|
|
67
|
+
offset += rows.length;
|
|
68
|
+
if (total != null ? offset >= total : rows.length < PAGE_SIZE) {
|
|
69
|
+
if (total != null && seen.size < total) throw new Error("Category pagination is incomplete");
|
|
70
|
+
return visible(cards);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
throw new Error("Category pagination exceeded the page limit");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The store's browsable categories in site order. Rejects incomplete reads so callers
|
|
77
|
+
* can retain their last good catalog instead of invalidating a later category. */
|
|
78
|
+
export async function loadStoreCategories(): Promise<StoreCategoryCard[]> {
|
|
79
|
+
let r: StoreCategoriesResponse;
|
|
80
|
+
try {
|
|
81
|
+
r = await queryStoreCategories(PAGE_SIZE);
|
|
82
|
+
} catch { return loadLegacyCategories(); /* Only the initial failure can identify a legacy V1 site. */ }
|
|
83
|
+
const cards: StoreCategoryCard[] = [];
|
|
84
|
+
const cursors = new Set<string>();
|
|
85
|
+
const seen = new Set<string>();
|
|
86
|
+
let total = 0;
|
|
87
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
88
|
+
const categories = r.categories ?? [];
|
|
89
|
+
categories.forEach(c => { if (c.id) seen.add(c.id); });
|
|
90
|
+
total = Math.max(total, r.pagingMetadata?.total ?? 0);
|
|
91
|
+
cards.push(...categories.filter(c => c.visible !== false).map(fromCategory));
|
|
92
|
+
const next = r.pagingMetadata?.cursors?.next;
|
|
93
|
+
if (!next) {
|
|
94
|
+
if (seen.size < total) throw new Error("Category pagination is incomplete");
|
|
95
|
+
return visible(cards);
|
|
96
|
+
}
|
|
97
|
+
if (cursors.has(next)) throw new Error("Category pagination repeated a cursor");
|
|
98
|
+
cursors.add(next);
|
|
99
|
+
if (page + 1 < MAX_PAGES) r = await queryStoreCategories(PAGE_SIZE, next);
|
|
100
|
+
}
|
|
101
|
+
throw new Error("Category pagination exceeded the page limit");
|
|
102
|
+
}
|
|
@@ -11,46 +11,189 @@ export function withIapPresentation<S extends object>(options: {
|
|
|
11
11
|
onCreate?(commerce: IapCommerce): void;
|
|
12
12
|
onContent?(state: S): void;
|
|
13
13
|
items(state: S): IapCatalogItem[];
|
|
14
|
-
present(state: S, price: (id: string, commercePrice?: string) => string, allowed: (id: string) => boolean): S;
|
|
14
|
+
present(state: S, price: (id: string, commercePrice?: string) => string, allowed: (id: string) => boolean, appleIapOrResolving: (id: string) => boolean): S;
|
|
15
15
|
contentActions: string[];
|
|
16
|
+
/** Defaults to load/refresh/retry; mutations can explicitly request fresh mapping. */
|
|
17
|
+
forcePriceRefreshActions?: string[];
|
|
18
|
+
concurrentPublishActions?: string[];
|
|
19
|
+
/** Override the commerce factory in focused worker tests. */
|
|
20
|
+
commerceFactory?: typeof createIapCommerce;
|
|
16
21
|
transientKeys: (keyof S & string)[];
|
|
17
22
|
prefix: string;
|
|
18
23
|
}): typeof defineService {
|
|
19
24
|
return ((name: string, definition: ServiceDefinition<S>) => {
|
|
20
|
-
|
|
25
|
+
let queuePublish = () => {};
|
|
26
|
+
const commerce = (options.commerceFactory ?? createIapCommerce)({ adapters: options.adapters, onChange: () => queuePublish() });
|
|
21
27
|
options.onCreate?.(commerce);
|
|
22
28
|
let items: IapCatalogItem[] = [];
|
|
29
|
+
let loadedItemsKey = "";
|
|
30
|
+
const settledViews = new Map<string, { commerceDisplayPrice?: string; view: ReturnType<IapCommerce["pricing"]["view"]> }>();
|
|
31
|
+
const forcePriceRefreshActions = new Set(options.forcePriceRefreshActions ?? options.contentActions.filter(action => ["load", "refresh", "retry"].includes(action)));
|
|
23
32
|
let contentVersion = 0;
|
|
24
|
-
|
|
33
|
+
let actionVersion = 0;
|
|
34
|
+
let activeActions = 0;
|
|
35
|
+
let activeConcurrentPublishActions = 0;
|
|
36
|
+
let actionWaiters: Array<() => void> = [];
|
|
37
|
+
let publishQueued = false;
|
|
38
|
+
// pollDirective() arms its interval as a side effect. Keep a directive from a
|
|
39
|
+
// projection that loses a commit race so the retry can still publish it.
|
|
40
|
+
let uncommittedPollDirective: ReturnType<IapCommerce["pollDirective"]>;
|
|
41
|
+
let uncommittedPollVersion = 0;
|
|
42
|
+
// The committed state, read when a publish runs. Assigned once the service exists, so the
|
|
43
|
+
// service's own initializer never refers to it.
|
|
44
|
+
let committed: () => S = () => definition.state;
|
|
45
|
+
queuePublish = () => {
|
|
46
|
+
if (publishQueued) return;
|
|
47
|
+
publishQueued = true;
|
|
48
|
+
// Tier-5 workers have no timer globals. A microtask is enough here: if the
|
|
49
|
+
// action that caused the price update is still in flight, iapPublish's
|
|
50
|
+
// action-version check queues another publish after that action commits.
|
|
51
|
+
const publish = () => {
|
|
52
|
+
publishQueued = false;
|
|
53
|
+
void service.iapPublish();
|
|
54
|
+
};
|
|
55
|
+
void Promise.resolve().then(publish);
|
|
56
|
+
};
|
|
57
|
+
const project = (state: S, retainedPoll?: ReturnType<IapCommerce["pollDirective"]>): S => {
|
|
58
|
+
const presentationView = (id: string, commercePrice?: string) => {
|
|
59
|
+
const item = items.find(item => item.reference.id === id);
|
|
60
|
+
const reference = item?.reference ?? { kind: "storesProduct", id };
|
|
61
|
+
const key = `${reference.kind}:${reference.id}`;
|
|
62
|
+
const current = commerce.pricing.view(reference);
|
|
63
|
+
const cached = settledViews.get(key);
|
|
64
|
+
const displayPrice = commercePrice ?? item?.commerceDisplayPrice;
|
|
65
|
+
if (current.route === "resolving" && cached && cached.commerceDisplayPrice === displayPrice) return cached.view;
|
|
66
|
+
if (current.route !== "resolving" && item) settledViews.set(key, { commerceDisplayPrice: item.commerceDisplayPrice, view: current });
|
|
67
|
+
return current;
|
|
68
|
+
};
|
|
25
69
|
const price = (id: string, commercePrice?: string) => {
|
|
26
70
|
const item = items.find(item => item.reference.id === id);
|
|
27
71
|
if (!item) return "";
|
|
28
|
-
const view =
|
|
72
|
+
const view = presentationView(id, commercePrice);
|
|
73
|
+
if (view.route === "resolving") return iapPriceCopy(options.prefix).loading;
|
|
29
74
|
return view.route === "disabled" || view.route === "regular" ? commercePrice ?? item.commerceDisplayPrice ?? "" : iapPriceFields(view, iapPriceCopy(options.prefix)).priceText;
|
|
30
75
|
};
|
|
31
76
|
const allowed = (id: string) => commerce.pricing.checkoutGuard({ kind: "storesProduct", id }).allowed;
|
|
32
|
-
const
|
|
33
|
-
|
|
77
|
+
const appleIapOrResolving = (id: string) => ["appleIap", "resolving", "unavailable"].includes(presentationView(id).route);
|
|
78
|
+
// Re-evaluate the cadence first. A checkout can enter/leave due work while a prior
|
|
79
|
+
// projection is waiting to retry; a fresh directive supersedes the retained one.
|
|
80
|
+
const poll = commerce.pollDirective(`${name}.iapPoll`) ?? retainedPoll;
|
|
81
|
+
return { ...options.present(state, price, allowed, appleIapOrResolving), ...(poll ? { _poll: poll } : {}) };
|
|
34
82
|
};
|
|
35
|
-
const
|
|
83
|
+
const mergeConcurrent = (before: S, next: S, current: S): S => {
|
|
84
|
+
if (current === before) return next;
|
|
85
|
+
const merged = { ...current } as Record<string, unknown>;
|
|
86
|
+
const previous = before as Record<string, unknown>;
|
|
87
|
+
const changed = next as Record<string, unknown>;
|
|
88
|
+
for (const key of new Set([...Object.keys(previous), ...Object.keys(changed)])) {
|
|
89
|
+
if (JSON.stringify(previous[key]) === JSON.stringify(changed[key])) continue;
|
|
90
|
+
if (Object.prototype.hasOwnProperty.call(changed, key)) merged[key] = changed[key];
|
|
91
|
+
else delete merged[key];
|
|
92
|
+
}
|
|
93
|
+
return merged as S;
|
|
94
|
+
};
|
|
95
|
+
const actions = Object.fromEntries(Object.entries(definition.actions ?? {}).map(([action, reduce]) => [action, (state: S, ...args: never[]) => {
|
|
96
|
+
actionVersion++;
|
|
97
|
+
activeActions++;
|
|
98
|
+
const concurrentPublish = options.concurrentPublishActions?.includes(action) ?? false;
|
|
99
|
+
if (concurrentPublish) activeConcurrentPublishActions++;
|
|
36
100
|
const content = options.contentActions.includes(action);
|
|
37
101
|
const version = action === "load" ? ++contentVersion : contentVersion;
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
102
|
+
const presentResult = (next: S | undefined) => {
|
|
103
|
+
// scopeLanded is the completion of the active load, not a stale load result. It
|
|
104
|
+
// must publish even if another overlapping onAppear load advanced contentVersion.
|
|
105
|
+
if (!next || (content && action !== "scopeLanded" && version !== contentVersion)) return undefined;
|
|
106
|
+
if (content) {
|
|
107
|
+
options.onContent?.(next);
|
|
108
|
+
const nextItems = options.items(next);
|
|
109
|
+
// This is an order-independent identity key, not display text. Default
|
|
110
|
+
// string ordering is deterministic on every worker engine, including QuickJS.
|
|
111
|
+
const nextItemsKey = JSON.stringify(nextItems.map(item => JSON.stringify([item.reference.kind, item.reference.id, item.commerceDisplayPrice]))
|
|
112
|
+
.sort());
|
|
113
|
+
items = nextItems;
|
|
114
|
+
const forcePriceRefresh = forcePriceRefreshActions.has(action);
|
|
115
|
+
if (forcePriceRefresh) settledViews.clear();
|
|
116
|
+
if (nextItemsKey !== loadedItemsKey || forcePriceRefresh) {
|
|
117
|
+
loadedItemsKey = nextItemsKey;
|
|
118
|
+
void commerce.loadPrices(items);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Checkout preserves prices published while it ran.
|
|
122
|
+
return project(concurrentPublish ? mergeConcurrent(state, next, committed()) : next);
|
|
123
|
+
};
|
|
124
|
+
const finished = () => {
|
|
125
|
+
activeActions--;
|
|
126
|
+
if (concurrentPublish) activeConcurrentPublishActions--;
|
|
127
|
+
const waiters = actionWaiters;
|
|
128
|
+
actionWaiters = [];
|
|
129
|
+
for (const resolve of waiters) resolve();
|
|
130
|
+
};
|
|
131
|
+
try {
|
|
132
|
+
const next = reduce(state, ...args);
|
|
133
|
+
// Keep synchronous sidecars synchronous. Adding an await lets their old
|
|
134
|
+
// catalog snapshot commit after a newer asynchronous refresh.
|
|
135
|
+
if (content || (next && typeof (next as Promise<S | undefined>).then === "function")) {
|
|
136
|
+
return (async () => {
|
|
137
|
+
try { return presentResult(await next); }
|
|
138
|
+
finally { finished(); }
|
|
139
|
+
})();
|
|
140
|
+
}
|
|
141
|
+
const result = presentResult(next as S | undefined);
|
|
142
|
+
finished();
|
|
143
|
+
return result;
|
|
144
|
+
} catch (error) {
|
|
145
|
+
finished();
|
|
146
|
+
throw error;
|
|
44
147
|
}
|
|
45
|
-
return project(next);
|
|
46
148
|
}]));
|
|
47
149
|
const service = (options.register ?? defineService)(name, {
|
|
48
150
|
// The collection's shown page goes with the rows and chrome it copies: kept out of the restore with them.
|
|
49
151
|
...definition, notHydrated: [...(definition.notHydrated ?? []), ...options.transientKeys,
|
|
50
152
|
...((options.transientKeys as string[]).includes('collection') ? ['collectionShown' as keyof S & string] : [])],
|
|
51
|
-
|
|
153
|
+
// Prices land on their own clock, and a commit replaces the whole state — so a publish
|
|
154
|
+
// can carry a snapshot that an in-flight commit (a page that just landed, a tap) has
|
|
155
|
+
// since replaced. Project the state as it is now, and publish only when the prices
|
|
156
|
+
// actually change it: with no App Store mapping, or prices already shown, there is
|
|
157
|
+
// nothing to publish and nothing to overwrite.
|
|
158
|
+
actions: { ...actions, iapPublish: async () => {
|
|
159
|
+
// Checkout actions publish their busy phase while running. Every other action
|
|
160
|
+
// finishes before we take a projection, so it cannot erase a reducer's result.
|
|
161
|
+
for (;;) {
|
|
162
|
+
if (activeActions > activeConcurrentPublishActions) await new Promise<void>(resolve => actionWaiters.push(resolve));
|
|
163
|
+
await Promise.resolve();
|
|
164
|
+
const version = actionVersion;
|
|
165
|
+
await Promise.resolve();
|
|
166
|
+
if (activeActions <= activeConcurrentPublishActions && version === actionVersion) break;
|
|
167
|
+
}
|
|
168
|
+
const current = committed();
|
|
169
|
+
// The runtime commits an action's result just after this wrapper returns. If a
|
|
170
|
+
// scope action lands while prices are being projected, publishing this snapshot
|
|
171
|
+
// would put the older scope back on top. Drop it and retry against the commit.
|
|
172
|
+
const retainedPollVersion = uncommittedPollVersion;
|
|
173
|
+
const retainedPoll = uncommittedPollDirective;
|
|
174
|
+
const next: S & { _poll?: unknown } = project(current, retainedPoll);
|
|
175
|
+
await Promise.resolve();
|
|
176
|
+
if (committed() !== current) {
|
|
177
|
+
// Overlapping retries can race too: a projection with no new directive must
|
|
178
|
+
// not erase one another already retained after pollDirective() armed itself.
|
|
179
|
+
if (next._poll) {
|
|
180
|
+
uncommittedPollDirective = next._poll as ReturnType<IapCommerce["pollDirective"]>;
|
|
181
|
+
uncommittedPollVersion++;
|
|
182
|
+
}
|
|
183
|
+
queuePublish();
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
// Another concurrent projection may have retained a newer directive since this
|
|
187
|
+
// snapshot. Clear only the exact retained generation this successful commit used.
|
|
188
|
+
if (next._poll && retainedPollVersion === uncommittedPollVersion && retainedPoll === uncommittedPollDirective) {
|
|
189
|
+
uncommittedPollDirective = undefined;
|
|
190
|
+
uncommittedPollVersion++;
|
|
191
|
+
}
|
|
192
|
+
return !next._poll && JSON.stringify(next) === JSON.stringify(current) ? undefined : next;
|
|
193
|
+
}, iapPoll: async () => { await commerce.poll(); return undefined; },
|
|
52
194
|
iapRefresh: async () => { await commerce.loadPrices(items); return undefined; } },
|
|
53
195
|
});
|
|
196
|
+
committed = () => (service as unknown as { getState(): S }).getState();
|
|
54
197
|
resetUnhydratedKeysOnOlderRuntime(service, definition.state, options.transientKeys);
|
|
55
198
|
return service;
|
|
56
199
|
}) as unknown as typeof defineService;
|
|
@@ -2,6 +2,7 @@ import { fetchMemberBookings, type MemberBookingRow } from "../_shared/member-bo
|
|
|
2
2
|
import { hostSiteFromInput, hostedExploreCards, hostedHiddenFlags, type HostedExploreCard } from "../_shared/branded-lite-hosted.js";
|
|
3
3
|
import { memberAccess, assertMemberAccess } from "../_shared/member-access.js";
|
|
4
4
|
import { defineService as registerHome } from "../_shared/home-service.js";
|
|
5
|
+
import { navigate } from "@himalaya/state";
|
|
5
6
|
import { withIapPresentation } from "../_shared/iap-presentation.js";
|
|
6
7
|
import { queryServices, type WixService } from "@himalaya/wix-bookings";
|
|
7
8
|
import { getPost, queryPosts, type WixPost } from "@himalaya/wix-blog";
|
|
@@ -20,6 +21,7 @@ import { parseWeeklyGoal, weeklyGoalProgress, weeklySessionsFromPages } from "..
|
|
|
20
21
|
import { isBridgeUnavailable } from "../_shared/owner-load-error.js";
|
|
21
22
|
import { formatDateMedium } from "../_shared/engine-intl.js";
|
|
22
23
|
import { readSavedProducts, type SavedProduct } from "../_shared/himi-store-saved.js";
|
|
24
|
+
import { loadStoreCategories, type StoreCategoryCard } from "../_shared/himi-store-categories.js";
|
|
23
25
|
import { readSavedIds } from "../_shared/himi-blog-saved.js";
|
|
24
26
|
import { wixMediaUrl } from "@himalaya/wix-media";
|
|
25
27
|
import { isWixVideoWebRefusal, listLatestVideos, videoCard, type WixVideoCard } from "@himalaya/wix-video";
|
|
@@ -270,6 +272,9 @@ interface State {
|
|
|
270
272
|
noHeroImage: boolean;
|
|
271
273
|
hasFeaturedProducts: boolean;
|
|
272
274
|
noFeaturedProducts: boolean;
|
|
275
|
+
/** The store's categories for the "Shop by category" rail; each opens Shop on that chip. */
|
|
276
|
+
homeCategories: StoreCategoryCard[];
|
|
277
|
+
noHomeCategories: boolean;
|
|
273
278
|
/** Schedule-only trainingFirst fields (design §4.1). They remain empty until
|
|
274
279
|
* a dated availability source can back them; collapse gates hide them. */
|
|
275
280
|
todaysClasses: ClassEntry[];
|
|
@@ -857,6 +862,7 @@ function derive(state: State): State {
|
|
|
857
862
|
noHeroImage: !hasHeroImage,
|
|
858
863
|
hasFeaturedProducts: state.featuredProducts.length > 0,
|
|
859
864
|
noFeaturedProducts: state.featuredProducts.length === 0,
|
|
865
|
+
noHomeCategories: (state.homeCategories ?? []).length === 0,
|
|
860
866
|
noSavedProducts: state.savedProducts.length === 0,
|
|
861
867
|
noSavedPosts: state.savedPosts.length === 0,
|
|
862
868
|
noSavedContent: state.savedProducts.length === 0 && state.savedPosts.length === 0,
|
|
@@ -950,6 +956,8 @@ function baseState(): State {
|
|
|
950
956
|
noHeroImage: true,
|
|
951
957
|
hasFeaturedProducts: false,
|
|
952
958
|
noFeaturedProducts: true,
|
|
959
|
+
homeCategories: [],
|
|
960
|
+
noHomeCategories: true,
|
|
953
961
|
todaysClasses: [],
|
|
954
962
|
noTodaysClasses: true,
|
|
955
963
|
coachAvatars: [],
|
|
@@ -998,6 +1006,7 @@ let refreshGen = 0;
|
|
|
998
1006
|
/** Last successful featured list — catch paths read this when the action's
|
|
999
1007
|
* `state` snapshot was captured before a racing refresh painted products. */
|
|
1000
1008
|
let lastFeaturedProducts: ProductFeature[] = [];
|
|
1009
|
+
let lastHomeCategories: StoreCategoryCard[] = [];
|
|
1001
1010
|
|
|
1002
1011
|
async function refreshPublic(state: State, arg?: unknown): Promise<State | undefined> {
|
|
1003
1012
|
// Absorb the descriptor's copy-pack overrides (if any) before any string is
|
|
@@ -1034,6 +1043,21 @@ async function refreshPublic(state: State, arg?: unknown): Promise<State | undef
|
|
|
1034
1043
|
effectiveLayout: resolveEffectiveLayout(arg, 0),
|
|
1035
1044
|
});
|
|
1036
1045
|
}
|
|
1046
|
+
// Categories ride beside the products; a failed read only hides their rail. Started
|
|
1047
|
+
// outside the try, and merged as a separate action so a slow categories endpoint cannot
|
|
1048
|
+
// hold the primary products rail behind it. Offline on a cold boot the module copy is
|
|
1049
|
+
// still empty: fall back to the rail the state restored.
|
|
1050
|
+
let categoriesResolved = false;
|
|
1051
|
+
let resolvedCategories: StoreCategoryCard[] = [];
|
|
1052
|
+
const categoriesRead = loadStoreCategories().catch(() => (lastHomeCategories.length ? lastHomeCategories : state.homeCategories ?? []));
|
|
1053
|
+
void categoriesRead.then((homeCategories) => {
|
|
1054
|
+
categoriesResolved = true;
|
|
1055
|
+
resolvedCategories = homeCategories;
|
|
1056
|
+
if (gen === refreshGen) {
|
|
1057
|
+
lastHomeCategories = homeCategories;
|
|
1058
|
+
void svc.categoriesLoaded({ generation: gen, categories: homeCategories });
|
|
1059
|
+
}
|
|
1060
|
+
});
|
|
1037
1061
|
try {
|
|
1038
1062
|
const response = await queryStoreProducts(50);
|
|
1039
1063
|
if (gen !== refreshGen) return undefined;
|
|
@@ -1051,6 +1075,7 @@ async function refreshPublic(state: State, arg?: unknown): Promise<State | undef
|
|
|
1051
1075
|
welcome: welcome(),
|
|
1052
1076
|
refreshedAt: new Date().toISOString(),
|
|
1053
1077
|
featuredProducts,
|
|
1078
|
+
homeCategories: categoriesResolved ? resolvedCategories : state.homeCategories ?? [],
|
|
1054
1079
|
...bookings,
|
|
1055
1080
|
...extras,
|
|
1056
1081
|
...saved,
|
|
@@ -1075,6 +1100,7 @@ async function refreshPublic(state: State, arg?: unknown): Promise<State | undef
|
|
|
1075
1100
|
// genuine store outage still raises the banner.
|
|
1076
1101
|
isErrored: keptProducts.length === 0 && !isBridgeUnavailable(e),
|
|
1077
1102
|
featuredProducts: keptProducts,
|
|
1103
|
+
homeCategories: categoriesResolved ? resolvedCategories : state.homeCategories ?? [],
|
|
1078
1104
|
welcome: welcome(),
|
|
1079
1105
|
refreshedAt: new Date().toISOString(),
|
|
1080
1106
|
...bookings,
|
|
@@ -1087,6 +1113,7 @@ async function refreshPublic(state: State, arg?: unknown): Promise<State | undef
|
|
|
1087
1113
|
}
|
|
1088
1114
|
|
|
1089
1115
|
let personalGeneration = 0;
|
|
1116
|
+
let shopCategoryActivation = 0;
|
|
1090
1117
|
function memberSchedule(rows: MemberBookingRow[], now = new Date()): { label: string; fraction: number } {
|
|
1091
1118
|
const weekStart = new Date(now);
|
|
1092
1119
|
const day = (weekStart.getDay() + 6) % 7;
|
|
@@ -1184,20 +1211,33 @@ const svc = defineService<State, {
|
|
|
1184
1211
|
refresh: (state: State, arg?: unknown) => Promise<State | undefined>;
|
|
1185
1212
|
dismissOnboarding: (state: State) => State;
|
|
1186
1213
|
clearPersonal: (state: State) => State;
|
|
1214
|
+
openShopCategory: (state: State, arg?: unknown) => State;
|
|
1187
1215
|
personalLoaded: (state: State, arg: { generation: number; rows: MemberBookingRow[]; progress: { label: string; fraction: number }; error: boolean; weekly: { count: number; fraction: number; label: string } | null }) => State;
|
|
1216
|
+
categoriesLoaded: (state: State, arg: { generation: number; categories: StoreCategoryCard[] }) => State | undefined;
|
|
1188
1217
|
}>("himiBrandedHome", {
|
|
1189
1218
|
state: baseState(),
|
|
1190
1219
|
// The host's site is read fresh at every boot: a snapshot of the last session's site must not
|
|
1191
1220
|
// decide this one's verticals or name (an owner installs or removes one; another site opens).
|
|
1192
|
-
notHydrated: ["hosted", "hostedName", "hostedCards"],
|
|
1221
|
+
notHydrated: ["hosted", "hostedName", "hostedCards", "homeCategories", "noHomeCategories"],
|
|
1193
1222
|
actions: {
|
|
1194
1223
|
clearPersonal: state => derive({ ...state, nextBooking: [], hideNextBooking: true, memberProgressLabel: "", memberProgressFraction: 0, hideMemberProgress: true, personalError: false, hidePersonalError: true,
|
|
1195
1224
|
weeklyClassCount: 0, weeklyClassGoalFraction: 0, weeklyGoalLabel: "" }),
|
|
1225
|
+
openShopCategory: (state, arg) => {
|
|
1226
|
+
const category = String((arg as { category?: unknown } | undefined)?.category ?? "");
|
|
1227
|
+
if (!category) return state;
|
|
1228
|
+
shopCategoryActivation++;
|
|
1229
|
+
// Nav params are strings and the activation nonce is a transient route event,
|
|
1230
|
+
// not a distinct Shop record. ScreenHost forwards these live params to onAppear
|
|
1231
|
+
// when a pooled worker is reused.
|
|
1232
|
+
const activation = `${Date.now()}-${shopCategoryActivation}`;
|
|
1233
|
+
return navigate(state, { screenId: "himi_shop", args: { category, categoryId: category, categoryActivation: activation } });
|
|
1234
|
+
},
|
|
1196
1235
|
personalLoaded: (state, arg) => arg.generation !== personalGeneration ? state : derive({ ...state,
|
|
1197
1236
|
nextBooking: arg.rows, hideNextBooking: !arg.rows.length,
|
|
1198
1237
|
memberProgressLabel: arg.progress.label, memberProgressFraction: arg.progress.fraction, hideMemberProgress: !arg.progress.label,
|
|
1199
1238
|
personalError: arg.error, hidePersonalError: !arg.error,
|
|
1200
1239
|
weeklyClassCount: arg.weekly?.count ?? 0, weeklyClassGoalFraction: arg.weekly?.fraction ?? 0, weeklyGoalLabel: arg.weekly?.label ?? "" }),
|
|
1240
|
+
categoriesLoaded: (state, arg) => arg.generation !== refreshGen ? undefined : derive({ ...state, homeCategories: arg.categories }),
|
|
1201
1241
|
refresh,
|
|
1202
1242
|
// "Got it" on the first-run card: persist + hide. Persistence is
|
|
1203
1243
|
// best-effort — the UI hides either way for this session.
|
|
@@ -1213,7 +1253,7 @@ const svc = defineService<State, {
|
|
|
1213
1253
|
// name. In place, because on those runtimes getState() IS the stored state (as himi-player-home does).
|
|
1214
1254
|
if ((globalThis as { himalaya?: { hydrationOptOut?: unknown } }).himalaya?.hydrationOptOut !== true) {
|
|
1215
1255
|
const live = (svc as { getState?: () => State | undefined }).getState?.();
|
|
1216
|
-
if (live && typeof live === "object") Object.assign(live, hostedBoot);
|
|
1256
|
+
if (live && typeof live === "object") Object.assign(live, hostedBoot, { homeCategories: [], noHomeCategories: true });
|
|
1217
1257
|
}
|
|
1218
1258
|
|
|
1219
1259
|
svc.refresh();
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import { withCollectionSaving, type CollectionSaving } from './collection-service.js';
|
|
2
|
-
import { readSavedProducts, writeSavedProducts } from './himi-store-saved.js';
|
|
3
|
-
|
|
4
|
-
export const storeCollectionSaving: CollectionSaving = {
|
|
5
|
-
read: () => readSavedProducts().map(row => row.id),
|
|
6
|
-
write(ids, rows) {
|
|
7
|
-
const products = new Map(readSavedProducts().map(row => [row.id, row]));
|
|
8
|
-
for (const row of rows) {
|
|
9
|
-
if (typeof row.id !== 'string' || typeof row.name !== 'string') continue;
|
|
10
|
-
const previous = products.get(row.id);
|
|
11
|
-
const freshPrice = typeof row.commercePrice === 'string' ? row.commercePrice : typeof row.subtitle === 'string' ? row.subtitle : '';
|
|
12
|
-
const wasPrice = typeof row.wasPrice === 'string' ? row.wasPrice || undefined : previous?.price === freshPrice ? previous?.wasPrice : undefined;
|
|
13
|
-
products.set(row.id, { ...previous, wasPrice, id: row.id, name: row.name,
|
|
14
|
-
price: freshPrice || previous?.price || '',
|
|
15
|
-
image: typeof row.image === 'string' && row.image ? row.image : previous?.image ?? '' });
|
|
16
|
-
}
|
|
17
|
-
const selected = ids.map(id => products.get(id));
|
|
18
|
-
if (selected.some(row => !row)) throw new Error('Saved product metadata is unavailable');
|
|
19
|
-
writeSavedProducts(selected.filter((row): row is NonNullable<typeof row> => !!row));
|
|
20
|
-
},
|
|
21
|
-
};
|
|
22
|
-
export const defineService = withCollectionSaving(storeCollectionSaving);
|