diff --git a/DESIGN_SYSTEM.md b/DESIGN_SYSTEM.md index 9541162..8dd720c 100644 --- a/DESIGN_SYSTEM.md +++ b/DESIGN_SYSTEM.md @@ -374,3 +374,4 @@ PragatiVeer implements responsive SVG logo rendering based on the active theme c --- *Document compiled and verified for PragatiVeer v2 — Production Release* +az \ No newline at end of file diff --git a/DESIGN_SYSTEM.pdf b/DESIGN_SYSTEM.pdf new file mode 100644 index 0000000..9217335 Binary files /dev/null and b/DESIGN_SYSTEM.pdf differ diff --git a/Dockerfile b/Dockerfile index 7f67ef6..5e5f55b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,6 +19,10 @@ COPY . . # Set environment variables for build ENV NEXT_TELEMETRY_DISABLED=1 +# Accept API URL as build arg — defaults to Docker-internal service name +ARG NEXT_PUBLIC_API_URL=http://backend:8000 +ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} + RUN npm run build # Production image, copy all the files and run next diff --git a/backend/main.py b/backend/main.py index 8114af0..a5f618d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -105,6 +105,10 @@ class RegisterPayload(BaseModel): name: str email: str password: str + target_exam: Optional[str] = "JEE_Main" + student_class: Optional[str] = "12" + medium: Optional[str] = "English" + school: Optional[str] = None class LoginPayload(BaseModel): email: str @@ -118,19 +122,65 @@ def register(payload: RegisterPayload, session: Session = Depends(get_session)): existing = session.exec(select(models.User).where(models.User.email == payload.email)).first() if existing: raise HTTPException(status_code=400, detail="Email already registered") + + # Check if new registrations are allowed + settings = session.get(models.SystemSettings, "global") + if not settings: + settings = models.SystemSettings() + session.add(settings) + session.commit() + session.refresh(settings) + + if not settings.allow_new_registrations: + raise HTTPException(status_code=403, detail="New registrations are currently disabled.") hashed_password = bcrypt.hashpw(payload.password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') - # Creating a dummy user with default enums since the payload is simplified + # Map exam string to enum safely + exam_map = { + "JEE_Main": models.ExamEnum.JEE_MAIN, + "JEE_Advanced": models.ExamEnum.JEE_ADVANCED, + "NEET": models.ExamEnum.NEET, + "jee_main": models.ExamEnum.JEE_MAIN, + "jee_adv": models.ExamEnum.JEE_ADVANCED, + "neet": models.ExamEnum.NEET, + } + class_map = { + "11": models.ClassEnum.ELEVENTH, + "12": models.ClassEnum.TWELFTH, + "dropper": models.ClassEnum.DROPPER, + "Dropper": models.ClassEnum.DROPPER, + } + medium_map = { + "English": models.MediumEnum.ENGLISH, + "Hindi": models.MediumEnum.HINDI, + "Bilingual": models.MediumEnum.BILINGUAL, + "en": models.MediumEnum.ENGLISH, + "hi": models.MediumEnum.HINDI, + "bi": models.MediumEnum.BILINGUAL, + } + + target_exam = exam_map.get(payload.target_exam or "JEE_Main", models.ExamEnum.JEE_MAIN) + student_class = class_map.get(payload.student_class or "12", models.ClassEnum.TWELFTH) + medium = medium_map.get(payload.medium or "English", models.MediumEnum.ENGLISH) + + # Calculate trial_end based on admin settings + trial_end = None + if settings.enable_free_trial and settings.trial_duration_days > 0: + trial_end = datetime.utcnow() + timedelta(days=settings.trial_duration_days) + new_user = models.User( name=payload.name, email=payload.email, password_hash=hashed_password, - student_class=models.ClassEnum.TWELFTH, - target_exam=models.ExamEnum.JEE_MAIN, - medium=models.MediumEnum.ENGLISH, + student_class=student_class, + target_exam=target_exam, + medium=medium, + school=payload.school, city="Unknown", - state="Unknown" + state="Unknown", + trial_end=trial_end, + streak_count=0, ) session.add(new_user) @@ -138,23 +188,26 @@ def register(payload: RegisterPayload, session: Session = Depends(get_session)): session.refresh(new_user) # Send welcome email in background (non-blocking) - from fastapi.background import BackgroundTasks as BT send_welcome_email(payload.email, payload.name) - return {"success": True, "message": "User registered successfully"} + return {"success": True, "message": "User registered successfully", "trial_end": trial_end.isoformat() if trial_end else None} @app.post("/api/auth/login", tags=["Authentication"]) def login(payload: LoginPayload, session: Session = Depends(get_session)): import bcrypt # Allow demo/admin mock logic to still work if needed + # demo@pragativeer.com → Neha Verma (AI Coach demo account) + NEHA_UUID = "33333333-3333-3333-3333-333333333333" if payload.email == "demo@pragativeer.com" and payload.password == "Pv9#kL2$mQ8!vX4@": - demo_user = session.exec(select(models.User).where((models.User.email == "demo@pragativeer.com") | (models.User.email == "student@pragativeer.com"))).first() - if demo_user and demo_user.email != "demo@pragativeer.com": - demo_user.email = "demo@pragativeer.com" - session.add(demo_user) - session.commit() - token_id = str(demo_user.id) if demo_user else "b0e17378-864d-439e-8806-708dc48a891b" + import uuid as _uuid + demo_user = session.get(models.User, _uuid.UUID(NEHA_UUID)) + if not demo_user: + # Fallback: look for any demo-tagged user + demo_user = session.exec(select(models.User).where( + (models.User.email == "demo@pragativeer.com") | (models.User.name == "Neha Verma") + )).first() + token_id = str(demo_user.id) if demo_user else NEHA_UUID return {"success": True, "role": "user", "token": token_id} if payload.email == "admin@pragativeer.com" and payload.password == "admin123": return {"success": True, "role": "admin", "token": "admin"} @@ -1047,6 +1100,7 @@ def list_all_students(session: Session = Depends(get_session)): "longest_streak": u.longest_streak, "last_active": u.last_active.isoformat(), "created_at": u.created_at.isoformat(), + "trial_end": u.trial_end.isoformat() if u.trial_end else None, "total_tests": len(attempt_count), "avg_accuracy": avg_accuracy, "total_mistakes": len(mistake_count), diff --git a/docker-compose.yml b/docker-compose.yml index 69da383..ab03d5c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,11 @@ services: depends_on: postgres: condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 || exit 0"] + interval: 5s + timeout: 5s + retries: 5 networks: - pragativeer_network @@ -68,6 +73,8 @@ services: depends_on: postgres: condition: service_healthy + pgbouncer: + condition: service_healthy networks: - pragativeer_network @@ -117,11 +124,13 @@ services: build: context: . dockerfile: Dockerfile + args: + - NEXT_PUBLIC_API_URL=http://backend:8000 container_name: pragativeer_frontend ports: - "3000:3000" environment: - - NEXT_PUBLIC_API_URL=http://localhost:8000 + - NEXT_PUBLIC_API_URL=http://backend:8000 - NEXT_PUBLIC_PAYMENT_GATEWAY=razorpay depends_on: - backend diff --git a/pgbouncer.ini b/pgbouncer.ini index e7e8109..d062540 100644 --- a/pgbouncer.ini +++ b/pgbouncer.ini @@ -1,5 +1,5 @@ [databases] -pragativeer_prod = host=postgres port=5432 dbname=pragativeer_prod +pragativeer_prod = host=postgres port=5432 dbname=pragativeer_prod user=pragativeer_admin password=local_secure_password_2026 [pgbouncer] listen_addr = 0.0.0.0 @@ -10,5 +10,5 @@ auth_file = /etc/pgbouncer/userlist.txt pool_mode = transaction max_client_conn = 1000 default_pool_size = 50 -admin_users = postgres +admin_users = pgbouncer ignore_startup_parameters = extra_float_digits diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 0e4a5e8..4ab852b 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -10,7 +10,7 @@ import { ThemeToggle } from "@/components/ThemeToggle" import { TierBadge } from "@/components/TierBadge" import { StreakCounter } from "@/components/StreakCounter" import { AITutorChat } from "@/components/AI/AITutorChat" -import { useActiveUser, DEMO_USERS } from "@/hooks/useActiveUser" +import { useActiveUser, DEMO_USERS, isDemoUser } from "@/hooks/useActiveUser" import { StreakOnboardingModal } from "@/components/Dashboard/StreakOnboardingModal" import { Badge } from "@/components/ui/badge" import MaintenanceScreen from "@/components/MaintenanceScreen" @@ -20,7 +20,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) { const [dropdownOpen, setDropdownOpen] = useState(false) const dropdownRef = useRef(null) - const { userId, user, globalSettings, switchUser, logout, hasAccessTo, loading } = useActiveUser(); + const { userId, user, globalSettings, switchUser, logout, hasAccessTo, loading, trialInfo } = useActiveUser(); const [maintenance, setMaintenance] = useState(false); const [checkingMaintenance, setCheckingMaintenance] = useState(true); @@ -253,17 +253,28 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {

