feat: Complete marketing schema, client portal APIs, and CSV export
This commit is contained in:
114
frontend/src/pages/api/client/dashboard.ts
Normal file
114
frontend/src/pages/api/client/dashboard.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
// @ts-ignore - Astro types available at build time
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDirectusClient, readItems, aggregate } from '@/lib/directus/client';
|
||||
|
||||
/**
|
||||
* Client Dashboard API
|
||||
*
|
||||
* Returns stats and recent leads for a client's site.
|
||||
* Used by the client portal.
|
||||
*
|
||||
* GET /api/client/dashboard?site_id={id}
|
||||
*/
|
||||
export const GET: APIRoute = async ({ url }) => {
|
||||
try {
|
||||
const siteId = url.searchParams.get('site_id');
|
||||
|
||||
if (!siteId) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'site_id is required' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const directus = getDirectusClient();
|
||||
|
||||
// Get lead stats
|
||||
const [totalLeads, newLeads, convertedLeads, recentLeads, leadsByStatus] = await Promise.all([
|
||||
// Total leads count
|
||||
directus.request(
|
||||
readItems('leads', {
|
||||
filter: { site: { _eq: siteId } },
|
||||
aggregate: { count: '*' }
|
||||
})
|
||||
),
|
||||
// New leads (last 7 days)
|
||||
directus.request(
|
||||
readItems('leads', {
|
||||
filter: {
|
||||
site: { _eq: siteId },
|
||||
date_created: { _gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString() }
|
||||
},
|
||||
aggregate: { count: '*' }
|
||||
})
|
||||
),
|
||||
// Converted leads
|
||||
directus.request(
|
||||
readItems('leads', {
|
||||
filter: {
|
||||
site: { _eq: siteId },
|
||||
status: { _eq: 'converted' }
|
||||
},
|
||||
aggregate: { count: '*' }
|
||||
})
|
||||
),
|
||||
// Recent leads (last 10)
|
||||
directus.request(
|
||||
readItems('leads', {
|
||||
filter: { site: { _eq: siteId } },
|
||||
sort: ['-date_created'],
|
||||
limit: 10,
|
||||
fields: ['id', 'name', 'email', 'phone', 'source', 'status', 'date_created']
|
||||
})
|
||||
),
|
||||
// Leads grouped by status
|
||||
directus.request(
|
||||
readItems('leads', {
|
||||
filter: { site: { _eq: siteId } },
|
||||
groupBy: ['status'],
|
||||
aggregate: { count: '*' }
|
||||
})
|
||||
)
|
||||
]);
|
||||
|
||||
// Calculate total value
|
||||
const valueData = await directus.request(
|
||||
readItems('leads', {
|
||||
filter: {
|
||||
site: { _eq: siteId },
|
||||
status: { _eq: 'converted' }
|
||||
},
|
||||
aggregate: { sum: 'value' }
|
||||
})
|
||||
);
|
||||
|
||||
const stats = {
|
||||
total_leads: parseInt((totalLeads as any)?.[0]?.count || '0', 10),
|
||||
new_leads_7_days: parseInt((newLeads as any)?.[0]?.count || '0', 10),
|
||||
converted_leads: parseInt((convertedLeads as any)?.[0]?.count || '0', 10),
|
||||
total_value: parseFloat((valueData as any)?.[0]?.sum?.value || '0'),
|
||||
leads_by_status: (leadsByStatus as any[]).reduce((acc, item) => {
|
||||
acc[item.status] = parseInt(item.count, 10);
|
||||
return acc;
|
||||
}, {} as Record<string, number>)
|
||||
};
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
stats,
|
||||
recent_leads: recentLeads,
|
||||
export_url: `/api/leads/export?site_id=${siteId}`
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard:', error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to fetch dashboard data' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
155
frontend/src/pages/api/leads/export.ts
Normal file
155
frontend/src/pages/api/leads/export.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
// @ts-ignore - Astro types available at build time
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDirectusClient, readItems } from '@/lib/directus/client';
|
||||
|
||||
/**
|
||||
* Lead Export API - CSV Download
|
||||
*
|
||||
* Exports leads for a specific site as CSV.
|
||||
* Clients can only see their own site's leads.
|
||||
*
|
||||
* GET /api/leads/export?site_id={id}&format=csv
|
||||
*/
|
||||
export const GET: APIRoute = async ({ request, url }) => {
|
||||
try {
|
||||
const siteId = url.searchParams.get('site_id');
|
||||
const format = url.searchParams.get('format') || 'csv';
|
||||
const status = url.searchParams.get('status'); // Optional filter
|
||||
const startDate = url.searchParams.get('start_date');
|
||||
const endDate = url.searchParams.get('end_date');
|
||||
|
||||
if (!siteId) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'site_id is required' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const directus = getDirectusClient();
|
||||
|
||||
// Build filter
|
||||
const filter: Record<string, any> = {
|
||||
site: { _eq: siteId }
|
||||
};
|
||||
|
||||
if (status) {
|
||||
filter.status = { _eq: status };
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
filter.date_created = { _gte: startDate };
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
filter.date_created = { ...filter.date_created, _lte: endDate };
|
||||
}
|
||||
|
||||
// Fetch leads for this site
|
||||
const leads = await directus.request(
|
||||
readItems('leads', {
|
||||
filter,
|
||||
sort: ['-date_created'],
|
||||
limit: -1, // All leads
|
||||
fields: [
|
||||
'id',
|
||||
'name',
|
||||
'email',
|
||||
'phone',
|
||||
'message',
|
||||
'source',
|
||||
'status',
|
||||
'page_url',
|
||||
'landing_page',
|
||||
'referrer',
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_campaign',
|
||||
'value',
|
||||
'notes',
|
||||
'date_created'
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
if (format === 'json') {
|
||||
return new Response(
|
||||
JSON.stringify({ leads, total: (leads as any[]).length }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Generate CSV
|
||||
const csvHeaders = [
|
||||
'ID',
|
||||
'Name',
|
||||
'Email',
|
||||
'Phone',
|
||||
'Message',
|
||||
'Source',
|
||||
'Status',
|
||||
'Page URL',
|
||||
'Landing Page',
|
||||
'Referrer',
|
||||
'UTM Source',
|
||||
'UTM Medium',
|
||||
'UTM Campaign',
|
||||
'Value',
|
||||
'Notes',
|
||||
'Date Created'
|
||||
];
|
||||
|
||||
const csvRows = (leads as any[]).map(lead => [
|
||||
lead.id,
|
||||
escapeCsvField(lead.name),
|
||||
escapeCsvField(lead.email),
|
||||
escapeCsvField(lead.phone),
|
||||
escapeCsvField(lead.message),
|
||||
escapeCsvField(lead.source),
|
||||
escapeCsvField(lead.status),
|
||||
escapeCsvField(lead.page_url),
|
||||
escapeCsvField(lead.landing_page),
|
||||
escapeCsvField(lead.referrer),
|
||||
escapeCsvField(lead.utm_source),
|
||||
escapeCsvField(lead.utm_medium),
|
||||
escapeCsvField(lead.utm_campaign),
|
||||
lead.value || '',
|
||||
escapeCsvField(lead.notes),
|
||||
formatDate(lead.date_created)
|
||||
].join(','));
|
||||
|
||||
const csv = [csvHeaders.join(','), ...csvRows].join('\n');
|
||||
const filename = `leads-export-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
|
||||
return new Response(csv, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/csv',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error exporting leads:', error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to export leads' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function escapeCsvField(value: string | null | undefined): string {
|
||||
if (!value) return '';
|
||||
// Escape quotes and wrap in quotes if contains comma, quote, or newline
|
||||
const stringValue = String(value);
|
||||
if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) {
|
||||
return `"${stringValue.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return stringValue;
|
||||
}
|
||||
|
||||
function formatDate(dateString: string | null): string {
|
||||
if (!dateString) return '';
|
||||
return new Date(dateString).toLocaleString();
|
||||
}
|
||||
Reference in New Issue
Block a user