Skip to content

Commit dc4cd9b

Browse files
committed
feat(campaign): create types
1 parent 24d94bc commit dc4cd9b

File tree

5 files changed

+1565
-0
lines changed

5 files changed

+1565
-0
lines changed

src/packages/campaign/manager.ts

Lines changed: 397 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,397 @@
1+
import { Campaign, CampaignTrigger, CampaignContext, CampaignCollection } from "./types";
2+
import { Tour } from "../tour/tour";
3+
import { TriggerDetector } from "./triggers";
4+
import { UserTracker } from "./userTracker";
5+
import { CampaignStorage } from "./storage";
6+
import isFunction from "../../util/isFunction";
7+
8+
/**
9+
* Campaign Manager - Main class for managing and executing campaigns
10+
*/
11+
export class CampaignManager {
12+
private campaigns: Map<string, Campaign> = new Map();
13+
private triggerDetector: TriggerDetector;
14+
private userTracker: UserTracker;
15+
private storage: CampaignStorage;
16+
private activeTours: Map<string, Tour> = new Map();
17+
private isInitialized = false;
18+
19+
constructor() {
20+
this.triggerDetector = new TriggerDetector();
21+
this.userTracker = new UserTracker();
22+
this.storage = new CampaignStorage();
23+
}
24+
25+
/**
26+
* Initialize the campaign manager
27+
*/
28+
async initialize(): Promise<void> {
29+
if (this.isInitialized) return;
30+
31+
await this.userTracker.initialize();
32+
await this.triggerDetector.initialize();
33+
34+
this.isInitialized = true;
35+
}
36+
37+
/**
38+
* Load campaigns from JSON configuration
39+
*/
40+
async loadCampaigns(config: CampaignCollection | Campaign[]): Promise<void> {
41+
const campaigns = Array.isArray(config) ? config : config.campaigns;
42+
43+
for (const campaign of campaigns) {
44+
if (campaign.active) {
45+
this.campaigns.set(campaign.id, campaign);
46+
await this.setupCampaignTriggers(campaign);
47+
}
48+
}
49+
}
50+
51+
/**
52+
* Load campaigns from URL
53+
*/
54+
async loadCampaignsFromUrl(url: string): Promise<void> {
55+
try {
56+
const response = await fetch(url);
57+
const config = await response.json();
58+
await this.loadCampaigns(config);
59+
} catch (error) {
60+
console.error("Failed to load campaigns from URL:", error);
61+
}
62+
}
63+
64+
/**
65+
* Add a single campaign
66+
*/
67+
async addCampaign(campaign: Campaign): Promise<void> {
68+
if (campaign.active) {
69+
this.campaigns.set(campaign.id, campaign);
70+
await this.setupCampaignTriggers(campaign);
71+
}
72+
}
73+
74+
/**
75+
* Remove a campaign
76+
*/
77+
removeCampaign(campaignId: string): void {
78+
const campaign = this.campaigns.get(campaignId);
79+
if (campaign) {
80+
this.triggerDetector.removeCampaignTriggers(campaignId);
81+
this.campaigns.delete(campaignId);
82+
83+
// Stop active tour if running
84+
const activeTour = this.activeTours.get(campaignId);
85+
if (activeTour) {
86+
activeTour.exit();
87+
this.activeTours.delete(campaignId);
88+
}
89+
}
90+
}
91+
92+
/**
93+
* Get all active campaigns
94+
*/
95+
getCampaigns(): Campaign[] {
96+
return Array.from(this.campaigns.values());
97+
}
98+
99+
/**
100+
* Get a specific campaign
101+
*/
102+
getCampaign(campaignId: string): Campaign | undefined {
103+
return this.campaigns.get(campaignId);
104+
}
105+
106+
/**
107+
* Check if a campaign should be executed based on frequency and targeting
108+
*/
109+
private async shouldExecuteCampaign(campaign: Campaign): Promise<boolean> {
110+
// Check frequency constraints
111+
if (campaign.frequency) {
112+
const canExecute = await this.storage.canExecuteCampaign(
113+
campaign.id,
114+
campaign.frequency
115+
);
116+
if (!canExecute) return false;
117+
}
118+
119+
// Check targeting constraints
120+
if (campaign.targeting) {
121+
const matches = await this.checkTargeting(campaign.targeting);
122+
if (!matches) return false;
123+
}
124+
125+
return true;
126+
}
127+
128+
/**
129+
* Check if targeting conditions are met
130+
*/
131+
private async checkTargeting(targeting: any): Promise<boolean> {
132+
const userContext = this.userTracker.getUserContext();
133+
134+
// Check user agent
135+
if (targeting.userAgent) {
136+
const matches = targeting.userAgent.some((pattern: string) =>
137+
new RegExp(pattern).test(userContext.userAgent)
138+
);
139+
if (!matches) return false;
140+
}
141+
142+
// Check language
143+
if (targeting.language) {
144+
if (!targeting.language.includes(userContext.language)) return false;
145+
}
146+
147+
// Check referrer
148+
if (targeting.referrer) {
149+
const referrer = document.referrer;
150+
const matches = targeting.referrer.some((pattern: string) =>
151+
new RegExp(pattern).test(referrer)
152+
);
153+
if (!matches) return false;
154+
}
155+
156+
// Check query parameters
157+
if (targeting.queryParams) {
158+
const urlParams = new URLSearchParams(window.location.search);
159+
for (const [key, value] of Object.entries(targeting.queryParams)) {
160+
if (urlParams.get(key) !== value) return false;
161+
}
162+
}
163+
164+
// Check localStorage
165+
if (targeting.localStorage) {
166+
for (const [key, value] of Object.entries(targeting.localStorage)) {
167+
if (localStorage.getItem(key) !== value) return false;
168+
}
169+
}
170+
171+
// Check sessionStorage
172+
if (targeting.sessionStorage) {
173+
for (const [key, value] of Object.entries(targeting.sessionStorage)) {
174+
if (sessionStorage.getItem(key) !== value) return false;
175+
}
176+
}
177+
178+
// Check custom function
179+
if (targeting.customFunction) {
180+
const customFn = (window as any)[targeting.customFunction];
181+
if (isFunction(customFn)) {
182+
const result = await customFn(userContext);
183+
if (!result) return false;
184+
}
185+
}
186+
187+
return true;
188+
}
189+
190+
/**
191+
* Execute a campaign
192+
*/
193+
async executeCampaign(campaignId: string, trigger: CampaignTrigger): Promise<boolean> {
194+
const campaign = this.campaigns.get(campaignId);
195+
if (!campaign) return false;
196+
197+
// Check if campaign should be executed
198+
if (!(await this.shouldExecuteCampaign(campaign))) {
199+
return false;
200+
}
201+
202+
// Create campaign context
203+
const context: CampaignContext = {
204+
campaign,
205+
trigger,
206+
user: this.userTracker.getUserContext(),
207+
page: {
208+
url: window.location.href,
209+
referrer: document.referrer,
210+
title: document.title,
211+
loadTime: new Date(),
212+
},
213+
};
214+
215+
// Track campaign execution
216+
await this.storage.trackCampaignExecution(campaignId);
217+
218+
// Convert campaign steps to tour steps
219+
const tourSteps = campaign.tour.steps.map((step) => ({
220+
step: step.step,
221+
title: step.title,
222+
intro: step.intro,
223+
element: step.element,
224+
position: step.position,
225+
scrollTo: step.scrollTo,
226+
disableInteraction: step.disableInteraction,
227+
tooltipClass: step.customClass,
228+
}));
229+
230+
// Create and configure tour
231+
const tour = new Tour();
232+
tour.setOptions({
233+
...campaign.tour,
234+
steps: tourSteps,
235+
});
236+
237+
// Set up campaign-specific callbacks
238+
this.setupCampaignCallbacks(tour, campaign, context);
239+
240+
// Store active tour
241+
this.activeTours.set(campaignId, tour);
242+
243+
// Start the tour
244+
try {
245+
await tour.start();
246+
return true;
247+
} catch (error) {
248+
console.error("Failed to start campaign tour:", error);
249+
this.activeTours.delete(campaignId);
250+
return false;
251+
}
252+
}
253+
254+
/**
255+
* Setup campaign-specific callbacks
256+
*/
257+
private setupCampaignCallbacks(tour: Tour, campaign: Campaign): void {
258+
// On complete callback
259+
tour.onComplete(() => {
260+
this.activeTours.delete(campaign.id);
261+
});
262+
263+
// On exit callback
264+
tour.onExit(() => {
265+
this.activeTours.delete(campaign.id);
266+
});
267+
268+
// Custom step callbacks for campaign features
269+
tour.onAfterChange((element, stepIndex) => {
270+
const campaignStep = campaign.tour.steps[stepIndex];
271+
if (campaignStep) {
272+
this.handleStepActions(campaignStep, element);
273+
274+
// Auto advance if configured
275+
if (campaignStep.autoAdvance) {
276+
setTimeout(() => {
277+
tour.nextStep();
278+
}, campaignStep.autoAdvance);
279+
}
280+
}
281+
});
282+
}
283+
284+
/**
285+
* Handle step-specific actions
286+
*/
287+
private handleStepActions(step: any, element: HTMLElement): void {
288+
if (step.actions) {
289+
step.actions.forEach((action: any) => {
290+
setTimeout(() => {
291+
switch (action.type) {
292+
case "highlight":
293+
if (action.selector) {
294+
const targetElement = document.querySelector(action.selector);
295+
if (targetElement) {
296+
targetElement.classList.add("introjs-campaign-highlight");
297+
}
298+
}
299+
break;
300+
case "scroll":
301+
if (action.selector) {
302+
const targetElement = document.querySelector(action.selector);
303+
if (targetElement) {
304+
targetElement.scrollIntoView({ behavior: "smooth" });
305+
}
306+
}
307+
break;
308+
case "click":
309+
if (action.selector) {
310+
const targetElement = document.querySelector(action.selector) as HTMLElement;
311+
if (targetElement) {
312+
targetElement.click();
313+
}
314+
}
315+
break;
316+
case "focus":
317+
if (action.selector) {
318+
const targetElement = document.querySelector(action.selector) as HTMLElement;
319+
if (targetElement) {
320+
targetElement.focus();
321+
}
322+
}
323+
break;
324+
case "custom_function":
325+
if (action.functionName) {
326+
const customFn = (window as any)[action.functionName];
327+
if (isFunction(customFn)) {
328+
customFn(element, step);
329+
}
330+
}
331+
break;
332+
}
333+
}, action.delay || 0);
334+
});
335+
}
336+
}
337+
338+
/**
339+
* Setup triggers for a campaign
340+
*/
341+
private async setupCampaignTriggers(campaign: Campaign): Promise<void> {
342+
for (const trigger of campaign.triggers) {
343+
await this.triggerDetector.addTrigger(campaign.id, trigger, (triggeredCampaignId, triggeredTrigger) => {
344+
this.executeCampaign(triggeredCampaignId, triggeredTrigger);
345+
});
346+
}
347+
}
348+
349+
/**
350+
* Stop all active campaigns
351+
*/
352+
async stopAllCampaigns(): Promise<void> {
353+
for (const [campaignId, tour] of this.activeTours) {
354+
await tour.exit();
355+
}
356+
this.activeTours.clear();
357+
}
358+
359+
/**
360+
* Destroy the campaign manager
361+
*/
362+
destroy(): void {
363+
this.stopAllCampaigns();
364+
this.triggerDetector.destroy();
365+
this.campaigns.clear();
366+
this.isInitialized = false;
367+
}
368+
}
369+
370+
// Global campaign manager instance
371+
let globalCampaignManager: CampaignManager | null = null;
372+
373+
/**
374+
* Get or create the global campaign manager instance
375+
*/
376+
export function getCampaignManager(): CampaignManager {
377+
if (!globalCampaignManager) {
378+
globalCampaignManager = new CampaignManager();
379+
}
380+
return globalCampaignManager;
381+
}
382+
383+
/**
384+
* Initialize campaigns from configuration
385+
*/
386+
export async function initializeCampaigns(config: CampaignCollection | Campaign[] | string): Promise<CampaignManager> {
387+
const manager = getCampaignManager();
388+
await manager.initialize();
389+
390+
if (typeof config === "string") {
391+
await manager.loadCampaignsFromUrl(config);
392+
} else {
393+
await manager.loadCampaigns(config);
394+
}
395+
396+
return manager;
397+
}

0 commit comments

Comments
 (0)