Skip to content

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, /studyplan Source 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_gate runs 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 via StudyPlanService.top_chapter_id). Any other chapter raises QuotaUpgradeRequiredError, which the route maps to HTTP 402. Pro/paid B2C and non-B2C roles return early, unaffected.
  • Plan generation. Not Pro-gated. The generate POST creates 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 HTTP 409 Conflict (study_plan.py:275-277, generated_study_plans.py:209-211). apps/backend/src/api/v1/study_plan.py:203 states 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 the PaywallModal, passes isLocked and onLockedClick down.
  • apps/web/src/pages/b2c/PracticeHub/components/PriorityMapGrid.tsx: rank-1 free, locked tiles plus onLockedClick on 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: atCap disables generate and shows the cap-reached caption; a 409 becomes 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 (no PLAN_UPSELL_HREF); apps/web/src/components/layouts/DashboardLayout.tsx no 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.py and study_plan.py: generate POST returns 409 at the cap; reads open to all b2c_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

  1. Paywall on the nav item (study_plan_pro_gate flag plus require_pro_plan). Free learners never saw the surface, so the upsell sold an abstraction.
  2. 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, 409 at the cap, not 402), LockedStudyPlanPreview is no longer wired, PriorityChapterList is now PriorityMapGrid. Recorded the b2c_quota payload inconsistency.