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:
@@ -1,5 +1,6 @@
|
||||
import express from 'express'
|
||||
import { hasVisitorConsent } from './server/helpers.js'
|
||||
import { isValidAdminSession } from './server/auth.js'
|
||||
import { BACKUP_INTERVAL_MS } from './server/config.js'
|
||||
import { state } from './server/state.js'
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
loadEpisodeScriptsFromDisk,
|
||||
migrateStudyNotesIfNeeded,
|
||||
loadDownloadCountsFromDisk,
|
||||
loadQrCodesFromDisk,
|
||||
loadPodcastChecklistFromDisk,
|
||||
createBackupSnapshot,
|
||||
refreshContentCaches,
|
||||
@@ -48,6 +50,7 @@ import { register as registerQuestions } from './server/routes/questions.js'
|
||||
import { register as registerAnalytics } from './server/routes/analytics.js'
|
||||
import { register as registerDownloads } from './server/routes/downloads.js'
|
||||
import { register as registerEpisodes } from './server/routes/episodes.js'
|
||||
import { register as registerQrCodes } from './server/routes/qr-codes.js'
|
||||
import { register as registerPublic } from './server/routes/public.js'
|
||||
|
||||
const app = express()
|
||||
@@ -70,6 +73,7 @@ registerQuestions(app)
|
||||
registerAnalytics(app)
|
||||
registerDownloads(app)
|
||||
registerEpisodes(app)
|
||||
registerQrCodes(app)
|
||||
|
||||
// Hit-counting middleware (must come before public routes)
|
||||
app.use((req, res, next) => {
|
||||
@@ -78,7 +82,7 @@ app.use((req, res, next) => {
|
||||
const botDetection = detectBot(ua)
|
||||
recordHit(req.path, botDetection.isBot, botDetection.reason)
|
||||
queueHitStatsWrite()
|
||||
if (hasVisitorConsent(req) && !botDetection.isBot) {
|
||||
if (hasVisitorConsent(req) && !botDetection.isBot && !isValidAdminSession(req)) {
|
||||
recordVisitor(req, res).catch(err => {
|
||||
console.error('[visitor-stats] failed to record visitor:', err)
|
||||
})
|
||||
@@ -107,6 +111,7 @@ Promise.all([
|
||||
loadEpisodeScriptsFromDisk(),
|
||||
migrateStudyNotesIfNeeded(),
|
||||
loadDownloadCountsFromDisk(),
|
||||
loadQrCodesFromDisk(),
|
||||
loadPodcastChecklistFromDisk(),
|
||||
refreshContentCaches(),
|
||||
])
|
||||
|
||||
@@ -36,6 +36,7 @@ export const STUDY_REMINDERS_FILE = path.join(DATA_DIR, 'study-reminders.json')
|
||||
export const STUDY_COMMENTS_FILE = path.join(DATA_DIR, 'study-section-comments.json')
|
||||
export const STUDY_CERTIFICATES_FILE = path.join(DATA_DIR, 'study-certificates.json')
|
||||
export const EPISODE_SCRIPTS_FILE = path.join(DATA_DIR, 'episode-scripts.json')
|
||||
export const QR_CODES_FILE = path.join(DATA_DIR, 'qr-codes.json')
|
||||
export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon
|
||||
|
||||
export const DIST_DIR = path.join(ROOT_DIR, 'dist')
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
STUDY_COMMENTS_FILE,
|
||||
STUDY_CERTIFICATES_FILE,
|
||||
EPISODE_SCRIPTS_FILE,
|
||||
QR_CODES_FILE,
|
||||
REPLY_TEMPLATES_FILE,
|
||||
REPLY_HISTORY_FILE,
|
||||
PODCAST_CHECKLIST_FILE,
|
||||
@@ -1216,3 +1217,29 @@ export function sanitizeStudyCommunityPosts(value) {
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
// ── QR Codes ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function queueQrCodesWrite() {
|
||||
state.qrCodesWritePromise = state.qrCodesWritePromise
|
||||
.then(async () => {
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(QR_CODES_FILE, JSON.stringify({ codes: state.qrCodes, scans: state.qrScans }, null, 2), 'utf8')
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[qr-codes] failed to write:', err)
|
||||
})
|
||||
}
|
||||
|
||||
export function loadQrCodesFromDisk() {
|
||||
return readFile(QR_CODES_FILE, 'utf8')
|
||||
.then(raw => {
|
||||
const parsed = JSON.parse(raw)
|
||||
state.qrCodes = Array.isArray(parsed?.codes) ? parsed.codes : []
|
||||
state.qrScans = Array.isArray(parsed?.scans) ? parsed.scans : []
|
||||
})
|
||||
.catch(() => {
|
||||
state.qrCodes = []
|
||||
state.qrScans = []
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { requireAdminAuth } from '../auth.js'
|
||||
import { state } from '../state.js'
|
||||
import { queueQrCodesWrite } from '../data.js'
|
||||
|
||||
const MAX_SCANS = 5000
|
||||
|
||||
export function register(app) {
|
||||
// Public redirect — logs the scan, then redirects to destination
|
||||
app.get('/qr/:slug', (req, res) => {
|
||||
const { slug } = req.params
|
||||
const code = state.qrCodes.find(c => c.slug === slug && c.active !== false)
|
||||
if (!code) { res.status(404).send('QR code not found.'); return }
|
||||
|
||||
const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket.remoteAddress || 'unknown'
|
||||
const scan = {
|
||||
id: randomUUID(),
|
||||
qrId: code.id,
|
||||
slug,
|
||||
scannedAt: new Date().toISOString(),
|
||||
ip,
|
||||
userAgent: req.headers['user-agent'] || '',
|
||||
}
|
||||
state.qrScans.unshift(scan)
|
||||
if (state.qrScans.length > MAX_SCANS) state.qrScans.length = MAX_SCANS
|
||||
queueQrCodesWrite()
|
||||
|
||||
res.redirect(302, code.destination)
|
||||
})
|
||||
|
||||
// ── Admin API ──────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/admin/qr-codes', requireAdminAuth, (_req, res) => {
|
||||
const codesWithCounts = state.qrCodes.map(code => ({
|
||||
...code,
|
||||
scanCount: state.qrScans.filter(s => s.qrId === code.id).length,
|
||||
}))
|
||||
res.json({ codes: codesWithCounts, scans: state.qrScans.slice(0, 200) })
|
||||
})
|
||||
|
||||
app.post('/api/admin/qr-codes', requireAdminAuth, (req, res) => {
|
||||
const label = typeof req.body?.label === 'string' ? req.body.label.trim().slice(0, 100) : ''
|
||||
const slug = typeof req.body?.slug === 'string' ? req.body.slug.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 80) : ''
|
||||
const destination = typeof req.body?.destination === 'string' ? req.body.destination.trim() : ''
|
||||
|
||||
if (!label) { res.status(400).json({ message: 'Label is required.' }); return }
|
||||
if (!slug || !/^[a-z0-9][a-z0-9-]*$/.test(slug)) { res.status(400).json({ message: 'Slug must start with a letter or digit and contain only lowercase letters, digits, and hyphens.' }); return }
|
||||
if (!destination.startsWith('http')) { res.status(400).json({ message: 'Destination must be a URL.' }); return }
|
||||
if (state.qrCodes.some(c => c.slug === slug)) { res.status(409).json({ message: 'A QR code with this slug already exists.' }); return }
|
||||
|
||||
const code = { id: randomUUID(), slug, label, destination, active: true, createdAt: new Date().toISOString() }
|
||||
state.qrCodes.push(code)
|
||||
queueQrCodesWrite()
|
||||
res.json({ ok: true, code })
|
||||
})
|
||||
|
||||
app.patch('/api/admin/qr-codes/:id', requireAdminAuth, (req, res) => {
|
||||
const code = state.qrCodes.find(c => c.id === req.params.id)
|
||||
if (!code) { res.status(404).json({ message: 'QR code not found.' }); return }
|
||||
|
||||
if (typeof req.body?.label === 'string') code.label = req.body.label.trim().slice(0, 100)
|
||||
if (typeof req.body?.destination === 'string') {
|
||||
if (!req.body.destination.trim().startsWith('http')) { res.status(400).json({ message: 'Destination must be a URL.' }); return }
|
||||
code.destination = req.body.destination.trim()
|
||||
}
|
||||
if (typeof req.body?.active === 'boolean') code.active = req.body.active
|
||||
|
||||
queueQrCodesWrite()
|
||||
res.json({ ok: true, code })
|
||||
})
|
||||
|
||||
app.delete('/api/admin/qr-codes/:id', requireAdminAuth, (req, res) => {
|
||||
const idx = state.qrCodes.findIndex(c => c.id === req.params.id)
|
||||
if (idx === -1) { res.status(404).json({ message: 'QR code not found.' }); return }
|
||||
|
||||
const { id } = state.qrCodes[idx]
|
||||
state.qrCodes.splice(idx, 1)
|
||||
state.qrScans = state.qrScans.filter(s => s.qrId !== id)
|
||||
queueQrCodesWrite()
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.delete('/api/admin/qr-codes/:id/scans', requireAdminAuth, (req, res) => {
|
||||
state.qrScans = state.qrScans.filter(s => s.qrId !== req.params.id)
|
||||
queueQrCodesWrite()
|
||||
res.json({ ok: true })
|
||||
})
|
||||
}
|
||||
@@ -59,6 +59,12 @@ export const state = {
|
||||
downloadCounts: {},
|
||||
downloadCountsWritePromise: Promise.resolve(),
|
||||
|
||||
// qrCodes: Array<{ id, slug, label, destination, createdAt }>
|
||||
// qrScans: Array<{ id, qrId, slug, scannedAt, ip, userAgent }>
|
||||
qrCodes: [],
|
||||
qrScans: [],
|
||||
qrCodesWritePromise: Promise.resolve(),
|
||||
|
||||
lastBackupStatus: { ok: true, at: null, error: null, file: null },
|
||||
lastCachePurgeStatus: { ok: true, at: null, error: null },
|
||||
lastDeployHookStatus: { ok: true, at: null, error: null },
|
||||
|
||||
+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