Practice Hub & Study Plan: Freemium Gate¶
Status: shipped LNO: L. This is the B2C freemium model on the two learning surfaces. It decides what a free learner tastes and what they pay for. Owners: @bhanu49 Last updated: 2026-07-13 Roles affected: B2C free, B2C Pro (
role == b2c_user) Primary routes:/practice,/studyplanSource of the ask: freemium monetization, @bhanu49. Validation is a conversion hypothesis, not a user interview. Related: Practice Hub Engine, Study Plan, Two-tier pricing, Unified locks
TL;DR¶
The Practice Hub and the Study Plan (/studyplan) are both open to every B2C learner, free or Pro. There is no nav-level lock: a free learner clicks the sidebar entry and lands on the real surface. The gate lives inline (a locked action inside the page) and, authoritatively, in the backend. The two surfaces gate on different levers: Practice Hub gives a free learner one chapter per subject (rank 1 by exam yield); Study Plan gives them one active plan (Pro gets three). Neither surface is Pro-only.
Two surfaces, one philosophy
Practice Hub is the JEE/NEET past-paper-ranked practice engine (see Practice Hub Engine). Study Plan (/studyplan) is the generated-plan surface (see Study Plan). Both give the free learner a real taste of the artifact and charge for volume, not for entry.
History
An earlier version locked these surfaces at the sidebar: a free learner hit a PaywallModal on the nav item and could never reach the page. That model is gone (PR #1267). The study_plan_pro_gate platform flag, the require_pro_plan dependency, and the PLAN_UPSELL_HREF nav sentinel were all removed. A later change (PR #1295) went further and removed the Pro lock on plan generation itself: free learners now generate one real plan.
How it works¶
Three layers, from what the learner sees to what the server enforces.
1. Nav is open¶
apps/web/src/constants/navigation.ts points the B2C entries at the real routes (ROUTES.practiceHub, ROUTES.studyPlan) with no paywall wiring, under a comment that free-plan gating happens inline, not in nav. DashboardLayout no longer tracks paywall state or renders a lock on the nav item.
2. Inline locks (the teaser)¶
Practice Hub: one free chapter per subject. PriorityMapGrid ranks a subject's chapters by exam yield. Rank 1 is free to practise; every lower-ranked chapter renders locked and, on click, opens the PaywallModal the page hosts. The rule is locked={isLocked && index + 1 > FIRST_UNLOCKED_RANK} with FIRST_UNLOCKED_RANK = 1 (PriorityMapGrid.tsx:16, :142).
Study Plan: one active plan, no lock on the page. A free learner who opens /studyplan sees the real plans list. StudyPlanPage does not call useB2CProGate() and renders no paywall. The lever is the concurrent-plan cap: free holds one active plan, Pro holds three. When the learner is at the cap, the create page disables generate and explains why; archiving a plan frees the slot. Full detail in Study Plan.
useB2CProGate() returns { isLocked, isResolving }. isLocked is true only for a free-plan B2C user; Pro and B2B are never locked. isResolving is true while a B2C user's quota is still loading, so gated fetches wait for the verdict instead of racing the loading window. After PR #1295 the Practice Hub is its only consumer.
3. Backend enforcement (the real gate)¶
The inline locks are a teaser; the server is the source of truth, so a learner who calls the API directly hits the same limit.
- Per-chapter practice.
PracticeSessionService._enforce_free_tier_gateruns on session-create: a free B2C learner may only start a session on the subject's single highest-yield chapter (the same rank-1 the UI unlocks, resolved viaStudyPlanService.top_chapter_id). Any other chapter raisesQuotaUpgradeRequiredError, which the route maps to HTTP402. Pro/paid B2C and non-B2C roles return early, unaffected. - Plan generation. Not Pro-gated. The generate
POSTcreates the plan for a free learner too. What the server enforces is the per-tier active-plan cap (FREE_MAX_ACTIVE_STUDY_PLANS = 1,MAX_ACTIVE_STUDY_PLANS = 3,apps/backend/src/services/generated_study_plan.py:83-87), re-checked atomically under an advisory lock inside the service and surfaced as HTTP409 Conflict(study_plan.py:275-277,generated_study_plans.py:209-211).apps/backend/src/api/v1/study_plan.py:203states the rule in-code.
Reads are open. GET /api/v1/study-plan (the computed Practice Hub dashboard) and the generated-plan list endpoints require only the b2c_user role; a free learner reads 200, a wrong role gets 403.
Known inconsistency
B2CQuotaService.get_quota_status still returns study_plan: {locked: true, required_plan: "pro"} for free users (b2c_quota.py:375), and its docstrings still call STUDY_PLAN Pro-locked. Nothing enforces or reads it: study_plan is no longer in B2C_PRO_LOCKED_FEATURES (models/b2c_quota.py:47) and no route calls check_and_increment with it. Tracked in Study Plan.
Reference¶
| Surface | Route | Free learner sees |
|---|---|---|
| Practice Hub | ROUTES.practiceHub |
Full chapter list; rank-1 chapter practisable, the rest locked |
| Study Plan | /studyplan (ROUTES.studyPlan) |
The real plans list; one active plan allowed, generate disabled at the cap |
| Enforcement point | Free B2C | Enforced in |
|---|---|---|
| Start a practice session | rank-1 chapter only, else 402 |
_enforce_free_tier_gate |
| Generate a plan | allowed, capped at 1 active plan, else 409 |
GeneratedStudyPlanService (max_active_study_plans) |
| Read a plan or the hub | 200 |
role guard only (403 for wrong role) |
Where it lives¶
Frontend:
apps/web/src/pages/b2c/hooks.ts:useB2CProGate()returns{ isLocked, isResolving }(Practice Hub only).apps/web/src/pages/b2c/PracticeHub/index.tsx: hosts thePaywallModal, passesisLockedandonLockedClickdown.apps/web/src/pages/b2c/PracticeHub/components/PriorityMapGrid.tsx: rank-1 free, locked tiles plusonLockedClickon the rest (FIRST_UNLOCKED_RANK).apps/web/src/pages/b2c/StudyPlan/index.tsx: the plans list, no lock.apps/web/src/pages/b2c/StudyPlan/StudyPlanCreatePage/index.tsx:atCapdisables generate and shows the cap-reached caption; a409becomes a toast.apps/web/src/pages/b2c/components/PaywallModal.tsx: the upgrade prompt (Practice Hub locks, upload limits).apps/web/src/constants/navigation.ts: open B2C nav (noPLAN_UPSELL_HREF);apps/web/src/components/layouts/DashboardLayout.tsxno longer holds paywall state.
Backend:
apps/backend/src/services/practice_session/service.py:_enforce_free_tier_gate.apps/backend/src/services/b2c_quota.py:QuotaFeature.PRACTICE,is_free_b2c_user,upgrade_required_detail.apps/backend/src/services/generated_study_plan.py:FREE_MAX_ACTIVE_STUDY_PLANS,MAX_ACTIVE_STUDY_PLANS,max_active_study_plans,StudyPlanLimitExceededError.apps/backend/src/api/v1/generated_study_plans.pyandstudy_plan.py: generatePOSTreturns409at the cap; reads open to allb2c_user.apps/backend/src/api/v1/practice_hub.py: hub reads open to free and Pro alike.
Non-goals¶
- Nav-level locks. Removed in PR #1267 and not coming back. A sidebar entry that only opens a paywall makes the product feel smaller than it is.
- Pro-only Study Plan. Removed in PR #1295. The free learner gets one real plan.
Rejected alternatives¶
- Paywall on the nav item (
study_plan_pro_gateflag plusrequire_pro_plan). Free learners never saw the surface, so the upsell sold an abstraction. - Blurred Study Plan preview behind a paywall. Same problem one level in. Replaced by a real, capped plan.
Changelog¶
- 2026-07-13: corrected the Study Plan half. Plan generation is no longer Pro-only (free gets one active plan,
409at the cap, not402),LockedStudyPlanPreviewis no longer wired,PriorityChapterListis nowPriorityMapGrid. Recorded theb2c_quotapayload inconsistency.
Related¶
- Practice Hub Engine: how the ranking is computed
- Study Plan: the surface this gate wraps
- Two-tier pricing: the Free/Pro model
- Unified locks: the shared preview-to-paywall pattern across chat and downloads