@cartbase/storefront 0.1.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/package.json +226 -0
- package/src/api/auth.ts +108 -0
- package/src/api/carts.ts +506 -0
- package/src/api/categories.ts +184 -0
- package/src/api/checkout.ts +440 -0
- package/src/api/collections.ts +130 -0
- package/src/api/consent.ts +75 -0
- package/src/api/content.ts +125 -0
- package/src/api/customers.ts +307 -0
- package/src/api/gift-cards.ts +112 -0
- package/src/api/http.ts +122 -0
- package/src/api/index.ts +29 -0
- package/src/api/integrations.ts +130 -0
- package/src/api/menus.ts +77 -0
- package/src/api/metaobjects.ts +136 -0
- package/src/api/orders.ts +290 -0
- package/src/api/products.ts +303 -0
- package/src/api/redirects.ts +37 -0
- package/src/api/regions.ts +141 -0
- package/src/api/reviews.ts +259 -0
- package/src/api/search.ts +133 -0
- package/src/api/types.ts +91 -0
- package/src/cart-drawer/cart-drawer.tsx +86 -0
- package/src/cart-drawer/context.tsx +569 -0
- package/src/cart-drawer/continue-shopping.tsx +28 -0
- package/src/cart-drawer/cross-sell-carousel.tsx +212 -0
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -0
- package/src/cart-drawer/empty.tsx +52 -0
- package/src/cart-drawer/free-gift.tsx +71 -0
- package/src/cart-drawer/gift-wrap.tsx +83 -0
- package/src/cart-drawer/header.tsx +52 -0
- package/src/cart-drawer/index.ts +69 -0
- package/src/cart-drawer/item/index.tsx +164 -0
- package/src/cart-drawer/item/quantity.tsx +100 -0
- package/src/cart-drawer/item/upsell.tsx +110 -0
- package/src/cart-drawer/item/variant.tsx +46 -0
- package/src/cart-drawer/labels-bg.ts +72 -0
- package/src/cart-drawer/labels.ts +119 -0
- package/src/cart-drawer/notes.tsx +131 -0
- package/src/cart-drawer/payment-badges.tsx +96 -0
- package/src/cart-drawer/promo-banner.tsx +43 -0
- package/src/cart-drawer/rewards-points.tsx +78 -0
- package/src/cart-drawer/sticky-footer.tsx +73 -0
- package/src/cart-drawer/summary-breakdown.tsx +196 -0
- package/src/cart-drawer/template.tsx +225 -0
- package/src/cart-drawer/tiered-progress.tsx +168 -0
- package/src/checkout/address-error-copy.ts +119 -0
- package/src/checkout/address-form.tsx +224 -0
- package/src/checkout/address-select.tsx +79 -0
- package/src/checkout/boxnow-locker-selector.tsx +410 -0
- package/src/checkout/checkout-client.tsx +222 -0
- package/src/checkout/company-details.tsx +94 -0
- package/src/checkout/compare-addresses.ts +40 -0
- package/src/checkout/context.tsx +76 -0
- package/src/checkout/discount-section.tsx +218 -0
- package/src/checkout/econt-office-selector.tsx +332 -0
- package/src/checkout/error-message.tsx +25 -0
- package/src/checkout/geocode.ts +154 -0
- package/src/checkout/gift-card-section.tsx +224 -0
- package/src/checkout/index.ts +74 -0
- package/src/checkout/labels-bg.ts +128 -0
- package/src/checkout/labels.ts +263 -0
- package/src/checkout/line-item-card.tsx +152 -0
- package/src/checkout/order-summary.tsx +524 -0
- package/src/checkout/payment-button.tsx +373 -0
- package/src/checkout/payment-error-copy.ts +269 -0
- package/src/checkout/payment-method-list.tsx +365 -0
- package/src/checkout/payment-wrapper.tsx +102 -0
- package/src/checkout/promotion-error-copy.ts +124 -0
- package/src/checkout/shipping-method-list.tsx +335 -0
- package/src/checkout/stripe-wrapper.tsx +165 -0
- package/src/checkout/use-checkout-orchestration.ts +1504 -0
- package/src/common/cart-button-client.tsx +39 -0
- package/src/common/cart-button.tsx +28 -0
- package/src/common/country-select.tsx +65 -0
- package/src/common/delete-button.tsx +66 -0
- package/src/common/index.ts +17 -0
- package/src/common/language-select.tsx +78 -0
- package/src/common/localized-link.tsx +45 -0
- package/src/common/skeleton.tsx +29 -0
- package/src/index.ts +12 -0
- package/src/lib/cart-helpers.ts +113 -0
- package/src/lib/dual-price.tsx +73 -0
- package/src/lib/get-percentage-diff.ts +5 -0
- package/src/lib/get-product-price.ts +133 -0
- package/src/lib/hooks/use-intersection.ts +30 -0
- package/src/lib/hooks/use-toggle-state.ts +25 -0
- package/src/lib/money.ts +73 -0
- package/src/lib/payment-constants.ts +66 -0
- package/src/lib/product.ts +22 -0
- package/src/lib/sort-products.ts +63 -0
- package/src/lib/store-api-error.ts +36 -0
- package/src/lib/utils.ts +16 -0
- package/src/order/context.tsx +32 -0
- package/src/order/index.ts +63 -0
- package/src/order/labels-bg.ts +39 -0
- package/src/order/labels.ts +79 -0
- package/src/order/order-address-card.tsx +47 -0
- package/src/order/order-completed-template.tsx +165 -0
- package/src/order/order-confirmation-header.tsx +65 -0
- package/src/order/order-delivery-card.tsx +258 -0
- package/src/order/order-help-section.tsx +47 -0
- package/src/order/order-item.tsx +201 -0
- package/src/order/order-items-list.tsx +52 -0
- package/src/order/order-payment-card.tsx +95 -0
- package/src/order/order-timeline.tsx +141 -0
- package/src/order/order-totals.tsx +245 -0
- package/src/primitives/field.tsx +125 -0
- package/src/primitives/select-field.tsx +77 -0
- package/src/primitives/ui/accordion.tsx +61 -0
- package/src/primitives/ui/button.tsx +68 -0
- package/src/primitives/ui/collapsible.tsx +16 -0
- package/src/primitives/ui/dialog.tsx +112 -0
- package/src/primitives/ui/input.tsx +30 -0
- package/src/primitives/ui/label.tsx +31 -0
- package/src/primitives/ui/popover.tsx +38 -0
- package/src/primitives/ui/select.tsx +163 -0
- package/src/primitives/ui/sheet.tsx +131 -0
- package/src/primitives/ui/tabs.tsx +62 -0
- package/src/products/context.tsx +34 -0
- package/src/products/image-gallery.tsx +43 -0
- package/src/products/index.ts +44 -0
- package/src/products/labels-bg.ts +35 -0
- package/src/products/labels.ts +57 -0
- package/src/products/mobile-actions.tsx +180 -0
- package/src/products/option-select.tsx +67 -0
- package/src/products/preview-price.tsx +36 -0
- package/src/products/product-actions-wrapper.tsx +58 -0
- package/src/products/product-actions.tsx +217 -0
- package/src/products/product-info.tsx +43 -0
- package/src/products/product-preview.tsx +49 -0
- package/src/products/product-price.tsx +69 -0
- package/src/products/product-tabs.tsx +169 -0
- package/src/products/product-template.tsx +114 -0
- package/src/products/purchase-options.tsx +130 -0
- package/src/products/related-products.tsx +86 -0
- package/src/products/thumbnail.tsx +71 -0
- package/src/products/variant-matching.ts +71 -0
- package/src/reviews-ui/helpers.ts +174 -0
- package/src/reviews-ui/index.ts +74 -0
- package/src/reviews-ui/labels-bg.ts +91 -0
- package/src/reviews-ui/labels.ts +199 -0
- package/src/reviews-ui/photo-upload.tsx +345 -0
- package/src/reviews-ui/review-list.tsx +249 -0
- package/src/reviews-ui/review-widget.tsx +224 -0
- package/src/reviews-ui/review-wizard.tsx +560 -0
- package/src/reviews-ui/star-badge.tsx +104 -0
- package/src/reviews-ui/wizard-state.ts +81 -0
- package/src/store/category-template.tsx +129 -0
- package/src/store/collection-template.tsx +139 -0
- package/src/store/index.ts +41 -0
- package/src/store/labels-bg.ts +22 -0
- package/src/store/labels.ts +52 -0
- package/src/store/paginated-products.tsx +116 -0
- package/src/store/pagination.tsx +103 -0
- package/src/store/search-params.ts +256 -0
- package/src/store/search-template.tsx +249 -0
- package/src/store/skeleton-product-grid.tsx +26 -0
- package/src/store/sort-select.tsx +81 -0
- package/src/store/store-template.tsx +65 -0
- package/src/tracking/attribution.ts +418 -0
- package/src/tracking/consent-banner.tsx +355 -0
- package/src/tracking/consent-init.tsx +44 -0
- package/src/tracking/consent.ts +243 -0
- package/src/tracking/fbq.ts +168 -0
- package/src/tracking/ga4.tsx +49 -0
- package/src/tracking/get-tracking-attribution.ts +224 -0
- package/src/tracking/get-tracking-config.ts +50 -0
- package/src/tracking/gtag.ts +200 -0
- package/src/tracking/index.ts +133 -0
- package/src/tracking/meta-pixel.tsx +166 -0
- package/src/tracking/rybbit-events.ts +242 -0
- package/src/tracking/rybbit.tsx +40 -0
- package/src/tracking/types.ts +185 -0
- package/src/tracking/use-engagement-time.ts +58 -0
- package/tailwind-preset.cjs +72 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @cartbase/storefront/tracking — Meta Pixel + GA4 + Rybbit + Consent Mode v2.
|
|
3
|
+
*
|
|
4
|
+
* Ported from `@1click/ui/src/tracking` (v2.3.1) with the barter seams:
|
|
5
|
+
* - config comes from `GET /api/store/integrations` → `tracking` block
|
|
6
|
+
* via `getTrackingConfig(client)` (never env vars)
|
|
7
|
+
* - server-side CAPI / GA4-MP sending is barter-backend-owned (the
|
|
8
|
+
* `order.placed` forwarder) — this package fires CLIENT events only
|
|
9
|
+
* and writes attribution for the server to inherit
|
|
10
|
+
* - Purchase dedupe: Pixel `eventID = "purchase_" + order.display_id`
|
|
11
|
+
* (trackPurchase builds it); GA4 `transaction_id =
|
|
12
|
+
* String(order.display_id)`
|
|
13
|
+
*
|
|
14
|
+
* Public surface:
|
|
15
|
+
* - <ConsentInit> / <ConsentBanner> / <ConsentSettingsLink> — Consent
|
|
16
|
+
* Mode v2 (config from GET /api/store/consent; `_1c_consent` seam)
|
|
17
|
+
* - <MetaPixel pixelId> / <GA4 measurementId> / <Rybbit siteId> — layout tags
|
|
18
|
+
* - Meta Pixel client helpers: trackViewContent / trackAddToCart /
|
|
19
|
+
* trackInitiateCheckout / trackPurchase / trackLead
|
|
20
|
+
* - GA4 client helpers: trackGAViewItem / trackGAAddToCart /
|
|
21
|
+
* trackGABeginCheckout / trackGAPurchase / setEnhancedConversions
|
|
22
|
+
* - Rybbit client helpers: trackRybbit* (queue-buffered)
|
|
23
|
+
* - getTrackingConfig(client) — the public tag config
|
|
24
|
+
* - getTrackingAttribution(clientHints?, opts?) — server-side signal
|
|
25
|
+
* gathering for the cart.metadata writeback (consent-gated)
|
|
26
|
+
* - getEngagementTimeMsec() / initEngagementTime() — time-on-session
|
|
27
|
+
*
|
|
28
|
+
* Server vs client split (per Next.js App Router rules):
|
|
29
|
+
* - get-tracking-attribution.ts imports `next/headers` (server-only by
|
|
30
|
+
* construction); get-tracking-config.ts is isomorphic
|
|
31
|
+
* - meta-pixel.tsx, ga4.tsx, rybbit.tsx, fbq.ts, gtag.ts,
|
|
32
|
+
* rybbit-events.ts, attribution.ts, use-engagement-time.ts,
|
|
33
|
+
* consent-banner.tsx are `"use client"`
|
|
34
|
+
* - types.ts, consent.ts, consent-init.tsx are universal
|
|
35
|
+
*
|
|
36
|
+
* Importers should usually pull from the subpath that matches their
|
|
37
|
+
* environment. The barrel re-exports everything, but tree-shaking and
|
|
38
|
+
* Next.js's RSC boundary detection both work better with subpaths.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
export { MetaPixel, updatePixelAdvancedMatching } from "./meta-pixel"
|
|
42
|
+
export { GA4 } from "./ga4"
|
|
43
|
+
export { Rybbit } from "./rybbit"
|
|
44
|
+
export { ConsentInit } from "./consent-init"
|
|
45
|
+
export { ConsentBanner, ConsentBannerCard, ConsentSettingsLink } from "./consent-banner"
|
|
46
|
+
export {
|
|
47
|
+
applyConsent,
|
|
48
|
+
setConsent,
|
|
49
|
+
openConsentSettings,
|
|
50
|
+
readConsentCookie,
|
|
51
|
+
writeConsentCookie,
|
|
52
|
+
shouldRenderBanner,
|
|
53
|
+
pickConsentCopy,
|
|
54
|
+
CONSENT_COOKIE,
|
|
55
|
+
CONSENT_MAX_AGE_SECONDS,
|
|
56
|
+
CONSENT_OPEN_EVENT,
|
|
57
|
+
CONSENT_INIT_SNIPPET,
|
|
58
|
+
type ConsentChoices,
|
|
59
|
+
type ConsentCopy,
|
|
60
|
+
type ConsentLayout,
|
|
61
|
+
type ConsentMode,
|
|
62
|
+
type ConsentSettings,
|
|
63
|
+
} from "./consent"
|
|
64
|
+
export {
|
|
65
|
+
trackViewContent,
|
|
66
|
+
trackAddToCart,
|
|
67
|
+
trackInitiateCheckout,
|
|
68
|
+
trackPurchase,
|
|
69
|
+
trackLead,
|
|
70
|
+
generateEventId,
|
|
71
|
+
type LeadContext,
|
|
72
|
+
} from "./fbq"
|
|
73
|
+
export {
|
|
74
|
+
setTrackingDefaults,
|
|
75
|
+
getOrCreateAnonId,
|
|
76
|
+
getOrCreateFbp,
|
|
77
|
+
getOrCreateFbc,
|
|
78
|
+
getKnownVisitor,
|
|
79
|
+
rememberKnownVisitor,
|
|
80
|
+
captureUtmsFromUrl,
|
|
81
|
+
getCapturedFirstTouchUtms,
|
|
82
|
+
getCapturedLastTouchUtms,
|
|
83
|
+
sha256Hex,
|
|
84
|
+
normaliseEmailForHash,
|
|
85
|
+
normalisePhoneForHash,
|
|
86
|
+
type KnownVisitor,
|
|
87
|
+
type CapturedUtms,
|
|
88
|
+
} from "./attribution"
|
|
89
|
+
export {
|
|
90
|
+
trackGAViewItem,
|
|
91
|
+
trackGAAddToCart,
|
|
92
|
+
trackGABeginCheckout,
|
|
93
|
+
trackGAPurchase,
|
|
94
|
+
setEnhancedConversions,
|
|
95
|
+
type EnhancedConversionsInput,
|
|
96
|
+
} from "./gtag"
|
|
97
|
+
export {
|
|
98
|
+
trackRybbitViewItem,
|
|
99
|
+
trackRybbitAddToCart,
|
|
100
|
+
trackRybbitBeginCheckout,
|
|
101
|
+
trackRybbitPurchase,
|
|
102
|
+
type RybbitViewItemData,
|
|
103
|
+
type RybbitAddToCartData,
|
|
104
|
+
type RybbitBeginCheckoutData,
|
|
105
|
+
type RybbitPurchaseData,
|
|
106
|
+
} from "./rybbit-events"
|
|
107
|
+
export { getTrackingConfig } from "./get-tracking-config"
|
|
108
|
+
export { getTrackingAttribution } from "./get-tracking-attribution"
|
|
109
|
+
export {
|
|
110
|
+
getEngagementTimeMsec,
|
|
111
|
+
initEngagementTime,
|
|
112
|
+
} from "./use-engagement-time"
|
|
113
|
+
|
|
114
|
+
export type {
|
|
115
|
+
TrackingConfig,
|
|
116
|
+
TrackingConfigResponse,
|
|
117
|
+
TrackingAttribution,
|
|
118
|
+
TrackingClientHints,
|
|
119
|
+
MetaContentItem,
|
|
120
|
+
ViewContentData,
|
|
121
|
+
AddToCartData,
|
|
122
|
+
InitiateCheckoutData,
|
|
123
|
+
PurchaseData,
|
|
124
|
+
LeadData,
|
|
125
|
+
} from "./types"
|
|
126
|
+
|
|
127
|
+
export type {
|
|
128
|
+
GA4Item,
|
|
129
|
+
GA4ViewItemData,
|
|
130
|
+
GA4AddToCartData,
|
|
131
|
+
GA4BeginCheckoutData,
|
|
132
|
+
GA4PurchaseData,
|
|
133
|
+
} from "./gtag"
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import Script from "next/script"
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
normaliseEmailForHash,
|
|
7
|
+
normalisePhoneForHash,
|
|
8
|
+
rememberKnownVisitor,
|
|
9
|
+
sha256Hex,
|
|
10
|
+
type KnownVisitor,
|
|
11
|
+
} from "./attribution"
|
|
12
|
+
import { CONSENT_COOKIE } from "./consent"
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* MetaPixel — client-side base pixel injector.
|
|
16
|
+
*
|
|
17
|
+
* Loads `https://connect.facebook.net/en_US/fbevents.js` via Next.js
|
|
18
|
+
* `<Script strategy="afterInteractive">` (the canonical strategy for
|
|
19
|
+
* third-party tags per Next.js docs). Initializes `fbq` with the given
|
|
20
|
+
* pixel ID and fires an initial `PageView`.
|
|
21
|
+
*
|
|
22
|
+
* The snippet stashes `pixelId` on `window.__1click_fb_pixel_id` so
|
|
23
|
+
* `updatePixelAdvancedMatching()` (called later from checkout / popup)
|
|
24
|
+
* can re-init the Pixel with hashed em / ph for Advanced Matching
|
|
25
|
+
* without the caller having to plumb the pixel id through.
|
|
26
|
+
*
|
|
27
|
+
* Renders nothing when `pixelId` is falsy — every consuming layout
|
|
28
|
+
* can call this unconditionally; the script is only injected when the
|
|
29
|
+
* admin has configured Facebook Pixel + CAPI.
|
|
30
|
+
*
|
|
31
|
+
* Subsequent route navigations DON'T need a manual PageView — the
|
|
32
|
+
* single base init covers the initial page; further events are fired
|
|
33
|
+
* explicitly via the helpers in `./fbq.ts`.
|
|
34
|
+
*
|
|
35
|
+
* Consent gate: fbq('consent', …) is pushed from the `_1c_consent`
|
|
36
|
+
* cookie BEFORE fbq('init') — Meta only honors a pre-init revoke, which
|
|
37
|
+
* makes the Pixel queue every event (incl. this PageView) client-side
|
|
38
|
+
* until the <ConsentBanner> grants; a later fbq('consent','grant')
|
|
39
|
+
* flushes the queue. No cookie (first visit) = revoke.
|
|
40
|
+
*/
|
|
41
|
+
export function MetaPixel({ pixelId }: { pixelId?: string }) {
|
|
42
|
+
if (!pixelId) return null
|
|
43
|
+
|
|
44
|
+
const initSnippet = `
|
|
45
|
+
window.__1click_fb_pixel_id='${pixelId}';
|
|
46
|
+
!function(f,b,e,v,n,t,s)
|
|
47
|
+
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
|
|
48
|
+
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
|
|
49
|
+
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
|
|
50
|
+
n.queue=[];t=b.createElement(e);t.async=!0;
|
|
51
|
+
t.src=v;s=b.getElementsByTagName(e)[0];
|
|
52
|
+
s.parentNode.insertBefore(t,s)}(window, document,'script',
|
|
53
|
+
'https://connect.facebook.net/en_US/fbevents.js');
|
|
54
|
+
(function(){
|
|
55
|
+
var ads=false;
|
|
56
|
+
try{
|
|
57
|
+
var m=document.cookie.match(/(?:^|; )${CONSENT_COOKIE}=([^;]*)/);
|
|
58
|
+
if(m){ads=!!JSON.parse(decodeURIComponent(m[1])).ads;}
|
|
59
|
+
}catch(e){}
|
|
60
|
+
fbq('consent', ads?'grant':'revoke');
|
|
61
|
+
})();
|
|
62
|
+
fbq('init', '${pixelId}');
|
|
63
|
+
fbq('track', 'PageView');
|
|
64
|
+
`.trim()
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<>
|
|
68
|
+
<Script
|
|
69
|
+
id="meta-pixel-init"
|
|
70
|
+
strategy="afterInteractive"
|
|
71
|
+
dangerouslySetInnerHTML={{ __html: initSnippet }}
|
|
72
|
+
/>
|
|
73
|
+
<noscript>
|
|
74
|
+
<img
|
|
75
|
+
height="1"
|
|
76
|
+
width="1"
|
|
77
|
+
style={{ display: "none" }}
|
|
78
|
+
src={`https://www.facebook.com/tr?id=${pixelId}&ev=PageView&noscript=1`}
|
|
79
|
+
alt=""
|
|
80
|
+
/>
|
|
81
|
+
</noscript>
|
|
82
|
+
</>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
declare global {
|
|
87
|
+
interface Window {
|
|
88
|
+
__1click_fb_pixel_id?: string
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Re-initialize the Pixel with Advanced Matching params so EVERY
|
|
94
|
+
* subsequent browser-side event is enriched with hashed em / ph /
|
|
95
|
+
* fn / ln / ct / zp automatically. Per Meta docs, fbq('init') with
|
|
96
|
+
* a 3rd arg merges advanced-matching values rather than replacing
|
|
97
|
+
* them — safe to call repeatedly as new fields become known.
|
|
98
|
+
*
|
|
99
|
+
* Caller passes RAW PII (we hash here using Meta's exact normalization
|
|
100
|
+
* so the resulting hash matches what backend CAPI sends — both sides
|
|
101
|
+
* of dedup compare the same digest).
|
|
102
|
+
*
|
|
103
|
+
* Also persists raw values via rememberKnownVisitor() so any future
|
|
104
|
+
* consumer of getKnownVisitor() sees them. In barter the server-side
|
|
105
|
+
* CAPI enrichment is backend-owned (the order.placed forwarder reads
|
|
106
|
+
* hashed PII from the order itself), so this call's job is the
|
|
107
|
+
* browser-side Pixel Advanced Matching.
|
|
108
|
+
*
|
|
109
|
+
* No-ops when:
|
|
110
|
+
* - SSR (no window)
|
|
111
|
+
* - MetaPixel hasn't mounted yet (no `window.__1click_fb_pixel_id`)
|
|
112
|
+
* - fbevents.js hasn't loaded yet (no `window.fbq`)
|
|
113
|
+
*
|
|
114
|
+
* The localStorage write still happens in cases 2-3 so the data is
|
|
115
|
+
* available to any later getKnownVisitor() consumer.
|
|
116
|
+
*/
|
|
117
|
+
export async function updatePixelAdvancedMatching(
|
|
118
|
+
visitor: KnownVisitor
|
|
119
|
+
): Promise<void> {
|
|
120
|
+
if (typeof window === "undefined") return
|
|
121
|
+
|
|
122
|
+
// Persist raw values regardless of Pixel state — later consumers
|
|
123
|
+
// (getKnownVisitor) use these even when Pixel isn't reachable.
|
|
124
|
+
rememberKnownVisitor(visitor)
|
|
125
|
+
|
|
126
|
+
const pixelId = window.__1click_fb_pixel_id
|
|
127
|
+
const fbq = window.fbq
|
|
128
|
+
if (!pixelId || !fbq) return
|
|
129
|
+
|
|
130
|
+
const matching: Record<string, string> = {}
|
|
131
|
+
|
|
132
|
+
if (visitor.email) {
|
|
133
|
+
matching.em = await sha256Hex(normaliseEmailForHash(visitor.email))
|
|
134
|
+
}
|
|
135
|
+
if (visitor.phone) {
|
|
136
|
+
matching.ph = await sha256Hex(normalisePhoneForHash(visitor.phone))
|
|
137
|
+
}
|
|
138
|
+
if (visitor.firstName) {
|
|
139
|
+
matching.fn = await sha256Hex(visitor.firstName.trim().toLowerCase())
|
|
140
|
+
}
|
|
141
|
+
if (visitor.lastName) {
|
|
142
|
+
matching.ln = await sha256Hex(visitor.lastName.trim().toLowerCase())
|
|
143
|
+
}
|
|
144
|
+
if (visitor.city) {
|
|
145
|
+
matching.ct = await sha256Hex(
|
|
146
|
+
visitor.city.trim().toLowerCase().replace(/\s+/g, "")
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
if (visitor.state) {
|
|
150
|
+
matching.st = await sha256Hex(
|
|
151
|
+
visitor.state.trim().toLowerCase().replace(/\s+/g, "")
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
if (visitor.postalCode) {
|
|
155
|
+
matching.zp = await sha256Hex(
|
|
156
|
+
visitor.postalCode.trim().toLowerCase().replace(/\s+/g, "")
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
if (visitor.country) {
|
|
160
|
+
matching.country = await sha256Hex(visitor.country.trim().toLowerCase())
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (Object.keys(matching).length === 0) return
|
|
164
|
+
|
|
165
|
+
fbq("init", pixelId, matching)
|
|
166
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Typed wrappers around the global `window.rybbit.event()` from the
|
|
5
|
+
* Rybbit tracker script (`<Rybbit siteId={...} />`).
|
|
6
|
+
*
|
|
7
|
+
* Rybbit only supports STRING and NUMBER property values (no arrays,
|
|
8
|
+
* no objects, no booleans) and caps the property payload at 2KB
|
|
9
|
+
* (https://rybbit.com/docs/track-events). The helpers below stick to
|
|
10
|
+
* that contract — multi-item events flatten line ids into a
|
|
11
|
+
* comma-separated string, which is enough for aggregation in Rybbit's
|
|
12
|
+
* Custom Events view. Pixel/GA4 retain the rich item-level breakdown.
|
|
13
|
+
*
|
|
14
|
+
* Script-load race resilience (added v2.2.6):
|
|
15
|
+
*
|
|
16
|
+
* The Rybbit tracker is loaded by `<Rybbit>` via Next.js Script with
|
|
17
|
+
* strategy="afterInteractive" — `window.rybbit` is undefined for the
|
|
18
|
+
* first 50-800ms after page load while the script downloads. During
|
|
19
|
+
* that window, useEffect-mounted tracking calls would previously
|
|
20
|
+
* silently no-op (`safeRybbit()` returned null and the caller had no
|
|
21
|
+
* way to retry).
|
|
22
|
+
*
|
|
23
|
+
* This file now maintains a small per-page queue: when fireEvent is
|
|
24
|
+
* called before `window.rybbit` exists, the call is pushed onto a
|
|
25
|
+
* module-level queue and a single drainer is scheduled that polls
|
|
26
|
+
* for `window.rybbit` and replays everything once the script loads.
|
|
27
|
+
*
|
|
28
|
+
* The drainer caps total wait at 10 seconds — beyond that the script
|
|
29
|
+
* is presumed permanently blocked (ad-blocker, CSP rejection, network
|
|
30
|
+
* failure) and the queue is discarded so we don't leak memory.
|
|
31
|
+
*
|
|
32
|
+
* This is the canonical pattern for async tag loaders — same as
|
|
33
|
+
* Google's `window.dataLayer` (queue → consumed by gtag.js on load)
|
|
34
|
+
* and Segment's `analytics.js` snippet (method-stub queue → flushed
|
|
35
|
+
* on script ready).
|
|
36
|
+
*
|
|
37
|
+
* IMPORTANT: server-side firing (e.g. Rybbit Purchase from the
|
|
38
|
+
* `order.placed` subscriber in medusa-mindpages) remains the truth
|
|
39
|
+
* source for high-value conversions. This queue improves browser-side
|
|
40
|
+
* funnel events (view_item / add_to_cart / begin_checkout) where the
|
|
41
|
+
* server doesn't fire. Purchase still has the server flag guard.
|
|
42
|
+
*
|
|
43
|
+
* All helpers stay no-op-safe when called outside a browser (SSR) and
|
|
44
|
+
* when Rybbit is admin-disabled (the `<Rybbit>` component renders
|
|
45
|
+
* nothing for missing siteId, so `window.rybbit` simply never appears
|
|
46
|
+
* and the 10s drain timeout discards the queue silently).
|
|
47
|
+
*
|
|
48
|
+
* Event names match GA4 / industry vocabulary so the same dashboard
|
|
49
|
+
* names work across analytics tools:
|
|
50
|
+
* view_item · add_to_cart · begin_checkout · purchase
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
export type RybbitEventProps = Record<string, string | number>
|
|
54
|
+
|
|
55
|
+
type RybbitFn = {
|
|
56
|
+
event: (eventName: string, properties?: RybbitEventProps) => void
|
|
57
|
+
pageview?: () => void
|
|
58
|
+
identify?: (userId: string, traits?: RybbitEventProps) => void
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
declare global {
|
|
62
|
+
interface Window {
|
|
63
|
+
rybbit?: RybbitFn
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function safeRybbit(): RybbitFn | null {
|
|
68
|
+
if (typeof window === "undefined") return null
|
|
69
|
+
return window.rybbit ?? null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Truncate a comma-joined list of ids so the resulting properties
|
|
74
|
+
* payload stays under Rybbit's 2KB limit. Other props are short, so
|
|
75
|
+
* a 1500-char cap on the joined string leaves comfortable headroom.
|
|
76
|
+
*/
|
|
77
|
+
function joinIds(ids: string[]): string {
|
|
78
|
+
const joined = ids.join(",")
|
|
79
|
+
return joined.length <= 1500 ? joined : joined.slice(0, 1500)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Script-load race queue ────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
type PendingEvent = { name: string; props?: RybbitEventProps }
|
|
85
|
+
|
|
86
|
+
/** Calls made before `window.rybbit` exists wait here. Module-level so
|
|
87
|
+
* every fireEvent() across the page shares the same queue. */
|
|
88
|
+
const pendingQueue: PendingEvent[] = []
|
|
89
|
+
|
|
90
|
+
/** Single in-flight drainer guard — prevents N parallel pollers if N
|
|
91
|
+
* events queue during the same script-load window. */
|
|
92
|
+
let drainScheduled = false
|
|
93
|
+
|
|
94
|
+
const DRAIN_FIRST_DELAY_MS = 50
|
|
95
|
+
const DRAIN_POLL_INTERVAL_MS = 100
|
|
96
|
+
const DRAIN_MAX_WAIT_MS = 10_000
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Fire an event immediately if Rybbit is ready, or queue + schedule a
|
|
100
|
+
* drain otherwise. All public helpers below go through this so the
|
|
101
|
+
* queue covers every event type uniformly — no caller has to know
|
|
102
|
+
* whether the script has loaded yet.
|
|
103
|
+
*/
|
|
104
|
+
function fireEvent(name: string, props?: RybbitEventProps): void {
|
|
105
|
+
const r = safeRybbit()
|
|
106
|
+
if (r) {
|
|
107
|
+
try {
|
|
108
|
+
r.event(name, props)
|
|
109
|
+
} catch {
|
|
110
|
+
// Defensive — Rybbit's event() throwing would be unusual but
|
|
111
|
+
// tracking failures must never break the user flow.
|
|
112
|
+
}
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
if (typeof window === "undefined") return // SSR — drop silently
|
|
116
|
+
pendingQueue.push({ name, props })
|
|
117
|
+
scheduleDrain()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Poll for `window.rybbit` and drain the queue when it appears.
|
|
122
|
+
* Bounded — gives up after DRAIN_MAX_WAIT_MS so a permanently-blocked
|
|
123
|
+
* Rybbit (ad-blocker, CSP, offline) doesn't grow the queue forever.
|
|
124
|
+
*/
|
|
125
|
+
function scheduleDrain(): void {
|
|
126
|
+
if (drainScheduled) return
|
|
127
|
+
if (typeof window === "undefined") return
|
|
128
|
+
drainScheduled = true
|
|
129
|
+
|
|
130
|
+
const startedAt = Date.now()
|
|
131
|
+
const tick = (): void => {
|
|
132
|
+
const r = safeRybbit()
|
|
133
|
+
if (r) {
|
|
134
|
+
const drained = pendingQueue.splice(0, pendingQueue.length)
|
|
135
|
+
for (const ev of drained) {
|
|
136
|
+
try {
|
|
137
|
+
r.event(ev.name, ev.props)
|
|
138
|
+
} catch {
|
|
139
|
+
// Don't let one bad event stop the rest from draining
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
drainScheduled = false
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
if (Date.now() - startedAt > DRAIN_MAX_WAIT_MS) {
|
|
146
|
+
// Rybbit will not load (ad-blocker, CSP, network failure, admin
|
|
147
|
+
// disabled). Discard the queue so we don't leak memory if the
|
|
148
|
+
// user navigates around the SPA with the script permanently dead.
|
|
149
|
+
pendingQueue.length = 0
|
|
150
|
+
drainScheduled = false
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
setTimeout(tick, DRAIN_POLL_INTERVAL_MS)
|
|
154
|
+
}
|
|
155
|
+
setTimeout(tick, DRAIN_FIRST_DELAY_MS)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── Public event helpers ──────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
export type RybbitViewItemData = {
|
|
161
|
+
item_id: string
|
|
162
|
+
item_name: string
|
|
163
|
+
currency: string
|
|
164
|
+
value: number
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export type RybbitAddToCartData = {
|
|
168
|
+
item_id: string
|
|
169
|
+
item_name: string
|
|
170
|
+
quantity: number
|
|
171
|
+
currency: string
|
|
172
|
+
value: number
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export type RybbitBeginCheckoutData = {
|
|
176
|
+
item_ids: string[]
|
|
177
|
+
num_items: number
|
|
178
|
+
currency: string
|
|
179
|
+
value: number
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export type RybbitPurchaseData = {
|
|
183
|
+
transaction_id: string
|
|
184
|
+
item_ids: string[]
|
|
185
|
+
num_items: number
|
|
186
|
+
currency: string
|
|
187
|
+
value: number
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function trackRybbitViewItem(data: RybbitViewItemData): void {
|
|
191
|
+
fireEvent("view_item", {
|
|
192
|
+
item_id: data.item_id,
|
|
193
|
+
item_name: data.item_name,
|
|
194
|
+
currency: data.currency,
|
|
195
|
+
value: data.value,
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function trackRybbitAddToCart(data: RybbitAddToCartData): void {
|
|
200
|
+
fireEvent("add_to_cart", {
|
|
201
|
+
item_id: data.item_id,
|
|
202
|
+
item_name: data.item_name,
|
|
203
|
+
quantity: data.quantity,
|
|
204
|
+
currency: data.currency,
|
|
205
|
+
value: data.value,
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function trackRybbitBeginCheckout(
|
|
210
|
+
data: RybbitBeginCheckoutData
|
|
211
|
+
): void {
|
|
212
|
+
fireEvent("begin_checkout", {
|
|
213
|
+
item_ids: joinIds(data.item_ids),
|
|
214
|
+
num_items: data.num_items,
|
|
215
|
+
currency: data.currency,
|
|
216
|
+
value: data.value,
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Purchase event — fired client-side on the order-confirmed page.
|
|
222
|
+
*
|
|
223
|
+
* Browser-side Purchase is now redundant when the server-side fire
|
|
224
|
+
* has succeeded (medusa-mindpages tracking-events.ts subscriber sets
|
|
225
|
+
* `order.metadata.rybbit_purchase_fired`). The storefront's
|
|
226
|
+
* `<TrackPurchase>` reads that flag and skips this call when true.
|
|
227
|
+
* Server-side is the truth source; this browser-side call is the
|
|
228
|
+
* realtime safety net for the very first /confirmed visit when the
|
|
229
|
+
* subscriber hasn't finished yet.
|
|
230
|
+
*
|
|
231
|
+
* Even on first-visit, the queue above means a script-load race no
|
|
232
|
+
* longer drops the event — it'll fire as soon as Rybbit loads.
|
|
233
|
+
*/
|
|
234
|
+
export function trackRybbitPurchase(data: RybbitPurchaseData): void {
|
|
235
|
+
fireEvent("purchase", {
|
|
236
|
+
transaction_id: data.transaction_id,
|
|
237
|
+
item_ids: joinIds(data.item_ids),
|
|
238
|
+
num_items: data.num_items,
|
|
239
|
+
currency: data.currency,
|
|
240
|
+
value: data.value,
|
|
241
|
+
})
|
|
242
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import Script from "next/script"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Rybbit — privacy-friendly self-hosted analytics tracker injector.
|
|
7
|
+
*
|
|
8
|
+
* Loads `<baseUrl>/api/script.js` via Next.js `<Script
|
|
9
|
+
* strategy="afterInteractive">`. The script auto-fires pageviews on
|
|
10
|
+
* initial load and on SPA route changes, so storefronts don't need
|
|
11
|
+
* to wire route-change effects.
|
|
12
|
+
*
|
|
13
|
+
* `siteId` and `baseUrl` are platform-managed — Rybbit is the platform's
|
|
14
|
+
* self-hosted, cookieless analytics, provisioned per store from platform
|
|
15
|
+
* env, and is deliberately ABSENT from the barter store-config `tracking`
|
|
16
|
+
* block (`src/lib/tracking/store-config-block.ts`). The host app passes
|
|
17
|
+
* the values as props directly (e.g. from its own env). The merchant
|
|
18
|
+
* never sees or configures them. Rybbit needs no consent gate.
|
|
19
|
+
*
|
|
20
|
+
* Renders nothing when `siteId` is falsy — every consuming layout can
|
|
21
|
+
* call this unconditionally.
|
|
22
|
+
*/
|
|
23
|
+
export function Rybbit({
|
|
24
|
+
siteId,
|
|
25
|
+
baseUrl = "https://analytics.fam.social",
|
|
26
|
+
}: {
|
|
27
|
+
siteId?: string
|
|
28
|
+
baseUrl?: string
|
|
29
|
+
}) {
|
|
30
|
+
if (!siteId) return null
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<Script
|
|
34
|
+
id="rybbit-tracker"
|
|
35
|
+
src={`${baseUrl}/api/script.js`}
|
|
36
|
+
data-site-id={siteId}
|
|
37
|
+
strategy="afterInteractive"
|
|
38
|
+
/>
|
|
39
|
+
)
|
|
40
|
+
}
|