{user.name}

{user.email || user.phone}

+ {/* Trial info in dropdown */} + {trialInfo.isTrialActive && ( +
+ + Day {trialInfo.trialDayNumber} of {trialInfo.trialTotalDays} Trial + + {trialInfo.trialDaysRemaining}d left +
+ )}
Edit Profile - + {isDemoUser(userId) && ( + + )}
@@ -307,6 +326,13 @@ export default function DashboardLayout({ children }: { children: ReactNode }) { {targetExamLabel} Target )} + {/* Trial countdown badge — shown to trial users in top header */} + {trialInfo.isTrialActive && ( + + + Day {trialInfo.trialDayNumber}/{trialInfo.trialTotalDays} Trial · {trialInfo.trialDaysRemaining}d left + + )}
@@ -317,20 +343,22 @@ export default function DashboardLayout({ children }: { children: ReactNode }) { )} - {/* Profile switcher dropdown for testing */} -
- Demo Switcher: - -
+ {/* Demo Switcher — only visible when logged in as a known demo account */} + {isDemoUser(userId) && ( +
+ Demo: + +
+ )}
{/* */} diff --git a/src/app/page.tsx b/src/app/page.tsx index acc7681..9c6ffea 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -203,12 +203,12 @@ export default function MarketingPage() { {/* Greeting & Header */}
-

Welcome, Demo Student

+

Welcome, Neha Verma

Your adaptive NEET path is optimized. Next review in 2 hours.

- Subscription: AI Pro Plan + Subscription: AI Coach Plan
diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index 16f1a5b..6187daf 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -19,6 +19,10 @@ export default function SignupPage() { const [lastName, setLastName] = useState("") const [phone, setPhone] = useState("") const [termsAccepted, setTermsAccepted] = useState(false) + const [targetExam, setTargetExam] = useState("JEE_Main") + const [studentClass, setStudentClass] = useState("12") + const [medium, setMedium] = useState("English") + const [schoolName, setSchoolName] = useState("") // Validation temporarily relaxed: phone length check and OTP verification removed for now. // Original: firstName.trim() !== "" && lastName.trim() !== "" && email.trim() !== "" && phone.length === 10 && termsAccepted @@ -34,7 +38,15 @@ export default function SignupPage() { const res = await fetch("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: `${firstName} ${lastName}`, email, password }) + body: JSON.stringify({ + name: `${firstName} ${lastName}`, + email, + password, + target_exam: targetExam, + student_class: studentClass, + medium, + school: schoolName || undefined, + }) }); const data = await res.json(); @@ -97,13 +109,19 @@ export default function SignupPage() { -

Thanks for registering!

-

- Thank you for the registration. Our team will contact you shortly. -

+

Welcome, {firstName}! 🎉

+
+
+ + Day 1 of your Free Trial has started! +
+

+ You have full AI Coach access for your trial period. Let's build your streak starting today. +

+
@@ -208,16 +226,21 @@ export default function SignupPage() {
- setTargetExam(e.target.value)} + className="w-full bg-background border border-border rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all cursor-pointer"> + + +
- setStudentClass(e.target.value)} + className="w-full bg-background border border-border rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all cursor-pointer"> @@ -225,15 +248,23 @@ export default function SignupPage() {
- setMedium(e.target.value)} + className="w-full bg-background border border-border rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all cursor-pointer"> + + +
- + setSchoolName(e.target.value)} + placeholder="e.g. DPS R.K. Puram" + className="w-full bg-background border border-border rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"/>
)} diff --git a/src/hooks/useActiveUser.ts b/src/hooks/useActiveUser.ts index b381571..38766eb 100644 --- a/src/hooks/useActiveUser.ts +++ b/src/hooks/useActiveUser.ts @@ -30,6 +30,75 @@ export const DEMO_USERS = { AI_COACH: { id: DEFAULT_USER_ID, label: "Rahul Sharma (AI Coach)" }, }; +// IDs considered "demo" accounts — demo switcher only shows for these +export const DEMO_IDS = new Set(Object.values(DEMO_USERS).map(u => u.id)); + +/** Returns true if the userId matches a known demo account */ +export function isDemoUser(userId: string): boolean { + return DEMO_IDS.has(userId); +} + +/** Compute trial info from user profile and global settings */ +export function computeTrialInfo(user: UserProfile | null, globalSettings: any): { + isTrialActive: boolean; + trialDayNumber: number; // 1-based current day in trial + trialDaysRemaining: number; // days left (0 if expired) + trialTotalDays: number; + effectiveTier: "Free" | "Pro" | "Al_Coach"; +} { + const defaultResult = { + isTrialActive: false, + trialDayNumber: 0, + trialDaysRemaining: 0, + trialTotalDays: 0, + effectiveTier: (user?.tier ?? "Free") as "Free" | "Pro" | "Al_Coach", + }; + + if (!user || !globalSettings) return defaultResult; + + const trialDays: number = globalSettings.trial_duration_days ?? 15; + const trialEnabled: boolean = globalSettings.enable_free_trial ?? true; + + // Only apply trial logic to Free tier users (not already paid Pro/AI Coach) + if (user.tier !== "Free") { + return { ...defaultResult, effectiveTier: user.tier }; + } + + if (!trialEnabled || trialDays <= 0) { + return defaultResult; + } + + // Compute from created_at + const createdAt = user.created_at ? new Date(user.created_at) : null; + if (!createdAt) { + // If no created_at, assume trial is just starting (Day 1) + return { + isTrialActive: true, + trialDayNumber: 1, + trialDaysRemaining: trialDays, + trialTotalDays: trialDays, + effectiveTier: (globalSettings.trial_tier as "Pro" | "Al_Coach") ?? "Al_Coach", + }; + } + + const now = new Date(); + const msPerDay = 1000 * 60 * 60 * 24; + const daysSinceCreation = Math.floor((now.getTime() - createdAt.getTime()) / msPerDay); + const trialDayNumber = Math.max(1, daysSinceCreation + 1); // 1-indexed + const trialDaysRemaining = Math.max(0, trialDays - daysSinceCreation); + const isTrialActive = trialDaysRemaining > 0; + + return { + isTrialActive, + trialDayNumber: isTrialActive ? trialDayNumber : 0, + trialDaysRemaining, + trialTotalDays: trialDays, + effectiveTier: isTrialActive + ? ((globalSettings.trial_tier as "Pro" | "Al_Coach") ?? "Al_Coach") + : "Free", + }; +} + export function useActiveUser() { const [userId, setUserIdState] = useState(() => { if (typeof window !== "undefined") { @@ -154,25 +223,10 @@ export function useActiveUser() { const hasAccessTo = useCallback((feature: string): boolean => { if (!user) return false; - let tier = user.tier; - // Trial logic interception - if (globalSettings && (user.tier === 'Free' || user.id === DEMO_USERS.NEHA.id)) { - const trialDays = globalSettings.trial_duration_days !== undefined ? globalSettings.trial_duration_days : 15; - const trialEnabled = globalSettings.enable_free_trial !== undefined ? globalSettings.enable_free_trial : true; - let isTrialActive = false; - - if (trialEnabled && trialDays > 0) { - // For demo purposes, we ignore created_at and just assume trial is active if days > 0 - isTrialActive = true; - } - - if (isTrialActive) { - tier = (globalSettings.trial_tier as "Pro" | "Al_Coach") || "Al_Coach"; - } else { - tier = "Free"; - } - } + // Compute effective tier via trial logic + const { effectiveTier } = computeTrialInfo(user, globalSettings); + const tier = effectiveTier; // Core restrictions mapping based on PRD feature matrix switch (feature) { @@ -193,13 +247,14 @@ export function useActiveUser() { case "ai-tutor-doubt-resolution": case "ai-mistake-tracking": case "ai-readiness-prediction": + case "ai-tutor-chat": return tier === "Al_Coach"; default: // Free tier features: DPS (1/day), basic dashboard, basic streak tracking, leaderboard return true; } - }, [user]); + }, [user, globalSettings]); return { userId, @@ -210,6 +265,7 @@ export function useActiveUser() { switchUser, logout, hasAccessTo, + trialInfo: computeTrialInfo(user, globalSettings), refreshUser: async () => { await fetchUserProfile(userId); window.dispatchEvent(new CustomEvent('pragativeer-user-updated')); diff --git a/userlist.txt b/userlist.txt new file mode 100644 index 0000000..dbfe0ee --- /dev/null +++ b/userlist.txt @@ -0,0 +1 @@ +"pragativeer_admin" "local_secure_password_2026"