@syntrologie/adapt-product 2.41.2 → 2.42.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/cdn.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  onActivate,
3
3
  runtime
4
- } from "./chunk-E5TZSKZV.js";
4
+ } from "./chunk-6DWXPP5X.js";
5
5
  import "./chunk-Y2QAYCSK.js";
6
6
 
7
7
  // src/cdn.ts
@@ -21,6 +21,21 @@ function n(n2) {
21
21
  // ../../sdk-contracts/dist/canvas-context.js
22
22
  var canvasRuntimeContext = n("syntrologie:canvas-runtime");
23
23
 
24
+ // ../../sdk-contracts/dist/detector-events.js
25
+ var DETECTOR_EVENT_NAMES = [
26
+ "ui.hover",
27
+ "ui.idle",
28
+ "ui.hesitate",
29
+ "ui.rage_click",
30
+ "ui.scroll_thrash",
31
+ "ui.focus_bounce"
32
+ ];
33
+ var CANONICAL_BUS_EVENT_NAMES = [
34
+ ...DETECTOR_EVENT_NAMES,
35
+ "nav.section_viewed",
36
+ "nav.scroll_depth"
37
+ ];
38
+
24
39
  // ../../sdk-contracts/dist/dive-deeper.js
25
40
  var DIVE_DEEPER_EVENT = "syntro:chat:dive-deeper";
26
41
  function dispatchDiveDeeper(detail) {
@@ -43,14 +58,132 @@ function stripMountPlumbing(config) {
43
58
  }
44
59
 
45
60
  // ../../sdk-contracts/dist/routes.js
61
+ var RESERVED_BYTES = /* @__PURE__ */ new Set([
62
+ 33,
63
+ // !
64
+ 35,
65
+ // #
66
+ 36,
67
+ // $
68
+ 38,
69
+ // &
70
+ 39,
71
+ // '
72
+ 40,
73
+ // (
74
+ 41,
75
+ // )
76
+ 42,
77
+ // *
78
+ 43,
79
+ // +
80
+ 44,
81
+ // ,
82
+ 47,
83
+ // /
84
+ 58,
85
+ // :
86
+ 59,
87
+ // ;
88
+ 61,
89
+ // =
90
+ 63,
91
+ // ?
92
+ 64,
93
+ // @
94
+ 91,
95
+ // [
96
+ 93
97
+ // ]
98
+ ]);
46
99
  var utf8Decoder = new TextDecoder("utf-8", { fatal: false });
100
+ function decodeUnreservedOnly(input) {
101
+ let out = "";
102
+ let pending2 = [];
103
+ const flushPending = () => {
104
+ if (pending2.length === 0)
105
+ return;
106
+ const bytes = new Uint8Array(pending2);
107
+ out += utf8Decoder.decode(bytes);
108
+ pending2 = [];
109
+ };
110
+ let i2 = 0;
111
+ while (i2 < input.length) {
112
+ const ch = input[i2];
113
+ if (ch === "%" && i2 + 2 < input.length && isHex(input[i2 + 1]) && isHex(input[i2 + 2])) {
114
+ const byte = parseInt(input.slice(i2 + 1, i2 + 3), 16);
115
+ if (RESERVED_BYTES.has(byte)) {
116
+ flushPending();
117
+ out += `%${input.slice(i2 + 1, i2 + 3).toUpperCase()}`;
118
+ i2 += 3;
119
+ } else {
120
+ pending2.push(byte);
121
+ i2 += 3;
122
+ }
123
+ } else {
124
+ flushPending();
125
+ out += ch;
126
+ i2 += 1;
127
+ }
128
+ }
129
+ flushPending();
130
+ return out;
131
+ }
132
+ function isHex(c2) {
133
+ return c2 >= "0" && c2 <= "9" || c2 >= "a" && c2 <= "f" || c2 >= "A" && c2 <= "F";
134
+ }
135
+ function stripQueryAndHash(s4) {
136
+ const q = s4.indexOf("?");
137
+ if (q !== -1)
138
+ s4 = s4.slice(0, q);
139
+ const h = s4.indexOf("#");
140
+ if (h !== -1)
141
+ s4 = s4.slice(0, h);
142
+ return s4;
143
+ }
144
+ function normalizeRoute(path) {
145
+ if (typeof path !== "string" || path.length === 0) {
146
+ throw new TypeError("normalizeRoute: input must be a non-empty string");
147
+ }
148
+ if (!path.startsWith("/")) {
149
+ throw new TypeError(`normalizeRoute: input must be absolute (start with '/'); got ${JSON.stringify(path)}`);
150
+ }
151
+ let s4 = stripQueryAndHash(path);
152
+ s4 = decodeUnreservedOnly(s4);
153
+ s4 = s4.replace(/\/+/g, "/");
154
+ if (s4.length > 1 && s4.endsWith("/"))
155
+ s4 = s4.slice(0, -1);
156
+ return s4;
157
+ }
47
158
 
48
159
  // ../../sdk-contracts/dist/schemas.js
49
160
  import { z } from "zod";
161
+ var NO_CSS_BREAKOUT_PATTERN = /^[^{}]*$/;
50
162
  var AnchorIdZ = z.object({
51
- selector: z.string(),
52
- route: z.union([z.string(), z.array(z.string())])
53
- }).strict();
163
+ selector: z.string().regex(NO_CSS_BREAKOUT_PATTERN, {
164
+ message: 'selector must not contain "{" or "}" \u2014 not valid CSS selector syntax, and content:hideBySelector injects this value directly into a <style> element where these characters would break out of the generated rule.'
165
+ }).describe("CSS selector for the target element"),
166
+ route: z.union([z.string(), z.array(z.string())]).superRefine((value, ctx) => {
167
+ for (const route of Array.isArray(value) ? value : [value]) {
168
+ let canonical;
169
+ try {
170
+ canonical = normalizeRoute(route);
171
+ } catch (err) {
172
+ ctx.addIssue({
173
+ code: z.ZodIssueCode.custom,
174
+ message: `route must be an absolute path starting with "/" (got ${JSON.stringify(route)}): ${err instanceof Error ? err.message : String(err)}`
175
+ });
176
+ continue;
177
+ }
178
+ if (canonical !== route) {
179
+ ctx.addIssue({
180
+ code: z.ZodIssueCode.custom,
181
+ message: `route ${JSON.stringify(route)} is not canonical \u2014 use ${JSON.stringify(canonical)} (this must match what the backend's RouteCanonicalityCheck accepts)`
182
+ });
183
+ }
184
+ }
185
+ }).describe("URL path(s) where this element exists")
186
+ }).strict().describe("DOM element target. selector = CSS selector, route = URL path(s) where the element exists.");
54
187
  var AuthoringFieldsZ = {
55
188
  id: z.string().optional().describe('Stable action identifier (e.g. "act_3db6a14d2ab0").'),
56
189
  title: z.string().max(200).optional().describe("Authoring-only: short label shown on the action plan dashboard. Stripped before serving to the runtime SDK."),
@@ -204,10 +337,10 @@ var EventScopeZ = z.object({
204
337
  props: z.record(z.union([z.string(), z.number(), z.boolean()])).optional()
205
338
  });
206
339
  var NotifyZ = z.object({
207
- title: z.string().optional(),
208
- body: z.string().optional(),
209
- icon: z.string().optional()
210
- }).nullable().optional();
340
+ title: z.string().optional().describe("Notification title"),
341
+ body: z.string().optional().describe("Notification body text"),
342
+ icon: z.string().optional().describe("Notification icon (emoji or URL)")
343
+ }).describe("Optional toast notification shown when this action triggers.").nullable().optional();
211
344
 
