Add campaigns component and services
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -35,4 +35,8 @@ build/
|
||||
dashboard/build/
|
||||
dashboard-server/frontend/build/
|
||||
dashboard-server/build/
|
||||
**/assets/build/
|
||||
**/assets/build/
|
||||
dashboard-server/klaviyo-server/routes/._campaigns.routes.js
|
||||
dashboard-server/klaviyo-server/routes/._reporting.routes.js
|
||||
dashboard-server/klaviyo-server/services/._campaigns.service.js
|
||||
dashboard-server/klaviyo-server/services/._reporting.service.js
|
||||
|
||||
16
dashboard-server/klaviyo-server/package-lock.json
generated
16
dashboard-server/klaviyo-server/package-lock.json
generated
@@ -12,6 +12,7 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"esm": "^3.2.25",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"luxon": "^3.5.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
@@ -711,6 +712,21 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "7.5.0",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz",
|
||||
"integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": "^4.11 || 5 || ^5.0.0-beta.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.0.1.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"esm": "^3.2.25",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"luxon": "^3.5.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
|
||||
71
dashboard-server/klaviyo-server/routes/campaigns.routes.js
Normal file
71
dashboard-server/klaviyo-server/routes/campaigns.routes.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import express from 'express';
|
||||
import { CampaignsService } from '../services/campaigns.service.js';
|
||||
import { TimeManager } from '../utils/time.utils.js';
|
||||
|
||||
export function createCampaignsRouter(apiKey, apiRevision) {
|
||||
const router = express.Router();
|
||||
const timeManager = new TimeManager();
|
||||
const campaignsService = new CampaignsService(apiKey, apiRevision);
|
||||
|
||||
// Get campaigns with optional filtering
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const params = {
|
||||
pageSize: parseInt(req.query.pageSize) || 50,
|
||||
sort: req.query.sort || '-send_time',
|
||||
status: req.query.status,
|
||||
startDate: req.query.startDate,
|
||||
endDate: req.query.endDate,
|
||||
pageCursor: req.query.pageCursor
|
||||
};
|
||||
|
||||
console.log('[Campaigns Route] Fetching campaigns with params:', params);
|
||||
const data = await campaignsService.getCampaigns(params);
|
||||
console.log('[Campaigns Route] Success:', {
|
||||
count: data.data?.length || 0
|
||||
});
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
console.error('[Campaigns Route] Error:', error);
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
message: error.message,
|
||||
details: error.response?.data || null
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get campaigns by time range
|
||||
router.get('/:timeRange', async (req, res) => {
|
||||
try {
|
||||
const { timeRange } = req.params;
|
||||
const { status } = req.query;
|
||||
|
||||
let result;
|
||||
if (timeRange === 'custom') {
|
||||
const { startDate, endDate } = req.query;
|
||||
if (!startDate || !endDate) {
|
||||
return res.status(400).json({ error: 'Custom range requires startDate and endDate' });
|
||||
}
|
||||
|
||||
result = await campaignsService.getCampaigns({
|
||||
startDate,
|
||||
endDate,
|
||||
status
|
||||
});
|
||||
} else {
|
||||
result = await campaignsService.getCampaignsByTimeRange(
|
||||
timeRange,
|
||||
{ status }
|
||||
);
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error("[Campaigns Route] Error:", error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -1,15 +1,19 @@
|
||||
import express from 'express';
|
||||
import { createMetricsRoutes } from './metrics.routes.js';
|
||||
import { createEventsRouter } from './events.routes.js';
|
||||
import { createCampaignsRouter } from './campaigns.routes.js';
|
||||
import { createReportingRouter } from './reporting.routes.js';
|
||||
|
||||
export function createApiRouter(apiKey, apiRevision) {
|
||||
const router = express.Router();
|
||||
|
||||
// Mount metrics routes
|
||||
router.use('/metrics', createMetricsRoutes(apiKey, apiRevision));
|
||||
|
||||
// Mount events routes
|
||||
router.use('/events', createEventsRouter(apiKey, apiRevision));
|
||||
|
||||
// Mount campaigns routes
|
||||
router.use('/campaigns', createCampaignsRouter(apiKey, apiRevision));
|
||||
|
||||
// Mount reporting routes
|
||||
router.use('/reporting', createReportingRouter(apiKey, apiRevision));
|
||||
|
||||
return router;
|
||||
}
|
||||
72
dashboard-server/klaviyo-server/routes/reporting.routes.js
Normal file
72
dashboard-server/klaviyo-server/routes/reporting.routes.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import express from 'express';
|
||||
import { ReportingService } from '../services/reporting.service.js';
|
||||
import { TimeManager } from '../utils/time.utils.js';
|
||||
|
||||
export function createReportingRouter(apiKey, apiRevision) {
|
||||
const router = express.Router();
|
||||
const timeManager = new TimeManager();
|
||||
const reportingService = new ReportingService(apiKey, apiRevision);
|
||||
|
||||
// Get campaign reports with optional filtering
|
||||
router.get('/campaigns', async (req, res) => {
|
||||
try {
|
||||
const { timeRange, startDate, endDate } = req.query;
|
||||
|
||||
let params = {};
|
||||
if (timeRange === 'custom') {
|
||||
if (!startDate || !endDate) {
|
||||
return res.status(400).json({ error: 'Custom range requires startDate and endDate' });
|
||||
}
|
||||
params = { startDate, endDate };
|
||||
} else {
|
||||
params = { timeRange: timeRange || 'last30days' };
|
||||
}
|
||||
|
||||
console.log('[Reporting Route] Fetching campaign reports with params:', params);
|
||||
const data = await reportingService.getEnrichedCampaignReports(params);
|
||||
console.log('[Reporting Route] Success:', {
|
||||
count: data.data?.length || 0
|
||||
});
|
||||
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
console.error('[Reporting Route] Error:', error);
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
message: error.message,
|
||||
details: error.response?.data || null
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get campaign reports by time range
|
||||
router.get('/campaigns/:timeRange', async (req, res) => {
|
||||
try {
|
||||
const { timeRange } = req.params;
|
||||
|
||||
let result;
|
||||
if (timeRange === 'custom') {
|
||||
const { startDate, endDate } = req.query;
|
||||
if (!startDate || !endDate) {
|
||||
return res.status(400).json({ error: 'Custom range requires startDate and endDate' });
|
||||
}
|
||||
|
||||
result = await reportingService.getEnrichedCampaignReports({
|
||||
startDate,
|
||||
endDate
|
||||
});
|
||||
} else {
|
||||
result = await reportingService.getEnrichedCampaignReports({
|
||||
timeRange
|
||||
});
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error("[Reporting Route] Error:", error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { createApiRouter } from './routes/index.js';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
@@ -26,6 +27,21 @@ console.log('[Server] Environment variables loaded:', {
|
||||
const app = express();
|
||||
const port = process.env.KLAVIYO_PORT || 3004;
|
||||
|
||||
// Rate limiting for reporting endpoints
|
||||
const reportingLimiter = rateLimit({
|
||||
windowMs: 10 * 60 * 1000, // 10 minutes
|
||||
max: 10, // limit each IP to 10 requests per windowMs
|
||||
message: 'Too many requests to reporting endpoint, please try again later',
|
||||
keyGenerator: (req) => {
|
||||
// Use a combination of IP and endpoint for more granular control
|
||||
return `${req.ip}-reporting`;
|
||||
},
|
||||
skip: (req) => {
|
||||
// Only apply to campaign-values-reports endpoint
|
||||
return !req.path.includes('campaign-values-reports');
|
||||
}
|
||||
});
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
@@ -36,6 +52,9 @@ app.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// Apply rate limiting to reporting endpoints
|
||||
app.use('/api/klaviyo/reporting', reportingLimiter);
|
||||
|
||||
// Create and mount API routes
|
||||
const apiRouter = createApiRouter(
|
||||
process.env.KLAVIYO_API_KEY,
|
||||
|
||||
206
dashboard-server/klaviyo-server/services/campaigns.service.js
Normal file
206
dashboard-server/klaviyo-server/services/campaigns.service.js
Normal file
@@ -0,0 +1,206 @@
|
||||
import fetch from 'node-fetch';
|
||||
import { TimeManager } from '../utils/time.utils.js';
|
||||
import { RedisService } from './redis.service.js';
|
||||
|
||||
export class CampaignsService {
|
||||
constructor(apiKey, apiRevision) {
|
||||
this.apiKey = apiKey;
|
||||
this.apiRevision = apiRevision;
|
||||
this.baseUrl = 'https://a.klaviyo.com/api';
|
||||
this.timeManager = new TimeManager();
|
||||
this.redisService = new RedisService();
|
||||
}
|
||||
|
||||
async getCampaigns(params = {}) {
|
||||
try {
|
||||
// Add request debouncing
|
||||
const requestKey = JSON.stringify(params);
|
||||
if (this._pendingRequests && this._pendingRequests[requestKey]) {
|
||||
return this._pendingRequests[requestKey];
|
||||
}
|
||||
|
||||
// Try to get from cache first
|
||||
const cacheKey = this.redisService._getCacheKey('campaigns', params);
|
||||
let cachedData = null;
|
||||
try {
|
||||
cachedData = await this.redisService.get(`${cacheKey}:raw`);
|
||||
if (cachedData) {
|
||||
return cachedData;
|
||||
}
|
||||
} catch (cacheError) {
|
||||
console.warn('[CampaignsService] Cache error:', cacheError);
|
||||
}
|
||||
|
||||
this._pendingRequests = this._pendingRequests || {};
|
||||
this._pendingRequests[requestKey] = (async () => {
|
||||
let allCampaigns = [];
|
||||
let nextCursor = params.pageCursor;
|
||||
let pageCount = 0;
|
||||
|
||||
const filter = params.filter || this._buildFilter(params);
|
||||
|
||||
do {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (filter) {
|
||||
queryParams.append('filter', filter);
|
||||
}
|
||||
queryParams.append('sort', params.sort || '-send_time');
|
||||
|
||||
if (nextCursor) {
|
||||
queryParams.append('page[cursor]', nextCursor);
|
||||
}
|
||||
|
||||
const url = `${this.baseUrl}/campaigns?${queryParams.toString()}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Klaviyo-API-Key ${this.apiKey}`,
|
||||
'revision': this.apiRevision
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
console.error('[CampaignsService] API Error:', errorData);
|
||||
throw new Error(`Klaviyo API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
allCampaigns = allCampaigns.concat(responseData.data || []);
|
||||
pageCount++;
|
||||
|
||||
nextCursor = responseData.links?.next ?
|
||||
new URL(responseData.links.next).searchParams.get('page[cursor]') : null;
|
||||
|
||||
if (nextCursor) {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
} catch (fetchError) {
|
||||
console.error('[CampaignsService] Fetch error:', fetchError);
|
||||
throw fetchError;
|
||||
}
|
||||
|
||||
} while (nextCursor);
|
||||
|
||||
const transformedCampaigns = this._transformCampaigns(allCampaigns);
|
||||
|
||||
const result = {
|
||||
data: transformedCampaigns,
|
||||
meta: {
|
||||
total_count: transformedCampaigns.length,
|
||||
page_count: pageCount
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const ttl = this.redisService._getTTL(params.timeRange);
|
||||
await this.redisService.set(`${cacheKey}:raw`, result, ttl);
|
||||
} catch (cacheError) {
|
||||
console.warn('[CampaignsService] Cache set error:', cacheError);
|
||||
}
|
||||
|
||||
delete this._pendingRequests[requestKey];
|
||||
return result;
|
||||
})();
|
||||
|
||||
return await this._pendingRequests[requestKey];
|
||||
} catch (error) {
|
||||
console.error('[CampaignsService] Error fetching campaigns:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
_buildFilter(params) {
|
||||
const filters = [];
|
||||
|
||||
if (params.startDate && params.endDate) {
|
||||
const startUtc = this.timeManager.formatForAPI(params.startDate);
|
||||
const endUtc = this.timeManager.formatForAPI(params.endDate);
|
||||
|
||||
filters.push(`greater-or-equal(send_time,${startUtc})`);
|
||||
filters.push(`less-than(send_time,${endUtc})`);
|
||||
}
|
||||
|
||||
if (params.status) {
|
||||
filters.push(`equals(status,"${params.status}")`);
|
||||
}
|
||||
|
||||
if (params.customFilters) {
|
||||
filters.push(...params.customFilters);
|
||||
}
|
||||
|
||||
return filters.length > 0 ? (filters.length > 1 ? `and(${filters.join(',')})` : filters[0]) : null;
|
||||
}
|
||||
|
||||
async getCampaignsByTimeRange(timeRange, options = {}) {
|
||||
const range = this.timeManager.getDateRange(timeRange);
|
||||
if (!range) {
|
||||
throw new Error('Invalid time range specified');
|
||||
}
|
||||
|
||||
const params = {
|
||||
timeRange,
|
||||
startDate: range.start.toISO(),
|
||||
endDate: range.end.toISO(),
|
||||
...options
|
||||
};
|
||||
|
||||
// Try to get from cache first
|
||||
const cacheKey = this.redisService._getCacheKey('campaigns', params);
|
||||
let cachedData = null;
|
||||
try {
|
||||
cachedData = await this.redisService.get(`${cacheKey}:raw`);
|
||||
if (cachedData) {
|
||||
return cachedData;
|
||||
}
|
||||
} catch (cacheError) {
|
||||
console.warn('[CampaignsService] Cache error:', cacheError);
|
||||
}
|
||||
|
||||
return this.getCampaigns(params);
|
||||
}
|
||||
|
||||
_transformCampaigns(campaigns) {
|
||||
if (!Array.isArray(campaigns)) {
|
||||
console.warn('[CampaignsService] Campaigns is not an array:', campaigns);
|
||||
return [];
|
||||
}
|
||||
|
||||
return campaigns.map(campaign => {
|
||||
try {
|
||||
const stats = campaign.attributes?.campaign_message?.stats || {};
|
||||
|
||||
return {
|
||||
id: campaign.id,
|
||||
name: campaign.attributes?.name || "Unnamed Campaign",
|
||||
subject: campaign.attributes?.campaign_message?.subject || "",
|
||||
send_time: campaign.attributes?.send_time,
|
||||
stats: {
|
||||
delivery_rate: stats.delivery_rate || 0,
|
||||
delivered: stats.delivered || 0,
|
||||
recipients: stats.recipients || 0,
|
||||
open_rate: stats.open_rate || 0,
|
||||
opens_unique: stats.opens_unique || 0,
|
||||
opens: stats.opens || 0,
|
||||
clicks_unique: stats.clicks_unique || 0,
|
||||
click_rate: stats.click_rate || 0,
|
||||
click_to_open_rate: stats.click_to_open_rate || 0,
|
||||
conversion_value: stats.conversion_value || 0,
|
||||
conversion_uniques: stats.conversion_uniques || 0
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[CampaignsService] Error transforming campaign:', error, campaign);
|
||||
return {
|
||||
id: campaign.id || 'unknown',
|
||||
name: 'Error Processing Campaign',
|
||||
stats: {}
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
237
dashboard-server/klaviyo-server/services/reporting.service.js
Normal file
237
dashboard-server/klaviyo-server/services/reporting.service.js
Normal file
@@ -0,0 +1,237 @@
|
||||
import fetch from 'node-fetch';
|
||||
import { TimeManager } from '../utils/time.utils.js';
|
||||
import { RedisService } from './redis.service.js';
|
||||
|
||||
const METRIC_IDS = {
|
||||
PLACED_ORDER: 'Y8cqcF'
|
||||
};
|
||||
|
||||
export class ReportingService {
|
||||
constructor(apiKey, apiRevision) {
|
||||
this.apiKey = apiKey;
|
||||
this.apiRevision = apiRevision;
|
||||
this.baseUrl = 'https://a.klaviyo.com/api';
|
||||
this.timeManager = new TimeManager();
|
||||
this.redisService = new RedisService();
|
||||
this._pendingReportRequest = null;
|
||||
}
|
||||
|
||||
async getCampaignReports(params = {}) {
|
||||
try {
|
||||
// Check if there's a pending request
|
||||
if (this._pendingReportRequest) {
|
||||
console.log('[ReportingService] Using pending campaign report request');
|
||||
return this._pendingReportRequest;
|
||||
}
|
||||
|
||||
// Try to get from cache first
|
||||
const cacheKey = this.redisService._getCacheKey('campaign_reports', params);
|
||||
let cachedData = null;
|
||||
try {
|
||||
cachedData = await this.redisService.get(`${cacheKey}:raw`);
|
||||
if (cachedData) {
|
||||
console.log('[ReportingService] Using cached campaign report data');
|
||||
return cachedData;
|
||||
}
|
||||
} catch (cacheError) {
|
||||
console.warn('[ReportingService] Cache error:', cacheError);
|
||||
}
|
||||
|
||||
// Create new request promise
|
||||
this._pendingReportRequest = (async () => {
|
||||
console.log('[ReportingService] Fetching fresh campaign report data');
|
||||
|
||||
const range = this.timeManager.getDateRange(params.timeRange || 'last30days');
|
||||
|
||||
const payload = {
|
||||
data: {
|
||||
type: "campaign-values-report",
|
||||
attributes: {
|
||||
timeframe: {
|
||||
start: range.start.toISO(),
|
||||
end: range.end.toISO()
|
||||
},
|
||||
statistics: [
|
||||
"delivery_rate",
|
||||
"delivered",
|
||||
"recipients",
|
||||
"open_rate",
|
||||
"opens_unique",
|
||||
"opens",
|
||||
"click_rate",
|
||||
"clicks_unique",
|
||||
"click_to_open_rate",
|
||||
"conversion_value",
|
||||
"conversion_uniques"
|
||||
],
|
||||
conversion_metric_id: METRIC_IDS.PLACED_ORDER,
|
||||
filter: 'equals(send_channel,"email")'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/campaign-values-reports`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Klaviyo-API-Key ${this.apiKey}`,
|
||||
'revision': this.apiRevision
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
console.error('[ReportingService] API Error:', errorData);
|
||||
throw new Error(`Klaviyo API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const reportData = await response.json();
|
||||
|
||||
// Cache the response for 10 minutes
|
||||
try {
|
||||
await this.redisService.set(`${cacheKey}:raw`, reportData, 600);
|
||||
} catch (cacheError) {
|
||||
console.warn('[ReportingService] Cache set error:', cacheError);
|
||||
}
|
||||
|
||||
return reportData;
|
||||
})();
|
||||
|
||||
const result = await this._pendingReportRequest;
|
||||
this._pendingReportRequest = null;
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error('[ReportingService] Error fetching campaign reports:', error);
|
||||
this._pendingReportRequest = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getCampaignDetails(campaignIds = []) {
|
||||
try {
|
||||
if (!Array.isArray(campaignIds) || campaignIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Process in batches of 5 to avoid rate limits
|
||||
const batchSize = 5;
|
||||
const campaignDetails = [];
|
||||
|
||||
for (let i = 0; i < campaignIds.length; i += batchSize) {
|
||||
const batch = campaignIds.slice(i, i + batchSize);
|
||||
|
||||
const batchPromises = batch.map(async (campaignId) => {
|
||||
try {
|
||||
// Try to get from cache first
|
||||
const cacheKey = this.redisService._getCacheKey('campaign_details', { campaignId });
|
||||
const cachedData = await this.redisService.get(`${cacheKey}:raw`);
|
||||
if (cachedData) {
|
||||
return cachedData;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/campaigns/${campaignId}?include=campaign-messages`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Klaviyo-API-Key ${this.apiKey}`,
|
||||
'revision': this.apiRevision
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Error fetching campaign ${campaignId}:`, response.statusText);
|
||||
return null;
|
||||
}
|
||||
|
||||
const campaignData = await response.json();
|
||||
|
||||
// Cache individual campaign data for 1 hour
|
||||
await this.redisService.set(`${cacheKey}:raw`, campaignData, 3600);
|
||||
|
||||
return campaignData;
|
||||
} catch (error) {
|
||||
console.error(`Error processing campaign ${campaignId}:`, error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const batchResults = await Promise.all(batchPromises);
|
||||
campaignDetails.push(...batchResults.filter(Boolean));
|
||||
|
||||
// Add delay between batches if not the last batch
|
||||
if (i + batchSize < campaignIds.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
return campaignDetails;
|
||||
} catch (error) {
|
||||
console.error('[ReportingService] Error fetching campaign details:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getEnrichedCampaignReports(params = {}) {
|
||||
try {
|
||||
// Get campaign reports first
|
||||
const reportData = await this.getCampaignReports(params);
|
||||
|
||||
// Extract campaign IDs from the report
|
||||
const campaignIds = reportData.data?.attributes?.results?.map(
|
||||
result => result.groupings?.campaign_id
|
||||
).filter(Boolean) || [];
|
||||
|
||||
// Get campaign details including messages
|
||||
const campaignDetails = await this.getCampaignDetails(campaignIds);
|
||||
|
||||
// Merge report data with campaign details
|
||||
const enrichedData = reportData.data?.attributes?.results?.map(report => {
|
||||
const campaignId = report.groupings?.campaign_id;
|
||||
const campaignDetail = campaignDetails.find(
|
||||
detail => detail.data?.id === campaignId
|
||||
);
|
||||
|
||||
const campaignMessages = campaignDetail?.included?.filter(
|
||||
item => item.type === 'campaign-message'
|
||||
) || [];
|
||||
|
||||
return {
|
||||
id: campaignId,
|
||||
name: campaignDetail?.data?.attributes?.name || "Unnamed Campaign",
|
||||
subject: campaignMessages[0]?.attributes?.content?.subject || "",
|
||||
send_time: report.groupings?.send_time,
|
||||
stats: {
|
||||
delivery_rate: report.statistics?.delivery_rate || 0,
|
||||
delivered: report.statistics?.delivered || 0,
|
||||
recipients: report.statistics?.recipients || 0,
|
||||
open_rate: report.statistics?.open_rate || 0,
|
||||
opens_unique: report.statistics?.opens_unique || 0,
|
||||
opens: report.statistics?.opens || 0,
|
||||
click_rate: report.statistics?.click_rate || 0,
|
||||
clicks_unique: report.statistics?.clicks_unique || 0,
|
||||
click_to_open_rate: report.statistics?.click_to_open_rate || 0,
|
||||
conversion_value: report.statistics?.conversion_value || 0,
|
||||
conversion_uniques: report.statistics?.conversion_uniques || 0
|
||||
}
|
||||
};
|
||||
}).filter(Boolean) || [];
|
||||
|
||||
return {
|
||||
data: enrichedData,
|
||||
meta: {
|
||||
total_count: enrichedData.length
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('[ReportingService] Error getting enriched campaign reports:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import EventFeed from "./components/dashboard/EventFeed";
|
||||
import StatCards from "./components/dashboard/StatCards";
|
||||
import ProductGrid from "./components/dashboard/ProductGrid";
|
||||
import SalesChart from "./components/dashboard/SalesChart";
|
||||
import KlaviyoCampaigns from "@/components/dashboard/KlaviyoCampaigns";
|
||||
|
||||
// Public layout
|
||||
const PublicLayout = () => (
|
||||
@@ -88,6 +89,7 @@ const DashboardLayout = () => {
|
||||
</div>
|
||||
<Navigation />
|
||||
<div className="p-4 space-y-4">
|
||||
<KlaviyoCampaigns />
|
||||
<div className="grid grid-cols-1 xl:grid-cols-6 gap-4">
|
||||
<div className="xl:col-span-4 col-span-6">
|
||||
<div className="space-y-4 h-full w-full">
|
||||
|
||||
242
dashboard/src/components/dashboard/KlaviyoCampaigns.jsx
Normal file
242
dashboard/src/components/dashboard/KlaviyoCampaigns.jsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import axios from "axios";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { DateTime } from "luxon";
|
||||
import { Loader2, AlertCircle } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { TIME_RANGES } from "@/lib/constants";
|
||||
|
||||
// Helper functions for formatting
|
||||
const formatRate = (value) => {
|
||||
if (typeof value !== "number") return "0.0%";
|
||||
return `${(value * 100).toFixed(1)}%`;
|
||||
};
|
||||
|
||||
const formatCurrency = (value) => {
|
||||
if (typeof value !== "number") return "$0";
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
// Loading skeleton component
|
||||
const TableSkeleton = () => (
|
||||
<div className="space-y-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-16 bg-gray-100 dark:bg-gray-800 animate-pulse rounded"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
// MetricCell component for displaying campaign metrics
|
||||
const MetricCell = ({
|
||||
value,
|
||||
count,
|
||||
isMonetary = false,
|
||||
showConversionRate = false,
|
||||
totalRecipients = 0,
|
||||
}) => (
|
||||
<td className="p-2 text-center">
|
||||
<div className="text-blue-600 dark:text-blue-400 text-lg font-semibold">
|
||||
{isMonetary ? formatCurrency(value) : formatRate(value)}
|
||||
</div>
|
||||
<div className="text-gray-600 dark:text-gray-400 text-sm">
|
||||
{count?.toLocaleString() || 0} {count === 1 ? "recipient" : "recipients"}
|
||||
{showConversionRate &&
|
||||
totalRecipients > 0 &&
|
||||
` (${((count / totalRecipients) * 100).toFixed(2)}%)`}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
|
||||
const KlaviyoCampaigns = ({
|
||||
className,
|
||||
timeRange = "last7days",
|
||||
onTimeRangeChange,
|
||||
title = "Email Campaigns",
|
||||
description
|
||||
}) => {
|
||||
const [campaigns, setCampaigns] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedTimeRange, setSelectedTimeRange] = useState(timeRange);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCampaigns();
|
||||
}, [selectedTimeRange]);
|
||||
|
||||
const fetchCampaigns = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await axios.get(`/api/klaviyo/reporting/campaigns/${selectedTimeRange}`);
|
||||
setCampaigns(response.data.data || []);
|
||||
} catch (error) {
|
||||
console.error("Error fetching campaigns:", error);
|
||||
setError(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeRangeChange = (value) => {
|
||||
setSelectedTimeRange(value);
|
||||
if (onTimeRangeChange) {
|
||||
onTimeRangeChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="h-full bg-white dark:bg-gray-900">
|
||||
<CardHeader>
|
||||
<div className="h-6 w-48 bg-gray-200 dark:bg-gray-700 animate-pulse rounded" />
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-y-auto pl-4 max-h-[350px]">
|
||||
<TableSkeleton />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-full bg-white dark:bg-gray-900">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>Failed to load campaigns: {error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
{title}
|
||||
</CardTitle>
|
||||
<Select value={selectedTimeRange} onValueChange={handleTimeRangeChange}>
|
||||
<SelectTrigger className="w-[130px]">
|
||||
<SelectValue placeholder="Select time range" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIME_RANGES.map((range) => (
|
||||
<SelectItem key={range.value} value={range.value}>
|
||||
{range.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-y-auto pl-4 max-h-[350px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="font-medium">Campaign</TableHead>
|
||||
<TableHead className="text-center font-medium">Delivery</TableHead>
|
||||
<TableHead className="text-center font-medium">Opens</TableHead>
|
||||
<TableHead className="text-center font-medium">Clicks</TableHead>
|
||||
<TableHead className="text-center font-medium">Orders</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{campaigns.map((campaign) => (
|
||||
<TableRow key={campaign.id}>
|
||||
<TableCell className="align-top">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{campaign.name || "Unnamed Campaign"}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 truncate max-w-[300px]">
|
||||
{campaign.subject || "No subject"}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{campaign.send_time
|
||||
? DateTime.fromISO(campaign.send_time).toLocaleString(
|
||||
DateTime.DATETIME_MED
|
||||
)
|
||||
: "No date"}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
className="break-words bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 border dark:border-gray-700"
|
||||
>
|
||||
<p className="font-medium">
|
||||
{campaign.name || "Unnamed Campaign"}
|
||||
</p>
|
||||
<p>{campaign.subject || "No subject"}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{campaign.send_time
|
||||
? DateTime.fromISO(campaign.send_time).toLocaleString(
|
||||
DateTime.DATETIME_MED
|
||||
)
|
||||
: "No date"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</TableCell>
|
||||
<MetricCell
|
||||
value={campaign.stats.delivery_rate}
|
||||
count={campaign.stats.delivered}
|
||||
totalRecipients={campaign.stats.recipients}
|
||||
/>
|
||||
<MetricCell
|
||||
value={campaign.stats.open_rate}
|
||||
count={campaign.stats.opens_unique}
|
||||
totalRecipients={campaign.stats.recipients}
|
||||
/>
|
||||
<MetricCell
|
||||
value={campaign.stats.click_rate}
|
||||
count={campaign.stats.clicks_unique}
|
||||
totalRecipients={campaign.stats.recipients}
|
||||
/>
|
||||
<MetricCell
|
||||
value={campaign.stats.conversion_value}
|
||||
count={campaign.stats.conversion_uniques}
|
||||
isMonetary={true}
|
||||
showConversionRate={true}
|
||||
totalRecipients={campaign.stats.recipients}
|
||||
/>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default KlaviyoCampaigns;
|
||||
@@ -2,12 +2,12 @@ export const TIME_RANGES = [
|
||||
{ value: 'today', label: 'Today' },
|
||||
{ value: 'yesterday', label: 'Yesterday' },
|
||||
{ value: 'last7days', label: 'Last 7 Days' },
|
||||
{ value: 'last14days', label: 'Last 14 Days' },
|
||||
{ value: 'last30days', label: 'Last 30 Days' },
|
||||
{ value: 'last90days', label: 'Last 90 Days' },
|
||||
{ value: 'thisWeek', label: 'This Week' },
|
||||
{ value: 'lastWeek', label: 'Last Week' },
|
||||
{ value: 'thisMonth', label: 'This Month' },
|
||||
{ value: 'lastMonth', label: 'Last Month' }
|
||||
{ value: 'monthToDate', label: 'Month to Date' },
|
||||
{ value: 'quarterToDate', label: 'Quarter to Date' },
|
||||
{ value: 'yearToDate', label: 'Year to Date' },
|
||||
];
|
||||
|
||||
export const GROUP_BY_OPTIONS = [
|
||||
|
||||
@@ -117,21 +117,6 @@ export default defineConfig(({ mode }) => {
|
||||
});
|
||||
});
|
||||
},
|
||||
onProxyReq: (proxyReq, req, res) => {
|
||||
// Log the outgoing request
|
||||
console.log("Proxy request:", {
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
headers: req.headers,
|
||||
});
|
||||
},
|
||||
onProxyRes: (proxyRes, req, res) => {
|
||||
// Log the incoming response
|
||||
console.log("Proxy response:", {
|
||||
status: proxyRes.statusCode,
|
||||
headers: proxyRes.headers,
|
||||
});
|
||||
},
|
||||
},
|
||||
"/api": {
|
||||
target: "https://dashboard.kent.pw",
|
||||
|
||||
Reference in New Issue
Block a user