Add SMS campaigns
This commit is contained in:
@@ -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;
|
||||
|
||||
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);
|
||||
const { channel } = req.query;
|
||||
|
||||
// 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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
|
||||
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));
|
||||
const allResults = [];
|
||||
|
||||
// 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
|
||||
@@ -195,6 +206,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,
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user