@replohq/sdk 2.1.0 → 2.2.1

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.
@@ -1,4 +1,4 @@
1
- export type ReploScriptType = "GA4" | "GoogleTagManager" | "Meta" | "TikTok" | "Pinterest" | "Reddit" | "Snapchat" | "X" | "Hotjar" | "MicrosoftClarity" | "Contentsquare" | "Segment" | "Northbeam" | "Gorgias" | "Converge" | "HubSpot" | "Cookiebot" | "snippet" | "custom" | "consentPlatform";
1
+ export type ReploScriptType = "GA4" | "GoogleTagManager" | "Meta" | "TikTok" | "Pinterest" | "Reddit" | "Snapchat" | "X" | "Hotjar" | "MicrosoftClarity" | "Contentsquare" | "Segment" | "Northbeam" | "Gorgias" | "Converge" | "Mixpanel" | "HubSpot" | "Cookiebot" | "snippet" | "custom" | "consentPlatform";
2
2
  export type ConsentCategory = "necessary" | "analytics" | "marketing" | "preferences" | "sale_of_data";
3
3
  export type ConsentMode = "off" | "simple" | "per-category";
4
4
  export type ScriptTagDescriptor = {
@@ -19,6 +19,7 @@ export type XPixelEvents = {
19
19
  startCheckout?: string;
20
20
  purchase?: string;
21
21
  };
22
+ export type MixpanelRegion = "us" | "eu" | "in";
22
23
  export type ReploScriptEntry = {
23
24
  type: "GA4" | "GoogleTagManager" | "Meta" | "TikTok" | "Pinterest" | "Reddit" | "Snapchat" | "Hotjar" | "MicrosoftClarity" | "Contentsquare" | "Segment" | "Northbeam" | "Gorgias" | "Converge" | "HubSpot";
24
25
  identifier: string;
@@ -28,6 +29,11 @@ export type ReploScriptEntry = {
28
29
  identifier: string;
29
30
  events?: Partial<XPixelEvents>;
30
31
  requiredConsent?: ConsentCategory[];
32
+ } | {
33
+ type: "Mixpanel";
34
+ identifier: string;
35
+ region?: MixpanelRegion;
36
+ requiredConsent?: ConsentCategory[];
31
37
  } | {
32
38
  type: "Cookiebot";
33
39
  identifier: string;
@@ -4,6 +4,7 @@ import { ConvergeSink } from "./sinks/converge-sink";
4
4
  import { ElevarSink } from "./sinks/elevar-sink";
5
5
  import { GA4Sink } from "./sinks/ga4-sink";
6
6
  import { MetaSink } from "./sinks/meta-sink";
7
+ import { MixpanelSink } from "./sinks/mixpanel-sink";
7
8
  import { NorthbeamSink } from "./sinks/northbeam-sink";
8
9
  import { PinterestSink } from "./sinks/pinterest-sink";
9
10
  import { RedditSink } from "./sinks/reddit-sink";
@@ -23,12 +24,13 @@ const PROVIDER_SINK_FACTORIES = {
23
24
  Reddit: () => new RedditSink(),
24
25
  Northbeam: () => new NorthbeamSink(),
25
26
  Converge: () => new ConvergeSink(),
26
- Segment: () => new SegmentSink()
27
+ Segment: () => new SegmentSink(),
28
+ Mixpanel: () => new MixpanelSink()
27
29
  };
28
30
  const SNIPPET_PROVIDER_SINK_FACTORIES = {
29
31
  [snippetScriptId("Elevar")]: () => new ElevarSink()
30
32
  };
31
- const SHOPIFY_FUNNEL_GATE_EXEMPT_PROVIDERS = /* @__PURE__ */ new Set(["Segment"]);
33
+ const SHOPIFY_FUNNEL_GATE_EXEMPT_PROVIDERS = /* @__PURE__ */ new Set(["Segment", "Mixpanel"]);
32
34
  function findSinkFactory(script) {
33
35
  if (script.type === "X") {
34
36
  return () => {
@@ -0,0 +1,48 @@
1
+ import type { AnalyticsProductLine, CartViewedData, CheckoutStartedData, CollectionViewedData, PurchasePayload } from "../analytics-sink";
2
+ /**
3
+ * Maps Replo's analytics payloads onto Mixpanel's conventional e-commerce
4
+ * property names (the Segment-style vocabulary Mixpanel's docs recommend:
5
+ * `product_id`, `name`, `price`, `quantity`, `products`, `revenue`, ...).
6
+ *
7
+ * Mixpanel accepts any property bag, so unlike the Converge mapper nothing
8
+ * here rejects an event: a funnel built on `Product Viewed` still counts an
9
+ * event whose payload had no price. Empty SKUs and variant titles are left
10
+ * out rather than sent blank.
11
+ */
12
+ export type MixpanelProductProperties = {
13
+ product_id: string;
14
+ sku?: string;
15
+ name?: string;
16
+ variant?: string;
17
+ variant_id?: string;
18
+ price?: number;
19
+ quantity?: number;
20
+ currency?: string;
21
+ };
22
+ export type MixpanelCartProperties = {
23
+ total: number;
24
+ currency?: string;
25
+ products?: MixpanelProductProperties[];
26
+ };
27
+ export type MixpanelOrderProperties = {
28
+ total: number;
29
+ /** The property Mixpanel's revenue reports read. */
30
+ revenue: number;
31
+ currency: string;
32
+ order_id?: string;
33
+ products?: MixpanelProductProperties[];
34
+ tax?: number;
35
+ shipping?: number;
36
+ discount?: number;
37
+ coupon?: string;
38
+ };
39
+ export type MixpanelCollectionProperties = {
40
+ list_id: string;
41
+ name?: string;
42
+ products?: MixpanelProductProperties[];
43
+ };
44
+ export declare function mapMixpanelProduct(line: AnalyticsProductLine): MixpanelProductProperties;
45
+ export declare function mapMixpanelViewedCart(data: CartViewedData): MixpanelCartProperties;
46
+ export declare function mapMixpanelCheckout(data: CheckoutStartedData): MixpanelCartProperties;
47
+ export declare function mapMixpanelCollection(data: CollectionViewedData): MixpanelCollectionProperties;
48
+ export declare function mapMixpanelOrder({ amount, currency, data, }: PurchasePayload): MixpanelOrderProperties;
@@ -0,0 +1,96 @@
1
+ import { filterUndefined } from "../../_vendor/replo-utils/lib/object.mjs";
2
+ import { legacyResourceId } from "../utils/analytics-products";
3
+ function mapProduct({
4
+ line,
5
+ fallbackCurrency
6
+ }) {
7
+ return {
8
+ product_id: legacyResourceId(line.productId),
9
+ ...filterUndefined({
10
+ sku: line.variantSku || void 0,
11
+ name: line.productTitle || void 0,
12
+ variant: line.variantTitle || void 0,
13
+ variant_id: line.variantId ? legacyResourceId(line.variantId) : void 0,
14
+ price: line.price,
15
+ quantity: line.quantity,
16
+ currency: line.currency ?? fallbackCurrency ?? void 0
17
+ })
18
+ };
19
+ }
20
+ function mapProducts({
21
+ lines,
22
+ currency
23
+ }) {
24
+ if (lines.length === 0) {
25
+ return void 0;
26
+ }
27
+ return lines.map((line) => mapProduct({ line, fallbackCurrency: currency }));
28
+ }
29
+ function mapMixpanelProduct(line) {
30
+ return mapProduct({ line, fallbackCurrency: null });
31
+ }
32
+ function mapCart({
33
+ total,
34
+ currency,
35
+ lineItems
36
+ }) {
37
+ return {
38
+ total,
39
+ ...filterUndefined({
40
+ currency: currency ?? void 0,
41
+ products: mapProducts({ lines: lineItems, currency })
42
+ })
43
+ };
44
+ }
45
+ function mapMixpanelViewedCart(data) {
46
+ return mapCart({
47
+ total: data.subtotal,
48
+ currency: data.currency ?? null,
49
+ lineItems: data.lineItems
50
+ });
51
+ }
52
+ function mapMixpanelCheckout(data) {
53
+ return mapCart({
54
+ total: data.total,
55
+ currency: data.currency ?? null,
56
+ lineItems: data.lineItems
57
+ });
58
+ }
59
+ function mapMixpanelCollection(data) {
60
+ return {
61
+ list_id: legacyResourceId(data.collectionId),
62
+ ...filterUndefined({
63
+ name: data.collectionTitle || void 0,
64
+ products: mapProducts({
65
+ lines: data.products,
66
+ currency: data.currency ?? null
67
+ })
68
+ })
69
+ };
70
+ }
71
+ function mapMixpanelOrder({
72
+ amount,
73
+ currency,
74
+ data
75
+ }) {
76
+ return {
77
+ total: amount,
78
+ revenue: amount,
79
+ currency,
80
+ ...filterUndefined({
81
+ order_id: data.orderId || void 0,
82
+ products: mapProducts({ lines: data.lineItems ?? [], currency }),
83
+ tax: data.totalTax,
84
+ shipping: data.totalShipping,
85
+ discount: data.totalDiscount,
86
+ coupon: data.discountCodes && data.discountCodes.length > 0 ? data.discountCodes.join(", ") : void 0
87
+ })
88
+ };
89
+ }
90
+ export {
91
+ mapMixpanelCheckout,
92
+ mapMixpanelCollection,
93
+ mapMixpanelOrder,
94
+ mapMixpanelProduct,
95
+ mapMixpanelViewedCart
96
+ };
@@ -0,0 +1,27 @@
1
+ import type { AnalyticsSink, CartLineEventPayload, CartViewedPayload, CheckoutStartedPayload, ClickPayload, CollectionViewedPayload, CustomEventPayload, LegacyCheckoutStartedPayload, ProductViewedPayload, PurchasePayload } from "../analytics-sink";
2
+ import type { PageMetadata } from "../utils/navigation-tracker";
3
+ declare global {
4
+ interface Window {
5
+ mixpanel?: {
6
+ track: (eventName: string, properties: Record<string, unknown>) => void;
7
+ };
8
+ }
9
+ }
10
+ /**
11
+ * Sends Replo's analytics events to Mixpanel with its conventional e-commerce
12
+ * event names. The consent-gated snippet sends the landing "Page Viewed"; this
13
+ * sink adds one for each in-site navigation, so each load counts once.
14
+ */
15
+ export declare class MixpanelSink implements AnalyticsSink {
16
+ private send;
17
+ customEvent({ event, properties }: CustomEventPayload): Promise<void>;
18
+ pageView(metadata?: PageMetadata): Promise<void>;
19
+ purchase(payload: PurchasePayload): Promise<void>;
20
+ click({ data }: ClickPayload): Promise<void>;
21
+ productViewed({ data }: ProductViewedPayload): Promise<void>;
22
+ addToCart({ data }: CartLineEventPayload): Promise<void>;
23
+ removeFromCart({ data }: CartLineEventPayload): Promise<void>;
24
+ viewCart({ data }: CartViewedPayload): Promise<void>;
25
+ startCheckout(payload: CheckoutStartedPayload | LegacyCheckoutStartedPayload): Promise<void>;
26
+ viewCollection({ data }: CollectionViewedPayload): Promise<void>;
27
+ }
@@ -0,0 +1,99 @@
1
+ import { filterUndefined } from "../../_vendor/replo-utils/lib/object.mjs";
2
+ import { toCheckoutStartedData } from "../legacy-checkout";
3
+ import { createAnalyticsQueue } from "../utils/analytics-utils";
4
+ import {
5
+ mapMixpanelCheckout,
6
+ mapMixpanelCollection,
7
+ mapMixpanelOrder,
8
+ mapMixpanelProduct,
9
+ mapMixpanelViewedCart
10
+ } from "./mixpanel-event-mapper";
11
+ function findMixpanel() {
12
+ if (typeof window === "undefined") {
13
+ return null;
14
+ }
15
+ return window.mixpanel ?? null;
16
+ }
17
+ function pathFromUrl(url) {
18
+ try {
19
+ return new URL(url).pathname;
20
+ } catch {
21
+ return void 0;
22
+ }
23
+ }
24
+ class MixpanelSink {
25
+ send = createAnalyticsQueue({
26
+ isReady: () => Boolean(findMixpanel()),
27
+ send: ({ eventName, properties }) => {
28
+ findMixpanel()?.track(eventName, properties);
29
+ },
30
+ queueName: "Mixpanel"
31
+ });
32
+ async customEvent({ event, properties }) {
33
+ this.send({ eventName: event, properties: properties ?? {} });
34
+ }
35
+ async pageView(metadata) {
36
+ if (metadata?.navigationType === "initial") {
37
+ return;
38
+ }
39
+ const url = metadata?.url ?? (typeof window === "undefined" ? void 0 : window.location.href);
40
+ this.send({
41
+ eventName: "Page Viewed",
42
+ properties: filterUndefined({
43
+ url,
44
+ path: url ? pathFromUrl(url) : void 0,
45
+ title: metadata?.title || void 0,
46
+ referrer: metadata?.referrer || void 0,
47
+ navigation_type: metadata?.navigationType
48
+ })
49
+ });
50
+ }
51
+ async purchase(payload) {
52
+ this.send({
53
+ eventName: "Order Completed",
54
+ properties: mapMixpanelOrder(payload)
55
+ });
56
+ }
57
+ async click({ data }) {
58
+ this.send({ eventName: "Click", properties: data });
59
+ }
60
+ async productViewed({ data }) {
61
+ this.send({
62
+ eventName: "Product Viewed",
63
+ properties: mapMixpanelProduct(data)
64
+ });
65
+ }
66
+ async addToCart({ data }) {
67
+ this.send({
68
+ eventName: "Product Added",
69
+ properties: mapMixpanelProduct(data)
70
+ });
71
+ }
72
+ async removeFromCart({ data }) {
73
+ this.send({
74
+ eventName: "Product Removed",
75
+ properties: mapMixpanelProduct(data)
76
+ });
77
+ }
78
+ async viewCart({ data }) {
79
+ this.send({
80
+ eventName: "Cart Viewed",
81
+ properties: mapMixpanelViewedCart(data)
82
+ });
83
+ }
84
+ async startCheckout(payload) {
85
+ this.send({
86
+ eventName: "Checkout Started",
87
+ properties: mapMixpanelCheckout(toCheckoutStartedData(payload.data))
88
+ });
89
+ }
90
+ async viewCollection({ data }) {
91
+ this.send({
92
+ eventName: "Collection Viewed",
93
+ properties: mapMixpanelCollection(data)
94
+ });
95
+ }
96
+ }
97
+ export {
98
+ MixpanelSink
99
+ };
@@ -1,4 +1,4 @@
1
- import * as z from "zod";
1
+ import * as v from "valibot";
2
2
  import { DENIED_CATEGORIES } from "./cookie";
3
3
  const COOKIEBOT_CATEGORY_BY_CONSENT = {
4
4
  analytics: "statistics",
@@ -75,7 +75,7 @@ const OPTIONAL_CONSENT_CATEGORIES = [
75
75
  "preferences",
76
76
  "sale_of_data"
77
77
  ];
78
- const genericConsentCategorySchema = z.enum(OPTIONAL_CONSENT_CATEGORIES);
78
+ const genericConsentCategorySchema = v.picklist(OPTIONAL_CONSENT_CATEGORIES);
79
79
  const GENERIC_CONSENT_GRANTED_EVENT = "reploConsentCategoryGranted";
80
80
  let delegatedPlatform = null;
81
81
  let listeningToCookiebot = false;
@@ -134,11 +134,11 @@ function handleGenericConsentActivation(event) {
134
134
  if (!(event instanceof CustomEvent)) {
135
135
  return;
136
136
  }
137
- const result = genericConsentCategorySchema.safeParse(event.detail);
138
- if (!result.success || categories[result.data]) {
137
+ const result = v.safeParse(genericConsentCategorySchema, event.detail);
138
+ if (!result.success || categories[result.output]) {
139
139
  return;
140
140
  }
141
- categories = { ...categories, [result.data]: true };
141
+ categories = { ...categories, [result.output]: true };
142
142
  decidedAt = Date.now();
143
143
  notifyConsentListeners();
144
144
  }
@@ -1,4 +1,5 @@
1
1
  import type { ConsentCategory } from "../_vendor/schemas/generated/consent";
2
+ import * as v from "valibot";
2
3
  /**
3
4
  * Pure (non-React, framework-agnostic) consent cookie helpers used by the client
4
5
  * store (`consent-store.ts`).
@@ -6,20 +7,21 @@ import type { ConsentCategory } from "../_vendor/schemas/generated/consent";
6
7
  export declare const CONSENT_COOKIE_NAME = "replo_consent";
7
8
  export declare const CONSENT_COOKIE_VERSION = 1;
8
9
  export declare const CONSENT_COOKIE_MAX_AGE_SECONDS: number;
10
+ declare const persistedCookieSchema: v.ObjectSchema<{
11
+ readonly version: v.LiteralSchema<1, undefined>;
12
+ readonly decidedAt: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.FiniteAction<number, undefined>]>, () => number>;
13
+ /** Persistent pseudonymous per-browser id; absent on pre-id (legacy) cookies. */
14
+ readonly consentId: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
15
+ readonly categories: v.ObjectSchema<{
16
+ readonly analytics: v.BooleanSchema<undefined>;
17
+ readonly marketing: v.BooleanSchema<undefined>;
18
+ readonly preferences: v.BooleanSchema<undefined>;
19
+ readonly sale_of_data: v.BooleanSchema<undefined>;
20
+ }, undefined>;
21
+ }, undefined>;
9
22
  export type ConsentCategories = Record<ConsentCategory, boolean>;
10
23
  /** Persisted cookie payload. `necessary` is always granted and never stored. */
11
- export interface ConsentCookiePayload {
12
- version: number;
13
- decidedAt: number;
14
- /** Persistent pseudonymous per-browser id; absent on pre-id (legacy) cookies. */
15
- consentId?: string;
16
- categories: {
17
- analytics: boolean;
18
- marketing: boolean;
19
- preferences: boolean;
20
- sale_of_data: boolean;
21
- };
22
- }
24
+ export type ConsentCookiePayload = v.InferOutput<typeof persistedCookieSchema>;
23
25
  export declare const DENIED_CATEGORIES: ConsentCategories;
24
26
  export declare const GRANTED_CATEGORIES: ConsentCategories;
25
27
  /**
@@ -34,3 +36,4 @@ export declare function serializeConsentCookieValue({ categories, decidedAt, con
34
36
  decidedAt: number;
35
37
  consentId?: string;
36
38
  }): string;
39
+ export {};
package/consent/cookie.js CHANGED
@@ -1,17 +1,17 @@
1
1
  import { parseJsonOrNull } from "../_vendor/replo-utils/lib/json.mjs";
2
- import * as z from "zod";
2
+ import * as v from "valibot";
3
3
  const CONSENT_COOKIE_NAME = "replo_consent";
4
4
  const CONSENT_COOKIE_VERSION = 1;
5
5
  const CONSENT_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 180;
6
- const persistedCookieSchema = z.object({
7
- version: z.literal(CONSENT_COOKIE_VERSION),
8
- decidedAt: z.number().optional(),
9
- consentId: z.string().optional(),
10
- categories: z.object({
11
- analytics: z.boolean(),
12
- marketing: z.boolean(),
13
- preferences: z.boolean(),
14
- sale_of_data: z.boolean()
6
+ const persistedCookieSchema = v.object({
7
+ version: v.literal(CONSENT_COOKIE_VERSION),
8
+ decidedAt: v.optional(v.pipe(v.number(), v.finite()), () => Date.now()),
9
+ consentId: v.optional(v.string()),
10
+ categories: v.object({
11
+ analytics: v.boolean(),
12
+ marketing: v.boolean(),
13
+ preferences: v.boolean(),
14
+ sale_of_data: v.boolean()
15
15
  })
16
16
  });
17
17
  const DENIED_CATEGORIES = {
@@ -40,14 +40,14 @@ function parseConsentCookie(rawCookie) {
40
40
  } catch {
41
41
  return null;
42
42
  }
43
- const result = persistedCookieSchema.safeParse(parseJsonOrNull(decoded));
43
+ const result = v.safeParse(persistedCookieSchema, parseJsonOrNull(decoded));
44
44
  if (!result.success) {
45
45
  return null;
46
46
  }
47
- const { decidedAt, categories, consentId } = result.data;
47
+ const { decidedAt, categories, consentId } = result.output;
48
48
  return {
49
49
  version: CONSENT_COOKIE_VERSION,
50
- decidedAt: decidedAt ?? Date.now(),
50
+ decidedAt,
51
51
  consentId,
52
52
  categories
53
53
  };
@@ -1,5 +1,8 @@
1
1
  import type { ConsentCategory, ReploScriptEntry, ReploScriptType, ScriptTagDescriptor } from "../_vendor/schemas/generated/consent";
2
2
  type ProviderType = Exclude<ReploScriptType, "custom" | "snippet" | "consentPlatform" | "Cookiebot">;
3
+ type ProviderScriptEntry = Extract<ReploScriptEntry, {
4
+ type: ProviderType;
5
+ }>;
3
6
  /**
4
7
  * Snippet providers don't synthesize tags (the user pastes the provider-issued
5
8
  * snippet as the entry's `body`), but they still get a first-class default
@@ -11,9 +14,7 @@ export declare function isXEventIdForPixel({ eventId, pixelId, }: {
11
14
  pixelId: string;
12
15
  }): boolean;
13
16
  /** The runtime injects these tags in order after consent allows tracking. */
14
- export declare function buildScriptTags(entry: Extract<ReploScriptEntry, {
15
- type: ProviderType;
16
- }>): ScriptTagDescriptor[];
17
+ export declare function buildScriptTags(entry: ProviderScriptEntry): ScriptTagDescriptor[];
17
18
  /**
18
19
  * The default consent categories that gate an identifier-provider script when an
19
20
  * entry does not specify `requiredConsent`. `snippet` and `custom` entries carry
@@ -1,3 +1,12 @@
1
+ const MIXPANEL_API_HOSTS = {
2
+ us: null,
3
+ eu: "https://api-eu.mixpanel.com",
4
+ in: "https://api-in.mixpanel.com"
5
+ };
6
+ function mixpanelInitOptions(region) {
7
+ const apiHost = MIXPANEL_API_HOSTS[region ?? "us"];
8
+ return apiHost ? `{track_pageview:false,api_host:${JSON.stringify(apiHost)}}` : "{track_pageview:false}";
9
+ }
1
10
  const PROVIDER_SNIPPETS = {
2
11
  GA4: {
3
12
  defaultConsent: ["analytics"],
@@ -138,6 +147,15 @@ gtag('config', '${id}');`
138
147
  }
139
148
  ]
140
149
  },
150
+ Mixpanel: {
151
+ defaultConsent: ["analytics"],
152
+ buildTags: (id, entry) => [
153
+ {
154
+ kind: "inline",
155
+ body: `(function(f,b){if(!b.__SV){var e,g,i,h;window.mixpanel=b;b._i=[];b.init=function(e,f,c){function g(a,d){var b=d.split(".");2==b.length&&(a=a[b[0]],d=b[1]);a[d]=function(){a.push([d].concat(Array.prototype.slice.call(arguments,0)))}}var a=b;"undefined"!==typeof c?a=b[c]=[]:c="mixpanel";a.people=a.people||[];a.toString=function(a){var d="mixpanel";"mixpanel"!==c&&(d+="."+c);a||(d+=" (stub)");return d};a.people.toString=function(){return a.toString(1)+".people (stub)"};i="disable time_event track track_pageview track_links track_forms track_with_groups add_group set_group remove_group register register_once alias unregister identify name_tag set_config reset opt_in_tracking opt_out_tracking has_opted_in_tracking has_opted_out_tracking clear_opt_in_out_tracking start_batch_senders people.set people.set_once people.unset people.increment people.append people.union people.track_charge people.clear_charges people.delete_user people.remove".split(" ");for(h=0;h<i.length;h++)g(a,i[h]);var j="set set_once union unset remove delete".split(" ");a.get_group=function(){function b(c){d[c]=function(){var e=arguments;a.push([f,[c].concat(Array.prototype.slice.call(e,0))])}}for(var d={},f=["get_group"].concat(Array.prototype.slice.call(arguments,0)),c=0;c<j.length;c++)b(j[c]);return d};b._i.push([e,f,c])};b.__SV=1.2;e=f.createElement("script");e.type="text/javascript";e.async=!0;e.src="https://cdn.mxpnl.com/libs/mixpanel-2-latest.min.js";f.head.appendChild(e)}})(document,window.mixpanel||[]);mixpanel.init("${id}",${mixpanelInitOptions(entry.type === "Mixpanel" ? entry.region : void 0)});mixpanel.track("Page Viewed",{url:location.href,path:location.pathname,title:document.title||undefined,referrer:document.referrer||undefined,navigation_type:"initial"});`
156
+ }
157
+ ]
158
+ },
141
159
  Gorgias: {
142
160
  defaultConsent: ["preferences"],
143
161
  buildTags: (id) => [
@@ -167,7 +185,7 @@ function isXEventIdForPixel({
167
185
  return match?.[1]?.toLowerCase() === pixelId.toLowerCase();
168
186
  }
169
187
  function buildScriptTags(entry) {
170
- const tags = PROVIDER_SNIPPETS[entry.type].buildTags(entry.identifier);
188
+ const tags = PROVIDER_SNIPPETS[entry.type].buildTags(entry.identifier, entry);
171
189
  if (entry.type === "X") {
172
190
  const pageViewId = entry.events?.pageView?.trim();
173
191
  if (pageViewId && isXEventIdForPixel({ eventId: pageViewId, pixelId: entry.identifier })) {
@@ -1,4 +1,4 @@
1
- export type ReploScriptType = "GA4" | "GoogleTagManager" | "Meta" | "TikTok" | "Pinterest" | "Reddit" | "Snapchat" | "X" | "Hotjar" | "MicrosoftClarity" | "Contentsquare" | "Segment" | "Northbeam" | "Gorgias" | "Converge" | "HubSpot" | "Cookiebot" | "snippet" | "custom" | "consentPlatform";
1
+ export type ReploScriptType = "GA4" | "GoogleTagManager" | "Meta" | "TikTok" | "Pinterest" | "Reddit" | "Snapchat" | "X" | "Hotjar" | "MicrosoftClarity" | "Contentsquare" | "Segment" | "Northbeam" | "Gorgias" | "Converge" | "HubSpot" | "Mixpanel" | "Cookiebot" | "snippet" | "custom" | "consentPlatform";
2
2
  export type ConsentCategory = "necessary" | "analytics" | "marketing" | "preferences" | "sale_of_data";
3
3
  export type ConsentMode = "off" | "simple" | "per-category";
4
4
  declare module "react" {
@@ -23,6 +23,7 @@ export type XPixelEvents = {
23
23
  startCheckout?: string;
24
24
  purchase?: string;
25
25
  };
26
+ export type MixpanelRegion = "us" | "eu" | "in";
26
27
  export type ReploScriptEntry = {
27
28
  type: "GA4" | "GoogleTagManager" | "Meta" | "TikTok" | "Pinterest" | "Reddit" | "Snapchat" | "Hotjar" | "MicrosoftClarity" | "Contentsquare" | "Segment" | "Northbeam" | "Gorgias" | "Converge" | "HubSpot" | "Cookiebot";
28
29
  identifier: string;
@@ -32,6 +33,11 @@ export type ReploScriptEntry = {
32
33
  identifier: string;
33
34
  events?: XPixelEvents;
34
35
  requiredConsent?: ConsentCategory[];
36
+ } | {
37
+ type: "Mixpanel";
38
+ identifier: string;
39
+ region?: MixpanelRegion;
40
+ requiredConsent?: ConsentCategory[];
35
41
  } | {
36
42
  type: "snippet";
37
43
  id: string;
@@ -1,19 +1,10 @@
1
1
  import React from "react";
2
- import * as z from "zod";
2
+ import * as v from "valibot";
3
3
  import { CanopyError } from "../lib/canopy-error";
4
- declare const mailchimpSubscribeErrorCodeSchema: z.ZodEnum<{
5
- notConnected: "notConnected";
6
- listNotFound: "listNotFound";
7
- invalidEmail: "invalidEmail";
8
- invalidRequest: "invalidRequest";
9
- emailNotSubscribable: "emailNotSubscribable";
10
- mergeFieldsInvalid: "mergeFieldsInvalid";
11
- rateLimited: "rateLimited";
12
- upstreamError: "upstreamError";
13
- }>;
4
+ declare const mailchimpSubscribeErrorCodeSchema: v.PicklistSchema<["notConnected", "listNotFound", "invalidEmail", "invalidRequest", "emailNotSubscribable", "mergeFieldsInvalid", "rateLimited", "upstreamError"], undefined>;
14
5
  /** `pending` means Mailchimp emailed a confirmation link the visitor must click. */
15
6
  export type MailchimpSubscribeStatus = "subscribed" | "pending";
16
- export type MailchimpSubscribeErrorCode = z.infer<typeof mailchimpSubscribeErrorCodeSchema>;
7
+ export type MailchimpSubscribeErrorCode = v.InferOutput<typeof mailchimpSubscribeErrorCodeSchema>;
17
8
  export declare class MailchimpSubscribeError extends CanopyError {
18
9
  readonly code: MailchimpSubscribeErrorCode;
19
10
  constructor({ code, message, cause, }: {
@@ -2,10 +2,10 @@
2
2
  import React from "react";
3
3
  import { useMutation } from "@tanstack/react-query";
4
4
  import { parseJsonOrNull } from "../_vendor/replo-utils/lib/json.mjs";
5
- import * as z from "zod";
5
+ import * as v from "valibot";
6
6
  import { CanopyError } from "../lib/canopy-error";
7
7
  import { useCanopyClientConfig } from "../providers/canopy-client-config";
8
- const mailchimpSubscribeErrorCodeSchema = z.enum([
8
+ const mailchimpSubscribeErrorCodeSchema = v.picklist([
9
9
  "notConnected",
10
10
  "listNotFound",
11
11
  "invalidEmail",
@@ -15,12 +15,12 @@ const mailchimpSubscribeErrorCodeSchema = z.enum([
15
15
  "rateLimited",
16
16
  "upstreamError"
17
17
  ]);
18
- const mailchimpSubscribeSuccessSchema = z.object({
19
- status: z.enum(["subscribed", "pending"])
18
+ const mailchimpSubscribeSuccessSchema = v.object({
19
+ status: v.picklist(["subscribed", "pending"])
20
20
  });
21
- const mailchimpSubscribeFailureSchema = z.object({
21
+ const mailchimpSubscribeFailureSchema = v.object({
22
22
  key: mailchimpSubscribeErrorCodeSchema,
23
- message: z.string()
23
+ message: v.string()
24
24
  });
25
25
  class MailchimpSubscribeError extends CanopyError {
26
26
  code;
@@ -65,24 +65,28 @@ async function postSubscribe({
65
65
  }
66
66
  const body = parseJsonOrNull(responseText);
67
67
  if (!response.ok) {
68
- const failure = mailchimpSubscribeFailureSchema.safeParse(body);
68
+ const failure = v.safeParse(mailchimpSubscribeFailureSchema, body);
69
69
  throw new MailchimpSubscribeError(
70
- failure.success ? { code: failure.data.key, message: failure.data.message, cause: null } : {
70
+ failure.success ? {
71
+ code: failure.output.key,
72
+ message: failure.output.message,
73
+ cause: null
74
+ } : {
71
75
  code: "upstreamError",
72
76
  message: `The Mailchimp subscribe endpoint failed with status ${response.status}`,
73
- cause: failure.error
77
+ cause: new v.ValiError(failure.issues)
74
78
  }
75
79
  );
76
80
  }
77
- const success = mailchimpSubscribeSuccessSchema.safeParse(body);
81
+ const success = v.safeParse(mailchimpSubscribeSuccessSchema, body);
78
82
  if (!success.success) {
79
83
  throw new MailchimpSubscribeError({
80
84
  code: "upstreamError",
81
85
  message: "The Mailchimp subscribe endpoint returned an unexpected response",
82
- cause: success.error
86
+ cause: new v.ValiError(success.issues)
83
87
  });
84
88
  }
85
- return success.data.status;
89
+ return success.output.status;
86
90
  }
87
91
  function useMailchimpSubscribe({
88
92
  listId,
@@ -1,7 +1,7 @@
1
1
  export const BUILD_METADATA = {
2
2
  "packageName": "@replohq/sdk",
3
- "version": "2.1.0",
3
+ "version": "2.2.1",
4
4
  "branch": "HEAD",
5
- "commit": "fd5e2e68393795f73c76f04e5fbccad127d8ee31",
6
- "builtAt": "2026-10-20T14:39:29.462Z"
5
+ "commit": "cb0991cc863d256c3df479ed294f1a2dc48dba29",
6
+ "builtAt": "2026-10-20T23:18:54.483Z"
7
7
  };
@@ -1,3 +1,20 @@
1
+ /**
2
+ * Sample-data substitution for previewing dynamic routes inside the editor.
3
+ *
4
+ * When the editor previews an unfilled Next.js dynamic route (the literal
5
+ * `/products/[handle]` URL), the page receives the bracket token itself as its
6
+ * param, every lookup misses, and the preview 404s. Loader invocations detect
7
+ * that exact case — editor iframe + a bracket-token arg — and substitute a
8
+ * type-correct sample fixture so the page renders its real layout with sample
9
+ * data. A `replo:preview:placeholder-render` message tells the embedding
10
+ * editor the preview is showing sample data (mirrors `replo:preview:status`
11
+ * from NextErrorReporter).
12
+ *
13
+ * Substitution is gated to editor iframes only: production traffic to a
14
+ * bracket URL still misses lookups and 404s, so this can never soft-404 a
15
+ * published site.
16
+ */
17
+ import type { FullCollection, FullProduct, PaginatedProducts } from "./loader-schemas";
1
18
  export declare function hasUnfilledRouteParam(args: Record<string, unknown>): boolean;
2
19
  /**
3
20
  * Sample data for a loader invoked with an unfilled dynamic-route param, or
@@ -13,8 +30,9 @@ export declare function resolvePreviewPlaceholderData({ loaderKey, args, }: {
13
30
  loaderKey: string;
14
31
  args: Record<string, unknown>;
15
32
  }): unknown;
33
+ type PreviewableLoaderData = FullProduct | FullCollection | PaginatedProducts;
16
34
  /** Whether loader output is one of this module's sample fixtures. */
17
- export declare function isPreviewPlaceholderData(data: unknown): boolean;
35
+ export declare function isPreviewPlaceholderData(data: PreviewableLoaderData): boolean;
18
36
  /**
19
37
  * Notify the embedding editor when a loader component rendered sample data.
20
38
  * The single reporting path: inspecting rendered data (rather than hooking
@@ -22,4 +40,5 @@ export declare function isPreviewPlaceholderData(data: unknown): boolean;
22
40
  * hydration never re-invokes the loader on the client. Receivers treat the
23
41
  * message as idempotent, so no dedupe is needed.
24
42
  */
25
- export declare function useReportPreviewPlaceholderRender(data: unknown): void;
43
+ export declare function useReportPreviewPlaceholderRender(data: PreviewableLoaderData): void;
44
+ export {};
@@ -1,5 +1,4 @@
1
1
  import { useEffect } from "react";
2
- import * as z from "zod";
3
2
  import { DATA_LOADER_KEYS } from "./loader-keys";
4
3
  const PLACEHOLDER_RENDER_MESSAGE_TYPE = "replo:preview:placeholder-render";
5
4
  const UNFILLED_ROUTE_PARAM_RE = /^\[{1,2}(?:\.\.\.)?[^\]]+\]{1,2}$/;
@@ -184,17 +183,9 @@ function resolvePreviewPlaceholderData({
184
183
  }
185
184
  return window.parent === window ? null : placeholderData;
186
185
  }
187
- const placeholderIdSchema = z.object({ id: z.string() });
188
- const placeholderProductsSchema = z.object({
189
- products: z.array(z.object({ id: z.string() })).min(1)
190
- });
191
186
  function isPreviewPlaceholderData(data) {
192
- const withId = placeholderIdSchema.safeParse(data);
193
- if (withId.success) {
194
- return withId.data.id.startsWith(SAMPLE_ID_PREFIX);
195
- }
196
- const withProducts = placeholderProductsSchema.safeParse(data);
197
- return withProducts.success && (withProducts.data.products[0]?.id.startsWith(SAMPLE_ID_PREFIX) ?? false);
187
+ const id = "products" in data ? data.products[0]?.id : data.id;
188
+ return id?.startsWith(SAMPLE_ID_PREFIX) ?? false;
198
189
  }
199
190
  function useReportPreviewPlaceholderRender(data) {
200
191
  useEffect(() => {
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@replohq/sdk",
3
- "version": "2.1.0",
3
+ "version": "2.2.1",
4
4
  "reploBuild": {
5
5
  "packageName": "@replohq/sdk",
6
- "version": "2.1.0",
6
+ "version": "2.2.1",
7
7
  "branch": "HEAD",
8
- "commit": "fd5e2e68393795f73c76f04e5fbccad127d8ee31",
9
- "builtAt": "2026-10-20T14:39:29.462Z"
8
+ "commit": "cb0991cc863d256c3df479ed294f1a2dc48dba29",
9
+ "builtAt": "2026-10-20T23:18:54.483Z"
10
10
  },
11
11
  "description": "Replo SDK — cart, analytics, and data loaders for agent-built Next.js sites.",
12
12
  "license": "SEE LICENSE IN LICENSE",
@@ -300,6 +300,7 @@
300
300
  "server-only": "^0.0.1",
301
301
  "tiny-jsonc": "^1.0.2",
302
302
  "uuid": "^11.1.0",
303
+ "valibot": "^1.5.0",
303
304
  "yaml": "^2.8.3",
304
305
  "zod": "^4.3.6"
305
306
  },