Skip to content

Commit 9199bce

Browse files
committed
Working subscription system: QR payment + admin approval + ntfy notifications
- User scans UPI QR, pays, submits transaction ID - Backend stores upgrade_requests, sends push notification via ntfy.sh - Admin panel shows pending requests with approve/reject buttons - Approval grants 30-day PRO subscription - Admin gets instant phone notification (free via ntfy.sh) - upgrade_requests table with indexes - Admin 'Requests' button in header bar
1 parent e9e085f commit 9199bce

6 files changed

Lines changed: 431 additions & 29 deletions

File tree

apps/web/src/components/IDE.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { AuthModal } from "@/components/auth-modal";
3636
import { DonationModal } from "@/components/donation-modal";
3737
import { SubscriptionModal } from "@/components/subscription-modal";
3838
import { SettingsModal } from "@/components/settings-modal";
39+
import { AdminPanel } from "@/components/admin-panel";
3940
import { API_URL, apiFetch, clearAuthToken, demoFiles, geminiStudioUrl, getAuthToken } from "@/lib/config";
4041

4142
type FileMap = Record<string, string>;
@@ -75,6 +76,7 @@ export default function IDE() {
7576
const [showDonation, setShowDonation] = useState(false);
7677
const [showSubscription, setShowSubscription] = useState(false);
7778
const [showSettings, setShowSettings] = useState(false);
79+
const [showAdmin, setShowAdmin] = useState(false);
7880
const [toast, setToast] = useState<string | null>(null);
7981

8082
// Per-user workspace ID
@@ -371,6 +373,11 @@ export default function IDE() {
371373
) : (
372374
<button onClick={() => setShowSubscription(true)} className="rounded bg-amberForge/15 px-2 py-1 text-amberForge hover:bg-amberForge/25">Upgrade</button>
373375
)}
376+
{user.role === "admin" && (
377+
<button onClick={() => setShowAdmin(true)} className="rounded border border-amberForge/30 bg-amberForge/10 px-2 py-1 text-xs text-amberForge hover:bg-amberForge/20" title="Admin Panel">
378+
Requests
379+
</button>
380+
)}
374381
<button onClick={handleLogout} className="rounded border border-slate-700 p-1.5 text-slate-400 hover:text-white" title="Logout">
375382
<LogOut className="h-3.5 w-3.5" />
376383
</button>
@@ -554,6 +561,7 @@ export default function IDE() {
554561
<SettingsModal open={showSettings} onClose={() => setShowSettings(false)} onSettingsChange={(s) => {
555562
if (s.editor_font_size) setEditorFontSize(s.editor_font_size);
556563
}} />
564+
{user?.role === "admin" && <AdminPanel open={showAdmin} onClose={() => setShowAdmin(false)} />}
557565
</main>
558566
);
559567
}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
"use client";
2+
3+
import { AnimatePresence, motion } from "framer-motion";
4+
import { Shield, X, CheckCircle2, XCircle, Clock, Loader2 } from "lucide-react";
5+
import { useEffect, useState } from "react";
6+
import { apiFetch } from "@/lib/config";
7+
8+
type UpgradeRequest = {
9+
id: string;
10+
user_id: string;
11+
username: string;
12+
transaction_id: string;
13+
amount: number;
14+
status: string;
15+
admin_note: string;
16+
created_at: string;
17+
resolved_at: string | null;
18+
};
19+
20+
type Props = { open: boolean; onClose: () => void };
21+
22+
export function AdminPanel({ open, onClose }: Props) {
23+
const [requests, setRequests] = useState<UpgradeRequest[]>([]);
24+
const [loading, setLoading] = useState(true);
25+
const [actionLoading, setActionLoading] = useState<string | null>(null);
26+
27+
useEffect(() => {
28+
if (!open) return;
29+
loadRequests();
30+
}, [open]);
31+
32+
async function loadRequests() {
33+
setLoading(true);
34+
try {
35+
const res = await apiFetch("/api/admin/upgrade-requests");
36+
const data = await res.json();
37+
if (res.ok) setRequests(data.requests || []);
38+
} catch { /* offline */ }
39+
setLoading(false);
40+
}
41+
42+
async function handleAction(id: string, action: "approve" | "reject") {
43+
setActionLoading(id);
44+
try {
45+
const res = await apiFetch(`/api/admin/upgrade-requests/${id}/${action}`, { method: "POST" });
46+
if (res.ok) {
47+
await loadRequests();
48+
}
49+
} catch { /* offline */ }
50+
setActionLoading(null);
51+
}
52+
53+
if (!open) return null;
54+
55+
const pending = requests.filter((r) => r.status === "pending");
56+
const resolved = requests.filter((r) => r.status !== "pending");
57+
58+
return (
59+
<AnimatePresence>
60+
<motion.div
61+
initial={{ opacity: 0 }}
62+
animate={{ opacity: 1 }}
63+
exit={{ opacity: 0 }}
64+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
65+
onClick={onClose}
66+
>
67+
<motion.div
68+
initial={{ scale: 0.9, opacity: 0 }}
69+
animate={{ scale: 1, opacity: 1 }}
70+
exit={{ scale: 0.9, opacity: 0 }}
71+
className="glass mx-4 w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-xl p-6"
72+
onClick={(e) => e.stopPropagation()}
73+
role="dialog"
74+
aria-modal="true"
75+
>
76+
<div className="flex items-center justify-between">
77+
<h3 className="flex items-center gap-2 text-lg font-bold text-white">
78+
<Shield className="h-5 w-5 text-amberForge" /> Admin: Upgrade Requests
79+
</h3>
80+
<button onClick={onClose} className="text-slate-400 hover:text-white">
81+
<X className="h-5 w-5" />
82+
</button>
83+
</div>
84+
85+
{loading ? (
86+
<div className="flex items-center justify-center py-12">
87+
<Loader2 className="h-6 w-6 animate-spin text-cyanForge" />
88+
</div>
89+
) : (
90+
<>
91+
{/* Pending Requests */}
92+
<div className="mt-4">
93+
<h4 className="flex items-center gap-2 text-sm font-medium text-amberForge">
94+
<Clock className="h-4 w-4" /> Pending ({pending.length})
95+
</h4>
96+
{pending.length === 0 ? (
97+
<p className="mt-2 text-xs text-slate-500">No pending requests.</p>
98+
) : (
99+
<div className="mt-2 space-y-2">
100+
{pending.map((req) => (
101+
<div key={req.id} className="rounded-lg border border-amberForge/30 bg-amberForge/5 p-3">
102+
<div className="flex items-center justify-between">
103+
<div>
104+
<p className="text-sm font-medium text-white">{req.username}</p>
105+
<p className="text-xs text-slate-400">
106+
Txn: <span className="font-mono text-cyanForge">{req.transaction_id}</span>
107+
</p>
108+
<p className="text-xs text-slate-500">
109+
{req.amount}{new Date(req.created_at).toLocaleString()}
110+
</p>
111+
</div>
112+
<div className="flex gap-2">
113+
<button
114+
onClick={() => handleAction(req.id, "approve")}
115+
disabled={actionLoading === req.id}
116+
className="flex items-center gap-1 rounded bg-mintForge/20 px-3 py-1.5 text-xs font-medium text-mintForge hover:bg-mintForge/30 disabled:opacity-50"
117+
>
118+
{actionLoading === req.id ? (
119+
<Loader2 className="h-3 w-3 animate-spin" />
120+
) : (
121+
<CheckCircle2 className="h-3 w-3" />
122+
)}
123+
Approve
124+
</button>
125+
<button
126+
onClick={() => handleAction(req.id, "reject")}
127+
disabled={actionLoading === req.id}
128+
className="flex items-center gap-1 rounded bg-red-500/20 px-3 py-1.5 text-xs font-medium text-red-400 hover:bg-red-500/30 disabled:opacity-50"
129+
>
130+
<XCircle className="h-3 w-3" /> Reject
131+
</button>
132+
</div>
133+
</div>
134+
</div>
135+
))}
136+
</div>
137+
)}
138+
</div>
139+
140+
{/* Resolved Requests */}
141+
{resolved.length > 0 && (
142+
<div className="mt-4">
143+
<h4 className="text-sm font-medium text-slate-400">History ({resolved.length})</h4>
144+
<div className="mt-2 space-y-1">
145+
{resolved.slice(0, 20).map((req) => (
146+
<div key={req.id} className="flex items-center justify-between rounded-lg border border-slate-700/50 bg-slate-800/30 px-3 py-2">
147+
<div className="flex items-center gap-2">
148+
{req.status === "approved" ? (
149+
<CheckCircle2 className="h-3.5 w-3.5 text-mintForge" />
150+
) : (
151+
<XCircle className="h-3.5 w-3.5 text-red-400" />
152+
)}
153+
<span className="text-xs text-white">{req.username}</span>
154+
<span className="font-mono text-[10px] text-slate-500">{req.transaction_id}</span>
155+
</div>
156+
<span className="text-[10px] text-slate-500">
157+
{req.resolved_at ? new Date(req.resolved_at).toLocaleDateString() : ""}
158+
</span>
159+
</div>
160+
))}
161+
</div>
162+
</div>
163+
)}
164+
</>
165+
)}
166+
</motion.div>
167+
</motion.div>
168+
</AnimatePresence>
169+
);
170+
}

0 commit comments

Comments
 (0)