Implement email verification lockout and missing phase 2 items
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
@@ -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' });
|
||||
|
||||
|
||||
+82
-15
@@ -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] });
|
||||
|
||||
+43
-6
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<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 [qrOptions, setQrOptions] = useState<Options>({
|
||||
const [qrOptions, setQrOptions] = useState<ExtendedOptions>({
|
||||
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<HTMLInputElement>) => {
|
||||
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<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 () => {
|
||||
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 {
|
||||
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 (
|
||||
<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>
|
||||
<h1 className="text-3xl font-bold text-gray-800">Create QR Code</h1>
|
||||
<p className="text-gray-500 mt-1">Design a beautiful, dynamic QR code</p>
|
||||
<h1 className="text-3xl font-extrabold text-gray-900 tracking-tight">Create QR Code</h1>
|
||||
<p className="text-gray-500 mt-1">Design and configure a new QR code.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex gap-2">
|
||||
@@ -145,11 +190,11 @@ export default function CreateQRPage() {
|
||||
</div>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating}
|
||||
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"
|
||||
disabled={isGenerating || !qrName}
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
{/* Type Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">QR Type</label>
|
||||
<div className="flex gap-4">
|
||||
@@ -190,6 +236,22 @@ export default function CreateQRPage() {
|
||||
</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>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Internal Name</label>
|
||||
<input
|
||||
@@ -201,20 +263,116 @@ export default function CreateQRPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Forms based on dataType */}
|
||||
<div className="bg-gray-50 p-4 rounded-xl border border-gray-100">
|
||||
{dataType === 'URL' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Destination URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={destinationUrl}
|
||||
onChange={e => setDestinationUrl(e.target.value)}
|
||||
value={urlData}
|
||||
onChange={e => 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 === 'dynamic' && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
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';
|
||||
@@ -14,15 +15,27 @@ export default function EditQRPage() {
|
||||
const qrId = params.id as string;
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [destinationUrl, setDestinationUrl] = useState('https://ck-qr.com');
|
||||
const [qrName, setQrName] = useState('');
|
||||
const [qrType, setQrType] = useState<'dynamic' | 'static'>('dynamic');
|
||||
const [dataType, setDataType] = useState('URL');
|
||||
const [customSlug, setCustomSlug] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
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 [qrOptions, setQrOptions] = useState<Options>({
|
||||
|
||||
const [qrOptions, setQrOptions] = useState<ExtendedOptions>({
|
||||
width: 300,
|
||||
height: 300,
|
||||
type: 'svg',
|
||||
@@ -47,9 +60,17 @@ export default function EditQRPage() {
|
||||
api.get(`/qr/${qrId}`).then(res => {
|
||||
const qr = res.data.qr_code;
|
||||
setQrName(qr.name);
|
||||
setDestinationUrl(qr.destination_url);
|
||||
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) {
|
||||
setQrOptions({
|
||||
@@ -65,22 +86,65 @@ export default function EditQRPage() {
|
||||
});
|
||||
}, [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>) => {
|
||||
if (qrType === 'static') return;
|
||||
const val = e.target.value.replace(/[^a-zA-Z0-9-_]/g, '');
|
||||
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 () => {
|
||||
try {
|
||||
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}`, {
|
||||
name: qrName,
|
||||
destinationUrl: destinationUrl,
|
||||
dataType: dataType,
|
||||
destinationUrl: dataType === 'URL' ? urlData : undefined,
|
||||
routingData: routingData,
|
||||
shortUrlId: qrType === 'dynamic' ? customSlug : undefined,
|
||||
designData: qrOptions
|
||||
designData: qrOptions,
|
||||
tags: tags
|
||||
});
|
||||
|
||||
alert('QR Code Successfully Updated!');
|
||||
@@ -95,9 +159,25 @@ export default function EditQRPage() {
|
||||
|
||||
const handleDownload = async (extension: 'png' | 'svg' = 'png') => {
|
||||
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 qrCode = new QRCodeStyling(qrOptions);
|
||||
qrCode.download({ name: qrName || 'QR-Code', extension });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Download failed', err);
|
||||
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>
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
{/* Type Selection */}
|
||||
<div>
|
||||
<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">
|
||||
@@ -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>
|
||||
</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>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Internal Name</label>
|
||||
@@ -173,21 +315,118 @@ export default function EditQRPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Forms based on dataType */}
|
||||
<div className="bg-gray-50 p-4 rounded-xl border border-gray-100">
|
||||
{dataType === 'URL' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Destination URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={destinationUrl}
|
||||
onChange={e => {
|
||||
setDestinationUrl(e.target.value);
|
||||
if (qrType === 'static') {
|
||||
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'}`}
|
||||
value={urlData}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</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' && (
|
||||
<div>
|
||||
|
||||
@@ -14,6 +14,8 @@ interface QRCode {
|
||||
scans: number;
|
||||
design_data: any;
|
||||
folder_id: string | null;
|
||||
data_type?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
interface Folder {
|
||||
@@ -26,6 +28,7 @@ export default function DashboardPage() {
|
||||
const [qrs, setQrs] = useState<QRCode[]>([]);
|
||||
const [folders, setFolders] = useState<Folder[]>([]);
|
||||
const [selectedFolder, setSelectedFolder] = useState<string | 'all' | 'default'>('all');
|
||||
const [tagFilter, setTagFilter] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
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');
|
||||
|
||||
// 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 (
|
||||
<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">
|
||||
{/* 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>
|
||||
<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>
|
||||
</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
|
||||
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>
|
||||
Analytics
|
||||
@@ -206,30 +250,76 @@ export default function DashboardPage() {
|
||||
</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 */}
|
||||
<div className="space-y-4">
|
||||
{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>
|
||||
) : 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="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>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">No QR codes yet</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>
|
||||
<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 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>
|
||||
</div>
|
||||
) : (
|
||||
qrs.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">
|
||||
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 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 */}
|
||||
<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}` }} />
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<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">
|
||||
@@ -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-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 className="flex items-center gap-1">
|
||||
<span className="font-semibold text-gray-700">Created:</span> {new Date(qr.created_at).toLocaleDateString()}
|
||||
</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>
|
||||
|
||||
{/* Metrics & Actions */}
|
||||
|
||||
@@ -1,92 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, Suspense } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import api from '@/lib/api';
|
||||
import Link from 'next/link';
|
||||
|
||||
function VerifyEmailContent() {
|
||||
const router = useRouter();
|
||||
function VerifyContent() {
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||
const [message, setMessage] = useState('Verifying your email...');
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get('token');
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||
const [message, setMessage] = useState('Verifying your email address...');
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setStatus('error');
|
||||
setMessage('No verification token found. Please check your email link.');
|
||||
setMessage('No verification token provided.');
|
||||
return;
|
||||
}
|
||||
|
||||
const verifyToken = async () => {
|
||||
try {
|
||||
await api.get(`/auth/verify-email/${token}`);
|
||||
api.get(`/auth/verify-email/${token}`)
|
||||
.then((res) => {
|
||||
setStatus('success');
|
||||
setMessage('Your email has been verified successfully!');
|
||||
|
||||
// Auto redirect to dashboard after 3 seconds
|
||||
setTimeout(() => {
|
||||
router.push('/dashboard');
|
||||
}, 3000);
|
||||
} catch (err: any) {
|
||||
setMessage(res.data.message || 'Email verified successfully!');
|
||||
})
|
||||
.catch((err) => {
|
||||
setStatus('error');
|
||||
setMessage(err.response?.data?.error || 'Failed to verify email. The link may have expired.');
|
||||
}
|
||||
};
|
||||
|
||||
verifyToken();
|
||||
}, [token, router]);
|
||||
setMessage(err.response?.data?.error || 'Failed to verify email. The link may have expired or is invalid.');
|
||||
});
|
||||
}, [token]);
|
||||
|
||||
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="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Email Verification
|
||||
</h2>
|
||||
<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="max-w-md w-full space-y-8 bg-white p-10 rounded-2xl shadow-xl border border-gray-100 text-center">
|
||||
<div>
|
||||
<h2 className="mt-2 text-3xl font-extrabold text-gray-900 tracking-tight">Email Verification</h2>
|
||||
</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' && (
|
||||
<div className="text-gray-500">
|
||||
<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">
|
||||
<div className="mt-8">
|
||||
<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>
|
||||
<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>
|
||||
<p className="font-semibold text-lg">{message}</p>
|
||||
<p className="mt-4 text-gray-500">{message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<div>
|
||||
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4">
|
||||
<svg className="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<div className="mt-8">
|
||||
<div className="mx-auto flex items-center justify-center h-16 w-16 rounded-full bg-green-100 mb-6">
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-bold text-gray-900 text-lg mb-2">{message}</p>
|
||||
<p className="text-gray-500 text-sm mb-6">Redirecting to your dashboard...</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">
|
||||
<p className="text-gray-900 font-medium text-lg">{message}</p>
|
||||
<div className="mt-8">
|
||||
<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
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<div>
|
||||
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
|
||||
<svg className="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<div className="mt-8">
|
||||
<div className="mx-auto flex items-center justify-center h-16 w-16 rounded-full bg-red-100 mb-6">
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-bold text-gray-900 text-lg mb-4">{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">
|
||||
Back to Dashboard
|
||||
<p className="text-gray-900 font-medium text-lg">{message}</p>
|
||||
<div className="mt-8">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -94,8 +83,8 @@ function VerifyEmailContent() {
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="min-h-screen flex items-center justify-center">Loading...</div>}>
|
||||
<VerifyEmailContent />
|
||||
<Suspense fallback={<div className="min-h-screen flex items-center justify-center"><p>Loading...</p></div>}>
|
||||
<VerifyContent />
|
||||
</Suspense>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,11 @@
|
||||
import React from 'react';
|
||||
import { Options } from 'qr-code-styling';
|
||||
|
||||
import { ExtendedOptions } from './LivePreview';
|
||||
|
||||
interface DesignPanelProps {
|
||||
options: Options;
|
||||
onChange: (options: Options) => void;
|
||||
options: ExtendedOptions;
|
||||
onChange: (options: ExtendedOptions) => void;
|
||||
}
|
||||
|
||||
export default function DesignPanel({ options, onChange }: DesignPanelProps) {
|
||||
@@ -237,6 +239,76 @@ export default function DesignPanel({ options, onChange }: DesignPanelProps) {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,17 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import QRCodeStyling, { Options } from 'qr-code-styling';
|
||||
|
||||
export interface ExtendedOptions extends Options {
|
||||
frameOptions?: {
|
||||
enabled: boolean;
|
||||
text: string;
|
||||
bgColor: string;
|
||||
textColor: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface LivePreviewProps {
|
||||
options: Options;
|
||||
options: ExtendedOptions;
|
||||
}
|
||||
|
||||
export default function LivePreview({ options }: LivePreviewProps) {
|
||||
@@ -12,18 +21,33 @@ export default function LivePreview({ options }: LivePreviewProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
ref.current.innerHTML = '';
|
||||
qrCode.append(ref.current);
|
||||
}
|
||||
}, [qrCode, ref]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!qrCode) return;
|
||||
qrCode.update(options);
|
||||
const { frameOptions, ...pureOptions } = options;
|
||||
qrCode.update(pureOptions);
|
||||
}, [qrCode, options]);
|
||||
|
||||
const frame = options.frameOptions;
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user