Skip to content

Commit

Permalink
Merge pull request #548 from Pinelab-studio/feat/checkout-started
Browse files Browse the repository at this point in the history
Klaviyo: included checkout started mutation and emitting event to Klaviyo
  • Loading branch information
martijnvdbrug authored Dec 13, 2024
2 parents 621066e + be5804d commit a87887d
Show file tree
Hide file tree
Showing 12 changed files with 181 additions and 11 deletions.
4 changes: 4 additions & 0 deletions packages/vendure-plugin-klaviyo/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# 1.4.0 (2024-12-13)

- Include `klaviyoCheckoutStarted` mutation to be able to use abandoned cart email flows

# 1.3.1 (2024-12-13)

- Don't log push jobs and log errors for channels without api keys
Expand Down
4 changes: 4 additions & 0 deletions packages/vendure-plugin-klaviyo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,7 @@ Don't forget to exclude the default order placed handler if you do!
eventHandlers: [customOrderPlacedHandler],
}),
```

## Abandoned cart emails

This plugin includes a mutation `klaviyoCheckoutStarted`, which can be called from your storefront. When called, and an active order is present, it sends a custom event `Checkout Started` to Klaviyo, including basic order and profile data. This event can be used to set up abandoned cart email flows in Klaviyo.
2 changes: 1 addition & 1 deletion packages/vendure-plugin-klaviyo/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pinelab/vendure-plugin-klaviyo",
"version": "1.3.2",
"version": "1.4.0",
"description": "An extensible plugin for sending placed orders to the Klaviyo marketing platform.",
"author": "Martijn van de Brug <[email protected]>",
"homepage": "https://pinelab-plugins.com/",
Expand Down
11 changes: 11 additions & 0 deletions packages/vendure-plugin-klaviyo/src/api/api-extensions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { gql } from 'graphql-tag';

export const shopApiExtensions = gql`
extend type Mutation {
"""
This mutation indicates that a customer has started the checkout process.
The frontend should call this mutation. It will make the Klaviyo plugin emit a CheckoutStartedEvent.
"""
klaviyoCheckoutStarted: Boolean!
}
`;
29 changes: 29 additions & 0 deletions packages/vendure-plugin-klaviyo/src/api/klaviyo-shop-resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Mutation, Resolver } from '@nestjs/graphql';
import {
ActiveOrderService,
Ctx,
EventBus,
RequestContext,
} from '@vendure/core';
import { CheckoutStartedEvent } from '../service/checkout-started-event';

@Resolver()
export class KlaviyoShopResolver {
constructor(
private readonly activeOrderService: ActiveOrderService,
private readonly eventBus: EventBus
) {}

@Mutation()
async klaviyoCheckoutStarted(@Ctx() ctx: RequestContext): Promise<boolean> {
const activeOrder = await this.activeOrderService.getActiveOrder(
ctx,
undefined
);
if (activeOrder) {
await this.eventBus.publish(new CheckoutStartedEvent(ctx, activeOrder));
return true;
}
return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import {
KlaviyoEventHandler,
KlaviyoGenericEvent,
} from '../event-handler/klaviyo-event-handler';
import { EntityHydrator, Logger } from '@vendure/core';
import { CheckoutStartedEvent } from '../service/checkout-started-event';
import { loggerCtx } from '../constants';

/**
* Sends an event to Klavyio when a checkout has started and the order has a customer email address.
*/
export const startedCheckoutHandler: KlaviyoEventHandler<CheckoutStartedEvent> =
{
vendureEvent: CheckoutStartedEvent,
mapToKlaviyoEvent: async ({ ctx, order }, injector) => {
await injector.get(EntityHydrator).hydrate(ctx, order, {
relations: ['customer', 'lines.productVariant'],
});
if (!order.customer?.emailAddress) {
return false;
}
const address = order.billingAddress?.streetLine1
? order.billingAddress
: order.shippingAddress;
const event: KlaviyoGenericEvent = {
eventName: 'Checkout Started',
uniqueId: order.code,
customProperties: {
orderCode: order.code,
orderItems: order.lines.map((line) => ({
productName: line.productVariant.name,
quantity: line.quantity,
})),
},
profile: {
emailAddress: order.customer.emailAddress,
externalId: order.customer.id.toString(),
firstName: order.customer.firstName,
lastName: order.customer.lastName,
phoneNumber: order.customer.phoneNumber,
address: {
address1: address?.streetLine1,
address2: address?.streetLine2,
city: address?.city,
postalCode: address?.postalCode,
countryCode: address.countryCode,
},
},
};
Logger.info(
`Sent '${event.eventName}' to Klaviyo for order ${order.code}`,
loggerCtx
);
return event;
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export interface KlaviyoOrderItem {
excludeFromOrderedProductEvent?: boolean;
}

type CustomProperties = Record<string, string | string[] | number | boolean>;
type CustomProperties = Record<string, unknown>;

/**
* Use this interface to define custom events for Klaviyo.
Expand Down
4 changes: 3 additions & 1 deletion packages/vendure-plugin-klaviyo/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export * from './klaviyo.plugin';
export * from './event-handler/klaviyo-event-handler';
export * from './klaviyo.service';
export * from './service/klaviyo.service';
export * from './event-handler/default-order-placed-event-handler';
export * from './event-handler/checkout-started-event-handler';
export * from './util/to-klaviyo-money';
export * from './service/checkout-started-event';
14 changes: 12 additions & 2 deletions packages/vendure-plugin-klaviyo/src/klaviyo.plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import {
KlaviyoEventHandler,
KlaviyoOrderPlacedEventHandler,
} from './event-handler/klaviyo-event-handler';
import { KlaviyoService } from './klaviyo.service';
import { KlaviyoService } from './service/klaviyo.service';
import { KlaviyoShopResolver } from './api/klaviyo-shop-resolver';
import { shopApiExtensions } from './api/api-extensions';
import { startedCheckoutHandler } from './event-handler/checkout-started-event-handler';

interface KlaviyoPluginOptionsInput {
/**
Expand All @@ -36,6 +39,10 @@ export type KlaviyoPluginOptions = Required<KlaviyoPluginOptionsInput>;
},
KlaviyoService,
],
shopApiExtensions: {
resolvers: [KlaviyoShopResolver],
schema: shopApiExtensions,
},
compatibility: '>=2.2.0',
})
export class KlaviyoPlugin {
Expand All @@ -44,7 +51,10 @@ export class KlaviyoPlugin {
static init(options: KlaviyoPluginOptionsInput): typeof KlaviyoPlugin {
this.options = {
...options,
eventHandlers: options.eventHandlers ?? [defaultOrderPlacedEventHandler],
eventHandlers: options.eventHandlers ?? [
defaultOrderPlacedEventHandler,
startedCheckoutHandler,
],
};
return KlaviyoPlugin;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Order, RequestContext, VendureEvent } from '@vendure/core';

/**
* Event indicating that a checkout has started for this order
*/
export class CheckoutStartedEvent extends VendureEvent {
constructor(public ctx: RequestContext, public order: Order) {
super();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,19 @@ import {
} from '@vendure/core';
import { isAxiosError } from 'axios';
import { ApiKeySession, EventCreateQueryV2, EventsApi } from 'klaviyo-api';
import { PLUGIN_INIT_OPTIONS, loggerCtx } from './constants';
import { PLUGIN_INIT_OPTIONS, loggerCtx } from '../constants';
import {
EventWithContext,
KlaviyoEventHandler,
KlaviyoGenericEvent,
KlaviyoOrderPlacedEvent,
} from './event-handler/klaviyo-event-handler';
import { KlaviyoPluginOptions } from './klaviyo.plugin';
} from '../event-handler/klaviyo-event-handler';
import { KlaviyoPluginOptions } from '../klaviyo.plugin';
import {
mapToKlaviyoEventInput,
mapToKlaviyoOrderPlacedInput,
mapToOrderedProductEvent,
} from './util/map-to-klaviyo-input';
} from '../util/map-to-klaviyo-input';

type JobData = {
ctx: SerializedRequestContext;
Expand Down
48 changes: 46 additions & 2 deletions packages/vendure-plugin-klaviyo/test/e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DefaultLogger, LogLevel, mergeConfig } from '@vendure/core';
import { DefaultLogger, EventBus, LogLevel, mergeConfig } from '@vendure/core';
import {
createTestEnvironment,
registerInitializer,
Expand All @@ -11,11 +11,13 @@ import { EventCreateQueryV2 } from 'klaviyo-api';
import nock from 'nock';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { initialData } from '../../test/src/initial-data';
import { createSettledOrder } from '../../test/src/shop-utils';
import { addItem, createSettledOrder } from '../../test/src/shop-utils';
import { testPaymentMethod } from '../../test/src/test-payment-method';
import { defaultOrderPlacedEventHandler, KlaviyoPlugin } from '../src';
import { mockOrderPlacedHandler } from './mock-order-placed-handler';
import { mockCustomEventHandler } from './mock-custom-event-handler';
import { CheckoutStartedEvent, startedCheckoutHandler } from '../src/';
import gql from 'graphql-tag';

let server: TestServer;
let adminClient: SimpleGraphQLClient;
Expand All @@ -30,6 +32,7 @@ beforeAll(async () => {
apiKey: 'some_private_api_key',
eventHandlers: [
defaultOrderPlacedEventHandler,
startedCheckoutHandler,
mockOrderPlacedHandler,
mockCustomEventHandler,
],
Expand Down Expand Up @@ -184,4 +187,45 @@ describe('Klaviyo', () => {
(customEvent?.data.attributes.properties as any).customTestEventProp
).toEqual('some information');
});

it('Emits CheckoutStartedEvent on calling checkoutStarted() mutation', async () => {
// Create active order
await shopClient.asUserWithCredentials(
'[email protected]',
'test'
);
await addItem(shopClient, 'T_1', 1);
// Mock API response
nock('https://a.klaviyo.com/api/')
.post('/events/', (reqBody) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
klaviyoRequests.push(reqBody);
return true;
})
.reply(200, {})
.persist();
const events: CheckoutStartedEvent[] = [];
server.app
.get(EventBus)
.ofType(CheckoutStartedEvent)
.subscribe((e) => events.push(e));
await shopClient.query(
gql`
mutation {
klaviyoCheckoutStarted
}
`
);
// Give worker some time to send event to klaviyo
await new Promise((resolve) => setTimeout(resolve, 1000));
const checkoutStartedEvent = klaviyoRequests.find(
(r) =>
r.data.attributes.metric.data.attributes.name === 'Checkout Started'
);
expect(events[0].order.id).toBeDefined();
const profile = checkoutStartedEvent?.data.attributes.profile.data
.attributes as any;
expect(profile.email).toBe('[email protected]');
expect(checkoutStartedEvent).toBeDefined();
});
});

0 comments on commit a87887d

Please sign in to comment.