feat: add Plivo SMS admin panel with rate limits
This commit is contained in:
+236
-19
@@ -7,9 +7,11 @@ import {
|
||||
Film,
|
||||
Gift,
|
||||
List,
|
||||
MessageSquare,
|
||||
RefreshCcw,
|
||||
Save,
|
||||
Search,
|
||||
Send,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
TrendingUp,
|
||||
@@ -53,6 +55,8 @@ interface User {
|
||||
isAdmin: boolean;
|
||||
totalEarned: number;
|
||||
totalSpent: number;
|
||||
phoneNumber?: string | null;
|
||||
smsOptOut?: boolean;
|
||||
}
|
||||
|
||||
interface SystemSettings {
|
||||
@@ -85,6 +89,11 @@ export default function AdminPage() {
|
||||
const [kofiPayments, setKofiPayments] = useState<KofiPayment[]>([]);
|
||||
const [requests, setRequests] = useState<any[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [smsLogs, setSmsLogs] = useState<any[]>([]);
|
||||
const [smsMessage, setSmsMessage] = useState("");
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
|
||||
const [smsLoading, setSmsLoading] = useState(false);
|
||||
const [phoneEdits, setPhoneEdits] = useState<Record<string, string>>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSavingSettings, setIsSavingSettings] = useState(false);
|
||||
const [rewardAmount, setRewardAmount] = useState<Record<string, string>>({});
|
||||
@@ -99,19 +108,21 @@ export default function AdminPage() {
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [analyticsRes, usersRes, settingsRes, kofiRes, requestsRes] =
|
||||
const [analyticsRes, usersRes, settingsRes, kofiRes, requestsRes, smsRes] =
|
||||
await Promise.all([
|
||||
adminApi.getAnalytics(),
|
||||
adminApi.getUsers(),
|
||||
adminApi.getSettings(),
|
||||
adminApi.getKofiPayments(),
|
||||
adminApi.getAllRequests(),
|
||||
adminApi.getSmsLogs(),
|
||||
]);
|
||||
setAnalytics(analyticsRes.data);
|
||||
setUsers(usersRes.data.users || []);
|
||||
setSettings(settingsRes.data);
|
||||
setKofiPayments(kofiRes.data.payments || []);
|
||||
setRequests(requestsRes.data.requests || []);
|
||||
setSmsLogs(smsRes.data.logs || []);
|
||||
} catch (err) {
|
||||
console.error("Admin load error:", err);
|
||||
toast.error("Failed to load coop secrets");
|
||||
@@ -165,6 +176,54 @@ export default function AdminPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const updateUserPhone = async (userId: string) => {
|
||||
const phone = phoneEdits[userId];
|
||||
if (phone === undefined) return;
|
||||
try {
|
||||
await adminApi.updateUserPhone(userId, { phoneNumber: phone || undefined });
|
||||
toast.success("Phone number updated!");
|
||||
loadData();
|
||||
setPhoneEdits((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[userId];
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
toast.error("Failed to update phone");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendSms = async () => {
|
||||
if (!smsMessage.trim()) {
|
||||
toast.error("Message cannot be empty");
|
||||
return;
|
||||
}
|
||||
if (selectedUserIds.length === 0) {
|
||||
toast.error("Select at least one chicken");
|
||||
return;
|
||||
}
|
||||
setSmsLoading(true);
|
||||
try {
|
||||
const res = await adminApi.sendSms({
|
||||
userIds: selectedUserIds,
|
||||
message: smsMessage.trim(),
|
||||
});
|
||||
const { sent, failed, results } = res.data;
|
||||
toast.success(`Sent ${sent} squawks, ${failed} failed`);
|
||||
if (failed > 0 && results) {
|
||||
const firstFail = results.find((r: any) => !r.success);
|
||||
if (firstFail) toast.error(firstFail.error);
|
||||
}
|
||||
setSmsMessage("");
|
||||
setSelectedUserIds([]);
|
||||
loadData();
|
||||
} catch {
|
||||
toast.error("Failed to send squawks");
|
||||
} finally {
|
||||
setSmsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter((u) =>
|
||||
u.plexUsername?.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
@@ -207,7 +266,7 @@ export default function AdminPage() {
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="flock" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-5 gap-2 rounded-full border-4 border-foreground bg-muted/30 p-2 mb-8 h-auto">
|
||||
<TabsList className="grid w-full grid-cols-6 gap-2 rounded-full border-4 border-foreground bg-muted/30 p-2 mb-8 h-auto">
|
||||
<TabsTrigger
|
||||
value="flock"
|
||||
className="rounded-full py-3 font-black uppercase data-[state=active]:bg-primary data-[state=active]:text-primary-foreground transition-all"
|
||||
@@ -226,12 +285,18 @@ export default function AdminPage() {
|
||||
>
|
||||
<Coffee className="mr-2 h-4 w-4" /> DONATIONS
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="requests"
|
||||
className="rounded-full py-3 font-black uppercase data-[state=active]:bg-primary data-[state=active]:text-primary-foreground transition-all"
|
||||
>
|
||||
<Film className="mr-2 h-4 w-4" /> REQUESTS
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="requests"
|
||||
className="rounded-full py-3 font-black uppercase data-[state=active]:bg-primary data-[state=active]:text-primary-foreground transition-all"
|
||||
>
|
||||
<Film className="mr-2 h-4 w-4" /> REQUESTS
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="squawk"
|
||||
className="rounded-full py-3 font-black uppercase data-[state=active]:bg-primary data-[state=active]:text-primary-foreground transition-all"
|
||||
>
|
||||
<MessageSquare className="mr-2 h-4 w-4" /> SQUAWK BOX
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="stats"
|
||||
className="rounded-full py-3 font-black uppercase data-[state=active]:bg-primary data-[state=active]:text-primary-foreground transition-all"
|
||||
@@ -281,6 +346,33 @@ export default function AdminPage() {
|
||||
Nest Egg: {formatNumber(u.totalEarned - u.totalSpent)}{" "}
|
||||
$COOP
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input
|
||||
placeholder="Phone #"
|
||||
value={
|
||||
phoneEdits[u.id] !== undefined
|
||||
? phoneEdits[u.id]
|
||||
: u.phoneNumber || ""
|
||||
}
|
||||
onChange={(e) =>
|
||||
setPhoneEdits({
|
||||
...phoneEdits,
|
||||
[u.id]: e.target.value,
|
||||
})
|
||||
}
|
||||
className="h-8 w-40 rounded-full border-2 border-foreground text-xs font-bold"
|
||||
/>
|
||||
{phoneEdits[u.id] !== undefined &&
|
||||
phoneEdits[u.id] !== (u.phoneNumber || "") && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 rounded-full font-black px-3"
|
||||
onClick={() => updateUserPhone(u.id)}
|
||||
>
|
||||
SAVE
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -464,14 +556,20 @@ export default function AdminPage() {
|
||||
<TabsContent value="donations" className="focus-visible:outline-none">
|
||||
<div className="rounded-[2.5rem] border-4 border-foreground bg-card p-6 shadow-[10px_10px_0px_0px_rgba(0,0,0,0.05)]">
|
||||
<div className="mb-6">
|
||||
<h2 className="font-display text-3xl uppercase italic">Ko-fi Donations</h2>
|
||||
<p className="text-sm font-bold text-muted-foreground">Incoming feed purchases</p>
|
||||
<h2 className="font-display text-3xl uppercase italic">
|
||||
Ko-fi Donations
|
||||
</h2>
|
||||
<p className="text-sm font-bold text-muted-foreground">
|
||||
Incoming feed purchases
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{kofiPayments.length === 0 ? (
|
||||
<div className="text-center py-10 opacity-40">
|
||||
<div className="text-6xl mb-4">☕</div>
|
||||
<div className="font-black uppercase tracking-widest text-sm">No donations yet. The well is dry.</div>
|
||||
<div className="font-black uppercase tracking-widest text-sm">
|
||||
No donations yet. The well is dry.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
@@ -482,8 +580,12 @@ export default function AdminPage() {
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-black text-lg">{p.fromName || "Anonymous"}</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[10px] font-black uppercase ${p.status === "CREDITED" ? "bg-primary text-white" : "bg-destructive/20 text-destructive"}`}>
|
||||
<span className="font-black text-lg">
|
||||
{p.fromName || "Anonymous"}
|
||||
</span>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-black uppercase ${p.status === "CREDITED" ? "bg-primary text-white" : "bg-destructive/20 text-destructive"}`}
|
||||
>
|
||||
{p.status}
|
||||
</span>
|
||||
</div>
|
||||
@@ -514,17 +616,20 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
<TabsContent value="requests" className="focus-visible:outline-none">
|
||||
<div className="rounded-[2.5rem] border-4 border-foreground bg-card p-6 shadow-[10px_10px_0px_0px_rgba(0,0,0,0.05)]">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="font-display text-3xl uppercase italic">The Feed Queue</h2>
|
||||
<h2 className="font-display text-3xl uppercase italic">
|
||||
The Feed Queue
|
||||
</h2>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
toast.info("Checking with Overseer...");
|
||||
try {
|
||||
const res = await adminApi.syncAllRequests();
|
||||
toast.success(`Synced ${res.data.updated?.length || 0} requests`);
|
||||
toast.success(
|
||||
`Synced ${res.data.updated?.length || 0} requests`,
|
||||
);
|
||||
loadData();
|
||||
} catch {
|
||||
toast.error("Sync failed");
|
||||
@@ -540,7 +645,9 @@ export default function AdminPage() {
|
||||
{requests.length === 0 ? (
|
||||
<div className="text-center py-10 opacity-40">
|
||||
<div className="text-6xl mb-4">📭</div>
|
||||
<div className="font-black uppercase tracking-widest text-sm">No requests in the queue.</div>
|
||||
<div className="font-black uppercase tracking-widest text-sm">
|
||||
No requests in the queue.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
@@ -552,12 +659,16 @@ export default function AdminPage() {
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-black text-lg">{r.title}</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[10px] font-black uppercase ${r.status === "APPROVED" ? "bg-primary text-white" : r.status === "DECLINED" ? "bg-destructive text-white" : "bg-secondary text-secondary-foreground"}`}>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-black uppercase ${r.status === "APPROVED" ? "bg-primary text-white" : r.status === "DECLINED" ? "bg-destructive text-white" : "bg-secondary text-secondary-foreground"}`}
|
||||
>
|
||||
{r.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm font-bold text-muted-foreground mt-1">
|
||||
{r.user?.plexUsername || "Unknown"} • {r.mediaType === "movie" ? "Movie" : "TV"} • {formatNumber(r.creditsCost)} $COOP
|
||||
{r.user?.plexUsername || "Unknown"} •{" "}
|
||||
{r.mediaType === "movie" ? "Movie" : "TV"} •{" "}
|
||||
{formatNumber(r.creditsCost)} $COOP
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
@@ -571,6 +682,112 @@ export default function AdminPage() {
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="squawk" className="focus-visible:outline-none space-y-6">
|
||||
<div className="rounded-[2.5rem] border-4 border-foreground bg-card p-6 shadow-[10px_10px_0px_0px_rgba(0,0,0,0.05)]">
|
||||
<div className="mb-6">
|
||||
<h2 className="font-display text-3xl uppercase italic">Squawk Box</h2>
|
||||
<p className="text-sm font-bold text-muted-foreground">Send clucks to the flock</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border-4 border-foreground bg-accent/5 p-4">
|
||||
<h3 className="font-black uppercase text-sm mb-3">Select Chickens ({selectedUserIds.length})</h3>
|
||||
<div className="flex flex-wrap gap-2 max-h-40 overflow-y-auto">
|
||||
{users.filter(u => u.phoneNumber).map((u) => (
|
||||
<label
|
||||
key={u.id}
|
||||
className={`flex items-center gap-2 rounded-full border-2 px-3 py-1.5 cursor-pointer transition-all ${selectedUserIds.includes(u.id) ? 'border-primary bg-primary/10' : 'border-foreground/20'}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedUserIds.includes(u.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedUserIds([...selectedUserIds, u.id]);
|
||||
} else {
|
||||
setSelectedUserIds(selectedUserIds.filter(id => id !== u.id));
|
||||
}
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-xs font-black">{u.plexUsername}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{u.phoneNumber}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{users.filter(u => u.phoneNumber).length === 0 && (
|
||||
<div className="text-sm font-bold text-muted-foreground">No chickens have phone numbers yet.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-black uppercase text-muted-foreground">Message</label>
|
||||
<textarea
|
||||
value={smsMessage}
|
||||
onChange={(e) => setSmsMessage(e.target.value)}
|
||||
placeholder="Cluck cluck..."
|
||||
maxLength={1600}
|
||||
className="w-full min-h-[120px] rounded-2xl border-4 border-foreground bg-background p-4 font-bold focus-visible:ring-primary focus-visible:ring-2 focus-visible:outline-none resize-y"
|
||||
/>
|
||||
<div className="text-right text-xs font-bold text-muted-foreground">
|
||||
{smsMessage.length}/1600
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSendSms}
|
||||
disabled={smsLoading || selectedUserIds.length === 0 || !smsMessage.trim()}
|
||||
className="w-full h-14 rounded-full font-black text-xl shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-none transition-all border-4 border-foreground"
|
||||
>
|
||||
{smsLoading ? "SENDING..." : "SEND SQUAWK"}
|
||||
<Send className="ml-2 h-6 w-6" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[2.5rem] border-4 border-foreground bg-card p-6 shadow-[10px_10px_0px_0px_rgba(0,0,0,0.05)]">
|
||||
<div className="mb-6">
|
||||
<h2 className="font-display text-3xl uppercase italic">Squawk History</h2>
|
||||
<p className="text-sm font-bold text-muted-foreground">Previously sent messages</p>
|
||||
</div>
|
||||
|
||||
{smsLogs.length === 0 ? (
|
||||
<div className="text-center py-10 opacity-40">
|
||||
<div className="text-6xl mb-4">📵</div>
|
||||
<div className="font-black uppercase tracking-widest text-sm">No squawks sent yet.</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{smsLogs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 rounded-2xl border-4 border-foreground bg-accent/5 p-4"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-black text-lg">{log.user?.plexUsername || log.phoneNumber}</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[10px] font-black uppercase ${log.status === "SENT" ? "bg-primary text-white" : "bg-destructive text-white"}`}>
|
||||
{log.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm font-bold text-muted-foreground mt-1 line-clamp-2">
|
||||
{log.message}
|
||||
</div>
|
||||
{log.error && (
|
||||
<div className="text-xs font-bold text-destructive mt-0.5">{log.error}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] font-bold text-muted-foreground uppercase">
|
||||
{new Date(log.sentAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="stats" className="focus-visible:outline-none">
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-5">
|
||||
<div className="rounded-3xl border-4 border-foreground bg-card p-6 shadow-[6px_6px_0px_0px_rgba(0,0,0,1)]">
|
||||
|
||||
@@ -97,6 +97,16 @@ export const adminApi = {
|
||||
getKofiPayments: () => api.get("/admin/kofi-payments"),
|
||||
getAllRequests: () => api.get("/admin/requests"),
|
||||
syncAllRequests: () => api.post("/admin/sync-requests"),
|
||||
updateUserPhone: (
|
||||
id: string,
|
||||
data: { phoneNumber?: string; smsOptOut?: boolean },
|
||||
) => api.put(`/admin/users/${id}/phone`, data),
|
||||
sendSms: (data: {
|
||||
userIds?: string[];
|
||||
phoneNumbers?: string[];
|
||||
message: string;
|
||||
}) => api.post("/admin/sms/send", data),
|
||||
getSmsLogs: () => api.get("/admin/sms/logs"),
|
||||
};
|
||||
|
||||
// Overseer API
|
||||
|
||||
Reference in New Issue
Block a user