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 [cornerStyle, setCornerStyle] = useState('square');
|
||||||
const [size, setSize] = useState(200);
|
const [size, setSize] = useState(200);
|
||||||
|
|
||||||
|
// Logo state
|
||||||
|
const [logoUrl, setLogoUrl] = useState('');
|
||||||
|
const [logoSize, setLogoSize] = useState(24);
|
||||||
|
const [excavate, setExcavate] = useState(true);
|
||||||
|
|
||||||
// QR preview
|
// QR preview
|
||||||
const [qrDataUrl, setQrDataUrl] = useState('');
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -167,6 +214,12 @@ export default function CreatePage() {
|
|||||||
backgroundColor: canCustomizeColors ? backgroundColor : '#FFFFFF',
|
backgroundColor: canCustomizeColors ? backgroundColor : '#FFFFFF',
|
||||||
cornerStyle,
|
cornerStyle,
|
||||||
size,
|
size,
|
||||||
|
imageSettings: (canCustomizeColors && logoUrl) ? {
|
||||||
|
src: logoUrl,
|
||||||
|
height: logoSize,
|
||||||
|
width: logoSize,
|
||||||
|
excavate,
|
||||||
|
} : undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -488,6 +541,90 @@ export default function CreatePage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Right: Preview */}
|
{/* Right: Preview */}
|
||||||
@@ -505,7 +642,13 @@ export default function CreatePage() {
|
|||||||
size={200}
|
size={200}
|
||||||
fgColor={foregroundColor}
|
fgColor={foregroundColor}
|
||||||
bgColor={backgroundColor}
|
bgColor={backgroundColor}
|
||||||
level="M"
|
level="H"
|
||||||
|
imageSettings={logoUrl ? {
|
||||||
|
src: logoUrl,
|
||||||
|
height: logoSize,
|
||||||
|
width: logoSize,
|
||||||
|
excavate: excavate,
|
||||||
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// Check newsletter-admin cookie authentication
|
// Check newsletter-admin cookie authentication
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ export async function GET(request: NextRequest) {
|
|||||||
? Math.round((previousUniqueScans / previousTotalScans) * 100)
|
? Math.round((previousUniqueScans / previousTotalScans) * 100)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const avgScansTrend = calculateTrend(currentConversion, previousConversion);
|
const avgScansTrend = calculateTrend(avgScansPerQR, previousAvgScansPerQR);
|
||||||
|
|
||||||
// Device stats
|
// Device stats
|
||||||
const deviceStats = qrCodes.flatMap(qr => qr.scans)
|
const deviceStats = qrCodes.flatMap(qr => qr.scans)
|
||||||
@@ -245,7 +245,7 @@ export async function GET(request: NextRequest) {
|
|||||||
summary: {
|
summary: {
|
||||||
totalScans,
|
totalScans,
|
||||||
uniqueScans,
|
uniqueScans,
|
||||||
avgScansPerQR: currentConversion, // Now sending Unique Rate instead of Avg per QR
|
avgScansPerQR,
|
||||||
mobilePercentage,
|
mobilePercentage,
|
||||||
topCountry: topCountry ? topCountry[0] : 'N/A',
|
topCountry: topCountry ? topCountry[0] : 'N/A',
|
||||||
topCountryPercentage: topCountry && totalScans > 0
|
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)
|
// ISO Alpha-2 to ISO Alpha-3 mapping (for matching with TopoJSON)
|
||||||
const alpha2ToAlpha3: Record<string, string> = {
|
const alpha2ToNumeric: Record<string, string> = {
|
||||||
'US': 'USA',
|
'US': '840',
|
||||||
'DE': 'DEU',
|
'DE': '276',
|
||||||
'GB': 'GBR',
|
'GB': '826',
|
||||||
'FR': 'FRA',
|
'FR': '250',
|
||||||
'CA': 'CAN',
|
'CA': '124',
|
||||||
'AU': 'AUS',
|
'AU': '036',
|
||||||
'JP': 'JPN',
|
'JP': '392',
|
||||||
'CN': 'CHN',
|
'CN': '156',
|
||||||
'IN': 'IND',
|
'IN': '356',
|
||||||
'BR': 'BRA',
|
'BR': '076',
|
||||||
'ES': 'ESP',
|
'ES': '724',
|
||||||
'IT': 'ITA',
|
'IT': '380',
|
||||||
'NL': 'NLD',
|
'NL': '528',
|
||||||
'CH': 'CHE',
|
'CH': '756',
|
||||||
'AT': 'AUT',
|
'AT': '040',
|
||||||
'PL': 'POL',
|
'PL': '616',
|
||||||
'SE': 'SWE',
|
'SE': '752',
|
||||||
'NO': 'NOR',
|
'NO': '578',
|
||||||
'DK': 'DNK',
|
'DK': '208',
|
||||||
'FI': 'FIN',
|
'FI': '246',
|
||||||
'BE': 'BEL',
|
'BE': '056',
|
||||||
'PT': 'PRT',
|
'PT': '620',
|
||||||
'IE': 'IRL',
|
'IE': '372',
|
||||||
'MX': 'MEX',
|
'MX': '484',
|
||||||
'AR': 'ARG',
|
'AR': '032',
|
||||||
'KR': 'KOR',
|
'KR': '410',
|
||||||
'SG': 'SGP',
|
'SG': '702',
|
||||||
'NZ': 'NZL',
|
'NZ': '554',
|
||||||
'RU': 'RUS',
|
'RU': '643',
|
||||||
'ZA': 'ZAF',
|
'ZA': '710',
|
||||||
};
|
};
|
||||||
|
|
||||||
interface CountryStat {
|
interface CountryStat {
|
||||||
@@ -132,9 +132,9 @@ const GeoMap: React.FC<GeoMapProps> = ({ countryStats, totalScans }) => {
|
|||||||
|
|
||||||
countryStats.forEach((stat) => {
|
countryStats.forEach((stat) => {
|
||||||
const alpha2 = countryNameToCode[stat.country] || stat.country;
|
const alpha2 = countryNameToCode[stat.country] || stat.country;
|
||||||
const alpha3 = alpha2ToAlpha3[alpha2];
|
const numericCode = alpha2ToNumeric[alpha2];
|
||||||
if (alpha3) {
|
if (numericCode) {
|
||||||
countryData[alpha3] = stat.count;
|
countryData[numericCode] = stat.count;
|
||||||
if (stat.count > maxCount) maxCount = stat.count;
|
if (stat.count > maxCount) maxCount = stat.count;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -144,8 +144,16 @@ const GeoMap: React.FC<GeoMapProps> = ({ countryStats, totalScans }) => {
|
|||||||
.domain([0, maxCount || 1])
|
.domain([0, maxCount || 1])
|
||||||
.range(['#E0F2FE', '#1E40AF']);
|
.range(['#E0F2FE', '#1E40AF']);
|
||||||
|
|
||||||
|
const [tooltipContent, setTooltipContent] = React.useState<{ name: string; count: number } | null>(null);
|
||||||
|
const [tooltipPos, setTooltipPos] = React.useState({ x: 0, y: 0 });
|
||||||
|
|
||||||
return (
|
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
|
<ComposableMap
|
||||||
projection="geoMercator"
|
projection="geoMercator"
|
||||||
projectionConfig={{
|
projectionConfig={{
|
||||||
@@ -158,8 +166,9 @@ const GeoMap: React.FC<GeoMapProps> = ({ countryStats, totalScans }) => {
|
|||||||
<Geographies geography={geoUrl}>
|
<Geographies geography={geoUrl}>
|
||||||
{({ geographies }) =>
|
{({ geographies }) =>
|
||||||
geographies.map((geo) => {
|
geographies.map((geo) => {
|
||||||
const isoCode = geo.properties.ISO_A3 || geo.id;
|
// geo.id is the numeric ISO code as a string (e.g., "840" for US)
|
||||||
const scanCount = countryData[isoCode] || 0;
|
const geoId = geo.id;
|
||||||
|
const scanCount = countryData[geoId] || 0;
|
||||||
const fillColor = scanCount > 0 ? colorScale(scanCount) : '#F1F5F9';
|
const fillColor = scanCount > 0 ? colorScale(scanCount) : '#F1F5F9';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -178,6 +187,13 @@ const GeoMap: React.FC<GeoMapProps> = ({ countryStats, totalScans }) => {
|
|||||||
},
|
},
|
||||||
pressed: { outline: 'none' },
|
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>
|
</Geographies>
|
||||||
</ZoomableGroup>
|
</ZoomableGroup>
|
||||||
</ComposableMap>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -200,7 +200,13 @@ END:VCARD`;
|
|||||||
size={96}
|
size={96}
|
||||||
fgColor={qr.style?.foregroundColor || '#000000'}
|
fgColor={qr.style?.foregroundColor || '#000000'}
|
||||||
bgColor={qr.style?.backgroundColor || '#FFFFFF'}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user