@rebuy/rebuy 3.21.0 → 3.22.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.
@@ -6,13 +6,17 @@ import { type MonetizeInput, type ServerContext } from '../server/shared';
6
6
  * built (impression/decline/click) — the composer wires them onto the card verbatim.
7
7
  */
8
8
  export declare const AdsEngineOffer: z.ZodObject<{
9
+ callout: z.ZodCatch<z.ZodString>;
9
10
  click_url: z.ZodString;
10
11
  cta: z.ZodString;
11
12
  decline_cta: z.ZodCatch<z.ZodString>;
12
13
  decline_url: z.ZodString;
13
14
  description: z.ZodString;
15
+ disclaimer: z.ZodCatch<z.ZodString>;
16
+ extended_disclaimer: z.ZodCatch<z.ZodString>;
14
17
  headline: z.ZodString;
15
18
  id: z.ZodString;
19
+ image_alt: z.ZodCatch<z.ZodString>;
16
20
  image_url: z.ZodOptional<z.ZodString>;
17
21
  view_url: z.ZodString;
18
22
  }, z.core.$strip>;
@@ -4184,14 +4184,22 @@ var transactionItems = (input) => (input.transactionItems?.map(({ id, priceCents
4184
4184
  type
4185
4185
  })) ?? input.cart.items.map(({ productId, quantity }) => ({ id: productId, quantity }))).slice(0, MAX_TRANSACTION_ITEMS);
4186
4186
  var AdsEngineOffer = import_zod28.z.object({
4187
+ /** Pill-style value prop ("Get $45 Cashback"). Carried through the parse; the card does not render it yet (REB-24429 — pill design pending). */
4188
+ callout: import_zod28.z.string().catch(""),
4187
4189
  click_url: import_zod28.z.string(),
4188
4190
  cta: import_zod28.z.string(),
4189
4191
  /** `omitempty` on the ad server — a bid without it must not fail the parse and drop the whole batch (composeOffer falls back to 'No Thanks'). */
4190
4192
  decline_cta: import_zod28.z.string().catch(""),
4191
4193
  decline_url: import_zod28.z.string(),
4192
4194
  description: import_zod28.z.string(),
4195
+ /** Short legal disclosure (FDIC / auto-renewal / FTC-affiliation lines). Rendered subdued under the CTAs. */
4196
+ disclaimer: import_zod28.z.string().catch(""),
4197
+ /** Long legal text behind the disclosure toggle in the composed card (REB-24429). */
4198
+ extended_disclaimer: import_zod28.z.string().catch(""),
4193
4199
  headline: import_zod28.z.string(),
4194
4200
  id: import_zod28.z.string(),
4201
+ /** Advertiser-supplied image alt; the composed card falls back to the stripped headline when empty. */
4202
+ image_alt: import_zod28.z.string().catch(""),
4195
4203
  image_url: import_zod28.z.string().optional(),
4196
4204
  view_url: import_zod28.z.string()
4197
4205
  });
