Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,033 changes: 1,896 additions & 137 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"build": "tsc && npm run generate:metadata && tsx ./scripts/write-ver-to-json.ts && vite build",
"lint": "eslint . --report-unused-disable-directives --max-warnings 0",
"format": "prettier --write .",
"test:unit": "vitest run",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed",
Expand Down Expand Up @@ -76,6 +77,8 @@
"@eslint/js": "^9.9.1",
"@playwright/test": "^1.55.0",
"@tailwindcss/vite": "^4.1.13",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/glob": "^8.1.0",
"@types/leaflet": "^1.9.18",
"@types/node": "^24.5.2",
Expand All @@ -99,7 +102,8 @@
"typescript": "^5.5.3",
"typescript-eslint": "^8.3.0",
"uuid": "^9.0.1",
"vite": "^5.4.2"
"vite": "^5.4.2",
"vitest": "^4.1.4"
},
"overrides": {
"react-helmet-async": {
Expand Down
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ import TermsOfService from './pages/TermsOfService';
import ScrollToTop from './components/ui/ScrollToTop';
import Discord from './pages/Discord';
import SalaryGradePage from './pages/government/salary-grade/index';
import CivicAssistant from './components/ui/CivicAssistant';
import NotFound from './pages/NotFound';

function App() {
Expand All @@ -98,6 +99,7 @@ function App() {
<Navbar />
<Ticker />
<ScrollToTop />
<CivicAssistant />
<Routes>
<Route path='/' element={<Home />} />
<Route path='/design' element={<DesignGuide />} />
Expand Down
155 changes: 155 additions & 0 deletions src/components/ui/CivicAssistant.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import React, { useState, useEffect, useRef } from 'react';
import { Sparkles, X, Send, ExternalLink, Bot } from 'lucide-react';
import { cn } from '../../lib/utils';
import { civicEngine, ServiceItem } from '../../lib/assistant';

const CivicAssistant: React.FC = () => {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState('');
const [results, setResults] = useState<ServiceItem[]>([]);
const [isInitializing, setIsInitializing] = useState(true);
const [isTyping, setIsTyping] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
const init = async () => {
await civicEngine.initialize();
setIsInitializing(false);
};
init();
}, []);

const handleSearch = (val: string) => {
setQuery(val);
if (val.length > 1) {
setIsTyping(true);
const matches = civicEngine.query(val);
setResults(matches);
setTimeout(() => setIsTyping(false), 300);
} else {
setResults([]);
}
};

return (
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end">
{/* Chat Window */}
{isOpen && (
<div
className={cn(
"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",
"dark:bg-gray-900 dark:border-gray-800"
)}
>
{/* Header */}
<div className="bg-primary-600 p-4 flex items-center justify-between text-white">
<div className="flex items-center gap-2">
<div className="p-1.5 bg-white/20 rounded-lg">
<Bot size={20} className="text-white" />
</div>
<span className="font-semibold text-sm tracking-tight">Civic Assistant</span>
</div>
<button
onClick={() => setIsOpen(false)}
className="p-1 hover:bg-white/10 rounded-full transition-colors"
>
<X size={18} />
</button>
</div>

{/* Body */}
<div className="h-96 flex flex-col">
<div className="flex-1 p-5 overflow-y-auto space-y-4">
<div className="flex gap-2">
<div className="w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center flex-shrink-0">
<Bot size={14} className="text-primary-600" />
</div>
<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">
{isInitializing ? (
"Initialzing knowledge base..."
) : (
"Hello! I can help you find government services. What are you looking for?"
)}
</div>
</div>

{results.length > 0 && (
<div className="space-y-2">
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-widest pl-1">Found Services</span>
{results.map((item) => (
<a
key={item.id}
href={item.url}
target="_blank"
rel="noopener noreferrer"
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"
>
<div className="flex items-start justify-between">
<span className="text-xs font-medium text-gray-800 dark:text-gray-200 group-hover:text-primary-600 transition-colors">
{item.service}
</span>
<ExternalLink size={12} className="text-gray-300 mt-0.5 group-hover:text-primary-400" />
</div>
<div className="mt-1 flex gap-1.5 flex-wrap">
<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">
{item.category.name}
</span>
</div>
</a>
))}
</div>
)}

{isTyping && (
<div className="flex gap-1 pl-10">
<div className="w-1.5 h-1.5 bg-gray-300 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<div className="w-1.5 h-1.5 bg-gray-300 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<div className="w-1.5 h-1.5 bg-gray-300 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
)}
</div>

{/* Input */}
<div className="p-4 border-t border-gray-100 dark:border-gray-800">
<div className="relative flex items-center">
<input
ref={inputRef}
type="text"
placeholder="e.g. Passport, SSS, Housing..."
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"
value={query}
onChange={(e) => handleSearch(e.target.value)}
disabled={isInitializing}
/>
<div className="absolute right-3 text-primary-500">
<Send size={16} className={cn(query.length > 0 ? "opacity-100" : "opacity-30")} />
</div>
</div>
</div>
</div>
</div>
)}

{/* Toggle Button */}
<button
onClick={() => {
setIsOpen(!isOpen);
if (!isOpen) setTimeout(() => inputRef.current?.focus(), 100);
}}
className={cn(
"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",
isOpen ? "rotate-90 bg-gray-800 hover:bg-black" : ""
)}
>
{isOpen ? <X size={24} /> : (
<div className="relative">
<Sparkles size={24} className="group-hover:animate-pulse" />
<div className="absolute -top-1 -right-1 w-2.5 h-2.5 bg-secondary-500 border-2 border-primary-600 rounded-full" />
</div>
)}
</button>
</div>
);
};

export default CivicAssistant;
34 changes: 34 additions & 0 deletions src/lib/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import { formatDate, truncateText, cn } from '../utils';

describe('utils.ts', () => {
describe('cn', () => {
it('should merge class names correctly', () => {
expect(cn('p-4', 'bg-blue-500')).toBe('p-4 bg-blue-500');
expect(cn('p-4', { 'bg-red-500': true, 'bg-blue-500': false })).toBe('p-4 bg-red-500');
});
});

describe('formatDate', () => {
it('should format dates for en-PH locale', () => {
const date = new Date('2026-04-16');
// Intl might format slightly differently depending on environment,
// but we expect something like "April 16, 2026"
const formatted = formatDate(date);
expect(formatted).toContain('April');
expect(formatted).toContain('16');
expect(formatted).toContain('2026');
});
});

describe('truncateText', () => {
it('should truncate text longer than maxLength', () => {
expect(truncateText('Hello World', 5)).toBe('Hello...');
});

it('should not truncate text shorter than or equal to maxLength', () => {
expect(truncateText('Hello', 5)).toBe('Hello');
expect(truncateText('Hello', 10)).toBe('Hello');
});
});
});
84 changes: 84 additions & 0 deletions src/lib/assistant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@

// Types for our service data
export interface ServiceItem {
service: string;
url: string;
id: string;
slug: string;
category: {
name: string;
slug: string;
};
subcategory: {
name: string;
slug: string;
};
}

/**
* Civic Assistant Logic
* Performs client-side intent mapping and fuzzy search across curated JSON datasets.
*/
export class CivicEngine {
private data: ServiceItem[] = [];

constructor() {}

async initialize() {
try {
// In a real app, we'd fetch these or import them.
// For this prototype, we'll focus on the core categories.
const categories = [
'passport-travel',
'certificates-ids',
'health',
'social-services',
'business-trade',
];

const datasets = await Promise.all(
categories.map(async (cat) => {
try {
const module = await import(`../data/services/${cat}.json`);
return module.default as ServiceItem[];
} catch (e) {
console.error(`Failed to load category: ${cat}`, e);
return [];
}
})
);

this.data = datasets.flat();
} catch (error) {
console.error('CivicEngine initialization failed:', error);
}
}

query(input: string): ServiceItem[] {
if (!input || input.length < 2) return [];

const searchTerms = input.toLowerCase().split(' ');

return this.data
.map(item => {
let score = 0;
const target = `${item.service} ${item.category.name} ${item.subcategory.name}`.toLowerCase();

searchTerms.forEach(term => {
if (target.includes(term)) {
score += 1;
// Exact word match bonus
if (new RegExp(`\\b${term}\\b`).test(target)) score += 2;
}
});

return { item, score };
})
.filter(result => result.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 5)
.map(result => result.item);
}
}

export const civicEngine = new CivicEngine();
8 changes: 8 additions & 0 deletions src/test/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import '@testing-library/jest-dom';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';

// Runs a cleanup after each test case (e.g. clearing jsdom)
afterEach(() => {
cleanup();
});
16 changes: 16 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
exclude: ['**/e2e/**', '**/node_modules/**', '**/dist/**'],
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});