diff --git a/apps/webapp/app/components/navigation/NotificationCard.tsx b/apps/webapp/app/components/navigation/NotificationCard.tsx new file mode 100644 index 0000000000..8b03c27fff --- /dev/null +++ b/apps/webapp/app/components/navigation/NotificationCard.tsx @@ -0,0 +1,142 @@ +import { XMarkIcon } from "@heroicons/react/20/solid"; +import { useLayoutEffect, useRef, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import { cn } from "~/utils/cn"; + +export function NotificationCard({ + title, + description, + image, + actionUrl, + onDismiss, + onCardClick, + onLinkClick, +}: { + title: string; + description: string; + image?: string; + actionUrl?: string; + onDismiss?: () => void; + onCardClick?: () => void; + onLinkClick?: () => void; +}) { + const [isExpanded, setIsExpanded] = useState(false); + const [isOverflowing, setIsOverflowing] = useState(false); + const descriptionRef = useRef(null); + + useLayoutEffect(() => { + const el = descriptionRef.current; + if (!el) return; + + const check = () => setIsOverflowing(el.scrollHeight - el.clientHeight > 1); + check(); + + const observer = new ResizeObserver(check); + observer.observe(el); + return () => observer.disconnect(); + }, [description]); + + const handleDismiss = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onDismiss?.(); + }; + + const handleToggleExpand = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsExpanded((v) => !v); + }; + + const safeActionUrl = sanitizeUrl(actionUrl); + const safeImage = sanitizeUrl(image); + + return ( +
+ {safeActionUrl && ( + + )} + +
+

{title}

+ +
+ +
+
+ + {description} + +
+ {(isOverflowing || isExpanded) && ( + + )} + + {safeImage && } +
+
+ ); +} + +function getMarkdownComponents(onLinkClick?: () => void) { + return { + p: ({ children }: { children?: React.ReactNode }) => ( +

{children}

+ ), + a: ({ href, children }: { href?: string; children?: React.ReactNode }) => ( +
{ + e.stopPropagation(); + onLinkClick?.(); + }} + > + {children} + + ), + strong: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), + em: ({ children }: { children?: React.ReactNode }) => {children}, + code: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), + }; +} + +const SAFE_URL_PROTOCOLS = new Set(["http:", "https:", "mailto:", "tel:"]); + +/** Sanitize a URL to prevent XSS via javascript: or data: URIs. Returns "" if invalid. */ +function sanitizeUrl(url: string | undefined): string { + if (!url) return ""; + try { + const parsed = new URL(url); + return SAFE_URL_PROTOCOLS.has(parsed.protocol) ? parsed.href : ""; + } catch { + return ""; + } +} diff --git a/apps/webapp/app/components/navigation/NotificationPanel.tsx b/apps/webapp/app/components/navigation/NotificationPanel.tsx index fdfbb2f874..15af60fde3 100644 --- a/apps/webapp/app/components/navigation/NotificationPanel.tsx +++ b/apps/webapp/app/components/navigation/NotificationPanel.tsx @@ -1,13 +1,12 @@ -import { BellAlertIcon, ChevronRightIcon, XMarkIcon } from "@heroicons/react/20/solid"; +import { BellAlertIcon } from "@heroicons/react/20/solid"; import { useFetcher } from "@remix-run/react"; -import { motion } from "framer-motion"; -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; -import ReactMarkdown from "react-markdown"; -import { Header3 } from "~/components/primitives/Headers"; +import { useCallback, useEffect, useRef, useState } from "react"; +import simplur from "simplur"; +import { Button } from "~/components/primitives/Buttons"; import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { usePlatformNotifications } from "~/routes/resources.platform-notifications"; -import { cn } from "~/utils/cn"; +import { NotificationCard } from "./NotificationCard"; type Notification = { id: string; @@ -102,211 +101,57 @@ export function NotificationPanel({ return null; } + const { title, description, image, actionUrl, dismissOnAction } = notification.payload.data; const card = ( handleDismiss(notification.id)} + onCardClick={() => { + fireClickBeacon(notification.id); + if (dismissOnAction) { + handleDismiss(notification.id); + } + }} onLinkClick={() => fireClickBeacon(notification.id)} /> ); return ( -
- {/* Expanded sidebar: show card directly */} - - {card} - - - {/* Collapsed sidebar: show bell icon that opens popover */} - +
+ {isCollapsed ? ( -
- - - {visibleNotifications.length} - -
- +
+ + + + + {visibleNotifications.length} + +
} - content="Notifications" + content={simplur`${visibleNotifications.length} notification[|s]`} side="right" sideOffset={8} disableHoverableContent - asChild /> - + ) : ( + card + )}
- + {card} ); } - -function NotificationCard({ - notification, - onDismiss, - onLinkClick, -}: { - notification: Notification; - onDismiss: (id: string) => void; - onLinkClick: () => void; -}) { - const { title, description, image, actionUrl, dismissOnAction } = notification.payload.data; - const [isExpanded, setIsExpanded] = useState(false); - const [isOverflowing, setIsOverflowing] = useState(false); - const descriptionRef = useRef(null); - - useLayoutEffect(() => { - const el = descriptionRef.current; - if (el) { - setIsOverflowing(el.scrollHeight > el.clientHeight); - } - }, [description]); - - const handleDismiss = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - onDismiss(notification.id); - }; - - const handleToggleExpand = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsExpanded((v) => !v); - }; - - const handleCardClick = () => { - onLinkClick(); - if (dismissOnAction) { - onDismiss(notification.id); - } - }; - - const Wrapper = actionUrl ? "a" : "div"; - const wrapperProps = actionUrl - ? { - href: actionUrl, - target: "_blank" as const, - rel: "noopener noreferrer" as const, - onClick: handleCardClick, - } - : {}; - - return ( - - {/* Header: title + dismiss */} -
- - {title} - - -
- - {/* Body: description + chevron */} -
-
-
-
- {description} -
- {(isOverflowing || isExpanded) && ( - - )} -
- {actionUrl && ( -
- -
- )} -
- - {image && ( - - )} -
-
- ); -} - -/** Sanitize image URL to prevent XSS via javascript: or data: URIs. */ -function sanitizeImageUrl(url: string): string { - try { - const parsed = new URL(url); - if (parsed.protocol === "https:" || parsed.protocol === "http:") { - return parsed.href; - } - return ""; - } catch { - return ""; - } -} - -function getMarkdownComponents(onLinkClick: () => void) { - return { - p: ({ children }: { children?: React.ReactNode }) => ( -

{children}

- ), - a: ({ href, children }: { href?: string; children?: React.ReactNode }) => ( - { - e.stopPropagation(); - onLinkClick(); - }} - > - {children} - - ), - strong: ({ children }: { children?: React.ReactNode }) => ( - {children} - ), - em: ({ children }: { children?: React.ReactNode }) => {children}, - code: ({ children }: { children?: React.ReactNode }) => ( - {children} - ), - }; -} diff --git a/apps/webapp/app/routes/admin.notifications.tsx b/apps/webapp/app/routes/admin.notifications.tsx index f05397d3c2..548fc16619 100644 --- a/apps/webapp/app/routes/admin.notifications.tsx +++ b/apps/webapp/app/routes/admin.notifications.tsx @@ -1,9 +1,8 @@ -import { ChevronRightIcon, TrashIcon, XMarkIcon } from "@heroicons/react/20/solid"; +import { TrashIcon } from "@heroicons/react/20/solid"; import { useFetcher, useSearchParams } from "@remix-run/react"; import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; import { redirect } from "@remix-run/server-runtime"; -import { useEffect, useRef, useState, useLayoutEffect } from "react"; -import ReactMarkdown from "react-markdown"; +import { useEffect, useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { @@ -20,13 +19,19 @@ import { Button } from "~/components/primitives/Buttons"; import { Dialog, DialogContent, + DialogFooter, DialogHeader, DialogTitle, } from "~/components/primitives/Dialog"; -import { Header3 } from "~/components/primitives/Headers"; +import { Checkbox, CheckboxWithLabel } from "~/components/primitives/Checkbox"; +import { Hint } from "~/components/primitives/Hint"; import { Input } from "~/components/primitives/Input"; +import { Label } from "~/components/primitives/Label"; +import { NotificationCard } from "~/components/navigation/NotificationCard"; import { PaginationControls } from "~/components/primitives/Pagination"; import { Paragraph } from "~/components/primitives/Paragraph"; +import { Select, SelectItem } from "~/components/primitives/Select"; +import { TextArea } from "~/components/primitives/TextArea"; import { Table, TableBlankRow, @@ -48,13 +53,16 @@ import { updatePlatformNotification, } from "~/services/platformNotifications.server"; import { createSearchParams } from "~/utils/searchParams"; -import { cn } from "~/utils/cn"; const PAGE_SIZE = 20; const WEBAPP_TYPES = ["card", "changelog"] as const; const CLI_TYPES = ["info", "warn", "error", "success"] as const; +/** Sentinel for the discovery "match behavior" select meaning "none / not configured". */ +const DISCOVERY_MATCH_NONE = ""; +const DISCOVERY_MATCH_LABEL = "— none —"; + const SearchParams = z.object({ page: z.coerce.number().optional(), hideInactive: z.coerce.boolean().optional(), @@ -70,7 +78,11 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const { page: rawPage, hideInactive } = searchParams.params.getAll(); const page = rawPage ?? 1; - const data = await getAdminNotificationsList({ page, pageSize: PAGE_SIZE, hideInactive: hideInactive ?? false }); + const data = await getAdminNotificationsList({ + page, + pageSize: PAGE_SIZE, + hideInactive: hideInactive ?? false, + }); return typedjson({ ...data, userId }); }; @@ -134,12 +146,12 @@ function parseNotificationFormData(formData: FormData) { : undefined; const discoveryFilePatterns = (formData.get("discoveryFilePatterns") as string) || ""; - const discoveryContentPattern = - (formData.get("discoveryContentPattern") as string) || undefined; - const discoveryMatchBehavior = (formData.get("discoveryMatchBehavior") as string) || ""; + const discoveryContentPattern = (formData.get("discoveryContentPattern") as string) || undefined; + const discoveryMatchBehavior = + (formData.get("discoveryMatchBehavior") as string) || DISCOVERY_MATCH_NONE; const discovery = - discoveryFilePatterns && discoveryMatchBehavior + discoveryFilePatterns && discoveryMatchBehavior !== DISCOVERY_MATCH_NONE ? { filePatterns: discoveryFilePatterns .split(",") @@ -191,7 +203,14 @@ function buildPayloadInput(fields: ReturnType) async function handleCreateAction(formData: FormData, userId: string, isPreview: boolean) { const fields = parseNotificationFormData(formData); - if (!fields.adminLabel || !fields.title || !fields.description || !fields.endsAt || !fields.surface || !fields.payloadType) { + if ( + !fields.adminLabel || + !fields.title || + !fields.description || + !fields.endsAt || + !fields.surface || + !fields.payloadType + ) { return typedjson({ error: "Missing required fields" }, { status: 400 }); } @@ -204,14 +223,18 @@ async function handleCreateAction(formData: FormData, userId: string, isPreview: ? { userId } : { ...(fields.scope === "USER" && fields.scopeUserId ? { userId: fields.scopeUserId } : {}), - ...(fields.scope === "ORGANIZATION" && fields.scopeOrganizationId ? { organizationId: fields.scopeOrganizationId } : {}), - ...(fields.scope === "PROJECT" && fields.scopeProjectId ? { projectId: fields.scopeProjectId } : {}), + ...(fields.scope === "ORGANIZATION" && fields.scopeOrganizationId + ? { organizationId: fields.scopeOrganizationId } + : {}), + ...(fields.scope === "PROJECT" && fields.scopeProjectId + ? { projectId: fields.scopeProjectId } + : {}), }), startsAt: isPreview ? new Date().toISOString() : fields.startsAt - ? new Date(fields.startsAt + "Z").toISOString() - : new Date().toISOString(), + ? new Date(fields.startsAt + "Z").toISOString() + : new Date().toISOString(), endsAt: isPreview ? new Date(Date.now() + 60 * 60 * 1000).toISOString() : new Date(fields.endsAt + "Z").toISOString(), @@ -256,7 +279,10 @@ async function handleArchiveAction(formData: FormData) { return typedjson({ success: true }); } catch (error) { logger.error("Failed to archive platform notification", { error, notificationId }); - return typedjson({ error: "Failed to archive notification, please try again." }, { status: 500 }); + return typedjson( + { error: "Failed to archive notification, please try again." }, + { status: 500 } + ); } } @@ -271,7 +297,10 @@ async function handleDeleteAction(formData: FormData) { return typedjson({ success: true }); } catch (error) { logger.error("Failed to delete platform notification", { error, notificationId }); - return typedjson({ error: "Failed to delete notification, please try again." }, { status: 500 }); + return typedjson( + { error: "Failed to delete notification, please try again." }, + { status: 500 } + ); } } @@ -286,7 +315,10 @@ async function handlePublishNowAction(formData: FormData) { return typedjson({ success: true }); } catch (error) { logger.error("Failed to publish platform notification", { error, notificationId }); - return typedjson({ error: "Failed to publish notification, please try again." }, { status: 500 }); + return typedjson( + { error: "Failed to publish notification, please try again." }, + { status: 500 } + ); } } @@ -294,7 +326,16 @@ async function handleEditAction(formData: FormData) { const notificationId = formData.get("notificationId") as string; const fields = parseNotificationFormData(formData); - if (!notificationId || !fields.adminLabel || !fields.title || !fields.description || !fields.endsAt || !fields.surface || !fields.payloadType || !fields.startsAt) { + if ( + !notificationId || + !fields.adminLabel || + !fields.title || + !fields.description || + !fields.endsAt || + !fields.surface || + !fields.payloadType || + !fields.startsAt + ) { return typedjson({ error: "Missing required fields" }, { status: 400 }); } @@ -305,8 +346,12 @@ async function handleEditAction(formData: FormData) { surface: fields.surface as "CLI" | "WEBAPP", scope: fields.scope as "USER" | "PROJECT" | "ORGANIZATION" | "GLOBAL", ...(fields.scope === "USER" && fields.scopeUserId ? { userId: fields.scopeUserId } : {}), - ...(fields.scope === "ORGANIZATION" && fields.scopeOrganizationId ? { organizationId: fields.scopeOrganizationId } : {}), - ...(fields.scope === "PROJECT" && fields.scopeProjectId ? { projectId: fields.scopeProjectId } : {}), + ...(fields.scope === "ORGANIZATION" && fields.scopeOrganizationId + ? { organizationId: fields.scopeOrganizationId } + : {}), + ...(fields.scope === "PROJECT" && fields.scopeProjectId + ? { projectId: fields.scopeProjectId } + : {}), startsAt: new Date(fields.startsAt + "Z").toISOString(), endsAt: new Date(fields.endsAt + "Z").toISOString(), priority: fields.priority, @@ -337,8 +382,12 @@ async function handleEditAction(formData: FormData) { export default function AdminNotificationsRoute() { const { notifications, total, page, pageCount } = useTypedLoaderData(); const [showCreate, setShowCreate] = useState(false); - const [detailNotification, setDetailNotification] = useState<(typeof notifications)[number] | null>(null); - const [editNotification, setEditNotification] = useState<(typeof notifications)[number] | null>(null); + const [detailNotification, setDetailNotification] = useState< + (typeof notifications)[number] | null + >(null); + const [editNotification, setEditNotification] = useState<(typeof notifications)[number] | null>( + null + ); const [urlSearchParams, setUrlSearchParams] = useSearchParams(); const hideInactive = urlSearchParams.get("hideInactive") === "true"; @@ -361,23 +410,21 @@ export default function AdminNotificationsRoute() {
{ if (!open) setShowCreate(false); }} + onOpenChange={(open) => { + if (!open) setShowCreate(false); + }} > - + - Create Notification + Create notification - setShowCreate(false)} - /> + setShowCreate(false)} /> @@ -386,12 +433,7 @@ export default function AdminNotificationsRoute() { {total} notifications (page {page} of {pageCount || 1})
@@ -427,7 +469,7 @@ export default function AdminNotificationsRoute() { @@ -448,33 +490,28 @@ export default function AdminNotificationsRoute() { {formatDate(n.endsAt)} - {n.stats.seen} + {n.stats.seen} - {n.stats.clicked} + {n.stats.clicked} - {n.stats.dismissed} + {n.stats.dismissed} -
- {status === "pending" && ( - - )} - {(status === "pending" || status === "releasing" || status === "active") && ( - )} - {status !== "archived" && ( - - )} + {status !== "archived" && }
@@ -494,7 +531,7 @@ export default function AdminNotificationsRoute() { if (!open) setDetailNotification(null); }} > - + {detailNotification && ( <> @@ -512,11 +549,11 @@ export default function AdminNotificationsRoute() { if (!open) setEditNotification(null); }} > - + {editNotification && ( <> - Edit Notification + Edit notification - + @@ -593,8 +630,8 @@ function DeleteConfirmationButton({ notificationId }: { notificationId: string } Delete notification - This will permanently delete this notification and all its interaction data. This - action cannot be undone. + This will permanently delete this notification and all its interaction data. This action + cannot be undone. @@ -651,13 +688,18 @@ function NotificationForm({ onClose: () => void; }) { const fetcher = useFetcher<{ success?: boolean; error?: string; previewId?: string }>(); - const [surface, setSurface] = useState<"CLI" | "WEBAPP">((n?.surface as "CLI" | "WEBAPP") ?? "WEBAPP"); + const [surface, setSurface] = useState<"CLI" | "WEBAPP">( + (n?.surface as "CLI" | "WEBAPP") ?? "WEBAPP" + ); const [payloadType, setPayloadType] = useState(n?.payloadType ?? "card"); const [scope, setScope] = useState(n?.scope ?? "GLOBAL"); const [title, setTitle] = useState(n?.payloadTitle ?? ""); const [description, setDescription] = useState(n?.payloadDescription ?? ""); const [actionUrl, setActionUrl] = useState(n?.payloadActionUrl ?? ""); const [image, setImage] = useState(n?.payloadImage ?? ""); + const [discoveryMatchBehavior, setDiscoveryMatchBehavior] = useState( + n?.payloadDiscovery?.matchBehavior ?? DISCOVERY_MATCH_NONE + ); const typeOptions = surface === "WEBAPP" ? WEBAPP_TYPES : CLI_TYPES; @@ -686,50 +728,67 @@ function NotificationForm({ )} -
-
- - -
+ + + + +
+ + +
+
- - + {(items) => + items.map((item) => ( + + {item} + + )) + } +
- - + {(items) => + items.map((item) => ( + + {item} + + )) + } +
-
- +
+
-
-
-
- - + {(items) => + items.map((item) => ( + + {item} + + )) + } +
+
- {scope === "USER" && ( -
- - -
- )} + {scope === "USER" && ( +
+ + +
+ )} - {scope === "ORGANIZATION" && ( -
- - -
- )} + {scope === "ORGANIZATION" && ( +
+ + +
+ )} - {scope === "PROJECT" && ( -
- - -
- )} -
+ {scope === "PROJECT" && ( +
+ + +
+ )} {/* CLI live preview */} {surface === "CLI" && (title || description) && (
-

- CLI Preview -

-
+ +
{title && (

@@ -796,109 +877,112 @@ function NotificationForm({

)} - {actionUrl && ( -

{actionUrl}

- )} + {actionUrl &&

{actionUrl}

}
)} -
- - setTitle(e.target.value)} - className="mt-1" - /> -
+
-
- - {surface === "WEBAPP" ? ( -
-