feat: add SVG/PNG download options, track geolocation from CF headers, and fix analytics pie charts by parsing counts as integers
This commit is contained in:
+15
-6
@@ -22,8 +22,8 @@ router.get('/:qrCodeId', verifyToken, async (req, res) => {
|
|||||||
return res.status(403).json({ error: 'Unauthorized or QR code not found' });
|
return res.status(403).json({ error: 'Unauthorized or QR code not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get total scans
|
// Get total scans and unique scanners
|
||||||
const totalQuery = await pgPool.query('SELECT COUNT(*) as total FROM qr_scans WHERE qr_code_id = $1', [qrCodeId]);
|
const totalQuery = await pgPool.query('SELECT COUNT(*) as total, COUNT(DISTINCT ip_address) as unique_scanners FROM qr_scans WHERE qr_code_id = $1', [qrCodeId]);
|
||||||
|
|
||||||
// Get OS breakdown
|
// Get OS breakdown
|
||||||
const osQuery = await pgPool.query(
|
const osQuery = await pgPool.query(
|
||||||
@@ -37,6 +37,12 @@ router.get('/:qrCodeId', verifyToken, async (req, res) => {
|
|||||||
[qrCodeId]
|
[qrCodeId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Get Country breakdown
|
||||||
|
const countryQuery = await pgPool.query(
|
||||||
|
'SELECT country, COUNT(*) as count FROM qr_scans WHERE qr_code_id = $1 GROUP BY country ORDER BY count DESC',
|
||||||
|
[qrCodeId]
|
||||||
|
);
|
||||||
|
|
||||||
// Get Scans over time (last 7 days grouped by date)
|
// Get Scans over time (last 7 days grouped by date)
|
||||||
const timeQuery = await pgPool.query(
|
const timeQuery = await pgPool.query(
|
||||||
`SELECT DATE(scanned_at) as date, COUNT(*) as count
|
`SELECT DATE(scanned_at) as date, COUNT(*) as count
|
||||||
@@ -46,11 +52,14 @@ router.get('/:qrCodeId', verifyToken, async (req, res) => {
|
|||||||
[qrCodeId]
|
[qrCodeId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Parse string counts from PG back to integers for Recharts
|
||||||
res.json({
|
res.json({
|
||||||
totalScans: parseInt(totalQuery.rows[0].total),
|
totalScans: parseInt(totalQuery.rows[0].total) || 0,
|
||||||
osBreakdown: osQuery.rows,
|
uniqueScanners: parseInt(totalQuery.rows[0].unique_scanners) || 0,
|
||||||
deviceBreakdown: deviceQuery.rows,
|
osBreakdown: osQuery.rows.map(r => ({ ...r, count: parseInt(r.count) })),
|
||||||
timeline: timeQuery.rows
|
deviceBreakdown: deviceQuery.rows.map(r => ({ ...r, count: parseInt(r.count) })),
|
||||||
|
countryBreakdown: countryQuery.rows.map(r => ({ country: r.country || 'Unknown', count: parseInt(r.count) })),
|
||||||
|
timeline: timeQuery.rows.map(r => ({ ...r, count: parseInt(r.count) }))
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|||||||
@@ -53,12 +53,13 @@ router.get('/:shortUrlId', async (req, res) => {
|
|||||||
const parser = new UAParser(req.headers['user-agent']);
|
const parser = new UAParser(req.headers['user-agent']);
|
||||||
const os = parser.getOS().name || 'Unknown';
|
const os = parser.getOS().name || 'Unknown';
|
||||||
const deviceType = parser.getDevice().type || 'desktop';
|
const deviceType = parser.getDevice().type || 'desktop';
|
||||||
const ip = req.ip || req.connection.remoteAddress;
|
const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress;
|
||||||
|
const country = req.headers['cf-ipcountry'] || 'Unknown';
|
||||||
|
|
||||||
pgPool.query(
|
pgPool.query(
|
||||||
`INSERT INTO qr_scans (qr_code_id, ip_address, user_agent, os, device_type)
|
`INSERT INTO qr_scans (qr_code_id, ip_address, user_agent, os, device_type, country)
|
||||||
VALUES ($1, $2, $3, $4, $5)`,
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||||
[qrData.qr_id, ip, req.headers['user-agent'], os, deviceType]
|
[qrData.qr_id, ip, req.headers['user-agent'], os, deviceType, country]
|
||||||
).catch(err => console.error('Failed to log scan:', err));
|
).catch(err => console.error('Failed to log scan:', err));
|
||||||
|
|
||||||
// 4. Check Tier & Redirect
|
// 4. Check Tier & Redirect
|
||||||
|
|||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
hostname: 'localhost',
|
||||||
|
port: 4001,
|
||||||
|
path: '/api/auth/login',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = http.request(options, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => data += chunk);
|
||||||
|
res.on('end', () => {
|
||||||
|
if (!res.headers['set-cookie']) {
|
||||||
|
console.log('No cookie', data); return;
|
||||||
|
}
|
||||||
|
const cookie = res.headers['set-cookie'][0];
|
||||||
|
|
||||||
|
// Generate QR
|
||||||
|
const largeStr = 'a'.repeat(200000);
|
||||||
|
const postData = JSON.stringify({
|
||||||
|
name: 'Test QR',
|
||||||
|
type: 'dynamic',
|
||||||
|
dataType: 'URL',
|
||||||
|
destinationUrl: 'https://google.com',
|
||||||
|
designData: { image: 'data:image/png;base64,' + largeStr }
|
||||||
|
});
|
||||||
|
|
||||||
|
const options2 = {
|
||||||
|
hostname: 'localhost',
|
||||||
|
port: 4001,
|
||||||
|
path: '/api/qr/generate',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(postData),
|
||||||
|
'Cookie': cookie
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const req2 = http.request(options2, (res2) => {
|
||||||
|
let d = '';
|
||||||
|
res2.on('data', c => d+=c);
|
||||||
|
res2.on('end', () => {
|
||||||
|
console.log('Status:', res2.statusCode);
|
||||||
|
console.log('Body:', d);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req2.write(postData);
|
||||||
|
req2.end();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.write(JSON.stringify({email: 'admin@ckqr.com', password: 'password123'}));
|
||||||
|
req.end();
|
||||||
@@ -22,9 +22,11 @@ interface QRCode {
|
|||||||
|
|
||||||
interface AnalyticsData {
|
interface AnalyticsData {
|
||||||
totalScans: number;
|
totalScans: number;
|
||||||
osBreakdown: { os: string; count: string }[];
|
uniqueScanners: number;
|
||||||
deviceBreakdown: { device_type: string; count: string }[];
|
osBreakdown: { os: string; count: number }[];
|
||||||
timeline: { date: string; count: string }[];
|
deviceBreakdown: { device_type: string; count: number }[];
|
||||||
|
countryBreakdown: { country: string; count: number }[];
|
||||||
|
timeline: { date: string; count: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLORS = ['#4f46e5', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
|
const COLORS = ['#4f46e5', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
|
||||||
@@ -126,17 +128,18 @@ export default function AnalyticsPage() {
|
|||||||
<div className="text-5xl font-black text-indigo-600">{analytics.totalScans.toLocaleString()}</div>
|
<div className="text-5xl font-black text-indigo-600">{analytics.totalScans.toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Adding placeholders for conversion rate / unique scanners since backend doesn't track unique yet */}
|
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-200">
|
||||||
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-200 opacity-50">
|
|
||||||
<h3 className="text-sm font-bold text-gray-500 uppercase tracking-wider mb-2">Unique Scanners</h3>
|
<h3 className="text-sm font-bold text-gray-500 uppercase tracking-wider mb-2">Unique Scanners</h3>
|
||||||
<div className="text-5xl font-black text-gray-900">--</div>
|
<div className="text-5xl font-black text-indigo-600">{analytics.uniqueScanners.toLocaleString()}</div>
|
||||||
<p className="text-xs text-gray-400 mt-2">Available in Pro</p>
|
<p className="text-xs text-gray-400 mt-2">Distinct IP Addresses</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-200 opacity-50">
|
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-200">
|
||||||
<h3 className="text-sm font-bold text-gray-500 uppercase tracking-wider mb-2">Top Location</h3>
|
<h3 className="text-sm font-bold text-gray-500 uppercase tracking-wider mb-2">Top Location</h3>
|
||||||
<div className="text-3xl font-black text-gray-900 mt-2">Unknown</div>
|
<div className="text-3xl font-black text-gray-900 mt-2">
|
||||||
<p className="text-xs text-gray-400 mt-2">Available in Pro</p>
|
{analytics.countryBreakdown?.length > 0 ? analytics.countryBreakdown[0].country : 'Unknown'}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 mt-2">By Country</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -243,6 +246,45 @@ export default function AnalyticsPage() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Country Chart */}
|
||||||
|
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-200">
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 mb-6">Locations (Country)</h3>
|
||||||
|
<div className="h-64 flex items-center justify-center">
|
||||||
|
{analytics.countryBreakdown?.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={analytics.countryBreakdown}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={60}
|
||||||
|
outerRadius={80}
|
||||||
|
paddingAngle={5}
|
||||||
|
dataKey="count"
|
||||||
|
nameKey="country"
|
||||||
|
>
|
||||||
|
{analytics.countryBreakdown.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">No location data</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex justify-center gap-4 flex-wrap mt-2">
|
||||||
|
{analytics.countryBreakdown?.map((entry, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded-full" style={{backgroundColor: COLORS[index % COLORS.length]}}></span>
|
||||||
|
<span className="text-sm font-medium text-gray-700 capitalize">{entry.country || 'Unknown'} ({entry.count})</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -82,11 +82,11 @@ export default function CreateQRPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = async (extension: 'png' | 'svg' = 'png') => {
|
||||||
try {
|
try {
|
||||||
const QRCodeStyling = (await import('qr-code-styling')).default;
|
const QRCodeStyling = (await import('qr-code-styling')).default;
|
||||||
const qrCode = new QRCodeStyling(qrOptions);
|
const qrCode = new QRCodeStyling(qrOptions);
|
||||||
qrCode.download({ name: qrName || 'QR-Code', extension: 'png' });
|
qrCode.download({ name: qrName || 'QR-Code', extension });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Download failed', err);
|
console.error('Download failed', err);
|
||||||
alert('Failed to download QR code');
|
alert('Failed to download QR code');
|
||||||
@@ -101,13 +101,20 @@ export default function CreateQRPage() {
|
|||||||
<p className="text-gray-500 mt-1">Design a beautiful, dynamic QR code</p>
|
<p className="text-gray-500 mt-1">Design a beautiful, dynamic QR code</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleDownload}
|
onClick={() => handleDownload('png')}
|
||||||
className="bg-white border border-gray-300 text-gray-700 px-6 py-2.5 rounded-lg hover:bg-gray-50 transition-colors font-semibold shadow-sm flex items-center gap-2"
|
className="bg-white border border-gray-300 text-gray-700 px-6 py-2.5 rounded-lg hover:bg-gray-50 transition-colors font-semibold shadow-sm"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
|
Download PNG
|
||||||
Download
|
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownload('svg')}
|
||||||
|
className="bg-white border border-gray-300 text-gray-700 px-6 py-2.5 rounded-lg hover:bg-gray-50 transition-colors font-semibold shadow-sm"
|
||||||
|
>
|
||||||
|
Download SVG
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleGenerate}
|
onClick={handleGenerate}
|
||||||
disabled={isGenerating}
|
disabled={isGenerating}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = async (qr: QRCode) => {
|
const handleDownload = async (qr: QRCode, extension: 'png' | 'svg' = 'png') => {
|
||||||
try {
|
try {
|
||||||
const QRCodeStyling = (await import('qr-code-styling')).default;
|
const QRCodeStyling = (await import('qr-code-styling')).default;
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://qr.houseofwebsites.ai';
|
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://qr.houseofwebsites.ai';
|
||||||
@@ -90,7 +90,7 @@ export default function DashboardPage() {
|
|||||||
...qr.design_data,
|
...qr.design_data,
|
||||||
data: shortUrl
|
data: shortUrl
|
||||||
});
|
});
|
||||||
qrCode.download({ name: qr.name || 'QR-Code', extension: 'png' });
|
qrCode.download({ name: qr.name || 'QR-Code', extension: extension });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to download QR code', err);
|
console.error('Failed to download QR code', err);
|
||||||
alert('Failed to download QR code');
|
alert('Failed to download QR code');
|
||||||
@@ -187,13 +187,22 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 w-full sm:w-48">
|
<div className="flex flex-col gap-2 w-full sm:w-48">
|
||||||
|
<div className="flex gap-2 w-full">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDownload(qr)}
|
onClick={() => handleDownload(qr, 'png')}
|
||||||
className="w-full bg-green-500 hover:bg-green-600 text-white font-bold py-2.5 px-4 rounded-lg transition-colors flex justify-center items-center gap-2 shadow-sm border border-transparent"
|
className="flex-1 bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-2 rounded-lg transition-colors flex justify-center items-center text-sm shadow-sm"
|
||||||
|
title="Download PNG"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
|
PNG
|
||||||
Download
|
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownload(qr, 'svg')}
|
||||||
|
className="flex-1 bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-2 rounded-lg transition-colors flex justify-center items-center text-sm shadow-sm"
|
||||||
|
title="Download SVG"
|
||||||
|
>
|
||||||
|
SVG
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button onClick={() => openEditModal(qr)} className="flex-1 bg-white border border-gray-300 hover:bg-gray-50 text-gray-700 font-semibold py-2 px-3 rounded-lg transition-colors text-sm">
|
<button onClick={() => openEditModal(qr)} className="flex-1 bg-white border border-gray-300 hover:bg-gray-50 text-gray-700 font-semibold py-2 px-3 rounded-lg transition-colors text-sm">
|
||||||
|
|||||||
BIN
Binary file not shown.
Reference in New Issue
Block a user