src/app/(proper_react)/(redesign)/(authenticated)/admin/announcements/AnnouncementsAdmin.tsx (428 lines of code) (raw):
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/* c8 ignore start */
/*
This file is excluded from unit test coverage because it's an internal admin-only
tool and not part of the user-facing experience. It contains minimal logic,
is not critical to core app functionality, and is tested manually as needed.
*/
"use client";
import { useEffect, useState } from "react";
import styles from "./AnnouncementsAdmin.module.scss";
import Image from "next/image";
import { AnnouncementRow } from "knex/types/tables";
import AnnouncementsModal from "./AnnouncementsModal";
import { usePathname } from "next/navigation";
import { LocalizedAnnouncementString } from "../../../../../components/client/toolbar/AnnouncementDialog";
import { useL10n } from "../../../../../hooks/l10n";
type Props = {
announcements: AnnouncementRow[];
};
export const AnnouncementsAdmin = (props: Props) => {
const [activeAnnouncementId, setActiveAnnouncementId] = useState<
number | null
>(props.announcements[0]?.id || null);
const [activeAnnouncementToEdit, setActiveAnnouncementToEdit] =
useState<AnnouncementRow | null>(null);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [announcements, setAnnouncements] = useState<AnnouncementRow[]>(
props.announcements,
);
const endpointBase = `/api/v1/admin/announcements`;
const [isSubmitting, setIsSubmitting] = useState(false);
const handleAddAnnouncement = async (newAnnouncement: AnnouncementRow) => {
try {
const response = await fetch(endpointBase, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newAnnouncement),
});
if (!response.ok) throw new Error("Failed to add announcement");
const savedAnnouncement: AnnouncementRow = await response.json();
setAnnouncements((prev) => [...prev, savedAnnouncement]);
setActiveAnnouncementId(savedAnnouncement.id);
setIsModalOpen(false);
} catch (error) {
console.error("Error adding announcement:", error);
} finally {
setIsSubmitting(false);
}
};
const handleUpdateAnnouncement = async (
updatedAnnouncement: AnnouncementRow,
) => {
setIsSubmitting(true);
try {
const response = await fetch(
`/api/v1/admin/announcements/${updatedAnnouncement.announcement_id}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedAnnouncement),
},
);
if (!response.ok) throw new Error("Failed to update announcement");
const updated = await response.json();
setAnnouncements((prev) =>
prev.map((a) => (a.id === updated.id ? updated : a)),
);
setActiveAnnouncementId(updated.id);
setIsModalOpen(false);
} catch (error) {
console.error("Error updating announcement:", error);
} finally {
setIsSubmitting(false);
}
};
const handleDeleteAnnouncement = async (announcementId: string) => {
try {
const response = await fetch(
`/api/v1/admin/announcements/${announcementId}/`,
{
method: "DELETE",
},
);
if (!response.ok) {
console.error(
"Failed to delete announcement:",
response.status,
response.statusText,
);
return;
}
setAnnouncements((prevAnnouncements) =>
prevAnnouncements.filter(
(announcement) => announcement.announcement_id !== announcementId,
),
);
if (announcementId === activeAnnouncement?.announcement_id) {
setActiveAnnouncementId(
announcements.length > 1 ? announcements[0].id : null,
);
}
} catch (error) {
console.error("Error deleting announcement:", error);
}
};
const handleEditAnnouncement = (announcementId: string) => {
const announcementToEdit = announcements.find(
(n) => n.announcement_id === announcementId,
);
if (announcementToEdit) {
setIsModalOpen(true);
// Pass the notification data to the modal for editing
setActiveAnnouncementToEdit(announcementToEdit);
}
};
// Handle selecting a notification
const handleClick = (announcementId: number) => {
const newActiveAnnouncement = announcements.find(
(n) => n.id === announcementId,
);
if (newActiveAnnouncement) {
setActiveAnnouncementId(announcementId);
}
};
// Find the active notification from the list of announcements
const activeAnnouncement = announcements.find(
(notification) => notification.id === activeAnnouncementId,
);
useEffect(() => {
if (announcements.length > 0 && activeAnnouncementId === null) {
setActiveAnnouncementId(announcements[0].id);
}
}, [announcements, activeAnnouncementId]);
// States for each image
const [smallImageIsLoading, setSmallImageIsLoading] = useState(true);
const [bigImageIsLoading, setBigImageIsLoading] = useState(true);
const [smallImageUnavailable, setSmallImageUnavailable] = useState(false);
const [bigImageUnavailable, setBigImageUnavailable] = useState(false);
const smallImagePath = `/images/announcements/${activeAnnouncement?.announcement_id.trim()}/small.svg`;
const bigImagePath = `/images/announcements/${activeAnnouncement?.announcement_id.trim()}/big.svg`;
useEffect(() => {
setSmallImageIsLoading(true);
setBigImageIsLoading(true);
setSmallImageUnavailable(false);
setBigImageUnavailable(false);
}, [activeAnnouncementId]);
const sortedAnnouncements = [...announcements].sort((a, b) => {
return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime();
});
return (
<div className={styles.container}>
<div className={styles.wrapper}>
{/* List of announcements */}
<div className={styles.notificationsWrapper}>
<h1>All Announcements</h1>
<ul>
{sortedAnnouncements.map((notification) => (
<li
key={notification.id}
className={
activeAnnouncementId === notification.id ? styles.active : ""
}
onClick={() => handleClick(notification.id)}
>
<div>
<p className={styles.title}>
<span className={styles.missingLabelContainer}>
<LocalizedAnnouncementString
announcement={notification}
type="title"
/>
<MissingFluentId
announcement={notification}
type="title"
/>
</span>
</p>
<p className={styles.description}>
<span className={styles.missingLabelContainer}>
<LocalizedAnnouncementString
announcement={notification}
type="description"
/>
<MissingFluentId
announcement={notification}
type="description"
/>
</span>
</p>
</div>
<div className={styles.pills}>
<div
className={`${styles.statusPill} ${styles[notification.label]}`}
>
{notification.label}
</div>
</div>
</li>
))}
<button
className={styles.addButton}
onClick={() => setIsModalOpen(true)}
>
+ Add new announcement
</button>
</ul>
</div>
{/* Announcement Details */}
{activeAnnouncement && (
<div className={styles.notificationSettings}>
<h2>Details</h2>
<dl>
<dt>Announcement ID</dt>
<dd>{activeAnnouncement.announcement_id}</dd>
<dt>Title</dt>
<dd>
<span className={styles.missingLabelContainer}>
<LocalizedAnnouncementString
announcement={activeAnnouncement}
type="title"
/>
<MissingFluentId
announcement={activeAnnouncement}
type="title"
/>
</span>
</dd>
<dt>Description</dt>
<dd>
<span className={styles.missingLabelContainer}>
<LocalizedAnnouncementString
announcement={activeAnnouncement}
type="description"
/>
<MissingFluentId
announcement={activeAnnouncement}
type="description"
/>
</span>
</dd>
<dt>Small Image</dt>
<dd>
{smallImageIsLoading && !smallImageUnavailable && (
<div className={styles.loader}>Loading...</div>
)}
{smallImageUnavailable ? (
<Image
alt="Fallback image"
width={500}
height={300}
key={activeAnnouncement.id}
className={styles.smallImage}
src="/images/announcements/fallback/small.svg"
onLoadingComplete={() => setSmallImageIsLoading(false)}
/>
) : (
<Image
alt="Small Image"
width={500}
height={300}
key={activeAnnouncement.id}
src={smallImagePath}
className={styles.smallImage}
onLoadingComplete={() => setSmallImageIsLoading(false)}
onError={() => setSmallImageUnavailable(true)}
/>
)}
</dd>
{/* Big Image */}
<dt>Big Image</dt>
<dd>
{bigImageIsLoading && !bigImageUnavailable && (
<div className={styles.loader}>Loading...</div>
)}
{bigImageUnavailable ? (
<Image
alt="Fallback image"
width={500}
height={300}
key={activeAnnouncement.id}
className={styles.bigImage}
src="/images/announcements/fallback/big.svg"
onLoadingComplete={() => setBigImageIsLoading(false)}
/>
) : (
<Image
alt="Announcement preview"
width={500}
height={300}
key={activeAnnouncement.id}
src={bigImagePath}
className={styles.bigImage}
onLoadingComplete={() => setBigImageIsLoading(false)}
onError={() => setBigImageUnavailable(true)}
/>
)}
</dd>
<dt>CTA Label</dt>
<dd>
<span className={styles.missingLabelContainer}>
<LocalizedAnnouncementString
announcement={activeAnnouncement}
type="cta-label"
/>
<MissingFluentId
announcement={activeAnnouncement}
type="cta-label"
/>
</span>
</dd>
<dt>CTA Link</dt>
<dd>{activeAnnouncement.cta_link}</dd>
<dt>Status</dt>
<dd>{activeAnnouncement.label}</dd>
<dt>Audience</dt>
<dd>{activeAnnouncement.audience}</dd>
<dt>Created At</dt>
<dd>
{new Date(activeAnnouncement.created_at).toLocaleString()}
</dd>
<dt>Updated At</dt>
<dd>
{new Date(activeAnnouncement.updated_at).toLocaleString()}
</dd>
</dl>
<div className={styles.buttons}>
<button
className={styles.deleteBtn}
onClick={() =>
void handleDeleteAnnouncement(
activeAnnouncement.announcement_id,
)
}
>
Delete
</button>
<button
onClick={() =>
void handleEditAnnouncement(
activeAnnouncement.announcement_id,
)
}
>
Edit
</button>
</div>
</div>
)}
{/* Preview Modal */}
{activeAnnouncement && (
<div className={styles.previewModalWrapper}>
<div className={styles.previewModal}>
{bigImageIsLoading && (
<div className={styles.loader}>Loading...</div>
)}
{bigImageUnavailable ? (
<Image
alt="Fallback image"
width={500}
height={300}
key={activeAnnouncement.id}
src="/images/announcements/fallback/big.svg"
onLoadingComplete={() => setBigImageIsLoading(false)}
/>
) : (
<Image
alt="Announcement preview"
width={500}
height={300}
key={activeAnnouncement.id}
src={bigImagePath}
onLoadingComplete={() => setBigImageIsLoading(false)}
onError={() => setBigImageUnavailable(true)}
/>
)}
<dl>
<dt>
<span className={styles.missingLabelContainer}>
<LocalizedAnnouncementString
announcement={activeAnnouncement}
type="title"
/>
<MissingFluentId
announcement={activeAnnouncement}
type="title"
/>
</span>
</dt>
<dd>
<span className={styles.missingLabelContainer}>
<LocalizedAnnouncementString
announcement={activeAnnouncement}
type="description"
/>
<MissingFluentId
announcement={activeAnnouncement}
type="title"
/>
</span>
</dd>
</dl>
<a href={activeAnnouncement.cta_link}>
<span className={styles.missingLabelContainer}>
<LocalizedAnnouncementString
announcement={activeAnnouncement}
type="cta-label"
/>
<MissingFluentId
announcement={activeAnnouncement}
type="cta-label"
/>
</span>
</a>
</div>
</div>
)}
</div>
<AnnouncementsModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
announcementToEdit={activeAnnouncementToEdit}
onAddAnnouncement={handleAddAnnouncement}
onUpdateAnnouncement={handleUpdateAnnouncement}
isSubmitting={isSubmitting}
setIsSubmitting={setIsSubmitting}
/>
</div>
);
};
type LocalizedAnnouncementStringProps = {
announcement: AnnouncementRow;
type: "title" | "description" | "cta-label";
};
export const MissingFluentId = (props: LocalizedAnnouncementStringProps) => {
const key = `announcement-${props.announcement.announcement_id}-${props.type}`;
const l10n = useL10n();
const pathname = usePathname();
if (key === l10n.getString(key) && pathname === "/admin/announcements") {
return <span className={styles.missingLabel}>Missing fluent ID</span>;
}
};