Add SMS campaigns
This commit is contained in:
@@ -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 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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"}
|
||||||
@@ -62,13 +84,22 @@ const MetricCell = ({
|
|||||||
` (${((count / totalRecipients) * 100).toFixed(2)}%)`}
|
` (${((count / totalRecipients) * 100).toFixed(2)}%)`}
|
||||||
</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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user