'use client'; import React, { useState, useEffect, useRef } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import StyledQRCode from '@/components/generator/StyledQRCode'; import { renderStyledQRSvg } from '@/lib/render-qr-svg'; import { ModuleShape, EyeFrameShape, EyeBallShape, PRO_MODULE_SHAPES, BUSINESS_MODULE_SHAPES, LOW_COVERAGE_SHAPES, MODULE_SHAPE_LABELS, EYE_FRAME_LABELS, EYE_BALL_LABELS, } from '@/lib/qr-shapes'; import { toPng } from 'html-to-image'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; import { Input } from '@/components/ui/Input'; import { Select } from '@/components/ui/Select'; import { Button } from '@/components/ui/Button'; import { Badge } from '@/components/ui/Badge'; import { calculateContrast, cn } from '@/lib/utils'; import { useTranslation } from '@/hooks/useTranslation'; import { useCsrf } from '@/hooks/useCsrf'; import { showToast } from '@/components/ui/Toast'; import { trackEvent } from '@/components/PostHogProvider'; import { appendRedirectParam, sanitizeRedirectPath } from '@/lib/auth-flow'; import UpgradeModal, { UpgradeReason, ActiveCodeSummary, } from '@/components/app/UpgradeModal'; import { ONBOARDING_DOWNLOAD_COMPLETE_EVENT, ONBOARDING_DOWNLOAD_COMPLETE_KEY, } from '@/lib/revops'; import { 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 }) => (
{text}
); // Content-type specific frame options const getFrameOptionsForContentType = (contentType: string) => { const baseOptions = [{ id: 'none', label: 'No Frame' }, { id: 'scanme', label: 'Scan Me' }]; switch (contentType) { case 'URL': return [...baseOptions, { id: 'website', label: 'Website' }, { id: 'visit', label: 'Visit' }]; case 'PHONE': return [...baseOptions, { id: 'callme', label: 'Call Me' }, { id: 'call', label: 'Call' }]; case 'GEO': return [...baseOptions, { id: 'findus', label: 'Find Us' }, { id: 'navigate', label: 'Navigate' }]; case 'VCARD': return [...baseOptions, { id: 'contact', label: 'Contact' }, { id: 'save', label: 'Save' }]; case 'SMS': return [...baseOptions, { id: 'textme', label: 'Text Me' }, { id: 'message', label: 'Message' }]; case 'WHATSAPP': return [...baseOptions, { id: 'chatme', label: 'Chat Me' }, { id: 'whatsapp', label: 'WhatsApp' }]; case 'TEXT': return [...baseOptions, { id: 'read', label: 'Read' }, { id: 'info', label: 'Info' }]; case 'PDF': return [...baseOptions, { id: 'download', label: 'Download' }, { id: 'view', label: 'View PDF' }]; case 'APP': return [...baseOptions, { id: 'getapp', label: 'Get App' }, { id: 'download', label: 'Download' }]; case 'COUPON': return [...baseOptions, { id: 'redeem', label: 'Redeem' }, { id: 'save', label: 'Save Offer' }]; case 'FEEDBACK': return [...baseOptions, { id: 'review', label: 'Review' }, { id: 'feedback', label: 'Feedback' }]; default: return [...baseOptions, { id: 'website', label: 'Website' }, { id: 'visit', label: 'Visit' }]; } }; // Injects a caption element below a barcode SVG and expands its height/viewBox. // Used so the "scanner app" hint is baked into the downloaded SVG. function addBarcodeCaptionToSvg(svgElement: SVGElement, caption: string): string { const cloned = svgElement.cloneNode(true) as SVGElement; const NS = 'http://www.w3.org/2000/svg'; const widthAttr = cloned.getAttribute('width'); const heightAttr = cloned.getAttribute('height'); const width = widthAttr ? parseFloat(widthAttr) : 200; const height = heightAttr ? parseFloat(heightAttr) : 100; const extraHeight = 18; cloned.setAttribute('height', String(height + extraHeight)); const viewBox = cloned.getAttribute('viewBox'); if (viewBox) { const parts = viewBox.split(/\s+/); if (parts.length === 4) { cloned.setAttribute( 'viewBox', `${parts[0]} ${parts[1]} ${parts[2]} ${parseFloat(parts[3]) + extraHeight}` ); } } const text = document.createElementNS(NS, 'text'); text.setAttribute('x', String(width / 2)); text.setAttribute('y', String(height + 12)); text.setAttribute('text-anchor', 'middle'); text.setAttribute('font-size', '9'); text.setAttribute('font-family', 'Arial, Helvetica, sans-serif'); text.setAttribute('fill', '#666666'); text.textContent = caption; cloned.appendChild(text); return new XMLSerializer().serializeToString(cloned); } export default function CreatePage() { const router = useRouter(); const searchParams = useSearchParams(); const { t } = useTranslation(); const { fetchWithCsrf } = useCsrf(); const [loading, setLoading] = useState(false); const [uploading, setUploading] = useState(false); const [userPlan, setUserPlan] = useState('FREE'); const qrRef = useRef(null); // Form state const [title, setTitle] = useState(''); const [contentType, setContentType] = useState('URL'); const [content, setContent] = useState({ url: '' }); const [isDynamic, setIsDynamic] = useState(true); // Style state const [foregroundColor, setForegroundColor] = useState('#000000'); const [backgroundColor, setBackgroundColor] = useState('#FFFFFF'); const [cornerStyle, setCornerStyle] = useState('square'); const [size, setSize] = useState(200); const [frameType, setFrameType] = useState('none'); const [moduleShape, setModuleShape] = useState('square'); const [eyeFrameShape, setEyeFrameShape] = useState('square'); const [eyeBallShape, setEyeBallShape] = useState('square'); const [gradientMode, setGradientMode] = useState<'none' | 'linear' | 'radial'>('none'); const [gradientTo, setGradientTo] = useState('#7C3AED'); const [presets, setPresets] = useState<{ id: string; name: string; style: any }[]>([]); const [presetName, setPresetName] = useState(''); // Upgrade modal. Replaces the old redirect to /pricing, which destroyed the // form state at the exact moment purchase intent was highest. const [upgradeOpen, setUpgradeOpen] = useState(false); const [upgradeReason, setUpgradeReason] = useState('limit'); const [limitInfo, setLimitInfo] = useState<{ current: number; limit: number } | null>(null); const [activeCodes, setActiveCodes] = useState([]); // Get frame options for current content type const frameOptions = getFrameOptionsForContentType(contentType); // Reset frame type when content type changes (if current frame is not valid) useEffect(() => { const validIds = frameOptions.map(f => f.id); if (!validIds.includes(frameType)) { setFrameType('none'); } }, [contentType, frameOptions, frameType]); // Force dynamic mode for COUPON and FEEDBACK types useEffect(() => { if (contentType === 'COUPON' || contentType === 'FEEDBACK') { setIsDynamic(true); } }, [contentType]); // Logo state const [logoUrl, setLogoUrl] = useState(''); const [logoSize, setLogoSize] = useState(24); const [excavate, setExcavate] = useState(true); // QR preview const [qrDataUrl, setQrDataUrl] = useState(''); const markDownloadComplete = () => { if (typeof window === 'undefined') { return; } localStorage.setItem(ONBOARDING_DOWNLOAD_COMPLETE_KEY, '1'); window.dispatchEvent(new CustomEvent(ONBOARDING_DOWNLOAD_COMPLETE_EVENT)); }; // Design gating by plan. // Colors are free for everyone - a QR code the user cannot color reads as a // basic utility, and that judgement carries into every comparison they make. const canCustomizeColors = true; // Module shapes and eye styles are the PRO driver that replaced colors. const canUseShapes = userPlan === 'PRO' || userPlan === 'BUSINESS'; // Logo stays PRO. const canUseLogo = userPlan === 'PRO' || userPlan === 'BUSINESS'; // Gradients, frames with labels, logo shapes and the exotic module shapes. const canUseFullDesign = userPlan === 'BUSINESS'; // Load user plan useEffect(() => { const fetchUserPlan = async () => { try { const response = await fetch('/api/user/plan'); if (response.ok) { const data = await response.json(); setUserPlan(data.plan || 'FREE'); } } catch (error) { console.error('Error fetching user plan:', error); } }; fetchUserPlan(); }, []); useEffect(() => { const queryContentType = searchParams.get('contentType'); const useCase = searchParams.get('useCase'); const titleParam = searchParams.get('title'); const isDynamicParam = searchParams.get('dynamic'); if (queryContentType) { setContentType(queryContentType); } if (titleParam) { setTitle(titleParam); } if (isDynamicParam) { setIsDynamic(isDynamicParam === '1'); } if (useCase === 'menu_pdf') { setContent((prev: any) => ({ ...prev, fileUrl: prev.fileUrl || '' })); } else if (useCase === 'contact_card') { setContent((prev: any) => ({ ...prev, firstName: prev.firstName || '', lastName: prev.lastName || '', })); } else if (useCase === 'barcode') { setContent((prev: any) => ({ ...prev, format: prev.format || 'CODE128', })); } else if (queryContentType === 'URL') { setContent((prev: any) => ({ ...prev, url: prev.url || '' })); } }, [searchParams]); const contrast = calculateContrast(foregroundColor, backgroundColor); const hasGoodContrast = contrast >= 4.5; const contentTypes = [ { value: 'URL', label: 'URL / Website', icon: Globe }, { value: 'VCARD', label: 'Contact Card', icon: User }, { value: 'GEO', label: 'Location / Maps', icon: MapPin }, { value: 'PHONE', label: 'Phone Number', icon: Phone }, { value: 'PDF', label: 'PDF / File', icon: FileText }, { 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 const getQRContent = () => { switch (contentType) { case 'URL': return content.url || 'https://example.com'; case 'PHONE': return `tel:${content.phone || '+1234567890'}`; case 'SMS': return `sms:${content.phone || '+1234567890'}${content.message ? `?body=${encodeURIComponent(content.message)}` : ''}`; case 'VCARD': return `BEGIN:VCARD\nVERSION:3.0\nFN:${content.firstName || 'John'} ${content.lastName || 'Doe'}\nORG:${content.organization || 'Company'}\nTITLE:${content.title || 'Position'}\nEMAIL:${content.email || 'email@example.com'}\nTEL:${content.phone || '+1234567890'}\nEND:VCARD`; case 'GEO': const lat = content.latitude || 37.7749; const lon = content.longitude || -122.4194; const label = content.label ? `?q=${encodeURIComponent(content.label)}` : ''; return `geo:${lat},${lon}${label}`; case 'TEXT': return content.text || 'Sample text'; case 'WHATSAPP': return `https://wa.me/${content.phone || '+1234567890'}${content.message ? `?text=${encodeURIComponent(content.message)}` : ''}`; case 'PDF': return content.fileUrl || 'https://example.com/file.pdf'; case 'APP': return content.fallbackUrl || content.iosUrl || content.androidUrl || 'https://example.com/app'; case 'COUPON': return `Coupon: ${content.code || 'SAVE20'} - ${content.discount || '20% OFF'}`; case 'FEEDBACK': return content.feedbackUrl || 'https://example.com/feedback'; case 'BARCODE': return isDynamic ? (content.url || '') : (content.value || ''); default: return 'https://example.com'; } }; const qrContent = getQRContent(); const previewScale = contentType === 'BARCODE' ? 1 : Math.min(1, 240 / Math.max(size, 1)); const getFrameLabel = () => { const frame = frameOptions.find((f: { id: string; label: string }) => f.id === frameType); return frame?.id !== 'none' ? frame?.label : null; }; const downloadQR = async (format: 'svg' | 'png') => { if (!qrRef.current) return; try { // Unframed codes are re-rendered from the design rather than captured // from the DOM. The on-screen preview is drawn with margin 0 because the // container supplies the visual padding, but a downloaded file needs the // 4-module quiet zone the spec requires - without it a code printed next // to other artwork often will not scan. This also makes the file from // here byte-identical to the one the dashboard produces. if (format === 'png' && frameType === 'none' && contentType !== 'BARCODE') { const svg = renderStyledQRSvg(qrContent, currentDesign(), 1024); const img = new Image(); img.onload = () => { const canvas = document.createElement('canvas'); canvas.width = 1024; canvas.height = 1024; const ctx = canvas.getContext('2d'); if (!ctx) return; ctx.drawImage(img, 0, 0, 1024, 1024); const link = document.createElement('a'); link.download = `qrcode-${title || 'download'}.png`; link.href = canvas.toDataURL('image/png'); link.click(); markDownloadComplete(); trackEvent('qr_code_downloaded', { format: 'png', content_type: contentType, qr_type: isDynamic ? 'dynamic' : 'static', plan: userPlan, }); }; img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg))); return; } if (format === 'png') { const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' }); const link = document.createElement('a'); link.download = `qrcode-${title || 'download'}.png`; link.href = dataUrl; link.click(); markDownloadComplete(); trackEvent('qr_code_downloaded', { format: 'png', content_type: contentType, qr_type: isDynamic ? 'dynamic' : 'static', plan: userPlan, }); } else { // Without a frame the preview already is the finished vector, so the SVG // download is a serialisation of what is on screen. With a frame the // surrounding markup is HTML, which has no faithful vector equivalent - // that case falls back to PNG and says so. if (frameType === 'none') { const svgElement = qrRef.current.querySelector('svg'); if (svgElement) { const svgData = contentType === 'BARCODE' ? addBarcodeCaptionToSvg(svgElement, 'Scan: iPhone -> Barcode Scanner App | Android -> Google Lens') : renderStyledQRSvg(qrContent, currentDesign(), 512); const blob = new Blob([svgData], { type: 'image/svg+xml' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `qrcode-${title || 'download'}.svg`; a.click(); URL.revokeObjectURL(url); markDownloadComplete(); trackEvent('qr_code_downloaded', { format: 'svg', content_type: contentType, qr_type: isDynamic ? 'dynamic' : 'static', plan: userPlan, }); } } else { showToast('SVG download not available with frames yet. Downloading PNG instead.', 'info'); const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' }); const link = document.createElement('a'); link.download = `qrcode-${title || 'download'}.png`; link.href = dataUrl; link.click(); markDownloadComplete(); trackEvent('qr_code_downloaded', { format: 'png', content_type: contentType, qr_type: isDynamic ? 'dynamic' : 'static', plan: userPlan, fallback_from: 'svg_with_frame', }); } } } catch (err) { console.error('Error downloading QR code:', err); showToast('Error downloading QR code', 'error'); } }; const handleLogoUpload = (e: React.ChangeEvent) => { 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); } }; // Load the user's active dynamic codes so the limit modal can offer a way out // that does not cost money. Silent failure is fine here - the modal simply // hides the pause option if this does not come back. const loadActiveCodes = async () => { try { const res = await fetch('/api/qrs'); if (!res.ok) return; const all = await res.json(); setActiveCodes( (Array.isArray(all) ? all : []) .filter((qr: any) => qr.type === 'DYNAMIC' && qr.status === 'ACTIVE') .map((qr: any) => ({ id: qr.id, title: qr.title || 'Untitled', scans30d: qr.scans30d ?? 0, })) ); } catch { // ignore } }; const handlePauseCode = async (id: string) => { const res = await fetchWithCsrf(`/api/qrs/${id}`, { method: 'PATCH', body: JSON.stringify({ status: 'PAUSED' }), }); if (!res.ok) { const data = await res.json().catch(() => null); throw new Error(data?.error || 'Could not pause that code.'); } trackEvent('dynamic_code_paused_for_slot', { qr_id: id }); setUpgradeOpen(false); showToast('Slot freed. Saving your code now.', 'success'); // The form state was never lost, so the original save just runs again. await handleSubmit({ preventDefault: () => {} } as React.FormEvent); }; // Last resort that still leaves the user with something usable. A static code // cannot be edited or tracked, but it works forever on every plan - and // saying so here is what makes the rest of the modal credible. const handleDownloadStatic = () => { trackEvent('static_fallback_from_limit', { plan: userPlan }); setUpgradeOpen(false); setIsDynamic(false); showToast( 'Switched to a static code. It cannot be edited or tracked, but it never expires.', 'info' ); }; // The logo belongs in the preset. For an agency, "client A looks the same on // all 500 codes" is mostly about the mark in the middle - a preset that // carries the colours but drops the logo solves the smaller half of the job. const currentDesign = () => ({ foregroundColor, backgroundColor, moduleShape, eyeFrameShape, eyeBallShape, gradientMode, gradientTo, frameType, logoUrl: canUseLogo ? logoUrl : '', logoSize, }); const applyDesign = (style: any) => { if (!style) return; if (style.foregroundColor) setForegroundColor(style.foregroundColor); if (style.backgroundColor) setBackgroundColor(style.backgroundColor); if (style.moduleShape) setModuleShape(style.moduleShape); if (style.eyeFrameShape) setEyeFrameShape(style.eyeFrameShape); if (style.eyeBallShape) setEyeBallShape(style.eyeBallShape); if (style.gradientMode) setGradientMode(style.gradientMode); if (style.gradientTo) setGradientTo(style.gradientTo); if (style.frameType) setFrameType(style.frameType); if (typeof style.logoUrl === 'string' && canUseLogo) setLogoUrl(style.logoUrl); if (style.logoSize) setLogoSize(style.logoSize); }; const loadPresets = async () => { try { const res = await fetch('/api/design-presets'); if (res.ok) setPresets(await res.json()); } catch { // presets are a convenience, never block the page on them } }; useEffect(() => { if (canUseFullDesign) void loadPresets(); }, [canUseFullDesign]); const savePreset = async () => { const name = presetName.trim(); if (!name) { showToast('Give the preset a name first.', 'error'); return; } const res = await fetchWithCsrf('/api/design-presets', { method: 'POST', body: JSON.stringify({ name, style: currentDesign() }), }); if (res.ok) { setPresetName(''); await loadPresets(); trackEvent('design_preset_saved', { plan: userPlan }); showToast(`Preset "${name}" saved.`, 'success'); } else { const err = await res.json().catch(() => null); showToast(err?.message || 'Could not save the preset.', 'error'); } }; const deletePreset = async (id: string) => { const res = await fetchWithCsrf(`/api/design-presets?id=${id}`, { method: 'DELETE' }); if (res.ok) await loadPresets(); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); try { const qrData = { title, contentType, content, isStatic: !isDynamic, tags: [], style: { // Colors are available on every plan, including Free. foregroundColor, backgroundColor, cornerStyle, size, moduleShape, eyeFrameShape, eyeBallShape, gradientMode, gradientTo, imageSettings: (canUseLogo && logoUrl) ? { src: logoUrl, height: logoSize, width: logoSize, excavate, } : undefined, frameType, // Save frame type }, }; console.log('SENDING QR DATA:', qrData); const response = await fetchWithCsrf('/api/qrs', { method: 'POST', body: JSON.stringify(qrData), }); const responseData = await response.json(); console.log('RESPONSE DATA:', responseData); if (response.ok) { trackEvent('qr_code_created', { content_type: contentType, qr_type: isDynamic ? 'dynamic' : 'static', plan: userPlan, has_logo: Boolean(logoUrl), frame_type: frameType, }); showToast(`QR Code "${title}" created successfully!`, 'success'); // Wait a moment so user sees the toast, then redirect setTimeout(() => { const redirectTarget = sanitizeRedirectPath(searchParams.get('redirect')); if (searchParams.get('onboarding') === '1') { router.push(appendRedirectParam('/onboarding', redirectTarget, { step: '8' })); } else { router.push('/dashboard'); } router.refresh(); }, 1000); } else { console.error('Error creating QR code:', responseData); if (response.status === 403 && responseData.error === 'Limit reached') { // Do NOT navigate away. The finished code only exists in this // component's state - a redirect throws away the user's work at the // exact moment they were most willing to pay for it. trackEvent('dynamic_limit_reached', { plan: responseData.plan, current_count: responseData.currentCount, }); setLimitInfo({ current: responseData.currentCount ?? 3, limit: responseData.limit ?? 3, }); setUpgradeReason('limit'); void loadActiveCodes(); setUpgradeOpen(true); return; } showToast(responseData.error || 'Error creating QR code', 'error'); } } catch (error) { console.error('Error creating QR code:', error); showToast('Error creating QR code. Please try again.', 'error'); } finally { setLoading(false); } }; const renderContentFields = () => { switch (contentType) { case 'URL': return ( setContent({ url: e.target.value })} placeholder="https://example.com" required /> ); case 'PHONE': return ( setContent({ phone: e.target.value })} placeholder="+1234567890" required /> ); case 'VCARD': return ( <> setContent({ ...content, firstName: e.target.value })} placeholder="John" required /> setContent({ ...content, lastName: e.target.value })} placeholder="Doe" required /> setContent({ ...content, email: e.target.value })} placeholder="john@example.com" /> setContent({ ...content, phone: e.target.value })} placeholder="+1234567890" /> setContent({ ...content, organization: e.target.value })} placeholder="Company Name" /> setContent({ ...content, title: e.target.value })} placeholder="CEO" /> ); case 'GEO': return ( <> setContent({ ...content, latitude: parseFloat(e.target.value) || 0 })} placeholder="37.7749" required /> setContent({ ...content, longitude: parseFloat(e.target.value) || 0 })} placeholder="-122.4194" required /> setContent({ ...content, label: e.target.value })} placeholder="Golden Gate Bridge" /> ); case 'TEXT': return (