feat: add analytics summary API, dashboard page with stats grid, and blog post detail page.
This commit is contained in:
parent
0302821f0f
commit
d0a114c1c3
|
|
@ -218,12 +218,13 @@ export default function DashboardPage() {
|
||||||
// Calculate real stats
|
// Calculate real stats
|
||||||
const totalScans = data.reduce((sum: number, qr: QRCodeData) => sum + (qr.scans || 0), 0);
|
const totalScans = data.reduce((sum: number, qr: QRCodeData) => sum + (qr.scans || 0), 0);
|
||||||
const activeQRCodes = data.filter((qr: QRCodeData) => qr.status === 'ACTIVE').length;
|
const activeQRCodes = data.filter((qr: QRCodeData) => qr.status === 'ACTIVE').length;
|
||||||
const conversionRate = activeQRCodes > 0 ? Math.round((totalScans / (activeQRCodes * 100)) * 100) : 0;
|
// Calculate "Unique Rate" (Conversion)
|
||||||
|
const conversionRate = totalScans > 0 ? Math.round((data.reduce((acc: number, qr: any) => acc + (qr.uniqueScans || 0), 0) / totalScans) * 100) : 0;
|
||||||
|
|
||||||
setStats({
|
setStats({
|
||||||
totalScans,
|
totalScans,
|
||||||
activeQRCodes,
|
activeQRCodes,
|
||||||
conversionRate: Math.min(conversionRate, 100), // Cap at 100%
|
conversionRate,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// If not logged in, show zeros
|
// If not logged in, show zeros
|
||||||
|
|
|
||||||
|
|
@ -2014,6 +2014,30 @@ export default function BlogPostPage({ params }: { params: { slug: string } }) {
|
||||||
<Button size="lg">Create QR Code Free</Button>
|
<Button size="lg">Create QR Code Free</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Related Articles Section */}
|
||||||
|
<div className="mt-16">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-8">Related Articles</h2>
|
||||||
|
<div className="grid md:grid-cols-3 gap-6">
|
||||||
|
{Object.values(blogPosts)
|
||||||
|
.filter((p) => p.slug !== post.slug)
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((relatedPost) => (
|
||||||
|
<Link
|
||||||
|
key={relatedPost.slug}
|
||||||
|
href={`/blog/${relatedPost.slug}`}
|
||||||
|
className="group block bg-gray-50 rounded-xl p-6 hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<Badge variant="default" className="mb-3">{relatedPost.category}</Badge>
|
||||||
|
<h3 className="font-semibold text-gray-900 group-hover:text-primary-600 transition-colors mb-2 line-clamp-2">
|
||||||
|
{relatedPost.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 line-clamp-2">{relatedPost.excerpt}</p>
|
||||||
|
<span className="text-sm text-primary-600 mt-3 inline-block">Read more →</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -140,8 +140,17 @@ export async function GET(request: NextRequest) {
|
||||||
|
|
||||||
// Calculate trends
|
// Calculate trends
|
||||||
const scansTrend = calculateTrend(totalScans, previousTotalScans);
|
const scansTrend = calculateTrend(totalScans, previousTotalScans);
|
||||||
const avgScansTrend = calculateTrend(avgScansPerQR, previousAvgScansPerQR);
|
|
||||||
|
// New Conversion Rate Logic: (Unique Scans / Total Scans) * 100
|
||||||
|
// This represents "Engagement Efficiency" - how many scans are from fresh users
|
||||||
|
const currentConversion = totalScans > 0 ? Math.round((uniqueScans / totalScans) * 100) : 0;
|
||||||
|
|
||||||
|
const previousConversion = previousTotalScans > 0
|
||||||
|
? Math.round((previousUniqueScans / previousTotalScans) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const avgScansTrend = calculateTrend(currentConversion, previousConversion);
|
||||||
|
|
||||||
// Device stats
|
// Device stats
|
||||||
const deviceStats = qrCodes.flatMap(qr => qr.scans)
|
const deviceStats = qrCodes.flatMap(qr => qr.scans)
|
||||||
.reduce((acc, scan) => {
|
.reduce((acc, scan) => {
|
||||||
|
|
@ -149,12 +158,12 @@ export async function GET(request: NextRequest) {
|
||||||
acc[device] = (acc[device] || 0) + 1;
|
acc[device] = (acc[device] || 0) + 1;
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, number>);
|
}, {} as Record<string, number>);
|
||||||
|
|
||||||
const mobileScans = (deviceStats.mobile || 0) + (deviceStats.tablet || 0);
|
const mobileScans = (deviceStats.mobile || 0) + (deviceStats.tablet || 0);
|
||||||
const mobilePercentage = totalScans > 0
|
const mobilePercentage = totalScans > 0
|
||||||
? Math.round((mobileScans / totalScans) * 100)
|
? Math.round((mobileScans / totalScans) * 100)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// Country stats (current period)
|
// Country stats (current period)
|
||||||
const countryStats = qrCodes.flatMap(qr => qr.scans)
|
const countryStats = qrCodes.flatMap(qr => qr.scans)
|
||||||
.reduce((acc, scan) => {
|
.reduce((acc, scan) => {
|
||||||
|
|
@ -172,8 +181,8 @@ export async function GET(request: NextRequest) {
|
||||||
}, {} as Record<string, number>);
|
}, {} as Record<string, number>);
|
||||||
|
|
||||||
const topCountry = Object.entries(countryStats)
|
const topCountry = Object.entries(countryStats)
|
||||||
.sort(([,a], [,b]) => b - a)[0];
|
.sort(([, a], [, b]) => b - a)[0];
|
||||||
|
|
||||||
// Daily scan counts for chart (current period)
|
// Daily scan counts for chart (current period)
|
||||||
const dailyScans = qrCodes.flatMap(qr => qr.scans).reduce((acc, scan) => {
|
const dailyScans = qrCodes.flatMap(qr => qr.scans).reduce((acc, scan) => {
|
||||||
const date = new Date(scan.ts).toISOString().split('T')[0];
|
const date = new Date(scan.ts).toISOString().split('T')[0];
|
||||||
|
|
@ -215,7 +224,7 @@ export async function GET(request: NextRequest) {
|
||||||
summary: {
|
summary: {
|
||||||
totalScans,
|
totalScans,
|
||||||
uniqueScans,
|
uniqueScans,
|
||||||
avgScansPerQR,
|
avgScansPerQR: currentConversion, // Now sending Unique Rate instead of Avg per QR
|
||||||
mobilePercentage,
|
mobilePercentage,
|
||||||
topCountry: topCountry ? topCountry[0] : 'N/A',
|
topCountry: topCountry ? topCountry[0] : 'N/A',
|
||||||
topCountryPercentage: topCountry && totalScans > 0
|
topCountryPercentage: topCountry && totalScans > 0
|
||||||
|
|
@ -228,7 +237,7 @@ export async function GET(request: NextRequest) {
|
||||||
},
|
},
|
||||||
deviceStats,
|
deviceStats,
|
||||||
countryStats: Object.entries(countryStats)
|
countryStats: Object.entries(countryStats)
|
||||||
.sort(([,a], [,b]) => b - a)
|
.sort(([, a], [, b]) => b - a)
|
||||||
.slice(0, 10)
|
.slice(0, 10)
|
||||||
.map(([country, count]) => {
|
.map(([country, count]) => {
|
||||||
const previousCount = previousCountryStats[country] || 0;
|
const previousCount = previousCountryStats[country] || 0;
|
||||||
|
|
|
||||||
|
|
@ -67,9 +67,9 @@ export const StatsGrid: React.FC<StatsGridProps> = ({ stats, trends }) => {
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('dashboard.stats.conversion_rate'),
|
title: 'Unique Scan Rate',
|
||||||
value: `${stats.conversionRate}%`,
|
value: `${stats.conversionRate}%`,
|
||||||
change: stats.totalScans > 0 ? `${stats.conversionRate}% rate` : 'No scans yet',
|
change: stats.totalScans > 0 ? `${stats.conversionRate}% new users` : 'No scans yet',
|
||||||
changeType: stats.conversionRate > 0 ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',
|
changeType: stats.conversionRate > 0 ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',
|
||||||
icon: (
|
icon: (
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|
@ -88,11 +88,10 @@ export const StatsGrid: React.FC<StatsGridProps> = ({ stats, trends }) => {
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600 mb-1">{card.title}</p>
|
<p className="text-sm text-gray-600 mb-1">{card.title}</p>
|
||||||
<p className="text-2xl font-bold text-gray-900">{card.value}</p>
|
<p className="text-2xl font-bold text-gray-900">{card.value}</p>
|
||||||
<p className={`text-sm mt-2 ${
|
<p className={`text-sm mt-2 ${card.changeType === 'positive' ? 'text-success-600' :
|
||||||
card.changeType === 'positive' ? 'text-success-600' :
|
|
||||||
card.changeType === 'negative' ? 'text-red-600' :
|
card.changeType === 'negative' ? 'text-red-600' :
|
||||||
'text-gray-500'
|
'text-gray-500'
|
||||||
}`}>
|
}`}>
|
||||||
{card.change}
|
{card.change}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue