Skip to content

Commit 743760e

Browse files
committed
feat: add Civic Assistant navigator interface
1 parent 74f5588 commit 743760e

3 files changed

Lines changed: 241 additions & 0 deletions

File tree

src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ import TermsOfService from './pages/TermsOfService';
8787
import ScrollToTop from './components/ui/ScrollToTop';
8888
import Discord from './pages/Discord';
8989
import SalaryGradePage from './pages/government/salary-grade/index';
90+
import CivicAssistant from './components/ui/CivicAssistant';
9091
import NotFound from './pages/NotFound';
9192

9293
function App() {
@@ -98,6 +99,7 @@ function App() {
9899
<Navbar />
99100
<Ticker />
100101
<ScrollToTop />
102+
<CivicAssistant />
101103
<Routes>
102104
<Route path='/' element={<Home />} />
103105
<Route path='/design' element={<DesignGuide />} />
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import React, { useState, useEffect, useRef } from 'react';
2+
import { Sparkles, X, Send, ExternalLink, Bot } from 'lucide-react';
3+
import { cn } from '../../lib/utils';
4+
import { civicEngine, ServiceItem } from '../../lib/assistant';
5+
6+
const CivicAssistant: React.FC = () => {
7+
const [isOpen, setIsOpen] = useState(false);
8+
const [query, setQuery] = useState('');
9+
const [results, setResults] = useState<ServiceItem[]>([]);
10+
const [isInitializing, setIsInitializing] = useState(true);
11+
const [isTyping, setIsTyping] = useState(false);
12+
const inputRef = useRef<HTMLInputElement>(null);
13+
14+
useEffect(() => {
15+
const init = async () => {
16+
await civicEngine.initialize();
17+
setIsInitializing(false);
18+
};
19+
init();
20+
}, []);
21+
22+
const handleSearch = (val: string) => {
23+
setQuery(val);
24+
if (val.length > 1) {
25+
setIsTyping(true);
26+
const matches = civicEngine.query(val);
27+
setResults(matches);
28+
setTimeout(() => setIsTyping(false), 300);
29+
} else {
30+
setResults([]);
31+
}
32+
};
33+
34+
return (
35+
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end">
36+
{/* Chat Window */}
37+
{isOpen && (
38+
<div
39+
className={cn(
40+
"mb-4 w-80 md:w-96 bg-white rounded-2xl shadow-2xl overflow-hidden border border-gray-100 transition-all duration-300 transform scale-100 origin-bottom-right",
41+
"dark:bg-gray-900 dark:border-gray-800"
42+
)}
43+
>
44+
{/* Header */}
45+
<div className="bg-primary-600 p-4 flex items-center justify-between text-white">
46+
<div className="flex items-center gap-2">
47+
<div className="p-1.5 bg-white/20 rounded-lg">
48+
<Bot size={20} className="text-white" />
49+
</div>
50+
<span className="font-semibold text-sm tracking-tight">Civic Assistant</span>
51+
</div>
52+
<button
53+
onClick={() => setIsOpen(false)}
54+
className="p-1 hover:bg-white/10 rounded-full transition-colors"
55+
>
56+
<X size={18} />
57+
</button>
58+
</div>
59+
60+
{/* Body */}
61+
<div className="h-96 flex flex-col">
62+
<div className="flex-1 p-5 overflow-y-auto space-y-4">
63+
<div className="flex gap-2">
64+
<div className="w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center flex-shrink-0">
65+
<Bot size={14} className="text-primary-600" />
66+
</div>
67+
<div className="bg-gray-100 dark:bg-gray-800 p-3 rounded-2xl rounded-tl-none text-sm max-w-[85%] text-gray-700 dark:text-gray-300">
68+
{isInitializing ? (
69+
"Initialzing knowledge base..."
70+
) : (
71+
"Hello! I can help you find government services. What are you looking for?"
72+
)}
73+
</div>
74+
</div>
75+
76+
{results.length > 0 && (
77+
<div className="space-y-2">
78+
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-widest pl-1">Found Services</span>
79+
{results.map((item) => (
80+
<a
81+
key={item.id}
82+
href={item.url}
83+
target="_blank"
84+
rel="noopener noreferrer"
85+
className="block group p-3 bg-white border border-gray-100 rounded-xl hover:border-primary-200 hover:shadow-md transition-all dark:bg-gray-800 dark:border-gray-700 dark:hover:border-primary-900"
86+
>
87+
<div className="flex items-start justify-between">
88+
<span className="text-xs font-medium text-gray-800 dark:text-gray-200 group-hover:text-primary-600 transition-colors">
89+
{item.service}
90+
</span>
91+
<ExternalLink size={12} className="text-gray-300 mt-0.5 group-hover:text-primary-400" />
92+
</div>
93+
<div className="mt-1 flex gap-1.5 flex-wrap">
94+
<span className="text-[9px] px-1.5 bg-gray-50 text-gray-500 rounded border border-gray-100 dark:bg-gray-900 dark:border-gray-800">
95+
{item.category.name}
96+
</span>
97+
</div>
98+
</a>
99+
))}
100+
</div>
101+
)}
102+
103+
{isTyping && (
104+
<div className="flex gap-1 pl-10">
105+
<div className="w-1.5 h-1.5 bg-gray-300 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
106+
<div className="w-1.5 h-1.5 bg-gray-300 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
107+
<div className="w-1.5 h-1.5 bg-gray-300 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
108+
</div>
109+
)}
110+
</div>
111+
112+
{/* Input */}
113+
<div className="p-4 border-t border-gray-100 dark:border-gray-800">
114+
<div className="relative flex items-center">
115+
<input
116+
ref={inputRef}
117+
type="text"
118+
placeholder="e.g. Passport, SSS, Housing..."
119+
className="w-full pl-4 pr-10 py-2.5 bg-gray-50 dark:bg-gray-800 border-none rounded-xl text-sm focus:ring-2 focus:ring-primary-500 transition-all outline-none"
120+
value={query}
121+
onChange={(e) => handleSearch(e.target.value)}
122+
disabled={isInitializing}
123+
/>
124+
<div className="absolute right-3 text-primary-500">
125+
<Send size={16} className={cn(query.length > 0 ? "opacity-100" : "opacity-30")} />
126+
</div>
127+
</div>
128+
</div>
129+
</div>
130+
</div>
131+
)}
132+
133+
{/* Toggle Button */}
134+
<button
135+
onClick={() => {
136+
setIsOpen(!isOpen);
137+
if (!isOpen) setTimeout(() => inputRef.current?.focus(), 100);
138+
}}
139+
className={cn(
140+
"p-4 bg-primary-600 rounded-full text-white shadow-xl hover:bg-primary-700 hover:scale-110 active:scale-95 transition-all duration-300 flex items-center justify-center group",
141+
isOpen ? "rotate-90 bg-gray-800 hover:bg-black" : ""
142+
)}
143+
>
144+
{isOpen ? <X size={24} /> : (
145+
<div className="relative">
146+
<Sparkles size={24} className="group-hover:animate-pulse" />
147+
<div className="absolute -top-1 -right-1 w-2.5 h-2.5 bg-secondary-500 border-2 border-primary-600 rounded-full" />
148+
</div>
149+
)}
150+
</button>
151+
</div>
152+
);
153+
};
154+
155+
export default CivicAssistant;

src/lib/assistant.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
2+
// Types for our service data
3+
export interface ServiceItem {
4+
service: string;
5+
url: string;
6+
id: string;
7+
slug: string;
8+
category: {
9+
name: string;
10+
slug: string;
11+
};
12+
subcategory: {
13+
name: string;
14+
slug: string;
15+
};
16+
}
17+
18+
/**
19+
* Civic Assistant Logic
20+
* Performs client-side intent mapping and fuzzy search across curated JSON datasets.
21+
*/
22+
export class CivicEngine {
23+
private data: ServiceItem[] = [];
24+
25+
constructor() {}
26+
27+
async initialize() {
28+
try {
29+
// In a real app, we'd fetch these or import them.
30+
// For this prototype, we'll focus on the core categories.
31+
const categories = [
32+
'passport-travel',
33+
'certificates-ids',
34+
'health',
35+
'social-services',
36+
'business-trade',
37+
];
38+
39+
const datasets = await Promise.all(
40+
categories.map(async (cat) => {
41+
try {
42+
const module = await import(`../data/services/${cat}.json`);
43+
return module.default as ServiceItem[];
44+
} catch (e) {
45+
console.error(`Failed to load category: ${cat}`, e);
46+
return [];
47+
}
48+
})
49+
);
50+
51+
this.data = datasets.flat();
52+
} catch (error) {
53+
console.error('CivicEngine initialization failed:', error);
54+
}
55+
}
56+
57+
query(input: string): ServiceItem[] {
58+
if (!input || input.length < 2) return [];
59+
60+
const searchTerms = input.toLowerCase().split(' ');
61+
62+
return this.data
63+
.map(item => {
64+
let score = 0;
65+
const target = `${item.service} ${item.category.name} ${item.subcategory.name}`.toLowerCase();
66+
67+
searchTerms.forEach(term => {
68+
if (target.includes(term)) {
69+
score += 1;
70+
// Exact word match bonus
71+
if (new RegExp(`\\b${term}\\b`).test(target)) score += 2;
72+
}
73+
});
74+
75+
return { item, score };
76+
})
77+
.filter(result => result.score > 0)
78+
.sort((a, b) => b.score - a.score)
79+
.slice(0, 5)
80+
.map(result => result.item);
81+
}
82+
}
83+
84+
export const civicEngine = new CivicEngine();

0 commit comments

Comments
 (0)