1693 lines
71 KiB
TypeScript
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 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}`}
|
|
>
|
|
×
|
|
</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>
|
|
);
|
|
}
|