389 lines
15 KiB
TypeScript
389 lines
15 KiB
TypeScript
'use server';
|
|
|
|
// Sponsored Ads — Server Actions (localStorage-persisted mock)
|
|
|
|
import type {
|
|
Campaign, CampaignWithEvents, CampaignFormData,
|
|
CampaignReport, CampaignStatus, PlacementDailyStats,
|
|
} from '@/lib/types/ads';
|
|
import type { SurfaceKey } from '@/lib/types/ad-control';
|
|
import { MOCK_CAMPAIGNS, MOCK_DAILY_STATS, MOCK_TRACKING_EVENTS } from '@/features/ad-control/data/mockAdsData';
|
|
import { MOCK_PICKER_EVENTS, MOCK_SURFACES } from '@/features/ad-control/data/mockAdData';
|
|
import { computeDailyStats, getTrackingEvents, recordImpression, recordClick } from '@/lib/ads/tracking';
|
|
|
|
const CAMPAIGNS_KEY = 'ad_campaigns';
|
|
const CAMPAIGN_AUDIT_KEY = 'ad_campaign_audit';
|
|
|
|
// ===== Persistence =====
|
|
|
|
function getCampaignStore(): Campaign[] {
|
|
if (typeof window === 'undefined') return MOCK_CAMPAIGNS;
|
|
try {
|
|
const raw = localStorage.getItem(CAMPAIGNS_KEY);
|
|
return raw ? JSON.parse(raw) : MOCK_CAMPAIGNS;
|
|
} catch { return MOCK_CAMPAIGNS; }
|
|
}
|
|
|
|
function saveCampaignStore(campaigns: Campaign[]) {
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.setItem(CAMPAIGNS_KEY, JSON.stringify(campaigns));
|
|
}
|
|
}
|
|
|
|
function logCampaignAudit(campaignId: string, action: string, details?: Record<string, any>) {
|
|
if (typeof window === 'undefined') return;
|
|
try {
|
|
const entries = JSON.parse(localStorage.getItem(CAMPAIGN_AUDIT_KEY) || '[]');
|
|
entries.push({
|
|
id: `ca-${Date.now()}`,
|
|
campaignId,
|
|
actorId: 'admin-1',
|
|
action,
|
|
details: details || null,
|
|
createdAt: new Date().toISOString(),
|
|
});
|
|
localStorage.setItem(CAMPAIGN_AUDIT_KEY, JSON.stringify(entries.slice(-500)));
|
|
} catch { }
|
|
}
|
|
|
|
// ===== Resolve Events =====
|
|
|
|
function resolveEvents(campaign: Campaign): CampaignWithEvents {
|
|
const events = MOCK_PICKER_EVENTS.filter(e => campaign.eventIds.includes(e.id));
|
|
return { ...campaign, events };
|
|
}
|
|
|
|
// ===== Auto-end expired campaigns =====
|
|
|
|
function autoEndCampaigns(campaigns: Campaign[]): Campaign[] {
|
|
const now = new Date().toISOString();
|
|
return campaigns.map(c => {
|
|
if (c.status === 'ACTIVE' && (c.endAt < now || c.spent >= c.totalBudget)) {
|
|
return { ...c, status: 'ENDED' as CampaignStatus, updatedAt: now };
|
|
}
|
|
return c;
|
|
});
|
|
}
|
|
|
|
// ===== QUERIES =====
|
|
|
|
export async function getCampaigns(
|
|
status?: CampaignStatus | 'ALL',
|
|
): Promise<{ success: boolean; data: CampaignWithEvents[] }> {
|
|
let campaigns = autoEndCampaigns(getCampaignStore());
|
|
saveCampaignStore(campaigns);
|
|
|
|
if (status && status !== 'ALL') {
|
|
campaigns = campaigns.filter(c => c.status === status);
|
|
}
|
|
|
|
// Sort: IN_REVIEW first, then ACTIVE, then by updatedAt desc
|
|
const statusOrder: Record<string, number> = { IN_REVIEW: 0, ACTIVE: 1, PAUSED: 2, DRAFT: 3, ENDED: 4, REJECTED: 5 };
|
|
campaigns.sort((a, b) => (statusOrder[a.status] ?? 9) - (statusOrder[b.status] ?? 9) || b.updatedAt.localeCompare(a.updatedAt));
|
|
|
|
return { success: true, data: campaigns.map(resolveEvents) };
|
|
}
|
|
|
|
export async function getCampaign(id: string): Promise<{ success: boolean; data?: CampaignWithEvents; message: string }> {
|
|
const campaigns = getCampaignStore();
|
|
const campaign = campaigns.find(c => c.id === id);
|
|
if (!campaign) return { success: false, message: 'Campaign not found' };
|
|
return { success: true, data: resolveEvents(campaign), message: 'OK' };
|
|
}
|
|
|
|
// ===== MUTATIONS =====
|
|
|
|
export async function createCampaign(
|
|
data: CampaignFormData,
|
|
): Promise<{ success: boolean; message: string; data?: Campaign }> {
|
|
const campaigns = getCampaignStore();
|
|
const now = new Date().toISOString();
|
|
const id = `camp-${Date.now().toString(36)}`;
|
|
|
|
const newCampaign: Campaign = {
|
|
id,
|
|
partnerId: `partner-${data.partnerName.toLowerCase().replace(/\s+/g, '-').slice(0, 10)}`,
|
|
partnerName: data.partnerName,
|
|
name: data.name,
|
|
objective: data.objective,
|
|
status: 'DRAFT',
|
|
startAt: data.startAt,
|
|
endAt: data.endAt,
|
|
billingModel: data.billingModel,
|
|
totalBudget: data.totalBudget,
|
|
dailyCap: data.dailyCap,
|
|
spent: 0,
|
|
targeting: data.targeting,
|
|
surfaceKeys: data.surfaceKeys,
|
|
eventIds: data.eventIds,
|
|
frequencyCap: data.frequencyCap,
|
|
approvedBy: null,
|
|
rejectedReason: null,
|
|
createdBy: 'admin-1',
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
|
|
campaigns.push(newCampaign);
|
|
saveCampaignStore(campaigns);
|
|
logCampaignAudit(id, 'CREATED', { name: data.name });
|
|
return { success: true, message: 'Campaign created as draft', data: newCampaign };
|
|
}
|
|
|
|
export async function updateCampaign(
|
|
id: string,
|
|
patch: Partial<CampaignFormData>,
|
|
): Promise<{ success: boolean; message: string }> {
|
|
const campaigns = getCampaignStore();
|
|
const idx = campaigns.findIndex(c => c.id === id);
|
|
if (idx === -1) return { success: false, message: 'Campaign not found' };
|
|
|
|
const campaign = campaigns[idx];
|
|
if (campaign.status !== 'DRAFT' && campaign.status !== 'PAUSED') {
|
|
return { success: false, message: 'Can only edit draft or paused campaigns' };
|
|
}
|
|
|
|
const updated: Campaign = {
|
|
...campaign,
|
|
...patch,
|
|
updatedAt: new Date().toISOString(),
|
|
} as Campaign;
|
|
|
|
campaigns[idx] = updated;
|
|
saveCampaignStore(campaigns);
|
|
logCampaignAudit(id, 'UPDATED', patch);
|
|
return { success: true, message: 'Campaign updated' };
|
|
}
|
|
|
|
export async function submitCampaign(
|
|
id: string,
|
|
): Promise<{ success: boolean; message: string }> {
|
|
const campaigns = getCampaignStore();
|
|
const idx = campaigns.findIndex(c => c.id === id);
|
|
if (idx === -1) return { success: false, message: 'Campaign not found' };
|
|
if (campaigns[idx].status !== 'DRAFT') return { success: false, message: 'Only draft campaigns can be submitted' };
|
|
|
|
// Validation checks
|
|
const c = campaigns[idx];
|
|
if (c.eventIds.length === 0) return { success: false, message: 'At least one event is required' };
|
|
if (c.surfaceKeys.length === 0) return { success: false, message: 'At least one surface is required' };
|
|
if (c.totalBudget <= 0) return { success: false, message: 'Budget must be positive' };
|
|
if (!c.startAt || !c.endAt) return { success: false, message: 'Schedule dates are required' };
|
|
|
|
campaigns[idx] = { ...c, status: 'IN_REVIEW', updatedAt: new Date().toISOString() };
|
|
saveCampaignStore(campaigns);
|
|
logCampaignAudit(id, 'SUBMITTED');
|
|
return { success: true, message: 'Campaign submitted for review' };
|
|
}
|
|
|
|
export async function approveCampaign(
|
|
id: string,
|
|
): Promise<{ success: boolean; message: string }> {
|
|
const campaigns = getCampaignStore();
|
|
const idx = campaigns.findIndex(c => c.id === id);
|
|
if (idx === -1) return { success: false, message: 'Campaign not found' };
|
|
if (campaigns[idx].status !== 'IN_REVIEW') return { success: false, message: 'Campaign is not in review' };
|
|
|
|
// Eligibility checks
|
|
const c = campaigns[idx];
|
|
for (const eventId of c.eventIds) {
|
|
const event = MOCK_PICKER_EVENTS.find(e => e.id === eventId);
|
|
if (!event) return { success: false, message: `Event ${eventId} not found` };
|
|
if (event.approvalStatus !== 'APPROVED') return { success: false, message: `Event "${event.title}" is not approved` };
|
|
if (new Date(event.endDate) < new Date()) return { success: false, message: `Event "${event.title}" has ended` };
|
|
}
|
|
|
|
campaigns[idx] = { ...c, status: 'ACTIVE', approvedBy: 'admin-1', updatedAt: new Date().toISOString() };
|
|
saveCampaignStore(campaigns);
|
|
logCampaignAudit(id, 'APPROVED');
|
|
return { success: true, message: 'Campaign approved and activated' };
|
|
}
|
|
|
|
export async function rejectCampaign(
|
|
id: string,
|
|
reason: string,
|
|
): Promise<{ success: boolean; message: string }> {
|
|
const campaigns = getCampaignStore();
|
|
const idx = campaigns.findIndex(c => c.id === id);
|
|
if (idx === -1) return { success: false, message: 'Campaign not found' };
|
|
if (campaigns[idx].status !== 'IN_REVIEW') return { success: false, message: 'Campaign is not in review' };
|
|
|
|
campaigns[idx] = { ...campaigns[idx], status: 'REJECTED', rejectedReason: reason, updatedAt: new Date().toISOString() };
|
|
saveCampaignStore(campaigns);
|
|
logCampaignAudit(id, 'REJECTED', { reason });
|
|
return { success: true, message: 'Campaign rejected' };
|
|
}
|
|
|
|
export async function pauseCampaign(
|
|
id: string,
|
|
): Promise<{ success: boolean; message: string }> {
|
|
const campaigns = getCampaignStore();
|
|
const idx = campaigns.findIndex(c => c.id === id);
|
|
if (idx === -1) return { success: false, message: 'Campaign not found' };
|
|
if (campaigns[idx].status !== 'ACTIVE') return { success: false, message: 'Only active campaigns can be paused' };
|
|
|
|
campaigns[idx] = { ...campaigns[idx], status: 'PAUSED', updatedAt: new Date().toISOString() };
|
|
saveCampaignStore(campaigns);
|
|
logCampaignAudit(id, 'PAUSED');
|
|
return { success: true, message: 'Campaign paused' };
|
|
}
|
|
|
|
export async function resumeCampaign(
|
|
id: string,
|
|
): Promise<{ success: boolean; message: string }> {
|
|
const campaigns = getCampaignStore();
|
|
const idx = campaigns.findIndex(c => c.id === id);
|
|
if (idx === -1) return { success: false, message: 'Campaign not found' };
|
|
if (campaigns[idx].status !== 'PAUSED') return { success: false, message: 'Only paused campaigns can be resumed' };
|
|
|
|
campaigns[idx] = { ...campaigns[idx], status: 'ACTIVE', updatedAt: new Date().toISOString() };
|
|
saveCampaignStore(campaigns);
|
|
logCampaignAudit(id, 'RESUMED');
|
|
return { success: true, message: 'Campaign resumed' };
|
|
}
|
|
|
|
// ===== REPORTING =====
|
|
|
|
export async function getCampaignReport(
|
|
id: string,
|
|
): Promise<{ success: boolean; data?: CampaignReport; message: string }> {
|
|
const campaigns = getCampaignStore();
|
|
const campaign = campaigns.find(c => c.id === id);
|
|
if (!campaign) return { success: false, message: 'Campaign not found' };
|
|
|
|
// Get or compute daily stats
|
|
let dailyStats: PlacementDailyStats[];
|
|
if (campaign.id === 'camp-001') {
|
|
// Use pre-computed mock data for demo campaign
|
|
dailyStats = MOCK_DAILY_STATS;
|
|
} else {
|
|
dailyStats = computeDailyStats(
|
|
campaign.id,
|
|
campaign.billingModel,
|
|
campaign.totalBudget / (campaign.billingModel === 'CPM' ? 1000 : campaign.billingModel === 'CPC' ? 500 : 1),
|
|
);
|
|
}
|
|
|
|
// Totals
|
|
const totalImpressions = dailyStats.reduce((s, d) => s + d.impressions, 0);
|
|
const totalClicks = dailyStats.reduce((s, d) => s + d.clicks, 0);
|
|
const totalSpend = dailyStats.reduce((s, d) => s + d.spend, 0);
|
|
|
|
// By surface
|
|
const surfaceMap = new Map<string, { impressions: number; clicks: number; spend: number }>();
|
|
for (const stat of dailyStats) {
|
|
const existing = surfaceMap.get(stat.surfaceKey) || { impressions: 0, clicks: 0, spend: 0 };
|
|
existing.impressions += stat.impressions;
|
|
existing.clicks += stat.clicks;
|
|
existing.spend += stat.spend;
|
|
surfaceMap.set(stat.surfaceKey, existing);
|
|
}
|
|
|
|
const bySurface = Array.from(surfaceMap.entries()).map(([surfaceKey, data]) => {
|
|
const surface = MOCK_SURFACES.find(s => s.key === surfaceKey);
|
|
return {
|
|
surfaceKey: surfaceKey as SurfaceKey,
|
|
surfaceName: surface?.name || surfaceKey,
|
|
impressions: data.impressions,
|
|
clicks: data.clicks,
|
|
ctr: data.impressions > 0 ? Number((data.clicks / data.impressions).toFixed(4)) : 0,
|
|
spend: Number(data.spend.toFixed(2)),
|
|
};
|
|
});
|
|
|
|
const report: CampaignReport = {
|
|
campaign: resolveEvents(campaign),
|
|
totals: {
|
|
impressions: totalImpressions,
|
|
clicks: totalClicks,
|
|
ctr: totalImpressions > 0 ? Number((totalClicks / totalImpressions).toFixed(4)) : 0,
|
|
spend: Number(totalSpend.toFixed(2)),
|
|
remaining: Number((campaign.totalBudget - totalSpend).toFixed(2)),
|
|
},
|
|
dailyStats,
|
|
bySurface,
|
|
};
|
|
|
|
return { success: true, data: report, message: 'OK' };
|
|
}
|
|
|
|
// ===== CSV EXPORT =====
|
|
|
|
export async function exportCampaignCSV(id: string): Promise<{ success: boolean; csv?: string; message: string }> {
|
|
const res = await getCampaignReport(id);
|
|
if (!res.success || !res.data) return { success: false, message: res.message };
|
|
|
|
const { campaign, totals, dailyStats, bySurface } = res.data;
|
|
|
|
const lines: string[] = [
|
|
`Campaign Report: ${campaign.name}`,
|
|
`Partner: ${campaign.partnerName}`,
|
|
`Status: ${campaign.status}`,
|
|
`Period: ${campaign.startAt.slice(0, 10)} to ${campaign.endAt.slice(0, 10)}`,
|
|
`Billing: ${campaign.billingModel}`,
|
|
`Budget: ₹${campaign.totalBudget.toLocaleString()}`,
|
|
`Spent: ₹${totals.spend.toLocaleString()}`,
|
|
``,
|
|
`Date,Surface,Impressions,Clicks,CTR,Spend`,
|
|
...dailyStats.map(d =>
|
|
`${d.date},${d.surfaceKey},${d.impressions},${d.clicks},${(d.ctr * 100).toFixed(2)}%,₹${d.spend.toFixed(2)}`
|
|
),
|
|
``,
|
|
`Surface Summary`,
|
|
`Surface,Impressions,Clicks,CTR,Spend`,
|
|
...bySurface.map(s =>
|
|
`${s.surfaceName},${s.impressions},${s.clicks},${(s.ctr * 100).toFixed(2)}%,₹${s.spend.toFixed(2)}`
|
|
),
|
|
``,
|
|
`Totals,${totals.impressions},${totals.clicks},${(totals.ctr * 100).toFixed(2)}%,₹${totals.spend.toFixed(2)}`,
|
|
];
|
|
|
|
return { success: true, csv: lines.join('\n'), message: 'OK' };
|
|
}
|
|
|
|
// ===== DASHBOARD STATS =====
|
|
|
|
export async function getSponsoredStats(): Promise<{
|
|
success: boolean;
|
|
data: {
|
|
activeCampaigns: number;
|
|
todaySpend: number;
|
|
impressions24h: number;
|
|
clicks24h: number;
|
|
ctr24h: number;
|
|
};
|
|
}> {
|
|
const campaigns = autoEndCampaigns(getCampaignStore());
|
|
const activeCampaigns = campaigns.filter(c => c.status === 'ACTIVE').length;
|
|
|
|
// Last 24h stats from tracking events
|
|
const cutoff24h = new Date(Date.now() - 86400000).toISOString();
|
|
|
|
// For pre-seeded data, use mock stats; for real-time, use tracking
|
|
let impressions24h = 0;
|
|
let clicks24h = 0;
|
|
let todaySpend = 0;
|
|
|
|
// Check real tracking events first
|
|
const allEvents = getTrackingEvents();
|
|
const recent = allEvents.filter(e => e.timestamp > cutoff24h);
|
|
if (recent.length > 0) {
|
|
impressions24h = recent.filter(e => e.type === 'IMPRESSION').length;
|
|
clicks24h = recent.filter(e => e.type === 'CLICK').length;
|
|
todaySpend = Number(((impressions24h / 1000) * 250).toFixed(2)); // ₹250 CPM assumed
|
|
} else {
|
|
// Fallback to mock daily stats (last day)
|
|
const lastDayStats = MOCK_DAILY_STATS.filter(d => d.date === '2026-02-09');
|
|
impressions24h = lastDayStats.reduce((s, d) => s + d.impressions, 0);
|
|
clicks24h = lastDayStats.reduce((s, d) => s + d.clicks, 0);
|
|
todaySpend = lastDayStats.reduce((s, d) => s + d.spend, 0);
|
|
}
|
|
|
|
const ctr24h = impressions24h > 0 ? Number((clicks24h / impressions24h).toFixed(4)) : 0;
|
|
|
|
return {
|
|
success: true,
|
|
data: { activeCampaigns, todaySpend, impressions24h, clicks24h, ctr24h },
|
|
};
|
|
}
|