@@ -4208,6 +4216,15 @@ var isTestingRequest = (input, isProduction) => !isProduction || !!input.testing
4208
4216
  var buildAdsParams = (input, publisherKey, supplyKey, testing, userAgent, ipAddress) => {
4209
4217
  const items = transactionItems(input);
4210
4218
  return {
4219
+ /**
4220
+ * The engine converts text fields to the requested format before responding (Fluent's source
4221
+ * format is html). `html` makes that conversion a no-op, so the canonical HTML→Tiptap pipeline
4222
+ * receives the DSP's original markup — the default (markdown) would hand it `**bold**` and
4223
+ * `[link](url)` syntax the pipeline renders literally. Contract verified against ad-server
4224
+ * PR #349: the engine lowercases/trims the value, `html` is the accepted spelling, and an
4225
+ * unrecognized value falls through unconverted rather than erroring.
4226
+ */
4227
+ format: "html",
4211
4228
  limit: String(OFFER_LIMIT),
4212
4229
  placement: input.placement,
4213
4230
  publisher_key: publisherKey,
@@ -4255,6 +4272,14 @@ var fetchAdsEngineOffers = async (input, ctx, prefetched) => {
4255
4272
  return { offers: AdsEngineResponse.parse(upstream).offers, storeName };
4256
4273
  };
4257
4274
 
4275
+ // src/transforms/htmlToPlainText.ts
4276
+ var paragraphText = (paragraph) => paragraph.content.map((run) => run.text).join("");
4277
+ var FORCE_PARSER = { defaultTextStyle: {} };
4278
+ var withoutTagShapes = (text2) => isHTML(text2) ? text2.replace(/[<>]/g, "") : text2;
4279
+ var htmlToPlainText = (html) => withoutTagShapes(
4280
+ normalizeTextValue(html, FORCE_PARSER).content.map((paragraph) => paragraphText(paragraph).trim()).filter((line) => line !== "").join("\n")
4281
+ );
4282
+
4258
4283
  // src/server/composeOffer.ts
4259
4284
  var ACCEPT_FALLBACK = "Accept";
4260
4285
  var DECLINE_FALLBACK = "No Thanks";
@@ -4264,8 +4289,19 @@ var label = (text2) => ({
4264
4289
  sectionType: SectionType.text
4265
4290
  });
4266
4291
  var text = (content) => ({ content: { en: content }, sectionType: SectionType.text });
4292
+ var AS_DOC = { defaultTextStyle: {} };
4293
+ var safeTextValue = (raw, options) => {
4294
+ const converted = normalizeTextValue(raw, options);
4295
+ return checkForHTML(converted) ? converted : normalizeTextValue(htmlToPlainText(raw), options);
4296
+ };
4297
+ var baseText = (raw) => ({ content: { en: safeTextValue(raw, AS_DOC) }, sectionType: SectionType.text });
4298
+ var dspLabel = (raw, fallback) => ({
4299
+ buttonField: ButtonField.buttonLabel,
4300
+ content: { en: htmlToPlainText(raw) === "" ? fallback : safeTextValue(raw, AS_DOC) },
4301
+ sectionType: SectionType.text
4302
+ });
4267
4303
  var smallText = (raw) => ({
4268
- content: { en: normalizeTextValue(raw, { defaultTextStyle: { fontSize: "small" } }) },
4304
+ content: { en: safeTextValue(raw, { defaultTextStyle: { fontSize: "small" } }) },
4269
4305
  sectionType: SectionType.text
4270
4306
  });
4271
4307
  var styled = (value, { align, bold, color, fontSize, href }) => ({
@@ -4308,9 +4344,43 @@ var headerSections = (input, storeName) => [
4308
4344
  },
4309
4345
  text(input.orderName ? `Order ${input.orderName} Confirmed` : "Order Confirmed")
4310
4346
  ];
4347
+ var SHOW_EXTENDED = "showExtendedDisclaimer";
4348
+ var LEGAL_STYLE = { defaultTextStyle: { color: TextColorName.subdued, fontSize: "extraSmall" } };
4349
+ var legalCopy = (raw, visibleWhen) => ({
4350
+ content: { en: safeTextValue(raw, LEGAL_STYLE) },
4351
+ sectionType: SectionType.text,
4352
+ ...visibleWhen !== void 0 && { visibility: { variable: SHOW_EXTENDED, visibleWhen } }
4353
+ });
4354
+ var disclosureToggle = (text2, visibleWhen) => ({
4355
+ buttonStyle: ButtonStyle.plain,
4356
+ sections: [label(text2)],
4357
+ sectionType: SectionType.button,
4358
+ variableEffects: [{ effect: "toggle", variable: SHOW_EXTENDED }],
4359
+ visibility: { variable: SHOW_EXTENDED, visibleWhen }
4360
+ });
4361
+ var disclaimerSections = (offer) => {
4362
+ const hasShort = htmlToPlainText(offer.disclaimer) !== "";
4363
+ const hasExtended = htmlToPlainText(offer.extended_disclaimer) !== "";
4364
+ if (!hasExtended) return hasShort ? [legalCopy(offer.disclaimer)] : [];
4365
+ return [
4366
+ {
4367
+ direction: Direction.rows,
4368
+ sections: [
4369
+ ...hasShort ? [legalCopy(offer.disclaimer)] : [],
4370
+ legalCopy(offer.extended_disclaimer, true),
4371
+ /** With no short line above it, "See more..." has no referent — the toggle names the disclaimer itself. */
4372
+ disclosureToggle(hasShort ? "See more for details..." : "See disclaimer", false),
4373
+ disclosureToggle(hasShort ? "See less" : "Hide disclaimer", true)
4374
+ ],
4375
+ sectionType: SectionType.layout,
4376
+ spacing: Spacing.none,
4377
+ variables: [{ defaultValue: false, name: SHOW_EXTENDED }]
4378
+ }
4379
+ ];
4380
+ };
4311
4381
  var composeOfferCard = (offer, input, storeName) => {
4312
4382
  const copy = {
4313
- sections: [text(offer.headline), smallText(offer.description)],
4383
+ sections: [baseText(offer.headline), smallText(offer.description)],
4314
4384
  sectionType: SectionType.layout,
4315
4385
  spacing: Spacing.tight
4316
4386
  };
@@ -4327,8 +4397,8 @@ var composeOfferCard = (offer, input, storeName) => {
4327
4397
  sections: [
4328
4398
  copy,
4329
4399
  {
4330
- /** Image alt must be PLAIN — a DSP headline like `Get <b>50%</b> off` would trip the image NO_HTML refine and bleed fill; the visible `text(offer.headline)` still gets the HTML→Tiptap pipeline. */
4331
- altText: offer.headline.replace(/<[^>]*>/g, ""),
4400
+ /** Image alt must be PLAIN — a DSP headline like `Get <b>50%</b> off` would trip the image NO_HTML refine and bleed fill; the advertiser's own `image_alt` wins over the headline fallback, both single-lined through {@link htmlToPlainText}. */
4401
+ altText: htmlToPlainText(offer.image_alt).replace(/\n+/g, " ") || htmlToPlainText(offer.headline).replace(/\n+/g, " "),
4332
4402
  sectionType: SectionType.image,
4333
4403
  source: offer.image_url
4334
4404
  }
@@ -4344,7 +4414,7 @@ var composeOfferCard = (offer, input, storeName) => {
4344
4414
  action: ButtonAction.acceptOffer,
4345
4415
  buttonStyle: ButtonStyle.primary,
4346
4416
  sections: [
4347
- label(offer.cta || ACCEPT_FALLBACK),
4417
+ dspLabel(offer.cta, ACCEPT_FALLBACK),
4348
4418
  {
4349
4419
  buttonField: ButtonField.destinationUrl,
4350
4420
  content: { en: offer.click_url },
@@ -4356,13 +4426,14 @@ var composeOfferCard = (offer, input, storeName) => {
4356
4426
  {
4357
4427
  action: ButtonAction.declineOffer,
4358
4428
  buttonStyle: ButtonStyle.secondary,
4359
- sections: [label(offer.decline_cta || DECLINE_FALLBACK)],
4429
+ sections: [dspLabel(offer.decline_cta, DECLINE_FALLBACK)],
4360
4430
  sectionType: SectionType.button,
4361
4431
  trackingUrl: offer.decline_url
4362
4432
  }
4363
4433
  ],
4364
4434
  sectionType: SectionType.layout
4365
4435
  },
4436
+ ...disclaimerSections(offer),
4366
4437
  /**
4367
4438
  * Ad-unit Privacy Policy footer (compliance). Its own end-aligned layout packs a fit-content
4368
4439
  * paragraph right; the paragraph's own `textAlign: end` covers the full-width case — together
@@ -4133,14 +4133,22 @@ var transactionItems = (input) => (input.transactionItems?.map(({ id, priceCents
4133
4133
  type
4134
4134
  })) ?? input.cart.items.map(({ productId, quantity }) => ({ id: productId, quantity }))).slice(0, MAX_TRANSACTION_ITEMS);
4135
4135
  var AdsEngineOffer = z28.object({
4136
+ /** Pill-style value prop ("Get $45 Cashback"). Carried through the parse; the card does not render it yet (REB-24429 — pill design pending). */
4137
+ callout: z28.string().catch(""),
4136
4138
  click_url: z28.string(),
4137
4139
  cta: z28.string(),
4138
4140
  /** `omitempty` on the ad server — a bid without it must not fail the parse and drop the whole batch (composeOffer falls back to 'No Thanks'). */
4139
4141
  decline_cta: z28.string().catch(""),
4140
4142
  decline_url: z28.string(),
4141
4143
  description: z28.string(),
4144
+ /** Short legal disclosure (FDIC / auto-renewal / FTC-affiliation lines). Rendered subdued under the CTAs. */
4145
+ disclaimer: z28.string().catch(""),
4146
+ /** Long legal text behind the disclosure toggle in the composed card (REB-24429). */
4147
+ extended_disclaimer: z28.string().catch(""),
4142
4148
  headline: z28.string(),
4143
4149
  id: z28.string(),
4150
+ /** Advertiser-supplied image alt; the composed card falls back to the stripped headline when empty. */
4151
+ image_alt: z28.string().catch(""),
4144
4152
  image_url: z28.string().optional(),
4145
4153
  view_url: z28.string()
4146
4154
  });
@@ -4157,6 +4165,15 @@ var isTestingRequest = (input, isProduction) => !isProduction || !!input.testing
4157
4165
  var buildAdsParams = (input, publisherKey, supplyKey, testing, userAgent, ipAddress) => {
4158
4166
  const items = transactionItems(input);
4159
4167
  return {
4168
+ /**
4169
+ * The engine converts text fields to the requested format before responding (Fluent's source
4170
+ * format is html). `html` makes that conversion a no-op, so the canonical HTML→Tiptap pipeline
4171
+ * receives the DSP's original markup — the default (markdown) would hand it `**bold**` and
4172
+ * `[link](url)` syntax the pipeline renders literally. Contract verified against ad-server
4173
+ * PR #349: the engine lowercases/trims the value, `html` is the accepted spelling, and an
4174
+ * unrecognized value falls through unconverted rather than erroring.
4175
+ */
4176
+ format: "html",
4160
4177
  limit: String(OFFER_LIMIT),
4161
4178
  placement: input.placement,
4162
4179
  publisher_key: publisherKey,
@@ -4204,6 +4221,14 @@ var fetchAdsEngineOffers = async (input, ctx, prefetched) => {
4204
4221
  return { offers: AdsEngineResponse.parse(upstream).offers, storeName };
4205
4222
  };
4206
4223
 
4224
+ // src/transforms/htmlToPlainText.ts
4225
+ var paragraphText = (paragraph) => paragraph.content.map((run) => run.text).join("");
4226
+ var FORCE_PARSER = { defaultTextStyle: {} };
4227
+ var withoutTagShapes = (text2) => isHTML(text2) ? text2.replace(/[<>]/g, "") : text2;
4228
+ var htmlToPlainText = (html) => withoutTagShapes(
4229
+ normalizeTextValue(html, FORCE_PARSER).content.map((paragraph) => paragraphText(paragraph).trim()).filter((line) => line !== "").join("\n")
4230
+ );
4231
+
4207
4232
  // src/server/composeOffer.ts
4208
4233
  var ACCEPT_FALLBACK = "Accept";
4209
4234
  var DECLINE_FALLBACK = "No Thanks";
@@ -4213,8 +4238,19 @@ var label = (text2) => ({
4213
4238
  sectionType: SectionType.text
4214
4239
  });
4215
4240
  var text = (content) => ({ content: { en: content }, sectionType: SectionType.text });
4241
+ var AS_DOC = { defaultTextStyle: {} };
4242
+ var safeTextValue = (raw, options) => {
4243
+ const converted = normalizeTextValue(raw, options);
4244
+ return checkForHTML(converted) ? converted : normalizeTextValue(htmlToPlainText(raw), options);
4245
+ };
4246
+ var baseText = (raw) => ({ content: { en: safeTextValue(raw, AS_DOC) }, sectionType: SectionType.text });
4247
+ var dspLabel = (raw, fallback) => ({
4248
+ buttonField: ButtonField.buttonLabel,
4249
+ content: { en: htmlToPlainText(raw) === "" ? fallback : safeTextValue(raw, AS_DOC) },
4250
+ sectionType: SectionType.text
4251
+ });
4216
4252
  var smallText = (raw) => ({
4217
- content: { en: normalizeTextValue(raw, { defaultTextStyle: { fontSize: "small" } }) },
4253
+ content: { en: safeTextValue(raw, { defaultTextStyle: { fontSize: "small" } }) },
4218
4254
  sectionType: SectionType.text
4219
4255
  });
4220
4256
  var styled = (value, { align, bold, color, fontSize, href }) => ({
@@ -4257,9 +4293,43 @@ var headerSections = (input, storeName) => [
4257
4293
  },
4258
4294
  text(input.orderName ? `Order ${input.orderName} Confirmed` : "Order Confirmed")
4259
4295
  ];
4296
+ var SHOW_EXTENDED = "showExtendedDisclaimer";
4297
+ var LEGAL_STYLE = { defaultTextStyle: { color: TextColorName.subdued, fontSize: "extraSmall" } };
4298
+ var legalCopy = (raw, visibleWhen) => ({
4299
+ content: { en: safeTextValue(raw, LEGAL_STYLE) },
4300
+ sectionType: SectionType.text,
4301
+ ...visibleWhen !== void 0 && { visibility: { variable: SHOW_EXTENDED, visibleWhen } }
4302
+ });
4303
+ var disclosureToggle = (text2, visibleWhen) => ({
4304
+ buttonStyle: ButtonStyle.plain,
4305
+ sections: [label(text2)],
4306
+ sectionType: SectionType.button,
4307
+ variableEffects: [{ effect: "toggle", variable: SHOW_EXTENDED }],
4308
+ visibility: { variable: SHOW_EXTENDED, visibleWhen }
4309
+ });
4310
+ var disclaimerSections = (offer) => {
4311
+ const hasShort = htmlToPlainText(offer.disclaimer) !== "";
4312
+ const hasExtended = htmlToPlainText(offer.extended_disclaimer) !== "";
4313
+ if (!hasExtended) return hasShort ? [legalCopy(offer.disclaimer)] : [];
4314
+ return [
4315
+ {
4316
+ direction: Direction.rows,
4317
+ sections: [
4318
+ ...hasShort ? [legalCopy(offer.disclaimer)] : [],
4319
+ legalCopy(offer.extended_disclaimer, true),
4320
+ /** With no short line above it, "See more..." has no referent — the toggle names the disclaimer itself. */
4321
+ disclosureToggle(hasShort ? "See more for details..." : "See disclaimer", false),
4322
+ disclosureToggle(hasShort ? "See less" : "Hide disclaimer", true)
4323
+ ],
4324
+ sectionType: SectionType.layout,
4325
+ spacing: Spacing.none,
4326
+ variables: [{ defaultValue: false, name: SHOW_EXTENDED }]
4327
+ }
4328
+ ];
4329
+ };
4260
4330
  var composeOfferCard = (offer, input, storeName) => {
4261
4331
  const copy = {
4262
- sections: [text(offer.headline), smallText(offer.description)],
4332
+ sections: [baseText(offer.headline), smallText(offer.description)],
4263
4333
  sectionType: SectionType.layout,
4264
4334
  spacing: Spacing.tight
4265
4335
  };
@@ -4276,8 +4346,8 @@ var composeOfferCard = (offer, input, storeName) => {
4276
4346
  sections: [
4277
4347
  copy,
4278
4348
  {
4279
- /** Image alt must be PLAIN — a DSP headline like `Get <b>50%</b> off` would trip the image NO_HTML refine and bleed fill; the visible `text(offer.headline)` still gets the HTML→Tiptap pipeline. */
4280
- altText: offer.headline.replace(/<[^>]*>/g, ""),
4349
+ /** Image alt must be PLAIN — a DSP headline like `Get <b>50%</b> off` would trip the image NO_HTML refine and bleed fill; the advertiser's own `image_alt` wins over the headline fallback, both single-lined through {@link htmlToPlainText}. */
4350
+ altText: htmlToPlainText(offer.image_alt).replace(/\n+/g, " ") || htmlToPlainText(offer.headline).replace(/\n+/g, " "),
4281
4351
  sectionType: SectionType.image,
4282
4352
  source: offer.image_url
4283
4353
  }
@@ -4293,7 +4363,7 @@ var composeOfferCard = (offer, input, storeName) => {
4293
4363
  action: ButtonAction.acceptOffer,
4294
4364
  buttonStyle: ButtonStyle.primary,
4295
4365
  sections: [
4296
- label(offer.cta || ACCEPT_FALLBACK),
4366
+ dspLabel(offer.cta, ACCEPT_FALLBACK),
4297
4367
  {
4298
4368
  buttonField: ButtonField.destinationUrl,
4299
4369
  content: { en: offer.click_url },
@@ -4305,13 +4375,14 @@ var composeOfferCard = (offer, input, storeName) => {
4305
4375
  {
4306
4376
  action: ButtonAction.declineOffer,
4307
4377
  buttonStyle: ButtonStyle.secondary,
4308
- sections: [label(offer.decline_cta || DECLINE_FALLBACK)],
4378
+ sections: [dspLabel(offer.decline_cta, DECLINE_FALLBACK)],
4309
4379
  sectionType: SectionType.button,
4310
4380
  trackingUrl: offer.decline_url
4311
4381
  }
4312
4382
  ],
4313
4383
  sectionType: SectionType.layout
4314
4384
  },
4385
+ ...disclaimerSections(offer),
4315
4386
  /**
4316
4387
  * Ad-unit Privacy Policy footer (compliance). Its own end-aligned layout packs a fit-content
4317
4388
  * paragraph right; the paragraph's own `textAlign: end` covers the full-width case — together
@@ -0,0 +1,10 @@
1
+ /**
2
+ * HTML → display plain text for ad copy that must ship as a bare string (image alt, the compose
3
+ * gates on legal copy). Derived FROM the canonical pipeline rather than a parallel strip:
4
+ * {@link normalizeTextValue} converts exactly as the in-card text sections do (entities decoded
5
+ * NO_HTML-safe, comments and script/style bodies dropped, block tags and `<br>`/`<hr>` flushing
6
+ * paragraphs), and the doc's paragraphs flatten to newline-joined lines. By construction this
7
+ * cannot drift from what the card renders, and the single-pass parser has none of the
8
+ * malformed-nesting or adversarial-input hazards a regex strip must defend against.
9
+ */
10
+ export declare const htmlToPlainText: (html: string) => string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rebuy/rebuy",
3
- "version": "3.21.0",
3
+ "version": "3.22.0",
4
4
  "description": "Shared zod schemas, legacy-to-CAB widget transforms, and server orchestrators for Rebuy's Shopify consumers (rebuy-api, admin-nextjs, rebuy-shopify-extensions)",
5
5
  "license": "MIT",
6
6
  "author": "Rebuy, Inc.",