diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b79c89e..9abbad7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -121,6 +121,7 @@ enum ContentType { APP COUPON FEEDBACK + BARCODE } enum QRStatus { diff --git a/src/app/(main)/(app)/bulk-creation/page.tsx b/src/app/(main)/(app)/bulk-creation/page.tsx index bc6a9d9..3c63b80 100644 --- a/src/app/(main)/(app)/bulk-creation/page.tsx +++ b/src/app/(main)/(app)/bulk-creation/page.tsx @@ -22,8 +22,10 @@ interface BulkQRData { interface GeneratedQR { title: string; - content: string; // Original URL - svg: string; // SVG markup + content: string; + svg: string; + slug?: string; + redirectUrl?: string; } export default function BulkCreationPage() { @@ -35,16 +37,25 @@ export default function BulkCreationPage() { const [loading, setLoading] = useState(false); const [generatedQRs, setGeneratedQRs] = useState([]); const [userPlan, setUserPlan] = useState('FREE'); + const [isDynamic, setIsDynamic] = useState(false); + const [remainingDynamic, setRemainingDynamic] = useState(0); - // Check user plan on mount + // Check user plan and dynamic quota on mount React.useEffect(() => { const checkPlan = async () => { try { - const response = await fetch('/api/user/plan'); - if (response.ok) { - const data = await response.json(); + const [planRes, statsRes] = await Promise.all([ + fetch('/api/user/plan'), + fetch('/api/user/stats'), + ]); + if (planRes.ok) { + const data = await planRes.json(); setUserPlan(data.plan || 'FREE'); } + if (statsRes.ok) { + const stats = await statsRes.json(); + setRemainingDynamic((stats.dynamicLimit || 0) - (stats.dynamicUsed || 0)); + } } catch (error) { console.error('Error checking plan:', error); } @@ -196,6 +207,58 @@ export default function BulkCreationPage() { } }; + const generateDynamicQRCodes = async () => { + setLoading(true); + const toProcess = remainingDynamic > 0 ? data.slice(0, remainingDynamic) : []; + + if (toProcess.length === 0) { + showToast('Du hast keine dynamischen QR-Codes mehr übrig. Bitte upgrade deinen Plan.', 'error'); + setLoading(false); + return; + } + + if (data.length > remainingDynamic) { + showToast(`Nur ${remainingDynamic} dynamische Codes verfügbar. Es werden nur die ersten ${remainingDynamic} Zeilen verarbeitet.`, 'warning'); + } + + try { + const QRCode = require('qrcode'); + const results: GeneratedQR[] = []; + + for (const row of toProcess) { + const title = String(row[mapping.title as keyof typeof row] || 'Untitled'); + const url = String(row[mapping.content as keyof typeof row] || 'https://example.com'); + + const res = await fetchWithCsrf('/api/qrs', { + method: 'POST', + body: JSON.stringify({ + title, + contentType: 'URL', + content: { url }, + isStatic: false, + }), + }); + + if (res.ok) { + const qr = await res.json(); + const redirectUrl = `${window.location.origin}/r/${qr.slug}`; + const svg = await QRCode.toString(redirectUrl, { type: 'svg', width: 300, margin: 2 }); + results.push({ title, content: url, svg, slug: qr.slug, redirectUrl }); + } + } + + setGeneratedQRs(results); + setRemainingDynamic(prev => Math.max(0, prev - results.length)); + setStep('complete'); + showToast(`${results.length} dynamische QR-Codes erstellt!`, 'success'); + } catch (error) { + console.error('Dynamic QR generation error:', error); + showToast('Fehler beim Erstellen der dynamischen QR-Codes', 'error'); + } finally { + setLoading(false); + } + }; + const downloadAllQRCodes = async () => { const zip = new JSZip(); @@ -204,6 +267,18 @@ export default function BulkCreationPage() { zip.file(fileName, qr.svg); }); + // Add metadata CSV for dynamic QR codes + const hasDynamic = generatedQRs.some(qr => qr.slug); + if (hasDynamic) { + const csvRows = ['title,original_url,redirect_url,slug']; + generatedQRs.forEach(qr => { + if (qr.slug) { + csvRows.push(`"${qr.title}","${qr.content}","${qr.redirectUrl}","${qr.slug}"`); + } + }); + zip.file('metadata.csv', csvRows.join('\n')); + } + const blob = await zip.generateAsync({ type: 'blob' }); saveAs(blob, 'qr-codes-bulk.zip'); showToast('Download started!', 'success'); @@ -274,8 +349,8 @@ export default function BulkCreationPage() { URL.revokeObjectURL(url); }; - // Show upgrade prompt if not Business plan - if (userPlan !== 'BUSINESS') { + // Show upgrade prompt if not Business or Enterprise plan + if (userPlan !== 'BUSINESS' && userPlan !== 'ENTERPRISE') { return (
@@ -309,6 +384,39 @@ export default function BulkCreationPage() {

{t('bulk.title')}

{t('bulk.subtitle')}

+ + {/* Static / Dynamic Toggle */} +
+ QR Code Type: + + +
{/* Template Warning Banner */} @@ -641,8 +749,13 @@ export default function BulkCreationPage() { -
diff --git a/src/app/(main)/(app)/create/page.tsx b/src/app/(main)/(app)/create/page.tsx index 6d7af5f..b88765b 100644 --- a/src/app/(main)/(app)/create/page.tsx +++ b/src/app/(main)/(app)/create/page.tsx @@ -15,8 +15,9 @@ import { useTranslation } from '@/hooks/useTranslation'; import { useCsrf } from '@/hooks/useCsrf'; import { showToast } from '@/components/ui/Toast'; import { - Globe, User, MapPin, Phone, FileText, Smartphone, Ticket, Star, HelpCircle, Upload + Globe, User, MapPin, Phone, FileText, Smartphone, Ticket, Star, HelpCircle, Upload, Barcode as BarcodeIcon } from 'lucide-react'; +import Barcode from 'react-barcode'; // Tooltip component for form field help const Tooltip = ({ text }: { text: string }) => ( @@ -140,6 +141,7 @@ export default function CreatePage() { { value: 'APP', label: 'App Download', icon: Smartphone }, { value: 'COUPON', label: 'Coupon / Discount', icon: Ticket }, { value: 'FEEDBACK', label: 'Feedback / Review', icon: Star }, + { value: 'BARCODE', label: 'Barcode', icon: BarcodeIcon }, ]; // Get QR content based on content type @@ -170,6 +172,8 @@ export default function CreatePage() { return `Coupon: ${content.code || 'SAVE20'} - ${content.discount || '20% OFF'}`; case 'FEEDBACK': return content.feedbackUrl || 'https://example.com/feedback'; + case 'BARCODE': + return content.value || '123456789'; default: return 'https://example.com'; } @@ -642,6 +646,68 @@ export default function CreatePage() { /> ); + case 'BARCODE': + return ( + <> + {isDynamic ? ( + <> +
+ How dynamic barcodes work: The barcode encodes a short redirect URL + (e.g. qrmaster.net/r/…). When scanned with a + smartphone camera, it opens the browser and redirects to your destination — which you + can update anytime. Works with smartphone cameras, not POS laser scanners. +
+ setContent({ ...content, url: e.target.value })} + placeholder="https://example.com" + required + /> +
+ + +

+ Only URL-capable formats available. EAN-13, UPC, and ITF-14 encode numbers only and cannot embed a redirect URL. +

+
+ + ) : ( + <> + setContent({ ...content, value: e.target.value })} + placeholder="123456789012" + required + /> +
+ + +
+ + )} + + ); default: return null; } @@ -992,7 +1058,25 @@ export default function CreatePage() { )} - {qrContent ? ( + {contentType === 'BARCODE' ? ( + qrContent ? ( +
+ +
+ ) : ( +
+ Enter barcode value +
+ ) + ) : qrContent ? (
{ e.preventDefault(); @@ -79,15 +80,15 @@ export default function LoginClient({ showPageHeading = true }: LoginClientProps
- - QR Master - QR Master - - {showPageHeading ? ( -

Welcome Back

- ) : ( -

Welcome Back

- )} + + QR Master + QR Master + + {showPageHeading ? ( +

Welcome Back

+ ) : ( +

Welcome Back

+ )}

Sign in to your account

← Back to Home @@ -112,14 +113,37 @@ export default function LoginClient({ showPageHeading = true }: LoginClientProps required /> - setPassword(e.target.value)} - placeholder="••••••••" - required - /> +
+ +
+ setPassword(e.target.value)} + placeholder="••••••••" + required + className="flex h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 pr-10 text-sm placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2" + /> + +
+