Phase 3: Express + SQLite server, staff login, ticket email, admin dashboard

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJy6X45by9tzgQjaHHGMvW
This commit is contained in:
Claude committed 2026-10-02 03:41:00 +00:00
1 parent 87dc305496
commit 08befffdeb
16 files changed
+1339 -30

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
# Copy to .env style settings on your host. Never commit real values.
ADMIN_EMAIL=rana.krunal7558@gmail.com
ADMIN_PASSWORD=pick-a-long-password-here
# Where new tickets are emailed (this is already the default)
TICKET_TO=rana.krunal7558@gmail.com
# Gmail sending: use an "App Password", not your normal password
SMTP_HOST=smtp.gmail.com
SMTP_PORT=465
SMTP_USER=rana.krunal7558@gmail.com
SMTP_PASS=your-16-letter-app-password
# Set to 1 when hosted behind a proxy (most hosts)
TRUST_PROXY=1
+2
View File
@@ -1,2 +1,4 @@
node_modules
dist
server/data
.env
+1 -1
View File
@@ -12,7 +12,7 @@ Restaurant analogy: the FAQ database is the **kitchen's recipe book**. The websi
## Phased roadmap
- **Phase 1 – Food stand (TODAY):** hero + search + categories + 87 FAQs from the database, baked in as a JSON file. No server.
- **Phase 2 – Small restaurant (DONE, browser-only storage):** "Was this helpful?" votes, "Contact IT" ticket form, shareable links per question.
- **Phase 3 – Full restaurant:** small back-of-house server + real database, staff login, admin page to edit FAQs, ticket tracking.
- **Phase 3 – Full restaurant (DONE):** small back-of-house server + real database, staff login, admin page to edit FAQs, ticket tracking.
## Test ladder
1. Page loads, no console errors 2. Buttons/search work 3. Real data/links work 4. Security (no raw HTML injection, safe links)
+11
View File
@@ -0,0 +1,11 @@
# HelpDesk IT Support
Run locally (needs Node 22+):
npm install
npm run build
ADMIN_EMAIL=you@example.com ADMIN_PASSWORD='a-long-password' npm start
Open http://localhost:3001 (staff login: http://localhost:3001/#/staff)
For development with live reload: run `npm run server` and `npm run dev` in two terminals.
Settings are listed in `.env.example`. Data lives in `server/data/helpdesk.db`: back it up, and keep it on a persistent disk when hosting.
+837 -4
View File
File diff suppressed because it is too large. Load diff
+6 -2
View File
@@ -6,10 +6,14 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"server": "node server/index.js",
"start": "NODE_ENV=production node server/index.js"
},
"dependencies": {
"express": "^4.22.3",
"lucide-react": "^0.460.0",
"nodemailer": "^10.0.13",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
@@ -23,4 +27,4 @@
"typescript": "~5.6.2",
"vite": "^5.4.11"
}
}
}
+48
View File
@@ -0,0 +1,48 @@
import { scryptSync, randomBytes, timingSafeEqual, createHash } from 'node:crypto'
import { db } from './db.js'
export const SESSION_DAYS = 7
export const hashPassword = (pw) => {
const salt = randomBytes(16)
return `${salt.toString('hex')}:${scryptSync(pw, salt, 64).toString('hex')}`
}
export const verifyPassword = (pw, stored) => {
const [salt, hash] = stored.split(':')
const a = Buffer.from(hash, 'hex')
const b = scryptSync(pw, Buffer.from(salt, 'hex'), 64)
return a.length === b.length && timingSafeEqual(a, b)
}
const sha = (t) => createHash('sha256').update(t).digest('hex')
export function createSession(staffId) {
const token = randomBytes(32).toString('hex')
db.prepare('INSERT INTO sessions VALUES (?,?,?)').run(sha(token), staffId, Date.now() + SESSION_DAYS * 864e5)
return token
}
export const destroySession = (token) => db.prepare('DELETE FROM sessions WHERE token_hash=?').run(sha(token))
function cookieToken(req) {
const m = /(?:^|;\s*)session=([a-f0-9]{64})/.exec(req.headers.cookie || '')
return m ? m[1] : null
}
export { cookieToken }
export function requireStaff(req, res, next) {
const token = cookieToken(req)
const row = token && db.prepare('SELECT s.id, s.email, s.name, x.expires FROM sessions x JOIN staff s ON s.id=x.staff_id WHERE x.token_hash=?').get(sha(token))
if (!row || row.expires < Date.now()) return res.status(401).json({ error: 'Please log in.' })
req.staff = row
next()
}
// Tiny in-memory rate limiter: `max` hits per `windowMs` per IP+bucket.
const hits = new Map()
export const rateLimit = (bucket, max, windowMs) => (req, res, next) => {
const key = `${bucket}:${req.ip}`
const now = Date.now()
const recent = (hits.get(key) || []).filter((t) => now - t < windowMs)
if (recent.length >= max) return res.status(429).json({ error: 'Too many tries. Please wait a bit.' })
recent.push(now)
hits.set(key, recent)
next()
}
+24
View File
@@ -0,0 +1,24 @@
import { DatabaseSync } from 'node:sqlite'
import { readFileSync, mkdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const dataDir = process.env.DATA_DIR || join(here, 'data')
mkdirSync(dataDir, { recursive: true })
export const db = new DatabaseSync(join(dataDir, 'helpdesk.db'))
db.exec(`
CREATE TABLE IF NOT EXISTS faqs (id INTEGER PRIMARY KEY AUTOINCREMENT, category TEXT NOT NULL, question TEXT NOT NULL, answer TEXT NOT NULL, link TEXT NOT NULL DEFAULT '', link_label TEXT NOT NULL DEFAULT '');
CREATE TABLE IF NOT EXISTS tickets (id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL, topic TEXT NOT NULL, message TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open', emailed INTEGER NOT NULL DEFAULT 0, created TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS votes (id INTEGER PRIMARY KEY AUTOINCREMENT, faq_id INTEGER NOT NULL, vote TEXT NOT NULL, created TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS staff (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, password_hash TEXT NOT NULL, created TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS sessions (token_hash TEXT PRIMARY KEY, staff_id INTEGER NOT NULL, expires INTEGER NOT NULL);
`)
// First run: copy the 87 FAQs from the bundled file into the real database.
if (db.prepare('SELECT COUNT(*) AS n FROM faqs').get().n === 0) {
const seed = JSON.parse(readFileSync(join(here, '..', 'src', 'data', 'faqs.json'), 'utf8'))
const ins = db.prepare('INSERT INTO faqs (id, category, question, answer, link, link_label) VALUES (?,?,?,?,?,?)')
for (const f of seed) ins.run(f.id, f.category, f.question, f.answer, f.link, f.linkLabel)
}
+151
View File
@@ -0,0 +1,151 @@
import express from 'express'
import { existsSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { randomBytes } from 'node:crypto'
import { db } from './db.js'
import { hashPassword, verifyPassword, createSession, destroySession, cookieToken, requireStaff, rateLimit, SESSION_DAYS } from './auth.js'
import { sendTicketEmail, emailConfigured } from './mail.js'
const app = express()
const prod = process.env.NODE_ENV === 'production'
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const clean = (s, max) => String(s ?? '').replace(/[\r\n]+/g, ' ').trim().slice(0, max)
const cleanBlock = (s, max) => String(s ?? '').trim().slice(0, max)
const isHttpUrl = (u) => { try { return ['http:', 'https:'].includes(new URL(u).protocol) } catch { return false } }
app.set('trust proxy', process.env.TRUST_PROXY ? 1 : false)
app.disable('x-powered-by')
app.use((req, res, next) => {
res.set({
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Referrer-Policy': 'same-origin',
'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; media-src https://d8j0ntlcm91z4.cloudfront.net; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'",
})
next()
})
app.use(express.json({ limit: '20kb' }))
// Block cross-site writes: browsers always send Origin on POST/PUT/PATCH/DELETE from other sites.
app.use((req, res, next) => {
if (['GET', 'HEAD'].includes(req.method)) return next()
const origin = req.headers.origin
if (origin && new URL(origin).host !== req.headers.host) return res.status(403).json({ error: 'Bad origin.' })
next()
})
// ---------- Public ----------
const faqOut = (r) => ({ id: r.id, category: r.category, question: r.question, answer: r.answer, link: r.link, linkLabel: r.link_label })
app.get('/api/faqs', (_req, res) => res.json(db.prepare('SELECT * FROM faqs ORDER BY id').all().map(faqOut)))
app.post('/api/tickets', rateLimit('ticket', 5, 3600e3), async (req, res) => {
const t = {
id: `IT-${Date.now().toString(36).toUpperCase()}${randomBytes(2).toString('hex').toUpperCase()}`,
name: clean(req.body.name, 100), email: clean(req.body.email, 200),
topic: clean(req.body.topic, 100), message: cleanBlock(req.body.message, 2000),
}
if (!t.name) return res.status(400).json({ error: 'Please enter your name.' })
if (!EMAIL_RE.test(t.email)) return res.status(400).json({ error: 'Please enter a valid email.' })
if (t.message.length < 10) return res.status(400).json({ error: 'Please describe the problem (at least 10 characters).' })
db.prepare('INSERT INTO tickets (id,name,email,topic,message,created) VALUES (?,?,?,?,?,?)').run(t.id, t.name, t.email, t.topic, t.message, new Date().toISOString())
const emailed = await sendTicketEmail(t)
if (emailed) db.prepare('UPDATE tickets SET emailed=1 WHERE id=?').run(t.id)
res.status(201).json({ id: t.id })
})
app.post('/api/votes', rateLimit('vote', 30, 3600e3), (req, res) => {
const faqId = Number(req.body.faqId)
const vote = req.body.vote
if (!['up', 'down'].includes(vote) || !db.prepare('SELECT 1 FROM faqs WHERE id=?').get(faqId)) return res.status(400).json({ error: 'Bad vote.' })
db.prepare('INSERT INTO votes (faq_id,vote,created) VALUES (?,?,?)').run(faqId, vote, new Date().toISOString())
res.status(201).json({ ok: true })
})
// ---------- Staff login ----------
app.post('/api/login', rateLimit('login', 10, 15 * 60e3), (req, res) => {
const row = db.prepare('SELECT * FROM staff WHERE email=?').get(clean(req.body.email, 200).toLowerCase())
// Same message either way so attackers can't learn which emails exist.
if (!row || !verifyPassword(String(req.body.password ?? ''), row.password_hash)) return res.status(401).json({ error: 'Wrong email or password.' })
const token = createSession(row.id)
res.cookie('session', token, { httpOnly: true, sameSite: 'strict', secure: prod, maxAge: SESSION_DAYS * 864e5, path: '/' })
res.json({ name: row.name, email: row.email })
})
app.post('/api/logout', (req, res) => {
const t = cookieToken(req)
if (t) destroySession(t)
res.clearCookie('session', { path: '/' })
res.json({ ok: true })
})
app.get('/api/me', requireStaff, (req, res) => res.json({ name: req.staff.name, email: req.staff.email }))
// ---------- Staff only ----------
app.get('/api/staff/status', requireStaff, (_req, res) => res.json({ emailConfigured }))
app.get('/api/staff/tickets', requireStaff, (_req, res) => res.json(db.prepare('SELECT * FROM tickets ORDER BY created DESC').all()))
app.patch('/api/staff/tickets/:id', requireStaff, (req, res) => {
if (!['open', 'in_progress', 'closed'].includes(req.body.status)) return res.status(400).json({ error: 'Bad status.' })
db.prepare('UPDATE tickets SET status=? WHERE id=?').run(req.body.status, req.params.id)
res.json({ ok: true })
})
app.get('/api/staff/faqs', requireStaff, (_req, res) => {
const votes = db.prepare("SELECT faq_id, SUM(vote='up') AS up, SUM(vote='down') AS down FROM votes GROUP BY faq_id").all()
const byId = new Map(votes.map((v) => [v.faq_id, v]))
res.json(db.prepare('SELECT * FROM faqs ORDER BY id').all().map((r) => ({ ...faqOut(r), up: byId.get(r.id)?.up ?? 0, down: byId.get(r.id)?.down ?? 0 })))
})
function faqFields(body) {
const f = { category: clean(body.category, 100), question: clean(body.question, 300), answer: cleanBlock(body.answer, 3000), link: clean(body.link, 500), link_label: clean(body.linkLabel, 200) }
if (!f.category || !f.question || !f.answer) return { error: 'Category, question and answer are required.' }
if (f.link && !isHttpUrl(f.link)) return { error: 'Link must start with http:// or https://' }
return { f }
}
app.post('/api/staff/faqs', requireStaff, (req, res) => {
const { f, error } = faqFields(req.body)
if (error) return res.status(400).json({ error })
const r = db.prepare('INSERT INTO faqs (category,question,answer,link,link_label) VALUES (?,?,?,?,?)').run(f.category, f.question, f.answer, f.link, f.link_label)
res.status(201).json({ id: Number(r.lastInsertRowid) })
})
app.put('/api/staff/faqs/:id', requireStaff, (req, res) => {
const { f, error } = faqFields(req.body)
if (error) return res.status(400).json({ error })
db.prepare('UPDATE faqs SET category=?,question=?,answer=?,link=?,link_label=? WHERE id=?').run(f.category, f.question, f.answer, f.link, f.link_label, req.params.id)
res.json({ ok: true })
})
app.delete('/api/staff/faqs/:id', requireStaff, (req, res) => {
db.prepare('DELETE FROM faqs WHERE id=?').run(req.params.id)
res.json({ ok: true })
})
app.get('/api/staff/team', requireStaff, (_req, res) => res.json(db.prepare('SELECT id,email,name,created FROM staff ORDER BY id').all()))
app.post('/api/staff/team', requireStaff, (req, res) => {
const email = clean(req.body.email, 200).toLowerCase(), name = clean(req.body.name, 100), pw = String(req.body.password ?? '')
if (!EMAIL_RE.test(email) || !name) return res.status(400).json({ error: 'Name and a valid email are required.' })
if (pw.length < 10) return res.status(400).json({ error: 'Password must be at least 10 characters.' })
if (db.prepare('SELECT 1 FROM staff WHERE email=?').get(email)) return res.status(409).json({ error: 'That email is already on the team.' })
db.prepare('INSERT INTO staff (email,name,password_hash,created) VALUES (?,?,?,?)').run(email, name, hashPassword(pw), new Date().toISOString())
res.status(201).json({ ok: true })
})
app.delete('/api/staff/team/:id', requireStaff, (req, res) => {
if (Number(req.params.id) === req.staff.id) return res.status(400).json({ error: "You can't remove yourself." })
db.prepare('DELETE FROM sessions WHERE staff_id=?').run(req.params.id)
db.prepare('DELETE FROM staff WHERE id=?').run(req.params.id)
res.json({ ok: true })
})
app.use('/api', (_req, res) => res.status(404).json({ error: 'Not found.' }))
// ---------- First staff member ----------
if (db.prepare('SELECT COUNT(*) AS n FROM staff').get().n === 0) {
const { ADMIN_EMAIL, ADMIN_PASSWORD } = process.env
if (ADMIN_EMAIL && ADMIN_PASSWORD && ADMIN_PASSWORD.length >= 10) {
db.prepare('INSERT INTO staff (email,name,password_hash,created) VALUES (?,?,?,?)').run(ADMIN_EMAIL.toLowerCase(), 'Admin', hashPassword(ADMIN_PASSWORD), new Date().toISOString())
console.log(`Created first staff login: ${ADMIN_EMAIL}`)
} else console.warn('No staff yet. Set ADMIN_EMAIL and ADMIN_PASSWORD (10+ characters) and restart.')
}
if (!emailConfigured) console.warn('Email not configured (SMTP_HOST/SMTP_USER/SMTP_PASS). Tickets are saved but not emailed.')
// ---------- Serve the website (production) ----------
const dist = join(dirname(fileURLToPath(import.meta.url)), '..', 'dist')
if (existsSync(dist)) app.use(express.static(dist))
const port = Number(process.env.PORT || 3001)
app.listen(port, () => console.log(`HelpDesk server on http://localhost:${port}`))
+25
View File
@@ -0,0 +1,25 @@
import nodemailer from 'nodemailer'
const { SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, TICKET_TO = 'rana.krunal7558@gmail.com' } = process.env
const transporter = SMTP_HOST && SMTP_USER && SMTP_PASS
? nodemailer.createTransport({ host: SMTP_HOST, port: Number(SMTP_PORT || 465), secure: Number(SMTP_PORT || 465) === 465, auth: { user: SMTP_USER, pass: SMTP_PASS } })
: null
export const emailConfigured = !!transporter
export async function sendTicketEmail(t) {
if (!transporter) return false
try {
await transporter.sendMail({
from: SMTP_USER,
to: TICKET_TO,
replyTo: t.email,
subject: `[${t.id}] ${t.topic}: new IT request from ${t.name}`,
text: `Ticket: ${t.id}\nFrom: ${t.name} <${t.email}>\nTopic: ${t.topic}\n\n${t.message}\n`,
})
return true
} catch (e) {
console.error('Email failed:', e.message)
return false
}
}
+19 -6
View File
@@ -1,15 +1,26 @@
import { useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { ArrowRight, Globe, Instagram, Twitter } from 'lucide-react'
import VideoBackground from './VideoBackground'
import FaqList from './Faq'
import ContactForm from './ContactForm'
import type { Faq } from './types'
import Staff from './Staff'
import faqData from './data/faqs.json'
const faqs = faqData as Faq[]
const categories = Array.from(new Set(faqs.map((f) => f.category)))
const bundledFaqs = faqData as Faq[]
export default function App() {
const [faqs, setFaqs] = useState<Faq[]>(bundledFaqs)
const [isStaff, setIsStaff] = useState(location.hash.startsWith('#/staff'))
const categories = useMemo(() => Array.from(new Set(faqs.map((f) => f.category))), [faqs])
useEffect(() => {
fetch('/api/faqs').then((r) => (r.ok ? r.json() : Promise.reject())).then(setFaqs).catch(() => {})
const onHash = () => setIsStaff(location.hash.startsWith('#/staff'))
window.addEventListener('hashchange', onHash)
return () => window.removeEventListener('hashchange', onHash)
}, [])
const [query, setQuery] = useState('')
const [category, setCategory] = useState<string | null>(null)
const answersRef = useRef<HTMLElement>(null)
@@ -21,7 +32,9 @@ export default function App() {
const text = `${f.question} ${f.answer} ${f.category}`.toLowerCase()
return words.every((w) => text.includes(w))
})
}, [query, category])
}, [query, category, faqs])
if (isStaff) return <Staff />
const scrollToAnswers = () => answersRef.current?.scrollIntoView({ behavior: 'smooth' })
@@ -50,8 +63,8 @@ export default function App() {
</div>
</div>
<div className="flex items-center gap-4">
<button className="text-white text-sm font-medium">Sign Up</button>
<button className="liquid-glass rounded-full px-6 py-2 text-white text-sm font-medium">Login</button>
<a href="#contact" className="text-white text-sm font-medium">Contact IT</a>
<a href="#/staff" className="liquid-glass rounded-full px-6 py-2 text-white text-sm font-medium">Staff login</a>
</div>
</div>
</nav>
+21 -15
View File
@@ -1,8 +1,5 @@
import { useState } from 'react'
import { Send } from 'lucide-react'
import { load, save } from './storage'
interface Ticket { id: string; name: string; email: string; topic: string; message: string; created: string }
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const inputCls = 'w-full bg-transparent outline-none text-white placeholder:text-white/40 text-base'
@@ -11,23 +8,33 @@ export default function ContactForm({ topics }: { topics: string[] }) {
const [form, setForm] = useState({ name: '', email: '', topic: topics[0] ?? '', message: '' })
const [errors, setErrors] = useState<Record<string, string>>({})
const [ticketId, setTicketId] = useState<string | null>(null)
const [sending, setSending] = useState(false)
const [serverError, setServerError] = useState('')
const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) =>
setForm({ ...form, [k]: e.target.value })
const submit = (e: React.FormEvent) => {
const submit = async (e: React.FormEvent) => {
e.preventDefault()
const errs: Record<string, string> = {}
if (!form.name.trim()) errs.name = 'Please enter your name.'
if (!EMAIL_RE.test(form.email.trim())) errs.email = 'Please enter a valid email.'
if (form.message.trim().length < 10) errs.message = 'Please describe the problem (at least 10 characters).'
setErrors(errs)
setServerError('')
if (Object.keys(errs).length) return
const id = `IT-${Date.now().toString(36).toUpperCase()}`
const ticket: Ticket = { id, ...form, created: new Date().toISOString() }
save('tickets', [...load<Ticket[]>('tickets', []), ticket])
setTicketId(id)
setForm({ name: '', email: '', topic: topics[0] ?? '', message: '' })
setSending(true)
try {
const res = await fetch('/api/tickets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form) })
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || 'Something went wrong. Please try again.')
setTicketId(data.id)
setForm({ name: '', email: '', topic: topics[0] ?? '', message: '' })
} catch (err) {
setServerError(err instanceof Error ? err.message : 'Could not reach the server.')
} finally {
setSending(false)
}
}
const field = 'liquid-glass rounded-3xl px-6 py-4'
@@ -39,10 +46,8 @@ export default function ContactForm({ topics }: { topics: string[] }) {
<p className="text-white/60 text-sm mb-6">Can't find your answer? Tell us what's wrong.</p>
{ticketId ? (
<div className="liquid-glass rounded-3xl p-8 text-white" role="status">
<p className="text-xl mb-2">Request saved. Your ticket number is <strong>{ticketId}</strong>.</p>
<p className="text-white/70 text-sm mb-4">
Heads up: this demo version only saves the request in this browser. It is not yet sent to an IT team. Real sending arrives in the next phase.
</p>
<p className="text-xl mb-2">Request sent. Your ticket number is <strong>{ticketId}</strong>.</p>
<p className="text-white/70 text-sm mb-4">Our IT team has been notified. Keep this number if you follow up.</p>
<button onClick={() => setTicketId(null)} className="liquid-glass rounded-full px-6 py-2 text-sm hover:bg-white/5">Send another</button>
</div>
) : (
@@ -64,8 +69,9 @@ export default function ContactForm({ topics }: { topics: string[] }) {
<div className={field}><textarea className={`${inputCls} resize-none`} rows={5} placeholder="What's the problem?" aria-label="Problem description" maxLength={2000} value={form.message} onChange={set('message')} /></div>
{err('message')}
</div>
<button type="submit" className="bg-white text-black rounded-full px-8 py-3 text-sm font-medium inline-flex items-center gap-2">
<Send size={16} /> Send request
{serverError && <p role="alert" className="text-red-300 text-sm px-2">{serverError}</p>}
<button type="submit" disabled={sending} className="bg-white text-black rounded-full px-8 py-3 text-sm font-medium inline-flex items-center gap-2 disabled:opacity-60">
<Send size={16} /> {sending ? 'Sending...' : 'Send request'}
</button>
</form>
)}
+1
View File
@@ -99,6 +99,7 @@ export default function FaqList({ items }: { items: Faq[] }) {
const next = { ...votes, [id]: v }
setVotes(next)
save('faq-votes', next)
fetch('/api/votes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ faqId: id, vote: v }) }).catch(() => {})
}
if (items.length === 0) {
+175
View File
@@ -0,0 +1,175 @@
import { useCallback, useEffect, useState } from 'react'
import { LogOut } from 'lucide-react'
interface Ticket { id: string; name: string; email: string; topic: string; message: string; status: string; emailed: number; created: string }
interface StaffFaq { id: number; category: string; question: string; answer: string; link: string; linkLabel: string; up: number; down: number }
interface Member { id: number; email: string; name: string }
type Tab = 'tickets' | 'faqs' | 'team'
async function api<T = unknown>(path: string, method = 'GET', body?: unknown): Promise<T> {
const res = await fetch(`/api${path}`, { method, headers: body ? { 'Content-Type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined })
const data = await res.json().catch(() => ({}))
if (!res.ok) throw Object.assign(new Error(data.error || 'Request failed'), { status: res.status })
return data as T
}
const box = 'liquid-glass rounded-3xl p-6 text-white'
const input = 'w-full bg-white/5 rounded-2xl px-4 py-3 text-white placeholder:text-white/40 outline-none'
const btn = 'liquid-glass rounded-full px-5 py-2 text-sm text-white hover:bg-white/5 transition-colors'
function Login({ onDone }: { onDone: () => void }) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const submit = async (e: React.FormEvent) => {
e.preventDefault()
try { await api('/login', 'POST', { email, password }); onDone() } catch (err) { setError((err as Error).message) }
}
return (
<form onSubmit={submit} className={`${box} max-w-sm mx-auto space-y-3`}>
<h1 className="text-3xl mb-2" style={{ fontFamily: "'Instrument Serif', serif" }}>Staff login</h1>
<input className={input} type="email" placeholder="Email" aria-label="Email" value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="username" />
<input className={input} type="password" placeholder="Password" aria-label="Password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" />
{error && <p role="alert" className="text-red-300 text-sm">{error}</p>}
<button type="submit" className="bg-white text-black rounded-full px-6 py-2 text-sm font-medium">Log in</button>
</form>
)
}
function Tickets() {
const [rows, setRows] = useState<Ticket[]>([])
const load = useCallback(() => api<Ticket[]>('/staff/tickets').then(setRows), [])
useEffect(() => { load() }, [load])
const setStatus = async (id: string, status: string) => { await api(`/staff/tickets/${id}`, 'PATCH', { status }); load() }
if (!rows.length) return <p className="text-white/60">No tickets yet.</p>
return (
<ul className="space-y-3">
{rows.map((t) => (
<li key={t.id} className={box}>
<div className="flex flex-wrap justify-between gap-2 text-sm text-white/60">
<span>{t.id} · {t.topic} · {new Date(t.created).toLocaleString()}</span>
<span>{t.emailed ? 'emailed' : 'not emailed'}</span>
</div>
<p className="mt-2 whitespace-pre-wrap">{t.message}</p>
<p className="mt-2 text-sm text-white/70">{t.name} · <a className="underline" href={`mailto:${encodeURIComponent(t.email)}`}>{t.email}</a></p>
<select aria-label="Status" value={t.status} onChange={(e) => setStatus(t.id, e.target.value)} className="mt-3 bg-white/10 rounded-full px-4 py-2 text-sm [&>option]:text-black">
<option value="open">Open</option><option value="in_progress">In progress</option><option value="closed">Closed</option>
</select>
</li>
))}
</ul>
)
}
const emptyFaq = { category: '', question: '', answer: '', link: '', linkLabel: '' }
function Faqs() {
const [rows, setRows] = useState<StaffFaq[]>([])
const [editing, setEditing] = useState<(typeof emptyFaq & { id?: number }) | null>(null)
const [error, setError] = useState('')
const load = useCallback(() => api<StaffFaq[]>('/staff/faqs').then(setRows), [])
useEffect(() => { load() }, [load])
const save = async (e: React.FormEvent) => {
e.preventDefault()
if (!editing) return
try {
if (editing.id) await api(`/staff/faqs/${editing.id}`, 'PUT', editing)
else await api('/staff/faqs', 'POST', editing)
setEditing(null); setError(''); load()
} catch (err) { setError((err as Error).message) }
}
const remove = async (f: StaffFaq) => { if (confirm(`Delete "${f.question}"?`)) { await api(`/staff/faqs/${f.id}`, 'DELETE'); load() } }
const set = (k: keyof typeof emptyFaq) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => editing && setEditing({ ...editing, [k]: e.target.value })
return (
<div className="space-y-4">
{editing ? (
<form onSubmit={save} className={`${box} space-y-3`}>
<input className={input} placeholder="Category" aria-label="Category" value={editing.category} onChange={set('category')} />
<input className={input} placeholder="Question" aria-label="Question" value={editing.question} onChange={set('question')} />
<textarea className={input} rows={5} placeholder="Answer" aria-label="Answer" value={editing.answer} onChange={set('answer')} />
<input className={input} placeholder="Link (optional, https://...)" aria-label="Link" value={editing.link} onChange={set('link')} />
<input className={input} placeholder="Link label (optional)" aria-label="Link label" value={editing.linkLabel} onChange={set('linkLabel')} />
{error && <p role="alert" className="text-red-300 text-sm">{error}</p>}
<div className="flex gap-2"><button type="submit" className="bg-white text-black rounded-full px-6 py-2 text-sm font-medium">Save</button><button type="button" className={btn} onClick={() => { setEditing(null); setError('') }}>Cancel</button></div>
</form>
) : <button className={btn} onClick={() => setEditing({ ...emptyFaq })}>+ Add FAQ</button>}
<ul className="space-y-2">
{rows.map((f) => (
<li key={f.id} className={`${box} flex flex-wrap items-center justify-between gap-3 !py-4`}>
<span><span className="block text-xs text-white/50">{f.category}</span>{f.question}</span>
<span className="flex items-center gap-3 text-sm text-white/70">
👍 {f.up} 👎 {f.down}
<button className={btn} onClick={() => setEditing(f)}>Edit</button>
<button className={btn} onClick={() => remove(f)}>Delete</button>
</span>
</li>
))}
</ul>
</div>
)
}
function Team({ me }: { me: string }) {
const [rows, setRows] = useState<Member[]>([])
const [f, setF] = useState({ name: '', email: '', password: '' })
const [error, setError] = useState('')
const load = useCallback(() => api<Member[]>('/staff/team').then(setRows), [])
useEffect(() => { load() }, [load])
const add = async (e: React.FormEvent) => {
e.preventDefault()
try { await api('/staff/team', 'POST', f); setF({ name: '', email: '', password: '' }); setError(''); load() } catch (err) { setError((err as Error).message) }
}
const remove = async (m: Member) => { if (confirm(`Remove ${m.name}?`)) { try { await api(`/staff/team/${m.id}`, 'DELETE'); load() } catch (err) { setError((err as Error).message) } } }
return (
<div className="space-y-4">
<ul className="space-y-2">
{rows.map((m) => (
<li key={m.id} className={`${box} flex items-center justify-between !py-4`}>
<span>{m.name} <span className="text-white/60 text-sm">· {m.email}</span></span>
{m.email !== me && <button className={btn} onClick={() => remove(m)}>Remove</button>}
</li>
))}
</ul>
<form onSubmit={add} className={`${box} space-y-3`}>
<h2 className="text-xl">Add a team member</h2>
<input className={input} placeholder="Name" aria-label="Name" value={f.name} onChange={(e) => setF({ ...f, name: e.target.value })} />
<input className={input} type="email" placeholder="Email" aria-label="Email" value={f.email} onChange={(e) => setF({ ...f, email: e.target.value })} />
<input className={input} type="password" placeholder="Temporary password (10+ characters)" aria-label="Password" value={f.password} onChange={(e) => setF({ ...f, password: e.target.value })} autoComplete="new-password" />
{error && <p role="alert" className="text-red-300 text-sm">{error}</p>}
<button type="submit" className="bg-white text-black rounded-full px-6 py-2 text-sm font-medium">Add</button>
</form>
</div>
)
}
export default function Staff() {
const [me, setMe] = useState<{ name: string; email: string } | null | undefined>(undefined)
const [emailOk, setEmailOk] = useState(true)
const [tab, setTab] = useState<Tab>('tickets')
const check = useCallback(() => {
api<{ name: string; email: string }>('/me').then((m) => { setMe(m); api<{ emailConfigured: boolean }>('/staff/status').then((s) => setEmailOk(s.emailConfigured)) }).catch(() => setMe(null))
}, [])
useEffect(() => { check() }, [check])
const logout = async () => { await api('/logout', 'POST'); setMe(null) }
return (
<div className="min-h-screen bg-black text-white px-6 py-10">
<div className="max-w-3xl mx-auto">
<div className="flex items-center justify-between mb-8">
<a href="#" className="text-white/70 hover:text-white text-sm">← Back to site</a>
{me && <button onClick={logout} className={`${btn} inline-flex items-center gap-2`}><LogOut size={14} /> Log out ({me.name})</button>}
</div>
{me === undefined ? <p className="text-white/60">Loading...</p> : me === null ? <Login onDone={check} /> : (
<>
{!emailOk && <p className="mb-4 text-amber-300 text-sm">Email is not set up on the server yet, so new tickets are saved here but not emailed.</p>}
<div className="flex gap-2 mb-6">
{(['tickets', 'faqs', 'team'] as Tab[]).map((t) => (
<button key={t} onClick={() => setTab(t)} aria-pressed={tab === t} className={`${btn} capitalize ${tab === t ? 'bg-white/20' : ''}`}>{t === 'faqs' ? 'FAQs' : t}</button>
))}
</div>
{tab === 'tickets' && <Tickets />}{tab === 'faqs' && <Faqs />}{tab === 'team' && <Team me={me.email} />}
</>
)}
</div>
</div>
)
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/ContactForm.tsx","./src/Faq.tsx","./src/VideoBackground.tsx","./src/main.tsx","./src/storage.ts","./src/types.ts","./src/vite-env.d.ts","./vite.config.ts"],"version":"5.6.3"}
{"root":["./src/App.tsx","./src/ContactForm.tsx","./src/Faq.tsx","./src/Staff.tsx","./src/VideoBackground.tsx","./src/main.tsx","./src/storage.ts","./src/types.ts","./src/vite-env.d.ts","./vite.config.ts"],"version":"5.6.3"}
+5 -1
View File
@@ -1,4 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({ plugins: [react()], base: './' })
export default defineConfig({
plugins: [react()],
base: './',
server: { proxy: { '/api': 'http://localhost:3001' } },
})