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
+7
View File
@@ -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",
+1
View File
@@ -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",
+239 -81
View File
@@ -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 {
const QRCodeStyling = (await import('qr-code-styling')).default;
const qrCode = new QRCodeStyling(qrOptions);
qrCode.download({ name: qrName || 'QR-Code', extension });
if (qrOptions.frameOptions?.enabled) {
if (extension === 'svg') {
alert("SVG export is not supported when a Frame is enabled.");
return;
}
const { toPng } = await import('html-to-image');
const node = document.getElementById('qr-preview-container');
if (node) {
const dataUrl = await toPng(node, { quality: 1, pixelRatio: 3 });
const link = document.createElement('a');
link.download = `${qrName || 'QR-Code'}.png`;
link.href = dataUrl;
link.click();
}
} else {
const QRCodeStyling = (await import('qr-code-styling')).default;
const qrCode = new QRCodeStyling(qrOptions);
qrCode.download({ name: qrName || 'QR-Code', extension });
}
} catch (err) {
console.error('Download failed', err);
alert('Failed to download QR code');
@@ -117,10 +157,15 @@ export default function CreateQRPage() {
return (
<div className="p-8 max-w-7xl mx-auto h-full">
<div className="flex justify-between items-center mb-8">
<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>
<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-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>
<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)}
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>
{/* 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={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>
)}
{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>
+265 -26
View File
@@ -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 baseUrl = typeof window !== 'undefined' ? (process.env.NEXT_PUBLIC_APP_URL || 'https://qr.houseofwebsites.ai') : 'https://qr.houseofwebsites.ai';
const [tags, setTags] = useState<string[]>([]);
const [tagInput, setTagInput] = useState('');
const [qrOptions, setQrOptions] = useState<Options>({
// 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<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 {
const QRCodeStyling = (await import('qr-code-styling')).default;
const qrCode = new QRCodeStyling(qrOptions);
qrCode.download({ name: qrName || 'QR-Code', extension });
if (qrOptions.frameOptions?.enabled) {
if (extension === 'svg') {
alert("SVG export is not supported when a Frame is enabled.");
return;
}
const { toPng } = await import('html-to-image');
const node = document.getElementById('qr-preview-container');
if (node) {
const dataUrl = await toPng(node, { quality: 1, pixelRatio: 3 });
const link = document.createElement('a');
link.download = `${qrName || 'QR-Code'}.png`;
link.href = dataUrl;
link.click();
}
} else {
const QRCodeStyling = (await import('qr-code-styling')).default;
const qrCode = new QRCodeStyling(qrOptions);
qrCode.download({ name: qrName || 'QR-Code', extension });
}
} catch (err) {
console.error('Download failed', err);
alert('Failed to download QR code');
@@ -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,20 +315,117 @@ export default function EditQRPage() {
/>
</div>
<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'}`}
/>
{/* 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={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' && (
+111 -11
View File
@@ -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 */}
+55 -66
View File
@@ -1,101 +1,90 @@
'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>
<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">
<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>
{status === 'loading' && (
<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="mt-4 text-gray-500">{message}</p>
</div>
)}
{status === 'success' && (
<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>
<p className="font-semibold text-lg">{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">
<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">
<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
{status === 'error' && (
<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="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>
</div>
);
}
export default function VerifyEmailPage() {
return (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center">Loading...</div>}>
<VerifyEmailContent />
</Suspense>
)
return (
<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) {
@@ -236,6 +238,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>
);
}