Frontend Architecture
YeboJobs frontend is a React SPA built with Vite and TypeScript.
Tech Stack
- Framework: React 18 with TypeScript
- Build Tool: Vite
- Styling: Tailwind CSS
- Animations: Framer Motion
- State: React Context + useState
- HTTP: Custom API client with fetch
- Icons: Lucide React
- PWA: Service worker for installability
Project Structure
frontend/
├── src/
│ ├── App.tsx # Main app with routing
│ ├── main.tsx # Entry point
│ ├── index.css # Tailwind + custom styles
│ │
│ ├── components/
│ │ ├── pages/ # Full page components
│ │ │ ├── LandingPage.tsx
│ │ │ ├── JobsPage.tsx
│ │ │ ├── ServicesPage.tsx
│ │ │ ├── MessagesPage.tsx
│ │ │ ├── ApplicationsPage.tsx
│ │ │ ├── ProfilePage.tsx
│ │ │ ├── PostJobPage.tsx
│ │ │ └── CVPage.tsx
│ │ │
│ │ ├── jobs/ # Job-related components
│ │ │ ├── JobSwiper.tsx
│ │ │ ├── JobCard.tsx
│ │ │ ├── SwipeTutorial.tsx
│ │ │ └── FilterBar.tsx
│ │ │
│ │ ├── services/ # Service worker components
│ │ │ ├── WorkerFeed.tsx
│ │ │ ├── WorkerCard.tsx
│ │ │ └── ServiceRequestForm.tsx
│ │ │
│ │ ├── messages/ # Chat components
│ │ │ ├── ChatView.tsx
│ │ │ ├── MessageBubble.tsx
│ │ │ ├── MessageInput.tsx
│ │ │ ├── ConversationList.tsx
│ │ │ └── TypingIndicator.tsx
│ │ │
│ │ ├── okia/ # AI Interview UI
│ │ │ ├── OkiaInterviewPage.tsx
│ │ │ ├── InterviewChat.tsx
│ │ │ └── ReportView.tsx
│ │ │
│ │ ├── auth/ # Authentication
│ │ │ ├── LoginModal.tsx
│ │ │ └── PhoneVerification.tsx
│ │ │
│ │ ├── experience/ # Experience Lab
│ │ │ ├── TrackList.tsx
│ │ │ ├── TaskView.tsx
│ │ │ └── Certificate.tsx
│ │ │
│ │ ├── layout/ # Layout components
│ │ │ ├── Navigation.tsx
│ │ │ ├── BottomNav.tsx
│ │ │ └── Header.tsx
│ │ │
│ │ ├── ui/ # Reusable UI components
│ │ │ ├── Button.tsx
│ │ │ ├── Input.tsx
│ │ │ ├── Modal.tsx
│ │ │ └── Card.tsx
│ │ │
│ │ └── pwa/
│ │ └── InstallPrompt.tsx
│ │
│ ├── context/
│ │ └── AuthContext.tsx # Auth state management
│ │
│ ├── services/
│ │ └── api.ts # API client
│ │
│ ├── hooks/
│ │ ├── useJobs.ts
│ │ ├── useMessages.ts
│ │ └── useAuth.ts
│ │
│ └── types/
│ ├── index.ts # Core types
│ └── messages.ts # Message types
│
├── public/
│ ├── manifest.json # PWA manifest
│ └── sw.js # Service worker
│
├── index.html
├── vite.config.ts
├── tailwind.config.js
└── package.jsonRouting
Client-side routing is handled in App.tsx:
tsx
function AppContent() {
const [currentPage, setCurrentPage] = useState('home');
const { isAuthenticated } = useAuth();
const renderCurrentPage = () => {
switch (currentPage) {
case 'home':
return <LandingPage onGetStarted={() => setCurrentPage('jobs')} />;
case 'jobs':
return <JobsPage onLoginRequired={() => openLoginModal('login')} />;
case 'services':
return <ServicesPage onLoginRequired={() => openLoginModal('login')} />;
case 'messages':
if (!isAuthenticated) return <LoginRequired />;
return <MessagesPage />;
case 'applications':
if (!isAuthenticated) return <LoginRequired />;
return <ApplicationsPage />;
case 'profile':
if (!isAuthenticated) return <LoginRequired />;
return <ProfilePage />;
// ...
}
};
return (
<div className="min-h-screen bg-white">
{currentPage !== 'jobs' && (
<Navigation currentPage={currentPage} onPageChange={setCurrentPage} />
)}
<AnimatePresence mode="wait">
<motion.div key={currentPage}>
{renderCurrentPage()}
</motion.div>
</AnimatePresence>
</div>
);
}Okia Interview Route
Special handling for /okia/:token routes:
tsx
function App() {
const [okiaToken, setOkiaToken] = useState<string | null>(null);
useEffect(() => {
const match = window.location.pathname.match(/^\/okia\/([^/]+)$/);
if (match) setOkiaToken(match[1]);
}, []);
// If on /okia/:token, render interview UI directly
if (okiaToken) {
return <OkiaInterviewPage sessionToken={okiaToken} />;
}
return (
<AuthProvider>
<AppContent />
</AuthProvider>
);
}API Client
Custom fetch-based client with token refresh:
typescript
// services/api.ts
class ApiClient {
private token: string | null = null;
private refreshToken: string | null = null;
async request<T>(endpoint: string, options: RequestInit = {}): Promise<ApiResponse<T>> {
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
const response = await fetch(`${API_URL}/api${endpoint}`, {
...options,
headers,
});
// Handle 401 - attempt token refresh
if (response.status === 401 && this.refreshToken) {
const newToken = await this.refreshAccessToken();
// Retry with new token
headers['Authorization'] = `Bearer ${newToken}`;
return fetch(`${API_URL}/api${endpoint}`, { ...options, headers }).then(r => r.json());
}
return response.json();
}
get<T>(endpoint: string) {
return this.request<T>(endpoint, { method: 'GET' });
}
post<T>(endpoint: string, body: unknown) {
return this.request<T>(endpoint, { method: 'POST', body: JSON.stringify(body) });
}
// ...
}
export const api = new ApiClient();Auth Context
Global authentication state:
tsx
// context/AuthContext.tsx
interface AuthContextType {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (phone: string, password: string) => Promise<void>;
logout: () => void;
updateUser: (data: Partial<User>) => void;
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Check for stored tokens on mount
const token = localStorage.getItem('accessToken');
if (token) {
api.setToken(token);
api.setRefreshToken(localStorage.getItem('refreshToken'));
fetchCurrentUser();
} else {
setIsLoading(false);
}
}, []);
const login = async (phone: string, password: string) => {
const result = await api.post<LoginResponse>('/auth/login/user', { phone, password });
api.setToken(result.data.accessToken);
api.setRefreshToken(result.data.refreshToken);
localStorage.setItem('accessToken', result.data.accessToken);
localStorage.setItem('refreshToken', result.data.refreshToken);
setUser(result.data.user);
};
const logout = () => {
api.setToken(null);
api.setRefreshToken(null);
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
setUser(null);
};
return (
<AuthContext.Provider value={{ user, isAuthenticated: !!user, isLoading, login, logout, updateUser }}>
{children}
</AuthContext.Provider>
);
}PWA Support
The app is installable as a PWA:
json
// public/manifest.json
{
"name": "YeboJobs",
"short_name": "YeboJobs",
"description": "Find jobs and services in Africa",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#f97316",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}Install prompt component:
tsx
// components/pwa/InstallPrompt.tsx
export function InstallPrompt() {
const [deferredPrompt, setDeferredPrompt] = useState<any>(null);
const [showPrompt, setShowPrompt] = useState(false);
useEffect(() => {
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
setDeferredPrompt(e);
setShowPrompt(true);
});
}, []);
const handleInstall = async () => {
if (deferredPrompt) {
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
setShowPrompt(false);
}
}
};
if (!showPrompt) return null;
return (
<div className="fixed bottom-4 left-4 right-4 bg-orange-500 text-white p-4 rounded-lg shadow-lg">
<p>Install YeboJobs for a better experience!</p>
<button onClick={handleInstall}>Install</button>
</div>
);
}