Skip to content

Commit 37f3ebf

Browse files
authored
feat: update email templates, enhance admin pages, and improve crisis map (#102)
- Refactor and improve email templates (Alert, Crisis, Weekly Digest, Welcome, etc.) - Add logo component for email templates - Enhance admin pages with improved functionality - Update crisis map component with better handling - Update admin API client with new methods - Improve dashboard and onboarding flows - Update various services and routers - Update deployment workflow and Docker configurations
1 parent ea63903 commit 37f3ebf

38 files changed

+2776
-1441
lines changed

.github/workflows/deploy-production.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,9 +186,9 @@ jobs:
186186
echo "Version: v${{ needs.version.outputs.current_version }} → v${{ needs.version.outputs.version }}"
187187
echo "Type: ${{ needs.version.outputs.bump_type }} release"
188188
echo "Commit: ${{ needs.version.outputs.commit_sha }}"
189-
echo "Backend: https://api.private.bluerelief.app"
190-
echo "Frontend: https://platform.private.bluerelief.app"
191-
echo "Email Service: https://email-api.private.bluerelief.app"
189+
echo "Backend: https://api.bluerelief.app"
190+
echo "Frontend: https://bluerelief.app"
191+
echo "Email Service: https://email-api.bluerelief.app"
192192
echo ""
193193
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
194194

client/app/admin/page.tsx

Lines changed: 290 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,33 @@ import { Badge } from "@/components/ui/badge";
99
import { Input } from "@/components/ui/input";
1010
import { Skeleton } from "@/components/ui/skeleton";
1111
import { ThemeSwitcher } from "@/components/theme-switcher";
12-
import { Shield, LogOut, Users, Settings, Activity, Clock, AlertTriangle, TrendingUp, CheckCircle2, XCircle, Search, Wrench, MapPin } from "lucide-react";
12+
import { Shield, LogOut, Users, Settings, Activity, Clock, AlertTriangle, TrendingUp, CheckCircle2, XCircle, Search, Wrench, MapPin, Bell, Loader2 } from "lucide-react";
13+
import {
14+
Dialog,
15+
DialogContent,
16+
DialogDescription,
17+
DialogFooter,
18+
DialogHeader,
19+
DialogTitle,
20+
} from "@/components/ui/dialog";
21+
import {
22+
Select,
23+
SelectContent,
24+
SelectItem,
25+
SelectTrigger,
26+
SelectValue,
27+
} from "@/components/ui/select";
28+
import { Label } from "@/components/ui/label";
1329
import {
1430
getAdminStats,
1531
getRecentCrises,
1632
getRecentUsers,
33+
listUsers,
34+
triggerTestAlert,
1735
type AdminStats,
1836
type RecentCrisis,
19-
type RecentUser
37+
type RecentUser,
38+
type User
2039
} from "@/lib/admin-api-client";
2140
import { formatActivityTime } from "@/lib/utils";
2241
import { LoadingSpinner } from "@/components/loading-spinner";
@@ -37,8 +56,67 @@ export default function AdminDashboard() {
3756
const [recentCrises, setRecentCrises] = useState<RecentCrisis[]>([]);
3857
const [recentUsers, setRecentUsers] = useState<RecentUser[]>([]);
3958
const [searchQuery, setSearchQuery] = useState("");
59+
const [alertDialogOpen, setAlertDialogOpen] = useState(false);
60+
const [sendingAlert, setSendingAlert] = useState(false);
61+
const [allUsers, setAllUsers] = useState<User[]>([]);
62+
const [selectedUserId, setSelectedUserId] = useState<string>("");
63+
const [alertForm, setAlertForm] = useState({
64+
disaster_type: "earthquake",
65+
severity: 4,
66+
description: "",
67+
});
68+
const [alertSuccess, setAlertSuccess] = useState<string | null>(null);
69+
const [alertError, setAlertError] = useState<string | null>(null);
4070
const router = useRouter();
4171

72+
const DISASTER_TYPES = [
73+
{ value: "earthquake", label: "Earthquake" },
74+
{ value: "flood", label: "Flood" },
75+
{ value: "wildfire", label: "Wildfire" },
76+
{ value: "hurricane", label: "Hurricane" },
77+
{ value: "tornado", label: "Tornado" },
78+
{ value: "tsunami", label: "Tsunami" },
79+
{ value: "volcano", label: "Volcanic Activity" },
80+
];
81+
82+
const RANDOM_DESCRIPTIONS: Record<string, string[]> = {
83+
earthquake: [
84+
"Seismic activity detected. Residents advised to take shelter.",
85+
"Earthquake warning issued. Secure loose objects and stay away from windows.",
86+
"Ground tremors reported. Emergency services on standby.",
87+
],
88+
flood: [
89+
"Flash flood warning in effect. Move to higher ground immediately.",
90+
"Rising water levels detected. Evacuations may be necessary.",
91+
"Heavy rainfall causing flooding in low-lying areas.",
92+
],
93+
wildfire: [
94+
"Wildfire spreading rapidly. Evacuation orders in effect.",
95+
"Brush fire reported. Air quality advisory issued.",
96+
"Fire danger extreme. Avoid outdoor burning.",
97+
],
98+
hurricane: [
99+
"Hurricane approaching. Secure property and prepare emergency supplies.",
100+
"Tropical storm intensifying. Coastal areas should prepare for impact.",
101+
"Hurricane warning issued. Evacuate if in flood-prone areas.",
102+
],
103+
tornado: [
104+
"Tornado warning issued. Seek shelter immediately in interior room.",
105+
"Severe thunderstorm with tornado potential. Stay alert.",
106+
"Funnel cloud spotted. Take cover now.",
107+
],
108+
tsunami: [
109+
"Tsunami warning. Move to high ground immediately.",
110+
"Coastal evacuation ordered due to tsunami threat.",
111+
"Seismic event may trigger tsunami. Stay away from beaches.",
112+
],
113+
volcano: [
114+
"Volcanic activity increasing. Ash fall possible.",
115+
"Eruption imminent. Evacuate danger zone immediately.",
116+
"Volcanic alert level raised. Monitor official channels.",
117+
],
118+
};
119+
42120
useEffect(() => {
43121
const checkAuth = async () => {
44122
const token = localStorage.getItem('admin_token') || sessionStorage.getItem('admin_token');
@@ -124,6 +202,74 @@ export default function AdminDashboard() {
124202
router.push('/admin/login');
125203
};
126204

205+
const openAlertDialog = async () => {
206+
setAlertDialogOpen(true);
207+
setAlertError(null);
208+
setAlertSuccess(null);
209+
210+
try {
211+
const response = await listUsers({ page_size: 100 });
212+
setAllUsers(response.users);
213+
if (response.users.length > 0) {
214+
setSelectedUserId(response.users[0].id);
215+
}
216+
} catch (err) {
217+
console.error("Failed to fetch users:", err);
218+
}
219+
220+
const randomType = DISASTER_TYPES[Math.floor(Math.random() * DISASTER_TYPES.length)].value;
221+
const descriptions = RANDOM_DESCRIPTIONS[randomType];
222+
const randomDesc = descriptions[Math.floor(Math.random() * descriptions.length)];
223+
224+
setAlertForm({
225+
disaster_type: randomType,
226+
severity: Math.floor(Math.random() * 2) + 4,
227+
description: randomDesc,
228+
});
229+
};
230+
231+
const handleSendTestAlert = async () => {
232+
if (!selectedUserId) {
233+
setAlertError("Please select a user");
234+
return;
235+
}
236+
237+
setSendingAlert(true);
238+
setAlertError(null);
239+
240+
try {
241+
const result = await triggerTestAlert({
242+
user_id: selectedUserId,
243+
disaster_type: alertForm.disaster_type,
244+
severity: alertForm.severity,
245+
description: alertForm.description,
246+
send_email: true,
247+
});
248+
249+
setAlertSuccess(`Alert sent to ${result.user_email} at ${result.location}`);
250+
setTimeout(() => {
251+
setAlertDialogOpen(false);
252+
setAlertSuccess(null);
253+
}, 2000);
254+
} catch (err) {
255+
setAlertError(err instanceof Error ? err.message : "Failed to send alert");
256+
} finally {
257+
setSendingAlert(false);
258+
}
259+
};
260+
261+
const randomizeAlert = () => {
262+
const randomType = DISASTER_TYPES[Math.floor(Math.random() * DISASTER_TYPES.length)].value;
263+
const descriptions = RANDOM_DESCRIPTIONS[randomType];
264+
const randomDesc = descriptions[Math.floor(Math.random() * descriptions.length)];
265+
266+
setAlertForm({
267+
disaster_type: randomType,
268+
severity: Math.floor(Math.random() * 2) + 4,
269+
description: randomDesc,
270+
});
271+
};
272+
127273
const formatLastLogin = (timeStr: string | null) => {
128274
if (!timeStr) return "Never";
129275
return formatActivityTime(timeStr);
@@ -575,17 +721,6 @@ export default function AdminDashboard() {
575721
<div className="font-medium">Manage Users</div>
576722
<div className="text-xs text-muted-foreground">View and manage all users</div>
577723
</div>
578-
</Button>
579-
<Button
580-
className="justify-start h-auto py-3"
581-
variant="outline"
582-
onClick={() => router.push('/admin')}
583-
>
584-
<Settings className="mr-2 h-4 w-4" />
585-
<div className="text-left">
586-
<div className="font-medium">Admin Settings</div>
587-
<div className="text-xs text-muted-foreground">Configure system settings</div>
588-
</div>
589724
</Button>
590725
<Button
591726
className="justify-start h-auto py-3"
@@ -609,10 +744,152 @@ export default function AdminDashboard() {
609744
<div className="text-xs text-muted-foreground">Developer tools and utilities</div>
610745
</div>
611746
</Button>
747+
<Button
748+
className="justify-start h-auto py-3 border-amber-500/50 hover:bg-amber-500/10"
749+
variant="outline"
750+
onClick={openAlertDialog}
751+
>
752+
<Bell className="mr-2 h-4 w-4 text-amber-600" />
753+
<div className="text-left">
754+
<div className="font-medium">Send Test Alert</div>
755+
<div className="text-xs text-muted-foreground">Trigger a test alert for a user</div>
756+
</div>
757+
</Button>
612758
</div>
613759
</CardContent>
614760
</Card>
615761
</div>
762+
763+
{alertSuccess && (
764+
<div className="fixed bottom-4 right-4 p-4 bg-green-500/10 border border-green-500/20 rounded-lg flex items-center gap-2 text-green-700 dark:text-green-400 shadow-lg z-50">
765+
<CheckCircle2 className="h-4 w-4" />
766+
<span>{alertSuccess}</span>
767+
</div>
768+
)}
769+
770+
<Dialog open={alertDialogOpen} onOpenChange={setAlertDialogOpen}>
771+
<DialogContent className="sm:max-w-[500px]">
772+
<DialogHeader>
773+
<DialogTitle className="flex items-center gap-2">
774+
<Bell className="h-5 w-5 text-amber-600" />
775+
Send Test Alert
776+
</DialogTitle>
777+
<DialogDescription>
778+
Create a test disaster and send an alert notification to a user.
779+
</DialogDescription>
780+
</DialogHeader>
781+
782+
{alertError && (
783+
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg text-destructive text-sm">
784+
{alertError}
785+
</div>
786+
)}
787+
788+
{alertSuccess ? (
789+
<div className="p-4 bg-green-500/10 border border-green-500/20 rounded-lg text-green-700 dark:text-green-400 text-center">
790+
<CheckCircle2 className="h-8 w-8 mx-auto mb-2" />
791+
<p className="font-medium">{alertSuccess}</p>
792+
</div>
793+
) : (
794+
<div className="space-y-4">
795+
<div>
796+
<Label>Select User</Label>
797+
<Select value={selectedUserId} onValueChange={setSelectedUserId}>
798+
<SelectTrigger>
799+
<SelectValue placeholder="Select a user..." />
800+
</SelectTrigger>
801+
<SelectContent>
802+
{allUsers.map((user) => (
803+
<SelectItem key={user.id} value={user.id}>
804+
{user.email} {user.location ? `(${user.location})` : "(No location)"}
805+
</SelectItem>
806+
))}
807+
</SelectContent>
808+
</Select>
809+
</div>
810+
811+
<div className="grid grid-cols-2 gap-4">
812+
<div>
813+
<Label>Disaster Type</Label>
814+
<Select
815+
value={alertForm.disaster_type}
816+
onValueChange={(v) => {
817+
const descriptions = RANDOM_DESCRIPTIONS[v];
818+
const randomDesc = descriptions[Math.floor(Math.random() * descriptions.length)];
819+
setAlertForm({ ...alertForm, disaster_type: v, description: randomDesc });
820+
}}
821+
>
822+
<SelectTrigger>
823+
<SelectValue />
824+
</SelectTrigger>
825+
<SelectContent>
826+
{DISASTER_TYPES.map((type) => (
827+
<SelectItem key={type.value} value={type.value}>
828+
{type.label}
829+
</SelectItem>
830+
))}
831+
</SelectContent>
832+
</Select>
833+
</div>
834+
<div>
835+
<Label>Severity</Label>
836+
<Select
837+
value={alertForm.severity.toString()}
838+
onValueChange={(v) => setAlertForm({ ...alertForm, severity: parseInt(v) })}
839+
>
840+
<SelectTrigger>
841+
<SelectValue />
842+
</SelectTrigger>
843+
<SelectContent>
844+
<SelectItem value="3">3 - Significant</SelectItem>
845+
<SelectItem value="4">4 - Severe</SelectItem>
846+
<SelectItem value="5">5 - Critical</SelectItem>
847+
</SelectContent>
848+
</Select>
849+
</div>
850+
</div>
851+
852+
<div>
853+
<Label>Description</Label>
854+
<Input
855+
value={alertForm.description}
856+
onChange={(e) => setAlertForm({ ...alertForm, description: e.target.value })}
857+
placeholder="Alert description..."
858+
/>
859+
</div>
860+
861+
<Button variant="ghost" size="sm" onClick={randomizeAlert} className="w-full">
862+
🎲 Randomize Alert
863+
</Button>
864+
</div>
865+
)}
866+
867+
<DialogFooter>
868+
<Button variant="outline" onClick={() => setAlertDialogOpen(false)} disabled={sendingAlert}>
869+
Cancel
870+
</Button>
871+
{!alertSuccess && (
872+
<Button
873+
onClick={handleSendTestAlert}
874+
disabled={sendingAlert || !selectedUserId}
875+
className="bg-amber-600 hover:bg-amber-700"
876+
>
877+
{sendingAlert ? (
878+
<>
879+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
880+
Sending...
881+
</>
882+
) : (
883+
<>
884+
<Bell className="mr-2 h-4 w-4" />
885+
Send Alert
886+
</>
887+
)}
888+
</Button>
889+
)}
890+
</DialogFooter>
891+
</DialogContent>
892+
</Dialog>
616893
</main>
617894
</div>
618895
);

0 commit comments

Comments
 (0)