@wix/himalaya-cli 0.1498.0 → 0.1499.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/sdk-reference.json +384 -29
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
// Shared store sort + filter + product card, for the Branded Lite Shop and Search
|
|
2
|
+
// screens (ported from the BARE fashion template's bare-facets / bare-product-card).
|
|
3
|
+
//
|
|
4
|
+
// Each screen owns its SCOPE (a category, a search query) and fetches it; this module
|
|
5
|
+
// only narrows and orders the rows inside that scope, on the device: a price bucket
|
|
6
|
+
// and up to three of the store's own product options (Color, Size, …). The sort picker
|
|
7
|
+
// and the filter panel are BottomSheets on the screen itself, not navigation modals —
|
|
8
|
+
// a modal is a separate screen with its own worker, and state does not cross screens.
|
|
9
|
+
//
|
|
10
|
+
// Every user-facing string goes through copyParam / copyTemplate, so a copy pack
|
|
11
|
+
// retitles them like any other worker string.
|
|
12
|
+
import { normalizeFormattedPrice, productImage, productOptions, productPrice, type StoreProduct } from "./himi-store.js";
|
|
13
|
+
import { readSavedProducts, toggleSavedProduct } from "./himi-store-saved.js";
|
|
14
|
+
import { cleanTitle, initials } from "./himi-text.js";
|
|
15
|
+
import { copyParam, copyTemplate } from "./himi-copy.js";
|
|
16
|
+
import { formatCurrency } from "./engine-intl.js";
|
|
17
|
+
|
|
18
|
+
/** The layout has no nested repeater inside a grid cell, so swatches ride in five
|
|
19
|
+
* fixed slots; a product with more colours shows the first five. */
|
|
20
|
+
const MAX_SWATCHES = 5;
|
|
21
|
+
/** Rows revealed per page. The scope is already on the device; paging keeps the
|
|
22
|
+
* first paint and every list diff small. */
|
|
23
|
+
export const PAGE_SIZE = 24;
|
|
24
|
+
|
|
25
|
+
export interface Chip { id: string; label: string }
|
|
26
|
+
export interface Option extends Chip { selected: boolean; notSelected: boolean }
|
|
27
|
+
export type Panel = "none" | "filters" | "sort";
|
|
28
|
+
export type View = "grid" | "list";
|
|
29
|
+
export type Density = "comfortable" | "compact";
|
|
30
|
+
|
|
31
|
+
const SORT_IDS = ["recommended", "newest", "price_asc", "price_desc", "name_asc", "name_desc"] as const;
|
|
32
|
+
const SORT_FALLBACK: Record<(typeof SORT_IDS)[number], string> = {
|
|
33
|
+
recommended: "Recommended",
|
|
34
|
+
newest: "Newest",
|
|
35
|
+
price_asc: "Price: low to high",
|
|
36
|
+
price_desc: "Price: high to low",
|
|
37
|
+
name_asc: "Name: A–Z",
|
|
38
|
+
name_desc: "Name: Z–A",
|
|
39
|
+
};
|
|
40
|
+
export function sorts(): Chip[] {
|
|
41
|
+
return SORT_IDS.map((id) => ({ id, label: copyParam(`storeFacets.sort.${id}`, SORT_FALLBACK[id]) }));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ProductCard {
|
|
45
|
+
id: string;
|
|
46
|
+
name: string;
|
|
47
|
+
image: string;
|
|
48
|
+
hasImage: boolean;
|
|
49
|
+
noImage: boolean;
|
|
50
|
+
initial: string;
|
|
51
|
+
price: string;
|
|
52
|
+
/** The pre-discount price, struck through; "" when the product is not on sale. */
|
|
53
|
+
wasPrice: string;
|
|
54
|
+
onSale: boolean;
|
|
55
|
+
notOnSale: boolean;
|
|
56
|
+
saved: boolean;
|
|
57
|
+
notSaved: boolean;
|
|
58
|
+
swatch1: string; swatch2: string; swatch3: string; swatch4: string; swatch5: string;
|
|
59
|
+
swatch1Hidden: boolean; swatch2Hidden: boolean; swatch3Hidden: boolean; swatch4Hidden: boolean; swatch5Hidden: boolean;
|
|
60
|
+
noSwatches: boolean;
|
|
61
|
+
/** Hides the Sale tag / swatch row when it has nothing to show. */
|
|
62
|
+
noTagRow: boolean;
|
|
63
|
+
/** The Wix price before any App Store presentation rewrites `price`. */
|
|
64
|
+
commercePrice: string;
|
|
65
|
+
/** Original Wix sale metadata retained when App Store presentation hides it. */
|
|
66
|
+
commerceWasPrice?: string;
|
|
67
|
+
detailScreenId: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A product plus the facts the filters and sorts read. */
|
|
71
|
+
export interface Row {
|
|
72
|
+
card: ProductCard;
|
|
73
|
+
price: number;
|
|
74
|
+
created: string;
|
|
75
|
+
name: string;
|
|
76
|
+
options: Record<string, string[]>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The sort + filter + view slice of a screen's state. */
|
|
80
|
+
export interface FacetState {
|
|
81
|
+
sort: string;
|
|
82
|
+
sortOptions: Option[];
|
|
83
|
+
sortLabel: string;
|
|
84
|
+
panel: Panel;
|
|
85
|
+
filtersOpen: boolean;
|
|
86
|
+
sortOpen: boolean;
|
|
87
|
+
filterLabel: string;
|
|
88
|
+
activeFilters: number;
|
|
89
|
+
noActiveFilters: boolean;
|
|
90
|
+
priceChips: Chip[];
|
|
91
|
+
priceSelected: string[];
|
|
92
|
+
hidePrice: boolean;
|
|
93
|
+
opt1Name: string; opt1Chips: Chip[]; opt1Selected: string[]; hideOpt1: boolean;
|
|
94
|
+
opt2Name: string; opt2Chips: Chip[]; opt2Selected: string[]; hideOpt2: boolean;
|
|
95
|
+
opt3Name: string; opt3Chips: Chip[]; opt3Selected: string[]; hideOpt3: boolean;
|
|
96
|
+
view: View;
|
|
97
|
+
density: Density;
|
|
98
|
+
hideGrid: boolean;
|
|
99
|
+
hideList: boolean;
|
|
100
|
+
hideComfortable: boolean;
|
|
101
|
+
hideCompact: boolean;
|
|
102
|
+
/** How many matched rows are revealed. */
|
|
103
|
+
visible: number;
|
|
104
|
+
/** Changes whenever filters or scope reset paging, so a reused visible count is a new cursor. */
|
|
105
|
+
cursorEpoch: number;
|
|
106
|
+
/** Every matched row's card, in sort order; `results` is its revealed prefix. */
|
|
107
|
+
matched: number;
|
|
108
|
+
results: ProductCard[];
|
|
109
|
+
/** Paging cursor: "more" while unrevealed rows remain, "" when exhausted. */
|
|
110
|
+
nextCursor: string;
|
|
111
|
+
noMore: boolean;
|
|
112
|
+
showLabel: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const FACETS_BOOT: FacetState = {
|
|
116
|
+
sort: "recommended", sortOptions: [], sortLabel: "",
|
|
117
|
+
panel: "none", filtersOpen: false, sortOpen: false, filterLabel: "", activeFilters: 0, noActiveFilters: true,
|
|
118
|
+
priceChips: [], priceSelected: [], hidePrice: true,
|
|
119
|
+
opt1Name: "", opt1Chips: [], opt1Selected: [], hideOpt1: true,
|
|
120
|
+
opt2Name: "", opt2Chips: [], opt2Selected: [], hideOpt2: true,
|
|
121
|
+
opt3Name: "", opt3Chips: [], opt3Selected: [], hideOpt3: true,
|
|
122
|
+
view: "grid", density: "comfortable", hideGrid: false, hideList: true, hideComfortable: false, hideCompact: true,
|
|
123
|
+
visible: PAGE_SIZE, cursorEpoch: 0, matched: 0, results: [], nextCursor: "", noMore: true, showLabel: "",
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Reading products — V1 and V3 shapes, defensively (the SDK type covers neither
|
|
128
|
+
// sale price nor creation date; a missing field degrades to "not on sale", never NaN).
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
interface PriceBag { price?: number; discountedPrice?: number; currency?: string; formatted?: { price?: string; discountedPrice?: string } }
|
|
132
|
+
interface V3Range { minValue?: { amount?: string; formattedAmount?: string; currency?: string } }
|
|
133
|
+
interface RawPrices { price?: PriceBag; priceData?: PriceBag; actualPriceRange?: V3Range; compareAtPriceRange?: V3Range; currency?: string }
|
|
134
|
+
|
|
135
|
+
/** Engine-safe money (engine-intl): watch runtimes have no ECMA-402. */
|
|
136
|
+
export function money(n: number, currency: string | undefined): string {
|
|
137
|
+
if (!Number.isFinite(n)) return "";
|
|
138
|
+
return currency ? formatCurrency(n, currency) : n.toFixed(2);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Now / was / numeric price. V1 carries the sale on `discountedPrice`; V3 carries the
|
|
142
|
+
* pre-discount figure on `compareAtPriceRange`. Wix's own formatted strings win. */
|
|
143
|
+
function prices(p: StoreProduct): { now: string; was: string; value: number; currency?: string } {
|
|
144
|
+
const raw = p as unknown as RawPrices;
|
|
145
|
+
const bag = raw.price ?? raw.priceData;
|
|
146
|
+
if (bag && typeof bag.price === "number") {
|
|
147
|
+
const sale = bag.discountedPrice;
|
|
148
|
+
const discounted = typeof sale === "number" && sale < bag.price;
|
|
149
|
+
return discounted
|
|
150
|
+
? { now: bag.formatted?.discountedPrice ? normalizeFormattedPrice(bag.formatted.discountedPrice) : money(sale, bag.currency), was: bag.formatted?.price ? normalizeFormattedPrice(bag.formatted.price) : money(bag.price, bag.currency), value: sale, currency: bag.currency }
|
|
151
|
+
: { now: productPrice(p), was: "", value: bag.price, currency: bag.currency };
|
|
152
|
+
}
|
|
153
|
+
const now = Number(raw.actualPriceRange?.minValue?.amount);
|
|
154
|
+
const was = Number(raw.compareAtPriceRange?.minValue?.amount);
|
|
155
|
+
const currency = raw.actualPriceRange?.minValue?.currency ?? raw.currency;
|
|
156
|
+
const discounted = Number.isFinite(now) && Number.isFinite(was) && now < was;
|
|
157
|
+
return {
|
|
158
|
+
now: productPrice(p),
|
|
159
|
+
was: discounted ? (raw.compareAtPriceRange?.minValue?.formattedAmount ? normalizeFormattedPrice(raw.compareAtPriceRange.minValue.formattedAmount) : money(was, currency)) : "",
|
|
160
|
+
value: Number.isFinite(now) ? now : 0,
|
|
161
|
+
currency,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function currencyOf(p: StoreProduct): string | undefined {
|
|
166
|
+
return prices(p).currency;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function savedIds(): Set<string> {
|
|
170
|
+
try { return new Set(readSavedProducts().map((row) => row.id)); } catch { return new Set(); }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function swatchFields(hexes: string[], onSale: boolean) {
|
|
174
|
+
const s = hexes.filter(Boolean).slice(0, MAX_SWATCHES);
|
|
175
|
+
return {
|
|
176
|
+
noSwatches: s.length === 0, noTagRow: !onSale && s.length === 0,
|
|
177
|
+
swatch1: s[0] ?? "", swatch2: s[1] ?? "", swatch3: s[2] ?? "", swatch4: s[3] ?? "", swatch5: s[4] ?? "",
|
|
178
|
+
swatch1Hidden: !s[0], swatch2Hidden: !s[1], swatch3Hidden: !s[2], swatch4Hidden: !s[3], swatch5Hidden: !s[4],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function productCard(p: StoreProduct, saved: Set<string> = savedIds()): ProductCard {
|
|
183
|
+
const { now, was } = prices(p);
|
|
184
|
+
const groups = productOptions(p);
|
|
185
|
+
const color = groups.find((g) => g.kind === "color");
|
|
186
|
+
const name = cleanTitle(p.name) || copyParam("storeFacets.untitled", "Product");
|
|
187
|
+
const image = productImage(p);
|
|
188
|
+
const id = p.id ?? "";
|
|
189
|
+
const onSale = !!was;
|
|
190
|
+
return {
|
|
191
|
+
id, name, image, hasImage: !!image, noImage: !image, initial: initials(name),
|
|
192
|
+
price: now, wasPrice: was, onSale, notOnSale: !onSale,
|
|
193
|
+
saved: saved.has(id), notSaved: !saved.has(id),
|
|
194
|
+
...swatchFields((color?.choices ?? []).map((c) => c.colorCode ?? ""), onSale),
|
|
195
|
+
commercePrice: now,
|
|
196
|
+
detailScreenId: "himi_product",
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Option name → choice labels, from either catalog. */
|
|
201
|
+
function optionsOf(p: StoreProduct): Record<string, string[]> {
|
|
202
|
+
const out: Record<string, string[]> = {};
|
|
203
|
+
for (const g of productOptions(p)) {
|
|
204
|
+
const values = g.choices.map((c) => c.label.trim()).filter(Boolean);
|
|
205
|
+
if (g.name.trim() && values.length) out[g.name.trim()] = values;
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function toRow(p: StoreProduct, saved?: Set<string>): Row {
|
|
211
|
+
const card = productCard(p, saved);
|
|
212
|
+
const raw = p as unknown as { createdDate?: string; _createdDate?: string };
|
|
213
|
+
return { card, price: prices(p).value, created: raw.createdDate ?? raw._createdDate ?? "", name: card.name.toLowerCase(), options: optionsOf(p) };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function toRows(products: StoreProduct[]): Row[] {
|
|
217
|
+
const saved = savedIds();
|
|
218
|
+
return products.map((p) => toRow(p, saved)).filter((r) => r.card.id);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// Facets
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
/** Round price buckets over the scope's price span and cap the filter sheet at eight chips. */
|
|
226
|
+
function priceBuckets(scope: Row[], currency: string | undefined): Chip[] {
|
|
227
|
+
const max = Math.max(0, ...scope.map((r) => r.price));
|
|
228
|
+
if (max <= 0) return [];
|
|
229
|
+
const ordinaryStep = max <= 100 ? 25 : max <= 200 ? 50 : max <= 500 ? 100 : 250;
|
|
230
|
+
const target = max / 8;
|
|
231
|
+
const magnitude = 10 ** Math.floor(Math.log10(target));
|
|
232
|
+
const normalized = target / magnitude;
|
|
233
|
+
const scaledStep = (normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10) * magnitude;
|
|
234
|
+
const step = Math.max(ordinaryStep, scaledStep);
|
|
235
|
+
const edges: number[] = [];
|
|
236
|
+
for (let e = step; e < max; e += step) edges.push(e);
|
|
237
|
+
if (edges.length === 0) return [];
|
|
238
|
+
const m = (n: number) => money(n, currency).replace(/\.00$/, "");
|
|
239
|
+
const chips: Chip[] = [{ id: `0-${edges[0]}`, label: copyTemplate("storeFacets.priceUnder", "Under {price}", { price: m(edges[0]) }) }];
|
|
240
|
+
for (let i = 1; i < edges.length; i++) chips.push({ id: `${edges[i - 1]}-${edges[i]}`, label: `${m(edges[i - 1])} – ${m(edges[i])}` });
|
|
241
|
+
chips.push({ id: `${edges[edges.length - 1]}-`, label: copyTemplate("storeFacets.priceOver", "Over {price}", { price: m(edges[edges.length - 1]) }) });
|
|
242
|
+
return chips;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** A bucket's label from its own id, for a selection the current buckets no longer offer. */
|
|
246
|
+
function bucketLabel(id: string, currency: string | undefined): string {
|
|
247
|
+
const [lo, hi] = id.split("-");
|
|
248
|
+
const m = (n: number) => money(n, currency).replace(/\.00$/, "");
|
|
249
|
+
if (!hi) return copyTemplate("storeFacets.priceOver", "Over {price}", { price: m(Number(lo) || 0) });
|
|
250
|
+
if (!Number(lo)) return copyTemplate("storeFacets.priceUnder", "Under {price}", { price: m(Number(hi)) });
|
|
251
|
+
return `${m(Number(lo))} – ${m(Number(hi))}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function inBucket(price: number, id: string): boolean {
|
|
255
|
+
const [lo, hi] = id.split("-");
|
|
256
|
+
return price >= (Number(lo) || 0) && (!hi ? true : price < Number(hi));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const SIZE_ORDER = ["XXS", "XS", "S", "M", "L", "XL", "XXL", "XXXL"];
|
|
260
|
+
/** Clothing sizes in size order; anything else keeps the store's order. */
|
|
261
|
+
function sortChoices(values: string[]): string[] {
|
|
262
|
+
const rank = (v: string) => SIZE_ORDER.indexOf(v.trim().toUpperCase());
|
|
263
|
+
return values.every((v) => rank(v) >= 0) ? values.slice().sort((a, b) => rank(a) - rank(b)) : values;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function optionFacets(scope: Row[]): { name: string; chips: Chip[] }[] {
|
|
267
|
+
const seen = new Map<string, Set<string>>();
|
|
268
|
+
for (const r of scope) {
|
|
269
|
+
for (const [name, values] of Object.entries(r.options)) {
|
|
270
|
+
const set = seen.get(name) ?? new Set<string>();
|
|
271
|
+
values.forEach((v) => set.add(v));
|
|
272
|
+
seen.set(name, set);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return [...seen.entries()].map(([name, set]) => ({
|
|
276
|
+
name,
|
|
277
|
+
chips: sortChoices([...set]).map((v) => ({ id: v, label: v })),
|
|
278
|
+
}));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const SLOT_KEYS = [
|
|
282
|
+
["opt1Name", "opt1Chips", "opt1Selected", "hideOpt1"],
|
|
283
|
+
["opt2Name", "opt2Chips", "opt2Selected", "hideOpt2"],
|
|
284
|
+
["opt3Name", "opt3Chips", "opt3Selected", "hideOpt3"],
|
|
285
|
+
] as const;
|
|
286
|
+
|
|
287
|
+
/** Plain code-unit order, not localeCompare: watch runtimes have no ECMA-402. ISO dates
|
|
288
|
+
* sort correctly this way, and names are already lower-cased. */
|
|
289
|
+
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
|
|
290
|
+
|
|
291
|
+
function sortRows(list: Row[], sort: string): Row[] {
|
|
292
|
+
const out = list.slice();
|
|
293
|
+
switch (sort) {
|
|
294
|
+
case "newest": return out.sort((a, b) => cmp(b.created, a.created));
|
|
295
|
+
case "price_asc": return out.sort((a, b) => a.price - b.price);
|
|
296
|
+
case "price_desc": return out.sort((a, b) => b.price - a.price);
|
|
297
|
+
case "name_asc": return out.sort((a, b) => cmp(a.name, b.name));
|
|
298
|
+
case "name_desc": return out.sort((a, b) => cmp(b.name, a.name));
|
|
299
|
+
default: return out;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function option(c: Chip, on: boolean): Option {
|
|
304
|
+
return { ...c, selected: on, notSelected: !on };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function countText(n: number): string {
|
|
308
|
+
return n === 1 ? copyParam("storeFacets.countOne", "1 product") : copyTemplate("storeFacets.countMany", "{count} products", { count: n });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Narrow `scope` by the selected filters, order it by the selected sort, and reveal
|
|
312
|
+
* the first `visible` rows. Facets follow the scope, but a selection is the shopper's and
|
|
313
|
+
* outlives it: the scope grows a page at a time and a search narrows it, so a price bucket
|
|
314
|
+
* the new span no longer draws, or an option a narrowed scope no longer carries, keeps
|
|
315
|
+
* filtering and stays on screen as a chip the shopper can turn off. Options are matched by
|
|
316
|
+
* name, never by slot, so a facet moving to another slot keeps its selection. */
|
|
317
|
+
export function applyFacets<T extends FacetState>(s: T, scope: Row[], currency: string | undefined): T {
|
|
318
|
+
const buckets = priceBuckets(scope, currency);
|
|
319
|
+
const priceSelected = s.priceSelected.slice();
|
|
320
|
+
const priceChips = [...buckets, ...priceSelected.filter((id) => !buckets.some((c) => c.id === id)).map((id) => ({ id, label: bucketLabel(id, currency) }))];
|
|
321
|
+
const chosen = new Map<string, string[]>();
|
|
322
|
+
SLOT_KEYS.forEach(([nameK, , selK]) => { if (s[nameK] && s[selK].length) chosen.set(s[nameK], s[selK]); });
|
|
323
|
+
const offered = optionFacets(scope).map((f) => {
|
|
324
|
+
const keep = (chosen.get(f.name) ?? []).filter((v) => !f.chips.some((c) => c.id === v));
|
|
325
|
+
return { name: f.name, chips: [...f.chips, ...keep.map((v) => ({ id: v, label: v }))] };
|
|
326
|
+
});
|
|
327
|
+
for (const [name, values] of chosen) {
|
|
328
|
+
if (!offered.some((f) => f.name === name)) offered.push({ name, chips: values.map((v) => ({ id: v, label: v })) });
|
|
329
|
+
}
|
|
330
|
+
// Slots are addressed by index (selectOpt1..3), so a facet keeps the slot it is in while
|
|
331
|
+
// it exists — a page landing under an open sheet never moves it under the shopper's finger.
|
|
332
|
+
// A selected facet always has a slot; free slots take the rest in first-seen order.
|
|
333
|
+
const byName = (name: string) => offered.find((f) => f.name === name);
|
|
334
|
+
const facets: ({ name: string; chips: Chip[] } | undefined)[] = SLOT_KEYS.map(([nameK]) => (s[nameK] ? byName(s[nameK]) : undefined));
|
|
335
|
+
const placed = () => new Set(facets.filter(Boolean).map((f) => f!.name));
|
|
336
|
+
for (const name of chosen.keys()) {
|
|
337
|
+
if (placed().has(name)) continue;
|
|
338
|
+
let slot = facets.findIndex((f) => !f);
|
|
339
|
+
if (slot < 0) slot = facets.map((f) => !!f && chosen.has(f.name)).lastIndexOf(false);
|
|
340
|
+
if (slot >= 0) facets[slot] = byName(name);
|
|
341
|
+
}
|
|
342
|
+
for (const f of offered) {
|
|
343
|
+
if (placed().has(f.name)) continue;
|
|
344
|
+
const slot = facets.findIndex((x) => !x);
|
|
345
|
+
if (slot < 0) break;
|
|
346
|
+
facets[slot] = f;
|
|
347
|
+
}
|
|
348
|
+
const next: T = { ...s, priceChips, priceSelected, hidePrice: priceChips.length === 0 };
|
|
349
|
+
SLOT_KEYS.forEach(([nameK, chipsK, selK, hideK], i) => {
|
|
350
|
+
const f = facets[i];
|
|
351
|
+
next[nameK] = f?.name ?? "";
|
|
352
|
+
next[chipsK] = f?.chips ?? [];
|
|
353
|
+
next[selK] = f ? chosen.get(f.name) ?? [] : [];
|
|
354
|
+
next[hideK] = !f;
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
const optionFilters = SLOT_KEYS
|
|
358
|
+
.map(([nameK, , selK]) => ({ name: next[nameK], values: next[selK] }))
|
|
359
|
+
.filter((f) => f.name && f.values.length);
|
|
360
|
+
const matched = scope.filter((r) =>
|
|
361
|
+
(priceSelected.length === 0 || priceSelected.some((id) => inBucket(r.price, id)))
|
|
362
|
+
&& optionFilters.every((f) => (r.options[f.name] ?? []).some((v) => f.values.includes(v))));
|
|
363
|
+
|
|
364
|
+
const saved = savedIds();
|
|
365
|
+
const ordered = sortRows(matched, s.sort).map((r) => ({ ...r.card, saved: saved.has(r.card.id), notSaved: !saved.has(r.card.id) }));
|
|
366
|
+
const visible = Math.max(PAGE_SIZE, s.visible);
|
|
367
|
+
const active = priceSelected.length + optionFilters.reduce((n, f) => n + f.values.length, 0);
|
|
368
|
+
const choices = sorts();
|
|
369
|
+
next.matched = ordered.length;
|
|
370
|
+
next.visible = visible;
|
|
371
|
+
next.results = ordered.slice(0, visible);
|
|
372
|
+
// The renderer requests each cursor at most once. Include the filter/scope
|
|
373
|
+
// epoch as well as the visible page so a reset to page one is still new.
|
|
374
|
+
next.nextCursor = ordered.length > visible ? `${s.cursorEpoch}:${visible}` : "";
|
|
375
|
+
next.noMore = ordered.length <= visible;
|
|
376
|
+
next.sortOptions = choices.map((c) => option(c, c.id === s.sort));
|
|
377
|
+
next.sortLabel = (choices.find((c) => c.id === s.sort) ?? choices[0]).label;
|
|
378
|
+
next.filtersOpen = s.panel === "filters";
|
|
379
|
+
next.sortOpen = s.panel === "sort";
|
|
380
|
+
next.activeFilters = active;
|
|
381
|
+
next.noActiveFilters = active === 0;
|
|
382
|
+
next.filterLabel = active
|
|
383
|
+
? copyTemplate("storeFacets.filterActive", "Filter ({count})", { count: active })
|
|
384
|
+
: copyParam("storeFacets.filter", "Filter");
|
|
385
|
+
next.showLabel = copyTemplate("storeFacets.show", "Show {count}", { count: countText(ordered.length) });
|
|
386
|
+
next.hideGrid = s.view !== "grid";
|
|
387
|
+
next.hideList = s.view !== "list";
|
|
388
|
+
next.hideComfortable = s.density !== "comfortable";
|
|
389
|
+
next.hideCompact = s.density !== "compact";
|
|
390
|
+
return next;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
// View preference (grid / list, comfortable / compact) — device KV, shared by Shop and
|
|
395
|
+
// Search so the shopper's choice holds on both. A failed read or write never blocks.
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
type StorageLike = { getItem(key: string): string | null; setItem(key: string, value: string): void };
|
|
399
|
+
const VIEW_KEY = "himi-store:view:v1";
|
|
400
|
+
function storage(): StorageLike | undefined {
|
|
401
|
+
const host = globalThis as { himalaya?: { storage?: StorageLike }; localStorage?: StorageLike };
|
|
402
|
+
return host.himalaya?.storage ?? host.localStorage;
|
|
403
|
+
}
|
|
404
|
+
export function readViewPrefs(): { view: View; density: Density } {
|
|
405
|
+
try {
|
|
406
|
+
const raw = JSON.parse(storage()?.getItem(VIEW_KEY) ?? "{}") as { view?: unknown; density?: unknown };
|
|
407
|
+
return { view: raw.view === "list" ? "list" : "grid", density: raw.density === "compact" ? "compact" : "comfortable" };
|
|
408
|
+
} catch {
|
|
409
|
+
return { view: "grid", density: "comfortable" };
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
function writeViewPrefs(view: View, density: Density): void {
|
|
413
|
+
try { storage()?.setItem(VIEW_KEY, JSON.stringify({ view, density })); } catch { /* keep in memory */ }
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
// Action argument readers + the shared actions
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
/** A tapped row's `{ id }` param — tolerant of a bare string. */
|
|
421
|
+
export function idOf(arg: unknown): string {
|
|
422
|
+
return typeof arg === "string" ? arg : String((arg as { id?: unknown } | undefined)?.id ?? "");
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** A FilterChips `onChange` payload's selected ids. */
|
|
426
|
+
export function selected(arg: unknown): string[] {
|
|
427
|
+
const ids = (arg as { selectedIds?: unknown } | undefined)?.selectedIds;
|
|
428
|
+
return Array.isArray(ids) ? ids.filter((x): x is string => typeof x === "string") : [];
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function selectedId(arg: unknown): string {
|
|
432
|
+
return String((arg as { selectedId?: unknown } | undefined)?.selectedId ?? "");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Toggle one card's saved state in the shared saved-products store. Throws when
|
|
436
|
+
* storage fails, so the caller keeps its state and the heart never lies. */
|
|
437
|
+
export function toggleSaved(cards: ProductCard[], id: string): void {
|
|
438
|
+
const card = cards.find((c) => c.id === id);
|
|
439
|
+
if (!card) return;
|
|
440
|
+
toggleSavedProduct({ id: card.id, name: card.name, price: card.commercePrice || card.price, image: card.image,
|
|
441
|
+
wasPrice: card.commerceWasPrice ?? card.wasPrice,
|
|
442
|
+
swatches: [card.swatch1, card.swatch2, card.swatch3, card.swatch4, card.swatch5].filter(Boolean) });
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** The sort / filter / view actions, identical on every screen that has the sheets.
|
|
446
|
+
* A change of sort or filter starts the reveal over at the first page. */
|
|
447
|
+
export function facetActions<T extends FacetState>(derive: (s: T) => T) {
|
|
448
|
+
const first = (s: T) => ({ visible: PAGE_SIZE, cursorEpoch: s.cursorEpoch + 1 });
|
|
449
|
+
return {
|
|
450
|
+
// Choosing a sort applies it and closes the sort sheet.
|
|
451
|
+
sort: (s: T, arg?: unknown): T => {
|
|
452
|
+
const id = idOf(arg);
|
|
453
|
+
if (!SORT_IDS.includes(id as (typeof SORT_IDS)[number])) return s;
|
|
454
|
+
return derive({ ...s, ...first(s), sort: id, panel: "none" });
|
|
455
|
+
},
|
|
456
|
+
openFilters: (s: T): T => derive({ ...s, panel: "filters" }),
|
|
457
|
+
openSort: (s: T): T => derive({ ...s, panel: "sort" }),
|
|
458
|
+
closePanel: (s: T): T => derive({ ...s, panel: "none" }),
|
|
459
|
+
selectPrice: (s: T, arg?: unknown): T => derive({ ...s, ...first(s), priceSelected: selected(arg) }),
|
|
460
|
+
selectOpt1: (s: T, arg?: unknown): T => derive({ ...s, ...first(s), opt1Selected: selected(arg) }),
|
|
461
|
+
selectOpt2: (s: T, arg?: unknown): T => derive({ ...s, ...first(s), opt2Selected: selected(arg) }),
|
|
462
|
+
selectOpt3: (s: T, arg?: unknown): T => derive({ ...s, ...first(s), opt3Selected: selected(arg) }),
|
|
463
|
+
clearFilters: (s: T): T => derive({ ...s, ...first(s), priceSelected: [], opt1Selected: [], opt2Selected: [], opt3Selected: [] }),
|
|
464
|
+
showMore: (s: T): T => (s.noMore ? s : derive({ ...s, visible: s.visible + PAGE_SIZE })),
|
|
465
|
+
setView: (s: T, arg?: unknown): T => {
|
|
466
|
+
const view = selectedId(arg);
|
|
467
|
+
if (view !== "grid" && view !== "list") return s;
|
|
468
|
+
writeViewPrefs(view, s.density);
|
|
469
|
+
return derive({ ...s, view });
|
|
470
|
+
},
|
|
471
|
+
setDensity: (s: T, arg?: unknown): T => {
|
|
472
|
+
const density = selectedId(arg);
|
|
473
|
+
if (density !== "comfortable" && density !== "compact") return s;
|
|
474
|
+
writeViewPrefs(s.view, density);
|
|
475
|
+
return derive({ ...s, density });
|
|
476
|
+
},
|
|
477
|
+
};
|
|
478
|
+
}
|