From d04e7a1f7018b0fb77f307cc99533d19de0258b7 Mon Sep 17 00:00:00 2001 From: Timo Date: Wed, 7 Jan 2026 19:59:04 +0100 Subject: [PATCH] feat: Add QR code card component with download functionality and a new pricing page with internationalization. --- src/app/(app)/pricing/page.tsx | 10 +- src/components/dashboard/QRCodeCard.tsx | 478 ++++++++++++------------ src/components/marketing/Pricing.tsx | 34 +- src/i18n/en.json | 2 +- 4 files changed, 263 insertions(+), 261 deletions(-) diff --git a/src/app/(app)/pricing/page.tsx b/src/app/(app)/pricing/page.tsx index 86d11f1..037a3bc 100644 --- a/src/app/(app)/pricing/page.tsx +++ b/src/app/(app)/pricing/page.tsx @@ -141,13 +141,13 @@ export default function PricingPage() { '50 dynamic QR codes', 'Unlimited static QR codes', 'Advanced analytics (scans, devices, locations)', - 'Custom branding (colors)', + 'Custom branding (colors & logos)', ], buttonText: isCurrentPlanWithInterval('PRO', selectedInterval) ? 'Current Plan' : hasPlanDifferentInterval('PRO') - ? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}` - : 'Upgrade to Pro', + ? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}` + : 'Upgrade to Pro', buttonVariant: 'primary' as const, disabled: isCurrentPlanWithInterval('PRO', selectedInterval), popular: true, @@ -170,8 +170,8 @@ export default function PricingPage() { buttonText: isCurrentPlanWithInterval('BUSINESS', selectedInterval) ? 'Current Plan' : hasPlanDifferentInterval('BUSINESS') - ? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}` - : 'Upgrade to Business', + ? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}` + : 'Upgrade to Business', buttonVariant: 'primary' as const, disabled: isCurrentPlanWithInterval('BUSINESS', selectedInterval), popular: false, diff --git a/src/components/dashboard/QRCodeCard.tsx b/src/components/dashboard/QRCodeCard.tsx index 659cccf..b13ec32 100644 --- a/src/components/dashboard/QRCodeCard.tsx +++ b/src/components/dashboard/QRCodeCard.tsx @@ -1,240 +1,240 @@ -'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; - createdAt: string; - scans?: number; - style?: any; - }; - onEdit: (id: string) => void; - onDelete: (id: string) => void; -} - -export const QRCodeCard: React.FC = ({ - qr, - onEdit, - 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 === 'VCARD') { - // VCARD content needs to be formatted properly - qrUrl = `BEGIN:VCARD -VERSION:3.0 -FN:${qr.content.firstName || ''} ${qr.content.lastName || ''} -N:${qr.content.lastName || ''};${qr.content.firstName || ''};;; -${qr.content.organization ? `ORG:${qr.content.organization}` : ''} -${qr.content.title ? `TITLE:${qr.content.title}` : ''} -${qr.content.email ? `EMAIL:${qr.content.email}` : ''} -${qr.content.phone ? `TEL:${qr.content.phone}` : ''} -END:VCARD`; - } else if (qr.contentType === 'GEO' && qr.content) { - const lat = qr.content.latitude || 0; - const lon = qr.content.longitude || 0; - const label = qr.content.label ? `?q=${encodeURIComponent(qr.content.label)}` : ''; - qrUrl = `geo:${lat},${lon}${label}`; - } 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') { - let svgData = new XMLSerializer().serializeToString(svg); - - // If rounded corners, wrap in a clipped SVG - if (qr.style?.cornerStyle === 'rounded') { - const width = svg.getAttribute('width') || '96'; - const height = svg.getAttribute('height') || '96'; - const borderRadius = 10; // Smaller radius for dashboard - - svgData = ` - - - - - - - ${svgData} - - `; - } - - 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'); - if (!ctx) return; - - 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; - - // Apply rounded corners if needed - if (qr.style?.cornerStyle === 'rounded') { - const borderRadius = 30; // Scale up for 300px canvas - ctx.beginPath(); - ctx.moveTo(borderRadius, 0); - ctx.lineTo(300 - borderRadius, 0); - ctx.quadraticCurveTo(300, 0, 300, borderRadius); - ctx.lineTo(300, 300 - borderRadius); - ctx.quadraticCurveTo(300, 300, 300 - borderRadius, 300); - ctx.lineTo(borderRadius, 300); - ctx.quadraticCurveTo(0, 300, 0, 300 - borderRadius); - ctx.lineTo(0, borderRadius); - ctx.quadraticCurveTo(0, 0, borderRadius, 0); - ctx.closePath(); - ctx.clip(); - } - - 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 ( - - -
-
-

{qr.title}

-
- - {qr.type} - -
-
- - - - - - - } - > - downloadQR('png')}>Download PNG - downloadQR('svg')}>Download SVG - {qr.type === 'DYNAMIC' && ( - onEdit(qr.id)}>Edit - )} - onDelete(qr.id)} className="text-red-600"> - Delete - - -
- -
-
- -
-
- -
-
- Type: - {qr.contentType} -
- {qr.type === 'DYNAMIC' && ( -
- Scans: - {qr.scans || 0} -
- )} -
- Created: - {formatDate(qr.createdAt)} -
- {qr.type === 'DYNAMIC' && ( -
-

- 📊 Dynamic QR: Tracks scans via {baseUrl}/r/{qr.slug} -

-
- )} -
-
-
- ); +'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; + createdAt: string; + scans?: number; + style?: any; + }; + onEdit: (id: string) => void; + onDelete: (id: string) => void; +} + +export const QRCodeCard: React.FC = ({ + qr, + onEdit, + 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 === 'VCARD') { + // VCARD content needs to be formatted properly + qrUrl = `BEGIN:VCARD +VERSION:3.0 +FN:${qr.content.firstName || ''} ${qr.content.lastName || ''} +N:${qr.content.lastName || ''};${qr.content.firstName || ''};;; +${qr.content.organization ? `ORG:${qr.content.organization}` : ''} +${qr.content.title ? `TITLE:${qr.content.title}` : ''} +${qr.content.email ? `EMAIL:${qr.content.email}` : ''} +${qr.content.phone ? `TEL:${qr.content.phone}` : ''} +END:VCARD`; + } else if (qr.contentType === 'GEO' && qr.content) { + const lat = qr.content.latitude || 0; + const lon = qr.content.longitude || 0; + const label = qr.content.label ? `?q=${encodeURIComponent(qr.content.label)}` : ''; + qrUrl = `geo:${lat},${lon}${label}`; + } 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') { + let svgData = new XMLSerializer().serializeToString(svg); + + // If rounded corners, wrap in a clipped SVG + if (qr.style?.cornerStyle === 'rounded') { + const width = svg.getAttribute('width') || '96'; + const height = svg.getAttribute('height') || '96'; + const borderRadius = 10; // Smaller radius for dashboard + + svgData = ` + + + + + + + ${svgData} + + `; + } + + 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'); + if (!ctx) return; + + 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; + + // Apply rounded corners if needed + if (qr.style?.cornerStyle === 'rounded') { + const borderRadius = 30; // Scale up for 300px canvas + ctx.beginPath(); + ctx.moveTo(borderRadius, 0); + ctx.lineTo(300 - borderRadius, 0); + ctx.quadraticCurveTo(300, 0, 300, borderRadius); + ctx.lineTo(300, 300 - borderRadius); + ctx.quadraticCurveTo(300, 300, 300 - borderRadius, 300); + ctx.lineTo(borderRadius, 300); + ctx.quadraticCurveTo(0, 300, 0, 300 - borderRadius); + ctx.lineTo(0, borderRadius); + ctx.quadraticCurveTo(0, 0, borderRadius, 0); + ctx.closePath(); + ctx.clip(); + } + + 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 ( + + +
+
+

{qr.title}

+
+ + {qr.type} + +
+
+ + + + + + + } + > + downloadQR('png')}>Download PNG + downloadQR('svg')}>Download SVG + {qr.type === 'DYNAMIC' && ( + onEdit(qr.id)}>Edit + )} + onDelete(qr.id)} className="text-red-600"> + Delete + + +
+ +
+
+ +
+
+ +
+
+ Type: + {qr.contentType} +
+ {qr.type === 'DYNAMIC' && ( +
+ Scans: + {qr.scans || 0} +
+ )} +
+ Created: + {formatDate(qr.createdAt)} +
+ {qr.type === 'DYNAMIC' && ( +
+

+ 📊 Dynamic QR: Tracks scans via {baseUrl}/r/{qr.slug} +

+
+ )} +
+
+
+ ); }; \ No newline at end of file diff --git a/src/components/marketing/Pricing.tsx b/src/components/marketing/Pricing.tsx index 2bb2632..b7be3fa 100644 --- a/src/components/marketing/Pricing.tsx +++ b/src/components/marketing/Pricing.tsx @@ -69,17 +69,17 @@ export const Pricing: React.FC = ({ t }) => { {plan.key === 'free' ? t.pricing[plan.key].price : billingPeriod === 'month' - ? t.pricing[plan.key].price - : plan.key === 'pro' - ? '€90' - : '€290'} + ? t.pricing[plan.key].price + : plan.key === 'pro' + ? '€90' + : '€290'} {plan.key === 'free' ? t.pricing[plan.key].period : billingPeriod === 'month' - ? t.pricing[plan.key].period - : 'per year'} + ? t.pricing[plan.key].period + : 'per year'} {billingPeriod === 'year' && plan.key !== 'free' && ( @@ -90,7 +90,7 @@ export const Pricing: React.FC = ({ t }) => { - +
    {t.pricing[plan.key].features.map((feature: string, index: number) => (
  • @@ -102,15 +102,17 @@ export const Pricing: React.FC = ({ t }) => { ))}
- - - +
+ + + +
))} diff --git a/src/i18n/en.json b/src/i18n/en.json index 4f6df67..e3437ef 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -149,7 +149,7 @@ "50 dynamic QR codes", "Unlimited static QR codes", "Advanced analytics (scans, devices, locations)", - "Custom branding (colors)" + "Custom branding (colors & logos)" ] }, "business": {