From 542426a12ab338362a989afca9d3501989e12bd2 Mon Sep 17 00:00:00 2001 From: Mohan Ki Date: Mon, 27 Jul 2026 19:55:19 +0530 Subject: [PATCH] Implement email verification lockout and missing phase 2 items --- api/db/init.sql | 20 ++ api/fix_db.js | 31 ++ api/routes/auth.js | 9 + api/routes/qr.js | 97 +++++- api/routes/redirect.js | 49 ++- api/server.js | 16 + client/package-lock.json | 7 + client/package.json | 1 + client/src/app/dashboard/create/page.tsx | 320 +++++++++++++----- client/src/app/dashboard/edit/[id]/page.tsx | 291 ++++++++++++++-- client/src/app/dashboard/page.tsx | 122 ++++++- client/src/app/verify-email/page.tsx | 121 +++---- .../src/components/qr-editor/DesignPanel.tsx | 76 ++++- .../src/components/qr-editor/LivePreview.tsx | 30 +- 14 files changed, 980 insertions(+), 210 deletions(-) create mode 100644 api/fix_db.js diff --git a/api/db/init.sql b/api/db/init.sql index c4afde5..abd8ddb 100644 --- a/api/db/init.sql +++ b/api/db/init.sql @@ -13,14 +13,34 @@ CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS qr_codes ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID REFERENCES users(id) ON DELETE CASCADE, + workspace_id UUID, -- If created inside a team workspace type VARCHAR(20) NOT NULL, -- 'static' or 'dynamic' data_type VARCHAR(50) NOT NULL, -- 'URL', 'Wi-Fi', etc. destination_url TEXT, + routing_data JSONB, -- Stores specific data for vCard, App Store, etc. short_url_id VARCHAR(50) UNIQUE, -- Only for dynamic routing e.g. 'xyz123' design_data JSONB, -- Custom styling + tags TEXT[] DEFAULT '{}', created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); +CREATE TABLE IF NOT EXISTS folders ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + workspace_id UUID, + name VARCHAR(255) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS workspace_members ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + owner_id UUID REFERENCES users(id) ON DELETE CASCADE, + member_email VARCHAR(255) NOT NULL, + role VARCHAR(50) DEFAULT 'editor', + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(owner_id, member_email) +); + CREATE TABLE IF NOT EXISTS qr_scans ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), qr_code_id UUID REFERENCES qr_codes(id) ON DELETE CASCADE, diff --git a/api/fix_db.js b/api/fix_db.js new file mode 100644 index 0000000..532a721 --- /dev/null +++ b/api/fix_db.js @@ -0,0 +1,31 @@ +const { Pool } = require('pg'); +const pool = new Pool({host: 'localhost', port: 5432, user: 'postgres', password: 'password123', database: 'ckqr'}); +async function run() { + try { + await pool.query(` + CREATE TABLE IF NOT EXISTS app_settings ( + key VARCHAR(255) PRIMARY KEY, + value TEXT, + category VARCHAR(50), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ); + `); + console.log('Created app_settings table.'); + + await pool.query(` + INSERT INTO app_settings (key, value, category) VALUES + ('smtp_host', 'smtp.office365.com', 'smtp'), + ('smtp_port', '587', 'smtp'), + ('smtp_user', 'noreply@cavininfotech.com', 'smtp'), + ('smtp_pass', 'xmkshjlszjbkcyzb', 'smtp'), + ('smtp_secure', 'false', 'smtp') + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value; + `); + console.log('Inserted SMTP settings.'); + } catch (err) { + console.error(err); + } finally { + process.exit(0); + } +} +run(); diff --git a/api/routes/auth.js b/api/routes/auth.js index e8d3940..7d91962 100644 --- a/api/routes/auth.js +++ b/api/routes/auth.js @@ -80,6 +80,15 @@ router.post('/login', async (req, res) => { const isMatch = await bcrypt.compare(password, user.password_hash); if (!isMatch) return res.status(401).json({ error: 'Invalid credentials' }); + if (!user.is_email_verified) { + const createdAt = new Date(user.created_at); + const now = new Date(); + const daysSinceCreation = (now.getTime() - createdAt.getTime()) / (1000 * 60 * 60 * 24); + if (daysSinceCreation > 5) { + return res.status(403).json({ error: 'Account locked. Please verify your email address to continue.', needsVerification: true }); + } + } + const token = jwt.sign({ id: user.id, email: user.email, role: user.role }, JWT_SECRET, { expiresIn: '14d' }); res.cookie('token', token, { httpOnly: true, maxAge: 14 * 24 * 60 * 60 * 1000, secure: false, sameSite: 'lax' }); diff --git a/api/routes/qr.js b/api/routes/qr.js index 705b74e..9ae0ee9 100644 --- a/api/routes/qr.js +++ b/api/routes/qr.js @@ -12,6 +12,56 @@ const pgPool = new Pool({ password: process.env.PGPASSWORD || 'postgres', database: process.env.PGDATABASE || 'ckqr', }); +// === BULK OPERATIONS === + +// POST /api/qr/bulk-delete +router.post('/bulk-delete', verifyToken, async (req, res) => { + const { ids } = req.body; + if (!ids || !Array.isArray(ids) || ids.length === 0) { + return res.status(400).json({ error: 'No IDs provided' }); + } + + try { + const result = await pgPool.query( + 'DELETE FROM qr_codes WHERE id = ANY($1) AND user_id = $2 RETURNING id', + [ids, req.user.id] + ); + res.json({ success: true, deleted: result.rowCount }); + } catch (err) { + console.error('Error in bulk delete:', err); + res.status(500).json({ error: 'Server error during bulk delete' }); + } +}); + +// POST /api/qr/bulk-move +router.post('/bulk-move', verifyToken, async (req, res) => { + const { ids, folderId } = req.body; + if (!ids || !Array.isArray(ids) || ids.length === 0) { + return res.status(400).json({ error: 'No IDs provided' }); + } + + try { + let targetFolder = folderId; + if (targetFolder === 'default') targetFolder = null; + + // Verify folder ownership if not default + if (targetFolder) { + const folderRes = await pgPool.query('SELECT id FROM folders WHERE id = $1 AND user_id = $2', [targetFolder, req.user.id]); + if (folderRes.rows.length === 0) { + return res.status(403).json({ error: 'Folder access denied' }); + } + } + + const result = await pgPool.query( + 'UPDATE qr_codes SET folder_id = $1 WHERE id = ANY($2) AND user_id = $3 RETURNING id', + [targetFolder, ids, req.user.id] + ); + res.json({ success: true, moved: result.rowCount }); + } catch (err) { + console.error('Error in bulk move:', err); + res.status(500).json({ error: 'Server error during bulk move' }); + } +}); // Get all QRs for user router.get('/', verifyToken, async (req, res) => { @@ -57,7 +107,7 @@ router.get('/:id', verifyToken, async (req, res) => { // Generate QR Code router.post('/generate', verifyToken, requireEmailVerification, async (req, res) => { - const { name, type, dataType, destinationUrl, designData, customSlug, folderId } = req.body; + const { name, type, dataType, destinationUrl, routingData, designData, customSlug, folderId, tags } = req.body; const userId = req.user.id; if (!type || !dataType) { @@ -66,11 +116,11 @@ router.post('/generate', verifyToken, requireEmailVerification, async (req, res) try { let shortUrlId = null; - let finalQrContent = destinationUrl; + let finalQrContent = destinationUrl; // Only valid if static and URL + // In static mode, the finalQrContent is whatever payload we send (handled mostly by frontend), + // but if dynamic, we generate the shortUrlId. if (type === 'dynamic') { - if (!destinationUrl) return res.status(400).json({ error: 'Destination URL is required for dynamic QR' }); - // Allow custom slug or generate random shortUrlId = customSlug ? customSlug.trim() : shortid.generate(); @@ -84,16 +134,20 @@ router.post('/generate', verifyToken, requireEmailVerification, async (req, res) // In production, this would use the real domain (e.g. https://ck-qr.com/l/) finalQrContent = `${process.env.FRONTEND_URL || 'http://localhost:4000'}/l/${shortUrlId}`; + } else { + // If static, destinationUrl is sent as the actual payload by the frontend for simple URLs, + // but for complex types, frontend puts the raw payload in designData.data. + // We just store destinationUrl for backward compatibility. } const result = await pgPool.query( - `INSERT INTO qr_codes (user_id, name, type, data_type, destination_url, short_url_id, design_data, folder_id) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, - [userId, name || 'Untitled QR', type, dataType, destinationUrl, shortUrlId, designData || {}, folderId || null] + `INSERT INTO qr_codes (user_id, name, type, data_type, destination_url, routing_data, short_url_id, design_data, folder_id, tags) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`, + [userId, name || 'Untitled QR', type, dataType, destinationUrl || null, routingData || null, shortUrlId, designData || {}, folderId || null, tags || []] ); // Generate Image (Data URI for simple response, could be saved to S3 later) - const qrImage = await QRCode.toDataURL(finalQrContent); + const qrImage = await QRCode.toDataURL(finalQrContent || 'preview'); res.json({ message: 'QR Code generated', @@ -108,7 +162,7 @@ router.post('/generate', verifyToken, requireEmailVerification, async (req, res) // Update QR Code (Editable Link / Move Folder / Update Design) router.put('/:id', verifyToken, requireEmailVerification, async (req, res) => { - const { name, destinationUrl, shortUrlId, designData, folderId } = req.body; + const { name, dataType, destinationUrl, routingData, shortUrlId, designData, folderId, tags } = req.body; const qrId = req.params.id; const userId = req.user.id; @@ -134,12 +188,25 @@ router.put('/:id', verifyToken, requireEmailVerification, async (req, res) => { const updated = await pgPool.query( `UPDATE qr_codes SET name = COALESCE($1, name), - destination_url = COALESCE($2, destination_url), - short_url_id = COALESCE($3, short_url_id), - design_data = COALESCE($4, design_data), - folder_id = $5 - WHERE id = $6 RETURNING *`, - [name, destinationUrl, newShortUrlId, designData || qr.design_data, folderId !== undefined ? (folderId === 'default' ? null : folderId) : qr.folder_id, qrId] + data_type = COALESCE($2, data_type), + destination_url = $3, + routing_data = COALESCE($4, routing_data), + short_url_id = COALESCE($5, short_url_id), + design_data = COALESCE($6, design_data), + folder_id = $7, + tags = $8 + WHERE id = $9 RETURNING *`, + [ + name, + dataType, + destinationUrl !== undefined ? destinationUrl : qr.destination_url, + routingData !== undefined ? routingData : qr.routing_data, + newShortUrlId, + designData || qr.design_data, + folderId !== undefined ? (folderId === 'default' ? null : folderId) : qr.folder_id, + tags !== undefined ? tags : qr.tags, + qrId + ] ); res.json({ message: 'QR Code updated', qrRecord: updated.rows[0] }); diff --git a/api/routes/redirect.js b/api/routes/redirect.js index e254ab7..b7c360f 100644 --- a/api/routes/redirect.js +++ b/api/routes/redirect.js @@ -34,7 +34,7 @@ router.get('/:shortUrlId', async (req, res) => { } else { // 2. Not in cache, check DB const result = await pgPool.query( - `SELECT q.id as qr_id, q.destination_url, u.role + `SELECT q.id as qr_id, q.destination_url, q.data_type, q.routing_data, u.role FROM qr_codes q JOIN users u ON q.user_id = u.id WHERE q.short_url_id = $1`, @@ -62,15 +62,52 @@ router.get('/:shortUrlId', async (req, res) => { [qrData.qr_id, ip, req.headers['user-agent'], os, deviceType, country] ).catch(err => console.error('Failed to log scan:', err)); - // 4. Check Tier & Redirect - if (qrData.role === 'free') { - const encodedUrl = encodeURIComponent(qrData.destination_url); + // 4. Determine final destination based on data_type + let finalDestination = qrData.destination_url; + let isDirectAction = false; + let isDownload = false; + let downloadContent = ''; + + if (qrData.data_type === 'App Store' && qrData.routing_data) { + const rd = typeof qrData.routing_data === 'string' ? JSON.parse(qrData.routing_data) : qrData.routing_data; + if (os === 'iOS' || os === 'Mac OS') { + finalDestination = rd.ios || rd.fallback; + } else if (os === 'Android') { + finalDestination = rd.android || rd.fallback; + } else { + finalDestination = rd.fallback || rd.ios || rd.android; + } + } else if (qrData.data_type === 'Email' && qrData.routing_data) { + const rd = typeof qrData.routing_data === 'string' ? JSON.parse(qrData.routing_data) : qrData.routing_data; + finalDestination = `mailto:${rd.email}?subject=${encodeURIComponent(rd.subject || '')}&body=${encodeURIComponent(rd.body || '')}`; + isDirectAction = true; + } else if (qrData.data_type === 'SMS' && qrData.routing_data) { + const rd = typeof qrData.routing_data === 'string' ? JSON.parse(qrData.routing_data) : qrData.routing_data; + finalDestination = `sms:${rd.phone}?body=${encodeURIComponent(rd.message || '')}`; + isDirectAction = true; + } else if (qrData.data_type === 'vCard' && qrData.routing_data) { + const rd = typeof qrData.routing_data === 'string' ? JSON.parse(qrData.routing_data) : qrData.routing_data; + isDownload = true; + downloadContent = `BEGIN:VCARD\r\nVERSION:3.0\r\nN:${rd.lastName || ''};${rd.firstName || ''}\r\nFN:${rd.firstName || ''} ${rd.lastName || ''}\r\nORG:${rd.company || ''}\r\nTITLE:${rd.title || ''}\r\nTEL:${rd.phone || ''}\r\nEMAIL:${rd.email || ''}\r\nEND:VCARD`; + } else if (qrData.data_type === 'Wi-Fi') { + return res.send('Wi-Fi QR Codes are meant to be scanned natively as static QRs.'); + } + + if (isDownload) { + res.setHeader('Content-Type', 'text/vcard'); + res.setHeader('Content-Disposition', 'attachment; filename="contact.vcf"'); + return res.send(downloadContent); + } + + // 5. Check Tier & Redirect + if (qrData.role === 'free' && !isDirectAction) { + const encodedUrl = encodeURIComponent(finalDestination); // In production, this points to the real Next.js domain const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:4000'; return res.redirect(302, `${frontendUrl}/ad-redirect?url=${encodedUrl}`); } else { - // Paid users skip the ad - return res.redirect(302, qrData.destination_url); + // Paid users or Direct Actions (mailto/sms) skip the ad + return res.redirect(302, finalDestination); } } catch (err) { diff --git a/api/server.js b/api/server.js index 5052eff..3185bc5 100644 --- a/api/server.js +++ b/api/server.js @@ -145,6 +145,22 @@ ADD COLUMN IF NOT EXISTS is_email_verified BOOLEAN DEFAULT false, ADD COLUMN IF NOT EXISTS email_verification_token VARCHAR(255); `).catch(console.error); +pgPool.query(`ALTER TABLE qr_codes ADD COLUMN IF NOT EXISTS tags TEXT[] DEFAULT '{}';`).catch(console.error); +pgPool.query(`ALTER TABLE qr_codes ADD COLUMN IF NOT EXISTS routing_data JSONB;`).catch(console.error); +pgPool.query(`ALTER TABLE qr_codes ADD COLUMN IF NOT EXISTS workspace_id UUID;`).catch(console.error); +pgPool.query(`ALTER TABLE folders ADD COLUMN IF NOT EXISTS workspace_id UUID;`).catch(console.error); + +pgPool.query(` +CREATE TABLE IF NOT EXISTS workspace_members ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + owner_id UUID REFERENCES users(id) ON DELETE CASCADE, + member_email VARCHAR(255) NOT NULL, + role VARCHAR(50) DEFAULT 'editor', + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(owner_id, member_email) +); +`).catch(console.error); + pgPool.query(` CREATE TABLE IF NOT EXISTS app_settings ( key VARCHAR(100) PRIMARY KEY, diff --git a/client/package-lock.json b/client/package-lock.json index 6f71c3d..0a2349e 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "axios": "^1.18.1", + "html-to-image": "^1.11.13", "lucide-react": "^1.26.0", "next": "16.2.11", "qr-code-styling": "^1.9.2", @@ -4372,6 +4373,12 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", diff --git a/client/package.json b/client/package.json index 00d4ed2..6e762ec 100644 --- a/client/package.json +++ b/client/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "axios": "^1.18.1", + "html-to-image": "^1.11.13", "lucide-react": "^1.26.0", "next": "16.2.11", "qr-code-styling": "^1.9.2", diff --git a/client/src/app/dashboard/create/page.tsx b/client/src/app/dashboard/create/page.tsx index b0b083b..bbb13a1 100644 --- a/client/src/app/dashboard/create/page.tsx +++ b/client/src/app/dashboard/create/page.tsx @@ -1,7 +1,8 @@ 'use client'; -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import type { Options } from 'qr-code-styling'; import dynamic from 'next/dynamic'; +import type { ExtendedOptions } from '@/components/qr-editor/LivePreview'; const LivePreview = dynamic(() => import('@/components/qr-editor/LivePreview'), { ssr: false }); import DesignPanel from '@/components/qr-editor/DesignPanel'; import api from '@/lib/api'; @@ -9,89 +10,112 @@ import { useRouter } from 'next/navigation'; export default function CreateQRPage() { const router = useRouter(); - const [destinationUrl, setDestinationUrl] = useState('https://ck-qr.com'); const [qrName, setQrName] = useState(''); - const [qrType, setQrType] = useState<'dynamic' | 'static'>('dynamic'); + const [qrType, setQrType] = useState('dynamic'); // 'static' or 'dynamic' + const [dataType, setDataType] = useState('URL'); const [customSlug, setCustomSlug] = useState(''); const [randomSlug, setRandomSlug] = useState(''); const [isGenerating, setIsGenerating] = useState(false); - const [userRole, setUserRole] = useState('free'); + const [userRole, setUserRole] = useState('free'); + const [tags, setTags] = useState([]); + const [tagInput, setTagInput] = useState(''); + + // Data Type States + const [urlData, setUrlData] = useState('https://example.com'); + const [vcardData, setVcardData] = useState({ firstName: '', lastName: '', phone: '', email: '', company: '', title: '' }); + const [emailData, setEmailData] = useState({ email: '', subject: '', body: '' }); + const [smsData, setSmsData] = useState({ phone: '', message: '' }); + const [wifiData, setWifiData] = useState({ ssid: '', password: '', encryption: 'WPA' }); + const [appStoreData, setAppStoreData] = useState({ ios: '', android: '', fallback: '' }); + const baseUrl = typeof window !== 'undefined' ? (process.env.NEXT_PUBLIC_APP_URL || 'https://qr.houseofwebsites.ai') : 'https://qr.houseofwebsites.ai'; - const [qrOptions, setQrOptions] = useState({ + const [qrOptions, setQrOptions] = useState({ width: 300, height: 300, type: 'svg', - data: `${baseUrl}/l/preview`, // Will be updated in useEffect + data: `${baseUrl}/l/preview`, margin: 10, - qrOptions: { - typeNumber: 0, - mode: 'Byte', - errorCorrectionLevel: 'Q' - }, - imageOptions: { - hideBackgroundDots: true, - imageSize: 0.4, - margin: 5 - }, - dotsOptions: { - color: '#4f46e5', - type: 'rounded' - }, - backgroundOptions: { - color: '#ffffff', - }, - cornersSquareOptions: { - color: '#4f46e5', - type: 'extra-rounded' - } + qrOptions: { typeNumber: 0, mode: 'Byte', errorCorrectionLevel: 'Q' }, + imageOptions: { hideBackgroundDots: true, imageSize: 0.4, margin: 5 }, + dotsOptions: { color: '#4f46e5', type: 'rounded' }, + backgroundOptions: { color: '#ffffff' }, + cornersSquareOptions: { color: '#4f46e5', type: 'extra-rounded' } }); - // Generate random slug on mount to prevent SSR hydration errors - React.useEffect(() => { + useEffect(() => { const generated = Math.random().toString(36).substring(2, 8); setRandomSlug(generated); - if (qrType === 'dynamic') { - setQrOptions(prev => ({ ...prev, data: `${baseUrl}/l/${generated}` })); - } else { - setQrOptions(prev => ({ ...prev, data: destinationUrl })); - } - - // Fetch user role api.get('/auth/me').then(res => { - if (res.data?.user?.role) { - setUserRole(res.data.user.role); - } + if (res.data?.user?.role) setUserRole(res.data.user.role); }).catch(() => {}); - }, [baseUrl]); + }, []); - // Keep QR data in sync with type and destinationUrl - React.useEffect(() => { - if (qrType === 'static') { - setQrOptions(prev => ({ ...prev, data: destinationUrl })); - } else { - setQrOptions(prev => ({ ...prev, data: `${baseUrl}/l/${customSlug || randomSlug}` })); + // Update QR Data when inputs change + useEffect(() => { + let payload = ''; + + // Force static for WiFi + if (dataType === 'Wi-Fi' && qrType !== 'static') { + setQrType('static'); + alert('Wi-Fi QR Codes must be Static.'); + } + // Force dynamic for App Store + if (dataType === 'App Store' && qrType !== 'dynamic') { + setQrType('dynamic'); + alert('App Store QR Codes must be Dynamic.'); } - }, [qrType, destinationUrl]); - // Keep QR data in sync with custom slug + if (qrType === 'static') { + if (dataType === 'URL') payload = urlData; + else if (dataType === 'Wi-Fi') payload = `WIFI:T:${wifiData.encryption};S:${wifiData.ssid};P:${wifiData.password};;`; + else if (dataType === 'Email') payload = `mailto:${emailData.email}?subject=${encodeURIComponent(emailData.subject)}&body=${encodeURIComponent(emailData.body)}`; + else if (dataType === 'SMS') payload = `smsto:${smsData.phone}:${smsData.message}`; + else if (dataType === 'vCard') payload = `BEGIN:VCARD\nVERSION:3.0\nN:${vcardData.lastName};${vcardData.firstName}\nFN:${vcardData.firstName} ${vcardData.lastName}\nORG:${vcardData.company}\nTITLE:${vcardData.title}\nTEL:${vcardData.phone}\nEMAIL:${vcardData.email}\nEND:VCARD`; + else payload = urlData; // fallback + } else { + payload = `${baseUrl}/l/${customSlug || randomSlug}`; + } + + setQrOptions(prev => ({ ...prev, data: payload || 'preview' })); + }, [qrType, dataType, urlData, vcardData, emailData, smsData, wifiData, appStoreData, customSlug, randomSlug, baseUrl]); + const handleSlugChange = (e: React.ChangeEvent) => { - const val = e.target.value.replace(/[^a-zA-Z0-9-_]/g, ''); - setCustomSlug(val); - setQrOptions(prev => ({ ...prev, data: `${baseUrl}/l/${val || randomSlug}` })); + setCustomSlug(e.target.value.replace(/[^a-zA-Z0-9-_]/g, '')); }; + const handleAddTag = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + const val = tagInput.trim().replace(/,/g, ''); + if (val && !tags.includes(val)) { + setTags([...tags, val]); + } + setTagInput(''); + } + }; + const removeTag = (t: string) => setTags(tags.filter(tag => tag !== t)); + const handleGenerate = async () => { try { setIsGenerating(true); - // Generate Dynamic QR via API - const response = await api.post('/qr/generate', { + + let routingData = null; + if (dataType === 'vCard') routingData = vcardData; + else if (dataType === 'Email') routingData = emailData; + else if (dataType === 'SMS') routingData = smsData; + else if (dataType === 'Wi-Fi') routingData = wifiData; + else if (dataType === 'App Store') routingData = appStoreData; + + await api.post('/qr/generate', { name: qrName, - customSlug: qrType === 'dynamic' ? (customSlug || randomSlug) : undefined, // Pass the previewed slug so the backend uses it! + customSlug: qrType === 'dynamic' ? (customSlug || randomSlug) : undefined, type: qrType, - dataType: 'URL', - destinationUrl: destinationUrl, - designData: qrOptions + dataType: dataType, + destinationUrl: dataType === 'URL' ? urlData : undefined, + routingData: routingData, + designData: qrOptions, + tags: tags }); alert('QR Code Successfully Generated & Saved!'); @@ -106,9 +130,25 @@ export default function CreateQRPage() { const handleDownload = async (extension: 'png' | 'svg' = 'png') => { try { - const QRCodeStyling = (await import('qr-code-styling')).default; - const qrCode = new QRCodeStyling(qrOptions); - qrCode.download({ name: qrName || 'QR-Code', extension }); + if (qrOptions.frameOptions?.enabled) { + if (extension === 'svg') { + alert("SVG export is not supported when a Frame is enabled."); + return; + } + const { toPng } = await import('html-to-image'); + const node = document.getElementById('qr-preview-container'); + if (node) { + const dataUrl = await toPng(node, { quality: 1, pixelRatio: 3 }); + const link = document.createElement('a'); + link.download = `${qrName || 'QR-Code'}.png`; + link.href = dataUrl; + link.click(); + } + } else { + const QRCodeStyling = (await import('qr-code-styling')).default; + const qrCode = new QRCodeStyling(qrOptions); + qrCode.download({ name: qrName || 'QR-Code', extension }); + } } catch (err) { console.error('Download failed', err); alert('Failed to download QR code'); @@ -117,10 +157,15 @@ export default function CreateQRPage() { return (
-
-
-

Create QR Code

-

Design a beautiful, dynamic QR code

+
+
+ +
+

Create QR Code

+

Design and configure a new QR code.

+
@@ -145,11 +190,11 @@ export default function CreateQRPage() {
@@ -162,6 +207,7 @@ export default function CreateQRPage() {

QR Content

+ {/* Type Selection */}
@@ -190,6 +236,22 @@ export default function CreateQRPage() {
+
+ +
+ {['URL', 'vCard', 'Email', 'SMS', 'Wi-Fi', 'App Store'].map(type => ( + + ))} +
+
+
-
- - setDestinationUrl(e.target.value)} - placeholder="https://your-website.com" - className="w-full border-gray-300 rounded-lg shadow-sm p-3 border focus:ring-indigo-500 focus:border-indigo-500 text-gray-900 bg-white" - /> - {qrType === 'dynamic' && ( -

This is a dynamic QR. You can change this URL later without reprinting the code.

+ {/* Dynamic Forms based on dataType */} +
+ {dataType === 'URL' && ( +
+ + setUrlData(e.target.value)} + placeholder="https://your-website.com" + className="w-full border-gray-300 rounded-lg shadow-sm p-3 border focus:ring-indigo-500 focus:border-indigo-500 text-gray-900 bg-white" + /> +
)} - {qrType === 'static' && ( -

This URL is embedded directly into the code and cannot be changed later.

+ + {dataType === 'vCard' && ( +
+
+ + setVcardData({...vcardData, firstName: e.target.value})} className="w-full border-gray-300 rounded-lg shadow-sm p-2 border focus:ring-indigo-500 text-gray-900 text-sm" /> +
+
+ + setVcardData({...vcardData, lastName: e.target.value})} className="w-full border-gray-300 rounded-lg shadow-sm p-2 border focus:ring-indigo-500 text-gray-900 text-sm" /> +
+
+ + setVcardData({...vcardData, phone: e.target.value})} className="w-full border-gray-300 rounded-lg shadow-sm p-2 border focus:ring-indigo-500 text-gray-900 text-sm" /> +
+
+ + setVcardData({...vcardData, email: e.target.value})} className="w-full border-gray-300 rounded-lg shadow-sm p-2 border focus:ring-indigo-500 text-gray-900 text-sm" /> +
+
+ + setVcardData({...vcardData, company: e.target.value})} className="w-full border-gray-300 rounded-lg shadow-sm p-2 border focus:ring-indigo-500 text-gray-900 text-sm" /> +
+
+ + setVcardData({...vcardData, title: e.target.value})} className="w-full border-gray-300 rounded-lg shadow-sm p-2 border focus:ring-indigo-500 text-gray-900 text-sm" /> +
+
+ )} + + {dataType === 'Email' && ( +
+
+ + setEmailData({...emailData, email: e.target.value})} className="w-full border-gray-300 rounded-lg shadow-sm p-2 border focus:ring-indigo-500 text-gray-900 text-sm" /> +
+
+ + setEmailData({...emailData, subject: e.target.value})} className="w-full border-gray-300 rounded-lg shadow-sm p-2 border focus:ring-indigo-500 text-gray-900 text-sm" /> +
+
+ +