Preact + Vite SPA served behind nginx. Dark-themed home-services platform where clients find workers and workers find jobs. Connects to the Go backend via API proxied through Traefik.
URL: https://helpingpeople.cloud | Container: helpingpeoplenow-frontend
| Layer | Technology |
|---|---|
| UI | Preact (React-compatible, 3kB) |
| Routing | preact-router v4 |
| Build | Vite + TypeScript |
| Runtime | nginx:alpine (multi-stage Docker) |
| CI/CD | GitHub Actions β ghcr.io |
| Route | Component | Auth Required | Description |
|---|---|---|---|
/ |
LandingPage.tsx |
No | Landing page β redirects to /chat if logged in |
/login |
LoginPage.tsx |
No | Magic-link login + signup form |
/terms |
TermsPage.tsx |
No | Terms and conditions (bilingual EN/ES) |
/privacy |
PrivacyPage.tsx |
No | Privacy policy (bilingual EN/ES) |
/cookies |
CookiesPage.tsx |
No | Cookie policy (bilingual EN/ES) |
/profile/:slug |
PublicProfilePage.tsx |
No | Public worker profile with hero, stats, bio, links, CTA |
/chat |
ChatPage.tsx |
Yes | AI chat β modes via ?mode= param: worker_intake, client_intake, search |
/find |
FindPage.tsx |
Yes | Find professionals β worker card grid |
/inbox |
InboxPage.tsx |
Yes | DM inbox with status indicators |
/inbox/:convId |
DirectMessagePage.tsx |
Yes | DM thread with send, block, report, archive |
/workers/:workerId |
WorkerContactPage.tsx |
Yes | Create/resume DM with a worker, redirects to inbox |
/admin |
AdminPage.tsx |
Yes | Admin menu β links to LLM provider, prompts, and entity CRUD pages |
/admin/feedback |
FeedbackAdminPage.tsx |
Yes | Admin feedback dashboard β triage user-submitted feedback |
Browser βββΊ Traefik (:80)
β
βββ /api/v1/* βββΊ Backend (:8081)
β (unified POST /api/v1/chat with mode + optional GPS coordinates in body,
β GET/DELETE /api/v1/{worker,client}/profile,
β GET/PUT /api/v1/system-prompts/,
β GET /api/v1/conversations,
β GET/POST/PATCH /api/v1/direct-messages/,
β GET /api/v1/workers/:id/contact,
β GET/PUT/DELETE /api/v1/admin/{entity}/)
β
βββ /api/auth/* βββΊ Auth Service (:8083) β magic-link + sessions
β
βββ adminer.helpingpeople.cloud, grafana.helpingpeople.cloud, prometheus.helpingpeople.cloud β observability / DB UI
β
βββ /* βββΊ Frontend nginx β SPA (index.html)
/health βββΊ Frontend nginx β 200 "ok"
The SPA uses client-side routing via preact-router. Public routes (/, /login, /terms, /privacy, /cookies, /profile/:slug) require no auth. All other routes are wrapped in ProtectedRoute which redirects to /login if no valid session.
API calls use relative URLs (/api/v1/..., /api/auth/...) which Traefik proxies to the appropriate backend service.
The nginx config has a /health location block that returns 200 OK with body "ok" and has access_log off. It is used for Docker container healthchecks.
| Endpoint | Response | Notes |
|---|---|---|
GET /health |
200 OK with body ok |
access_log off; no auth required |
- User visits
/loginβ enters email (or name + email for new accounts) β auth service sends a magic link - Clicking the magic link creates a session cookie (
better-auth.session_token) AuthProviderchecks the session on every route changeProtectedRoutewrapper redirects to/loginif no valid session- After login, user lands on LandingPage β
ModeChooser(or navigates to/chat?mode=...)
- Provider: Cap β open-source CAPTCHA service
- Widget:
<cap-widget>custom element embedded onLoginPageandPublicProfilePage(for the contact-CTAgetContactflow) - Token delivery:
capTokenis obtained from the widget and sent:- In
metadata.capTokenwhen callingsendMagicLink(magic-link login/signup) - As a
?capToken=query parameter when callingGET /api/v1/workers/:id/contact(public profile CTA)
- In
- CSP requirements: The Content-Security-Policy in
nginx.confmust allow:script-src/worker-src:cap.helpingpeople.cloudandwasm-unsafe-evalconnect-src:cap.helpingpeople.cloud
- Spanish is the default language (configurable via
LangTogglein sidebar) - Single
i18n.tsfile with translations, language context,useLanguage()hook, and toggle - All UI text uses
t()fromuseLanguage()for translated strings - Chat requests include a
langparameter so the AI responds in the matching language
- A self-hosted Umami analytics script is injected in
index.html(https://helpingpeople.cloud/umami/script.js). It tracks page views without cookies and does not collect personal data. TheCookiesPagestates that the platform does not use third-party, advertising, social-media, or analytics/tracking cookies.
useGeolocation()hook wrapsnavigator.geolocationwith 5-min cache (maximumAge: 300000)- Chat and Find pages pass
latitude/longitudein thePOST /api/v1/chatbody when available - Backend computes distance;
WorkerCardrendersΒ· π X.X kminline (values <1 km show as<1 km) - When location permission is denied, a blue banner (
.location-banner) shows witht('chat.location.denied')guiding users to enable it in browser settings
GPS source precedence (highest β lowest):
- Request
latitude/longitudefrom browser geolocation (most current) - Stored
client_profiles.latitude/longitudefrom intake - Absent β no distance sorting, no proximity filter
City source precedence (highest β lowest):
- City extracted from the user's message by the Pass-1 LLM
- Stored
client_profiles.cityfrom intake - Absent β no city filter
When mode: "search", the backend returns a different response envelope than intake modes:
{
"answer": "Here are the plumbers I found near you...",
"workers": [
{ "id": "...", "full_name": "...", "profession": "Plumber", "city": "Madrid",
"distance_km": 2.3, "slug": "carlos-plumbing", ... }
],
"conversation_id": "42",
"branch": "vector",
"top_score": 0.82
}workers[]β array of matched worker cards (rendered asWorkerCardgrid byFindPage)branchβ which search path was taken:vector(pgvector cosine),ilike(text match),ilike_low_top_score(vector score below threshold),ilike_embed_failed(embedding error)top_scoreβ highest cosine similarity score from the vector search (absent for ILIKE-only results)distance_kmβ Haversine distance in km from the client's GPS coordinates (absent when coordinates unavailable)
| File | Purpose |
|---|---|
src/main.tsx |
App entry point |
src/App.tsx |
Root component β router config + AuthProvider + ProtectedRoute wrapper |
src/AuthProvider.tsx |
Session context β loads user from /api/auth/get-session, exposes useAuth() hook |
src/auth.ts |
Barrel re-export of services/auth β getSession, sendMagicLink, logout |
src/ChatPage.tsx |
Chat UI β message list, input, mode-based routing (worker_intake/client_intake), SSE streaming |
src/AdminPage.tsx |
Admin menu β links to /admin/llm, /admin/prompts, and entity CRUD pages |
src/AdminLLMPage.tsx |
LLM provider dropdown β calls PUT /api/v1/system-prompts/provider |
src/AdminPromptsPage.tsx |
4-prompt textarea editor β calls PUT /api/v1/system-prompts/{column} |
src/FeedbackAdminPage.tsx |
Admin feedback dashboard β lists/subcategories feedback with status filters |
src/LoginPage.tsx |
Magic-link login + signup β email input, send link (embeds Cap CAPTCHA widget) |
src/PublicProfilePage.tsx |
Public worker profile with hero, stats, bio, links, CTA (embeds Cap CAPTCHA for contact) |
src/TermsPage.tsx |
Bilingual EN/ES terms and conditions |
src/PrivacyPage.tsx |
Bilingual EN/ES privacy policy |
src/CookiesPage.tsx |
Bilingual EN/ES cookie policy |
src/i18n.ts |
Internationalization β translations, language toggle |
src/lib/logger.ts |
Centralized logger β createLogger(prefix) for scoped console output |
src/lib/feedbackApi.ts |
Feedback API client β submitFeedback(), listFeedback(), updateFeedbackStatus() |
src/lib/publicProfileApi.ts |
Public profile API β fetchPublicProfile(slug), fetchLatestProfiles(limit) |
src/lib/validate.ts |
Anti-corruption validators β assertString, assertNumber, assertArray, etc. |
src/components/feedback/FeedbackWidget.tsx |
Feedback FAB button (π¬) β fixed-position, opens FeedbackPopover |
src/components/feedback/FeedbackPopover.tsx |
Feedback form β category buttons, textarea, submit with success toast |
src/components/CookieConsent.tsx |
Cookie consent banner β shown on first visit |
src/hooks/useGeolocation.ts |
GPS geolocation β wraps navigator.geolocation, returns { latitude, longitude, loading, permissionDenied, error } |
nginx.conf |
Static file serving + SPA fallback (try_files $uri /index.html) + security headers (CSP/HSTS/X-Frame-Options/etc.), gzip, asset caching (audit P1-2) |
Dockerfile |
Multi-stage: node:22-alpine build β nginx:alpine runtime. Runs as non-root nginx user on port 8080 with HEALTHCHECK (audit P2-1) |
The Worker intake mode is reached via /chat?mode=worker_intake. The chat is the only UI β no separate /worker page exists.
- Users type naturally: "I'm a plumber in Madrid with 12 years experience"
- The LLM (with the
worker_profile_prompt) asks follow-up questions to gather all 22 fields - The LLM emits a
[FIELDS]{json}[/FIELDS]block; the backend parses it and merges into the worker profile via map-based upsert - Previous conversations are loaded on mount from
GET /api/v1/conversations?type=worker&limit=1
| Field | JSON key | Type |
|---|---|---|
| Profession | profession |
string |
| Business Name | business_name |
string |
| Bio | bio |
string |
| Phone | phone |
string |
| City | city |
string |
| Address | address |
string |
| Service Radius | service_radius_km |
number |
| Hourly Rate | hourly_rate |
number |
| Minimum Charge | minimum_charge |
number |
| Free Estimate | free_estimate |
boolean |
| Years Exp | years_experience |
number |
| Certifications | certifications |
string[] |
| Has Insurance | has_insurance |
boolean |
| Languages | languages |
string[] |
| Emergency | emergency_service |
boolean |
| Website | website |
string |
| Social Links | social_links |
{platform,url}[] |
- "Reset Profile" calls
DELETE /api/v1/worker/profile(noPUTendpoint β chat handlers do the saving) - Chat request body includes
mode: "worker_intake"andlang
The Client intake mode is reached via /chat?mode=client_intake. The chat is the only UI β no separate /client page exists.
- Users describe what they need: "I need help fixing my bathroom"
- The LLM (with the
client_profile_prompt) asks follow-up questions to gather profile fields - The LLM emits a
[FIELDS]{json}[/FIELDS]block; the backend parses it and merges into the client profile - Previous conversations are loaded on mount from
GET /api/v1/conversations?type=client&limit=1
| Field | JSON key | Type |
|---|---|---|
| Full Name | full_name |
string |
| Phone | phone |
string |
| City | city |
string |
| Address | address |
string |
| Bio | bio |
string |
| Property Type | property_type |
string |
| Notes | notes |
string |
- "Reset Profile" calls
DELETE /api/v1/client/profile - Chat request body includes
mode: "client_intake"andlang
Dropdown at the top of the admin page:
| Option | Value | Behaviour |
|---|---|---|
| Default (auto) | "" |
Falls back to helper's auto fallback chain (OpenCode 0 β OpenCode 1 β OpenCode 2 β Mistral β Ollama, cheap-first R5) |
| OpenCode 0 (Big Pickle) | "opencode0" |
Forces big-pickle model |
| OpenCode 1 | "opencode1" |
Forces first OpenCode endpoint (deepseek-v4-flash-free) |
| OpenCode 2 | "opencode2" |
Forces second OpenCode endpoint (mimo-v2.5-free) |
| Ollama (local) | "ollama" |
Forces local Ollama |
| Mistral (cloud) | "mistral" |
Forces Mistral API (requires MISTRAL_API_KEY) |
Changes take effect immediately β no container restart needed.
Textareas to edit (one per column in the system_prompts table):
| Column | i18n key | Purpose |
|---|---|---|
worker_profile_prompt |
admin.prompt.worker |
System prompt for worker profile intake chat |
client_profile_prompt |
admin.prompt.client |
System prompt for client profile intake chat |
find_trader_search_prompt |
admin.prompt.find_trader_search |
First-pass search-params extraction |
find_trader_presentation_prompt |
admin.prompt.find_trader_presentation |
Second-pass results-presentation |
Each textarea has a per-column Save button that calls PUT /api/v1/system-prompts/{column}.
npm install # Install dependencies
npm run dev # Vite dev server with HMR on :5173
npm run build # Production build β dist/
npm run preview # vite preview (serves built dist/)
npm run typecheck # tsc --noEmit
npm run lint # eslint src/npm run test:coverage # vitest run --coverage (unit + integration)
npm run test:e2e # playwright test (uses built dist/)Coverage thresholds (enforced in vitest.config.ts): lines 75%, branches 75%, functions 78%, statements 75%. The CI pipeline (.github/workflows/ci.yml) runs: lint β typecheck β vitest (unit + integration) β Playwright e2e β Docker build/push to ghcr.io/helpingpeoplenow/frontend.
Note: the
package.jsononly exposestest:coverageandtest:e2eβ there is no plainnpm test(vitest watch) script. Usenpx vitestfor watch mode locally.
docker build -t ghcr.io/helpingpeoplenow/frontend:latest .
docker push ghcr.io/helpingpeoplenow/frontend:latestCI builds and pushes automatically on push to main.
Browser console logging with component prefixes:
| Prefix | Component | Events |
|---|---|---|
[Chat] |
ChatPage | Request timing, answer length, mode, errors |
[Auth] |
AuthProvider | Session check, login/logout, redirect |
[Admin] |
AdminLLMPage / AdminPromptsPage | Prompt load/save, provider switch, timing |
[DM] |
DirectMessagePage / WorkerContactPage | DM send, block, report, archive |
[Nav] |
App router | Route changes, auth redirects |
[ModeChooser] |
ModeChooser | Card click navigation |
[API] |
services/api.ts | Generic fetch calls, error handling |
[SSE] |
DirectMessageSSE | Connect, disconnect, reconnect attempts, polling fallback |
[Inbox] |
InboxPage | Thread list load, unread badge |
[Thread] |
DirectMessagePage | Message load, scroll, read receipt |
[Speech] |
useSpeechRecognition | Voice input start/stop/error |
[App] |
App.tsx | Mount, route init |
[Profile] |
PublicProfilePage | Profile load, CTA click |
[Landing] |
LandingPage | Hero load, latest professionals |
[Feedback] |
FeedbackWidget / FeedbackPopover | Submit, success, error |
[Geo] |
useGeolocation | GPS permission, coords, error |
Open browser DevTools (F12) β Console for debugging.
frontend/
βββ index.html # HTML shell (imports /src/main.tsx)
βββ nginx.conf # nginx static file serving + SPA fallback + /health
βββ Dockerfile # Multi-stage: node:22-alpine β nginx:alpine
βββ package.json # Dependencies
βββ vite.config.js # Vite config (Preact preset)
βββ tsconfig.json # TypeScript config
βββ playwright.config.js # Playwright e2e config
βββ e2e/
β βββ deploy.spec.js # Playwright deploy-smoke tests
βββ src/
β βββ main.tsx # Entry point β renders App with LanguageProvider
β βββ App.tsx # Router + AuthProvider + ProtectedRoute + ErrorBoundary
β βββ AppShell.tsx # Sidebar + header layout
β βββ AuthProvider.tsx # Session context + useAuth hook
β βββ auth.ts # Barrel re-export of services/auth
β βββ i18n.ts # EN/ES translations + LanguageProvider + LangToggle
β βββ style.css # Shared design system
β βββ LoginPage.tsx # Magic link login + signup (embeds Cap CAPTCHA widget)
β βββ LandingPage.tsx # Marketing landing for visitors / ModeChooser for authed
β βββ ChatPage.tsx # Worker/client intake chat (mode in query string)
β βββ FindPage.tsx # Search/find professional chat
β βββ PublicProfilePage.tsx # Public worker profile with hero, stats, bio, links, CTA
β βββ ModeChooser.tsx # 3-card mode selector
β βββ AdminPage.tsx # Admin menu
β βββ AdminLLMPage.tsx # LLM provider dropdown
β βββ AdminPromptsPage.tsx # 4-prompt textarea editor
β βββ FeedbackAdminPage.tsx # Admin feedback dashboard β status filters, category, message
β βββ TermsPage.tsx # Bilingual EN/ES terms and conditions
β βββ PrivacyPage.tsx # Bilingual EN/ES privacy policy
β βββ CookiesPage.tsx # Bilingual EN/ES cookie policy
β βββ UsersPage / UserDetailPage.tsx
β βββ WorkersPage / WorkerDetailPage.tsx
β βββ ClientsPage / ClientDetailPage.tsx
β βββ ConversationsPage / ConversationDetailPage.tsx
β βββ MessagesPage / MessageDetailPage.tsx
β βββ EntityListPage.tsx # Generic admin CRUD list
β βββ EntityDetailPage.tsx # Generic admin CRUD detail
β βββ components/
β β βββ ErrorBoundary.tsx # Catches render errors
β β βββ CookieConsent.tsx # Cookie consent banner β shown on first visit
β β βββ feedback/
β β β βββ FeedbackWidget.tsx # Fixed-position FAB (π¬) β opens FeedbackPopover
β β β βββ FeedbackPopover.tsx # Feedback form β category, textarea, submit
β β βββ chat/
β β βββ ChatInput.tsx # Input bar with optional mic button
β β βββ ChatMessages.tsx # Bubble list with worker card grid
β β βββ ChatWelcome.tsx # First-message welcome card
β β βββ WorkerCard.tsx # Clickable worker summary card (shows distance_km when available)
β βββ hooks/
β β βββ useChat.ts # Chat state + SSE streaming
β β βββ useChatInit.ts # Load most recent conversation
β β βββ useGeolocation.ts # GPS geolocation (latitude, longitude, permissionDenied)
β β βββ useSpeechRecognition.ts # Voice input via Web Speech API
β βββ lib/
β β βββ logger.ts # Centralized logger β createLogger(prefix)
β β βββ feedbackApi.ts # Feedback API β submitFeedback, listFeedback, updateFeedbackStatus
β β βββ publicProfileApi.ts # Public profile API β fetchPublicProfile, fetchLatestProfiles
β β βββ validate.ts # Anti-corruption validators β assertString, assertArray, etc.
β β βββ directMessageApi.ts # DM API client (contact, inbox, messages, etc.)
β β βββ sse.ts # DirectMessageSSE class with reconnect + polling fallback
β βββ pages/
β β βββ InboxPage.tsx # DM inbox
β β βββ DirectMessagePage.tsx # DM thread + actions
β β βββ WorkerContactPage.tsx # Intermediate redirect after clicking WorkerCard
β βββ services/
β β βββ api.ts # request() β generic fetch with credentials
β β βββ auth.ts # getSession, sendMagicLink, logout
β β βββ chat.ts # sendChat (raw fetch, SSE-aware)
β β βββ conversations.ts # list/get conversations
β β βββ systemPrompts.ts # GET/PUT system prompts
β β βββ admin.ts # list/get/update/delete admin entities
β β βββ profiles.ts # (dead β see GOTCHA)
β βββ store/
β βββ directMessages.ts # Zustand store
βββ dist/ # Production build output