diff --git a/api/routes/folders.js b/api/routes/folders.js new file mode 100644 index 0000000..206d056 --- /dev/null +++ b/api/routes/folders.js @@ -0,0 +1,60 @@ +const express = require('express'); +const router = express.Router(); +const { Pool } = require('pg'); +const { verifyToken } = require('../middleware/auth'); + +const pgPool = new Pool({ + host: process.env.PGHOST || 'postgres', + port: process.env.PGPORT || 5432, + user: process.env.PGUSER || 'postgres', + password: process.env.PGPASSWORD || 'postgres', + database: process.env.PGDATABASE || 'ckqr', +}); + +// Get all folders for user +router.get('/', verifyToken, async (req, res) => { + try { + const result = await pgPool.query( + 'SELECT * FROM folders WHERE user_id = $1 ORDER BY created_at DESC', + [req.user.id] + ); + res.json({ folders: result.rows }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Server error fetching folders' }); + } +}); + +// Create a new folder +router.post('/', verifyToken, async (req, res) => { + const { name } = req.body; + if (!name) return res.status(400).json({ error: 'Folder name is required' }); + + try { + const result = await pgPool.query( + 'INSERT INTO folders (user_id, name) VALUES ($1, $2) RETURNING *', + [req.user.id, name] + ); + res.json({ message: 'Folder created', folder: result.rows[0] }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Server error creating folder' }); + } +}); + +// Delete a folder +router.delete('/:id', verifyToken, async (req, res) => { + try { + const result = await pgPool.query( + 'DELETE FROM folders WHERE id = $1 AND user_id = $2 RETURNING id', + [req.params.id, req.user.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Folder not found or unauthorized' }); + res.json({ message: 'Folder deleted' }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Error deleting folder' }); + } +}); + +module.exports = router; diff --git a/api/routes/qr.js b/api/routes/qr.js index f936d7b..d16cdd9 100644 --- a/api/routes/qr.js +++ b/api/routes/qr.js @@ -16,14 +16,23 @@ const pgPool = new Pool({ // Get all QRs for user router.get('/', verifyToken, async (req, res) => { try { - const result = await pgPool.query( - `SELECT q.*, + const { folderId } = req.query; + let queryStr = `SELECT q.*, COALESCE((SELECT COUNT(*) FROM qr_scans s WHERE s.qr_code_id = q.id), 0) as scans FROM qr_codes q - WHERE q.user_id = $1 - ORDER BY q.created_at DESC`, - [req.user.id] - ); + WHERE q.user_id = $1`; + let params = [req.user.id]; + + if (folderId === 'default') { + queryStr += ` AND q.folder_id IS NULL`; + } else if (folderId) { + queryStr += ` AND q.folder_id = $2`; + params.push(folderId); + } + + queryStr += ` ORDER BY q.created_at DESC`; + + const result = await pgPool.query(queryStr, params); res.json({ qr_codes: result.rows }); } catch (err) { console.error(err); @@ -31,9 +40,24 @@ router.get('/', verifyToken, async (req, res) => { } }); +// Get single QR code by ID +router.get('/:id', verifyToken, async (req, res) => { + try { + const result = await pgPool.query( + `SELECT * FROM qr_codes WHERE id = $1 AND user_id = $2`, + [req.params.id, req.user.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'QR Code not found' }); + res.json({ qr_code: result.rows[0] }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Server error' }); + } +}); + // Generate QR Code router.post('/generate', verifyToken, async (req, res) => { - const { name, type, dataType, destinationUrl, designData, customSlug } = req.body; + const { name, type, dataType, destinationUrl, designData, customSlug, folderId } = req.body; const userId = req.user.id; if (!type || !dataType) { @@ -63,9 +87,9 @@ router.post('/generate', verifyToken, async (req, res) => { } const result = await pgPool.query( - `INSERT INTO qr_codes (user_id, name, type, data_type, destination_url, short_url_id, design_data) - VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, - [userId, name || 'Untitled QR', type, dataType, destinationUrl, shortUrlId, designData || {}] + `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] ); // Generate Image (Data URI for simple response, could be saved to S3 later) @@ -82,9 +106,9 @@ router.post('/generate', verifyToken, async (req, res) => { } }); -// Update QR Code (Editable Link) +// Update QR Code (Editable Link / Move Folder / Update Design) router.put('/:id', verifyToken, async (req, res) => { - const { name, destinationUrl, shortUrlId } = req.body; + const { name, destinationUrl, shortUrlId, designData, folderId } = req.body; const qrId = req.params.id; const userId = req.user.id; @@ -111,9 +135,11 @@ router.put('/:id', verifyToken, async (req, res) => { `UPDATE qr_codes SET name = COALESCE($1, name), destination_url = COALESCE($2, destination_url), - short_url_id = COALESCE($3, short_url_id) - WHERE id = $4 RETURNING *`, - [name, destinationUrl, newShortUrlId, qrId] + 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] ); res.json({ message: 'QR Code updated', qrRecord: updated.rows[0] }); diff --git a/api/server.js b/api/server.js index 3046675..684d29b 100644 --- a/api/server.js +++ b/api/server.js @@ -28,12 +28,14 @@ const redirectRoutes = require('./routes/redirect'); const analyticsRoutes = require('./routes/analytics'); const adminRoutes = require('./routes/admin'); const paymentsRoutes = require('./routes/payments'); +const foldersRoutes = require('./routes/folders'); app.use('/api/auth', authRoutes); app.use('/api/qr', qrRoutes); app.use('/api/analytics', analyticsRoutes); app.use('/api/admin', adminRoutes); app.use('/api/payments', paymentsRoutes); +app.use('/api/folders', foldersRoutes); // Support Traefik stripped prefixes app.use('/auth', authRoutes); @@ -41,6 +43,7 @@ app.use('/qr', qrRoutes); app.use('/analytics', analyticsRoutes); app.use('/admin', adminRoutes); app.use('/payments', paymentsRoutes); +app.use('/folders', foldersRoutes); app.use('/l', redirectRoutes); @@ -112,7 +115,16 @@ app.get('/api/health', healthCheck); app.get('/health', healthCheck); // Auto-migrate schema +pgPool.query(` +CREATE TABLE IF NOT EXISTS folders ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +);`).catch(console.error); + pgPool.query(`ALTER TABLE qr_codes ADD COLUMN IF NOT EXISTS name VARCHAR(255);`).catch(console.error); +pgPool.query(`ALTER TABLE qr_codes ADD COLUMN IF NOT EXISTS folder_id UUID REFERENCES folders(id) ON DELETE SET NULL;`).catch(console.error); pgPool.query(` CREATE TABLE IF NOT EXISTS qr_scans ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), diff --git a/client/src/app/dashboard/edit/[id]/page.tsx b/client/src/app/dashboard/edit/[id]/page.tsx new file mode 100644 index 0000000..a632984 --- /dev/null +++ b/client/src/app/dashboard/edit/[id]/page.tsx @@ -0,0 +1,230 @@ +'use client'; +import React, { useState, useEffect } from 'react'; +import type { Options } from 'qr-code-styling'; +import dynamic from 'next/dynamic'; +const LivePreview = dynamic(() => import('@/components/qr-editor/LivePreview'), { ssr: false }); +import DesignPanel from '@/components/qr-editor/DesignPanel'; +import api from '@/lib/api'; +import { useRouter, useParams } from 'next/navigation'; +import Link from 'next/link'; + +export default function EditQRPage() { + const router = useRouter(); + const params = useParams(); + 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 [customSlug, setCustomSlug] = useState(''); + const [isSaving, setIsSaving] = useState(false); + const [userRole, setUserRole] = useState('free'); + const baseUrl = typeof window !== 'undefined' ? (process.env.NEXT_PUBLIC_APP_URL || 'https://qr.houseofwebsites.ai') : 'https://qr.houseofwebsites.ai'; + + const [qrOptions, setQrOptions] = useState({ + width: 300, + height: 300, + type: 'svg', + 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' } + }); + + useEffect(() => { + // Fetch user role + api.get('/auth/me').then(res => { + if (res.data?.user?.role) { + setUserRole(res.data.user.role); + } + }).catch(() => {}); + + // Fetch existing QR Data + 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); + + if (qr.design_data) { + setQrOptions({ + ...qr.design_data, + data: qr.type === 'static' ? qr.destination_url : $baseUrl/l/ + qr.short_url_id + }); + } + setLoading(false); + }).catch(err => { + console.error(err); + alert('Failed to load QR code'); + router.push('/dashboard'); + }); + }, [qrId, baseUrl, router]); + + // Keep QR data in sync with custom slug (only if dynamic) + const handleSlugChange = (e: React.ChangeEvent) => { + 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 handleSave = async () => { + try { + setIsSaving(true); + await api.put(/qr/ + qrId, { + name: qrName, + destinationUrl: destinationUrl, + shortUrlId: qrType === 'dynamic' ? customSlug : undefined, + designData: qrOptions + }); + + alert('QR Code Successfully Updated!'); + router.push('/dashboard'); + } catch (err) { + console.error(err); + alert('Failed to update QR Code.'); + } finally { + setIsSaving(false); + } + }; + + 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 }); + } catch (err) { + console.error('Download failed', err); + alert('Failed to download QR code'); + } + }; + + if (loading) { + return
Loading Editor...
; + } + + return ( +
+
+
+ + ← Back to Dashboard + +

Edit QR Code

+

Update your QR destination and visual design

+
+
+
+ + +
+ +
+
+ +
+ {/* Left Column - Controls */} +
+ + {/* Data Input Panel */} +
+

QR Content

+
+ +
+ +
+ {qrType === 'dynamic' ? 'Dynamic (Trackable & Editable)' : 'Static (Permanent - Cannot change URL)'} + (Type cannot be changed after creation) +
+
+ +
+ + setQrName(e.target.value)} + placeholder="e.g. Front Door Menu" + 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" + /> +
+ +
+ + { + 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 === 'dynamic' && ( +
+ +
+ + {baseUrl.replace(/^https?:\/\//, '')}/l/ + + +
+
+ )} + +
+
+ + {/* Design Panel */} + + +
+ + {/* Right Column - Live Preview Sticky */} +
+
+
+

Live Preview

+ +

Scan to preview the destination

+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/client/src/app/dashboard/page.tsx b/client/src/app/dashboard/page.tsx index 2b642d2..7545252 100644 --- a/client/src/app/dashboard/page.tsx +++ b/client/src/app/dashboard/page.tsx @@ -1,7 +1,9 @@ -'use client'; +'use client'; import React, { useEffect, useState } from 'react'; import Link from 'next/link'; +import { useRouter } from 'next/navigation'; import api from '@/lib/api'; +import LiveThumbnail from '@/components/qr-editor/LiveThumbnail'; interface QRCode { id: string; @@ -11,25 +13,48 @@ interface QRCode { created_at: string; scans: number; design_data: any; + folder_id: string | null; +} + +interface Folder { + id: string; + name: string; } export default function DashboardPage() { + const router = useRouter(); const [qrs, setQrs] = useState([]); + const [folders, setFolders] = useState([]); + const [selectedFolder, setSelectedFolder] = useState('all'); const [loading, setLoading] = useState(true); const [userRole, setUserRole] = useState('free'); - // Edit Modal State - const [editingQr, setEditingQr] = useState(null); - const [editName, setEditName] = useState(''); - const [editUrl, setEditUrl] = useState(''); - const [editSlug, setEditSlug] = useState(''); - const [editError, setEditError] = useState(''); - const [editLoading, setEditLoading] = useState(false); + // Folder Creation State + const [showFolderModal, setShowFolderModal] = useState(false); + const [newFolderName, setNewFolderName] = useState(''); + + // Move Folder State + const [moveQrId, setMoveQrId] = useState(null); + + const fetchFolders = async () => { + try { + const res = await api.get('/folders'); + setFolders(res.data.folders || []); + } catch (err) { + console.error("Failed to fetch folders", err); + } + }; const fetchQRs = async () => { try { + setLoading(true); + let url = '/qr'; + if (selectedFolder !== 'all') { + url += ?folderId= + selectedFolder; + } + const [res, authRes] = await Promise.all([ - api.get('/qr'), + api.get(url), api.get('/auth/me').catch(() => null) ]); setQrs(res.data.qr_codes || []); @@ -44,43 +69,54 @@ export default function DashboardPage() { }; useEffect(() => { - fetchQRs(); + fetchFolders(); }, []); - const openEditModal = (qr: QRCode) => { - setEditingQr(qr); - setEditName(qr.name); - setEditUrl(qr.destination_url); - setEditSlug(qr.short_url_id); - setEditError(''); + useEffect(() => { + fetchQRs(); + }, [selectedFolder]); + + const handleCreateFolder = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newFolderName.trim()) return; + try { + const res = await api.post('/folders', { name: newFolderName }); + setFolders([res.data.folder, ...folders]); + setShowFolderModal(false); + setNewFolderName(''); + setSelectedFolder(res.data.folder.id); + } catch (err) { + alert('Failed to create folder'); + } }; - const handleEditSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!editingQr) return; - setEditLoading(true); - setEditError(''); - + const handleDeleteFolder = async (id: string) => { + if (!confirm('Are you sure? QRs in this folder will be moved to the default folder.')) return; try { - await api.put(`/qr/${editingQr.id}`, { - name: editName, - destinationUrl: editUrl, - shortUrlId: editSlug - }); - // Refresh list - await fetchQRs(); - setEditingQr(null); - } catch (err: any) { - setEditError(err.response?.data?.error || 'Failed to update QR code'); - } finally { - setEditLoading(false); + await api.delete(/folders/ + id); + setFolders(folders.filter(f => f.id !== id)); + if (selectedFolder === id) setSelectedFolder('all'); + fetchQRs(); // refresh QRs just in case + } catch (err) { + alert('Failed to delete folder'); + } + }; + + const handleMoveQr = async (folderId: string | 'default') => { + if (!moveQrId) return; + try { + await api.put(/qr/ + moveQrId, { folderId }); + setMoveQrId(null); + fetchQRs(); + } catch (err) { + alert('Failed to move QR code'); } }; const handleDelete = async (id: string) => { if (!confirm('Are you sure you want to delete this QR code? The link will immediately stop working.')) return; try { - await api.delete(`/qr/${id}`); + await api.delete(/qr/ + id); setQrs(qrs.filter(qr => qr.id !== id)); } catch (err) { alert('Failed to delete QR code'); @@ -91,7 +127,7 @@ export default function DashboardPage() { try { const QRCodeStyling = (await import('qr-code-styling')).default; const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://qr.houseofwebsites.ai'; - const shortUrl = `${baseUrl}/l/${qr.short_url_id}`; + const shortUrl = baseUrl + '/l/' + qr.short_url_id; const qrCode = new QRCodeStyling({ ...qr.design_data, @@ -104,208 +140,216 @@ export default function DashboardPage() { } }; - const totalScans = qrs.reduce((sum, qr) => sum + Number(qr.scans), 0); const baseUrl = typeof window !== 'undefined' ? window.location.origin : (process.env.NEXT_PUBLIC_APP_URL || 'https://qr.houseofwebsites.ai'); return ( -
+
- {/* Header */} -
-
-

My QR Codes

-

Manage, track, and dynamically route your active links.

-
-
- - - Analytics - -
-
- - {/* Verification Warning (Mock ME-QR style) */} -
-
-

WARNING!

-

Your email should be verified to prevent link expiration.

-
- -
- - {/* List View */} -
- {loading ? ( -
Loading your dynamic links...
- ) : qrs.length === 0 ? ( -
-
- -
-

No QR codes yet

-

Create your first dynamic link to generate a trackable QR code.

- Create New QR Code -
- ) : ( - qrs.map((qr) => ( -
+ {/* Sidebar Folders */} +
+
+

Folders

+
+ + - {/* QR Image Placeholder (In real app, fetch from backend) */} -
- QR -
+ {folders.map(folder => ( +
+ + +
+ ))} +
+ + +
+
- {/* Info Area */} -
-
-

{qr.name}

- -
- - - -
-
- Type: Link -
-
- Created: {new Date(qr.created_at).toLocaleDateString()} -
-
-
+
+ {/* Header */} +
+
+

My QR Codes

+

Manage, track, and dynamically route your active links.

+
+
+ + + Analytics + +
+
- {/* Metrics & Actions */} -
-
-
{qr.scans.toLocaleString()}
-
Scans
-
+ {/* List View */} +
+ {loading ? ( +
Loading your dynamic links...
+ ) : qrs.length === 0 ? ( +
+
+ +
+

No QR codes yet

+

Create your first dynamic link to generate a trackable QR code in this folder.

+ Create New QR Code +
+ ) : ( + qrs.map((qr) => ( +
-
-
- - + {/* QR Image Custom Thumbnail */} +
+ +
+ + {/* Info Area */} +
+
+

{qr.name}

+ + +
-
- - + + +
+
+ Type: Link +
+
+ Created: {new Date(qr.created_at).toLocaleDateString()} +
-
-
- )) - )} + {/* Metrics & Actions */} +
+
+
{qr.scans.toLocaleString()}
+
Scans
+
+ +
+
+ + +
+ +
+ + Edit Link + + + {/* Move Folder Dropdown Toggle */} +
+ + {moveQrId === qr.id && ( +
+
Move to Folder
+ + {folders.map(f => ( + + ))} +
+ )} +
+ + +
+
+
+ +
+ )) + )} +
- {/* Edit Modal Overlay */} - {editingQr && ( + {/* Folder Creation Modal */} + {showFolderModal && (
-
+
-

Edit Dynamic Link

-
-
- {editError && ( -
- {editError} -
- )} - +
- + setEditName(e.target.value)} + value={newFolderName} + onChange={e => setNewFolderName(e.target.value)} className="w-full bg-white border border-gray-300 rounded-xl px-4 py-3 text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all shadow-sm" + placeholder="e.g., Summer Campaign" />
- -
- - setEditUrl(e.target.value)} - className="w-full bg-white border border-gray-300 rounded-xl px-4 py-3 text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all shadow-sm" - placeholder="https://yourwebsite.com/new-campaign" - /> -

Where should scanners be instantly redirected?

-
- -
- -
- - {baseUrl.replace(/^https?:\/\//, '')}/l/ - - setEditSlug(e.target.value.replace(/[^a-zA-Z0-9-_]/g, ''))} - className="flex-1 bg-white px-4 py-3 text-gray-900 focus:outline-none font-bold" - placeholder="my-custom-slug" - /> -
-

Only letters, numbers, hyphens, and underscores.

-
-
diff --git a/client/src/components/qr-editor/LiveThumbnail.tsx b/client/src/components/qr-editor/LiveThumbnail.tsx new file mode 100644 index 0000000..9e75151 --- /dev/null +++ b/client/src/components/qr-editor/LiveThumbnail.tsx @@ -0,0 +1,39 @@ +'use client'; +import React, { useEffect, useRef, useState } from 'react'; +import QRCodeStyling, { Options } from 'qr-code-styling'; + +interface LiveThumbnailProps { + options: Options; +} + +export default function LiveThumbnail({ options }: LiveThumbnailProps) { + const ref = useRef(null); + + // Clone options and override size for thumbnail + const thumbOptions: Options = { + ...options, + width: 150, + height: 150, + margin: 5 + }; + + const [qrCode] = useState(new QRCodeStyling(thumbOptions)); + + useEffect(() => { + if (ref.current) { + qrCode.append(ref.current); + } + }, [qrCode, ref]); + + useEffect(() => { + if (!qrCode) return; + // ensure size remains thumbnail size + qrCode.update({ ...options, width: 150, height: 150, margin: 5 }); + }, [qrCode, options]); + + return ( +
+
+
+ ); +}