// 貼り付け用コード
// 必ず sid をご自身のプロファイルIDに置き換えてください
const sid = "プロファイルidを入力してください";
const area = "jp";
// サンドボックスのパス形式は web pixel の runtimeContext によって決まる
const SANDBOX_RE =
/\/(?:wpm|web-pixels)@[^/]+\/(?:app\/|custom\/)?web-pixel-[^/]+\/sandbox\/modern/;
let ptenginePromise = null;
/**
* 実際のストアページ URL を取得する。
* サンドボックス URL にはページパスが含まれており、サンドボックス部分を除去すれば実際の
* ページ URL を復元できる。ただしイベントコンテキストの方が信頼できるため、
* event.context を優先し、正規表現はフォールバックとしてのみ使用する。
*/
function getPageUrl(event) {
return (
event?.context?.document?.location?.href ||
event?.context?.window?.location?.href ||
window.location.href.replace(SANDBOX_RE, "")
);
}
// sid / area はモジュール定数を直接参照し、引数として渡さない
function loadPtengineScript(pageUrl) {
// ptengineが既に存在する場合は即座にresolve
if (window.ptengine) return Promise.resolve();
// 読み込みが進行中であれば再利用し、二重に挿入しない
if (ptenginePromise) return ptenginePromise;
ptenginePromise = new Promise((resolve, reject) => {
// 元の URL で送信する。SDK は setPVTag を ',' で分割し(replace 判定は厳密に第3要素を見る)、
// URL 内にカンマがあると全体がずれるため、必ずエスケープする
window._pt_sp_2 = window._pt_sp_2 || [];
window._pt_sp_2.push("setAccount," + sid);
window._pt_sp_2.push(
"setPVTag," + String(pageUrl).replace(/,/g, "%2C") + ",replace",
);
// 構造上の制約:setPVTag は初回ロード時にのみ SDK に消費されるため、同一サンドボックスの
// ライフサイクル内では後続イベントもすべて最初のイベントの URL に帰属する。複数ページ構成の
// Shopify ストアではナビゲーションごとにサンドボックスが再生成されるため影響なし。テーマが
// SPA 的な遷移をする場合や、checkout が単一ページで複数ステップを進む場合は、後続イベントが
// 最初の URL に紐付く。正確な帰属はイベントプロパティの url フィールドに依存する。
// ptengineスクリプトを動的に読み込む
const script = document.createElement("script");
// checkoutページかどうかを判定(実際のページ URL を使う必要がある。サンドボックス URL には
// /checkouts/ が含まれない)
const isCheckoutPage =
/\/checkouts?\//.test(pageUrl) || /thank[_-]you/.test(pageUrl);
const sandboxQuery = isCheckoutPage ? "" : "?sandbox"; // checkoutページでは '?sandbox' を付けない
script.src =
"https://js.ptengine." + area + "/" + sid + ".js" + sandboxQuery;
script.onload = () => resolve(); // 読み込み成功
script.onerror = () => {
ptenginePromise = null; // 後続イベントでのリトライを許可
reject(new Error("Script loading failed")); // 読み込み失敗
};
document.head.appendChild(script);
});
return ptenginePromise;
}
/**
* 読み込み完了後、利用可能な ptengine インスタンスを取得して fn を実行する。
* SDK はドメイン不一致 / サンプリング / URL 除外 / タグの重複設置により中断されることがあり、
* その場合 onload は発火しても window.ptengine が存在しない。明示的に警告しないと
* イベントが黙って破棄されてしまう。
* @param {string} pageUrl
* @param {function} fn
* @param {string} [label] イベント名。問題発生時に警告へ含めて特定しやすくする
*/
function withPtengine(pageUrl, fn, label) {
return loadPtengineScript(pageUrl)
.then(() => {
if (!window.ptengine) {
console.warn(
"ptengine が初期化されていません(URL 除外 / サンプリング / タグ重複設置の可能性)、イベントを破棄" +
(label ? ":" + label : ""),
);
return;
}
fn(window.ptengine);
})
.catch((error) => {
console.error(
"ptengineスクリプトの読み込み失敗" + (label ? "、イベントを破棄:" + label : ""),
error,
);
});
}
function trackEvent(eventType, eventProperties, event) {
const options = eventProperties || {};
return withPtengine(
getPageUrl(event),
(pt) => pt.track(eventType, options),
eventType,
);
}
// null/undefined/'' の項目を送信しないように空値プロパティを除去
function removeEmptyKeys(obj) {
for (let key in obj) {
if (
obj.hasOwnProperty(key) &&
(obj[key] === null || obj[key] === undefined || obj[key] === "")
) {
delete obj[key];
}
}
return obj;
}
// 注文完了:注文単位の checkout_completed 1件 + 商品ごとに checkout_completed_order を1件ずつ送信
analytics.subscribe("checkout_completed", (event) => {
const checkout = event?.data?.checkout;
if (!checkout) return;
// 注文の顧客ID(gid://shopify/Customer/xxx)から数値IDを抽出して uid とする
const rawCustomerId = checkout?.order?.customer?.id;
const numericCustomerId = rawCustomerId ? rawCustomerId.split("/").pop(): null;
const uid = numericCustomerId;
// ⚠️ 定義未確定:ここでは totalPrice(税・送料込みの成約総額)を使用しているが、
// checkout_started と payment_info_submitted は subtotalPrice(小計)を使用している。
// 同一ファネル内で2種類の定義が混在すると金額を比較できないため、どちらに統一するか、
// または両側にもう一方のフィールドを追加するかをお客様と確認する必要がある。
const totalPrice = checkout?.totalPrice?.amount;
const currencyCode = checkout?.totalPrice?.currencyCode;
const orderId = checkout?.order?.id;
const items = checkout?.lineItems || [];
const eventProperties = removeEmptyKeys({
totalPrice: totalPrice,
currencyCode: currencyCode,
totalorderid: orderId,
productCount: items.length,
});
withPtengine(
getPageUrl(event),
(pt) => {
// 1) identify:注文ごとに1回、注文情報をユーザープロファイルに書き込む。
// track より前に送信する必要がある。そうしないと後続イベントが識別済みユーザーに紐付かない。
if (uid) {
pt.identify(uid, {
totalPrice: totalPrice,
totalorderid: orderId,
});
}
// 2) track:注文単位の checkout_completed を1回だけ送信(商品明細は含めない)
pt.track("checkout_completed", eventProperties);
// 3) 商品明細:商品ごとに checkout_completed_order を1件ずつ送信。
// 注文単位イベントと紐付けられるよう totalorderid を含める
items.forEach((item) => {
const itemProperties = removeEmptyKeys({
totalorderid: orderId,
sku: item?.variant?.sku,
name: item?.title,
spu: item?.variant?.product?.type,
// ⚠️ 定義未確定:item.id は「行アイテム ID」で注文ごとに異なるため、これで商品単位に
// 集計すると完全にばらけてしまう。商品単位は item.variant.product.id、
// バリエーション単位は item.variant.id を使うべき。フィールドの意味を変えると
// 既存レポートに影響するため、お客様の確認後に変更する。
id: item?.id,
quantity: item?.quantity,
price: item?.finalLinePrice?.amount,
currencyCode: currencyCode,
});
pt.track("checkout_completed_order", itemProperties);
});
},
"checkout_completed",
);
});
analytics.subscribe("search_submitted", (event) => {
const eventProperties = removeEmptyKeys({
keyword: event?.data?.searchResult?.query,
resultCount: event?.data?.searchResult?.productVariants?.length,
});
trackEvent("search_submitted", eventProperties, event);
});
analytics.subscribe("collection_viewed", (event) => {
const eventProperties = removeEmptyKeys({
id: event?.data?.collection?.id,
name: event?.data?.collection?.title,
productCount: event?.data?.collection?.productVariants?.length,
});
trackEvent("collection_viewed", eventProperties, event);
});
analytics.subscribe("product_viewed", (event) => {
const origin = event?.context?.window?.origin || window.location.origin;
const variant = event?.data?.productVariant;
const eventProperties = removeEmptyKeys({
sku: variant?.sku,
name: variant?.product?.title,
// ⚠️ 定義未確定:type と spu は同じ値を取っており、イベントプロパティの枠を重複して消費し
// 意味も不明確。既存レポートがどちらかを使っている可能性があるため残している。
// お客様の確認後に統合する。
type: variant?.product?.type,
spu: variant?.product?.type,
price: variant?.price?.amount,
currencyCode: variant?.price?.currencyCode,
url: variant?.product?.url ? origin + variant.product.url : undefined,
imageUrl: variant?.image?.src,
});
trackEvent("product_viewed", eventProperties, event);
});
analytics.subscribe("product_added_to_cart", (event) => {
const origin = event?.context?.window?.origin || window.location.origin;
const merchandise = event?.data?.cartLine?.merchandise;
const eventProperties = removeEmptyKeys({
url: merchandise?.product?.url ? origin + merchandise.product.url: undefined,
name: merchandise?.product?.title,
sku: merchandise?.sku,
quantity: event?.data?.cartLine?.quantity,
price: merchandise?.price?.amount,
currencyCode: merchandise?.price?.currencyCode,
spu: merchandise?.product?.type,
});
trackEvent("product_added_to_cart", eventProperties, event);
});
analytics.subscribe("product_removed_from_cart", (event) => {
const origin = event?.context?.window?.origin || window.location.origin;
const merchandise = event?.data?.cartLine?.merchandise;
const eventProperties = removeEmptyKeys({
url: merchandise?.product?.url ? origin + merchandise.product.url : undefined,
name: merchandise?.product?.title,
sku: merchandise?.sku,
quantity: event?.data?.cartLine?.quantity,
price: merchandise?.price?.amount,
currencyCode: merchandise?.price?.currencyCode,
spu: merchandise?.product?.type,
});
trackEvent("product_removed_from_cart", eventProperties, event);
});
analytics.subscribe("cart_viewed", (event) => {
const cart = event?.data?.cart;
if (!cart) {
trackEvent("cart_viewed", {}, event);
return;
}
const origin = event?.context?.window?.origin || window.location.origin;
const lines = cart?.lines || [];
// ⚠️ 定義に関する注意:ここではカート内の商品ごとに cart_viewed を1件ずつ送信するため、
// 3商品なら同名イベントが3件になる。イベント枠の消費が倍増し、コンバージョン率の分母も膨らむ。
// 既存レポートを壊さないためこの挙動を維持している。「カート単位1件 + 商品単位N件」に
// 変更する場合は定義変更となるため、事前にお客様と確認しレポートも合わせて調整する必要がある。
if (lines.length) {
lines.forEach((line) => {
const merchandise = line?.merchandise;
const eventProperties = removeEmptyKeys({
totalQuantity: cart?.totalQuantity,
url: merchandise?.product?.url ? origin + merchandise.product.url : undefined,
name: merchandise?.product?.title,
sku: merchandise?.sku,
spu: merchandise?.product?.type,
quantity: line?.quantity,
// 意図的な選択:line.cost.totalAmount は行小計、merchandise.price は単価であり意味が異なる。
// フォールバックで混在させない。cost が欠けている場合はフィールドを落とし、
// price の意味を曖昧にしない。
price: line?.cost?.totalAmount?.amount,
currencyCode: line?.cost?.totalAmount?.currencyCode,
});
trackEvent("cart_viewed", eventProperties, event);
});
} else {
trackEvent(
"cart_viewed",
removeEmptyKeys({ totalQuantity: cart?.totalQuantity }),
event,
);
}
});
analytics.subscribe("checkout_started", (event) => {
const checkout = event?.data?.checkout;
const origin = event?.context?.window?.origin || window.location.origin;
const checkoutItems = checkout?.lineItems;
const totalPrice = checkout?.subtotalPrice?.amount;
const currencyCode = checkout?.subtotalPrice?.currencyCode;
// ⚠️ cart_viewed と同様:商品ごとに checkout_started を1件ずつ送信するため、同様に膨らむ。
// 改善方法は上記参照。
if (checkoutItems && checkoutItems.length) {
checkoutItems.forEach((item) => {
const eventProperties = removeEmptyKeys({
totalPrice: totalPrice,
currencyCode: currencyCode,
url: item?.variant?.product?.url ? origin + item.variant.product.url : undefined,
name: item?.variant?.product?.title,
sku: item?.variant?.sku,
spu: item?.variant?.product?.type,
quantity: item?.quantity,
price: item?.finalLinePrice?.amount,
});
trackEvent("checkout_started", eventProperties, event);
});
} else {
trackEvent(
"checkout_started",
removeEmptyKeys({ totalPrice, currencyCode }),
event,
);
}
});
analytics.subscribe("checkout_contact_info_submitted", (event) => {
trackEvent("checkout_contact_info_submitted", {}, event);
});
analytics.subscribe("checkout_address_info_submitted", (event) => {
trackEvent("checkout_address_info_submitted", {}, event);
});
analytics.subscribe("checkout_shipping_info_submitted", (event) => {
trackEvent("checkout_shipping_info_submitted", {}, event);
});
analytics.subscribe("payment_info_submitted", (event) => {
const checkout = event?.data?.checkout;
const eventProperties = removeEmptyKeys({
totalPrice: checkout?.subtotalPrice?.amount,
currencyCode: checkout?.subtotalPrice?.currencyCode,
});
trackEvent("payment_info_submitted", eventProperties, event);
});