feat: add per-user trial control in admin panel (backend + UI)

This commit is contained in:
2026-09-24 12:12:37 +05:30
parent 85665a5eab
commit a6db3e7c17
2 changed files with 71 additions and 3 deletions

View File

@@ -1470,6 +1470,7 @@ class AdminUserPatch(BaseModel):
school: Optional[str] = None school: Optional[str] = None
city: Optional[str] = None city: Optional[str] = None
state: Optional[str] = None state: Optional[str] = None
trial_days: Optional[int] = None # number of trial days to grant from now (0 = remove trial)
@app.get("/api/admin/students", tags=["Admin"]) @app.get("/api/admin/students", tags=["Admin"])
def list_all_students(session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)): def list_all_students(session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
@@ -1608,7 +1609,7 @@ def get_student_detail(user_id: UUID, session: Session = Depends(get_session), c
@app.patch("/api/admin/students/{user_id}", tags=["Admin"]) @app.patch("/api/admin/students/{user_id}", tags=["Admin"])
def patch_student(user_id: UUID, payload: AdminUserPatch, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)): def patch_student(user_id: UUID, payload: AdminUserPatch, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
"""Admin update: tier, name, streak, exam, location.""" """Admin update: tier, name, streak, exam, location, trial."""
user = session.get(models.User, user_id) user = session.get(models.User, user_id)
if not user: if not user:
raise HTTPException(status_code=404, detail="Student not found") raise HTTPException(status_code=404, detail="Student not found")
@@ -1619,10 +1620,15 @@ def patch_student(user_id: UUID, payload: AdminUserPatch, session: Session = Dep
if payload.school is not None: user.school = payload.school if payload.school is not None: user.school = payload.school
if payload.city is not None: user.city = payload.city if payload.city is not None: user.city = payload.city
if payload.state is not None: user.state = payload.state if payload.state is not None: user.state = payload.state
if payload.trial_days is not None:
if payload.trial_days <= 0:
user.trial_end = None # remove trial
else:
user.trial_end = datetime.utcnow() + timedelta(days=payload.trial_days)
session.add(user) session.add(user)
session.commit() session.commit()
session.refresh(user) session.refresh(user)
return {"status": "ok", "user_id": str(user.id), "tier": user.tier.value} return {"status": "ok", "user_id": str(user.id), "tier": user.tier.value, "trial_end": user.trial_end.isoformat() if user.trial_end else None}
# --- BILLING & PAYMENTS --- # --- BILLING & PAYMENTS ---

View File

@@ -1,4 +1,4 @@
"use client" "use client"
import { fetchWithAuth } from "@/lib/apiClient"; import { fetchWithAuth } from "@/lib/apiClient";
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
@@ -45,6 +45,8 @@ export default function StudentKundliPage() {
const [editTier, setEditTier] = useState("") const [editTier, setEditTier] = useState("")
const [editStreak, setEditStreak] = useState(0) const [editStreak, setEditStreak] = useState(0)
const [editExam, setEditExam] = useState("") const [editExam, setEditExam] = useState("")
const [trialDays, setTrialDays] = useState(15)
const [grantingTrial, setGrantingTrial] = useState(false)
useEffect(() => { useEffect(() => {
fetchWithAuth(`/api/admin/students/${studentId}`) fetchWithAuth(`/api/admin/students/${studentId}`)
@@ -370,6 +372,66 @@ export default function StudentKundliPage() {
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Trial Control Card */}
<Card className="border-amber-500/30 bg-amber-500/5 shadow-sm">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base text-amber-500">
<Flame size={16} className="text-amber-500" /> Trial Access Control
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-end gap-4">
<div className="space-y-1.5">
<label className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Current Trial End</label>
<p className="text-sm font-semibold">
{data.trial_end ? (
<span className={new Date(data.trial_end) > new Date() ? "text-green-500" : "text-red-500"}>
{formatDate(data.trial_end)} ({new Date(data.trial_end) > new Date() ? "Active" : "Expired"})
</span>
) : (
<span className="text-muted-foreground">No trial set</span>
)}
</p>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Grant Trial (days)</label>
<div className="flex gap-2">
<input
type="number"
min={0}
max={365}
value={trialDays}
onChange={e => setTrialDays(Number(e.target.value))}
className="w-24 bg-background border border-border rounded-lg px-3 py-2 text-sm font-bold focus:outline-none focus:ring-2 focus:ring-amber-500"
/>
<Button
onClick={async () => {
setGrantingTrial(true)
try {
const res = await fetchWithAuth(`/api/admin/students/${studentId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ trial_days: trialDays }),
})
if (!res.ok) throw new Error("Failed")
const fresh = await fetchWithAuth(`/api/admin/students/${studentId}`).then(r => r.json())
setData(fresh)
alert(`Trial ${trialDays > 0 ? `granted for ${trialDays} days` : 'removed'} successfully!`)
} catch { alert("Failed to update trial") }
finally { setGrantingTrial(false) }
}}
disabled={grantingTrial}
className="bg-amber-500 text-black hover:bg-amber-400 font-bold gap-2"
>
{grantingTrial ? <RefreshCw size={14} className="animate-spin" /> : <CheckCircle2 size={14} />}
{trialDays > 0 ? `Grant ${trialDays}d Trial` : "Remove Trial"}
</Button>
</div>
<p className="text-xs text-muted-foreground">Set 0 to remove trial. Trial starts from now.</p>
</div>
</div>
</CardContent>
</Card>
</div> </div>
) )
} }