Standalone front-end for the Glific web channel — a public, WhatsApp-style chat app
that end users open in a browser to converse with an NGO running on Glific. It is a
dedicated SPA (deployed at e.g. web.<org>.glific.com), fully decoupled from the Glific
staff console (glific-frontend).
It talks to the Glific backend over REST (OTP auth) and a Phoenix websocket
(/web_socket) — there is no Apollo/GraphQL here.
- React 19 + Vite + TypeScript
- Tailwind CSS v4 + shadcn/ui (Radix primitives)
- react-hook-form + zod (forms/validation)
- phoenix JS client (realtime), axios (REST)
- Vitest + React Testing Library (tests)
- Package manager: yarn · Node pinned via
.tool-versions(24.20.0)
yarn install
yarn dev # https://glific.test:5173 (trusted mkcert cert via vite-plugin-mkcert)The dev server runs on https://glific.test:5173 — add 127.0.0.1 glific.test to
/etc/hosts if needed. On first run, vite-plugin-mkcert may prompt to install its
local CA. Generated certs are saved under certs/ (gitignored).
The dev server proxies /api and /web_socket to the Glific backend on
https://localhost:4001 (see vite.config.ts). Start the backend first. There is no
bypass code — the backend mints a real OTP and delivers it over WhatsApp.
| Command | What |
|---|---|
yarn dev |
Start the Vite dev server (with backend proxy) |
yarn build |
Typecheck (tsc -b) + production build to dist/ |
yarn test |
Run the Vitest suite once |
yarn test:watch |
Watch mode |
yarn e2e |
Run the Playwright end-to-end suite against a production build |
yarn e2e:live |
Run the opt-in journeys against a real backend (see below) |
yarn e2e:ui |
Playwright's interactive UI mode |
yarn e2e:report |
Open the last HTML report |
yarn lint |
Lint (oxlint) |
Endpoints are resolved in src/config.ts. In dev leave the env vars unset — relative
paths + the Vite proxy handle everything. In prod, set (see .env.example):
VITE_GLIFIC_API_URL=https://api.<org>.glific.com/api
VITE_WEB_SOCKET=wss://api.<org>.glific.com/web_socket
Branding is per organisation and fetched at runtime — one build serves every NGO. At boot
the app calls GET /api/v1/web_channel/branding; the backend resolves the organisation from the
request Host, so the client sends no organisation identifier. Admins set the values from
Glific's Settings page under Web Channel, and a change takes effect on reload with no
redeploy.
| Value | Where it lands |
|---|---|
| Theme (a named palette) | --primary and its matched --primary-foreground |
Logo URL (https only) |
Login card and chat header |
| Display name | Login card and document.title |
Three properties worth knowing before changing src/services/branding.ts:
- First paint is held behind the fetch (
src/main.tsx), so there is no flash of the default palette. - A failed fetch does not fall back.
loadBrandingreturnsok/disabled/unavailable: a 404 renders the app with a "not enabled" banner, but an unreachable backend renders a retryable error page. Quietly serving the default palette under an NGO's own domain would read as the wrong organisation rather than as a failure. - The foreground ships with the palette, never supplied by the org. An organisation that
picked its own foreground could make its button text vanish; contrast is an accessibility
obligation, not a preference.
themes.test.tsasserts every pair clears WCAG AA. - Override the raw custom properties (
--primary), not the--color-*aliases — those are compiled through Tailwind's@theme inlineand cannot be set at runtime.
Playwright, in e2e/, running against the production build via vite preview — not the dev
server. yarn e2e builds nothing itself, so run yarn build first if dist/ is stale.
yarn build && yarn e2eThe convention: every ticket adds its own journeys here. The harness landed with the first
user-visible feature precisely so it never trails the features it is meant to guard — a ticket
that changes user-visible behaviour is not done until e2e/ covers it.
Journeys are grouped one file per feature area, with shared fixtures in e2e/support/. Backend
responses are stubbed with page.route, which is what lets a single build be exercised as two
different organisations without standing up two backends.
Journeys run on Pixel 7, iPhone 14, iPhone SE and Desktop Chrome — beneficiaries reach this on a phone, often a shared or borrowed one, so mobile is the primary target rather than an afterthought.
CI (.github/workflows/ci.yml) runs install → typecheck → lint → vitest run → production
build → playwright test on every push and pull request.
e2e/live/ holds one journey that runs with nothing stubbed: a real request to
/api/v1/web_channel/request-otp, a code minted by Glific.OTP, a real message row, a real
signed token. It is opt-in and excluded from CI, which has neither Postgres nor Phoenix.
yarn e2e:liveThe code is never persisted on its own — PasswordlessAuth keeps it in the backend's memory and
there is deliberately no route that hands it back. But both send paths compose it into the message
body before the message reaches Gupshup, and a staff user can read that message. So the suite signs
in as staff and reads it over the same GraphQL API the console uses: no database access, nothing an
operator could not see, and no test-only door into production code.
The one hop it does not cover is Gupshup handing the message to WhatsApp: that needs a real handset and a human reading it, so it stays a manual acceptance check.
Preconditions, all on the org the backend resolves from the request host:
| Backend | running on https://localhost:4001 |
web_channel_enabled |
on for the organisation |
| Staff user | phone + password with at least manager access, for seeding and reading |
The suite seeds its own contacts. It has to: glific#5710 creates contacts with no consent and no session, so a number typed into the login box for the first time is deliberately sent nothing at all until #5713 adds the exemption.
| Variable | Default |
|---|---|
E2E_STAFF_PHONE |
required |
E2E_STAFF_PASSWORD |
required |
E2E_BACKEND_URL |
https://localhost:4001 |
E2E_LIVE_URL |
https://glific.test:5173 |
E2E_PHONE / E2E_PHONE_ALT |
919999900001 / 919999900002 |
The staff credentials have no defaults on purpose: a wrong guess surfaces as a 401 midway through a journey, which reads as the feature being broken rather than the harness being unconfigured. To set a password on a seeded dev user:
mix run -e '
Glific.Repo.put_organization_id(1)
Glific.Users.get_user_by(%{phone: "917834811114"})
|> Glific.Users.update_user(%{password: "Secret12345678!", confirm_password: "Secret12345678!"})
'Runs are serial with retries off, because the backend throttles a phone to one OTP per 30 seconds and a retry would fail on the throttle rather than on whatever went wrong.
This repo is configured for Vercel as a static Vite SPA (vercel.json).
-
Import the repository in the Vercel dashboard (or run
vercelfrom the project root). -
Vercel detects Vite automatically. Build settings are pinned in
vercel.json:- Install:
yarn install - Build:
yarn build - Output:
dist
- Install:
-
Add Environment Variables for production (required — Vite inlines
VITE_*at build time):Variable Example VITE_GLIFIC_API_URLhttps://api.your-org.glific.com/apiVITE_WEB_SOCKETwss://api.your-org.glific.com/web_socketApply them to Production (and Preview if previews should hit a real backend).
-
Deploy. Client-side routes (
/login,/chat) are rewritten toindex.htmlso hard refreshes work.
Node 24.20.0 is pinned via .nvmrc and package.json engines.
src/
config.ts # backend endpoints (env-driven, relative defaults)
services/
webChannelSocket.ts # phoenix socket: join, send, load-more, rename
webChannelAuth.ts # OTP request/verify + localStorage session
branding.ts # per-org branding: fetch, apply, module store
themes.ts # the named colour palettes
routes/
Login.tsx # phone -> OTP (react-hook-form + zod)
Chat.tsx # conversation: realtime, optimistic send, reverse-infinite scroll
components/
chat/MessageBubble.tsx # WhatsApp-style bubble
chat/EditName.tsx # inline contact rename
branding/Logo.tsx # the org's logo, or nothing when unset
ui/ # shadcn components (owned in-repo)
lib/
whatsapp.tsx # safe WhatsApp-markup -> JSX + time formatting
utils.ts # shadcn cn()
App.tsx # routes (/login, /chat)
main.tsx # resolves branding, then mounts BrowserRouter
e2e/ # Playwright journeys (see "End-to-end tests")
Built as an SPA now, structured so it can later be packaged as an embeddable widget (script/iframe snippet for third-party NGO sites) with Shadow-DOM / scoped-Tailwind style isolation — the shadcn "you own the component code" model makes that migration local.