feat: add per-user trial control in admin panel (backend + UI)
This commit is contained in:
@@ -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 ---
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user