Implement email verification lockout and missing phase 2 items

This commit is contained in:
Mohan Ki
2026-07-27 19:55:19 +05:30
parent 2bfa741a71
commit 542426a12a
14 changed files with 980 additions and 210 deletions
+20
View File
@@ -13,14 +13,34 @@ CREATE TABLE IF NOT EXISTS users (
CREATE TABLE IF NOT EXISTS qr_codes ( CREATE TABLE IF NOT EXISTS qr_codes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE, 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' type VARCHAR(20) NOT NULL, -- 'static' or 'dynamic'
data_type VARCHAR(50) NOT NULL, -- 'URL', 'Wi-Fi', etc. data_type VARCHAR(50) NOT NULL, -- 'URL', 'Wi-Fi', etc.
destination_url TEXT, 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' short_url_id VARCHAR(50) UNIQUE, -- Only for dynamic routing e.g. 'xyz123'
design_data JSONB, -- Custom styling design_data JSONB, -- Custom styling
tags TEXT[] DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP 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 ( CREATE TABLE IF NOT EXISTS qr_scans (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
qr_code_id UUID REFERENCES qr_codes(id) ON DELETE CASCADE, qr_code_id UUID REFERENCES qr_codes(id) ON DELETE CASCADE,
+31
View File
@@ -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();
+9
View File
@@ -80,6 +80,15 @@ router.post('/login', async (req, res) => {
const isMatch = await bcrypt.compare(password, user.password_hash); const isMatch = await bcrypt.compare(password, user.password_hash);
if (!isMatch) return res.status(401).json({ error: 'Invalid credentials' }); 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' }); 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' }); res.cookie('token', token, { httpOnly: true, maxAge: 14 * 24 * 60 * 60 * 1000, secure: false, sameSite: 'lax' });
+82 -15
View File
@@ -12,6 +12,56 @@ const pgPool = new Pool({
password: process.env.PGPASSWORD || 'postgres', password: process.env.PGPASSWORD || 'postgres',
database: process.env.PGDATABASE || 'ckqr', 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 // Get all QRs for user
router.get('/', verifyToken, async (req, res) => { router.get('/', verifyToken, async (req, res) => {
@@ -57,7 +107,7 @@ router.get('/:id', verifyToken, async (req, res) => {
// Generate QR Code // Generate QR Code
router.post('/generate', verifyToken, requireEmailVerification, async (req, res) => { 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; const userId = req.user.id;
if (!type || !dataType) { if (!type || !dataType) {
@@ -66,11 +116,11 @@ router.post('/generate', verifyToken, requireEmailVerification, async (req, res)
try { try {
let shortUrlId = null; 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 (type === 'dynamic') {
if (!destinationUrl) return res.status(400).json({ error: 'Destination URL is required for dynamic QR' });
// Allow custom slug or generate random // Allow custom slug or generate random
shortUrlId = customSlug ? customSlug.trim() : shortid.generate(); 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/) // 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}`; 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( const result = await pgPool.query(
`INSERT INTO qr_codes (user_id, name, type, data_type, destination_url, short_url_id, design_data, folder_id) `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) RETURNING *`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`,
[userId, name || 'Untitled QR', type, dataType, destinationUrl, shortUrlId, designData || {}, folderId || null] [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) // 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({ res.json({
message: 'QR Code generated', 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) // Update QR Code (Editable Link / Move Folder / Update Design)
router.put('/:id', verifyToken, requireEmailVerification, async (req, res) => { 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 qrId = req.params.id;
const userId = req.user.id; const userId = req.user.id;
@@ -134,12 +188,25 @@ router.put('/:id', verifyToken, requireEmailVerification, async (req, res) => {
const updated = await pgPool.query( const updated = await pgPool.query(
`UPDATE qr_codes `UPDATE qr_codes
SET name = COALESCE($1, name), SET name = COALESCE($1, name),
destination_url = COALESCE($2, destination_url), data_type = COALESCE($2, data_type),
short_url_id = COALESCE($3, short_url_id), destination_url = $3,
design_data = COALESCE($4, design_data), routing_data = COALESCE($4, routing_data),
folder_id = $5 short_url_id = COALESCE($5, short_url_id),
WHERE id = $6 RETURNING *`, design_data = COALESCE($6, design_data),
[name, destinationUrl, newShortUrlId, designData || qr.design_data, folderId !== undefined ? (folderId === 'default' ? null : folderId) : qr.folder_id, qrId] 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] }); res.json({ message: 'QR Code updated', qrRecord: updated.rows[0] });
+43 -6
View File
@@ -34,7 +34,7 @@ router.get('/:shortUrlId', async (req, res) => {
} else { } else {
// 2. Not in cache, check DB // 2. Not in cache, check DB
const result = await pgPool.query( 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 FROM qr_codes q
JOIN users u ON q.user_id = u.id JOIN users u ON q.user_id = u.id
WHERE q.short_url_id = $1`, 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] [qrData.qr_id, ip, req.headers['user-agent'], os, deviceType, country]
).catch(err => console.error('Failed to log scan:', err)); ).catch(err => console.error('Failed to log scan:', err));
// 4. Check Tier & Redirect // 4. Determine final destination based on data_type
if (qrData.role === 'free') { let finalDestination = qrData.destination_url;
const encodedUrl = encodeURIComponent(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 // In production, this points to the real Next.js domain
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:4000'; const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:4000';
return res.redirect(302, `${frontendUrl}/ad-redirect?url=${encodedUrl}`); return res.redirect(302, `${frontendUrl}/ad-redirect?url=${encodedUrl}`);
} else { } else {
// Paid users skip the ad // Paid users or Direct Actions (mailto/sms) skip the ad
return res.redirect(302, qrData.destination_url); return res.redirect(302, finalDestination);
} }
} catch (err) { } catch (err) {
+16
View File
@@ -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); ADD COLUMN IF NOT EXISTS email_verification_token VARCHAR(255);
`).catch(console.error); `).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(` pgPool.query(`
CREATE TABLE IF NOT EXISTS app_settings ( CREATE TABLE IF NOT EXISTS app_settings (
key VARCHAR(100) PRIMARY KEY, key VARCHAR(100) PRIMARY KEY,
+7
View File
@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"axios": "^1.18.1", "axios": "^1.18.1",
"html-to-image": "^1.11.13",
"lucide-react": "^1.26.0", "lucide-react": "^1.26.0",
"next": "16.2.11", "next": "16.2.11",
"qr-code-styling": "^1.9.2", "qr-code-styling": "^1.9.2",
@@ -4372,6 +4373,12 @@
"hermes-estree": "0.25.1" "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": { "node_modules/https-proxy-agent": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+1
View File
@@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"axios": "^1.18.1", "axios": "^1.18.1",
"html-to-image": "^1.11.13",
"lucide-react": "^1.26.0", "lucide-react": "^1.26.0",
"next": "16.2.11", "next": "16.2.11",
"qr-code-styling": "^1.9.2", "qr-code-styling": "^1.9.2",
+228 -70
View File
@@ -1,7 +1,8 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import type { Options } from 'qr-code-styling'; import type { Options } from 'qr-code-styling';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import type { ExtendedOptions } from '@/components/qr-editor/LivePreview';
const LivePreview = dynamic(() => import('@/components/qr-editor/LivePreview'), { ssr: false }); const LivePreview = dynamic(() => import('@/components/qr-editor/LivePreview'), { ssr: false });
import DesignPanel from '@/components/qr-editor/DesignPanel'; import DesignPanel from '@/components/qr-editor/DesignPanel';
import api from '@/lib/api'; import api from '@/lib/api';
@@ -9,89 +10,112 @@ import { useRouter } from 'next/navigation';
export default function CreateQRPage() { export default function CreateQRPage() {
const router = useRouter(); const router = useRouter();
const [destinationUrl, setDestinationUrl] = useState('https://ck-qr.com');
const [qrName, setQrName] = useState(''); 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 [customSlug, setCustomSlug] = useState('');
const [randomSlug, setRandomSlug] = useState(''); const [randomSlug, setRandomSlug] = useState('');
const [isGenerating, setIsGenerating] = useState(false); const [isGenerating, setIsGenerating] = useState(false);
const [userRole, setUserRole] = useState<string>('free'); const [userRole, setUserRole] = useState('free');
const [tags, setTags] = useState<string[]>([]);
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 baseUrl = typeof window !== 'undefined' ? (process.env.NEXT_PUBLIC_APP_URL || 'https://qr.houseofwebsites.ai') : 'https://qr.houseofwebsites.ai';
const [qrOptions, setQrOptions] = useState<Options>({ const [qrOptions, setQrOptions] = useState<ExtendedOptions>({
width: 300, width: 300,
height: 300, height: 300,
type: 'svg', type: 'svg',
data: `${baseUrl}/l/preview`, // Will be updated in useEffect data: `${baseUrl}/l/preview`,
margin: 10, margin: 10,
qrOptions: { qrOptions: { typeNumber: 0, mode: 'Byte', errorCorrectionLevel: 'Q' },
typeNumber: 0, imageOptions: { hideBackgroundDots: true, imageSize: 0.4, margin: 5 },
mode: 'Byte', dotsOptions: { color: '#4f46e5', type: 'rounded' },
errorCorrectionLevel: 'Q' backgroundOptions: { color: '#ffffff' },
}, cornersSquareOptions: { color: '#4f46e5', type: 'extra-rounded' }
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 useEffect(() => {
React.useEffect(() => {
const generated = Math.random().toString(36).substring(2, 8); const generated = Math.random().toString(36).substring(2, 8);
setRandomSlug(generated); 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 => { api.get('/auth/me').then(res => {
if (res.data?.user?.role) { if (res.data?.user?.role) setUserRole(res.data.user.role);
setUserRole(res.data.user.role);
}
}).catch(() => {}); }).catch(() => {});
}, [baseUrl]); }, []);
// Keep QR data in sync with type and destinationUrl // Update QR Data when inputs change
React.useEffect(() => { useEffect(() => {
if (qrType === 'static') { let payload = '';
setQrOptions(prev => ({ ...prev, data: destinationUrl }));
} else { // Force static for WiFi
setQrOptions(prev => ({ ...prev, data: `${baseUrl}/l/${customSlug || randomSlug}` })); 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<HTMLInputElement>) => { const handleSlugChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value.replace(/[^a-zA-Z0-9-_]/g, ''); setCustomSlug(e.target.value.replace(/[^a-zA-Z0-9-_]/g, ''));
setCustomSlug(val);
setQrOptions(prev => ({ ...prev, data: `${baseUrl}/l/${val || randomSlug}` }));
}; };
const handleAddTag = (e: React.KeyboardEvent<HTMLInputElement>) => {
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 () => { const handleGenerate = async () => {
try { try {
setIsGenerating(true); 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, 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, type: qrType,
dataType: 'URL', dataType: dataType,
destinationUrl: destinationUrl, destinationUrl: dataType === 'URL' ? urlData : undefined,
designData: qrOptions routingData: routingData,
designData: qrOptions,
tags: tags
}); });
alert('QR Code Successfully Generated & Saved!'); alert('QR Code Successfully Generated & Saved!');
@@ -106,9 +130,25 @@ export default function CreateQRPage() {
const handleDownload = async (extension: 'png' | 'svg' = 'png') => { const handleDownload = async (extension: 'png' | 'svg' = 'png') => {
try { try {
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 QRCodeStyling = (await import('qr-code-styling')).default;
const qrCode = new QRCodeStyling(qrOptions); const qrCode = new QRCodeStyling(qrOptions);
qrCode.download({ name: qrName || 'QR-Code', extension }); qrCode.download({ name: qrName || 'QR-Code', extension });
}
} catch (err) { } catch (err) {
console.error('Download failed', err); console.error('Download failed', err);
alert('Failed to download QR code'); alert('Failed to download QR code');
@@ -117,10 +157,15 @@ export default function CreateQRPage() {
return ( return (
<div className="p-8 max-w-7xl mx-auto h-full"> <div className="p-8 max-w-7xl mx-auto h-full">
<div className="flex justify-between items-center mb-8"> <div className="flex items-center gap-4 mb-8 justify-between">
<div className="flex items-center gap-4">
<button onClick={() => router.back()} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<svg className="w-6 h-6 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>
</button>
<div> <div>
<h1 className="text-3xl font-bold text-gray-800">Create QR Code</h1> <h1 className="text-3xl font-extrabold text-gray-900 tracking-tight">Create QR Code</h1>
<p className="text-gray-500 mt-1">Design a beautiful, dynamic QR code</p> <p className="text-gray-500 mt-1">Design and configure a new QR code.</p>
</div>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<div className="flex gap-2"> <div className="flex gap-2">
@@ -145,11 +190,11 @@ export default function CreateQRPage() {
</div> </div>
<button <button
onClick={handleGenerate} onClick={handleGenerate}
disabled={isGenerating} disabled={isGenerating || !qrName}
className="bg-indigo-600 text-white px-6 py-2.5 rounded-lg hover:bg-indigo-700 transition-colors font-semibold shadow-sm disabled:opacity-50" className={`px-8 py-2.5 rounded-lg font-bold shadow-sm transition-all text-white ${
(isGenerating || !qrName) ? 'bg-indigo-400 cursor-not-allowed' : 'bg-indigo-600 hover:bg-indigo-700'
}`}
> >
{isGenerating ? 'Saving...' : 'Save QR Code'}
</button>
</div> </div>
</div> </div>
@@ -162,6 +207,7 @@ export default function CreateQRPage() {
<h2 className="text-xl font-bold text-gray-800 border-b pb-4 mb-6">QR Content</h2> <h2 className="text-xl font-bold text-gray-800 border-b pb-4 mb-6">QR Content</h2>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* Type Selection */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">QR Type</label> <label className="block text-sm font-medium text-gray-700 mb-1">QR Type</label>
<div className="flex gap-4"> <div className="flex gap-4">
@@ -190,6 +236,22 @@ export default function CreateQRPage() {
</div> </div>
</div> </div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Data Type</label>
<div className="flex flex-wrap gap-2">
{['URL', 'vCard', 'Email', 'SMS', 'Wi-Fi', 'App Store'].map(type => (
<button
key={type}
type="button"
onClick={() => setDataType(type)}
className={`px-3 py-1.5 text-sm rounded-lg border font-medium transition-colors ${dataType === type ? 'bg-indigo-50 border-indigo-200 text-indigo-700' : 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'}`}
>
{type}
</button>
))}
</div>
</div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Internal Name</label> <label className="block text-sm font-medium text-gray-700 mb-1">Internal Name</label>
<input <input
@@ -201,20 +263,116 @@ export default function CreateQRPage() {
/> />
</div> </div>
{/* Dynamic Forms based on dataType */}
<div className="bg-gray-50 p-4 rounded-xl border border-gray-100">
{dataType === 'URL' && (
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Destination URL</label> <label className="block text-sm font-medium text-gray-700 mb-1">Destination URL</label>
<input <input
type="url" type="url"
value={destinationUrl} value={urlData}
onChange={e => setDestinationUrl(e.target.value)} onChange={e => setUrlData(e.target.value)}
placeholder="https://your-website.com" 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" 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' && ( </div>
<p className="text-xs text-gray-500 mt-2">This is a dynamic QR. You can change this URL later without reprinting the code.</p>
)} )}
{qrType === 'static' && (
<p className="text-xs text-gray-500 mt-2">This URL is embedded directly into the code and cannot be changed later.</p> {dataType === 'vCard' && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">First Name</label>
<input type="text" value={vcardData.firstName} onChange={e => 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" />
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Last Name</label>
<input type="text" value={vcardData.lastName} onChange={e => 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" />
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Phone</label>
<input type="tel" value={vcardData.phone} onChange={e => 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" />
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Email</label>
<input type="email" value={vcardData.email} onChange={e => 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" />
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Company</label>
<input type="text" value={vcardData.company} onChange={e => 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" />
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Title</label>
<input type="text" value={vcardData.title} onChange={e => 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" />
</div>
</div>
)}
{dataType === 'Email' && (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Email To</label>
<input type="email" value={emailData.email} onChange={e => 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" />
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Subject</label>
<input type="text" value={emailData.subject} onChange={e => 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" />
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Body</label>
<textarea value={emailData.body} onChange={e => setEmailData({...emailData, body: 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 h-20" />
</div>
</div>
)}
{dataType === 'SMS' && (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Phone Number</label>
<input type="tel" value={smsData.phone} onChange={e => setSmsData({...smsData, 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" />
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Message</label>
<textarea value={smsData.message} onChange={e => setSmsData({...smsData, message: 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 h-20" />
</div>
</div>
)}
{dataType === 'Wi-Fi' && (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Network Name (SSID)</label>
<input type="text" value={wifiData.ssid} onChange={e => setWifiData({...wifiData, ssid: 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" />
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Password</label>
<input type="text" value={wifiData.password} onChange={e => setWifiData({...wifiData, password: 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" />
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Encryption</label>
<select value={wifiData.encryption} onChange={e => setWifiData({...wifiData, encryption: 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">
<option value="WPA">WPA/WPA2/WPA3</option>
<option value="WEP">WEP</option>
<option value="nopass">None</option>
</select>
</div>
</div>
)}
{dataType === 'App Store' && (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Apple App Store URL</label>
<input type="url" value={appStoreData.ios} onChange={e => setAppStoreData({...appStoreData, ios: e.target.value})} placeholder="https://apps.apple.com/..." className="w-full border-gray-300 rounded-lg shadow-sm p-2 border focus:ring-indigo-500 text-gray-900 text-sm" />
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Google Play Store URL</label>
<input type="url" value={appStoreData.android} onChange={e => setAppStoreData({...appStoreData, android: e.target.value})} placeholder="https://play.google.com/..." className="w-full border-gray-300 rounded-lg shadow-sm p-2 border focus:ring-indigo-500 text-gray-900 text-sm" />
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Fallback URL (Desktop)</label>
<input type="url" value={appStoreData.fallback} onChange={e => setAppStoreData({...appStoreData, fallback: e.target.value})} placeholder="https://your-website.com" className="w-full border-gray-300 rounded-lg shadow-sm p-2 border focus:ring-indigo-500 text-gray-900 text-sm" />
</div>
</div>
)} )}
</div> </div>
+256 -17
View File
@@ -2,6 +2,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import type { Options } from 'qr-code-styling'; import type { Options } from 'qr-code-styling';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import type { ExtendedOptions } from '@/components/qr-editor/LivePreview';
const LivePreview = dynamic(() => import('@/components/qr-editor/LivePreview'), { ssr: false }); const LivePreview = dynamic(() => import('@/components/qr-editor/LivePreview'), { ssr: false });
import DesignPanel from '@/components/qr-editor/DesignPanel'; import DesignPanel from '@/components/qr-editor/DesignPanel';
import api from '@/lib/api'; import api from '@/lib/api';
@@ -14,15 +15,27 @@ export default function EditQRPage() {
const qrId = params.id as string; const qrId = params.id as string;
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [destinationUrl, setDestinationUrl] = useState('https://ck-qr.com');
const [qrName, setQrName] = useState(''); const [qrName, setQrName] = useState('');
const [qrType, setQrType] = useState<'dynamic' | 'static'>('dynamic'); const [qrType, setQrType] = useState<'dynamic' | 'static'>('dynamic');
const [dataType, setDataType] = useState('URL');
const [customSlug, setCustomSlug] = useState(''); const [customSlug, setCustomSlug] = useState('');
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [userRole, setUserRole] = useState<string>('free'); const [userRole, setUserRole] = useState<string>('free');
const [tags, setTags] = useState<string[]>([]);
const [tagInput, setTagInput] = useState('');
// Data Type States
const [urlData, setUrlData] = useState('https://ck-qr.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 baseUrl = typeof window !== 'undefined' ? (process.env.NEXT_PUBLIC_APP_URL || 'https://qr.houseofwebsites.ai') : 'https://qr.houseofwebsites.ai';
const [qrOptions, setQrOptions] = useState<Options>({
const [qrOptions, setQrOptions] = useState<ExtendedOptions>({
width: 300, width: 300,
height: 300, height: 300,
type: 'svg', type: 'svg',
@@ -47,9 +60,17 @@ export default function EditQRPage() {
api.get(`/qr/${qrId}`).then(res => { api.get(`/qr/${qrId}`).then(res => {
const qr = res.data.qr_code; const qr = res.data.qr_code;
setQrName(qr.name); setQrName(qr.name);
setDestinationUrl(qr.destination_url);
setQrType(qr.type); setQrType(qr.type);
setCustomSlug(qr.short_url_id); setDataType(qr.data_type || 'URL');
setCustomSlug(qr.short_url_id || '');
setTags(qr.tags || []);
if (qr.data_type === 'URL' || !qr.data_type) setUrlData(qr.destination_url || '');
else if (qr.data_type === 'vCard') setVcardData(qr.routing_data || {});
else if (qr.data_type === 'Email') setEmailData(qr.routing_data || {});
else if (qr.data_type === 'SMS') setSmsData(qr.routing_data || {});
else if (qr.data_type === 'Wi-Fi') setWifiData(qr.routing_data || {});
else if (qr.data_type === 'App Store') setAppStoreData(qr.routing_data || {});
if (qr.design_data) { if (qr.design_data) {
setQrOptions({ setQrOptions({
@@ -65,22 +86,65 @@ export default function EditQRPage() {
}); });
}, [qrId, baseUrl, router]); }, [qrId, baseUrl, router]);
// Keep QR data in sync with custom slug (only if dynamic) // Keep QR data in sync with inputs
useEffect(() => {
if (loading) return;
let payload = '';
if (dataType === 'Wi-Fi' && qrType !== 'static') setQrType('static');
if (dataType === 'App Store' && qrType !== 'dynamic') setQrType('dynamic');
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;
} else {
payload = `${baseUrl}/l/${customSlug}`;
}
setQrOptions(prev => ({ ...prev, data: payload || 'preview' }));
}, [qrType, dataType, urlData, vcardData, emailData, smsData, wifiData, appStoreData, customSlug, baseUrl, loading]);
const handleSlugChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleSlugChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (qrType === 'static') return; if (qrType === 'static') return;
const val = e.target.value.replace(/[^a-zA-Z0-9-_]/g, ''); const val = e.target.value.replace(/[^a-zA-Z0-9-_]/g, '');
setCustomSlug(val); setCustomSlug(val);
setQrOptions(prev => ({ ...prev, data: `${baseUrl}/l/${val}` }));
}; };
const handleAddTag = (e: React.KeyboardEvent<HTMLInputElement>) => {
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 handleSave = async () => { const handleSave = async () => {
try { try {
setIsSaving(true); setIsSaving(true);
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.put(`/qr/${qrId}`, { await api.put(`/qr/${qrId}`, {
name: qrName, name: qrName,
destinationUrl: destinationUrl, dataType: dataType,
destinationUrl: dataType === 'URL' ? urlData : undefined,
routingData: routingData,
shortUrlId: qrType === 'dynamic' ? customSlug : undefined, shortUrlId: qrType === 'dynamic' ? customSlug : undefined,
designData: qrOptions designData: qrOptions,
tags: tags
}); });
alert('QR Code Successfully Updated!'); alert('QR Code Successfully Updated!');
@@ -95,9 +159,25 @@ export default function EditQRPage() {
const handleDownload = async (extension: 'png' | 'svg' = 'png') => { const handleDownload = async (extension: 'png' | 'svg' = 'png') => {
try { try {
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 QRCodeStyling = (await import('qr-code-styling')).default;
const qrCode = new QRCodeStyling(qrOptions); const qrCode = new QRCodeStyling(qrOptions);
qrCode.download({ name: qrName || 'QR-Code', extension }); qrCode.download({ name: qrName || 'QR-Code', extension });
}
} catch (err) { } catch (err) {
console.error('Download failed', err); console.error('Download failed', err);
alert('Failed to download QR code'); alert('Failed to download QR code');
@@ -154,6 +234,7 @@ export default function EditQRPage() {
<h2 className="text-xl font-bold text-gray-800 border-b pb-4 mb-6">QR Content</h2> <h2 className="text-xl font-bold text-gray-800 border-b pb-4 mb-6">QR Content</h2>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* Type Selection */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">QR Type</label> <label className="block text-sm font-medium text-gray-700 mb-1">QR Type</label>
<div className="p-3 bg-gray-50 rounded-lg border border-gray-200 text-gray-600 font-medium text-sm"> <div className="p-3 bg-gray-50 rounded-lg border border-gray-200 text-gray-600 font-medium text-sm">
@@ -161,6 +242,67 @@ export default function EditQRPage() {
<span className="ml-2 text-xs text-gray-400 font-normal">(Type cannot be changed after creation)</span> <span className="ml-2 text-xs text-gray-400 font-normal">(Type cannot be changed after creation)</span>
</div> </div>
</div> </div>
{qrType === 'dynamic' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 pt-4 border-t border-gray-100">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Custom Short Link <span className="text-indigo-600 font-bold ml-1 text-xs px-2 py-0.5 bg-indigo-50 rounded-full border border-indigo-100">PRO</span></label>
<div className="flex rounded-md shadow-sm">
<span className="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-300 bg-gray-50 text-gray-500 sm:text-sm">
ck-qr.com/l/
</span>
<input
type="text"
value={customSlug}
onChange={handleSlugChange}
disabled={userRole === 'free' || userRole === 'free_trial'}
className={`flex-1 min-w-0 block w-full px-3 py-3 rounded-none rounded-r-md border border-gray-300 focus:ring-indigo-500 focus:border-indigo-500 text-gray-900 sm:text-sm ${userRole === 'free' || userRole === 'free_trial' ? 'bg-gray-100 cursor-not-allowed' : ''}`}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Tags</label>
<div className="flex flex-col gap-2">
<input
type="text"
value={tagInput}
onChange={e => setTagInput(e.target.value)}
onKeyDown={handleAddTag}
placeholder="Type and press Enter"
className="w-full border-gray-300 rounded-lg shadow-sm p-3 border focus:ring-indigo-500 focus:border-indigo-500 text-gray-900 sm:text-sm"
/>
{tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{tags.map(tag => (
<span key={tag} className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-gray-100 text-gray-700">
{tag}
<button type="button" onClick={() => removeTag(tag)} className="ml-1 text-gray-400 hover:text-red-500">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</span>
))}
</div>
)}
</div>
</div>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Data Type</label>
<div className="flex flex-wrap gap-2">
{['URL', 'vCard', 'Email', 'SMS', 'Wi-Fi', 'App Store'].map(type => (
<button
key={type}
type="button"
onClick={() => setDataType(type)}
className={`px-3 py-1.5 text-sm rounded-lg border font-medium transition-colors ${dataType === type ? 'bg-indigo-50 border-indigo-200 text-indigo-700' : 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'}`}
>
{type}
</button>
))}
</div>
</div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Internal Name</label> <label className="block text-sm font-medium text-gray-700 mb-1">Internal Name</label>
@@ -173,21 +315,118 @@ export default function EditQRPage() {
/> />
</div> </div>
{/* Dynamic Forms based on dataType */}
<div className="bg-gray-50 p-4 rounded-xl border border-gray-100">
{dataType === 'URL' && (
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Destination URL</label> <label className="block text-sm font-medium text-gray-700 mb-1">Destination URL</label>
<input <input
type="url" type="url"
value={destinationUrl} value={urlData}
onChange={e => { onChange={e => setUrlData(e.target.value)}
setDestinationUrl(e.target.value); placeholder="https://your-website.com"
if (qrType === 'static') { 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"
setQrOptions(prev => ({ ...prev, data: e.target.value }));
}
}}
disabled={qrType === 'static'}
className={`w-full rounded-lg shadow-sm p-3 border focus:ring-indigo-500 focus:border-indigo-500 text-gray-900 ${qrType === 'static' ? 'bg-gray-100 border-gray-200 text-gray-500' : 'bg-white border-gray-300'}`}
/> />
</div> </div>
)}
{dataType === 'vCard' && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">First Name</label>
<input type="text" value={vcardData.firstName} onChange={e => 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" />
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Last Name</label>
<input type="text" value={vcardData.lastName} onChange={e => 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" />
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Phone</label>
<input type="tel" value={vcardData.phone} onChange={e => 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" />
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Email</label>
<input type="email" value={vcardData.email} onChange={e => 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" />
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Company</label>
<input type="text" value={vcardData.company} onChange={e => 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" />
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Title</label>
<input type="text" value={vcardData.title} onChange={e => 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" />
</div>
</div>
)}
{dataType === 'Email' && (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Email To</label>
<input type="email" value={emailData.email} onChange={e => 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" />
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Subject</label>
<input type="text" value={emailData.subject} onChange={e => 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" />
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Body</label>
<textarea value={emailData.body} onChange={e => setEmailData({...emailData, body: 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 h-20" />
</div>
</div>
)}
{dataType === 'SMS' && (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Phone Number</label>
<input type="tel" value={smsData.phone} onChange={e => setSmsData({...smsData, 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" />
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Message</label>
<textarea value={smsData.message} onChange={e => setSmsData({...smsData, message: 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 h-20" />
</div>
</div>
)}
{dataType === 'Wi-Fi' && (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Network Name (SSID)</label>
<input type="text" value={wifiData.ssid} onChange={e => setWifiData({...wifiData, ssid: 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" />
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Password</label>
<input type="text" value={wifiData.password} onChange={e => setWifiData({...wifiData, password: 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" />
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Encryption</label>
<select value={wifiData.encryption} onChange={e => setWifiData({...wifiData, encryption: 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">
<option value="WPA">WPA/WPA2/WPA3</option>
<option value="WEP">WEP</option>
<option value="nopass">None</option>
</select>
</div>
</div>
)}
{dataType === 'App Store' && (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Apple App Store URL</label>
<input type="url" value={appStoreData.ios} onChange={e => setAppStoreData({...appStoreData, ios: e.target.value})} placeholder="https://apps.apple.com/..." className="w-full border-gray-300 rounded-lg shadow-sm p-2 border focus:ring-indigo-500 text-gray-900 text-sm" />
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Google Play Store URL</label>
<input type="url" value={appStoreData.android} onChange={e => setAppStoreData({...appStoreData, android: e.target.value})} placeholder="https://play.google.com/..." className="w-full border-gray-300 rounded-lg shadow-sm p-2 border focus:ring-indigo-500 text-gray-900 text-sm" />
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Fallback URL (Desktop)</label>
<input type="url" value={appStoreData.fallback} onChange={e => setAppStoreData({...appStoreData, fallback: e.target.value})} placeholder="https://your-website.com" className="w-full border-gray-300 rounded-lg shadow-sm p-2 border focus:ring-indigo-500 text-gray-900 text-sm" />
</div>
</div>
)}
</div>
{qrType === 'dynamic' && ( {qrType === 'dynamic' && (
<div> <div>
+111 -11
View File
@@ -14,6 +14,8 @@ interface QRCode {
scans: number; scans: number;
design_data: any; design_data: any;
folder_id: string | null; folder_id: string | null;
data_type?: string;
tags?: string[];
} }
interface Folder { interface Folder {
@@ -26,6 +28,7 @@ export default function DashboardPage() {
const [qrs, setQrs] = useState<QRCode[]>([]); const [qrs, setQrs] = useState<QRCode[]>([]);
const [folders, setFolders] = useState<Folder[]>([]); const [folders, setFolders] = useState<Folder[]>([]);
const [selectedFolder, setSelectedFolder] = useState<string | 'all' | 'default'>('all'); const [selectedFolder, setSelectedFolder] = useState<string | 'all' | 'default'>('all');
const [tagFilter, setTagFilter] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [userRole, setUserRole] = useState<string>('free'); const [userRole, setUserRole] = useState<string>('free');
@@ -142,6 +145,37 @@ export default function DashboardPage() {
const baseUrl = typeof window !== 'undefined' ? window.location.origin : (process.env.NEXT_PUBLIC_APP_URL || 'https://qr.houseofwebsites.ai'); const baseUrl = typeof window !== 'undefined' ? window.location.origin : (process.env.NEXT_PUBLIC_APP_URL || 'https://qr.houseofwebsites.ai');
// Bulk Actions
const [selectedQrs, setSelectedQrs] = useState<string[]>([]);
const toggleSelectAll = () => {
if (selectedQrs.length === qrs.length) setSelectedQrs([]);
else setSelectedQrs(qrs.map(qr => qr.id));
};
const toggleSelectQr = (id: string) => {
setSelectedQrs(prev => prev.includes(id) ? prev.filter(q => q !== id) : [...prev, id]);
};
const handleBulkDelete = async () => {
if (!confirm(`Are you sure you want to delete ${selectedQrs.length} QR codes?`)) return;
try {
await api.post('/qr/bulk-delete', { ids: selectedQrs });
setQrs(qrs.filter(qr => !selectedQrs.includes(qr.id)));
setSelectedQrs([]);
} catch (err) {
alert('Failed to delete QR codes');
}
};
const handleBulkMove = async (folderId: string | 'default') => {
try {
await api.post('/qr/bulk-move', { ids: selectedQrs, folderId });
fetchQRs();
setSelectedQrs([]);
} catch (err) {
alert('Failed to move QR codes');
}
};
return ( return (
<div className="p-8 max-w-7xl mx-auto font-sans relative flex gap-8 flex-col md:flex-row"> <div className="p-8 max-w-7xl mx-auto font-sans relative flex gap-8 flex-col md:flex-row">
@@ -190,15 +224,25 @@ export default function DashboardPage() {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{/* Header */} {/* Header */}
<div className="flex justify-between items-center mb-8 bg-white p-6 rounded-2xl shadow-sm border border-gray-200"> <div className="flex justify-between items-center mb-6 bg-white p-6 rounded-2xl shadow-sm border border-gray-200">
<div> <div>
<h1 className="text-3xl font-extrabold text-gray-900 tracking-tight">My QR Codes</h1> <h1 className="text-3xl font-extrabold text-gray-900 tracking-tight">My QR Codes</h1>
<p className="text-gray-500 mt-1">Manage, track, and dynamically route your active links.</p> <p className="text-gray-500 mt-1">Manage, track, and dynamically route your active links.</p>
</div> </div>
<div className="flex gap-4"> <div className="flex gap-4 items-center">
<div className="relative">
<input
type="text"
placeholder="Filter by tag..."
value={tagFilter}
onChange={(e) => setTagFilter(e.target.value)}
className="pl-9 pr-4 py-2 border border-gray-300 rounded-lg text-sm focus:ring-indigo-500 focus:border-indigo-500 bg-gray-50 text-gray-900"
/>
<svg className="w-4 h-4 text-gray-400 absolute left-3 top-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
</div>
<Link <Link
href="/dashboard/analytics" href="/dashboard/analytics"
className="bg-white border border-gray-300 text-gray-700 px-5 py-2.5 rounded-lg hover:bg-gray-50 transition-colors font-semibold flex items-center gap-2" className="bg-white border border-gray-300 text-gray-700 px-5 py-2.5 rounded-lg hover:bg-gray-50 transition-colors font-semibold flex items-center gap-2 shadow-sm"
> >
<svg className="w-5 h-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg> <svg className="w-5 h-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>
Analytics Analytics
@@ -206,30 +250,76 @@ export default function DashboardPage() {
</div> </div>
</div> </div>
{/* Bulk Actions Toolbar */}
{selectedQrs.length > 0 && (
<div className="bg-indigo-50 border border-indigo-100 rounded-xl p-3 mb-4 flex justify-between items-center animate-fade-in">
<span className="text-indigo-700 font-semibold px-2">{selectedQrs.length} Selected</span>
<div className="flex gap-2 relative">
<div className="group relative">
<button className="bg-white border border-indigo-200 text-indigo-700 px-4 py-2 rounded-lg font-medium text-sm hover:bg-indigo-50 transition-colors">
Move to Folder
</button>
<div className="absolute right-0 mt-1 w-48 bg-white rounded-xl shadow-xl border border-gray-100 py-2 hidden group-hover:block z-20">
<button onClick={() => handleBulkMove('default')} className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-indigo-50 hover:text-indigo-700">Default Folder</button>
{folders.map(f => (
<button key={f.id} onClick={() => handleBulkMove(f.id)} className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-indigo-50 hover:text-indigo-700">{f.name}</button>
))}
</div>
</div>
<button onClick={handleBulkDelete} className="bg-red-50 border border-red-200 text-red-600 px-4 py-2 rounded-lg font-medium text-sm hover:bg-red-100 transition-colors">
Delete
</button>
</div>
</div>
)}
{/* Select All Row */}
{qrs.length > 0 && (
<div className="flex items-center px-6 py-2 mb-2 bg-white rounded-xl border border-gray-100 shadow-sm">
<input
type="checkbox"
checked={selectedQrs.length === qrs.length && qrs.length > 0}
onChange={toggleSelectAll}
className="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500 cursor-pointer"
/>
<span className="ml-3 text-sm font-medium text-gray-700">Select All</span>
</div>
)}
{/* List View */} {/* List View */}
<div className="space-y-4"> <div className="space-y-4">
{loading ? ( {loading ? (
<div className="p-12 text-center text-gray-500 bg-white rounded-2xl shadow-sm border border-gray-200">Loading your dynamic links...</div> <div className="p-12 text-center text-gray-500 bg-white rounded-2xl shadow-sm border border-gray-200">Loading your dynamic links...</div>
) : qrs.length === 0 ? ( ) : qrs.filter(qr => !tagFilter || qr.tags?.some(tag => tag.toLowerCase().includes(tagFilter.toLowerCase()))).length === 0 ? (
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-16 text-center"> <div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-16 text-center">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4"> <div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg> <svg className="w-8 h-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg>
</div> </div>
<h3 className="text-xl font-bold text-gray-900 mb-2">No QR codes yet</h3> <h3 className="text-xl font-bold text-gray-900 mb-2">No QR codes found</h3>
<p className="text-gray-500 mb-6 max-w-sm mx-auto">Create your first dynamic link to generate a trackable QR code in this folder.</p> <p className="text-gray-500 mb-6 max-w-sm mx-auto">Create a new QR code or adjust your filters.</p>
<Link href="/dashboard/create" className="inline-block bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-3 px-8 rounded-xl transition-colors shadow-sm">Create New QR Code</Link> <Link href="/dashboard/create" className="inline-block bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-3 px-8 rounded-xl transition-colors shadow-sm">Create New QR Code</Link>
</div> </div>
) : ( ) : (
qrs.map((qr) => ( qrs.filter(qr => !tagFilter || qr.tags?.some(tag => tag.toLowerCase().includes(tagFilter.toLowerCase()))).map((qr) => (
<div key={qr.id} className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6 flex flex-col md:flex-row items-center gap-8 hover:shadow-md transition-shadow relative group"> <div key={qr.id} className={`bg-white rounded-2xl shadow-sm border p-6 flex flex-col md:flex-row items-center gap-8 hover:shadow-md transition-all relative group ${selectedQrs.includes(qr.id) ? 'border-indigo-400 ring-1 ring-indigo-400' : 'border-gray-200'}`}>
{/* Checkbox */}
<div className="absolute top-6 left-6 md:relative md:top-0 md:left-0 z-10 bg-white md:bg-transparent p-1 md:p-0 rounded-md">
<input
type="checkbox"
checked={selectedQrs.includes(qr.id)}
onChange={() => toggleSelectQr(qr.id)}
className="w-5 h-5 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500 cursor-pointer shadow-sm"
/>
</div>
{/* QR Image Custom Thumbnail */} {/* QR Image Custom Thumbnail */}
<div className="w-32 h-32 flex-shrink-0 flex items-center justify-center relative border border-gray-100 rounded-xl overflow-hidden bg-gray-50"> <div className="w-32 h-32 flex-shrink-0 flex items-center justify-center relative border border-gray-100 rounded-xl overflow-hidden bg-gray-50 mt-4 md:mt-0">
<LiveThumbnail options={{ ...qr.design_data, data: `${baseUrl}/l/${qr.short_url_id}` }} /> <LiveThumbnail options={{ ...qr.design_data, data: `${baseUrl}/l/${qr.short_url_id}` }} />
</div> </div>
{/* Info Area */} {/* Info Area */}
<div className="flex-1 min-w-0 w-full"> <div className="flex-1 min-w-0 w-full mt-4 md:mt-0">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<h3 className="text-xl font-bold text-gray-900 truncate">{qr.name}</h3> <h3 className="text-xl font-bold text-gray-900 truncate">{qr.name}</h3>
<Link href={`/dashboard/edit/${qr.id}`} className="text-gray-400 hover:text-indigo-600 transition-colors" title="Edit QR settings"> <Link href={`/dashboard/edit/${qr.id}`} className="text-gray-400 hover:text-indigo-600 transition-colors" title="Edit QR settings">
@@ -246,12 +336,22 @@ export default function DashboardPage() {
<div className="flex items-center gap-6 text-sm text-gray-500"> <div className="flex items-center gap-6 text-sm text-gray-500">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="font-semibold text-gray-700">Type:</span> Link <span className="font-semibold text-gray-700">Type:</span> {qr.data_type || 'URL'}
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="font-semibold text-gray-700">Created:</span> {new Date(qr.created_at).toLocaleDateString()} <span className="font-semibold text-gray-700">Created:</span> {new Date(qr.created_at).toLocaleDateString()}
</div> </div>
</div> </div>
{qr.tags && qr.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{qr.tags.map(tag => (
<span key={tag} className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600 border border-gray-200">
#{tag}
</span>
))}
</div>
)}
</div> </div>
{/* Metrics & Actions */} {/* Metrics & Actions */}
+37 -48
View File
@@ -1,92 +1,81 @@
'use client'; 'use client';
import { useEffect, useState, Suspense } from 'react'; import { useEffect, useState, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import api from '@/lib/api'; import api from '@/lib/api';
import Link from 'next/link'; import Link from 'next/link';
function VerifyEmailContent() { function VerifyContent() {
const router = useRouter(); const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
const [message, setMessage] = useState('Verifying your email...');
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const token = searchParams.get('token'); const token = searchParams.get('token');
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
const [message, setMessage] = useState('Verifying your email address...');
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
setStatus('error'); setStatus('error');
setMessage('No verification token found. Please check your email link.'); setMessage('No verification token provided.');
return; return;
} }
const verifyToken = async () => { api.get(`/auth/verify-email/${token}`)
try { .then((res) => {
await api.get(`/auth/verify-email/${token}`);
setStatus('success'); setStatus('success');
setMessage('Your email has been verified successfully!'); setMessage(res.data.message || 'Email verified successfully!');
})
// Auto redirect to dashboard after 3 seconds .catch((err) => {
setTimeout(() => {
router.push('/dashboard');
}, 3000);
} catch (err: any) {
setStatus('error'); setStatus('error');
setMessage(err.response?.data?.error || 'Failed to verify email. The link may have expired.'); setMessage(err.response?.data?.error || 'Failed to verify email. The link may have expired or is invalid.');
} });
}; }, [token]);
verifyToken();
}, [token, router]);
return ( return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8 font-sans"> <div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8 font-sans">
<div className="sm:mx-auto sm:w-full sm:max-w-md"> <div className="max-w-md w-full space-y-8 bg-white p-10 rounded-2xl shadow-xl border border-gray-100 text-center">
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900"> <div>
Email Verification <h2 className="mt-2 text-3xl font-extrabold text-gray-900 tracking-tight">Email Verification</h2>
</h2>
</div> </div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10 text-center">
{status === 'loading' && ( {status === 'loading' && (
<div className="text-gray-500"> <div className="mt-8">
<svg className="animate-spin h-8 w-8 mx-auto mb-4 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <svg className="animate-spin h-10 w-10 text-indigo-600 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg> </svg>
<p className="font-semibold text-lg">{message}</p> <p className="mt-4 text-gray-500">{message}</p>
</div> </div>
)} )}
{status === 'success' && ( {status === 'success' && (
<div> <div className="mt-8">
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4"> <div className="mx-auto flex items-center justify-center h-16 w-16 rounded-full bg-green-100 mb-6">
<svg className="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <svg className="h-8 w-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
</svg> </svg>
</div> </div>
<p className="font-bold text-gray-900 text-lg mb-2">{message}</p> <p className="text-gray-900 font-medium text-lg">{message}</p>
<p className="text-gray-500 text-sm mb-6">Redirecting to your dashboard...</p> <div className="mt-8">
<Link href="/dashboard" className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> <Link href="/dashboard" className="w-full flex justify-center py-3 px-4 border border-transparent rounded-xl shadow-sm text-sm font-bold text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors">
Go to Dashboard Go to Dashboard
</Link> </Link>
</div> </div>
</div>
)} )}
{status === 'error' && ( {status === 'error' && (
<div> <div className="mt-8">
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4"> <div className="mx-auto flex items-center justify-center h-16 w-16 rounded-full bg-red-100 mb-6">
<svg className="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <svg className="h-8 w-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</div> </div>
<p className="font-bold text-gray-900 text-lg mb-4">{message}</p> <p className="text-gray-900 font-medium text-lg">{message}</p>
<Link href="/dashboard" className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> <div className="mt-8">
Back to Dashboard <Link href="/login" className="w-full flex justify-center py-3 px-4 border border-gray-300 rounded-xl shadow-sm text-sm font-bold text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors">
Back to Login
</Link> </Link>
</div> </div>
)}
</div> </div>
)}
</div> </div>
</div> </div>
); );
@@ -94,8 +83,8 @@ function VerifyEmailContent() {
export default function VerifyEmailPage() { export default function VerifyEmailPage() {
return ( return (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center">Loading...</div>}> <Suspense fallback={<div className="min-h-screen flex items-center justify-center"><p>Loading...</p></div>}>
<VerifyEmailContent /> <VerifyContent />
</Suspense> </Suspense>
) );
} }
@@ -2,9 +2,11 @@
import React from 'react'; import React from 'react';
import { Options } from 'qr-code-styling'; import { Options } from 'qr-code-styling';
import { ExtendedOptions } from './LivePreview';
interface DesignPanelProps { interface DesignPanelProps {
options: Options; options: ExtendedOptions;
onChange: (options: Options) => void; onChange: (options: ExtendedOptions) => void;
} }
export default function DesignPanel({ options, onChange }: DesignPanelProps) { export default function DesignPanel({ options, onChange }: DesignPanelProps) {
@@ -237,6 +239,76 @@ export default function DesignPanel({ options, onChange }: DesignPanelProps) {
</div> </div>
</div> </div>
{/* Frame */}
<div className="border-t pt-4">
<div className="flex justify-between items-center mb-4">
<label className="block text-sm font-medium text-gray-700">QR Frame</label>
<button
type="button"
onClick={() => onChange({
...options,
frameOptions: {
enabled: !options.frameOptions?.enabled,
text: options.frameOptions?.text || 'SCAN ME',
bgColor: options.frameOptions?.bgColor || '#4f46e5',
textColor: options.frameOptions?.textColor || '#ffffff'
}
})}
className={`px-3 py-1.5 rounded-lg text-sm font-semibold transition-colors ${options.frameOptions?.enabled ? 'bg-indigo-100 text-indigo-700' : 'bg-gray-100 text-gray-600'}`}
>
{options.frameOptions?.enabled ? 'Disable Frame' : 'Enable Frame'}
</button>
</div>
{options.frameOptions?.enabled && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 bg-gray-50 p-4 rounded-xl border border-gray-100">
<div className="sm:col-span-2">
<label className="block text-xs font-medium text-gray-700 mb-1">Frame Text</label>
<input
type="text"
value={options.frameOptions.text}
onChange={e => onChange({ ...options, frameOptions: { ...options.frameOptions!, text: e.target.value }})}
maxLength={20}
className="w-full border-gray-300 rounded-lg shadow-sm p-2 border focus:ring-indigo-500 text-gray-900 text-sm uppercase"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Background Color</label>
<div className="flex items-center gap-2">
<input
type="color"
value={options.frameOptions.bgColor}
onChange={e => onChange({ ...options, frameOptions: { ...options.frameOptions!, bgColor: e.target.value }})}
className="w-8 h-8 rounded cursor-pointer border-0 p-0"
/>
<span className="text-gray-500 font-mono text-xs">{options.frameOptions.bgColor}</span>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Text Color</label>
<div className="flex items-center gap-2">
<input
type="color"
value={options.frameOptions.textColor}
onChange={e => onChange({ ...options, frameOptions: { ...options.frameOptions!, textColor: e.target.value }})}
className="w-8 h-8 rounded cursor-pointer border-0 p-0"
/>
<span className="text-gray-500 font-mono text-xs">{options.frameOptions.textColor}</span>
</div>
</div>
<div className="sm:col-span-2 mt-2 bg-yellow-50 border border-yellow-200 text-yellow-800 text-xs p-3 rounded-lg flex items-start gap-2">
<svg className="w-4 h-4 text-yellow-600 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<p>
<strong>Note:</strong> SVG download is not supported when a Frame is enabled. To export as SVG, please disable the frame.
</p>
</div>
</div>
)}
</div>
</div> </div>
); );
} }
@@ -2,8 +2,17 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import QRCodeStyling, { Options } from 'qr-code-styling'; import QRCodeStyling, { Options } from 'qr-code-styling';
export interface ExtendedOptions extends Options {
frameOptions?: {
enabled: boolean;
text: string;
bgColor: string;
textColor: string;
};
}
interface LivePreviewProps { interface LivePreviewProps {
options: Options; options: ExtendedOptions;
} }
export default function LivePreview({ options }: LivePreviewProps) { export default function LivePreview({ options }: LivePreviewProps) {
@@ -12,18 +21,33 @@ export default function LivePreview({ options }: LivePreviewProps) {
useEffect(() => { useEffect(() => {
if (ref.current) { if (ref.current) {
ref.current.innerHTML = '';
qrCode.append(ref.current); qrCode.append(ref.current);
} }
}, [qrCode, ref]); }, [qrCode, ref]);
useEffect(() => { useEffect(() => {
if (!qrCode) return; if (!qrCode) return;
qrCode.update(options); const { frameOptions, ...pureOptions } = options;
qrCode.update(pureOptions);
}, [qrCode, options]); }, [qrCode, options]);
const frame = options.frameOptions;
return ( return (
<div className="flex justify-center items-center p-8 bg-gray-50 rounded-xl border border-gray-200 min-h-[400px]"> <div className="flex justify-center items-center p-8 bg-gray-50 rounded-xl border border-gray-200 min-h-[400px]">
<div ref={ref} className="shadow-lg rounded-xl overflow-hidden bg-white" /> <div
id="qr-preview-container"
className={`relative ${frame?.enabled ? 'p-4 pb-8 rounded-2xl shadow-lg flex flex-col items-center justify-center gap-3' : 'shadow-lg rounded-xl overflow-hidden'}`}
style={frame?.enabled ? { backgroundColor: frame.bgColor || '#4f46e5' } : { backgroundColor: '#ffffff' }}
>
{frame?.enabled && (
<div className="font-bold text-center text-lg uppercase tracking-wider w-full" style={{ color: frame.textColor || '#ffffff' }}>
{frame.text || 'SCAN ME'}
</div>
)}
<div ref={ref} className="bg-white rounded-lg overflow-hidden flex items-center justify-center p-2" />
</div>
</div> </div>
); );
} }