Skip to content

Commit 20f080d

Browse files
feat: integrate Solvro Alerts service (#247)
1 parent 7c77404 commit 20f080d

5 files changed

Lines changed: 209 additions & 94 deletions

File tree

src/components/alerts.tsx

Lines changed: 206 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,95 +1,228 @@
11
"use client";
22

3+
import { useQuery } from "@tanstack/react-query";
34
import DOMPurify from "dompurify";
4-
import { XIcon } from "lucide-react";
5-
import { useContext, useEffect, useState } from "react";
5+
import {
6+
InfoIcon,
7+
OctagonAlertIcon,
8+
TriangleAlertIcon,
9+
XIcon,
10+
} from "lucide-react";
11+
import { useEffect, useState } from "react";
612

7-
import { AppContext } from "@/app-context";
813
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
914
import { Button } from "@/components/ui/button";
10-
import type { AlertData } from "@/types/alert";
15+
import { env } from "@/env";
16+
import { cn } from "@/lib/utils";
1117

12-
export function Alerts(): React.JSX.Element | null {
13-
const appContext = useContext(AppContext);
14-
const [alerts, setAlerts] = useState<AlertData[]>([]);
15-
const [dismissedAlerts, setDismissedAlerts] = useState<string[]>(() => {
16-
if (typeof window === "undefined") {
18+
interface SolvroAlert {
19+
id: string;
20+
title: string;
21+
content: string;
22+
alert_type: "info" | "warning" | "critical";
23+
link: string;
24+
open_in_new_tab: boolean;
25+
is_global: boolean;
26+
is_dismissable: boolean;
27+
start_at: string | null;
28+
end_at: string | null;
29+
}
30+
31+
const ALERTS_ENDPOINT = "https://alerts.solvro.pl/api/v1/alerts/";
32+
const DISMISSED_STORAGE_KEY = "solvro-alerts-dismissed";
33+
34+
const ALLOWED_TAGS = [
35+
"a",
36+
"b",
37+
"blockquote",
38+
"br",
39+
"code",
40+
"del",
41+
"div",
42+
"em",
43+
"h1",
44+
"h2",
45+
"h3",
46+
"h4",
47+
"h5",
48+
"h6",
49+
"hr",
50+
"i",
51+
"li",
52+
"ol",
53+
"p",
54+
"pre",
55+
"s",
56+
"span",
57+
"strong",
58+
"sub",
59+
"sup",
60+
"u",
61+
"ul",
62+
];
63+
const ALLOWED_ATTR = ["href", "title", "target"];
64+
65+
const VARIANT_STYLES: Record<SolvroAlert["alert_type"], string> = {
66+
info: "border-blue-200 bg-blue-50 text-blue-900 [&>svg]:text-blue-600 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-100 dark:[&>svg]:text-blue-300",
67+
warning:
68+
"border-amber-200 bg-amber-50 text-amber-900 [&>svg]:text-amber-600 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100 dark:[&>svg]:text-amber-300",
69+
critical:
70+
"border-red-200 bg-red-50 text-red-900 [&>svg]:text-red-600 dark:border-red-900 dark:bg-red-950/40 dark:text-red-100 dark:[&>svg]:text-red-300",
71+
};
72+
73+
const VARIANT_ICONS: Record<SolvroAlert["alert_type"], typeof InfoIcon> = {
74+
info: InfoIcon,
75+
warning: TriangleAlertIcon,
76+
critical: OctagonAlertIcon,
77+
};
78+
79+
function readDismissedIds(): string[] {
80+
if (typeof window === "undefined") {
81+
return [];
82+
}
83+
try {
84+
const stored = window.localStorage.getItem(DISMISSED_STORAGE_KEY);
85+
if (stored === null) {
1786
return [];
1887
}
19-
const stored = localStorage.getItem("dismissedAlerts");
20-
return stored === null ? [] : (JSON.parse(stored) as string[]);
88+
const parsed: unknown = JSON.parse(stored);
89+
return Array.isArray(parsed)
90+
? parsed.filter((value): value is string => typeof value === "string")
91+
: [];
92+
} catch {
93+
return [];
94+
}
95+
}
96+
97+
function persistDismissedIds(ids: string[]): void {
98+
if (typeof window === "undefined") {
99+
return;
100+
}
101+
window.localStorage.setItem(DISMISSED_STORAGE_KEY, JSON.stringify(ids));
102+
}
103+
104+
async function fetchAlerts(appCode: string): Promise<SolvroAlert[]> {
105+
const url = `${ALERTS_ENDPOINT}?app=${encodeURIComponent(appCode)}`;
106+
const response = await fetch(url, {
107+
headers: { Accept: "application/json" },
21108
});
22109

23-
const dismissAlert = (alertId: string) => {
24-
setDismissedAlerts([...dismissedAlerts, alertId]);
25-
localStorage.setItem(
26-
"dismissedAlerts",
27-
JSON.stringify([...dismissedAlerts, alertId]),
110+
if (response.status === 400) {
111+
throw new Error(
112+
`Solvro Alerts: unknown app code "${appCode}". Set NEXT_PUBLIC_ALERTS_APP_CODE to the slug registered for this app.`,
28113
);
29-
};
114+
}
115+
if (!response.ok) {
116+
throw new Error(
117+
`Solvro Alerts: request failed (${String(response.status)})`,
118+
);
119+
}
120+
121+
const payload: unknown = await response.json();
122+
return Array.isArray(payload) ? (payload as SolvroAlert[]) : [];
123+
}
124+
125+
export function Alerts(): React.JSX.Element | null {
126+
const appCode = env.NEXT_PUBLIC_ALERTS_APP_CODE;
127+
128+
const { data, error } = useQuery({
129+
queryKey: ["solvro-alerts", appCode],
130+
queryFn: async () => fetchAlerts(appCode),
131+
staleTime: 60_000,
132+
gcTime: 60_000,
133+
retry: false,
134+
});
135+
136+
const [dismissedIds, setDismissedIds] = useState<string[]>(readDismissedIds);
30137

31138
useEffect(() => {
32-
appContext.services.user
33-
.getAlerts()
34-
.then((fetchedAlerts) => {
35-
setAlerts(fetchedAlerts);
36-
})
37-
.catch((error: unknown) => {
38-
console.error("Failed to fetch alerts:", error);
39-
});
40-
}, [appContext.services.user]);
41-
42-
if (
43-
alerts.every(
44-
(alert) =>
45-
(dismissedAlerts.includes(alert.id) || !alert.active) &&
46-
alert.dismissible,
47-
)
48-
) {
139+
if (error !== null) {
140+
console.error(error);
141+
}
142+
}, [error]);
143+
144+
const dismissAlert = (id: string) => {
145+
setDismissedIds((previous) => {
146+
if (previous.includes(id)) {
147+
return previous;
148+
}
149+
const next = [...previous, id];
150+
persistDismissedIds(next);
151+
return next;
152+
});
153+
};
154+
155+
const visible = (data ?? []).filter(
156+
(alert) => !alert.is_dismissable || !dismissedIds.includes(alert.id),
157+
);
158+
159+
if (visible.length === 0) {
49160
return null;
50161
}
51162

52163
return (
53164
<div className="mt-2 space-y-2">
54-
{alerts.map(
55-
(alert) =>
56-
(!dismissedAlerts.includes(alert.id) || !alert.dismissible) &&
57-
alert.active && (
58-
<Alert
59-
key={alert.id}
60-
variant={
61-
alert.color === "danger" || alert.color === "warning"
62-
? "destructive"
63-
: "default"
64-
}
65-
className="relative pr-10"
66-
>
67-
{alert.dismissible ? (
68-
<Button
69-
variant="ghost"
70-
size="icon"
71-
aria-label="Zamknij"
72-
onClick={() => {
73-
dismissAlert(alert.id);
74-
}}
75-
className="text-muted-foreground hover:text-foreground absolute top-0 right-0 m-1 transition-colors"
76-
>
77-
<XIcon className="size-4" />
78-
</Button>
79-
) : null}
80-
{alert.title ? <AlertTitle>{alert.title}</AlertTitle> : null}
81-
<AlertDescription>
82-
<div
83-
// eslint-disable-next-line react/no-danger
84-
dangerouslySetInnerHTML={{
85-
__html: DOMPurify.sanitize(alert.content),
86-
}}
87-
className="[&_a]:underline"
88-
/>
89-
</AlertDescription>
90-
</Alert>
91-
),
92-
)}
165+
{visible.map((alert) => {
166+
const Icon = VARIANT_ICONS[alert.alert_type];
167+
const safeContent = DOMPurify.sanitize(alert.content, {
168+
ALLOWED_TAGS,
169+
ALLOWED_ATTR,
170+
});
171+
const hasLink = alert.link !== "";
172+
173+
const body = (
174+
<Alert
175+
className={cn(
176+
"relative",
177+
alert.is_dismissable && "pr-10",
178+
VARIANT_STYLES[alert.alert_type],
179+
hasLink && "cursor-pointer transition-opacity hover:opacity-90",
180+
)}
181+
>
182+
<Icon />
183+
{alert.is_dismissable ? (
184+
<Button
185+
variant="ghost"
186+
size="icon"
187+
aria-label="Zamknij"
188+
onClick={(event) => {
189+
event.preventDefault();
190+
event.stopPropagation();
191+
dismissAlert(alert.id);
192+
}}
193+
className="absolute top-0 right-0 m-1 text-current opacity-70 transition-opacity hover:bg-black/5 hover:opacity-100 dark:hover:bg-white/10"
194+
>
195+
<XIcon className="size-4" />
196+
</Button>
197+
) : null}
198+
{alert.title === "" ? null : <AlertTitle>{alert.title}</AlertTitle>}
199+
<AlertDescription className="text-current/90">
200+
<div
201+
// eslint-disable-next-line react/no-danger
202+
dangerouslySetInnerHTML={{ __html: safeContent }}
203+
className="prose prose-sm dark:prose-invert max-w-none"
204+
/>
205+
</AlertDescription>
206+
</Alert>
207+
);
208+
209+
if (!hasLink) {
210+
return <div key={alert.id}>{body}</div>;
211+
}
212+
213+
return (
214+
<a
215+
key={alert.id}
216+
href={alert.link}
217+
{...(alert.open_in_new_tab
218+
? { target: "_blank", rel: "noopener noreferrer" }
219+
: {})}
220+
className="block no-underline"
221+
>
222+
{body}
223+
</a>
224+
);
225+
})}
93226
</div>
94227
);
95228
}

src/env.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@ export const env = createEnv({
1414
NEXT_PUBLIC_API_URL: z.url(),
1515
NEXT_PUBLIC_TURN_USERNAME: z.string().optional(),
1616
NEXT_PUBLIC_TURN_CREDENTIAL: z.string().optional(),
17+
NEXT_PUBLIC_ALERTS_APP_CODE: z.string().min(1).default("testownik"),
1718
},
1819
experimental__runtimeEnv: {
1920
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
2021
NEXT_PUBLIC_TURN_USERNAME: process.env.NEXT_PUBLIC_TURN_USERNAME,
2122
NEXT_PUBLIC_TURN_CREDENTIAL: process.env.NEXT_PUBLIC_TURN_CREDENTIAL,
23+
NEXT_PUBLIC_ALERTS_APP_CODE: process.env.NEXT_PUBLIC_ALERTS_APP_CODE,
2224
},
2325
});

src/services/types.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@ export type {
1515
UserSettings,
1616
UserData,
1717
} from "@/types/user";
18-
export type { AlertData } from "@/types/alert";
19-
2018
export interface ApiResponse<T> {
2119
data: T;
2220
status: number;

src/services/user.service.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { BaseApiService } from "./base-api.service";
2-
import type { AlertData, GradesData, UserData, UserSettings } from "./types";
2+
import type { GradesData, UserData, UserSettings } from "./types";
33

44
/**
55
* Service for handling user-related API operations
@@ -57,14 +57,6 @@ export class UserService extends BaseApiService {
5757
return response.data;
5858
}
5959

60-
/**
61-
* Get alerts
62-
*/
63-
async getAlerts(): Promise<AlertData[]> {
64-
const response = await this.get<AlertData[]>("alerts/");
65-
return response.data;
66-
}
67-
6860
/**
6961
* Send feedback/bug report
7062
*/

src/types/alert.ts

Lines changed: 0 additions & 10 deletions
This file was deleted.

0 commit comments

Comments
 (0)