Add SMS campaigns

This commit is contained in:
2024-12-27 12:10:08 -05:00
parent adbaa75499
commit 7a224ee870
3 changed files with 200 additions and 142 deletions

View File

@@ -11,52 +11,17 @@ export function createReportingRouter(apiKey, apiRevision) {
router.get('/campaigns/:timeRange', async (req, res) => {
try {
const { timeRange } = req.params;
const { startDate, endDate } = req.query;
const { channel } = req.query;
let params = {};
if (timeRange === 'custom' && startDate && endDate) {
const range = timeManager.getCustomRange(startDate, endDate);
if (!range) {
return res.status(400).json({ error: 'Invalid date range' });
}
params = { startDate: range.start.toISO(), endDate: range.end.toISO() };
} else {
params = { timeRange };
}
const data = await reportingService.getCampaignReports(params);
// Transform the data to match the expected format
const transformedData = {
data: data.data.map(campaign => ({
id: campaign.id,
name: campaign.name,
subject: campaign.subject,
send_time: campaign.send_time,
stats: {
delivery_rate: campaign.attributes?.statistics?.delivery_rate || 0,
delivered: campaign.attributes?.statistics?.delivered || 0,
recipients: campaign.attributes?.statistics?.recipients || 0,
open_rate: campaign.attributes?.statistics?.open_rate || 0,
opens_unique: campaign.attributes?.statistics?.opens_unique || 0,
opens: campaign.attributes?.statistics?.opens || 0,
click_rate: campaign.attributes?.statistics?.click_rate || 0,
clicks_unique: campaign.attributes?.statistics?.clicks_unique || 0,
click_to_open_rate: campaign.attributes?.statistics?.click_to_open_rate || 0,
conversion_value: campaign.attributes?.statistics?.conversion_value || 0,
conversion_uniques: campaign.attributes?.statistics?.conversion_uniques || 0
}
}))
};
res.json(transformedData);
} catch (error) {
console.error('[Reporting Route] Error:', error);
res.status(500).json({
status: 'error',
message: error.message,
details: error.response?.data || null
const reports = await reportingService.getCampaignReports({
timeRange,
channel
});
res.json(reports);
} catch (error) {
console.error('[ReportingRoutes] Error fetching campaign reports:', error);
res.status(500).json({ error: error.message });
}
});

View File

@@ -43,78 +43,83 @@ export class ReportingService {
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")'
}
}
};
// Determine which channels to fetch based on params
const channelsToFetch = params.channel === 'all' || !params.channel
? ['email', 'sms']
: [params.channel];
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)
});
const allResults = [];
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();
console.log('[ReportingService] Raw report data:', JSON.stringify(reportData, null, 2));
// Get campaign IDs from the report
const campaignIds = reportData.data?.attributes?.results?.map(result =>
result.groupings?.campaign_id
).filter(Boolean) || [];
console.log('[ReportingService] Extracted campaign IDs:', campaignIds);
// Get campaign details including send time and subject lines
const campaignDetails = await this.getCampaignDetails(campaignIds);
console.log('[ReportingService] Campaign details:', JSON.stringify(campaignDetails, null, 2));
// Merge campaign details with report data
const enrichedData = {
data: reportData.data.attributes.results.map(result => {
const campaignId = result.groupings.campaign_id;
const details = campaignDetails.find(detail => detail.id === campaignId);
const message = details?.included?.find(item => item.type === 'campaign-message');
return {
id: campaignId,
name: details.attributes.name,
subject: details.attributes.subject,
send_time: details.attributes.send_time,
// Fetch each channel
for (const channel of channelsToFetch) {
const payload = {
data: {
type: "campaign-values-report",
attributes: {
statistics: {
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,"${channel}")`
}
}
};
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();
console.log(`[ReportingService] Raw ${channel} report data:`, JSON.stringify(reportData, null, 2));
// Get campaign IDs from the report
const campaignIds = reportData.data?.attributes?.results?.map(result =>
result.groupings?.campaign_id
).filter(Boolean) || [];
if (campaignIds.length > 0) {
// Get campaign details including send time and subject lines
const campaignDetails = await this.getCampaignDetails(campaignIds);
// Process results for this channel
const channelResults = reportData.data.attributes.results.map(result => {
const campaignId = result.groupings.campaign_id;
const details = campaignDetails.find(detail => detail.id === campaignId);
return {
id: campaignId,
name: details.attributes.name,
subject: details.attributes.subject,
send_time: details.attributes.send_time,
channel: channel, // Use the channel we're currently processing
stats: {
delivery_rate: result.statistics.delivery_rate,
delivered: result.statistics.delivered,
recipients: result.statistics.recipients,
@@ -127,10 +132,16 @@ export class ReportingService {
conversion_value: result.statistics.conversion_value,
conversion_uniques: result.statistics.conversion_uniques
}
}
};
})
.sort((a, b) => {
};
});
allResults.push(...channelResults);
}
}
// Sort all results by date
const enrichedData = {
data: allResults.sort((a, b) => {
const dateA = new Date(a.send_time);
const dateB = new Date(b.send_time);
return dateB - dateA; // Sort by date descending
@@ -196,6 +207,11 @@ export class ReportingService {
const message = data.included?.find(item => item.type === 'campaign-message');
console.log('[ReportingService] Campaign details for ID:', campaignId, {
send_channel: data.data.attributes.send_channel,
raw_attributes: data.data.attributes
});
return {
id: data.data.id,
type: data.data.type,
@@ -203,7 +219,8 @@ export class ReportingService {
...data.data.attributes,
name: data.data.attributes.name,
send_time: data.data.attributes.send_time,
subject: message?.attributes?.content?.subject
subject: message?.attributes?.content?.subject,
send_channel: data.data.attributes.send_channel || 'email'
}
};
} catch (error) {

View File

@@ -7,9 +7,19 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { DateTime } from "luxon";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { TIME_RANGES } from "@/lib/constants";
import { Mail, MessageSquare } from "lucide-react";
// Helper functions for formatting
const formatRate = (value) => {
const formatRate = (value, isSMS = false, hideForSMS = false) => {
if (isSMS && hideForSMS) return "N/A";
if (typeof value !== "number") return "0.0%";
return `${(value * 100).toFixed(1)}%`;
};
@@ -50,25 +60,46 @@ const MetricCell = ({
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>
);
isSMS = false,
hideForSMS = false,
}) => {
if (isSMS && hideForSMS) {
return (
<td className="p-2 text-center">
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold">N/A</div>
<div className="text-gray-400 dark:text-gray-500 text-sm">-</div>
</td>
);
}
const KlaviyoCampaigns = ({ className, timeRange = "last7days" }) => {
return (
<td className="p-2 text-center">
<div className="text-blue-600 dark:text-blue-400 text-lg font-semibold">
{isMonetary ? formatCurrency(value) : formatRate(value, isSMS, hideForSMS)}
</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 CHANNEL_OPTIONS = [
{ value: "all", label: "All Campaigns" },
{ value: "email", label: "Email Only" },
{ value: "sms", label: "SMS Only" },
];
const KlaviyoCampaigns = ({ className }) => {
const [campaigns, setCampaigns] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [searchTerm, setSearchTerm] = useState("");
const [selectedChannel, setSelectedChannel] = useState("all");
const [selectedTimeRange, setSelectedTimeRange] = useState("last7days");
const [sortConfig, setSortConfig] = useState({
key: "send_time",
direction: "desc",
@@ -77,7 +108,9 @@ const KlaviyoCampaigns = ({ className, timeRange = "last7days" }) => {
const fetchCampaigns = async () => {
try {
setIsLoading(true);
const response = await fetch(`/api/klaviyo/reporting/campaigns/${timeRange}`);
const response = await fetch(
`/api/klaviyo/reporting/campaigns/${selectedTimeRange}${selectedChannel !== 'all' ? `?channel=${selectedChannel}` : ''}`
);
if (!response.ok) {
throw new Error(`Failed to fetch campaigns: ${response.status}`);
@@ -98,7 +131,7 @@ const KlaviyoCampaigns = ({ className, timeRange = "last7days" }) => {
fetchCampaigns();
const interval = setInterval(fetchCampaigns, 10 * 60 * 1000); // Refresh every 10 minutes
return () => clearInterval(interval);
}, [timeRange]);
}, [selectedTimeRange, selectedChannel]);
// Sort campaigns
const sortedCampaigns = [...campaigns].sort((a, b) => {
@@ -109,10 +142,11 @@ const KlaviyoCampaigns = ({ className, timeRange = "last7days" }) => {
return direction * (a[sortConfig.key] - b[sortConfig.key]);
});
// Filter campaigns by search term
// Filter campaigns by search term and channel
const filteredCampaigns = sortedCampaigns.filter(
(campaign) =>
campaign?.name?.toLowerCase().includes((searchTerm || "").toLowerCase())
campaign?.name?.toLowerCase().includes((searchTerm || "").toLowerCase()) &&
(selectedChannel === "all" || campaign?.channel === selectedChannel)
);
if (isLoading) {
@@ -132,9 +166,37 @@ const KlaviyoCampaigns = ({ className, timeRange = "last7days" }) => {
<Card className="h-full bg-white dark:bg-gray-900">
{error && <ErrorAlert description={error} />}
<CardHeader className="pb-2">
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
Email Campaigns
</CardTitle>
<div className="flex justify-between items-center">
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
Campaigns
</CardTitle>
<div className="flex gap-2">
<Select value={selectedChannel} onValueChange={setSelectedChannel}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select channel" />
</SelectTrigger>
<SelectContent>
{CHANNEL_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={selectedTimeRange} onValueChange={setSelectedTimeRange}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select time range" />
</SelectTrigger>
<SelectContent>
{TIME_RANGES.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent className="overflow-y-auto pl-4 max-h-[350px] mb-4">
<table className="w-full">
@@ -170,8 +232,15 @@ const KlaviyoCampaigns = ({ className, timeRange = "last7days" }) => {
<Tooltip>
<TooltipTrigger asChild>
<td className="p-2 align-top">
<div className="font-medium text-gray-900 dark:text-gray-100">
{campaign.name}
<div className="flex items-center gap-2">
<div className="font-medium text-gray-900 dark:text-gray-100">
{campaign.name}
</div>
{campaign.channel === 'sms' ? (
<MessageSquare className="h-4 w-4 text-gray-500" />
) : (
<Mail className="h-4 w-4 text-gray-500" />
)}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 truncate max-w-[300px]">
{campaign.subject}
@@ -201,21 +270,27 @@ const KlaviyoCampaigns = ({ className, timeRange = "last7days" }) => {
value={campaign.stats.delivery_rate}
count={campaign.stats.delivered}
totalRecipients={campaign.stats.recipients}
isSMS={campaign.channel === 'sms'}
/>
<MetricCell
value={campaign.stats.open_rate}
count={campaign.stats.opens_unique}
totalRecipients={campaign.stats.recipients}
isSMS={campaign.channel === 'sms'}
hideForSMS={true}
/>
<MetricCell
value={campaign.stats.click_rate}
count={campaign.stats.clicks_unique}
totalRecipients={campaign.stats.recipients}
isSMS={campaign.channel === 'sms'}
/>
<MetricCell
value={campaign.stats.click_to_open_rate}
count={campaign.stats.clicks_unique}
totalRecipients={campaign.stats.opens_unique}
isSMS={campaign.channel === 'sms'}
hideForSMS={true}
/>
<MetricCell
value={campaign.stats.conversion_value}
@@ -223,6 +298,7 @@ const KlaviyoCampaigns = ({ className, timeRange = "last7days" }) => {
isMonetary={true}
showConversionRate={true}
totalRecipients={campaign.stats.recipients}
isSMS={campaign.channel === 'sms'}
/>
</tr>
))}