@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
|
@@ -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.") });
|
|
@@ -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.") });
|