Initial commit - QR Master application
This commit is contained in:
186
src/components/dashboard/QRCodeCard.tsx
Normal file
186
src/components/dashboard/QRCodeCard.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Dropdown, DropdownItem } from '@/components/ui/Dropdown';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
|
||||
interface QRCodeCardProps {
|
||||
qr: {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'STATIC' | 'DYNAMIC';
|
||||
contentType: string;
|
||||
content?: any;
|
||||
slug: string;
|
||||
status: 'ACTIVE' | 'PAUSED';
|
||||
createdAt: string;
|
||||
scans?: number;
|
||||
};
|
||||
onEdit: (id: string) => void;
|
||||
onDuplicate: (id: string) => void;
|
||||
onPause: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export const QRCodeCard: React.FC<QRCodeCardProps> = ({
|
||||
qr,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onPause,
|
||||
onDelete,
|
||||
}) => {
|
||||
// For dynamic QR codes, use the redirect URL for tracking
|
||||
// For static QR codes, use the direct URL from content
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || (typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3050');
|
||||
|
||||
// Get the QR URL based on type
|
||||
let qrUrl = '';
|
||||
|
||||
// SIMPLE FIX: For STATIC QR codes, ALWAYS use the direct content
|
||||
if (qr.type === 'STATIC') {
|
||||
// Extract the actual URL/content based on contentType
|
||||
if (qr.contentType === 'URL' && qr.content?.url) {
|
||||
qrUrl = qr.content.url;
|
||||
} else if (qr.contentType === 'PHONE' && qr.content?.phone) {
|
||||
qrUrl = `tel:${qr.content.phone}`;
|
||||
} else if (qr.contentType === 'EMAIL' && qr.content?.email) {
|
||||
qrUrl = `mailto:${qr.content.email}`;
|
||||
} else if (qr.contentType === 'TEXT' && qr.content?.text) {
|
||||
qrUrl = qr.content.text;
|
||||
} else if (qr.content?.qrContent) {
|
||||
// Fallback to qrContent if it exists
|
||||
qrUrl = qr.content.qrContent;
|
||||
} else {
|
||||
// Last resort fallback
|
||||
qrUrl = `${baseUrl}/r/${qr.slug}`;
|
||||
}
|
||||
console.log(`STATIC QR [${qr.title}]: ${qrUrl}`);
|
||||
} else {
|
||||
// DYNAMIC QR codes always use redirect for tracking
|
||||
qrUrl = `${baseUrl}/r/${qr.slug}`;
|
||||
console.log(`DYNAMIC QR [${qr.title}]: ${qrUrl}`);
|
||||
}
|
||||
|
||||
const downloadQR = (format: 'png' | 'svg') => {
|
||||
const svg = document.querySelector(`#qr-${qr.id} svg`);
|
||||
if (!svg) return;
|
||||
|
||||
if (format === 'svg') {
|
||||
const svgData = new XMLSerializer().serializeToString(svg);
|
||||
const blob = new Blob([svgData], { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.svg`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
// Convert SVG to PNG
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const img = new Image();
|
||||
const svgData = new XMLSerializer().serializeToString(svg);
|
||||
const blob = new Blob([svgData], { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
img.onload = () => {
|
||||
canvas.width = 300;
|
||||
canvas.height = 300;
|
||||
ctx?.drawImage(img, 0, 0, 300, 300);
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
});
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
img.src = url;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card hover>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900 mb-1">{qr.title}</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant={qr.type === 'DYNAMIC' ? 'info' : 'default'}>
|
||||
{qr.type}
|
||||
</Badge>
|
||||
<Badge variant={qr.status === 'ACTIVE' ? 'success' : 'warning'}>
|
||||
{qr.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dropdown
|
||||
align="right"
|
||||
trigger={
|
||||
<button className="p-1 hover:bg-gray-100 rounded">
|
||||
<svg className="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<DropdownItem onClick={() => downloadQR('png')}>Download PNG</DropdownItem>
|
||||
<DropdownItem onClick={() => downloadQR('svg')}>Download SVG</DropdownItem>
|
||||
<DropdownItem onClick={() => onEdit(qr.id)}>Edit</DropdownItem>
|
||||
<DropdownItem onClick={() => onDuplicate(qr.id)}>Duplicate</DropdownItem>
|
||||
<DropdownItem onClick={() => onPause(qr.id)}>
|
||||
{qr.status === 'ACTIVE' ? 'Pause' : 'Resume'}
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={() => onDelete(qr.id)} className="text-red-600">
|
||||
Delete
|
||||
</DropdownItem>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<div id={`qr-${qr.id}`} className="flex items-center justify-center bg-gray-50 rounded-lg p-4 mb-3">
|
||||
<QRCodeSVG
|
||||
value={qrUrl}
|
||||
size={96}
|
||||
fgColor="#000000"
|
||||
bgColor="#FFFFFF"
|
||||
level="M"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-500">Type:</span>
|
||||
<span className="text-gray-900">{qr.contentType}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-500">Scans:</span>
|
||||
<span className="text-gray-900">{qr.scans || 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-500">Created:</span>
|
||||
<span className="text-gray-900">{formatDate(qr.createdAt)}</span>
|
||||
</div>
|
||||
{qr.type === 'DYNAMIC' && (
|
||||
<div className="pt-2 border-t">
|
||||
<p className="text-xs text-gray-500">
|
||||
📊 Dynamic QR: Tracks scans via {baseUrl}/r/{qr.slug}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
82
src/components/dashboard/StatsGrid.tsx
Normal file
82
src/components/dashboard/StatsGrid.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { formatNumber } from '@/lib/utils';
|
||||
|
||||
interface StatsGridProps {
|
||||
stats: {
|
||||
totalScans: number;
|
||||
activeQRCodes: number;
|
||||
conversionRate: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const StatsGrid: React.FC<StatsGridProps> = ({ stats }) => {
|
||||
// Only show growth if there are actual scans
|
||||
const showGrowth = stats.totalScans > 0;
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: 'Total Scans',
|
||||
value: formatNumber(stats.totalScans),
|
||||
change: showGrowth ? '+12%' : 'No data yet',
|
||||
changeType: showGrowth ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Active QR Codes',
|
||||
value: stats.activeQRCodes.toString(),
|
||||
change: stats.activeQRCodes > 0 ? `${stats.activeQRCodes} active` : 'Create your first',
|
||||
changeType: stats.activeQRCodes > 0 ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Conversion Rate',
|
||||
value: `${stats.conversionRate}%`,
|
||||
change: stats.totalScans > 0 ? `${stats.conversionRate}% rate` : 'No scans yet',
|
||||
changeType: stats.conversionRate > 0 ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
{cards.map((card, index) => (
|
||||
<Card key={index}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<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-sm mt-2 ${
|
||||
card.changeType === 'positive' ? 'text-success-600' :
|
||||
card.changeType === 'negative' ? 'text-red-600' :
|
||||
'text-gray-500'
|
||||
}`}>
|
||||
{card.changeType === 'neutral' ? card.change : `${card.change} from last month`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center text-primary-600">
|
||||
{card.icon}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user