Fast English
A Persian-first English podcast app for Iranian learners on Android — calm, mobile-first, and built for real conditions: manual card-to-card payment, placement test, level-graded episodes with protected audio, delivered as PWA and Android from one codebase.
What it is
Fast English Podcast is a Persian-UI English learning product for adults in Iran using Android phones. The student journey is short and explicit: sign up with phone and password, pay manually via card-to-card and wait for operator approval, take a 20-question placement test, then listen to level-graded podcast episodes (A1–C2) with protected audio and per-variant progress. A static landing explains the product; an operator console handles review.
Four surfaces share one repository and one package.json: Student App app/ (React 19 + MUI v9, Persian-first, app.fastenglishpodcast.com), Staff Admin Console admin/ (React 19 + MUI), Landing landing/ (React + Tailwind, static, fastenglishpodcast.com), and Backend server/ (PocketBase 0.39.9 — Go, embedded SQLite, JS hooks for auth, payment, placement, audio). Android is produced via Capacitor with webDir = "dist-app". No workspace framework, no monorepo tool.
All technical claims here trace to the repository itself: README.md, docs/ARCHITECTURE.md, docs/PRODUCT.md, package.json, server/pb_migrations/ and server/pb_hooks/. No users, revenue or outcome numbers are invented.
Why this exists
There was no calm, mobile-first, Persian-UI product that fits how many Iranian learners actually pay. App stores and gateway payments are unreliable in this context. The product requirement was therefore: manual card-to-card payment suited to the local market, PWA install plus downloadable APK, placement that grades without claiming certification, and text-plus-audio lessons that work on low-to-mid Android devices over unstable mobile networks with low cognitive load.
Constraints were clear from the start: PWA service worker must never cache /api/ or private/premium data; correct placement answers must never reach the client; receipt images must stay protected; and the site must stay useful without heavy client frameworks or theoretical scale architecture.
How the product fits together
The student-facing flow is deliberately simple: choose a plan → see the destination card (number, holder, bank, short instruction, review ETA) → transfer manually → upload one receipt → submit → wait for staff approval. The UI collects only plan_id and the receipt file. The server snapshots plan name, price and duration so later price edits never rewrite history.
Placement is 20 active questions from the current test version (four choices each), backend-graded, with one accepted final submission. The suggested CEFR level is stored separately from the selected level — browsing or level UI never mutates placement or subscription. Content is structured as Categories → Episodes (Topics, shared across levels) → Variants (one lesson per topic × CEFR level) with published state, protected audio, and per-variant progress. Entitled students (authenticated, active, not suspended, placement done, active subscription) may access every published variant across all levels — level is a browsing default, not an authorization boundary.
Surfaces
- Student App — React 19 + MUI v9, RTL with Stylis plugin, single
<audio>element viaPlayerProvider, React Router, React Hook Form + Zod. Persistent theme and single-audio lifecycle (bind transitions, retry with fresh file token, media session mirroring). - Landing — static, Tailwind only, fast first paint; no payment or placement logic. Pricing is backend-managed via
GET /api/fast-english/public/settings— no hard-coded prices. - Admin Console — unified staff console for payment review, business settings, content import; server verifies
staff_admins+is_activeon every staff endpoint. - PocketBase — migrations committed, JS hooks for phone normalization, uniqueness, receipt checks, approval transaction, grading, and protected-audio proxy.
What I worked on
My role is product engineering across product, implementation and operations — not a single-layer ticket. I worked across the Web, PWA, Android packaging/distribution, PocketBase modeling and deployment, shaping requirements, system structure, implementation details and verification rather than handing theory to another layer.
- Product shaping: journey definitions for signup, manual payment, placement and listening; copy and state language kept honest (no certification, no guaranteed outcomes).
- Implementation: React + TypeScript client surfaces, Vite isolated configs, PocketBase collections and hooks (phone identity, receipt protection, placement grading, audio proxy, subscription logic), Capacitor Android sync and release build, Caddy and systemd wiring.
- Verification: verification lanes, disposable-PocketBase smoke suites, Vitest, Playwright at 360–1440 widths, contrast and reduced-motion checks, log redaction and backup timers.
The repository is the proof. Nothing here is claimed beyond what the committed code, migrations and docs support.
Concrete system pieces
App and landing
Isolated Vite configs — vite.app.config.ts → dist-app (MUI only) and vite.landing.config.ts → dist-landing (Tailwind only). Shared code is limited to shared/brand.ts constants and CSS variables — no cross-surface component framework. Capacitor uses webDir = "dist-app". Self-hosted Vazirmatn variable WOFF2, no runtime CDN font.
Backend
PocketBase collections for users (fep_users), plans (plans), payment requests, subscriptions, placement attempts and answers, categories/episodes/variants and progress. Hooks enforce: phone canonical +989XXXXXXXXX with uniqueness; client never sets role / account_status; one pending payment per user; approval + subscription create/extend in one DB transaction with an idempotency link; receipt is a single protected file (JPEG/PNG/WebP, ≤5 MB, MIME/signature/extension match, randomized storage name, short-lived authorized preview only); correct answers never leave the server; premium body/audio denied to pending/rejected/expired/suspended.
Audio and progress
Protected audio is streamed through a lesson-audio proxy with a short-lived PB file token passed as a query parameter (the <audio> element cannot send custom headers). The proxy re-validates live entitlement on every request, so a leaked token grants nothing beyond current entitlement. Progress is per-variant, throttled and revision-guarded.
Platform details
Browser/PWA uses same-origin /api/* via Caddy (allowed origins: app.fastenglishpodcast.com, fastenglishpodcast.com, Capacitor origin — no wildcard CORS). Android release uses an explicit https://app.fastenglishpodcast.com API base and bundled assets — not window.location.origin. Dev uses Vite /api proxy and adb reverse for Android device + VITE_ANDROID_API_ORIGIN.
System structure
Lightweight explanatory view. The value is the constraints it makes visible, not decoration.
Why this shape
- No custom Node backend. PocketBase hooks cover auth, validation, transactions and grading without a second runtime.
- Two Vite configs, not a workspace. Keeps Tailwind out of the app and MUI out of the landing with clear isolated outputs — simplest boundary that prevents bleeding.
- Mobile-first without ionic. Single codebase via Capacitor, bundled assets, explicit API origin for APK — verified via
cap sync+assembleDebug. - Static where possible. Landing is fully static; pricing and card-transfer availability come from a single public settings endpoint at runtime — no hard-coded prices anywhere.
Meaningful trade-offs
- Phone as identity, not email. PB 0.39 forces
emailintopasswordAuth.identityFields. The resolution: user-facing identity is the phone, server derives a stable internal email<canonical_phone>@fep.local, and the SDK authenticates with the derived email. The phone field has a unique index. - Price snapshots, not live prices. Every paid payment request stores
plan_name_snapshot/amount_snapshot/duration_days_snapshotat submission — later edits never rewrite receipt meaning. - One pending payment per user. Guards manual review against duplicate queues; resubmit only after rejection.
- Free plans via canonical record.
price_toman === 0on theplansrecord is the free-plan signal; free activation is a dedicated transaction with a partial unique index (idx_subscriptions_one_free_per_user) as the concurrency backstop — never a second entitlement over an existing valid one. - Card transfer toggle without deletion. Disabling hides all card-transfer UI, makes paid plans unavailable, and re-checks server-side on every submission — but never deletes the stored card config.
- No native HTTP patch by default. Normal HTTPS/fetch is the path until real-device evidence proves upload fails.
- PWA boundaries. Service worker never caches
/api/, auth, payments, receipts, placement, premium text/audio or artwork — only app shell and public static assets.
How AI was used
AI coding tools were used for implementation, research, debugging, repetitive tasks, and iteration. Requirements, architecture, verification, testing, and final responsibility remained explicit parts of the development process.
Concretely: scaffolding and refactoring across React/PocketBase code, explaining PB 0.39 goja constraints, drafting tests and smoke scripts, tightening Caddy and systemd units, and speeding up diagnosis. Automation did not replace judgment — every protected path (auth, receipt validation, placement grading, audio entitlement, approval transaction) is server-enforced and verified by real-backend smoke suites and Playwright.
Generative output was reviewed before use. No production decision is attributed to a model. What matters is not that AI assisted, but that verification stayed manual and evidence stayed disposable-backend and browser-based.
Real constraints
- Working around PB 0.39
identityFieldsrevert by committing to derived email internally while keeping phone as the public identity — without leaking the derivation to UI copy. - Handling byte-identical PB file tokens within the same second by adding a cache-busting nonce for audio retry while the proxy still re-validates entitlement.
- Keeping the single
<audio>lifecycle honest across visibility changes, variant switches and Media Session mirroring — never auto-playing, never inventing position. - Designing audit-less, gateway-less money handling so a receipt image never proves payment — approval stays operator-verified externally and transaction-idempotent.
- Serving the same product to unstable mobile networks and low-to-mid Android devices while preserving tabular timestamps, RTL portals, and bounded concurrency without raw colors or heavy animation.
How it was checked
The main gates are: pnpm verify:fast (typecheck + Biome + Vitest), pnpm verify:feature (fast + affected real-backend smokes + @critical Playwright), and pnpm verify:full (all 16 real-PocketBase smoke suites + builds + full Playwright). Each smoke suite runs a disposable PocketBase in /tmp — never touching server/pb_data. CI runs the same lanes.
Coverage includes: signup/login with phone normalization and collision handling; one disposable receipt approved creates exactly one subscription and repeated approval does not double duration; unauthorized receipt/approval fails; 20-question placement completes on a real backend in browser and Android with no answer leakage; active student accesses real lesson + audio while pending/expired is denied and progress survives refresh. Automated checks enforce no raw hex in components, WCAG contrast, motion tokens and copy vocabulary (shared/ui/palette.contrast.test.ts, static-quality, copy-guidelines). Caddy access logs have filtered the token query param via a proven redaction script and PocketBase binds to 127.0.0.1:8090 only.
Honest state
The MVP path is implemented against the July 2026 product contract: signup → manual payment → approval → placement → lessons with protected audio and progress, across web/PWA and Android. PWA install is proven. The release APK build chain produces versioned APKs with SHA-256 and is verified as assembleDebug + cap sync — physical-device install, APK signature proof and the two-day deadline logistics remain the open gates (needs keystore on the VPS and a device). Billing still references the owner-approved launch set: monthly 299,000 toman (30 days) and quarterly 807,300 toman (90 days, 10 % off 3× monthly) — no yearly plan.
What is still honestly open: release keystore ownership and secure storage, VPS provider and DNS access, operator identities and count, the reviewed 20-question bank (the demo bank is not the reviewed bank), the production episode library (demo package is not production), receipt retention approval, and approved privacy/terms copy. Where data did not exist, placeholders are explicitly labeled — the system never fabricates a metric to look complete.
In progress, deployable via disposable-backend verification. The codebase builds, the disposable-backend smokes run locally, and rollback remains a symlink flip + restart. No public marketing outcome is claimed — the infrastructure is waiting for the launch decisions listed above.
Imagery
Real product screenshots are not yet published in this portfolio. The media treatment below preserves the honest placeholder strategy from the homepage — abstract, well-composed frames that accept real imagery later without redesigning the page. Achievements are not demonstrated via invented dashboards.
When captures are ready, they will be provided via <Image> with widths=4807201080, loading="lazy", explicit dimensions and truthful alt text — no browser mocks that imply proof.
Where to look next
The repository itself is the primary artifact. The project is currently private — access is on request. The surface URLs exist in the committed config but are not presented as live marketing destinations until the VPS, DNS and certificate rollout is completed. Every claim above is reachable in the committed docs/ and server/ code.
If you want to inspect the Fast English code, ask for repository access — I can walk through any protected path (receipt handling, placement grading, audio proxy) on a disposable backend.