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) => { router.get('/campaigns/:timeRange', async (req, res) => {
try { try {
const { timeRange } = req.params; const { timeRange } = req.params;
const { startDate, endDate } = req.query; const { channel } = req.query;
let params = {}; const reports = await reportingService.getCampaignReports({
if (timeRange === 'custom' && startDate && endDate) { timeRange,
const range = timeManager.getCustomRange(startDate, endDate); channel
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
}); });
res.json(reports);
} catch (error) {
console.error('[ReportingRoutes] Error fetching campaign reports:', error);
res.status(500).json({ error: error.message });
} }
}); });

View File

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

View File

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