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