@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.
Binary file
@@ -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. Coding agents edit files themselves, so the flag is
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.1117.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": "fc5b0cacba000b4c6e6a4a5002e19ab4d37d0560e6bd98aed8b49b13"
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
- try {
354
- const r = await getStoreProduct(id);
355
- const p = (r.product ?? (r as unknown as StoreProduct)) as StoreProduct;
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
- ...s,
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
- cartHint: "",
389
- cartCount: await cartItemCount(),
432
+ cartCount: badge(),
390
433
  emptyText: "",
391
- quantity: 1,
392
434
  });
393
- } catch {
394
- // Offline: fall back to the last-synced cached product row before the error state.
395
- const cached = await readCachedProduct(id);
396
- if (cached) {
397
- const name = String(cached.name ?? "") || "Product";
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;
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({ ...s, status: "error", optionGroups: [], selection: {}, optionsUnknown: false, emptyText: copyParam("product.errorText", "Product could not load.") });
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
- try {
354
- const r = await getStoreProduct(id);
355
- const p = (r.product ?? (r as unknown as StoreProduct)) as StoreProduct;
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
- ...s,
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
- cartHint: "",
389
- cartCount: await cartItemCount(),
432
+ cartCount: badge(),
390
433
  emptyText: "",
391
- quantity: 1,
392
434
  });
393
- } catch {
394
- // Offline: fall back to the last-synced cached product row before the error state.
395
- const cached = await readCachedProduct(id);
396
- if (cached) {
397
- const name = String(cached.name ?? "") || "Product";
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;
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({ ...s, status: "error", optionGroups: [], selection: {}, optionsUnknown: false, emptyText: copyParam("product.errorText", "Product could not load.") });
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.") });