5 Commits

17 changed files with 13478 additions and 13293 deletions

View File

@@ -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>
) : ( ) : (

View File

@@ -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

View File

@@ -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

View File

@@ -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>
); );
}; };

View File

@@ -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>