Add QR code tracker to admin panel
Each QR code gets a /qr/<slug> redirect that logs scans (IP, user agent, timestamp) to disk. The admin QR Codes view lets you add, edit, enable/disable, and delete codes, with per-code scan history inline. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+180
-1
@@ -673,7 +673,7 @@ type AdminView =
|
||||
| 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact'
|
||||
| 'podcast' | 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'archived-series'
|
||||
| 'downloads' | 'custom-links' | 'content-blocks'
|
||||
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study'
|
||||
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
|
||||
| 'emails' | 'subscribers' | 'contacts' | 'study-users' | 'email-templates'
|
||||
| 'seo' | 'legal' | 'security' | 'brand' | 'global'
|
||||
|
||||
@@ -718,6 +718,7 @@ const ADMIN_VIEW_OPTIONS: Array<{ group: string; options: Array<{ value: AdminVi
|
||||
{ value: 'email-templates', label: 'Email Templates' },
|
||||
{ value: 'analytics', label: 'Analytics' },
|
||||
{ value: 'assets', label: 'Asset Manager' },
|
||||
{ value: 'qr-codes', label: 'QR Codes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -1085,6 +1086,21 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
const [scriptUploadBusy, setScriptUploadBusy] = useState(false)
|
||||
const [scriptUploadMsg, setScriptUploadMsg] = useState('')
|
||||
|
||||
// QR codes state
|
||||
interface QrCode { id: string; slug: string; label: string; destination: string; active: boolean; createdAt: string; scanCount?: number }
|
||||
interface QrScan { id: string; qrId: string; slug: string; scannedAt: string; ip: string; userAgent: string }
|
||||
const [qrCodes, setQrCodes] = useState<QrCode[]>([])
|
||||
const [qrScans, setQrScans] = useState<QrScan[]>([])
|
||||
const [qrLoading, setQrLoading] = useState(false)
|
||||
const [qrLoaded, setQrLoaded] = useState(false)
|
||||
const [qrMsg, setQrMsg] = useState('')
|
||||
const [qrNewLabel, setQrNewLabel] = useState('')
|
||||
const [qrNewSlug, setQrNewSlug] = useState('')
|
||||
const [qrNewDest, setQrNewDest] = useState('')
|
||||
const [qrEditId, setQrEditId] = useState<string | null>(null)
|
||||
const [qrEditLabel, setQrEditLabel] = useState('')
|
||||
const [qrEditDest, setQrEditDest] = useState('')
|
||||
|
||||
const [podcastChecklist, setPodcastChecklist] = useState<PodcastChecklistData>({ tasks: [], episodes: [] })
|
||||
const [podcastChecklistStatus, setPodcastChecklistStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
const [podcastChecklistMsg, setPodcastChecklistMsg] = useState('')
|
||||
@@ -2643,6 +2659,78 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadQrCodes() {
|
||||
setQrLoading(true)
|
||||
setQrMsg('')
|
||||
try {
|
||||
const res = await fetch('/api/admin/qr-codes')
|
||||
const data = await res.json()
|
||||
setQrCodes(Array.isArray(data.codes) ? data.codes : [])
|
||||
setQrScans(Array.isArray(data.scans) ? data.scans : [])
|
||||
setQrLoaded(true)
|
||||
} catch { setQrMsg('Failed to load QR codes.') }
|
||||
finally { setQrLoading(false) }
|
||||
}
|
||||
|
||||
async function handleCreateQrCode() {
|
||||
setQrMsg('')
|
||||
try {
|
||||
const res = await fetch('/api/admin/qr-codes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ label: qrNewLabel, slug: qrNewSlug, destination: qrNewDest }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { setQrMsg(data.message || 'Failed to create QR code.'); return }
|
||||
setQrCodes(prev => [...prev, data.code])
|
||||
setQrNewLabel(''); setQrNewSlug(''); setQrNewDest('')
|
||||
} catch { setQrMsg('Failed to create QR code.') }
|
||||
}
|
||||
|
||||
async function handleSaveQrEdit(id: string) {
|
||||
setQrMsg('')
|
||||
try {
|
||||
const res = await fetch(`/api/admin/qr-codes/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ label: qrEditLabel, destination: qrEditDest }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { setQrMsg(data.message || 'Failed to save.'); return }
|
||||
setQrCodes(prev => prev.map(c => c.id === id ? { ...c, ...data.code } : c))
|
||||
setQrEditId(null)
|
||||
} catch { setQrMsg('Failed to save.') }
|
||||
}
|
||||
|
||||
async function handleToggleQrActive(id: string, active: boolean) {
|
||||
try {
|
||||
await fetch(`/api/admin/qr-codes/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ active }),
|
||||
})
|
||||
setQrCodes(prev => prev.map(c => c.id === id ? { ...c, active } : c))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function handleDeleteQrCode(id: string) {
|
||||
if (!confirm('Delete this QR code and all its scan history?')) return
|
||||
try {
|
||||
await fetch(`/api/admin/qr-codes/${id}`, { method: 'DELETE' })
|
||||
setQrCodes(prev => prev.filter(c => c.id !== id))
|
||||
setQrScans(prev => prev.filter(s => s.qrId !== id))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function handleClearQrScans(id: string) {
|
||||
if (!confirm('Clear all scan history for this QR code?')) return
|
||||
try {
|
||||
await fetch(`/api/admin/qr-codes/${id}/scans`, { method: 'DELETE' })
|
||||
setQrScans(prev => prev.filter(s => s.qrId !== id))
|
||||
setQrCodes(prev => prev.map(c => c.id === id ? { ...c, scanCount: 0 } : c))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function loadStudyComments() {
|
||||
setCommentsLoading(true)
|
||||
try {
|
||||
@@ -5435,6 +5523,97 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* QR CODES */}
|
||||
{adminView === 'qr-codes' && (() => {
|
||||
if (!qrLoaded && !qrLoading) loadQrCodes()
|
||||
return (
|
||||
<section className="admin-panel-section" aria-label="QR Codes">
|
||||
<div className="admin-panel-head">
|
||||
<h2>QR Codes</h2>
|
||||
<p>Each QR code is a short redirect at <code>/qr/<slug></code>. Scans are logged here instead of emailed.</p>
|
||||
</div>
|
||||
|
||||
{/* Add new */}
|
||||
<div className="admin-form-row" style={{ gap: '8px', flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
<div style={{ flex: '1 1 140px' }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontSize: '0.85em' }}>Label</label>
|
||||
<input type="text" placeholder="Spotify Podcast" value={qrNewLabel} onChange={e => setQrNewLabel(e.target.value)} className="admin-input" />
|
||||
</div>
|
||||
<div style={{ flex: '1 1 120px' }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontSize: '0.85em' }}>Slug</label>
|
||||
<input type="text" placeholder="spotify" value={qrNewSlug} onChange={e => setQrNewSlug(e.target.value)} className="admin-input" />
|
||||
</div>
|
||||
<div style={{ flex: '2 1 200px' }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontSize: '0.85em' }}>Destination URL</label>
|
||||
<input type="url" placeholder="https://…" value={qrNewDest} onChange={e => setQrNewDest(e.target.value)} className="admin-input" />
|
||||
</div>
|
||||
<button type="button" className="btn-admin-save" onClick={handleCreateQrCode} disabled={!qrNewLabel || !qrNewSlug || !qrNewDest}>
|
||||
Add QR Code
|
||||
</button>
|
||||
</div>
|
||||
{qrMsg && <p className="admin-stats-note" style={{ color: '#c0392b' }}>{qrMsg}</p>}
|
||||
|
||||
{qrLoading && <p className="admin-stats-note">Loading…</p>}
|
||||
|
||||
{/* Code list */}
|
||||
{!qrLoading && qrCodes.length === 0 && <p className="admin-stats-note">No QR codes yet.</p>}
|
||||
{qrCodes.map(code => {
|
||||
const scans = qrScans.filter(s => s.qrId === code.id)
|
||||
const isEditing = qrEditId === code.id
|
||||
return (
|
||||
<div key={code.id} style={{ border: '1px solid var(--admin-border, #ddd)', borderRadius: 6, padding: '12px 16px', marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontWeight: 600, flex: 1 }}>{code.label}</span>
|
||||
<code style={{ fontSize: '0.82em', background: 'var(--admin-bg2, #f5f5f5)', padding: '2px 6px', borderRadius: 4 }}>/qr/{code.slug}</code>
|
||||
<span style={{ fontSize: '0.82em', color: '#888' }}>{scans.length} scan{scans.length !== 1 ? 's' : ''}</span>
|
||||
<span style={{ fontSize: '0.8em', color: code.active ? '#27ae60' : '#c0392b' }}>{code.active ? 'Active' : 'Inactive'}</span>
|
||||
<button type="button" className="btn-admin-reset" style={{ fontSize: '0.8em' }} onClick={() => { setQrEditId(isEditing ? null : code.id); setQrEditLabel(code.label); setQrEditDest(code.destination) }}>
|
||||
{isEditing ? 'Cancel' : 'Edit'}
|
||||
</button>
|
||||
<button type="button" className="btn-admin-reset" style={{ fontSize: '0.8em' }} onClick={() => handleToggleQrActive(code.id, !code.active)}>
|
||||
{code.active ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<button type="button" className="btn-admin-reset" style={{ fontSize: '0.8em' }} onClick={() => handleClearQrScans(code.id)}>
|
||||
Clear scans
|
||||
</button>
|
||||
<button type="button" className="btn-admin-reset" style={{ fontSize: '0.8em', color: '#c0392b' }} onClick={() => handleDeleteQrCode(code.id)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ margin: '4px 0 0', fontSize: '0.82em', color: '#888', wordBreak: 'break-all' }}>{code.destination}</p>
|
||||
{isEditing && (
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap' }}>
|
||||
<input type="text" value={qrEditLabel} onChange={e => setQrEditLabel(e.target.value)} className="admin-input" style={{ flex: '1 1 140px' }} placeholder="Label" />
|
||||
<input type="url" value={qrEditDest} onChange={e => setQrEditDest(e.target.value)} className="admin-input" style={{ flex: '2 1 200px' }} placeholder="Destination URL" />
|
||||
<button type="button" className="btn-admin-save" onClick={() => handleSaveQrEdit(code.id)}>Save</button>
|
||||
</div>
|
||||
)}
|
||||
{scans.length > 0 && (
|
||||
<details style={{ marginTop: 10 }}>
|
||||
<summary style={{ cursor: 'pointer', fontSize: '0.85em', color: '#555' }}>Scan history ({scans.length})</summary>
|
||||
<div className="admin-visits-table-scroll" style={{ marginTop: 6 }}>
|
||||
<table className="admin-visits-table">
|
||||
<thead><tr><th>Time</th><th>IP</th><th>Device / UA</th></tr></thead>
|
||||
<tbody>
|
||||
{scans.slice(0, 100).map(s => (
|
||||
<tr key={s.id}>
|
||||
<td style={{ whiteSpace: 'nowrap' }}>{new Date(s.scannedAt).toLocaleString()}</td>
|
||||
<td>{s.ip}</td>
|
||||
<td style={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.userAgent}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* SEO & REDIRECTS */}
|
||||
{adminView === 'seo' && (
|
||||
<section className="admin-panel-section" aria-label="SEO & Redirects">
|
||||
|
||||
Reference in New Issue
Block a user