Skip to content

Commit

Permalink
Merge pull request #518 from Pinelab-studio/feat/campaign-tracker
Browse files Browse the repository at this point in the history
Campaign Tracker Plugin
  • Loading branch information
martijnvdbrug authored Oct 25, 2024
2 parents dc524b7 + 63a0550 commit e6ca515
Show file tree
Hide file tree
Showing 31 changed files with 2,045 additions and 0 deletions.
1 change: 1 addition & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ jobs:
'vendure-plugin-admin-social-auth',
'vendure-plugin-admin-ui-helpers',
'vendure-plugin-anonymized-order',
'vendure-plugin-campaign-tracker',
'vendure-plugin-coinbase',
'vendure-plugin-customer-managed-groups',
'vendure-plugin-dutch-postalcode',
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
"admin-ui-helpers",
"all-plugins",
"anonymized-order",
"campaign-tracker",
"coinbase",
"customer-managed-groups",
"docs-website",
Expand Down
3 changes: 3 additions & 0 deletions packages/vendure-plugin-campaign-tracker/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# 0.0.1 (2024-10-21)

- Initial setup of this plugin
67 changes: 67 additions & 0 deletions packages/vendure-plugin-campaign-tracker/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Vendure Campaign Tracker plugin

### [Official documentation here](https://pinelab-plugins.com/plugin/vendure-plugin-campaign-tracker)

Vendure plugin to track revenue per campaign, so that you can compare different campaigns from different sources.
To track campaigns, your storefront should send the unique campaign code to Vendure on a page visit:

- Pass a campaign code in the url, e.g. `my-website.com?ref=summer-sale-ad`. This URL is then included in your ads or email campaigns.
- Or, set a fixed campaign code for a landing page. For example, all visits to page `/sale-landing` will get the campaign code `sale-landing`

## Getting started

Add the plugin to your `vendure-config.ts`

```ts
import { CampaignTrackerPlugin, LastInteractionAttribution } from '@pinelab/vendure-plugin-campaign-tracker';

...
plugins: [
CampaignTrackerPlugin.init({
// Pick an attribution model. Choose from `LastInteractionAttribution`, `FirstInteractionAttribution`, `LinearAttribution`
// Or, implement your own by implementing the AttributionModel interface
attributionModel: new LastInteractionAttribution()
}),
AdminUiPlugin.init({
port: 3002,
route: 'admin',
app: compileUiExtensions({
outputPath: path.join(__dirname, '__admin-ui'),
extensions: [
CampaignTrackerPlugin.ui,
... // your other plugin UI extensions
],
}),
}),
... // your other plugins
]
```

1. Run a database migration.
2. Rebuild the admin UI
3. Start Vendure, and navigate to 'Campaign' (below Promotions)
4. Create a campaign, e.g. `my-first-campaign`.
5. Make sure that every page on your storefront includes the following code:

```ts
const url = new URL(window.location.href);
const params = new URLSearchParams(url.search);
const ref = params.get('ref');

const activeOrder = await yourGraphqlClient.query(
gql`
mutation addCampaignToOrder($campaignCode: String!) {
addCampaignToOrder(campaignCode: $campaignCode) {
id
code
total
}
}
`,
{ campaignCode: ref }
);
```

This will add any visits to your website with `?ref=my-first-campaign` campaign to the order. This mutation will create a new active order if none exists yet.

