@syntrologie/runtime-sdk 2.15.0 → 2.16.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/SmartCanvasApp.d.ts +4 -1
- package/dist/api.d.ts +3 -0
- package/dist/bootstrap-init.d.ts +2 -0
- package/dist/{chunk-CVMZW3II.js → chunk-NVV7IWJC.js} +330 -24
- package/dist/{chunk-CVMZW3II.js.map → chunk-NVV7IWJC.js.map} +4 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +75 -1
- package/dist/index.js.map +4 -4
- package/dist/platform/PlatformAdapter.d.ts +46 -0
- package/dist/platform/ShopifyAdapter.d.ts +36 -0
- package/dist/platform/ShopifyAnchorResolver.d.ts +31 -0
- package/dist/platform/ShopifyAntiFlicker.d.ts +21 -0
- package/dist/platform/ShopifyPixelBridge.d.ts +37 -0
- package/dist/platform/detect.d.ts +9 -0
- package/dist/platform/index.d.ts +10 -0
- package/dist/platform/shopify-cookie-contract.d.ts +39 -0
- package/dist/react.js +1 -1
- package/dist/shopify-pixel-entry.d.ts +68 -0
- package/dist/shopify-pixel.js +77 -0
- package/dist/shopify-pixel.js.map +7 -0
- package/dist/shopify-pixel.min.js +2 -0
- package/dist/shopify-pixel.min.js.map +7 -0
- package/dist/smart-canvas.esm.js +135 -135
- package/dist/smart-canvas.esm.js.map +4 -4
- package/dist/smart-canvas.js +288 -10
- package/dist/smart-canvas.js.map +4 -4
- package/dist/smart-canvas.min.js +135 -135
- package/dist/smart-canvas.min.js.map +4 -4
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PlatformAdapter — interface for platform-specific integrations.
|
|
3
|
+
*
|
|
4
|
+
* Each supported host platform (Shopify, WordPress, etc.) implements this
|
|
5
|
+
* interface so the runtime can react to platform-specific lifecycle events
|
|
6
|
+
* (section load/unload, theme changes, etc.) without coupling to any single
|
|
7
|
+
* platform's API surface.
|
|
8
|
+
*/
|
|
9
|
+
export interface PlatformAdapter {
|
|
10
|
+
/** Human-readable platform name, e.g. "shopify" */
|
|
11
|
+
readonly name: string;
|
|
12
|
+
/**
|
|
13
|
+
* Synchronously detect whether the current page belongs to this platform.
|
|
14
|
+
* Must be safe to call in any environment (returns false when unsure).
|
|
15
|
+
*/
|
|
16
|
+
detect(): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Async initialization hook. Called once after the adapter is selected.
|
|
19
|
+
* May wait for platform-specific readiness signals (e.g. Shopify sections).
|
|
20
|
+
*/
|
|
21
|
+
onInit(): Promise<void>;
|
|
22
|
+
/**
|
|
23
|
+
* Called by the runtime when a region's DOM has been mutated.
|
|
24
|
+
*/
|
|
25
|
+
onRegionMutated(regionId: string): void;
|
|
26
|
+
/**
|
|
27
|
+
* Returns a CSS-selector scope prefix for the given anchor element,
|
|
28
|
+
* based on the platform's section/region structure.
|
|
29
|
+
* Returns empty string when the anchor is not inside a known section.
|
|
30
|
+
*/
|
|
31
|
+
getAnchorScope(anchor: Element): string;
|
|
32
|
+
/**
|
|
33
|
+
* Subscribe to "region is about to unload" events.
|
|
34
|
+
* Returns an unsubscribe function.
|
|
35
|
+
*/
|
|
36
|
+
onRegionWillUnload(callback: (regionId: string) => void): () => void;
|
|
37
|
+
/**
|
|
38
|
+
* Subscribe to "region finished loading" events.
|
|
39
|
+
* Returns an unsubscribe function.
|
|
40
|
+
*/
|
|
41
|
+
onRegionDidLoad(callback: (regionId: string) => void): () => void;
|
|
42
|
+
/**
|
|
43
|
+
* Tear down all listeners and internal state.
|
|
44
|
+
*/
|
|
45
|
+
destroy(): void;
|
|
46
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { PlatformAdapter } from './PlatformAdapter';
|
|
2
|
+
/**
|
|
3
|
+
* Shopify-specific platform adapter.
|
|
4
|
+
*
|
|
5
|
+
* Detects Shopify storefronts and hooks into Shopify's section rendering
|
|
6
|
+
* lifecycle events (`shopify:section:load` / `shopify:section:unload`) so
|
|
7
|
+
* the runtime can react to dynamic section swaps in Shopify Online Store 2.0.
|
|
8
|
+
*/
|
|
9
|
+
export declare class ShopifyAdapter implements PlatformAdapter {
|
|
10
|
+
readonly name: "shopify";
|
|
11
|
+
private loadCallbacks;
|
|
12
|
+
private unloadCallbacks;
|
|
13
|
+
private abortController;
|
|
14
|
+
private antiFlicker;
|
|
15
|
+
private readonly initTimeoutMs;
|
|
16
|
+
constructor(options?: {
|
|
17
|
+
initTimeoutMs?: number;
|
|
18
|
+
});
|
|
19
|
+
detect(): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Wait for Shopify sections to be ready, then attach lifecycle listeners.
|
|
22
|
+
*
|
|
23
|
+
* Resolution order:
|
|
24
|
+
* 1. `.shopify-section` already in DOM -> resolve immediately
|
|
25
|
+
* 2. `shopify:section:load` fires -> resolve
|
|
26
|
+
* 3. Timeout (default 3000ms) -> resolve anyway (graceful degradation)
|
|
27
|
+
*/
|
|
28
|
+
onInit(): Promise<void>;
|
|
29
|
+
onRegionDidLoad(callback: (regionId: string) => void): () => void;
|
|
30
|
+
onRegionWillUnload(callback: (regionId: string) => void): () => void;
|
|
31
|
+
onRegionMutated(_regionId: string): void;
|
|
32
|
+
getAnchorScope(anchor: Element): string;
|
|
33
|
+
destroy(): void;
|
|
34
|
+
private attachSectionListeners;
|
|
35
|
+
private waitForInitialSections;
|
|
36
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme families sharing identical DOM structures.
|
|
3
|
+
* Dawn family: Dawn, Sense, Craft, Ride, Refresh, Colorblock, Taste, Studio
|
|
4
|
+
*/
|
|
5
|
+
export type ShopifyThemeFamily = 'dawn' | 'prestige' | 'impulse' | 'turbo' | 'trademark' | 'legacy' | 'unknown';
|
|
6
|
+
/**
|
|
7
|
+
* Detect the Shopify theme family from window.Shopify.theme.name.
|
|
8
|
+
*/
|
|
9
|
+
export declare function detectThemeFamily(): ShopifyThemeFamily;
|
|
10
|
+
export interface AnchorValidation {
|
|
11
|
+
unique: boolean;
|
|
12
|
+
matchCount: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* ShopifyAnchorResolver — provides Shopify-aware selector scoping and
|
|
16
|
+
* uniqueness validation for anchor configuration.
|
|
17
|
+
*/
|
|
18
|
+
export declare class ShopifyAnchorResolver {
|
|
19
|
+
/**
|
|
20
|
+
* Scope a CSS selector to its containing .shopify-section wrapper.
|
|
21
|
+
*/
|
|
22
|
+
scopeSelector(selector: string, element: Element): string;
|
|
23
|
+
/**
|
|
24
|
+
* Validate that a selector matches exactly one element in the document.
|
|
25
|
+
*/
|
|
26
|
+
validateUniqueness(selector: string): AnchorValidation;
|
|
27
|
+
/**
|
|
28
|
+
* Get the current theme family for diagnostic/logging purposes.
|
|
29
|
+
*/
|
|
30
|
+
getThemeFamily(): ShopifyThemeFamily;
|
|
31
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { PlatformAdapter } from './PlatformAdapter';
|
|
2
|
+
/**
|
|
3
|
+
* ShopifyAntiFlicker -- prevents visible content flash during section re-renders.
|
|
4
|
+
*
|
|
5
|
+
* When Shopify's Section Rendering API replaces a section's innerHTML, our
|
|
6
|
+
* injected content (identified by [data-syntro-action-id]) disappears momentarily.
|
|
7
|
+
* This module hooks section:unload to pre-hide our content and section:load to
|
|
8
|
+
* fade it back in, eliminating the visible flash.
|
|
9
|
+
*/
|
|
10
|
+
export declare class ShopifyAntiFlicker {
|
|
11
|
+
private readonly adapter;
|
|
12
|
+
private unsubUnload;
|
|
13
|
+
private unsubLoad;
|
|
14
|
+
private destroyed;
|
|
15
|
+
constructor(adapter: PlatformAdapter);
|
|
16
|
+
activate(): void;
|
|
17
|
+
destroy(): void;
|
|
18
|
+
private hideContentInSection;
|
|
19
|
+
private revealContentInSection;
|
|
20
|
+
private sectionSelector;
|
|
21
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ShopifyPixelBridge — storefront-side cookie writer for checkout attribution.
|
|
3
|
+
*
|
|
4
|
+
* The SDK can't run in Shopify's sandboxed checkout. This bridge writes a
|
|
5
|
+
* `syntro_experiments` cookie containing the telemetry context (distinct_id,
|
|
6
|
+
* telemetry host, public PostHog key). A Shopify Web Pixel extension running
|
|
7
|
+
* in the checkout sandbox reads the cookie and POSTs
|
|
8
|
+
* `checkout_completed` / `product_added_to_cart` events to
|
|
9
|
+
* `{telemetry_host}/t/{telemetry_key}/e/` — the Syntrologie PostHog proxy.
|
|
10
|
+
*
|
|
11
|
+
* Experiment attribution is NOT carried in the cookie. PostHog already tracks
|
|
12
|
+
* each shopper's assignments server-side via `$experiment_started` events
|
|
13
|
+
* fired from the storefront SDK under the same distinct_id. When the pixel's
|
|
14
|
+
* checkout event lands with that distinct_id, PostHog auto-attributes the
|
|
15
|
+
* conversion to the correct variant. The cookie only needs to ferry identity
|
|
16
|
+
* and destination.
|
|
17
|
+
*
|
|
18
|
+
* Max-age: 30 days (covers long shopping sessions).
|
|
19
|
+
* SameSite: Lax (readable by checkout on the same registrable domain).
|
|
20
|
+
*/
|
|
21
|
+
export interface ShopifyPixelBridgeContext {
|
|
22
|
+
/** PostHog distinct_id from the storefront telemetry client. */
|
|
23
|
+
distinctId: string;
|
|
24
|
+
/** Telemetry proxy host, e.g. `https://telemetry.syntrologie.com`. No trailing slash. */
|
|
25
|
+
telemetryHost: string;
|
|
26
|
+
/** Public write-only PostHog key. */
|
|
27
|
+
telemetryKey: string;
|
|
28
|
+
}
|
|
29
|
+
export declare class ShopifyPixelBridge {
|
|
30
|
+
private context;
|
|
31
|
+
constructor(context: ShopifyPixelBridgeContext);
|
|
32
|
+
/** Update the telemetry context (e.g., after consent is granted and
|
|
33
|
+
* PostHog finally initializes and a real distinct_id becomes available). */
|
|
34
|
+
updateContext(context: ShopifyPixelBridgeContext): void;
|
|
35
|
+
destroy(): void;
|
|
36
|
+
private writeCookie;
|
|
37
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { PlatformAdapter } from './PlatformAdapter';
|
|
2
|
+
/**
|
|
3
|
+
* Auto-detect the current host platform.
|
|
4
|
+
*
|
|
5
|
+
* Iterates registered adapters and returns the first one that detects
|
|
6
|
+
* its platform signals. Returns `null` if no platform is recognized
|
|
7
|
+
* (i.e. the page is a generic/unknown site).
|
|
8
|
+
*/
|
|
9
|
+
export declare function detectPlatform(): PlatformAdapter | null;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { detectPlatform } from './detect';
|
|
2
|
+
export type { PlatformAdapter } from './PlatformAdapter';
|
|
3
|
+
export { ShopifyAdapter } from './ShopifyAdapter';
|
|
4
|
+
export type { AnchorValidation, ShopifyThemeFamily } from './ShopifyAnchorResolver';
|
|
5
|
+
export { detectThemeFamily, ShopifyAnchorResolver } from './ShopifyAnchorResolver';
|
|
6
|
+
export { ShopifyAntiFlicker } from './ShopifyAntiFlicker';
|
|
7
|
+
export type { ShopifyPixelBridgeContext } from './ShopifyPixelBridge';
|
|
8
|
+
export { ShopifyPixelBridge } from './ShopifyPixelBridge';
|
|
9
|
+
export type { ShopifyPixelCookie } from './shopify-cookie-contract';
|
|
10
|
+
export { COOKIE_NAME as SHOPIFY_COOKIE_NAME } from './shopify-cookie-contract';
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared cookie contract between ShopifyPixelBridge (SDK) and the
|
|
3
|
+
* Shopify Web Pixel extension. Both sides must agree on this format.
|
|
4
|
+
*
|
|
5
|
+
* Cookie name: syntro_experiments (legacy name — kept for backward compat;
|
|
6
|
+
* the cookie carries telemetry identity, not experiment assignments)
|
|
7
|
+
* Cookie value: JSON-encoded ShopifyPixelCookie
|
|
8
|
+
*
|
|
9
|
+
* The storefront SDK writes a snapshot of its telemetry context — the
|
|
10
|
+
* PostHog distinct_id, the telemetry proxy host, and the public PostHog
|
|
11
|
+
* project key. The Web Pixel extension runs in Shopify's sandboxed
|
|
12
|
+
* checkout (which cannot reach the SDK), reads this cookie, and posts
|
|
13
|
+
* PostHog-shaped checkout events with the same distinct_id so that
|
|
14
|
+
* checkout events JOIN to the storefront session. PostHog already knows
|
|
15
|
+
* which experiments that shopper is in (via `$experiment_started` events
|
|
16
|
+
* fired from the storefront), so attribution happens server-side without
|
|
17
|
+
* the pixel having to re-transmit assignments.
|
|
18
|
+
*
|
|
19
|
+
* Naming note: the fields are `telemetry_host` / `telemetry_key` — not
|
|
20
|
+
* `api_host` / `api_key` — to disambiguate from the other `apiHost`
|
|
21
|
+
* fields in the codebase. This cookie is strictly about telemetry.
|
|
22
|
+
*
|
|
23
|
+
* Forward compat: pixel code must check `version` and reject unknown
|
|
24
|
+
* shapes. Bump `version` when fields are removed or semantics change.
|
|
25
|
+
*/
|
|
26
|
+
export interface ShopifyPixelCookie {
|
|
27
|
+
/** Schema version — pixel rejects unknown versions. Bump on breaking changes. */
|
|
28
|
+
version: 1;
|
|
29
|
+
/** PostHog distinct_id from the storefront SDK, used so checkout events
|
|
30
|
+
* join onto the pre-checkout session. Required — without it the pixel
|
|
31
|
+
* would create a new anonymous user at checkout and the funnel breaks. */
|
|
32
|
+
distinct_id: string;
|
|
33
|
+
/** Telemetry proxy host (e.g., `https://telemetry.syntrologie.com`). No trailing slash. */
|
|
34
|
+
telemetry_host: string;
|
|
35
|
+
/** Public write-only PostHog key (embedded in the client). */
|
|
36
|
+
telemetry_key: string;
|
|
37
|
+
}
|
|
38
|
+
export declare const COOKIE_NAME = "syntro_experiments";
|
|
39
|
+
export declare const COOKIE_VERSION: 1;
|
package/dist/react.js
CHANGED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDN-hosted Shopify Web Pixel — bundled to `dist/shopify-pixel.min.js` and
|
|
3
|
+
* served from `cdn.syntrologie.com/runtime-sdk/v2/latest/shopify-pixel.min.js`.
|
|
4
|
+
*
|
|
5
|
+
* Merchants install this by pasting a small loader snippet into Shopify admin
|
|
6
|
+
* under Settings → Customer events → Add custom pixel. The loader dynamically
|
|
7
|
+
* loads this file, then calls `window.__SyntroShopifyPixel.register({ analytics, browser })`
|
|
8
|
+
* to wire up subscribers. The indirection means we ship updates to every
|
|
9
|
+
* merchant's pixel by redeploying the CDN file — merchants never re-paste.
|
|
10
|
+
*
|
|
11
|
+
* The pixel's only job is to forward Shopify checkout events to the
|
|
12
|
+
* Syntrologie PostHog proxy with the shopper's distinct_id attached.
|
|
13
|
+
* Experiment attribution is handled server-side by PostHog — once the
|
|
14
|
+
* storefront SDK has fired `$experiment_started` for a distinct_id, PostHog
|
|
15
|
+
* associates all future events from that user (including checkout events
|
|
16
|
+
* this pixel posts) with the correct variant.
|
|
17
|
+
*
|
|
18
|
+
* Shopify custom pixels run in a "lax sandbox" iframe. Script tag injection
|
|
19
|
+
* works (verified by PostHog, Sentry, GTM following the same pattern). Cookie
|
|
20
|
+
* access is via the async `browser.cookie.get()` API which proxies to the
|
|
21
|
+
* top frame (merchant storefront domain), so the `syntro_experiments` cookie
|
|
22
|
+
* written by `ShopifyPixelBridge` on the storefront is readable here.
|
|
23
|
+
*
|
|
24
|
+
* All errors swallowed — a pixel failure must never disturb checkout.
|
|
25
|
+
*/
|
|
26
|
+
interface ShopifyAnalytics {
|
|
27
|
+
subscribe(eventName: string, handler: (event: ShopifyPixelEvent) => void | Promise<void>): void;
|
|
28
|
+
}
|
|
29
|
+
interface ShopifyBrowserCookie {
|
|
30
|
+
get(name: string): Promise<string | undefined>;
|
|
31
|
+
}
|
|
32
|
+
interface ShopifyBrowser {
|
|
33
|
+
cookie: ShopifyBrowserCookie;
|
|
34
|
+
}
|
|
35
|
+
interface ShopifyPixelEvent {
|
|
36
|
+
data: {
|
|
37
|
+
checkout?: {
|
|
38
|
+
totalPrice: {
|
|
39
|
+
amount: string | number;
|
|
40
|
+
currencyCode: string;
|
|
41
|
+
};
|
|
42
|
+
order?: {
|
|
43
|
+
id: string;
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
cartLine?: {
|
|
47
|
+
merchandise: {
|
|
48
|
+
id: string;
|
|
49
|
+
product: {
|
|
50
|
+
id: string;
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
quantity: number;
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export declare function register({ analytics, browser, }: {
|
|
58
|
+
analytics: ShopifyAnalytics;
|
|
59
|
+
browser: ShopifyBrowser;
|
|
60
|
+
}): void;
|
|
61
|
+
declare global {
|
|
62
|
+
interface Window {
|
|
63
|
+
__SyntroShopifyPixel?: {
|
|
64
|
+
register: typeof register;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export {};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
// src/platform/shopify-cookie-contract.ts
|
|
3
|
+
var COOKIE_NAME = "syntro_experiments";
|
|
4
|
+
var COOKIE_VERSION = 1;
|
|
5
|
+
|
|
6
|
+
// src/shopify-pixel-entry.ts
|
|
7
|
+
async function readCookie(browser) {
|
|
8
|
+
try {
|
|
9
|
+
const raw = await browser.cookie.get(COOKIE_NAME);
|
|
10
|
+
if (!raw) return null;
|
|
11
|
+
const parsed = JSON.parse(raw);
|
|
12
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
13
|
+
if (parsed.version !== COOKIE_VERSION) return null;
|
|
14
|
+
if (typeof parsed.distinct_id !== "string" || parsed.distinct_id.length === 0) return null;
|
|
15
|
+
if (typeof parsed.telemetry_host !== "string" || !parsed.telemetry_host.startsWith("https://"))
|
|
16
|
+
return null;
|
|
17
|
+
if (typeof parsed.telemetry_key !== "string" || parsed.telemetry_key.length === 0) return null;
|
|
18
|
+
return parsed;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
async function capture(cookie, eventName, properties) {
|
|
24
|
+
try {
|
|
25
|
+
const url = `${cookie.telemetry_host}/t/${cookie.telemetry_key}/e/`;
|
|
26
|
+
await fetch(url, {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: { "Content-Type": "application/json" },
|
|
29
|
+
keepalive: true,
|
|
30
|
+
// survive page unload on checkout_completed
|
|
31
|
+
body: JSON.stringify({
|
|
32
|
+
api_key: cookie.telemetry_key,
|
|
33
|
+
// PostHog's capture body field is always `api_key`
|
|
34
|
+
event: eventName,
|
|
35
|
+
distinct_id: cookie.distinct_id,
|
|
36
|
+
properties: {
|
|
37
|
+
$lib: "syntrologie-shopify-pixel",
|
|
38
|
+
$lib_version: "1.0.0",
|
|
39
|
+
...properties
|
|
40
|
+
},
|
|
41
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
42
|
+
})
|
|
43
|
+
});
|
|
44
|
+
} catch {
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function register({
|
|
48
|
+
analytics,
|
|
49
|
+
browser
|
|
50
|
+
}) {
|
|
51
|
+
analytics.subscribe("checkout_completed", async (event) => {
|
|
52
|
+
const cookie = await readCookie(browser);
|
|
53
|
+
if (!cookie || !event.data.checkout) return;
|
|
54
|
+
const { checkout } = event.data;
|
|
55
|
+
await capture(cookie, "checkout_completed", {
|
|
56
|
+
order_value: checkout.totalPrice.amount,
|
|
57
|
+
currency: checkout.totalPrice.currencyCode,
|
|
58
|
+
order_id: checkout.order?.id
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
analytics.subscribe("product_added_to_cart", async (event) => {
|
|
62
|
+
const cookie = await readCookie(browser);
|
|
63
|
+
if (!cookie) return;
|
|
64
|
+
const cartLine = event.data.cartLine;
|
|
65
|
+
if (!cartLine) return;
|
|
66
|
+
await capture(cookie, "product_added_to_cart", {
|
|
67
|
+
product_id: cartLine.merchandise.product.id,
|
|
68
|
+
variant_id: cartLine.merchandise.id,
|
|
69
|
+
quantity: cartLine.quantity
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
if (typeof window !== "undefined") {
|
|
74
|
+
window.__SyntroShopifyPixel = { register };
|
|
75
|
+
}
|
|
76
|
+
})();
|
|
77
|
+
//# sourceMappingURL=shopify-pixel.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/platform/shopify-cookie-contract.ts", "../src/shopify-pixel-entry.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Shared cookie contract between ShopifyPixelBridge (SDK) and the\n * Shopify Web Pixel extension. Both sides must agree on this format.\n *\n * Cookie name: syntro_experiments (legacy name \u2014 kept for backward compat;\n * the cookie carries telemetry identity, not experiment assignments)\n * Cookie value: JSON-encoded ShopifyPixelCookie\n *\n * The storefront SDK writes a snapshot of its telemetry context \u2014 the\n * PostHog distinct_id, the telemetry proxy host, and the public PostHog\n * project key. The Web Pixel extension runs in Shopify's sandboxed\n * checkout (which cannot reach the SDK), reads this cookie, and posts\n * PostHog-shaped checkout events with the same distinct_id so that\n * checkout events JOIN to the storefront session. PostHog already knows\n * which experiments that shopper is in (via `$experiment_started` events\n * fired from the storefront), so attribution happens server-side without\n * the pixel having to re-transmit assignments.\n *\n * Naming note: the fields are `telemetry_host` / `telemetry_key` \u2014 not\n * `api_host` / `api_key` \u2014 to disambiguate from the other `apiHost`\n * fields in the codebase. This cookie is strictly about telemetry.\n *\n * Forward compat: pixel code must check `version` and reject unknown\n * shapes. Bump `version` when fields are removed or semantics change.\n */\n\nexport interface ShopifyPixelCookie {\n /** Schema version \u2014 pixel rejects unknown versions. Bump on breaking changes. */\n version: 1;\n /** PostHog distinct_id from the storefront SDK, used so checkout events\n * join onto the pre-checkout session. Required \u2014 without it the pixel\n * would create a new anonymous user at checkout and the funnel breaks. */\n distinct_id: string;\n /** Telemetry proxy host (e.g., `https://telemetry.syntrologie.com`). No trailing slash. */\n telemetry_host: string;\n /** Public write-only PostHog key (embedded in the client). */\n telemetry_key: string;\n}\n\nexport const COOKIE_NAME = 'syntro_experiments';\nexport const COOKIE_VERSION = 1 as const;\n", "/**\n * CDN-hosted Shopify Web Pixel \u2014 bundled to `dist/shopify-pixel.min.js` and\n * served from `cdn.syntrologie.com/runtime-sdk/v2/latest/shopify-pixel.min.js`.\n *\n * Merchants install this by pasting a small loader snippet into Shopify admin\n * under Settings \u2192 Customer events \u2192 Add custom pixel. The loader dynamically\n * loads this file, then calls `window.__SyntroShopifyPixel.register({ analytics, browser })`\n * to wire up subscribers. The indirection means we ship updates to every\n * merchant's pixel by redeploying the CDN file \u2014 merchants never re-paste.\n *\n * The pixel's only job is to forward Shopify checkout events to the\n * Syntrologie PostHog proxy with the shopper's distinct_id attached.\n * Experiment attribution is handled server-side by PostHog \u2014 once the\n * storefront SDK has fired `$experiment_started` for a distinct_id, PostHog\n * associates all future events from that user (including checkout events\n * this pixel posts) with the correct variant.\n *\n * Shopify custom pixels run in a \"lax sandbox\" iframe. Script tag injection\n * works (verified by PostHog, Sentry, GTM following the same pattern). Cookie\n * access is via the async `browser.cookie.get()` API which proxies to the\n * top frame (merchant storefront domain), so the `syntro_experiments` cookie\n * written by `ShopifyPixelBridge` on the storefront is readable here.\n *\n * All errors swallowed \u2014 a pixel failure must never disturb checkout.\n */\n\nimport { COOKIE_NAME, COOKIE_VERSION } from './platform/shopify-cookie-contract';\n\ninterface ShopifyAnalytics {\n subscribe(eventName: string, handler: (event: ShopifyPixelEvent) => void | Promise<void>): void;\n}\n\ninterface ShopifyBrowserCookie {\n get(name: string): Promise<string | undefined>;\n}\n\ninterface ShopifyBrowser {\n cookie: ShopifyBrowserCookie;\n}\n\ninterface ShopifyPixelEvent {\n data: {\n checkout?: {\n totalPrice: { amount: string | number; currencyCode: string };\n order?: { id: string };\n };\n cartLine?: {\n merchandise: { id: string; product: { id: string } };\n quantity: number;\n };\n };\n}\n\ninterface PixelCookie {\n version: number;\n distinct_id: string;\n telemetry_host: string;\n telemetry_key: string;\n}\n\nasync function readCookie(browser: ShopifyBrowser): Promise<PixelCookie | null> {\n try {\n const raw = await browser.cookie.get(COOKIE_NAME);\n if (!raw) return null;\n const parsed = JSON.parse(raw);\n if (!parsed || typeof parsed !== 'object') return null;\n if (parsed.version !== COOKIE_VERSION) return null;\n if (typeof parsed.distinct_id !== 'string' || parsed.distinct_id.length === 0) return null;\n if (typeof parsed.telemetry_host !== 'string' || !parsed.telemetry_host.startsWith('https://'))\n return null;\n if (typeof parsed.telemetry_key !== 'string' || parsed.telemetry_key.length === 0) return null;\n return parsed as PixelCookie;\n } catch {\n return null;\n }\n}\n\n/**\n * POST an event to the PostHog capture endpoint via the Syntrologie proxy.\n * URL shape: `{telemetry_host}/t/{telemetry_key}/e/` \u2014 see\n * `packages/runtime-sdk/src/telemetry/adapters/posthog.ts` for the convention.\n */\nasync function capture(\n cookie: PixelCookie,\n eventName: string,\n properties: Record<string, unknown>\n): Promise<void> {\n try {\n const url = `${cookie.telemetry_host}/t/${cookie.telemetry_key}/e/`;\n await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n keepalive: true, // survive page unload on checkout_completed\n body: JSON.stringify({\n api_key: cookie.telemetry_key, // PostHog's capture body field is always `api_key`\n event: eventName,\n distinct_id: cookie.distinct_id,\n properties: {\n $lib: 'syntrologie-shopify-pixel',\n $lib_version: '1.0.0',\n ...properties,\n },\n timestamp: new Date().toISOString(),\n }),\n });\n } catch {\n // Swallow \u2014 pixel errors must never affect checkout.\n }\n}\n\nexport function register({\n analytics,\n browser,\n}: {\n analytics: ShopifyAnalytics;\n browser: ShopifyBrowser;\n}): void {\n analytics.subscribe('checkout_completed', async (event) => {\n const cookie = await readCookie(browser);\n if (!cookie || !event.data.checkout) return;\n const { checkout } = event.data;\n\n await capture(cookie, 'checkout_completed', {\n order_value: checkout.totalPrice.amount,\n currency: checkout.totalPrice.currencyCode,\n order_id: checkout.order?.id,\n });\n });\n\n analytics.subscribe('product_added_to_cart', async (event) => {\n const cookie = await readCookie(browser);\n if (!cookie) return;\n const cartLine = event.data.cartLine;\n if (!cartLine) return;\n\n await capture(cookie, 'product_added_to_cart', {\n product_id: cartLine.merchandise.product.id,\n variant_id: cartLine.merchandise.id,\n quantity: cartLine.quantity,\n });\n });\n}\n\n// Expose on window so the loader snippet can find us after script load.\n// Shopify's lax-sandbox iframe has a real `window`; this is safe.\ndeclare global {\n interface Window {\n __SyntroShopifyPixel?: { register: typeof register };\n }\n}\nif (typeof window !== 'undefined') {\n window.__SyntroShopifyPixel = { register };\n}\n"],
|
|
5
|
+
"mappings": ";;AAuCO,MAAM,cAAc;AACpB,MAAM,iBAAiB;;;ACoB9B,iBAAe,WAAW,SAAsD;AAC9E,QAAI;AACF,YAAM,MAAM,MAAM,QAAQ,OAAO,IAAI,WAAW;AAChD,UAAI,CAAC,IAAK,QAAO;AACjB,YAAM,SAAS,KAAK,MAAM,GAAG;AAC7B,UAAI,CAAC,UAAU,OAAO,WAAW,SAAU,QAAO;AAClD,UAAI,OAAO,YAAY,eAAgB,QAAO;AAC9C,UAAI,OAAO,OAAO,gBAAgB,YAAY,OAAO,YAAY,WAAW,EAAG,QAAO;AACtF,UAAI,OAAO,OAAO,mBAAmB,YAAY,CAAC,OAAO,eAAe,WAAW,UAAU;AAC3F,eAAO;AACT,UAAI,OAAO,OAAO,kBAAkB,YAAY,OAAO,cAAc,WAAW,EAAG,QAAO;AAC1F,aAAO;AAAA,IACT,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AAOA,iBAAe,QACb,QACA,WACA,YACe;AACf,QAAI;AACF,YAAM,MAAM,GAAG,OAAO,cAAc,MAAM,OAAO,aAAa;AAC9D,YAAM,MAAM,KAAK;AAAA,QACf,QAAQ;AAAA,QACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,QAC9C,WAAW;AAAA;AAAA,QACX,MAAM,KAAK,UAAU;AAAA,UACnB,SAAS,OAAO;AAAA;AAAA,UAChB,OAAO;AAAA,UACP,aAAa,OAAO;AAAA,UACpB,YAAY;AAAA,YACV,MAAM;AAAA,YACN,cAAc;AAAA,YACd,GAAG;AAAA,UACL;AAAA,UACA,YAAW,oBAAI,KAAK,GAAE,YAAY;AAAA,QACpC,CAAC;AAAA,MACH,CAAC;AAAA,IACH,QAAQ;AAAA,IAER;AAAA,EACF;AAEO,WAAS,SAAS;AAAA,IACvB;AAAA,IACA;AAAA,EACF,GAGS;AACP,cAAU,UAAU,sBAAsB,OAAO,UAAU;AACzD,YAAM,SAAS,MAAM,WAAW,OAAO;AACvC,UAAI,CAAC,UAAU,CAAC,MAAM,KAAK,SAAU;AACrC,YAAM,EAAE,SAAS,IAAI,MAAM;AAE3B,YAAM,QAAQ,QAAQ,sBAAsB;AAAA,QAC1C,aAAa,SAAS,WAAW;AAAA,QACjC,UAAU,SAAS,WAAW;AAAA,QAC9B,UAAU,SAAS,OAAO;AAAA,MAC5B,CAAC;AAAA,IACH,CAAC;AAED,cAAU,UAAU,yBAAyB,OAAO,UAAU;AAC5D,YAAM,SAAS,MAAM,WAAW,OAAO;AACvC,UAAI,CAAC,OAAQ;AACb,YAAM,WAAW,MAAM,KAAK;AAC5B,UAAI,CAAC,SAAU;AAEf,YAAM,QAAQ,QAAQ,yBAAyB;AAAA,QAC7C,YAAY,SAAS,YAAY,QAAQ;AAAA,QACzC,YAAY,SAAS,YAAY;AAAA,QACjC,UAAU,SAAS;AAAA,MACrB,CAAC;AAAA,IACH,CAAC;AAAA,EACH;AASA,MAAI,OAAO,WAAW,aAAa;AACjC,WAAO,uBAAuB,EAAE,SAAS;AAAA,EAC3C;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
(()=>{var o="syntro_experiments";async function s(t){try{let r=await t.cookie.get(o);if(!r)return null;let e=JSON.parse(r);return!e||typeof e!="object"||e.version!==1||typeof e.distinct_id!="string"||e.distinct_id.length===0||typeof e.telemetry_host!="string"||!e.telemetry_host.startsWith("https://")||typeof e.telemetry_key!="string"||e.telemetry_key.length===0?null:e}catch{return null}}async function c(t,r,e){try{let n=`${t.telemetry_host}/t/${t.telemetry_key}/e/`;await fetch(n,{method:"POST",headers:{"Content-Type":"application/json"},keepalive:!0,body:JSON.stringify({api_key:t.telemetry_key,event:r,distinct_id:t.distinct_id,properties:{$lib:"syntrologie-shopify-pixel",$lib_version:"1.0.0",...e},timestamp:new Date().toISOString()})})}catch{}}function d({analytics:t,browser:r}){t.subscribe("checkout_completed",async e=>{let n=await s(r);if(!n||!e.data.checkout)return;let{checkout:i}=e.data;await c(n,"checkout_completed",{order_value:i.totalPrice.amount,currency:i.totalPrice.currencyCode,order_id:i.order?.id})}),t.subscribe("product_added_to_cart",async e=>{let n=await s(r);if(!n)return;let i=e.data.cartLine;i&&await c(n,"product_added_to_cart",{product_id:i.merchandise.product.id,variant_id:i.merchandise.id,quantity:i.quantity})})}typeof window<"u"&&(window.__SyntroShopifyPixel={register:d});})();
|
|
2
|
+
//# sourceMappingURL=shopify-pixel.min.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/platform/shopify-cookie-contract.ts", "../src/shopify-pixel-entry.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Shared cookie contract between ShopifyPixelBridge (SDK) and the\n * Shopify Web Pixel extension. Both sides must agree on this format.\n *\n * Cookie name: syntro_experiments (legacy name \u2014 kept for backward compat;\n * the cookie carries telemetry identity, not experiment assignments)\n * Cookie value: JSON-encoded ShopifyPixelCookie\n *\n * The storefront SDK writes a snapshot of its telemetry context \u2014 the\n * PostHog distinct_id, the telemetry proxy host, and the public PostHog\n * project key. The Web Pixel extension runs in Shopify's sandboxed\n * checkout (which cannot reach the SDK), reads this cookie, and posts\n * PostHog-shaped checkout events with the same distinct_id so that\n * checkout events JOIN to the storefront session. PostHog already knows\n * which experiments that shopper is in (via `$experiment_started` events\n * fired from the storefront), so attribution happens server-side without\n * the pixel having to re-transmit assignments.\n *\n * Naming note: the fields are `telemetry_host` / `telemetry_key` \u2014 not\n * `api_host` / `api_key` \u2014 to disambiguate from the other `apiHost`\n * fields in the codebase. This cookie is strictly about telemetry.\n *\n * Forward compat: pixel code must check `version` and reject unknown\n * shapes. Bump `version` when fields are removed or semantics change.\n */\n\nexport interface ShopifyPixelCookie {\n /** Schema version \u2014 pixel rejects unknown versions. Bump on breaking changes. */\n version: 1;\n /** PostHog distinct_id from the storefront SDK, used so checkout events\n * join onto the pre-checkout session. Required \u2014 without it the pixel\n * would create a new anonymous user at checkout and the funnel breaks. */\n distinct_id: string;\n /** Telemetry proxy host (e.g., `https://telemetry.syntrologie.com`). No trailing slash. */\n telemetry_host: string;\n /** Public write-only PostHog key (embedded in the client). */\n telemetry_key: string;\n}\n\nexport const COOKIE_NAME = 'syntro_experiments';\nexport const COOKIE_VERSION = 1 as const;\n", "/**\n * CDN-hosted Shopify Web Pixel \u2014 bundled to `dist/shopify-pixel.min.js` and\n * served from `cdn.syntrologie.com/runtime-sdk/v2/latest/shopify-pixel.min.js`.\n *\n * Merchants install this by pasting a small loader snippet into Shopify admin\n * under Settings \u2192 Customer events \u2192 Add custom pixel. The loader dynamically\n * loads this file, then calls `window.__SyntroShopifyPixel.register({ analytics, browser })`\n * to wire up subscribers. The indirection means we ship updates to every\n * merchant's pixel by redeploying the CDN file \u2014 merchants never re-paste.\n *\n * The pixel's only job is to forward Shopify checkout events to the\n * Syntrologie PostHog proxy with the shopper's distinct_id attached.\n * Experiment attribution is handled server-side by PostHog \u2014 once the\n * storefront SDK has fired `$experiment_started` for a distinct_id, PostHog\n * associates all future events from that user (including checkout events\n * this pixel posts) with the correct variant.\n *\n * Shopify custom pixels run in a \"lax sandbox\" iframe. Script tag injection\n * works (verified by PostHog, Sentry, GTM following the same pattern). Cookie\n * access is via the async `browser.cookie.get()` API which proxies to the\n * top frame (merchant storefront domain), so the `syntro_experiments` cookie\n * written by `ShopifyPixelBridge` on the storefront is readable here.\n *\n * All errors swallowed \u2014 a pixel failure must never disturb checkout.\n */\n\nimport { COOKIE_NAME, COOKIE_VERSION } from './platform/shopify-cookie-contract';\n\ninterface ShopifyAnalytics {\n subscribe(eventName: string, handler: (event: ShopifyPixelEvent) => void | Promise<void>): void;\n}\n\ninterface ShopifyBrowserCookie {\n get(name: string): Promise<string | undefined>;\n}\n\ninterface ShopifyBrowser {\n cookie: ShopifyBrowserCookie;\n}\n\ninterface ShopifyPixelEvent {\n data: {\n checkout?: {\n totalPrice: { amount: string | number; currencyCode: string };\n order?: { id: string };\n };\n cartLine?: {\n merchandise: { id: string; product: { id: string } };\n quantity: number;\n };\n };\n}\n\ninterface PixelCookie {\n version: number;\n distinct_id: string;\n telemetry_host: string;\n telemetry_key: string;\n}\n\nasync function readCookie(browser: ShopifyBrowser): Promise<PixelCookie | null> {\n try {\n const raw = await browser.cookie.get(COOKIE_NAME);\n if (!raw) return null;\n const parsed = JSON.parse(raw);\n if (!parsed || typeof parsed !== 'object') return null;\n if (parsed.version !== COOKIE_VERSION) return null;\n if (typeof parsed.distinct_id !== 'string' || parsed.distinct_id.length === 0) return null;\n if (typeof parsed.telemetry_host !== 'string' || !parsed.telemetry_host.startsWith('https://'))\n return null;\n if (typeof parsed.telemetry_key !== 'string' || parsed.telemetry_key.length === 0) return null;\n return parsed as PixelCookie;\n } catch {\n return null;\n }\n}\n\n/**\n * POST an event to the PostHog capture endpoint via the Syntrologie proxy.\n * URL shape: `{telemetry_host}/t/{telemetry_key}/e/` \u2014 see\n * `packages/runtime-sdk/src/telemetry/adapters/posthog.ts` for the convention.\n */\nasync function capture(\n cookie: PixelCookie,\n eventName: string,\n properties: Record<string, unknown>\n): Promise<void> {\n try {\n const url = `${cookie.telemetry_host}/t/${cookie.telemetry_key}/e/`;\n await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n keepalive: true, // survive page unload on checkout_completed\n body: JSON.stringify({\n api_key: cookie.telemetry_key, // PostHog's capture body field is always `api_key`\n event: eventName,\n distinct_id: cookie.distinct_id,\n properties: {\n $lib: 'syntrologie-shopify-pixel',\n $lib_version: '1.0.0',\n ...properties,\n },\n timestamp: new Date().toISOString(),\n }),\n });\n } catch {\n // Swallow \u2014 pixel errors must never affect checkout.\n }\n}\n\nexport function register({\n analytics,\n browser,\n}: {\n analytics: ShopifyAnalytics;\n browser: ShopifyBrowser;\n}): void {\n analytics.subscribe('checkout_completed', async (event) => {\n const cookie = await readCookie(browser);\n if (!cookie || !event.data.checkout) return;\n const { checkout } = event.data;\n\n await capture(cookie, 'checkout_completed', {\n order_value: checkout.totalPrice.amount,\n currency: checkout.totalPrice.currencyCode,\n order_id: checkout.order?.id,\n });\n });\n\n analytics.subscribe('product_added_to_cart', async (event) => {\n const cookie = await readCookie(browser);\n if (!cookie) return;\n const cartLine = event.data.cartLine;\n if (!cartLine) return;\n\n await capture(cookie, 'product_added_to_cart', {\n product_id: cartLine.merchandise.product.id,\n variant_id: cartLine.merchandise.id,\n quantity: cartLine.quantity,\n });\n });\n}\n\n// Expose on window so the loader snippet can find us after script load.\n// Shopify's lax-sandbox iframe has a real `window`; this is safe.\ndeclare global {\n interface Window {\n __SyntroShopifyPixel?: { register: typeof register };\n }\n}\nif (typeof window !== 'undefined') {\n window.__SyntroShopifyPixel = { register };\n}\n"],
|
|
5
|
+
"mappings": "MAuCO,IAAMA,EAAc,qBCqB3B,eAAeC,EAAWC,EAAsD,CAC9E,GAAI,CACF,IAAMC,EAAM,MAAMD,EAAQ,OAAO,IAAIE,CAAW,EAChD,GAAI,CAACD,EAAK,OAAO,KACjB,IAAME,EAAS,KAAK,MAAMF,CAAG,EAM7B,MALI,CAACE,GAAU,OAAOA,GAAW,UAC7BA,EAAO,UAAY,GACnB,OAAOA,EAAO,aAAgB,UAAYA,EAAO,YAAY,SAAW,GACxE,OAAOA,EAAO,gBAAmB,UAAY,CAACA,EAAO,eAAe,WAAW,UAAU,GAEzF,OAAOA,EAAO,eAAkB,UAAYA,EAAO,cAAc,SAAW,EAAU,KACnFA,CACT,MAAQ,CACN,OAAO,IACT,CACF,CAOA,eAAeC,EACbC,EACAC,EACAC,EACe,CACf,GAAI,CACF,IAAMC,EAAM,GAAGH,EAAO,cAAc,MAAMA,EAAO,aAAa,MAC9D,MAAM,MAAMG,EAAK,CACf,OAAQ,OACR,QAAS,CAAE,eAAgB,kBAAmB,EAC9C,UAAW,GACX,KAAM,KAAK,UAAU,CACnB,QAASH,EAAO,cAChB,MAAOC,EACP,YAAaD,EAAO,YACpB,WAAY,CACV,KAAM,4BACN,aAAc,QACd,GAAGE,CACL,EACA,UAAW,IAAI,KAAK,EAAE,YAAY,CACpC,CAAC,CACH,CAAC,CACH,MAAQ,CAER,CACF,CAEO,SAASE,EAAS,CACvB,UAAAC,EACA,QAAAV,CACF,EAGS,CACPU,EAAU,UAAU,qBAAsB,MAAOC,GAAU,CACzD,IAAMN,EAAS,MAAMN,EAAWC,CAAO,EACvC,GAAI,CAACK,GAAU,CAACM,EAAM,KAAK,SAAU,OACrC,GAAM,CAAE,SAAAC,CAAS,EAAID,EAAM,KAE3B,MAAMP,EAAQC,EAAQ,qBAAsB,CAC1C,YAAaO,EAAS,WAAW,OACjC,SAAUA,EAAS,WAAW,aAC9B,SAAUA,EAAS,OAAO,EAC5B,CAAC,CACH,CAAC,EAEDF,EAAU,UAAU,wBAAyB,MAAOC,GAAU,CAC5D,IAAMN,EAAS,MAAMN,EAAWC,CAAO,EACvC,GAAI,CAACK,EAAQ,OACb,IAAMQ,EAAWF,EAAM,KAAK,SACvBE,GAEL,MAAMT,EAAQC,EAAQ,wBAAyB,CAC7C,WAAYQ,EAAS,YAAY,QAAQ,GACzC,WAAYA,EAAS,YAAY,GACjC,SAAUA,EAAS,QACrB,CAAC,CACH,CAAC,CACH,CASI,OAAO,OAAW,MACpB,OAAO,qBAAuB,CAAE,SAAAJ,CAAS",
|
|
6
|
+
"names": ["COOKIE_NAME", "readCookie", "browser", "raw", "COOKIE_NAME", "parsed", "capture", "cookie", "eventName", "properties", "url", "register", "analytics", "event", "checkout", "cartLine"]
|
|
7
|
+
}
|