Skip to content

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.json

Routing

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>
  );
}

One chat. Everything done.