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:
6 files changed
+183
-25
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 @@
|
||||
{"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"}
|
||||
Reference in new issue
Block a user