@wix/himalaya-cli 0.1096.0 → 0.1098.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-v1/src/display.d.ts +11 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/stores-v1/src/extensions.d.ts +1 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/stores-v1/src/options.d.ts +74 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-stores.mjs +132 -0
- package/dist/cli.mjs +106 -27
- package/guides/headless-loop.md +5 -5
- package/guides/sdk-reference.json +61 -1
- package/guides/skill.md +5 -5
- package/package.json +2 -2
- package/templates/beauty-salon/dev/index.ts +215 -1
- package/templates/beauty-salon/tier5-src/himi-store-product/index.ts +214 -7
- package/templates/branded-lite/dev/index.ts +215 -1
- package/templates/branded-lite/tier5-src/himi-store-product/index.ts +214 -7
- package/templates/gym-fitness/dev/index.ts +215 -1
- package/templates/gym-fitness/tier5-src/himi-store-product/index.ts +214 -7
- package/templates/portfolio-creative/dev/index.ts +215 -1
- package/templates/portfolio-creative/tier5-src/himi-store-product/index.ts +214 -7
- package/templates/retail-store/dev/index.ts +215 -1
- package/templates/retail-store/tier5-src/himi-store-product/index.ts +214 -7
|
@@ -25998,6 +25998,219 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
|
|
|
25998
25998
|
}
|
|
25999
25999
|
}
|
|
26000
26000
|
},
|
|
26001
|
+
{
|
|
26002
|
+
"id": "himi_product.options_card",
|
|
26003
|
+
"body": {
|
|
26004
|
+
"package": "himalaya",
|
|
26005
|
+
"type": "VStack",
|
|
26006
|
+
"props": {
|
|
26007
|
+
"spacing": 12,
|
|
26008
|
+
"alignment": "leading",
|
|
26009
|
+
"children": [
|
|
26010
|
+
{
|
|
26011
|
+
"id": "himi_product.opt1_row",
|
|
26012
|
+
"body": {
|
|
26013
|
+
"package": "himalaya",
|
|
26014
|
+
"type": "VStack",
|
|
26015
|
+
"props": {
|
|
26016
|
+
"spacing": 8,
|
|
26017
|
+
"alignment": "leading",
|
|
26018
|
+
"children": [
|
|
26019
|
+
{
|
|
26020
|
+
"id": "",
|
|
26021
|
+
"body": {
|
|
26022
|
+
"package": "himalaya",
|
|
26023
|
+
"type": "Text",
|
|
26024
|
+
"props": {
|
|
26025
|
+
"text": "$service:himiStoreProduct.opt1Label",
|
|
26026
|
+
"style": "bodyEmphasis",
|
|
26027
|
+
"collapseWhenEmpty": true
|
|
26028
|
+
}
|
|
26029
|
+
}
|
|
26030
|
+
},
|
|
26031
|
+
{
|
|
26032
|
+
"id": "himi_product.opt1",
|
|
26033
|
+
"body": {
|
|
26034
|
+
"package": "himalaya",
|
|
26035
|
+
"type": "FilterChips",
|
|
26036
|
+
"props": {
|
|
26037
|
+
"chips": [],
|
|
26038
|
+
"optionsPath": "$service:himiStoreProduct.opt1Chips",
|
|
26039
|
+
"selectedIds": [
|
|
26040
|
+
"$service:himiStoreProduct.opt1Selected"
|
|
26041
|
+
],
|
|
26042
|
+
"onChange": {
|
|
26043
|
+
"actionId": "tier5.himiStoreProduct.selectOption",
|
|
26044
|
+
"params": {
|
|
26045
|
+
"group": "1"
|
|
26046
|
+
}
|
|
26047
|
+
}
|
|
26048
|
+
}
|
|
26049
|
+
},
|
|
26050
|
+
"modifiers": {
|
|
26051
|
+
"accessibilityLabel": "$service:himiStoreProduct.opt1Label"
|
|
26052
|
+
}
|
|
26053
|
+
}
|
|
26054
|
+
]
|
|
26055
|
+
}
|
|
26056
|
+
},
|
|
26057
|
+
"modifiers": {
|
|
26058
|
+
"hiddenPath": "$service:himiStoreProduct.noOpt1"
|
|
26059
|
+
}
|
|
26060
|
+
},
|
|
26061
|
+
{
|
|
26062
|
+
"id": "himi_product.opt2_row",
|
|
26063
|
+
"body": {
|
|
26064
|
+
"package": "himalaya",
|
|
26065
|
+
"type": "VStack",
|
|
26066
|
+
"props": {
|
|
26067
|
+
"spacing": 8,
|
|
26068
|
+
"alignment": "leading",
|
|
26069
|
+
"children": [
|
|
26070
|
+
{
|
|
26071
|
+
"id": "",
|
|
26072
|
+
"body": {
|
|
26073
|
+
"package": "himalaya",
|
|
26074
|
+
"type": "Text",
|
|
26075
|
+
"props": {
|
|
26076
|
+
"text": "$service:himiStoreProduct.opt2Label",
|
|
26077
|
+
"style": "bodyEmphasis",
|
|
26078
|
+
"collapseWhenEmpty": true
|
|
26079
|
+
}
|
|
26080
|
+
}
|
|
26081
|
+
},
|
|
26082
|
+
{
|
|
26083
|
+
"id": "himi_product.opt2",
|
|
26084
|
+
"body": {
|
|
26085
|
+
"package": "himalaya",
|
|
26086
|
+
"type": "FilterChips",
|
|
26087
|
+
"props": {
|
|
26088
|
+
"chips": [],
|
|
26089
|
+
"optionsPath": "$service:himiStoreProduct.opt2Chips",
|
|
26090
|
+
"selectedIds": [
|
|
26091
|
+
"$service:himiStoreProduct.opt2Selected"
|
|
26092
|
+
],
|
|
26093
|
+
"onChange": {
|
|
26094
|
+
"actionId": "tier5.himiStoreProduct.selectOption",
|
|
26095
|
+
"params": {
|
|
26096
|
+
"group": "2"
|
|
26097
|
+
}
|
|
26098
|
+
}
|
|
26099
|
+
}
|
|
26100
|
+
},
|
|
26101
|
+
"modifiers": {
|
|
26102
|
+
"accessibilityLabel": "$service:himiStoreProduct.opt2Label"
|
|
26103
|
+
}
|
|
26104
|
+
}
|
|
26105
|
+
]
|
|
26106
|
+
}
|
|
26107
|
+
},
|
|
26108
|
+
"modifiers": {
|
|
26109
|
+
"hiddenPath": "$service:himiStoreProduct.noOpt2"
|
|
26110
|
+
}
|
|
26111
|
+
},
|
|
26112
|
+
{
|
|
26113
|
+
"id": "himi_product.opt3_row",
|
|
26114
|
+
"body": {
|
|
26115
|
+
"package": "himalaya",
|
|
26116
|
+
"type": "VStack",
|
|
26117
|
+
"props": {
|
|
26118
|
+
"spacing": 8,
|
|
26119
|
+
"alignment": "leading",
|
|
26120
|
+
"children": [
|
|
26121
|
+
{
|
|
26122
|
+
"id": "",
|
|
26123
|
+
"body": {
|
|
26124
|
+
"package": "himalaya",
|
|
26125
|
+
"type": "Text",
|
|
26126
|
+
"props": {
|
|
26127
|
+
"text": "$service:himiStoreProduct.opt3Label",
|
|
26128
|
+
"style": "bodyEmphasis",
|
|
26129
|
+
"collapseWhenEmpty": true
|
|
26130
|
+
}
|
|
26131
|
+
}
|
|
26132
|
+
},
|
|
26133
|
+
{
|
|
26134
|
+
"id": "himi_product.opt3",
|
|
26135
|
+
"body": {
|
|
26136
|
+
"package": "himalaya",
|
|
26137
|
+
"type": "FilterChips",
|
|
26138
|
+
"props": {
|
|
26139
|
+
"chips": [],
|
|
26140
|
+
"optionsPath": "$service:himiStoreProduct.opt3Chips",
|
|
26141
|
+
"selectedIds": [
|
|
26142
|
+
"$service:himiStoreProduct.opt3Selected"
|
|
26143
|
+
],
|
|
26144
|
+
"onChange": {
|
|
26145
|
+
"actionId": "tier5.himiStoreProduct.selectOption",
|
|
26146
|
+
"params": {
|
|
26147
|
+
"group": "3"
|
|
26148
|
+
}
|
|
26149
|
+
}
|
|
26150
|
+
}
|
|
26151
|
+
},
|
|
26152
|
+
"modifiers": {
|
|
26153
|
+
"accessibilityLabel": "$service:himiStoreProduct.opt3Label"
|
|
26154
|
+
}
|
|
26155
|
+
}
|
|
26156
|
+
]
|
|
26157
|
+
}
|
|
26158
|
+
},
|
|
26159
|
+
"modifiers": {
|
|
26160
|
+
"hiddenPath": "$service:himiStoreProduct.noOpt3"
|
|
26161
|
+
}
|
|
26162
|
+
},
|
|
26163
|
+
{
|
|
26164
|
+
"id": "himi_product.options_hint",
|
|
26165
|
+
"body": {
|
|
26166
|
+
"package": "himalaya",
|
|
26167
|
+
"type": "Text",
|
|
26168
|
+
"props": {
|
|
26169
|
+
"text": "$service:himiStoreProduct.optionsHint",
|
|
26170
|
+
"style": "footnote",
|
|
26171
|
+
"color": "textSecondary",
|
|
26172
|
+
"collapseWhenEmpty": true
|
|
26173
|
+
}
|
|
26174
|
+
}
|
|
26175
|
+
},
|
|
26176
|
+
{
|
|
26177
|
+
"id": "",
|
|
26178
|
+
"body": {
|
|
26179
|
+
"package": "himalaya",
|
|
26180
|
+
"type": "Text",
|
|
26181
|
+
"props": {
|
|
26182
|
+
"text": "This product has more options than this screen can show. Open it on the website to order.",
|
|
26183
|
+
"style": "footnote",
|
|
26184
|
+
"color": "textSecondary"
|
|
26185
|
+
}
|
|
26186
|
+
},
|
|
26187
|
+
"modifiers": {
|
|
26188
|
+
"hiddenPath": "$service:himiStoreProduct.noOptionsOverflow"
|
|
26189
|
+
}
|
|
26190
|
+
}
|
|
26191
|
+
]
|
|
26192
|
+
}
|
|
26193
|
+
},
|
|
26194
|
+
"modifiers": {
|
|
26195
|
+
"background": {
|
|
26196
|
+
"token": "surfaceElevated"
|
|
26197
|
+
},
|
|
26198
|
+
"border": {
|
|
26199
|
+
"width": 1,
|
|
26200
|
+
"color": {
|
|
26201
|
+
"token": "border"
|
|
26202
|
+
}
|
|
26203
|
+
},
|
|
26204
|
+
"cornerRadius": 8,
|
|
26205
|
+
"padding": {
|
|
26206
|
+
"leading": 14,
|
|
26207
|
+
"trailing": 14,
|
|
26208
|
+
"top": 14,
|
|
26209
|
+
"bottom": 14
|
|
26210
|
+
},
|
|
26211
|
+
"hiddenPath": "$service:himiStoreProduct.noOptions"
|
|
26212
|
+
}
|
|
26213
|
+
},
|
|
26001
26214
|
{
|
|
26002
26215
|
"id": "himi_product.qty_card",
|
|
26003
26216
|
"body": {
|
|
@@ -26282,7 +26495,8 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
|
|
|
26282
26495
|
"fullWidth": true,
|
|
26283
26496
|
"action": {
|
|
26284
26497
|
"actionId": "tier5.himiStoreProduct.addToCart"
|
|
26285
|
-
}
|
|
26498
|
+
},
|
|
26499
|
+
"disabledPath": "$service:himiStoreProduct.optionsIncomplete"
|
|
26286
26500
|
}
|
|
26287
26501
|
},
|
|
26288
26502
|
"modifiers": {
|
|
@@ -3,7 +3,9 @@ import { defineService } from "@himalaya/state";
|
|
|
3
3
|
import { share as shareSheet } from "@himalaya/share";
|
|
4
4
|
import { addToCurrentCart, getCurrentCart } from "@himalaya/wix-ecom";
|
|
5
5
|
import { get, upsert, enqueue, type DbEntity } from "@himalaya/local-db";
|
|
6
|
-
import { getStoreProduct, plainText, productImage, productPrice, wixMediaUrl, productGallery as sdkProductGallery, WIX_DETAIL_IMAGE, type StoreProduct
|
|
6
|
+
import { getStoreProduct, plainText, productImage, productPrice, wixMediaUrl, productGallery as sdkProductGallery, WIX_DETAIL_IMAGE, type StoreProduct,
|
|
7
|
+
productOptions, defaultSelection, isSelectionComplete, selectionSummary, cartOptionsPayload,
|
|
8
|
+
type StoreOption, type StoreOptionChoice, type StoreOptionSelection } from "../_shared/himi-store.js";
|
|
7
9
|
import { cleanTitle } from "../_shared/himi-text.js";
|
|
8
10
|
import { stashCopy, copyParam, copyTemplate } from "../_shared/himi-copy.js";
|
|
9
11
|
import { isBridgeUnavailable } from "../_shared/owner-load-error.js";
|
|
@@ -39,6 +41,85 @@ interface GalleryItem {
|
|
|
39
41
|
url: string;
|
|
40
42
|
}
|
|
41
43
|
|
|
44
|
+
interface OptionChip {
|
|
45
|
+
id: string;
|
|
46
|
+
label: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Product options (Colour, Size, …)
|
|
51
|
+
//
|
|
52
|
+
// A product with options is not buyable without them: the cart line goes out with
|
|
53
|
+
// no colour and no size, and the merchant cannot fulfil the order. The reader and
|
|
54
|
+
// the cart payload live in @himalaya/wix-stores (one reader, both catalogs, and a
|
|
55
|
+
// cart payload that is catalog-specific in a way a per-app copy gets wrong) — this
|
|
56
|
+
// worker only holds the shopper's choice and hides the slots it does not need.
|
|
57
|
+
//
|
|
58
|
+
// THREE fixed slots, because a screen descriptor is static: it cannot loop over N
|
|
59
|
+
// groups when N is unknown at authoring time. Catalog V3 allows up to 6. A product
|
|
60
|
+
// with more groups than slots is NOT silently half-configured — the overflow hint
|
|
61
|
+
// renders and Add to cart stays disabled, because a line item for the wrong variant
|
|
62
|
+
// is worse than one the shopper cannot place yet. Give the descriptor a bounded
|
|
63
|
+
// repeater and this cap goes away.
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
const OPTION_SLOTS = 3;
|
|
66
|
+
|
|
67
|
+
/** The raw product behind the current screen. NOT in published state: the V3 cart
|
|
68
|
+
* payload is resolved from `variantsInfo`, which no renderer needs to see. */
|
|
69
|
+
let lastProduct: StoreProduct | undefined;
|
|
70
|
+
|
|
71
|
+
/** The product id `lastProduct` was read for. A pooled screen re-appearing calls
|
|
72
|
+
* `refreshServices()` → `load()`, so this is what tells a genuine navigation to a
|
|
73
|
+
* DIFFERENT product (drop the picks) from the same product coming back (keep them). */
|
|
74
|
+
let lastLoadedId: string | undefined;
|
|
75
|
+
|
|
76
|
+
interface OptionSlots {
|
|
77
|
+
opt1Label: string; opt1Chips: OptionChip[]; opt1Selected: string[]; noOpt1: boolean;
|
|
78
|
+
opt2Label: string; opt2Chips: OptionChip[]; opt2Selected: string[]; noOpt2: boolean;
|
|
79
|
+
opt3Label: string; opt3Chips: OptionChip[]; opt3Selected: string[]; noOpt3: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function slotsOf(groups: StoreOption[], selection: StoreOptionSelection): OptionSlots {
|
|
83
|
+
const out = {} as Record<string, unknown>;
|
|
84
|
+
for (let i = 0; i < OPTION_SLOTS; i++) {
|
|
85
|
+
const g = groups[i];
|
|
86
|
+
const n = i + 1;
|
|
87
|
+
const chosen = g ? selection[g.id] ?? "" : "";
|
|
88
|
+
out[`opt${n}Label`] = g?.name ?? "";
|
|
89
|
+
// Out-of-stock choices stay selectable but say so — hiding them makes a product
|
|
90
|
+
// look like it has fewer colours than it does, and the stock flag is per-choice
|
|
91
|
+
// across variants, not a promise about the combination.
|
|
92
|
+
out[`opt${n}Chips`] = (g?.choices ?? []).map((c) => ({ id: c.id, label: c.inStock ? c.label : `${c.label} · ${copyParam("product.optionOutOfStock", "Out of stock")}` }));
|
|
93
|
+
out[`opt${n}Selected`] = chosen ? [chosen] : [];
|
|
94
|
+
out[`noOpt${n}`] = !g;
|
|
95
|
+
}
|
|
96
|
+
return out as unknown as OptionSlots;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The chosen choice that cannot be bought, if any. A chip stays SELECTABLE when it
|
|
100
|
+
* is out of stock — hiding it makes a product look like it has fewer colours than it
|
|
101
|
+
* does — but picking it must not reach the cart: the add was accepted and only failed
|
|
102
|
+
* at purchase, which is the worst place to learn it. */
|
|
103
|
+
function outOfStockPick(groups: StoreOption[], selection: StoreOptionSelection): StoreOptionChoice | undefined {
|
|
104
|
+
for (const g of groups) {
|
|
105
|
+
const chosen = g.choices.find((c) => c.id === selection[g.id]);
|
|
106
|
+
if (chosen && chosen.inStock === false) return chosen;
|
|
107
|
+
}
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** What to say under the picker: the groups still unchosen, else the choice made. */
|
|
112
|
+
function optionsHint(groups: StoreOption[], selection: StoreOptionSelection): string {
|
|
113
|
+
const missing = groups.filter((g) => !selection[g.id]).map((g) => g.name);
|
|
114
|
+
if (!missing.length) {
|
|
115
|
+
const blocked = outOfStockPick(groups, selection);
|
|
116
|
+
return blocked
|
|
117
|
+
? copyTemplate("product.optionOutOfStockHint", "{choice} is out of stock. Choose another.", { choice: blocked.label })
|
|
118
|
+
: selectionSummary(groups, selection);
|
|
119
|
+
}
|
|
120
|
+
return copyTemplate("product.optionsChoose", "Choose {options}", { options: missing.join(", ") });
|
|
121
|
+
}
|
|
122
|
+
|
|
42
123
|
interface S {
|
|
43
124
|
status: "loading" | "ready" | "error";
|
|
44
125
|
name: string;
|
|
@@ -79,6 +160,26 @@ interface S {
|
|
|
79
160
|
saved: boolean;
|
|
80
161
|
saveLabel: string;
|
|
81
162
|
saveHint: string;
|
|
163
|
+
/** The product's option groups, in site order — the source the slots below and
|
|
164
|
+
* the cart payload are derived from. */
|
|
165
|
+
optionGroups: StoreOption[];
|
|
166
|
+
/** option id → chosen choice id. A group the shopper has not picked is absent. */
|
|
167
|
+
selection: StoreOptionSelection;
|
|
168
|
+
hasOptions: boolean;
|
|
169
|
+
noOptions: boolean;
|
|
170
|
+
/** More option groups than the descriptor has slots — see OPTION_SLOTS. */
|
|
171
|
+
optionsOverflow: boolean;
|
|
172
|
+
noOptionsOverflow: boolean;
|
|
173
|
+
/** Add to cart is blocked: this product has options and they are not all chosen. */
|
|
174
|
+
optionsIncomplete: boolean;
|
|
175
|
+
/** The product was served from the offline cache, which carries no option groups.
|
|
176
|
+
* "No groups" here means UNKNOWN, not "none" — so the CTA must stay blocked rather
|
|
177
|
+
* than offer a one-tap add for a product that may need a colour and a size. */
|
|
178
|
+
optionsUnknown: boolean;
|
|
179
|
+
optionsHint: string;
|
|
180
|
+
opt1Label: string; opt1Chips: OptionChip[]; opt1Selected: string[]; noOpt1: boolean;
|
|
181
|
+
opt2Label: string; opt2Chips: OptionChip[]; opt2Selected: string[]; noOpt2: boolean;
|
|
182
|
+
opt3Label: string; opt3Chips: OptionChip[]; opt3Selected: string[]; noOpt3: boolean;
|
|
82
183
|
}
|
|
83
184
|
|
|
84
185
|
function inputId(): string {
|
|
@@ -148,8 +249,22 @@ function derive(s: S): S {
|
|
|
148
249
|
const empty = s.status === "ready" && !s.name;
|
|
149
250
|
const hasGallery = s.gallery.length > 1;
|
|
150
251
|
const hasSingleImage = !!s.image && !hasGallery;
|
|
252
|
+
const groups = s.optionGroups;
|
|
253
|
+
const overflow = groups.length > OPTION_SLOTS;
|
|
151
254
|
return {
|
|
152
255
|
...s,
|
|
256
|
+
...slotsOf(groups, s.selection),
|
|
257
|
+
hasOptions: groups.length > 0,
|
|
258
|
+
noOptions: groups.length === 0,
|
|
259
|
+
optionsOverflow: overflow,
|
|
260
|
+
noOptionsOverflow: !overflow,
|
|
261
|
+
// Overflow counts as incomplete on purpose: the shopper cannot choose a group
|
|
262
|
+
// the screen never rendered, so the only variant we could send would be a guess.
|
|
263
|
+
// `optionsUnknown` blocks too: a cached product whose options we never read is
|
|
264
|
+
// indistinguishable from one with none, and adding it would post the variant-less
|
|
265
|
+
// line this whole feature exists to prevent.
|
|
266
|
+
optionsIncomplete: s.optionsUnknown || !!outOfStockPick(groups, s.selection) || (groups.length > 0 && (overflow || !isSelectionComplete(groups, s.selection))),
|
|
267
|
+
optionsHint: groups.length ? optionsHint(groups, s.selection) : "",
|
|
153
268
|
saveLabel: s.saved ? copyParam("product.saved", "Saved") : copyParam("product.save", "Save product"),
|
|
154
269
|
notAdding: !s.adding,
|
|
155
270
|
notAdded: !s.added,
|
|
@@ -170,6 +285,7 @@ function derive(s: S): S {
|
|
|
170
285
|
const svc = defineService<S, {
|
|
171
286
|
load: (s: S, arg?: unknown) => Promise<S>;
|
|
172
287
|
setQuantity: (s: S, arg?: unknown) => S;
|
|
288
|
+
selectOption: (s: S, arg?: unknown) => S;
|
|
173
289
|
addToCart: (s: S) => Promise<S>;
|
|
174
290
|
beginAdd: (s: S, arg?: unknown) => S;
|
|
175
291
|
share: (s: S) => Promise<S>;
|
|
@@ -206,6 +322,18 @@ const svc = defineService<S, {
|
|
|
206
322
|
saved: false,
|
|
207
323
|
saveLabel: "",
|
|
208
324
|
saveHint: "",
|
|
325
|
+
optionGroups: [],
|
|
326
|
+
selection: {},
|
|
327
|
+
hasOptions: false,
|
|
328
|
+
noOptions: true,
|
|
329
|
+
optionsOverflow: false,
|
|
330
|
+
noOptionsOverflow: true,
|
|
331
|
+
optionsIncomplete: false,
|
|
332
|
+
optionsUnknown: false,
|
|
333
|
+
optionsHint: "",
|
|
334
|
+
opt1Label: "", opt1Chips: [], opt1Selected: [], noOpt1: true,
|
|
335
|
+
opt2Label: "", opt2Chips: [], opt2Selected: [], noOpt2: true,
|
|
336
|
+
opt3Label: "", opt3Chips: [], opt3Selected: [], noOpt3: true,
|
|
209
337
|
}),
|
|
210
338
|
actions: {
|
|
211
339
|
load: async (s, arg) => {
|
|
@@ -217,15 +345,39 @@ const svc = defineService<S, {
|
|
|
217
345
|
s = { ...s, saved, saveHint: "", adding: false, added: false };
|
|
218
346
|
// A fresh navigation into the product resets any quantity left over
|
|
219
347
|
// from a previous visit to the same worker instance.
|
|
220
|
-
if (!id)
|
|
348
|
+
if (!id) {
|
|
349
|
+
lastProduct = undefined;
|
|
350
|
+
lastLoadedId = undefined;
|
|
351
|
+
return derive({ ...s, status: "ready", quantity: 1, optionGroups: [], selection: {}, optionsUnknown: false, emptyText: copyParam("product.notFoundText", "Product not found.") });
|
|
352
|
+
}
|
|
221
353
|
try {
|
|
222
354
|
const r = await getStoreProduct(id);
|
|
223
355
|
const p = (r.product ?? (r as unknown as StoreProduct)) as StoreProduct;
|
|
224
356
|
const name = cleanTitle(p.name) || "Product";
|
|
225
357
|
const image = productImage(p, WIX_DETAIL_IMAGE);
|
|
358
|
+
lastProduct = p;
|
|
359
|
+
// A group with exactly one choice is chosen for the shopper; a real choice
|
|
360
|
+
// is left blank so the pick is deliberate.
|
|
361
|
+
const optionGroups = productOptions(p);
|
|
362
|
+
// A pooled screen re-appearing re-runs `load`. Wiping `selection` there means a
|
|
363
|
+
// trip to the cart and back silently clears the size the shopper picked and
|
|
364
|
+
// re-disables the CTA with no explanation. Carry the picks when this is the SAME
|
|
365
|
+
// product, and only for a choice the product still offers — a stale id would
|
|
366
|
+
// resolve to no variant and block the cart just as silently.
|
|
367
|
+
const carried: StoreOptionSelection = {};
|
|
368
|
+
if (id === lastLoadedId) {
|
|
369
|
+
for (const g of optionGroups) {
|
|
370
|
+
const chosen = s.selection[g.id];
|
|
371
|
+
if (chosen && g.choices.some((c) => c.id === chosen)) carried[g.id] = chosen;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
lastLoadedId = id;
|
|
226
375
|
return derive({
|
|
227
376
|
...s,
|
|
228
377
|
status: "ready",
|
|
378
|
+
optionGroups,
|
|
379
|
+
selection: { ...defaultSelection(optionGroups), ...carried },
|
|
380
|
+
optionsUnknown: false,
|
|
229
381
|
name,
|
|
230
382
|
price: productPrice(p),
|
|
231
383
|
description: plainText(p.plainDescription || p.description),
|
|
@@ -244,21 +396,34 @@ const svc = defineService<S, {
|
|
|
244
396
|
if (cached) {
|
|
245
397
|
const name = String(cached.name ?? "") || "Product";
|
|
246
398
|
const image = String(cached.image ?? "");
|
|
399
|
+
// The cached list row carries no options, and guessing "none" would offer
|
|
400
|
+
// a one-tap Add to cart for a product that needs a colour and a size. So
|
|
401
|
+
// the offline detail shows no picker AND marks the options UNKNOWN, which
|
|
402
|
+
// keeps `optionsIncomplete` true: the CTA stays disabled and `addToCart`
|
|
403
|
+
// refuses. `cartHint` carries the reason, because the options card is hidden
|
|
404
|
+
// on `noOptions` and would otherwise leave a dead button unexplained.
|
|
405
|
+
lastProduct = undefined;
|
|
406
|
+
lastLoadedId = undefined;
|
|
247
407
|
return derive({
|
|
248
408
|
...s,
|
|
249
409
|
status: "ready",
|
|
410
|
+
optionGroups: [],
|
|
411
|
+
selection: {},
|
|
412
|
+
optionsUnknown: true,
|
|
250
413
|
name,
|
|
251
414
|
price: String(cached.subtitle ?? ""),
|
|
252
415
|
description: "",
|
|
253
416
|
image,
|
|
254
417
|
initial: String(cached.initial ?? "") || (name[0] || "#").toUpperCase(),
|
|
255
|
-
cartHint: "",
|
|
418
|
+
cartHint: copyParam("product.cartHintOffline", "Reconnect to choose options and add this to your cart."),
|
|
256
419
|
cartCount: await cartItemCount(),
|
|
257
420
|
emptyText: "",
|
|
258
421
|
quantity: 1,
|
|
259
422
|
});
|
|
260
423
|
}
|
|
261
|
-
|
|
424
|
+
lastProduct = undefined;
|
|
425
|
+
lastLoadedId = undefined;
|
|
426
|
+
return derive({ ...s, status: "error", optionGroups: [], selection: {}, optionsUnknown: false, emptyText: copyParam("product.errorText", "Product could not load.") });
|
|
262
427
|
}
|
|
263
428
|
},
|
|
264
429
|
// The stepper fires onChange with { value } on every tap; clamp 1–99 and
|
|
@@ -267,6 +432,24 @@ const svc = defineService<S, {
|
|
|
267
432
|
const quantity = parseQuantity(argValue(arg, "value"));
|
|
268
433
|
return quantity ? derive({ ...s, quantity }) : s;
|
|
269
434
|
},
|
|
435
|
+
// One chip row per option group. `group` is the descriptor slot ("1".."3");
|
|
436
|
+
// FilterChips sends back the WHOLE selected set, which for a single-select row
|
|
437
|
+
// is either one id or none (re-tapping the active chip clears it).
|
|
438
|
+
selectOption: (s, arg) => {
|
|
439
|
+
const slot = Math.floor(Number(argValue(arg, "group")));
|
|
440
|
+
const group = s.optionGroups[slot - 1];
|
|
441
|
+
if (!group) return s;
|
|
442
|
+
const raw = (arg as { selectedIds?: unknown } | undefined)?.selectedIds;
|
|
443
|
+
const picked = Array.isArray(raw) ? String(raw[0] ?? "") : String(raw ?? "");
|
|
444
|
+
// Only a choice this group actually offers — a stale id from a previous
|
|
445
|
+
// product would otherwise resolve to no variant and block the cart silently.
|
|
446
|
+
const choice = group.choices.some((c) => c.id === picked) ? picked : "";
|
|
447
|
+
const selection = { ...s.selection };
|
|
448
|
+
if (choice) selection[group.id] = choice; else delete selection[group.id];
|
|
449
|
+
// A new choice invalidates the last add's confirmation — it was for the
|
|
450
|
+
// previous variant.
|
|
451
|
+
return derive({ ...s, selection, added: false, cartHint: "" });
|
|
452
|
+
},
|
|
270
453
|
// The pending half of addToCart, as its own reducer so it PUBLISHES before the
|
|
271
454
|
// network write starts: the pending line shows and the top-bar badge ticks up in the same
|
|
272
455
|
// interaction, instead of the tap looking dead until the round-trip (and the badge re-read)
|
|
@@ -280,20 +463,44 @@ const svc = defineService<S, {
|
|
|
280
463
|
if (!id) return derive({ ...s, cartHint: copyParam("product.cartHintNoProduct", "No product to add.") });
|
|
281
464
|
// A second tap while the first write is in flight must not add the line twice.
|
|
282
465
|
if (s.adding) return s;
|
|
466
|
+
// The CTA is already disabled on this state; this is the second gate, because
|
|
467
|
+
// a voice-control/deep-link dispatch reaches the action without the button.
|
|
468
|
+
if (s.optionsIncomplete) {
|
|
469
|
+
// With no groups in hand there is nothing for `optionsHint` to name, so the
|
|
470
|
+
// offline case needs its own reason rather than an empty string.
|
|
471
|
+
return derive({ ...s, cartHint: s.optionsUnknown
|
|
472
|
+
? copyParam("product.cartHintOffline", "Reconnect to choose options and add this to your cart.")
|
|
473
|
+
: optionsHint(s.optionGroups, s.selection) || copyParam("product.cartHintNoVariant", "That combination is not available. Try another.") });
|
|
474
|
+
}
|
|
475
|
+
// Which variant the choice resolves to is the SDK's job — V3 addresses the
|
|
476
|
+
// choice by variantId, V1 by a name→choice map, and it throws rather than
|
|
477
|
+
// hand back a payload that would add a line with no variant.
|
|
478
|
+
let cartOptions: Record<string, unknown> | undefined;
|
|
479
|
+
try {
|
|
480
|
+
cartOptions = cartOptionsPayload(lastProduct, s.optionGroups, s.selection);
|
|
481
|
+
} catch {
|
|
482
|
+
return derive({ ...s, cartHint: copyParam("product.cartHintNoVariant", "That combination is not available. Try another.") });
|
|
483
|
+
}
|
|
283
484
|
const quantity = s.quantity > 0 ? s.quantity : 1;
|
|
284
485
|
const before = s.cartCount;
|
|
285
486
|
await svc.beginAdd({ quantity: String(quantity) });
|
|
286
487
|
s = svc.getState() as S;
|
|
287
488
|
// Optimistic local cart line — the cart repaints from cache immediately (offline or not).
|
|
288
|
-
const
|
|
489
|
+
const chosen = selectionSummary(s.optionGroups, s.selection);
|
|
490
|
+
// Keyed by product AND variant. `line-${id}` alone collapses two variants of the
|
|
491
|
+
// same product into one cached row while the badge counts both — the offline cart
|
|
492
|
+
// then shows one line for two adds. Sorted so the key is stable whatever order the
|
|
493
|
+
// groups were picked in, and reduced to id-safe characters.
|
|
494
|
+
const variantKey = Object.keys(s.selection).sort().map((k) => `${k}:${s.selection[k]}`).join("|").replace(/[^A-Za-z0-9]+/g, "_");
|
|
495
|
+
const line: DbEntity = { id: variantKey ? `line-${id}-${variantKey}` : `line-${id}`, productId: id, name: s.name || "product", title: s.name || "product", subtitle: chosen ? `${chosen} · Qty ${quantity}` : `Qty ${quantity}`, quantity: String(quantity) };
|
|
289
496
|
await cartPut(line);
|
|
290
497
|
try {
|
|
291
|
-
await addToCurrentCart(id, quantity);
|
|
498
|
+
await addToCurrentCart(id, quantity, cartOptions);
|
|
292
499
|
} catch (e) {
|
|
293
500
|
if (isBridgeUnavailable(e)) {
|
|
294
501
|
// Offline / no bridge: the optimistic cache line already shows; queue the
|
|
295
502
|
// add to replay (WriteQueue, idempotent) when connectivity returns.
|
|
296
|
-
await queueWrite("tier5.himiStoreProduct.addToCart", { id });
|
|
503
|
+
await queueWrite("tier5.himiStoreProduct.addToCart", { id, ...(cartOptions ? { options: JSON.stringify(cartOptions) } : {}) });
|
|
297
504
|
return derive({ ...s, adding: false, added: false, cartHint: copyTemplate("product.cartHintQueued", "Added {name} to cart. Will sync when you reconnect.", { name: s.name || "product" }) });
|
|
298
505
|
}
|
|
299
506
|
// Roll the optimistic badge back — nothing was added.
|