Files
QR-master/src/app/(main)/(app)/create/page.tsx
2026-07-27 20:47:36 +02:00

1693 lines
71 KiB
TypeScript

'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 }) => (
<div className="group relative inline-block ml-1">
<HelpCircle className="w-4 h-4 text-gray-400 cursor-help" />
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-gray-900 text-white text-xs rounded-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50 w-48 text-center">
{text}
<div className="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div>
</div>
</div>
);
// 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 <text> 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<string>('FREE');
const qrRef = useRef<HTMLDivElement>(null);
// Form state
const [title, setTitle] = useState('');
const [contentType, setContentType] = useState('URL');
const [content, setContent] = useState<any>({ 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<ModuleShape>('square');
const [eyeFrameShape, setEyeFrameShape] = useState<EyeFrameShape>('square');
const [eyeBallShape, setEyeBallShape] = useState<EyeBallShape>('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<UpgradeReason>('limit');
const [limitInfo, setLimitInfo] = useState<{ current: number; limit: number } | null>(null);
const [activeCodes, setActiveCodes] = useState<ActiveCodeSummary[]>([]);
// 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<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);
}
};
// 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 (
<Input
label="URL"
value={content.url || ''}
onChange={(e) => setContent({ url: e.target.value })}
placeholder="https://example.com"
required
/>
);
case 'PHONE':
return (
<Input
label="Phone Number"
value={content.phone || ''}
onChange={(e) => setContent({ phone: e.target.value })}
placeholder="+1234567890"
required
/>
);
case 'VCARD':
return (
<>
<Input
label="First Name"
value={content.firstName || ''}
onChange={(e) => setContent({ ...content, firstName: e.target.value })}
placeholder="John"
required
/>
<Input
label="Last Name"
value={content.lastName || ''}
onChange={(e) => setContent({ ...content, lastName: e.target.value })}
placeholder="Doe"
required
/>
<Input
label="Email Address"
type="email"
value={content.email || ''}
onChange={(e) => setContent({ ...content, email: e.target.value })}
placeholder="john@example.com"
/>
<Input
label="Phone Number"
value={content.phone || ''}
onChange={(e) => setContent({ ...content, phone: e.target.value })}
placeholder="+1234567890"
/>
<Input
label="Company/Organization"
value={content.organization || ''}
onChange={(e) => setContent({ ...content, organization: e.target.value })}
placeholder="Company Name"
/>
<Input
label="Job Title"
value={content.title || ''}
onChange={(e) => setContent({ ...content, title: e.target.value })}
placeholder="CEO"
/>
</>
);
case 'GEO':
return (
<>
<Input
label="Latitude"
type="number"
step="any"
value={content.latitude || ''}
onChange={(e) => setContent({ ...content, latitude: parseFloat(e.target.value) || 0 })}
placeholder="37.7749"
required
/>
<Input
label="Longitude"
type="number"
step="any"
value={content.longitude || ''}
onChange={(e) => setContent({ ...content, longitude: parseFloat(e.target.value) || 0 })}
placeholder="-122.4194"
required
/>
<Input
label="Location Label (optional)"
value={content.label || ''}
onChange={(e) => setContent({ ...content, label: e.target.value })}
placeholder="Golden Gate Bridge"
/>
</>
);
case 'TEXT':
return (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Text</label>
<textarea
value={content.text || ''}
onChange={(e) => setContent({ text: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
rows={4}
placeholder="Enter your text here..."
required
/>
</div>
);
case 'PDF':
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// 10MB limit
if (file.size > 10 * 1024 * 1024) {
showToast('File size too large (max 10MB)', 'error');
return;
}
setUploading(true);
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
const data = await response.json();
if (response.ok) {
setContent({ ...content, fileUrl: data.url, fileName: data.filename });
showToast('File uploaded successfully!', 'success');
} else {
showToast(data.error || 'Upload failed', 'error');
}
} catch (error) {
console.error('Upload error:', error);
showToast('Error uploading file', 'error');
} finally {
setUploading(false);
}
};
return (
<>
<div>
<div className="flex items-center mb-1">
<label className="block text-sm font-medium text-gray-700">Upload Menu / PDF</label>
<Tooltip text="Upload your menu PDF (Max 10MB). Hosted securely." />
</div>
<div className="mt-2 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-lg hover:bg-gray-50 transition-colors relative">
<div className="space-y-1 text-center">
{uploading ? (
<div className="flex flex-col items-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500 mb-2"></div>
<p className="text-sm text-gray-500">Uploading...</p>
</div>
) : content.fileUrl ? (
<div className="flex flex-col items-center">
<div className="mx-auto h-12 w-12 text-primary-500 bg-primary-50 rounded-full flex items-center justify-center mb-2">
<FileText className="h-6 w-6" />
</div>
<p className="text-sm text-green-600 font-medium mb-1">Upload Complete!</p>
<a href={content.fileUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-primary-500 hover:underline break-all max-w-xs mb-3 block">
{content.fileName || 'View File'}
</a>
<label htmlFor="file-upload" className="cursor-pointer bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
<span>Replace File</span>
<input id="file-upload" name="file-upload" type="file" className="sr-only" accept=".pdf,image/*" onChange={handleFileUpload} />
</label>
</div>
) : (
<>
<Upload className="mx-auto h-12 w-12 text-gray-400" />
<div className="flex text-sm text-gray-600 justify-center">
<label htmlFor="file-upload" className="relative cursor-pointer bg-white rounded-md font-medium text-primary-600 hover:text-primary-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-primary-500">
<span>Upload a file</span>
<input id="file-upload" name="file-upload" type="file" className="sr-only" accept=".pdf,image/*" onChange={handleFileUpload} />
</label>
<p className="pl-1">or drag and drop</p>
</div>
<p className="text-xs text-gray-500">PDF, PNG, JPG up to 10MB</p>
</>
)}
</div>
</div>
</div>
{content.fileUrl && (
<Input
label="File Name / Menu Title"
value={content.fileName || ''}
onChange={(e) => setContent({ ...content, fileName: e.target.value })}
placeholder="Product Catalog 2026"
/>
)}
</>
);
case 'APP':
return (
<>
<div>
<div className="flex items-center mb-1">
<label className="block text-sm font-medium text-gray-700">iOS App Store URL</label>
<Tooltip text="Link to your app in the Apple App Store" />
</div>
<Input
value={content.iosUrl || ''}
onChange={(e) => setContent({ ...content, iosUrl: e.target.value })}
placeholder="https://apps.apple.com/app/..."
/>
</div>
<div>
<div className="flex items-center mb-1">
<label className="block text-sm font-medium text-gray-700">Android Play Store URL</label>
<Tooltip text="Link to your app in the Google Play Store" />
</div>
<Input
value={content.androidUrl || ''}
onChange={(e) => setContent({ ...content, androidUrl: e.target.value })}
placeholder="https://play.google.com/store/apps/..."
/>
</div>
<div>
<div className="flex items-center mb-1">
<label className="block text-sm font-medium text-gray-700">Fallback URL</label>
<Tooltip text="Where desktop users go (e.g., your website). QR detects device automatically!" />
</div>
<Input
value={content.fallbackUrl || ''}
onChange={(e) => setContent({ ...content, fallbackUrl: e.target.value })}
placeholder="https://yourapp.com"
/>
</div>
</>
);
case 'COUPON':
return (
<>
<Input
label="Coupon Code"
value={content.code || ''}
onChange={(e) => setContent({ ...content, code: e.target.value })}
placeholder="SUMMER20"
required
/>
<Input
label="Discount"
value={content.discount || ''}
onChange={(e) => setContent({ ...content, discount: e.target.value })}
placeholder="20% OFF"
required
/>
<Input
label="Title"
value={content.title || ''}
onChange={(e) => setContent({ ...content, title: e.target.value })}
placeholder="Summer Sale 2026"
/>
<Input
label="Description (optional)"
value={content.description || ''}
onChange={(e) => setContent({ ...content, description: e.target.value })}
placeholder="Valid on all products"
/>
<Input
label="Expiry Date (optional)"
type="date"
value={content.expiryDate || ''}
onChange={(e) => setContent({ ...content, expiryDate: e.target.value })}
/>
<Input
label="Redeem URL (optional)"
value={content.redeemUrl || ''}
onChange={(e) => setContent({ ...content, redeemUrl: e.target.value })}
placeholder="https://shop.example.com?coupon=SUMMER20"
/>
</>
);
case 'FEEDBACK':
return (
<>
<Input
label="Business Name"
value={content.businessName || ''}
onChange={(e) => setContent({ ...content, businessName: e.target.value })}
placeholder="Your Restaurant Name"
required
/>
<div>
<div className="flex items-center mb-1">
<label className="block text-sm font-medium text-gray-700">Google Review URL</label>
<Tooltip text="Redirect satisfied customers to leave a Google review." />
</div>
<Input
value={content.googleReviewUrl || ''}
onChange={(e) => setContent({ ...content, googleReviewUrl: e.target.value })}
placeholder="https://search.google.com/local/writereview?placeid=..."
/>
</div>
<Input
label="Thank You Message"
value={content.thankYouMessage || ''}
onChange={(e) => setContent({ ...content, thankYouMessage: e.target.value })}
placeholder="Thanks for your feedback!"
/>
</>
);
case 'BARCODE':
return (
<>
{isDynamic ? (
<>
<div className="rounded-lg bg-blue-50 border border-blue-200 p-3 text-sm text-blue-800 space-y-2">
<p>
<strong>How dynamic barcodes work:</strong> The barcode encodes a short redirect URL
(e.g. <span className="font-mono text-xs">qrmaster.net/r/</span>) that you can update anytime.
</p>
<p className="rounded border border-amber-300 bg-amber-50 p-2 text-xs text-amber-900">
<strong>📱 Scanner tip:</strong> Use a <strong>barcode scanner app</strong> on iPhone
(iOS Camera doesn't auto-open links from barcodes). Android Google Lens / Camera works
out of the box. Print min. 5&nbsp;cm wide for reliable scanning.
</p>
</div>
<Input
label="Destination URL"
value={content.url || ''}
onChange={(e) => setContent({ ...content, url: e.target.value })}
placeholder="https://example.com"
required
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Barcode Format</label>
<select
value={['CODE128', 'CODE39'].includes(content.format) ? content.format : 'CODE128'}
onChange={(e) => setContent({ ...content, format: e.target.value })}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="CODE128">CODE128 - General purpose (recommended)</option>
<option value="CODE39">CODE39 - Industrial / logistics</option>
</select>
<p className="text-xs text-gray-500 mt-1">
Only URL-capable formats available. EAN-13, UPC, and ITF-14 encode numbers only and cannot embed a redirect URL.
</p>
</div>
</>
) : (
<>
<Input
label="Barcode Value"
value={content.value || ''}
onChange={(e) => setContent({ ...content, value: e.target.value })}
placeholder="123456789012"
required
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Barcode Format</label>
<select
value={content.format || 'CODE128'}
onChange={(e) => setContent({ ...content, format: e.target.value })}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="CODE128">CODE128 - General purpose (recommended)</option>
<option value="EAN13">EAN-13 - Retail products (international)</option>
<option value="UPC">UPC - Retail products (USA/Canada)</option>
<option value="CODE39">CODE39 - Industrial / logistics</option>
<option value="ITF14">ITF-14 - Shipping containers</option>
<option value="MSI">MSI - Shelf labeling / inventory</option>
<option value="pharmacode">Pharmacode - Pharmaceutical packaging</option>
</select>
</div>
</>
)}
</>
);
default:
return null;
}
};
return (
<div className="max-w-7xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">{t('create.title')}</h1>
<p className="text-gray-600 mt-2">{t('create.subtitle')}</p>
</div>
<form onSubmit={handleSubmit}>
<div className="grid lg:grid-cols-3 gap-8">
{/* Left: Form */}
<div className="lg:col-span-2 space-y-6">
{/* Content Section */}
<Card>
<CardHeader>
<CardTitle>{t('create.content')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Input
label="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="My QR Code"
required
/>
{/* Custom Content Type Selector with Icons */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Content Type</label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{contentTypes.map((type) => {
const Icon = type.icon;
return (
<button
key={type.value}
type="button"
onClick={() => setContentType(type.value)}
className={cn(
"flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-all text-sm",
contentType === type.value
? "border-primary-500 bg-primary-50 text-primary-700"
: "border-gray-200 hover:border-gray-300 text-gray-600"
)}
>
<Icon className="w-5 h-5" />
<span className="text-xs font-medium text-center">{type.label}</span>
</button>
);
})}
</div>
</div>
{renderContentFields()}
</CardContent>
</Card>
{/* QR Type Section */}
<Card>
<CardHeader>
<CardTitle>QR Code Type</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center space-x-4">
<label className="flex items-center cursor-pointer">
<input
type="radio"
checked={isDynamic}
onChange={() => setIsDynamic(true)}
className="mr-2"
/>
<span className="font-medium">Dynamic</span>
<Badge variant="info" className="ml-2">Recommended</Badge>
</label>
<label className={cn(
"flex items-center",
(contentType === 'COUPON' || contentType === 'FEEDBACK')
? "opacity-50 cursor-not-allowed"
: "cursor-pointer"
)}>
<input
type="radio"
checked={!isDynamic}
onChange={() => setIsDynamic(false)}
disabled={contentType === 'COUPON' || contentType === 'FEEDBACK'}
className="mr-2"
/>
<span className="font-medium">Static</span>
{(contentType === 'COUPON' || contentType === 'FEEDBACK') && (
<Tooltip text="Coupon and Feedback QR codes require dynamic features for tracking and analytics." />
)}
</label>
</div>
<p className="text-sm text-gray-600 mt-2">
{isDynamic
? ' Dynamic: Track scans, edit URL later, view analytics. QR contains tracking link.'
: ' Static: Direct to content, no tracking, cannot edit. QR contains actual content.'}
</p>
{(contentType === 'COUPON' || contentType === 'FEEDBACK') && (
<div className="mt-3 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-900">
<strong>Note:</strong> {contentType === 'COUPON' ? 'Coupon' : 'Feedback'} QR codes must be Dynamic to track redemptions, collect feedback, and view detailed analytics.
</p>
</div>
)}
</CardContent>
</Card>
{/* Style Section */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{t('create.style')}</CardTitle>
<Badge variant="success">Free on every plan</Badge>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Module shape. Colors are free; shapes are the Pro driver that
replaced them. Locked options stay clickable so the preview
shows what is being bought before anyone pays for it. */}
<div>
<div className="mb-3 flex items-center justify-between">
<label className="block text-sm font-medium text-gray-700">Module shape</label>
{!canUseShapes && <Badge variant="info">Pro</Badge>}
</div>
<div className="grid grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-2 lg:gap-3">
{([...PRO_MODULE_SHAPES, ...BUSINESS_MODULE_SHAPES] as ModuleShape[]).map((shape) => {
const isBusinessOnly = BUSINESS_MODULE_SHAPES.includes(shape);
const allowed = shape === 'square'
|| (isBusinessOnly ? canUseFullDesign : canUseShapes);
return (
<button
key={shape}
type="button"
onClick={() => {
setModuleShape(shape);
if (!allowed) {
trackEvent('upgrade_prompt_shown', {
reason: 'shapes',
shape,
plan: userPlan,
});
setUpgradeReason(isBusinessOnly ? 'business-shapes' : 'shapes');
setUpgradeOpen(true);
}
}}
className={cn(
'rounded-lg border p-2 text-xs transition-colors lg:p-3 lg:text-sm',
moduleShape === shape
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-gray-200 text-gray-600 hover:border-gray-300',
!allowed && 'opacity-60'
)}
>
<span className="block truncate">{MODULE_SHAPE_LABELS[shape]}</span>
{!allowed && (
<span className="mt-0.5 block text-[10px] text-gray-400">
{isBusinessOnly ? 'Business' : 'Pro'}
</span>
)}
</button>
);
})}
</div>
</div>
{/* Eye styles. Only the combinations that survived decoding are
offered - see the note in lib/qr-shapes.ts. */}
<div className="grid grid-cols-2 gap-4">
<Select
label="Eye frame"
value={eyeFrameShape}
onChange={(e) => {
if (!canUseShapes) {
setUpgradeReason('shapes');
setUpgradeOpen(true);
return;
}
setEyeFrameShape(e.target.value as EyeFrameShape);
}}
options={Object.entries(EYE_FRAME_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
<Select
label="Eye centre"
value={eyeBallShape}
onChange={(e) => {
if (!canUseShapes) {
setUpgradeReason('shapes');
setUpgradeOpen(true);
return;
}
setEyeBallShape(e.target.value as EyeBallShape);
}}
options={Object.entries(EYE_BALL_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
</div>
{/* Gradient. Business only - the renderer takes it as a prop, so
this is purely a gating and input concern. */}
<div>
<div className="mb-3 flex items-center justify-between">
<label className="block text-sm font-medium text-gray-700">Gradient</label>
{!canUseFullDesign && <Badge variant="info">Business</Badge>}
</div>
<div className="grid grid-cols-3 gap-2">
{(['none', 'linear', 'radial'] as const).map((mode) => (
<button
key={mode}
type="button"
onClick={() => {
if (mode !== 'none' && !canUseFullDesign) {
trackEvent('upgrade_prompt_shown', { reason: 'business-shapes', feature: 'gradient', plan: userPlan });
setUpgradeReason('business-shapes');
setUpgradeOpen(true);
return;
}
setGradientMode(mode);
}}
className={cn(
'rounded-lg border p-2 text-xs capitalize transition-colors',
gradientMode === mode
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-gray-200 text-gray-600 hover:border-gray-300',
mode !== 'none' && !canUseFullDesign && 'opacity-60'
)}
>
{mode === 'none' ? 'Solid' : mode}
</button>
))}
</div>
{gradientMode !== 'none' && (
<div className="mt-3 flex items-center gap-2">
<label className="text-sm text-gray-700">Second colour</label>
<input
type="color"
value={gradientTo}
onChange={(e) => setGradientTo(e.target.value)}
className="h-10 w-12 rounded border border-gray-300"
/>
<Input
value={gradientTo}
onChange={(e) => setGradientTo(e.target.value)}
className="flex-1"
/>
</div>
)}
</div>
{/* Scannability. Says what was changed and why, rather than
silently raising the error correction behind the user. */}
{(LOW_COVERAGE_SHAPES.includes(moduleShape) || logoUrl) && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3">
<p className="text-sm text-amber-900">
{logoUrl
? 'Error correction is set to H because this code carries a logo.'
: `"${MODULE_SHAPE_LABELS[moduleShape]}" fills less of each module, so error correction has been raised.`}
</p>
<p className="mt-1 text-sm text-amber-800">
Print it at 2 x 2 cm or larger, and scan it once with your own
phone before you send it to the printer.
</p>
</div>
)}
{/* Saved presets. Business only. Repeatability is the actual
product here - the star shape is not what an agency buys. */}
{canUseFullDesign && (
<div className="rounded-lg border border-gray-200 p-3">
<label className="mb-2 block text-sm font-medium text-gray-700">
Design presets
</label>
{presets.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2">
{presets.map((preset) => (
<span
key={preset.id}
className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 py-1 pl-3 pr-1 text-xs"
>
<button
type="button"
onClick={() => applyDesign(preset.style)}
className="text-gray-700 hover:text-primary-700"
>
{preset.name}
</button>
<button
type="button"
onClick={() => deletePreset(preset.id)}
className="px-1 text-gray-400 hover:text-red-600"
aria-label={`Delete preset ${preset.name}`}
>
&times;
</button>
</span>
))}
</div>
)}
<div className="flex items-center gap-2">
<Input
value={presetName}
onChange={(e) => setPresetName(e.target.value)}
placeholder="Client A"
className="flex-1"
/>
<Button type="button" variant="outline" size="sm" onClick={savePreset}>
Save current design
</Button>
</div>
<p className="mt-2 text-xs text-gray-500">
Saving under an existing name overwrites it.
</p>
</div>
)}
{/* Frame Options */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">Frame</label>
<div className="grid grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-2 lg:gap-3">
{frameOptions.map((frame: { id: string; label: string }) => (
<button
key={frame.id}
type="button"
onClick={() => setFrameType(frame.id)}
className={cn(
"py-2 px-3 rounded-lg text-sm font-medium transition-all border lg:py-3",
frameType === frame.id
? "bg-slate-900 text-white border-slate-900"
: "bg-gray-50 text-gray-600 border-gray-200 hover:border-gray-300"
)}
>
{frame.label}
</button>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Foreground Color
</label>
<div className="flex items-center space-x-2">
<input
type="color"
value={foregroundColor}
onChange={(e) => setForegroundColor(e.target.value)}
className="w-12 h-10 rounded border border-gray-300"
/>
<Input
value={foregroundColor}
onChange={(e) => setForegroundColor(e.target.value)}
className="flex-1"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Background Color
</label>
<div className="flex items-center space-x-2">
<input
type="color"
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
className="w-12 h-10 rounded border border-gray-300"
/>
<Input
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
className="flex-1"
/>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<Select
label="Corner Style"
value={cornerStyle}
onChange={(e) => setCornerStyle(e.target.value)}
options={[
{ value: 'square', label: 'Square' },
{ value: 'rounded', label: 'Rounded' },
]}
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Size: {size}px
</label>
<input
type="range"
min="100"
max="400"
value={size}
onChange={(e) => setSize(Number(e.target.value))}
className="w-full"
/>
</div>
</div>
<div className="flex items-center justify-between">
<Badge variant={hasGoodContrast ? 'success' : 'warning'}>
{hasGoodContrast ? 'Good contrast' : 'Low contrast'}
</Badge>
<span className="text-sm text-gray-500">
Contrast ratio: {contrast.toFixed(1)}:1
</span>
</div>
</CardContent>
</Card>
{/* Logo Section */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Logo</CardTitle>
{!canUseLogo && (
<Badge variant="info">Pro</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{!canUseLogo && (
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg mb-4">
<p className="text-sm text-blue-900">
Your logo in the middle of the code tells people whose it is
before they decide to trust it.
</p>
<Button
type="button"
variant="primary"
size="sm"
className="mt-2"
onClick={() => {
trackEvent('upgrade_prompt_shown', { reason: 'logo', plan: userPlan });
setUpgradeReason('logo');
setUpgradeOpen(true);
}}
>
Add my logo
</Button>
</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}
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>
{/* Right: Preview */}
<div className="lg:col-span-1">
<Card className="sticky top-6">
<CardHeader>
<CardTitle>{t('create.preview')}</CardTitle>
</CardHeader>
<CardContent className="text-center">
<div id="create-qr-preview" className="flex justify-center mb-4 w-full min-w-0 overflow-hidden">
{/* WRAPPER FOR REF AND FRAME */}
<div
ref={qrRef}
className="relative flex w-full min-w-0 max-w-full flex-col items-center justify-center rounded-xl bg-white p-3 transition-all duration-300 sm:p-4 lg:p-6"
style={{
minHeight: '220px',
}}
>
{/* Frame Label */}
{getFrameLabel() && (
<div
className="mb-4 px-6 py-2 rounded-full font-bold text-sm tracking-widest uppercase shadow-md text-white"
style={{ backgroundColor: foregroundColor }}
>
{getFrameLabel()}
</div>
)}
{contentType === 'BARCODE' ? (
qrContent ? (
<div className="p-2 bg-white w-full max-w-full [&_svg]:!w-full [&_svg]:!h-auto [&_svg]:!max-w-full">
<Barcode
key={`${qrContent}-${content.format}-${foregroundColor}`}
value={qrContent}
format={content.format || 'CODE128'}
lineColor={foregroundColor}
background={backgroundColor}
width={2}
height={80}
margin={10}
displayValue={true}
fontSize={14}
/>
<p className="mt-2 text-center text-[10px] leading-tight text-gray-600 px-2">
Scan: iPhone → Barcode Scanner App · Android → Google Lens / Camera
</p>
</div>
) : (
<div className="w-[200px] h-[200px] bg-gray-100 rounded flex items-center justify-center text-gray-500">
Enter barcode value
</div>
)
) : qrContent ? (
<div
className={cornerStyle === 'rounded' ? 'overflow-hidden rounded-lg' : ''}
style={{
transform: `scale(${previewScale})`,
transformOrigin: 'center center',
}}
>
<StyledQRCode
value={qrContent}
size={size}
fgColor={foregroundColor}
bgColor={backgroundColor}
moduleShape={moduleShape}
eyeFrameShape={eyeFrameShape}
eyeBallShape={eyeBallShape}
gradient={
gradientMode === 'none'
? null
: { type: gradientMode, from: foregroundColor, to: gradientTo }
}
errorCorrection="H"
logoUrl={canUseLogo && logoUrl ? logoUrl : undefined}
logoScale={logoSize / 200}
margin={0}
/>
</div>
) : (
<div className="w-[200px] h-[200px] bg-gray-100 rounded flex items-center justify-center text-gray-500">
Enter content
</div>
)}
</div>
</div>
<div className="space-y-3">
<Button
variant="outline"
className="w-full"
type="button"
onClick={() => downloadQR('svg')}
disabled={!qrContent}
>
Download SVG
</Button>
<Button
variant="outline"
className="w-full"
type="button"
onClick={() => downloadQR('png')}
disabled={!qrContent}
>
Download PNG
</Button>
<Button type="submit" className="w-full" loading={loading}>
Save QR Code
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
</form>
<UpgradeModal
open={upgradeOpen}
reason={upgradeReason}
plan={userPlan}
currentCount={limitInfo?.current}
limit={limitInfo?.limit}
activeCodes={activeCodes}
onPauseCode={upgradeReason === 'limit' ? handlePauseCode : undefined}
onDownloadStatic={upgradeReason === 'limit' ? handleDownloadStatic : undefined}
onClose={() => setUpgradeOpen(false)}
/>
</div>
);
}