Compare commits
5 Commits
b2d83a0cd6
...
icons
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7478a4af7 | ||
|
|
749cabf0bf | ||
|
|
036500f6d1 | ||
|
|
57d6e3a449 | ||
|
|
509e5a51a7 |
@@ -33,6 +33,11 @@ export default function CreatePage() {
|
||||
const [cornerStyle, setCornerStyle] = useState('square');
|
||||
const [size, setSize] = useState(200);
|
||||
|
||||
// Logo state
|
||||
const [logoUrl, setLogoUrl] = useState('');
|
||||
const [logoSize, setLogoSize] = useState(24);
|
||||
const [excavate, setExcavate] = useState(true);
|
||||
|
||||
// QR preview
|
||||
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||
|
||||
@@ -150,6 +155,48 @@ export default function CreatePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.size > 10 * 1024 * 1024) { // 10MB limit (soft limit for upload, will be resized)
|
||||
showToast('Logo file size too large (max 10MB)', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (evt) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const maxDimension = 500; // Resize to max 500px
|
||||
let width = img.width;
|
||||
let height = img.height;
|
||||
|
||||
if (width > maxDimension || height > maxDimension) {
|
||||
if (width > height) {
|
||||
height = Math.round((height * maxDimension) / width);
|
||||
width = maxDimension;
|
||||
} else {
|
||||
width = Math.round((width * maxDimension) / height);
|
||||
height = maxDimension;
|
||||
}
|
||||
}
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx?.drawImage(img, 0, 0, width, height);
|
||||
|
||||
// Compress to JPEG/PNG with reduced quality to save space
|
||||
const dataUrl = canvas.toDataURL(file.type === 'image/png' ? 'image/png' : 'image/jpeg', 0.8);
|
||||
setLogoUrl(dataUrl);
|
||||
};
|
||||
img.src = evt.target?.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
@@ -167,6 +214,12 @@ export default function CreatePage() {
|
||||
backgroundColor: canCustomizeColors ? backgroundColor : '#FFFFFF',
|
||||
cornerStyle,
|
||||
size,
|
||||
imageSettings: (canCustomizeColors && logoUrl) ? {
|
||||
src: logoUrl,
|
||||
height: logoSize,
|
||||
width: logoSize,
|
||||
excavate,
|
||||
} : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -488,6 +541,90 @@ export default function CreatePage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Logo Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Logo</CardTitle>
|
||||
{!canCustomizeColors && (
|
||||
<Badge variant="warning">PRO Feature</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!canCustomizeColors && (
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg mb-4">
|
||||
<p className="text-sm text-blue-900">
|
||||
<strong>Upgrade to PRO</strong> to add logos to your QR codes.
|
||||
</p>
|
||||
<Link href="/pricing">
|
||||
<Button variant="primary" size="sm" className="mt-2">
|
||||
Upgrade Now
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Upload Logo
|
||||
</label>
|
||||
<div className="flex items-center space-x-4">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleLogoUpload}
|
||||
disabled={!canCustomizeColors}
|
||||
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
{logoUrl && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setLogoUrl('');
|
||||
setLogoSize(40);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{logoUrl && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Logo Size: {logoSize}px
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="20"
|
||||
max="70"
|
||||
value={logoSize}
|
||||
onChange={(e) => setLogoSize(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={excavate}
|
||||
onChange={(e) => setExcavate(e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
id="excavate-checkbox"
|
||||
/>
|
||||
<label htmlFor="excavate-checkbox" className="ml-2 block text-sm text-gray-900">
|
||||
Excavate background (remove dots behind logo)
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right: Preview */}
|
||||
@@ -505,7 +642,13 @@ export default function CreatePage() {
|
||||
size={200}
|
||||
fgColor={foregroundColor}
|
||||
bgColor={backgroundColor}
|
||||
level="M"
|
||||
level="H"
|
||||
imageSettings={logoUrl ? {
|
||||
src: logoUrl,
|
||||
height: logoSize,
|
||||
width: logoSize,
|
||||
excavate: excavate,
|
||||
} : undefined}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Check newsletter-admin cookie authentication
|
||||
|
||||
@@ -149,7 +149,7 @@ export async function GET(request: NextRequest) {
|
||||
? Math.round((previousUniqueScans / previousTotalScans) * 100)
|
||||
: 0;
|
||||
|
||||
const avgScansTrend = calculateTrend(currentConversion, previousConversion);
|
||||
const avgScansTrend = calculateTrend(avgScansPerQR, previousAvgScansPerQR);
|
||||
|
||||
// Device stats
|
||||
const deviceStats = qrCodes.flatMap(qr => qr.scans)
|
||||
@@ -245,7 +245,7 @@ export async function GET(request: NextRequest) {
|
||||
summary: {
|
||||
totalScans,
|
||||
uniqueScans,
|
||||
avgScansPerQR: currentConversion, // Now sending Unique Rate instead of Avg per QR
|
||||
avgScansPerQR,
|
||||
mobilePercentage,
|
||||
topCountry: topCountry ? topCountry[0] : 'N/A',
|
||||
topCountryPercentage: topCountry && totalScans > 0
|
||||
|
||||
@@ -81,37 +81,37 @@ const countryNameToCode: Record<string, string> = {
|
||||
};
|
||||
|
||||
// ISO Alpha-2 to ISO Alpha-3 mapping (for matching with TopoJSON)
|
||||
const alpha2ToAlpha3: Record<string, string> = {
|
||||
'US': 'USA',
|
||||
'DE': 'DEU',
|
||||
'GB': 'GBR',
|
||||
'FR': 'FRA',
|
||||
'CA': 'CAN',
|
||||
'AU': 'AUS',
|
||||
'JP': 'JPN',
|
||||
'CN': 'CHN',
|
||||
'IN': 'IND',
|
||||
'BR': 'BRA',
|
||||
'ES': 'ESP',
|
||||
'IT': 'ITA',
|
||||
'NL': 'NLD',
|
||||
'CH': 'CHE',
|
||||
'AT': 'AUT',
|
||||
'PL': 'POL',
|
||||
'SE': 'SWE',
|
||||
'NO': 'NOR',
|
||||
'DK': 'DNK',
|
||||
'FI': 'FIN',
|
||||
'BE': 'BEL',
|
||||
'PT': 'PRT',
|
||||
'IE': 'IRL',
|
||||
'MX': 'MEX',
|
||||
'AR': 'ARG',
|
||||
'KR': 'KOR',
|
||||
'SG': 'SGP',
|
||||
'NZ': 'NZL',
|
||||
'RU': 'RUS',
|
||||
'ZA': 'ZAF',
|
||||
const alpha2ToNumeric: Record<string, string> = {
|
||||
'US': '840',
|
||||
'DE': '276',
|
||||
'GB': '826',
|
||||
'FR': '250',
|
||||
'CA': '124',
|
||||
'AU': '036',
|
||||
'JP': '392',
|
||||
'CN': '156',
|
||||
'IN': '356',
|
||||
'BR': '076',
|
||||
'ES': '724',
|
||||
'IT': '380',
|
||||
'NL': '528',
|
||||
'CH': '756',
|
||||
'AT': '040',
|
||||
'PL': '616',
|
||||
'SE': '752',
|
||||
'NO': '578',
|
||||
'DK': '208',
|
||||
'FI': '246',
|
||||
'BE': '056',
|
||||
'PT': '620',
|
||||
'IE': '372',
|
||||
'MX': '484',
|
||||
'AR': '032',
|
||||
'KR': '410',
|
||||
'SG': '702',
|
||||
'NZ': '554',
|
||||
'RU': '643',
|
||||
'ZA': '710',
|
||||
};
|
||||
|
||||
interface CountryStat {
|
||||
@@ -132,9 +132,9 @@ const GeoMap: React.FC<GeoMapProps> = ({ countryStats, totalScans }) => {
|
||||
|
||||
countryStats.forEach((stat) => {
|
||||
const alpha2 = countryNameToCode[stat.country] || stat.country;
|
||||
const alpha3 = alpha2ToAlpha3[alpha2];
|
||||
if (alpha3) {
|
||||
countryData[alpha3] = stat.count;
|
||||
const numericCode = alpha2ToNumeric[alpha2];
|
||||
if (numericCode) {
|
||||
countryData[numericCode] = stat.count;
|
||||
if (stat.count > maxCount) maxCount = stat.count;
|
||||
}
|
||||
});
|
||||
@@ -144,8 +144,16 @@ const GeoMap: React.FC<GeoMapProps> = ({ countryStats, totalScans }) => {
|
||||
.domain([0, maxCount || 1])
|
||||
.range(['#E0F2FE', '#1E40AF']);
|
||||
|
||||
const [tooltipContent, setTooltipContent] = React.useState<{ name: string; count: number } | null>(null);
|
||||
const [tooltipPos, setTooltipPos] = React.useState({ x: 0, y: 0 });
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<div
|
||||
className="w-full h-full relative group"
|
||||
onMouseMove={(evt) => {
|
||||
setTooltipPos({ x: evt.clientX, y: evt.clientY });
|
||||
}}
|
||||
>
|
||||
<ComposableMap
|
||||
projection="geoMercator"
|
||||
projectionConfig={{
|
||||
@@ -158,8 +166,9 @@ const GeoMap: React.FC<GeoMapProps> = ({ countryStats, totalScans }) => {
|
||||
<Geographies geography={geoUrl}>
|
||||
{({ geographies }) =>
|
||||
geographies.map((geo) => {
|
||||
const isoCode = geo.properties.ISO_A3 || geo.id;
|
||||
const scanCount = countryData[isoCode] || 0;
|
||||
// geo.id is the numeric ISO code as a string (e.g., "840" for US)
|
||||
const geoId = geo.id;
|
||||
const scanCount = countryData[geoId] || 0;
|
||||
const fillColor = scanCount > 0 ? colorScale(scanCount) : '#F1F5F9';
|
||||
|
||||
return (
|
||||
@@ -178,6 +187,13 @@ const GeoMap: React.FC<GeoMapProps> = ({ countryStats, totalScans }) => {
|
||||
},
|
||||
pressed: { outline: 'none' },
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
const { name } = geo.properties;
|
||||
setTooltipContent({ name, count: scanCount });
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setTooltipContent(null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
@@ -185,6 +201,24 @@ const GeoMap: React.FC<GeoMapProps> = ({ countryStats, totalScans }) => {
|
||||
</Geographies>
|
||||
</ZoomableGroup>
|
||||
</ComposableMap>
|
||||
|
||||
{tooltipContent && (
|
||||
<div
|
||||
className="fixed z-50 px-3 py-2 text-sm font-medium text-white bg-gray-900 rounded-lg shadow-xl pointer-events-none transform -translate-x-1/2 -translate-y-full"
|
||||
style={{
|
||||
left: tooltipPos.x,
|
||||
top: tooltipPos.y - 10,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{tooltipContent.name}</span>
|
||||
<span className="text-gray-400">|</span>
|
||||
<span className="font-bold text-blue-400">{tooltipContent.count} scans</span>
|
||||
</div>
|
||||
{/* Arrow */}
|
||||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-full w-0 h-0 border-l-4 border-r-4 border-t-4 border-l-transparent border-r-transparent border-t-gray-900"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -200,7 +200,13 @@ END:VCARD`;
|
||||
size={96}
|
||||
fgColor={qr.style?.foregroundColor || '#000000'}
|
||||
bgColor={qr.style?.backgroundColor || '#FFFFFF'}
|
||||
level="M"
|
||||
level="H"
|
||||
imageSettings={qr.style?.imageSettings ? {
|
||||
src: qr.style.imageSettings.src,
|
||||
height: qr.style.imageSettings.height * (96 / 200), // Scale logo for smaller QR
|
||||
width: qr.style.imageSettings.width * (96 / 200),
|
||||
excavate: qr.style.imageSettings.excavate,
|
||||
} : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user