If `my-first-campaign` doesn't exists as campaign in Vendure, the call is ignored and no active order is returned (or created).
12 changes: 12 additions & 0 deletions packages/vendure-plugin-campaign-tracker/codegen.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
schema: 'src/api/api-extensions.ts'
documents: 'src/ui/queries.ts'
generates:
./src/ui/generated/graphql.ts:
plugins:
- typescript
- typescript-operations
config:
avoidOptionals: false
scalars:
DateTime: Date
ID: number | string
13 changes: 13 additions & 0 deletions packages/vendure-plugin-campaign-tracker/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
module.exports = (async () => {
const { default: parentConfig } = await import('../../eslint-base.config.js');
return [
...parentConfig,
{
languageOptions: {
parserOptions: {
project: './tsconfig.json',
},
},
},
];
})();
30 changes: 30 additions & 0 deletions packages/vendure-plugin-campaign-tracker/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "@pinelab/vendure-plugin-campaign-tracker",
"version": "0.0.1",
"description": "Compare different campaign and ads sources with server side revenue tracking.",
"author": "Martijn van de Brug <[email protected]>",
"homepage": "https://pinelab-plugins.com/",
"repository": "https://github.com/Pinelab-studio/pinelab-vendure-plugins",
"license": "MIT",
"private": false,
"publishConfig": {
"access": "public"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist",
"README.md",
"CHANGELOG.md"
],
"scripts": {
"build": "rimraf dist && yarn generate && tsc && copyfiles -u 1 'src/ui/**/*' dist/",
"start": "yarn ts-node test/dev-server.ts",
"generate": "graphql-codegen",
"test": "vitest run",
"lint": "eslint ."
},
"dependencies": {
"catch-unknown": "^2.0.0"
}
}
90 changes: 90 additions & 0 deletions packages/vendure-plugin-campaign-tracker/src/api/api-extensions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import gql from 'graphql-tag';

// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Only used by graphql codegen
const _scalars = gql`
scalar DateTime
scalar Money
scalar PaginatedList
scalar StringOperators
type Order {
id: ID
code: String
total: Money
}
enum SortOrder {
ASC
DESC
}
`;

const commonApiExtensions = gql`
type Campaign {
id: ID!
createdAt: DateTime!
updatedAt: DateTime!
code: String!
name: String!
metricsUpdatedAt: DateTime
revenueLast7days: Money
revenueLast30days: Money
revenueLast365days: Money
}
`;

export const shopApiExtensions = gql`
${commonApiExtensions}
extend type Mutation {
"""
Add a campaign code to the current order.
Creates a new active order if none exists.
"""
addCampaignToOrder(campaignCode: String!): Order
}
`;

