@wix/himalaya-cli 0.1117.0 → 0.1119.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/app-module.d.ts +2 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/wix-cache-defaults.generated.d.ts +2 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/wix-cache-policy.d.ts +21 -1
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/define-app.d.ts +9 -2
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/core.mjs +1 -1
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/sdui.mjs +1 -0
- package/dist/cli.mjs +513 -43
- package/dist/preview-assets.generated.bin +0 -0
- package/guides/himi-mcp.md +3 -1
- package/guides/push-cli-onboarding.md +33 -0
- package/package.json +2 -2
- package/templates/beauty-salon/tier5-src/himi-store-product/index.ts +112 -39
- package/templates/branded-lite/tier5-src/himi-store-product/index.ts +112 -39
- package/templates/gym-fitness/tier5-src/himi-store-product/index.ts +112 -39
- package/templates/portfolio-creative/tier5-src/himi-store-product/index.ts +112 -39
- package/templates/retail-store/tier5-src/himi-store-product/index.ts +112 -39
|
Binary file
|
package/guides/himi-mcp.md
CHANGED
|
@@ -90,7 +90,9 @@ without following links. They judge the deny-list in every spelling, because mac
|
|
|
90
90
|
write or delete anything the source deny-list keeps off the machine: `.env` files, the `.himi/`
|
|
91
91
|
secret store, MCP host configs with their keys, key files, and `node_modules`, `dist` and `.git`
|
|
92
92
|
at any depth. When the host names
|
|
93
|
-
no workspace, `HIMI_WORKSPACE` sets the folder.
|
|
93
|
+
no workspace, `HIMI_WORKSPACE` sets the folder. It is then a hard boundary for EVERY tool: a
|
|
94
|
+
`dir`, `parentDir` or `file` argument outside it, or through a symlink, is refused. That covers the
|
|
95
|
+
publish and share tools, not only the file tools. Coding agents edit files themselves, so the flag is
|
|
94
96
|
off by default.
|
|
95
97
|
|
|
96
98
|
**What the tools will not do:** make a release live, build store binaries, touch secrets, write to a
|
|
@@ -141,6 +141,39 @@ Before anything is sent, the server checks:
|
|
|
141
141
|
`--dry-run` runs every check and sends nothing. The link is forwarded exactly as given, query
|
|
142
142
|
string included. A private screen is allowed; the app's login gate still applies when tapped.
|
|
143
143
|
|
|
144
|
+
To notify **every approved member** of the site, use `--all-members` in place of
|
|
145
|
+
`--member-email`:
|
|
146
|
+
|
|
147
|
+
```sh
|
|
148
|
+
himi notify send --provider ping --target wix --content ./liftgear \
|
|
149
|
+
--all-members --title 'New in the app' \
|
|
150
|
+
--link 'himalaya-LiftGear://screen/himi_product?id=<itemId>' --dry-run
|
|
151
|
+
himi notify send --provider ping --target wix --content ./liftgear \
|
|
152
|
+
--all-members --title 'New in the app' \
|
|
153
|
+
--link 'himalaya-LiftGear://screen/himi_product?id=<itemId>' --yes
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Before reading any members, the CLI calls Himi Serve's owner-authorized setup status GET
|
|
157
|
+
to verify app/site ownership and ready setup. It also verifies that the site token is active,
|
|
158
|
+
USER-scoped, and belongs to the same owner account and site. These checks run even for an empty
|
|
159
|
+
roster. The CLI then pages through the site's Members roster twice and includes only approved members. It
|
|
160
|
+
stops before sending if the two reads differ, an approved member lacks a login email or contact
|
|
161
|
+
ID, or the roster is ambiguous. A dry run reports the recipient count and asks the server to
|
|
162
|
+
validate the first member, the app, site, link and setup; with no approved members, there is no
|
|
163
|
+
member to validate.
|
|
164
|
+
The live command requires `--yes`, then sends one request per member through the existing
|
|
165
|
+
owner-authorized Ping endpoint. Each request repeats the server's ownership, member, readiness
|
|
166
|
+
and link checks. Calls are spaced for the owner's 10-per-minute test-send limit, so 1,000 members
|
|
167
|
+
take at least about 102 minutes. It prints progress after every 10 members. The result counts
|
|
168
|
+
requests Ping accepted; it cannot count devices that displayed the notification.
|
|
169
|
+
|
|
170
|
+
The command prints a campaign ID when it starts. If it stops partway through, rerun the **same**
|
|
171
|
+
command with `--request-id <campaignId>` within Ping's 24-hour deduplication window. It derives
|
|
172
|
+
the same request ID for each member, so members whose earlier requests succeeded are not sent
|
|
173
|
+
a second notification. Keep the title, body, link, app and site unchanged when resuming.
|
|
174
|
+
After 24 hours, or if the site's membership changed during the run, verify whom to target
|
|
175
|
+
before repeating it; the CLI has no durable campaign ledger.
|
|
176
|
+
|
|
144
177
|
**Acceptance is not delivery.** Ping does not report which devices it reached. The notification
|
|
145
178
|
goes to the member's subscribed devices for this site's Branded-family app, which can be more
|
|
146
179
|
than one device. It does not go to their other Wix apps. Confirm receipt and the tap on the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wix/himalaya-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1119.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "himi — the Himalaya authoring CLI. Build a Tier-5 content package on your own machine, push it to the production serve as a draft, preview it on web + Himi Player, and read worker logs back — the remote dev loop. Wraps @himalaya/serve-cli for transport.",
|
|
6
6
|
"keywords": [
|
|
@@ -67,5 +67,5 @@
|
|
|
67
67
|
"artifactId": "himalaya-cli"
|
|
68
68
|
}
|
|
69
69
|
},
|
|
70
|
-
"falconPackageHash": "
|
|
70
|
+
"falconPackageHash": "095801b2c05f44cecb7cf7c8b66b69b17736a05e43bf01dd8c72dde5"
|
|
71
71
|
}
|
|
@@ -282,12 +282,18 @@ function derive(s: S): S {
|
|
|
282
282
|
};
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
+
// Bumped by every add (queued, failed or landed): a cart count read BEFORE it — the load's badge
|
|
286
|
+
// read answering late — must not replace the badge the add set (#3468 review).
|
|
287
|
+
let cartBadgeEpoch = 0;
|
|
288
|
+
|
|
285
289
|
const svc = defineService<S, {
|
|
286
290
|
load: (s: S, arg?: unknown) => Promise<S>;
|
|
287
291
|
setQuantity: (s: S, arg?: unknown) => S;
|
|
288
292
|
selectOption: (s: S, arg?: unknown) => S;
|
|
289
293
|
addToCart: (s: S) => Promise<S>;
|
|
290
294
|
beginAdd: (s: S, arg?: unknown) => S;
|
|
295
|
+
paintCached: (s: S, arg?: unknown) => S;
|
|
296
|
+
setCartCount: (s: S, arg?: unknown) => S;
|
|
291
297
|
share: (s: S) => Promise<S>;
|
|
292
298
|
toggleSave: (s: S) => S;
|
|
293
299
|
}>("himiStoreProduct", {
|
|
@@ -350,9 +356,46 @@ const svc = defineService<S, {
|
|
|
350
356
|
lastLoadedId = undefined;
|
|
351
357
|
return derive({ ...s, status: "ready", quantity: 1, optionGroups: [], selection: {}, optionsUnknown: false, emptyText: copyParam("product.notFoundText", "Product not found.") });
|
|
352
358
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
359
|
+
// The cart count only feeds the top-bar badge: ask for it NOW, beside the product, instead
|
|
360
|
+
// of after it — it used to add a whole cart round trip before the page could paint.
|
|
361
|
+
const cartCount = cartItemCount();
|
|
362
|
+
// The badge must never hold the page: remember the count if it is already in, and
|
|
363
|
+
// publish it separately if it lands after the product (#3468 review).
|
|
364
|
+
let cartKnown: number | undefined;
|
|
365
|
+
void cartCount.then((n) => { cartKnown = n; }, () => {});
|
|
366
|
+
// The live product read goes out FIRST too — the local-db read below must never delay it.
|
|
367
|
+
// Settled into a value so a failure is handled where it is awaited, never unhandled.
|
|
368
|
+
const live = getStoreProduct(id).then(
|
|
369
|
+
(r) => ({ ok: true as const, r }),
|
|
370
|
+
() => ({ ok: false as const }),
|
|
371
|
+
);
|
|
372
|
+
// Paint at once from the row the list already synced (name, price, image) — the page used
|
|
373
|
+
// to sit on its skeleton for the product round trip even when the list had just shown it.
|
|
374
|
+
// The live read then fills in the description and gallery.
|
|
375
|
+
const synced = await readCachedProduct(id);
|
|
376
|
+
let painted = false;
|
|
377
|
+
if (synced && s.status === "loading") {
|
|
378
|
+
await svc.paintCached(synced);
|
|
379
|
+
painted = true;
|
|
380
|
+
}
|
|
381
|
+
const outcome = await live;
|
|
382
|
+
// Once painted the page is INTERACTIVE while the live read is on the wire: the visitor may
|
|
383
|
+
// save, change the quantity or add to cart. Build on the state as it is NOW, and keep what
|
|
384
|
+
// they did — overwriting it would untick a stored save, reset the quantity, and drop the
|
|
385
|
+
// in-flight add's `adding` guard (a second tap would add the line twice).
|
|
386
|
+
const base: S = painted ? (svc.getState() as S) : s;
|
|
387
|
+
const keep = painted
|
|
388
|
+
? { saved: base.saved, saveHint: base.saveHint, quantity: base.quantity, adding: base.adding, added: base.added, cartHint: base.cartHint }
|
|
389
|
+
: { cartHint: "", quantity: 1 };
|
|
390
|
+
const badge = (): number => {
|
|
391
|
+
if (painted && (base.adding || base.added)) return base.cartCount;
|
|
392
|
+
if (cartKnown !== undefined) return cartKnown;
|
|
393
|
+
const epoch = cartBadgeEpoch;
|
|
394
|
+
void cartCount.then((n) => { void svc.setCartCount({ count: n, epoch }); }, () => {});
|
|
395
|
+
return base.cartCount;
|
|
396
|
+
};
|
|
397
|
+
if (outcome.ok) {
|
|
398
|
+
const p = (outcome.r.product ?? (outcome.r as unknown as StoreProduct)) as StoreProduct;
|
|
356
399
|
const name = cleanTitle(p.name) || "Product";
|
|
357
400
|
const image = productImage(p, WIX_DETAIL_IMAGE);
|
|
358
401
|
lastProduct = p;
|
|
@@ -373,7 +416,8 @@ const svc = defineService<S, {
|
|
|
373
416
|
}
|
|
374
417
|
lastLoadedId = id;
|
|
375
418
|
return derive({
|
|
376
|
-
...
|
|
419
|
+
...base,
|
|
420
|
+
...keep,
|
|
377
421
|
status: "ready",
|
|
378
422
|
optionGroups,
|
|
379
423
|
selection: { ...defaultSelection(optionGroups), ...carried },
|
|
@@ -385,46 +429,42 @@ const svc = defineService<S, {
|
|
|
385
429
|
gallery: productGallery(p),
|
|
386
430
|
initial: (name[0] || "#").toUpperCase(),
|
|
387
431
|
shareUrl: productShareUrl(p),
|
|
388
|
-
|
|
389
|
-
cartCount: await cartItemCount(),
|
|
432
|
+
cartCount: badge(),
|
|
390
433
|
emptyText: "",
|
|
391
|
-
quantity: 1,
|
|
392
434
|
});
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
// on `noOptions` and would otherwise leave a dead button unexplained.
|
|
405
|
-
lastProduct = undefined;
|
|
406
|
-
lastLoadedId = undefined;
|
|
407
|
-
return derive({
|
|
408
|
-
...s,
|
|
409
|
-
status: "ready",
|
|
410
|
-
optionGroups: [],
|
|
411
|
-
selection: {},
|
|
412
|
-
optionsUnknown: true,
|
|
413
|
-
name,
|
|
414
|
-
price: String(cached.subtitle ?? ""),
|
|
415
|
-
description: "",
|
|
416
|
-
image,
|
|
417
|
-
initial: String(cached.initial ?? "") || (name[0] || "#").toUpperCase(),
|
|
418
|
-
cartHint: copyParam("product.cartHintOffline", "Reconnect to choose options and add this to your cart."),
|
|
419
|
-
cartCount: await cartItemCount(),
|
|
420
|
-
emptyText: "",
|
|
421
|
-
quantity: 1,
|
|
422
|
-
});
|
|
423
|
-
}
|
|
435
|
+
}
|
|
436
|
+
// Offline: fall back to the last-synced cached product row before the error state.
|
|
437
|
+
if (synced) {
|
|
438
|
+
const name = String(synced.name ?? "") || "Product";
|
|
439
|
+
const image = String(synced.image ?? "");
|
|
440
|
+
// The cached list row carries no options, and guessing "none" would offer
|
|
441
|
+
// a one-tap Add to cart for a product that needs a colour and a size. So
|
|
442
|
+
// the offline detail shows no picker AND marks the options UNKNOWN, which
|
|
443
|
+
// keeps `optionsIncomplete` true: the CTA stays disabled and `addToCart`
|
|
444
|
+
// refuses. `cartHint` carries the reason, because the options card is hidden
|
|
445
|
+
// on `noOptions` and would otherwise leave a dead button unexplained.
|
|
424
446
|
lastProduct = undefined;
|
|
425
447
|
lastLoadedId = undefined;
|
|
426
|
-
return derive({
|
|
448
|
+
return derive({
|
|
449
|
+
...base,
|
|
450
|
+
...keep,
|
|
451
|
+
status: "ready",
|
|
452
|
+
optionGroups: [],
|
|
453
|
+
selection: {},
|
|
454
|
+
optionsUnknown: true,
|
|
455
|
+
name,
|
|
456
|
+
price: String(synced.subtitle ?? ""),
|
|
457
|
+
description: "",
|
|
458
|
+
image,
|
|
459
|
+
initial: String(synced.initial ?? "") || (name[0] || "#").toUpperCase(),
|
|
460
|
+
cartHint: copyParam("product.cartHintOffline", "Reconnect to choose options and add this to your cart."),
|
|
461
|
+
cartCount: badge(),
|
|
462
|
+
emptyText: "",
|
|
463
|
+
});
|
|
427
464
|
}
|
|
465
|
+
lastProduct = undefined;
|
|
466
|
+
lastLoadedId = undefined;
|
|
467
|
+
return derive({ ...s, status: "error", optionGroups: [], selection: {}, optionsUnknown: false, emptyText: copyParam("product.errorText", "Product could not load.") });
|
|
428
468
|
},
|
|
429
469
|
// The stepper fires onChange with { value } on every tap; clamp 1–99 and
|
|
430
470
|
// ignore unparsable/garbage args instead of silently zeroing the quantity.
|
|
@@ -456,8 +496,41 @@ const svc = defineService<S, {
|
|
|
456
496
|
// settle. Called from addToCart, not from the descriptor.
|
|
457
497
|
beginAdd: (s, arg) => {
|
|
458
498
|
const quantity = parseQuantity(argValue(arg, "quantity")) ?? 1;
|
|
499
|
+
cartBadgeEpoch += 1;
|
|
459
500
|
return derive({ ...s, adding: true, added: false, cartHint: "", cartCount: s.cartCount + quantity });
|
|
460
501
|
},
|
|
502
|
+
// The first paint from the list's synced row (called from load, not from the descriptor):
|
|
503
|
+
// name, price and image are on screen while the live product — description, gallery — loads.
|
|
504
|
+
// The cart count answered after the product: only the top-bar badge changes.
|
|
505
|
+
setCartCount: (s, arg) => {
|
|
506
|
+
// An add since the count was asked for owns the badge — in flight, landed or QUEUED offline
|
|
507
|
+
// (which leaves adding/added false): a pre-add count must not undo it.
|
|
508
|
+
const { count, epoch } = (arg ?? {}) as { count?: unknown; epoch?: unknown };
|
|
509
|
+
if (epoch !== cartBadgeEpoch || s.adding || s.added) return s;
|
|
510
|
+
const n = Math.max(0, Math.floor(Number(count)));
|
|
511
|
+
return Number.isFinite(n) ? derive({ ...s, cartCount: n }) : s;
|
|
512
|
+
},
|
|
513
|
+
paintCached: (s, arg) => {
|
|
514
|
+
const row = (arg ?? {}) as DbEntity;
|
|
515
|
+
const name = String(row.name ?? "") || "Product";
|
|
516
|
+
return derive({
|
|
517
|
+
...s,
|
|
518
|
+
status: "ready",
|
|
519
|
+
name,
|
|
520
|
+
price: String(row.subtitle ?? ""),
|
|
521
|
+
description: "",
|
|
522
|
+
image: String(row.image ?? ""),
|
|
523
|
+
gallery: [],
|
|
524
|
+
initial: String(row.initial ?? "") || (name[0] || "#").toUpperCase(),
|
|
525
|
+
// The synced list row carries no options: until the live read says what they are,
|
|
526
|
+
// they are UNKNOWN, which keeps the CTA disabled — never a one-tap Add to cart for a
|
|
527
|
+
// product that needs a size. The live read replaces this a moment later.
|
|
528
|
+
optionGroups: [],
|
|
529
|
+
optionsUnknown: true,
|
|
530
|
+
emptyText: "",
|
|
531
|
+
quantity: 1,
|
|
532
|
+
});
|
|
533
|
+
},
|
|
461
534
|
addToCart: async (s) => {
|
|
462
535
|
const id = inputId();
|
|
463
536
|
if (!id) return derive({ ...s, cartHint: copyParam("product.cartHintNoProduct", "No product to add.") });
|
|
@@ -282,12 +282,18 @@ function derive(s: S): S {
|
|
|
282
282
|
};
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
+
// Bumped by every add (queued, failed or landed): a cart count read BEFORE it — the load's badge
|
|
286
|
+
// read answering late — must not replace the badge the add set (#3468 review).
|
|
287
|
+
let cartBadgeEpoch = 0;
|
|
288
|
+
|
|
285
289
|
const svc = defineService<S, {
|
|
286
290
|
load: (s: S, arg?: unknown) => Promise<S>;
|
|
287
291
|
setQuantity: (s: S, arg?: unknown) => S;
|
|
288
292
|
selectOption: (s: S, arg?: unknown) => S;
|
|
289
293
|
addToCart: (s: S) => Promise<S>;
|
|
290
294
|
beginAdd: (s: S, arg?: unknown) => S;
|
|
295
|
+
paintCached: (s: S, arg?: unknown) => S;
|
|
296
|
+
setCartCount: (s: S, arg?: unknown) => S;
|
|
291
297
|
share: (s: S) => Promise<S>;
|
|
292
298
|
toggleSave: (s: S) => S;
|
|
293
299
|
}>("himiStoreProduct", {
|
|
@@ -350,9 +356,46 @@ const svc = defineService<S, {
|
|
|
350
356
|
lastLoadedId = undefined;
|
|
351
357
|
return derive({ ...s, status: "ready", quantity: 1, optionGroups: [], selection: {}, optionsUnknown: false, emptyText: copyParam("product.notFoundText", "Product not found.") });
|
|
352
358
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
359
|
+
// The cart count only feeds the top-bar badge: ask for it NOW, beside the product, instead
|
|
360
|
+
// of after it — it used to add a whole cart round trip before the page could paint.
|
|
361
|
+
const cartCount = cartItemCount();
|
|
362
|
+
// The badge must never hold the page: remember the count if it is already in, and
|
|
363
|
+
// publish it separately if it lands after the product (#3468 review).
|
|
364
|
+
let cartKnown: number | undefined;
|
|
365
|
+
void cartCount.then((n) => { cartKnown = n; }, () => {});
|
|
366
|
+
// The live product read goes out FIRST too — the local-db read below must never delay it.
|
|
367
|
+
// Settled into a value so a failure is handled where it is awaited, never unhandled.
|
|
368
|
+
const live = getStoreProduct(id).then(
|
|
369
|
+
(r) => ({ ok: true as const, r }),
|
|
370
|
+
() => ({ ok: false as const }),
|
|
371
|
+
);
|
|
372
|
+
// Paint at once from the row the list already synced (name, price, image) — the page used
|
|
373
|
+
// to sit on its skeleton for the product round trip even when the list had just shown it.
|
|
374
|
+
// The live read then fills in the description and gallery.
|
|
375
|
+
const synced = await readCachedProduct(id);
|
|
376
|
+
let painted = false;
|
|
377
|
+
if (synced && s.status === "loading") {
|
|
378
|
+
await svc.paintCached(synced);
|
|
379
|
+
painted = true;
|
|
380
|
+
}
|
|
381
|
+
const outcome = await live;
|
|
382
|
+
// Once painted the page is INTERACTIVE while the live read is on the wire: the visitor may
|
|
383
|
+
// save, change the quantity or add to cart. Build on the state as it is NOW, and keep what
|
|
384
|
+
// they did — overwriting it would untick a stored save, reset the quantity, and drop the
|
|
385
|
+
// in-flight add's `adding` guard (a second tap would add the line twice).
|
|
386
|
+
const base: S = painted ? (svc.getState() as S) : s;
|
|
387
|
+
const keep = painted
|
|
388
|
+
? { saved: base.saved, saveHint: base.saveHint, quantity: base.quantity, adding: base.adding, added: base.added, cartHint: base.cartHint }
|
|
389
|
+
: { cartHint: "", quantity: 1 };
|
|
390
|
+
const badge = (): number => {
|
|
391
|
+
if (painted && (base.adding || base.added)) return base.cartCount;
|
|
392
|
+
if (cartKnown !== undefined) return cartKnown;
|
|
393
|
+
const epoch = cartBadgeEpoch;
|
|
394
|
+
void cartCount.then((n) => { void svc.setCartCount({ count: n, epoch }); }, () => {});
|
|
395
|
+
return base.cartCount;
|
|
396
|
+
};
|
|
397
|
+
if (outcome.ok) {
|
|
398
|
+
const p = (outcome.r.product ?? (outcome.r as unknown as StoreProduct)) as StoreProduct;
|
|
356
399
|
const name = cleanTitle(p.name) || "Product";
|
|
357
400
|
const image = productImage(p, WIX_DETAIL_IMAGE);
|
|
358
401
|
lastProduct = p;
|
|
@@ -373,7 +416,8 @@ const svc = defineService<S, {
|
|
|
373
416
|
}
|
|
374
417
|
lastLoadedId = id;
|
|
375
418
|
return derive({
|
|
376
|
-
...
|
|
419
|
+
...base,
|
|
420
|
+
...keep,
|
|
377
421
|
status: "ready",
|
|
378
422
|
optionGroups,
|
|
379
423
|
selection: { ...defaultSelection(optionGroups), ...carried },
|
|
@@ -385,46 +429,42 @@ const svc = defineService<S, {
|
|
|
385
429
|
gallery: productGallery(p),
|
|
386
430
|
initial: (name[0] || "#").toUpperCase(),
|
|
387
431
|
shareUrl: productShareUrl(p),
|
|
388
|
-
|
|
389
|
-
cartCount: await cartItemCount(),
|
|
432
|
+
cartCount: badge(),
|
|
390
433
|
emptyText: "",
|
|
391
|
-
quantity: 1,
|
|
392
434
|
});
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
// on `noOptions` and would otherwise leave a dead button unexplained.
|
|
405
|
-
lastProduct = undefined;
|
|
406
|
-
lastLoadedId = undefined;
|
|
407
|
-
return derive({
|
|
408
|
-
...s,
|
|
409
|
-
status: "ready",
|
|
410
|
-
optionGroups: [],
|
|
411
|
-
selection: {},
|
|
412
|
-
optionsUnknown: true,
|
|
413
|
-
name,
|
|
414
|
-
price: String(cached.subtitle ?? ""),
|
|
415
|
-
description: "",
|
|
416
|
-
image,
|
|
417
|
-
initial: String(cached.initial ?? "") || (name[0] || "#").toUpperCase(),
|
|
418
|
-
cartHint: copyParam("product.cartHintOffline", "Reconnect to choose options and add this to your cart."),
|
|
419
|
-
cartCount: await cartItemCount(),
|
|
420
|
-
emptyText: "",
|
|
421
|
-
quantity: 1,
|
|
422
|
-
});
|
|
423
|
-
}
|
|
435
|
+
}
|
|
436
|
+
// Offline: fall back to the last-synced cached product row before the error state.
|
|
437
|
+
if (synced) {
|
|
438
|
+
const name = String(synced.name ?? "") || "Product";
|
|
439
|
+
const image = String(synced.image ?? "");
|
|
440
|
+
// The cached list row carries no options, and guessing "none" would offer
|
|
441
|
+
// a one-tap Add to cart for a product that needs a colour and a size. So
|
|
442
|
+
// the offline detail shows no picker AND marks the options UNKNOWN, which
|
|
443
|
+
// keeps `optionsIncomplete` true: the CTA stays disabled and `addToCart`
|
|
444
|
+
// refuses. `cartHint` carries the reason, because the options card is hidden
|
|
445
|
+
// on `noOptions` and would otherwise leave a dead button unexplained.
|
|
424
446
|
lastProduct = undefined;
|
|
425
447
|
lastLoadedId = undefined;
|
|
426
|
-
return derive({
|
|
448
|
+
return derive({
|
|
449
|
+
...base,
|
|
450
|
+
...keep,
|
|
451
|
+
status: "ready",
|
|
452
|
+
optionGroups: [],
|
|
453
|
+
selection: {},
|
|
454
|
+
optionsUnknown: true,
|
|
455
|
+
name,
|
|
456
|
+
price: String(synced.subtitle ?? ""),
|
|
457
|
+
description: "",
|
|
458
|
+
image,
|
|
459
|
+
initial: String(synced.initial ?? "") || (name[0] || "#").toUpperCase(),
|
|
460
|
+
cartHint: copyParam("product.cartHintOffline", "Reconnect to choose options and add this to your cart."),
|
|
461
|
+
cartCount: badge(),
|
|
462
|
+
emptyText: "",
|
|
463
|
+
});
|
|
427
464
|
}
|
|
465
|
+
lastProduct = undefined;
|
|
466
|
+
lastLoadedId = undefined;
|
|
467
|
+
return derive({ ...s, status: "error", optionGroups: [], selection: {}, optionsUnknown: false, emptyText: copyParam("product.errorText", "Product could not load.") });
|
|
428
468
|
},
|
|
429
469
|
// The stepper fires onChange with { value } on every tap; clamp 1–99 and
|
|
430
470
|
// ignore unparsable/garbage args instead of silently zeroing the quantity.
|
|
@@ -456,8 +496,41 @@ const svc = defineService<S, {
|
|
|
456
496
|
// settle. Called from addToCart, not from the descriptor.
|
|
457
497
|
beginAdd: (s, arg) => {
|
|
458
498
|
const quantity = parseQuantity(argValue(arg, "quantity")) ?? 1;
|
|
499
|
+
cartBadgeEpoch += 1;
|
|
459
500
|
return derive({ ...s, adding: true, added: false, cartHint: "", cartCount: s.cartCount + quantity });
|
|
460
501
|
},
|
|
502
|
+
// The first paint from the list's synced row (called from load, not from the descriptor):
|
|
503
|
+
// name, price and image are on screen while the live product — description, gallery — loads.
|
|
504
|
+
// The cart count answered after the product: only the top-bar badge changes.
|
|
505
|
+
setCartCount: (s, arg) => {
|
|
506
|
+
// An add since the count was asked for owns the badge — in flight, landed or QUEUED offline
|
|
507
|
+
// (which leaves adding/added false): a pre-add count must not undo it.
|
|
508
|
+
const { count, epoch } = (arg ?? {}) as { count?: unknown; epoch?: unknown };
|
|
509
|
+
if (epoch !== cartBadgeEpoch || s.adding || s.added) return s;
|
|
510
|
+
const n = Math.max(0, Math.floor(Number(count)));
|
|
511
|
+
return Number.isFinite(n) ? derive({ ...s, cartCount: n }) : s;
|
|
512
|
+
},
|
|
513
|
+
paintCached: (s, arg) => {
|
|
514
|
+
const row = (arg ?? {}) as DbEntity;
|
|
515
|
+
const name = String(row.name ?? "") || "Product";
|
|
516
|
+
return derive({
|
|
517
|
+
...s,
|
|
518
|
+
status: "ready",
|
|
519
|
+
name,
|
|
520
|
+
price: String(row.subtitle ?? ""),
|
|
521
|
+
description: "",
|
|
522
|
+
image: String(row.image ?? ""),
|
|
523
|
+
gallery: [],
|
|
524
|
+
initial: String(row.initial ?? "") || (name[0] || "#").toUpperCase(),
|
|
525
|
+
// The synced list row carries no options: until the live read says what they are,
|
|
526
|
+
// they are UNKNOWN, which keeps the CTA disabled — never a one-tap Add to cart for a
|
|
527
|
+
// product that needs a size. The live read replaces this a moment later.
|
|
528
|
+
optionGroups: [],
|
|
529
|
+
optionsUnknown: true,
|
|
530
|
+
emptyText: "",
|
|
531
|
+
quantity: 1,
|
|
532
|
+
});
|
|
533
|
+
},
|
|
461
534
|
addToCart: async (s) => {
|
|
462
535
|
const id = inputId();
|
|
463
536
|
if (!id) return derive({ ...s, cartHint: copyParam("product.cartHintNoProduct", "No product to add.") });
|