212
345
  // src/presets-store.ts
213
346
  function presetsWindow() {
@@ -4245,6 +4378,14 @@ resolvePrice_fn = async function(productId, bind) {
4245
4378
  };
4246
4379
 
4247
4380
  // src/decision/card.ts
4381
+ var VARIANT_ERROR_COPY = {
4382
+ retryable: "Couldn't load options right now.",
4383
+ terminal: "Options aren't available for this item right now."
4384
+ };
4385
+ var NON_RETRYABLE_VARIANT_ERROR_NAME = "ProductNotFoundError";
4386
+ function isRetryableVariantError(errorName) {
4387
+ return errorName !== NON_RETRYABLE_VARIANT_ERROR_NAME;
4388
+ }
4248
4389
  function createInitialCardState(generation = 0) {
4249
4390
  return {
4250
4391
  generation,
@@ -4267,18 +4408,23 @@ function selectCardView(state) {
4267
4408
  mode: "add",
4268
4409
  disabled: state.connection !== "connected" || state.commerce !== "available" || state.product.status !== "valid"
4269
4410
  };
4270
- let panel = { status: "hidden", payload: null, error: null };
4411
+ let panel = { status: "hidden", payload: null, error: null, retryable: false };
4271
4412
  if (state.face === "back") {
4272
4413
  switch (state.variants.status) {
4273
4414
  case "ready":
4274
- panel = { status: "ready", payload: state.variants.payload, error: null };
4415
+ panel = { status: "ready", payload: state.variants.payload, error: null, retryable: false };
4275
4416
  break;
4276
4417
  case "error":
4277
- panel = { status: "error", payload: null, error: state.variants.message };
4418
+ panel = {
4419
+ status: "error",
4420
+ payload: null,
4421
+ error: state.variants.retryable ? VARIANT_ERROR_COPY.retryable : VARIANT_ERROR_COPY.terminal,
4422
+ retryable: state.variants.retryable
4423
+ };
4278
4424
  break;
4279
4425
  case "idle":
4280
4426
  case "loading":
4281
- panel = { status: "loading", payload: null, error: null };
4427
+ panel = { status: "loading", payload: null, error: null, retryable: false };
4282
4428
  break;
4283
4429
  }
4284
4430
  }
@@ -4297,7 +4443,7 @@ function replaceProps(state, event) {
4297
4443
  const sameProduct = event.valid && event.productId !== null && state.product.status === "valid" && state.product.id === event.productId;
4298
4444
  if (sameProduct && event.productId !== null) {
4299
4445
  const effects2 = [{ type: "bind-product", productId: event.productId }];
4300
- if (state.variants.status === "idle") {
4446
+ if (event.commerceCapable && state.variants.status === "idle") {
4301
4447
  effects2.push({
4302
4448
  type: "lookup-variants",
4303
4449
  generation: state.generation,
@@ -4319,15 +4465,15 @@ function replaceProps(state, event) {
4319
4465
  effects
4320
4466
  };
4321
4467
  }
4322
- effects.push(
4323
- { type: "bind-product", productId: event.productId },
4324
- { type: "lookup-variants", generation, productId: event.productId },
4325
- { type: "report-render-health" }
4326
- );
4468
+ effects.push({ type: "bind-product", productId: event.productId });
4469
+ if (event.commerceCapable) {
4470
+ effects.push({ type: "lookup-variants", generation, productId: event.productId });
4471
+ }
4472
+ effects.push({ type: "report-render-health" });
4327
4473
  return {
4328
4474
  state: {
4329
4475
  ...next,
4330
- product: { status: "valid", id: event.productId }
4476
+ product: { status: "valid", id: event.productId, commerceCapable: event.commerceCapable }
4331
4477
  },
4332
4478
  effects
4333
4479
  };
@@ -4336,7 +4482,12 @@ function publish(name, props) {
4336
4482
  return { type: "publish-event", name, props };
4337
4483
  }
4338
4484
  function activatePrimary(state, event) {
4339
- if (state.connection !== "connected" || state.commerce !== "available" || state.product.status !== "valid" || state.face !== "front") {
4485
+ if (state.connection !== "connected" || state.commerce !== "available" || state.product.status !== "valid" || // Defense in depth: the widget only wires this event to an
4486
+ // actionId==='add_to_cart' CTA click (a non-commerce/booking product's
4487
+ // CTA is a plain navigating <a>, never dispatched here) — but the state
4488
+ // machine itself must also refuse to start the Woo/Shopify variant-
4489
+ // lookup flow for a product that has none.
4490
+ !state.product.commerceCapable || state.face !== "front") {
4340
4491
  return unchanged2(state);
4341
4492
  }
4342
4493
  const productId = state.product.id;
@@ -4487,7 +4638,11 @@ function transitionCard(state, event) {
4487
4638
  });
4488
4639
  const next = {
4489
4640
  ...state,
4490
- variants: { status: "error", message: event.message }
4641
+ variants: {
4642
+ status: "error",
4643
+ message: event.message,
4644
+ retryable: isRetryableVariantError(event.errorName)
4645
+ }
4491
4646
  };
4492
4647
  if (state.activation.status !== "pending") {
4493
4648
  return { state: next, effects: [failureEffect] };
@@ -4521,7 +4676,10 @@ function transitionCard(state, event) {
4521
4676
  case "add-failed":
4522
4677
  return applyAddResult(state, event);
4523
4678
  case "retry-activated":
4524
- if (state.product.status !== "valid" || state.commerce !== "available" || state.variants.status !== "error") {
4679
+ if (state.product.status !== "valid" || state.commerce !== "available" || state.variants.status !== "error" || // The panel never OFFERS a retry affordance when the failure isn't
4680
+ // retryable (see selectCardView), but a stale/duplicate dispatch of
4681
+ // this event must still be a no-op — it can never succeed.
4682
+ !state.variants.retryable) {
4525
4683
  return unchanged2(state);
4526
4684
  }
4527
4685
  return {
@@ -4568,7 +4726,12 @@ function transitionCard(state, event) {
4568
4726
  state: { ...state, generation, connection: "connected" },
4569
4727
  effects: state.product.status === "valid" && state.commerce === "available" ? [
4570
4728
  { type: "bind-product", productId: state.product.id },
4571
- { type: "lookup-variants", generation, productId: state.product.id },
4729
+ // Same commerce gate as replaceProps — a booking-fulfilment
4730
+ // product never re-fires the Woo/Shopify variant lookup,
4731
+ // including on the reconnect prefetch.
4732
+ ...state.product.commerceCapable ? [
4733
+ { type: "lookup-variants", generation, productId: state.product.id }
4734
+ ] : [],
4572
4735
  { type: "report-render-health" }
4573
4736
  ] : []
4574
4737
  };
@@ -5238,7 +5401,17 @@ var _VariantPanelLit = class _VariantPanelLit extends LitElement7 {
5238
5401
  * used as the fallback when the selected variant carries no featured_image. */
5239
5402
  this.productImage = null;
5240
5403
  this.state = "idle";
5404
+ /** Already sanitized, visitor-safe copy by the time it reaches this
5405
+ * component — the decision core (`card.ts`'s `selectCardView`) NEVER
5406
+ * forwards the raw platform error text (which may name an internal
5407
+ * slug/handle, e.g. `WooCommerce product not found for slug "botox"`).
5408
+ * This component's only job is to render whatever generic string it's
5409
+ * given; it must never be handed a raw message to begin with. */
5241
5410
  this.error = null;
5411
+ /** Whether "Try again" is offered — false for a structurally dead-end
5412
+ * failure (retrying the identical query can never succeed) so the panel
5413
+ * never invites a retry that's already known to be pointless. */
5414
+ this.retryable = true;
5242
5415
  /** @internal */
5243
5416
  this._selection = {};
5244
5417
  /** @internal */
@@ -5332,8 +5505,10 @@ var _VariantPanelLit = class _VariantPanelLit extends LitElement7 {
5332
5505
  if (this.state === "error") {
5333
5506
  return html7`
5334
5507
  <div class="svp-error-block" data-error-block>
5335
- <div>Couldn't load options. ${this.error ?? ""}</div>
5336
- <button class="svp-retry-btn" data-retry-button @click=${this._onRetry}>Try again</button>
5508
+ <div>${this.error ?? "Couldn't load options."}</div>
5509
+ ${this.retryable ? html7`<button class="svp-retry-btn" data-retry-button @click=${this._onRetry}>
5510
+ Try again
5511
+ </button>` : nothing3}
5337
5512
  </div>
5338
5513
  `;
5339
5514
  }
@@ -5580,6 +5755,7 @@ _VariantPanelLit.properties = {
5580
5755
  productImage: { attribute: false },
5581
5756
  state: { type: String },
5582
5757
  error: { type: String },
5758
+ retryable: { type: Boolean },
5583
5759
  _selection: { state: true },
5584
5760
  _touched: { state: true },
5585
5761
  _expandedAxes: { state: true },
@@ -5818,6 +5994,7 @@ var ProductCardLit = class extends LitElement8 {
5818
5994
  .productImage=${product.image ? { src: product.image.src, alt: product.image.alt } : null}
5819
5995
  .state=${view.panel.status === "error" ? "error" : view.panel.status === "ready" ? "idle" : "loading"}
5820
5996
  .error=${view.panel.error}
5997
+ .retryable=${view.panel.retryable}
5821
5998
  @variant-panel-back=${this._onVariantBack}
5822
5999
  @variant-panel-add=${this._onVariantAdd}
5823
6000
  @variant-panel-retry=${this._onVariantRetry}
@@ -5958,7 +6135,14 @@ readProductObservation_fn = function() {
5958
6135
  const rawProductId = this.props?.product?.id;
5959
6136
  return {
5960
6137
  productId: parsed?.success ? parsed.data.product.id : typeof rawProductId === "string" ? rawProductId : null,
5961
- valid: parsed?.success === true
6138
+ valid: parsed?.success === true,
6139
+ // Does this product have a REAL Add-to-cart action? A booking-
6140
+ // fulfilment product (server-hydrated with a single "Book a consult"
6141
+ // href CTA — see product_hydration.build_product_from_hit) has none,
6142
+ // and its platform variant-lookup endpoint has nothing to find — the
6143
+ // decision core (card.ts) uses this to skip the Woo/Shopify variant
6144
+ // prefetch entirely for it, never just to disable a button.
6145
+ commerceCapable: parsed?.success === true && parsed.data.product.ctas.some((cta) => cta.actionId === "add_to_cart")
5962
6146
  };
5963
6147
  };
5964
6148
  syncDecisionInputsBeforeIntent_fn = function() {
@@ -5968,7 +6152,7 @@ syncDecisionInputsBeforeIntent_fn = function() {
5968
6152
  });
5969
6153
  const observation = __privateMethod(this, _ProductCardLit_instances, readProductObservation_fn).call(this);
5970
6154
  const product = this._decisionState.product;
5971
- const isCurrent = observation.valid === (product.status === "valid") && observation.productId === product.id;
6155
+ const isCurrent = observation.valid === (product.status === "valid") && observation.productId === product.id && (product.status !== "valid" || observation.commerceCapable === product.commerceCapable);
5972
6156
  if (!isCurrent) __privateMethod(this, _ProductCardLit_instances, dispatchCard_fn).call(this, { type: "props-replaced", ...observation });
5973
6157
  };
5974
6158
  ProductCardLit.properties = {
@@ -6395,9 +6579,16 @@ function renderProductCardInner(product, density, resolved, visibleFacts, ctas,
6395
6579
  style=${styleMap(ctaStyles)}
6396
6580
  @click=${disabled2 ? (e2) => {
6397
6581
  e2.preventDefault();
6398
- } : i2 === 0 && onPrimaryCtaClick ? onPrimaryCtaClick : c2.actionId === "add_to_cart" ? (e2) => {
6399
- e2.preventDefault();
6400
- } : void 0}
6582
+ } : (
6583
+ // The commerce (Add-to-cart / open-picker) intent wires ONLY to
6584
+ // an actual add_to_cart CTA. A plain navigating CTA — e.g. a
6585
+ // booking-fulfilment product's "Book a consult" href, which
6586
+ // sits at i===0 too — must fall through to its own <a href>
6587
+ // and never enter the Woo/Shopify variant-lookup flow.
6588
+ i2 === 0 && c2.actionId === "add_to_cart" && onPrimaryCtaClick ? onPrimaryCtaClick : c2.actionId === "add_to_cart" ? (e2) => {
6589
+ e2.preventDefault();
6590
+ } : void 0
6591
+ )}
6401
6592
  >${// Secondary "Product Page" link gets a trailing chevron (panel's
6402
6593
  // quiet-secondary affordance) unless the author already added one.
6403
6594
  i2 !== 0 && !/[›>→]\s*$/.test(ctaLabel) ? `${ctaLabel} \u203A` : ctaLabel}</a
@@ -11736,4 +11927,4 @@ export {
11736
11927
  * SPDX-License-Identifier: BSD-3-Clause
11737
11928
  *)
11738
11929
  */
11739
- //# sourceMappingURL=chunk-E5TZSKZV.js.map
11930
+ //# sourceMappingURL=chunk-6DWXPP5X.js.map