diff --git a/.eslintrc.js b/.eslintrc.cjs
similarity index 100%
rename from .eslintrc.js
rename to .eslintrc.cjs
diff --git a/.prettierignore b/.prettierignore
index 0252f8e..b8366bf 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,5 +1,6 @@
+node_modules
.vscode
/.cache
/build
/dist
-/public/build
+/public/build
\ No newline at end of file
diff --git a/app/components/Analytics/Analytics.tsx b/app/components/Analytics/Analytics.tsx
index 81a6009..345a23b 100644
--- a/app/components/Analytics/Analytics.tsx
+++ b/app/components/Analytics/Analytics.tsx
@@ -13,6 +13,7 @@ import {AnalyticsEvent} from './constants';
import {ElevarEvents} from './ElevarEvents';
import {FueledEvents} from './FueledEvents';
import {GA4Events} from './GA4Events';
+import {KlaviyoEvents} from './KlaviyoEvents';
import {MetaPixelEvents} from './MetaPixelEvents';
import {TikTokPixelEvents} from './TikTokPixelEvents';
@@ -31,6 +32,7 @@ export const Analytics = memo(() => {
const enabledFueled = false;
const enabledElevar = !!ENV.PUBLIC_ELEVAR_SIGNING_KEY;
const enabledGA4 = !!ENV.PUBLIC_GA4_TAG_ID;
+ const enabledKlaviyo = !!ENV.PUBLIC_KLAVIYO_API_KEY;
const enabledMetaPixel = !!ENV.PUBLIC_META_PIXEL_ID;
const enabledTikTokPixel = !!ENV.PUBLIC_TIKTOK_PIXEL_ID;
@@ -66,6 +68,16 @@ export const Analytics = memo(() => {
/>
)}
+ {enabledKlaviyo && (
+
+ )}
+
{enabledMetaPixel && (
= {};
const config = (
await import(
+ /* @vite-ignore */
`https://shopify-gtm-suite.getelevar.com/configs/${elevarSigningKey}/config.js`
)
).default;
@@ -73,7 +74,10 @@ export function ElevarEvents({
: config.script_src_custom_pages;
if (scriptUrl) {
- const {handler} = await import(scriptUrl);
+ const {handler} = await import(
+ /* @vite-ignore */
+ scriptUrl
+ );
await handler(config, settings);
}
setScriptLoaded(true);
diff --git a/app/components/Analytics/ElevarEvents/events.ts b/app/components/Analytics/ElevarEvents/events.ts
index 8121c53..ac76f5a 100644
--- a/app/components/Analytics/ElevarEvents/events.ts
+++ b/app/components/Analytics/ElevarEvents/events.ts
@@ -331,12 +331,16 @@ const viewCartEvent = ({
window.location.pathname) ||
(previousPath?.startsWith('/collections') && previousPath) ||
'';
+ const cartCurrencyCode = cart?.cost?.totalAmount?.currencyCode;
const event = {
event: 'dl_view_cart',
user_properties: generateUserProperties({customer}),
cart_total: cart?.cost?.totalAmount?.amount || '0.0',
ecommerce: {
- currencyCode: cart?.cost?.totalAmount?.currencyCode || shop?.currency,
+ currencyCode:
+ cartCurrencyCode && cartCurrencyCode !== 'XXX'
+ ? cartCurrencyCode
+ : shop?.currency,
actionField: {list: 'Shopping Cart'},
impressions:
flattenConnection(cart?.lines)?.slice(0, 7).map(mapCartLine(list)) ||
diff --git a/app/components/Analytics/FueledEvents/events.ts b/app/components/Analytics/FueledEvents/events.ts
index bd3057e..fbc3d85 100644
--- a/app/components/Analytics/FueledEvents/events.ts
+++ b/app/components/Analytics/FueledEvents/events.ts
@@ -328,11 +328,15 @@ const viewCartEvent = ({
window.location.pathname) ||
(previousPath?.startsWith('/collections') && previousPath) ||
'';
+ const cartCurrencyCode = cart?.cost?.totalAmount?.currencyCode;
const event = {
event: 'dl_view_cart',
user_properties: generateUserProperties({customer}),
ecommerce: {
- currency_code: cart?.cost?.totalAmount?.currencyCode || shop?.currency,
+ currencyCode:
+ cartCurrencyCode && cartCurrencyCode !== 'XXX'
+ ? cartCurrencyCode
+ : shop?.currency,
actionField: {list: 'Shopping Cart'},
products:
flattenConnection(cart?.lines)?.slice(0, 12).map(mapCartLine(list)) ||
diff --git a/app/components/Analytics/GA4Events/events.ts b/app/components/Analytics/GA4Events/events.ts
index 37e7b46..7f9bfb8 100644
--- a/app/components/Analytics/GA4Events/events.ts
+++ b/app/components/Analytics/GA4Events/events.ts
@@ -122,11 +122,15 @@ const customerEvent = ({
(windowPathname.startsWith('/collections') && windowPathname) ||
(previousPath?.startsWith('/collections') && previousPath) ||
'';
+ const cartCurrencyCode = cart?.cost?.totalAmount?.currencyCode;
const event = {
event: 'user_data',
user_properties: generateUserProperties({customer}),
ecommerce: {
- currency_code: cart?.cost?.totalAmount?.currencyCode || shop?.currency,
+ currencyCode:
+ cartCurrencyCode && cartCurrencyCode !== 'XXX'
+ ? cartCurrencyCode
+ : shop?.currency,
cart_contents: {
products:
flattenConnection(cart?.lines)?.map(mapCartLine(list)) || [],
diff --git a/app/components/Analytics/KlaviyoEvents/KlaviyoEvents.tsx b/app/components/Analytics/KlaviyoEvents/KlaviyoEvents.tsx
new file mode 100644
index 0000000..38d53df
--- /dev/null
+++ b/app/components/Analytics/KlaviyoEvents/KlaviyoEvents.tsx
@@ -0,0 +1,71 @@
+import {useEffect} from 'react';
+
+import {useLoadScript} from '~/hooks';
+
+import {AnalyticsEvent} from '../constants';
+
+import {
+ ANALYTICS_NAME,
+ addToCartEvent,
+ customerSubscribeEvent,
+ viewProductEvent,
+} from './events';
+
+type Data = Record;
+
+export function KlaviyoEvents({
+ register,
+ subscribe,
+ customer,
+ debug = false,
+ klaviyoApiKey,
+}: {
+ register: (key: string) => {ready: () => void};
+ subscribe: (arg0: any, arg1: any) => void;
+ customer?: Record | null;
+ debug?: boolean;
+ klaviyoApiKey: string;
+}) {
+ let ready: (() => void) | undefined = undefined;
+ if (register) {
+ ready = register(ANALYTICS_NAME).ready;
+ }
+
+ const scriptStatus = useLoadScript(
+ {
+ id: 'klaviyo-script',
+ src: `https://static.klaviyo.com/onsite/js/klaviyo.js?company_id=${klaviyoApiKey}`,
+ },
+ 'body',
+ !!klaviyoApiKey,
+ );
+
+ useEffect(() => {
+ if (!klaviyoApiKey) {
+ console.error(
+ `${ANALYTICS_NAME}: ❌ error: \`klaviyoApiKey\` must be passed in.`,
+ );
+ }
+ if (scriptStatus !== 'done') return;
+ if (!ready || !subscribe) {
+ console.error(
+ `${ANALYTICS_NAME}: ❌ error: \`register\` and \`subscribe\` must be passed in from Hydrogen's useAnalytics hook.`,
+ );
+ return;
+ }
+ /* register analytics events only until script is ready */
+ subscribe(AnalyticsEvent.PRODUCT_VIEWED, (data: Data) => {
+ viewProductEvent({...data, customer, debug});
+ });
+ subscribe(AnalyticsEvent.PRODUCT_ADD_TO_CART, (data: Data) => {
+ addToCartEvent({...data, customer, debug});
+ });
+ subscribe(AnalyticsEvent.CUSTOMER_SUBSCRIBED, (data: Data) => {
+ customerSubscribeEvent({...data, debug});
+ });
+ ready();
+ if (debug) console.log(`${ANALYTICS_NAME}: 🔄 subscriptions are ready.`);
+ }, [customer?.id, debug, scriptStatus]);
+
+ return null;
+}
diff --git a/app/components/Analytics/KlaviyoEvents/events.ts b/app/components/Analytics/KlaviyoEvents/events.ts
new file mode 100644
index 0000000..760ee30
--- /dev/null
+++ b/app/components/Analytics/KlaviyoEvents/events.ts
@@ -0,0 +1,158 @@
+import {AnalyticsEvent} from '../constants';
+
+import {
+ pathWithoutLocalePrefix,
+ mapCartLine,
+ mapProductPageVariant,
+} from './utils';
+
+export const ANALYTICS_NAME = 'KlaviyoEvents';
+
+const logSubscription = ({
+ data,
+ analyticsEvent,
+}: {
+ data: Record;
+ analyticsEvent: string;
+}) => {
+ console.log(
+ `${ANALYTICS_NAME}: 📥 subscribed to analytics for \`${analyticsEvent}\`:`,
+ data,
+ );
+};
+
+const logError = ({
+ analyticsEvent,
+ message = 'Unknown error',
+}: {
+ analyticsEvent: string;
+ message?: string | unknown;
+}) => {
+ console.error(
+ `${ANALYTICS_NAME}: ❌ error from \`${analyticsEvent}\`: ${message}`,
+ );
+};
+
+export const identifyCustomer = async ({
+ email,
+ debug,
+}: {
+ email: string;
+ debug?: boolean;
+}) => {
+ if (!window.klaviyo) return;
+ await window.klaviyo.identify({email});
+ if (debug)
+ console.log(
+ `${ANALYTICS_NAME}: 🚀 event emitted for \`identify customer\`:`,
+ {email},
+ );
+};
+
+export const emitEvent = async ({
+ email,
+ event,
+ properties,
+ debug,
+}: {
+ email: string;
+ event: string;
+ properties: Record | null;
+ debug?: boolean;
+}) => {
+ if (!window.klaviyo) return;
+ const klaviyoIdentified = await window.klaviyo.isIdentified();
+ if (klaviyoIdentified) {
+ window.klaviyo.push(['track', event, properties]);
+ } else if (email) {
+ await window.klaviyo.identify({email});
+ window.klaviyo.push(['track', event, properties]);
+ }
+ if (debug)
+ console.log(`${ANALYTICS_NAME}: 🚀 event emitted for \`${event}\`:`, {
+ event,
+ email,
+ properties,
+ });
+};
+
+const viewProductEvent = ({
+ debug,
+ ...data
+}: Record & {debug?: boolean}) => {
+ const analyticsEvent = AnalyticsEvent.PRODUCT_VIEWED;
+ try {
+ if (debug) logSubscription({data, analyticsEvent});
+
+ const {customer} = data;
+ const {selectedVariant} = data.customData;
+ const previousPath = sessionStorage.getItem('PREVIOUS_PATH');
+ const list = previousPath?.startsWith('/collections') ? previousPath : '';
+ emitEvent({
+ email: customer?.email,
+ event: 'Viewed Product',
+ properties: mapProductPageVariant(list)(selectedVariant),
+ debug,
+ });
+ } catch (error) {
+ logError({
+ analyticsEvent,
+ message: error instanceof Error ? error.message : error,
+ });
+ }
+};
+
+const addToCartEvent = ({
+ debug,
+ ...data
+}: Record & {debug?: boolean}) => {
+ const analyticsEvent = AnalyticsEvent.PRODUCT_ADD_TO_CART;
+ try {
+ if (debug) logSubscription({data, analyticsEvent});
+
+ const {currentLine, customer} = data;
+ if (!currentLine)
+ throw new Error('`cart` and/or `currentLine` parameters are missing.');
+
+ const previousPath = sessionStorage.getItem('PREVIOUS_PATH');
+ const windowPathname = pathWithoutLocalePrefix(window.location.pathname);
+ const list =
+ (windowPathname.startsWith('/collections') && windowPathname) ||
+ (previousPath?.startsWith('/collections') && previousPath) ||
+ '';
+ const productProps = mapCartLine(list)(currentLine);
+ emitEvent({
+ email: customer?.email,
+ event: 'Added to Cart',
+ properties: productProps,
+ debug,
+ });
+ } catch (error) {
+ logError({
+ analyticsEvent,
+ message: error instanceof Error ? error.message : error,
+ });
+ }
+};
+
+const customerSubscribeEvent = ({
+ debug,
+ ...data
+}: Record & {debug?: boolean}) => {
+ const analyticsEvent = AnalyticsEvent.CUSTOMER_SUBSCRIBED;
+ try {
+ if (debug) logSubscription({data, analyticsEvent});
+
+ const {email} = data;
+ if (!email) throw new Error('`email` parameter is missing.');
+
+ identifyCustomer({email, debug});
+ } catch (error) {
+ logError({
+ analyticsEvent,
+ message: error instanceof Error ? error.message : error,
+ });
+ }
+};
+
+export {addToCartEvent, customerSubscribeEvent, viewProductEvent};
diff --git a/app/components/Analytics/KlaviyoEvents/index.ts b/app/components/Analytics/KlaviyoEvents/index.ts
new file mode 100644
index 0000000..d0b942d
--- /dev/null
+++ b/app/components/Analytics/KlaviyoEvents/index.ts
@@ -0,0 +1 @@
+export {KlaviyoEvents} from './KlaviyoEvents';
diff --git a/app/components/Analytics/KlaviyoEvents/utils.ts b/app/components/Analytics/KlaviyoEvents/utils.ts
new file mode 100644
index 0000000..9202727
--- /dev/null
+++ b/app/components/Analytics/KlaviyoEvents/utils.ts
@@ -0,0 +1,87 @@
+import {ANALYTICS_NAME} from './events';
+
+const STOREFRONT_NAME =
+ (typeof document !== 'undefined' && window.ENV?.SITE_TITLE) || 'Storefront';
+
+export const mapProductPageVariant =
+ (list = '') =>
+ (variant: Record) => {
+ try {
+ if (!variant) return null;
+
+ const params = new URLSearchParams('');
+ variant.selectedOptions?.forEach(({name, value}) => {
+ params.set(name, value);
+ });
+
+ return {
+ id: variant.sku || '',
+ name: variant.product?.title || '',
+ brand: variant.product?.vendor || STOREFRONT_NAME,
+ category: variant.product?.productType || 'Uncategorized',
+ variant: variant.title || '',
+ price: `${variant.price?.amount || ''}`,
+ list,
+ product_id: variant.product?.id?.split('/').pop() || '',
+ variant_id: variant.id?.split('/').pop() || '',
+ compare_at_price: `${variant.compareAtPrice?.amount || 'undefined'}`,
+ image: variant.image?.url || '',
+ url: `/products/${variant.product?.handle}?${params}`,
+ };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : error;
+ console.error(
+ `${ANALYTICS_NAME}: ❌ mapProductPageVariant error:`,
+ message,
+ );
+ console.error(
+ `${ANALYTICS_NAME}: ❌ mapProductPageVariant variant:`,
+ variant,
+ );
+ return null;
+ }
+ };
+
+export const mapCartLine =
+ (list = '') =>
+ (line: Record & {index?: number}, index = 0) => {
+ try {
+ const {quantity, merchandise} = {...line};
+ if (!merchandise) return null;
+
+ return {
+ id: merchandise.sku || '',
+ name: merchandise.product?.title || '',
+ brand: merchandise.product?.vendor || STOREFRONT_NAME,
+ category: merchandise.product?.productType || 'Uncategorized',
+ variant: merchandise.title || '',
+ price: merchandise.price?.amount || '',
+ quantity: `${quantity || ''}`,
+ list,
+ product_id: merchandise.product?.id?.split('/').pop() || '',
+ variant_id: merchandise.id?.split('/').pop() || '',
+ compare_at_price: merchandise.compareAtPrice?.amount || 'undefined',
+ image: merchandise.image?.url || '',
+ position: (line.index || index) + 1,
+ url: `/products/${merchandise.product?.handle}`,
+ };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : error;
+ console.error(`${ANALYTICS_NAME}: ❌ mapCartLine error:`, message);
+ console.error(`${ANALYTICS_NAME}: ❌ mapCartLine line:`, line);
+ return null;
+ }
+ };
+
+export const pathWithoutLocalePrefix = (pathname = '', pathPrefix = '') => {
+ if (!pathname) return pathname;
+ // if prefix is provided, remove it from string directly
+ if (pathPrefix) {
+ return pathname.replace(
+ pathPrefix.startsWith('/') ? pathPrefix : `/${pathPrefix}`,
+ '',
+ );
+ }
+ // otherwise remove locale based on `/aa-bb` pattern
+ return pathname.replace(/^\/[a-zA-Z]{2}-[a-zA-Z]{2}/, '');
+};
diff --git a/app/components/Cart/CartDiscounts.tsx b/app/components/Cart/CartDiscounts.tsx
index 971ed16..6334f53 100644
--- a/app/components/Cart/CartDiscounts.tsx
+++ b/app/components/Cart/CartDiscounts.tsx
@@ -26,7 +26,7 @@ export const CartDiscounts = memo(() => {
const handleUpdateCode = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
- const code = e.currentTarget.code.value;
+ const code = e.currentTarget?.code.value;
if (!code) return;
if (codes.map((c) => c.toLowerCase()).includes(code.toLowerCase())) {
setMessage('Discount code is already applied.');
diff --git a/app/components/Document/Document.tsx b/app/components/Document/Document.tsx
index 2477304..15d9bae 100644
--- a/app/components/Document/Document.tsx
+++ b/app/components/Document/Document.tsx
@@ -1,12 +1,6 @@
import {useMemo} from 'react';
import type {ReactNode} from 'react';
-import {
- Links,
- LiveReload,
- Meta,
- Scripts,
- ScrollRestoration,
-} from '@remix-run/react';
+import {Links, Meta, Scripts, ScrollRestoration} from '@remix-run/react';
import {CartProvider, ShopifyProvider} from '@shopify/hydrogen-react';
import {PreviewProvider} from '@pack/react';
@@ -72,7 +66,7 @@ export function Document({children, title}: DocumentProps) {
@@ -98,7 +92,6 @@ export function Document({children, title}: DocumentProps) {
}}
/>
-