export const adminApiExtensions = gql`
${commonApiExtensions}
type CampaignList {
items: [Campaign!]!
totalItems: Int!
}
input CampaignInput {
code: String!
name: String!
}
input CampaignSortParameter {
createdAt: SortOrder
updatedAt: SortOrder
code: SortOrder
name: SortOrder
revenueLast7days: SortOrder
revenueLast30days: SortOrder
revenueLast365days: SortOrder
}
input CampaignFilterParameter {
code: StringOperators
name: StringOperators
}
input CampaignListOptions {
skip: Int
take: Int
sort: CampaignSortParameter
filter: CampaignFilterParameter
}
extend type Mutation {
createCampaign(input: CampaignInput!): Campaign!
updateCampaign(id: ID!, input: CampaignInput!): Campaign!
deleteCampaign(id: ID!): Boolean!
}
extend type Query {
campaigns(options: CampaignListOptions): CampaignList!
}
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { Permission } from '@vendure/common/lib/generated-types';
import { ID } from '@vendure/common/lib/shared-types';
import { Allow, Ctx, RequestContext, Transaction } from '@vendure/core';
import { CampaignTrackerService } from '../services/campaign-tracker.service';
import {
Campaign,
CampaignList,
MutationCreateCampaignArgs,
MutationDeleteCampaignArgs,
MutationUpdateCampaignArgs,
QueryCampaignsArgs,
} from '../ui/generated/graphql';

@Resolver()
export class CampaignTrackerAdminResolver {
constructor(private campaignTrackerService: CampaignTrackerService) {}

@Query()
@Allow(Permission.SuperAdmin)
async campaigns(
@Ctx() ctx: RequestContext,
@Args() { options }: QueryCampaignsArgs
): Promise<CampaignList> {
return await this.campaignTrackerService.getCampaigns(
ctx,
options ?? undefined
);
}

@Mutation()
@Transaction()
@Allow(Permission.SuperAdmin)
async createCampaign(
@Ctx() ctx: RequestContext,
@Args() { input }: MutationCreateCampaignArgs
): Promise<Campaign> {
return await this.campaignTrackerService.createCampaign(ctx, input);
}

@Mutation()
@Transaction()
@Allow(Permission.SuperAdmin)
async updateCampaign(
@Ctx() ctx: RequestContext,
@Args() { id, input }: MutationUpdateCampaignArgs
): Promise<Campaign> {
return await this.campaignTrackerService.updateCampaign(ctx, id, input);
}

@Mutation()
@Transaction()
@Allow(Permission.SuperAdmin)
async deleteCampaign(
@Ctx() ctx: RequestContext,
@Args() { id }: MutationDeleteCampaignArgs
): Promise<boolean> {
await this.campaignTrackerService.deleteCampaign(ctx, id);
return true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import {
Allow,
Ctx,
Order,
Permission,
RequestContext,
Transaction,
} from '@vendure/core';
import { CampaignTrackerService } from '../services/campaign-tracker.service';
import { MutationAddCampaignToOrderArgs } from '../ui/generated/graphql';

@Resolver()
export class CampaignTrackerShopResolver {
constructor(private campaignTrackerService: CampaignTrackerService) {}

@Mutation()
@Transaction()
@Allow(Permission.UpdateOrder, Permission.Owner)
async addCampaignToOrder(
@Ctx() ctx: RequestContext,
@Args() { campaignCode }: MutationAddCampaignToOrderArgs
): Promise<Order | undefined> {
return await this.campaignTrackerService.addCampaignToOrder(
ctx,
campaignCode
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { PluginCommonModule, Type, VendurePlugin } from '@vendure/core';
import { AdminUiExtension } from '@vendure/ui-devkit/compiler';

import { adminApiExtensions, shopApiExtensions } from './api/api-extensions';
import { CampaignTrackerAdminResolver } from './api/campaign-tracker-admin.resolver';
import { CampaignTrackerShopResolver } from './api/campaign-tracker-shop.resolver';
import { CAMPAIGN_TRACKER_PLUGIN_OPTIONS } from './constants';
import { Campaign } from './entities/campaign.entity';
import { OrderCampaign } from './entities/order-campaign.entity';
import { LastInteractionAttribution } from './services/attribution-models';
import { CampaignTrackerService } from './services/campaign-tracker.service';
import { CampaignTrackerOptions } from './types';
import path from 'path';

@VendurePlugin({
imports: [PluginCommonModule],
providers: [
{
provide: CAMPAIGN_TRACKER_PLUGIN_OPTIONS,
useFactory: () => CampaignTrackerPlugin.options,
},
CampaignTrackerService,
],
configuration: (config) => {
return config;
},
compatibility: '>=3.0.0',
adminApiExtensions: {
schema: adminApiExtensions,
resolvers: [CampaignTrackerAdminResolver],
},
shopApiExtensions: {
schema: shopApiExtensions,
resolvers: [CampaignTrackerShopResolver],
},
entities: [Campaign, OrderCampaign],
})
export class CampaignTrackerPlugin {
static options: CampaignTrackerOptions = {
attributionModel: new LastInteractionAttribution(),
};

static init(
options: Partial<CampaignTrackerOptions>
): Type<CampaignTrackerPlugin> {
this.options = {
...this.options,
...options,
};
return CampaignTrackerPlugin;
}

static ui: AdminUiExtension = {
id: 'campaign-tracker',
extensionPath: path.join(__dirname, 'ui'),
routes: [{ route: 'campaigns', filePath: 'routes.ts' }],
providers: ['providers.ts'],
};
}
4 changes: 4 additions & 0 deletions packages/vendure-plugin-campaign-tracker/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const CAMPAIGN_TRACKER_PLUGIN_OPTIONS = Symbol(
'CAMPAIGN_TRACKER_PLUGIN_OPTIONS'
);
export const loggerCtx = 'CampaignTrackerPlugin';
Loading

0 comments on commit e6ca515

Please sign in to comment.