Phase 2: helpful votes, shareable FAQ links, Contact IT form

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:37:47 +00:00
1 parent 9c4e09e420
commit 87dc305496
6 files changed
+183 -25

No files matched your search

+1 -1
View File
@@ -11,7 +11,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:** "Was this helpful?" votes, "Contact IT" ticket form, shareable links per question.
- **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.
## Test ladder
+4 -1
View File
@@ -2,6 +2,7 @@ import { 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 faqData from './data/faqs.json'
@@ -40,7 +41,7 @@ export default function App() {
{[
['Topics', '#answers'],
['Search', '#top'],
['About', '#about'],
['Contact', '#contact'],
].map(([label, href]) => (
<a key={label} href={href} className="text-white/80 hover:text-white transition-colors text-sm font-medium">
{label}
@@ -141,6 +142,8 @@ export default function App() {
<FaqList items={results} />
</section>
<ContactForm topics={categories} />
<footer id="about" className="max-w-3xl mx-auto px-6 pb-16 text-center text-white/50 text-sm">
General guidance only. For account-specific problems, contact your organisation's IT service desk.
</footer>
+74
View File
@@ -0,0 +1,74 @@
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'
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 set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) =>
setForm({ ...form, [k]: e.target.value })
const submit = (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)
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: '' })
}
const field = 'liquid-glass rounded-3xl px-6 py-4'
const err = (k: string) => errors[k] && <p role="alert" className="text-red-300 text-sm mt-1 px-2">{errors[k]}</p>
return (
<section id="contact" className="max-w-3xl mx-auto px-6 pb-20">
<h2 className="text-4xl text-white mb-2" style={{ fontFamily: "'Instrument Serif', serif" }}>Contact IT</h2>
<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>
<button onClick={() => setTicketId(null)} className="liquid-glass rounded-full px-6 py-2 text-sm hover:bg-white/5">Send another</button>
</div>
) : (
<form onSubmit={submit} noValidate className="space-y-3">
<div>
<div className={field}><input className={inputCls} placeholder="Your name" aria-label="Your name" maxLength={100} value={form.name} onChange={set('name')} /></div>
{err('name')}
</div>
<div>
<div className={field}><input className={inputCls} type="email" placeholder="Your email" aria-label="Your email" maxLength={200} value={form.email} onChange={set('email')} /></div>
{err('email')}
</div>
<div className={field}>
<select className={`${inputCls} [&>option]:text-black`} aria-label="Topic" value={form.topic} onChange={set('topic')}>
{topics.map((t) => <option key={t}>{t}</option>)}
</select>
</div>
<div>
<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
</button>
</form>
)}
</section>
)
}
+86 -22
View File
@@ -1,5 +1,9 @@
import { ExternalLink, SearchX } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Check, ExternalLink, Link2, SearchX, ThumbsDown, ThumbsUp } from 'lucide-react'
import type { Faq } from './types'
import { load, save } from './storage'
type Votes = Record<number, 'up' | 'down'>
function safeUrl(url: string): string | null {
try {
@@ -10,22 +14,36 @@ function safeUrl(url: string): string | null {
}
}
export default function FaqList({ items }: { items: Faq[] }) {
if (items.length === 0) {
return (
<div className="liquid-glass rounded-3xl p-10 text-center text-white/70">
<SearchX className="mx-auto mb-3" size={28} />
<p>No answers found. Try different words, or contact your IT service desk.</p>
</div>
)
}
return (
<ul className="space-y-3">
{items.map((f) => {
function FaqCard({ f, forceOpen, vote, onVote }: { f: Faq; forceOpen: boolean; vote?: 'up' | 'down'; onVote: (v: 'up' | 'down') => void }) {
const [copied, setCopied] = useState(false)
const href = f.link ? safeUrl(f.link) : null
const copyLink = async () => {
const url = `${location.origin}${location.pathname}#faq-${f.id}`
try {
await navigator.clipboard.writeText(url)
setCopied(true)
window.setTimeout(() => setCopied(false), 2000)
} catch {
location.hash = `faq-${f.id}`
}
}
const voteBtn = (v: 'up' | 'down', label: string, Icon: typeof ThumbsUp) => (
<button
onClick={() => onVote(v)}
aria-pressed={vote === v}
className={`liquid-glass rounded-full px-4 py-2 inline-flex items-center gap-2 text-sm transition-colors ${
vote === v ? 'bg-white/20 text-white' : 'text-white/70 hover:text-white hover:bg-white/5'
}`}
>
<Icon size={14} /> {label}
</button>
)
return (
<li key={f.id}>
<details className="liquid-glass rounded-3xl group">
<li id={`faq-${f.id}`} className="scroll-mt-6">
<details className="liquid-glass rounded-3xl group" open={forceOpen || undefined}>
<summary className="cursor-pointer list-none px-6 py-5 flex items-start justify-between gap-4 text-white">
<span>
<span className="block text-xs uppercase tracking-wider text-white/50 mb-1">{f.category}</span>
@@ -36,20 +54,66 @@ export default function FaqList({ items }: { items: Faq[] }) {
<div className="px-6 pb-6 text-white/80 text-sm leading-relaxed">
<p>{f.answer}</p>
{href && (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 mt-4 text-white hover:underline"
>
<a href={href} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-2 mt-4 text-white hover:underline">
{f.linkLabel || 'Official guide'} <ExternalLink size={14} />
</a>
)}
<div className="mt-5 flex flex-wrap items-center gap-2">
<span className="text-white/50 mr-1">Was this helpful?</span>
{voteBtn('up', 'Yes', ThumbsUp)}
{voteBtn('down', 'No', ThumbsDown)}
<button
onClick={copyLink}
className="liquid-glass rounded-full px-4 py-2 inline-flex items-center gap-2 text-sm text-white/70 hover:text-white hover:bg-white/5 transition-colors ml-auto"
>
{copied ? <Check size={14} /> : <Link2 size={14} />} {copied ? 'Link copied' : 'Copy link'}
</button>
</div>
{vote === 'down' && (
<p className="mt-3 text-white/60">
Sorry about that. <a href="#contact" className="text-white underline">Contact IT</a> and we'll help directly.
</p>
)}
</div>
</details>
</li>
)
})}
}
export default function FaqList({ items }: { items: Faq[] }) {
const [votes, setVotes] = useState<Votes>(() => load<Votes>('faq-votes', {}))
const [hashId, setHashId] = useState<number | null>(null)
useEffect(() => {
const read = () => {
const m = /^#faq-(\d+)$/.exec(location.hash)
setHashId(m ? Number(m[1]) : null)
if (m) window.setTimeout(() => document.getElementById(`faq-${m[1]}`)?.scrollIntoView({ behavior: 'smooth' }), 50)
}
read()
window.addEventListener('hashchange', read)
return () => window.removeEventListener('hashchange', read)
}, [])
const onVote = (id: number, v: 'up' | 'down') => {
const next = { ...votes, [id]: v }
setVotes(next)
save('faq-votes', next)
}
if (items.length === 0) {
return (
<div className="liquid-glass rounded-3xl p-10 text-center text-white/70">
<SearchX className="mx-auto mb-3" size={28} />
<p>No answers found. Try different words, or <a href="#contact" className="text-white underline">contact IT</a>.</p>
</div>
)
}
return (
<ul className="space-y-3">
{items.map((f) => (
<FaqCard key={f.id} f={f} forceOpen={hashId === f.id} vote={votes[f.id]} onVote={(v) => onVote(f.id, v)} />
))}
</ul>
)
}
+17
View File
@@ -0,0 +1,17 @@
// Safe wrappers: localStorage can be blocked (private mode), so never let it crash the page.
export function load<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(key)
return raw ? (JSON.parse(raw) as T) : fallback
} catch {
return fallback
}
}
export function save(key: string, value: unknown): void {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch {
/* ignore */
}
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/Faq.tsx","./src/VideoBackground.tsx","./src/main.tsx","./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/VideoBackground.tsx","./src/main.tsx","./src/storage.ts","./src/types.ts","./src/vite-env.d.ts","./vite.config.ts"],"version":"5.6.3"}