added more things

This commit is contained in:
rgrgogu
2026-08-03 22:48:16 +08:00
parent 2e649fc96e
commit cd16e996e5
17 changed files with 691 additions and 1017 deletions
@@ -1,15 +1,12 @@
import { useCallback, useState } from "react";
import { X } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { useAdminNotifications } from "@/contexts/AdminNotificationContext";
import { getTierColor, getContrastText } from "@/utils/tierColors";
import { goToLink } from "@/components/generic/notificationDisplay";
import AnnouncementDetailsDialog from "@/components/generic/AnnouncementDetailsDialog";
// Admin counterpart to StickyAnnouncementBar (client). Only supports the
// explicit link_url from the "On Open" section — the type-based fallback
// resolver in notificationDisplay.jsx points at client-only routes
// explicit link_url from the composer's "Link" section — the type-based
// fallback resolver in notificationDisplay.jsx points at client-only routes
// (/course/:id, /plans, /group/:id), which don't exist in the admin app.
function resolveClickAction(stickyAnnouncement) {
const linkUrl = stickyAnnouncement.data?.linkUrl || null;
@@ -20,92 +17,70 @@ function resolveClickAction(stickyAnnouncement) {
};
}
export default function AdminStickyAnnouncementBar() {
// Up to 2 sticky alerts shown at once, stacked — no dialog on click anymore;
// the centered title text itself is the click target and redirects straight
// to the alert's link (if any).
const MAX_VISIBLE_STICKY = 2;
function AdminStickyAnnouncementRow({ announcement, onDismiss }) {
const navigate = useNavigate();
const { stickyAnnouncements, markSeen } = useAdminNotifications();
const [detailsOpen, setDetailsOpen] = useState(false);
// Only one sticky alert shows at a time — no rotation/autoplay. Dismissing
// it (X on the bar) reveals whichever is next in the queue.
const current = stickyAnnouncements[0];
const onDismiss = useCallback(async () => {
if (!current) return;
await markSeen(current.notification_id);
}, [current, markSeen]);
const onClickBanner = useCallback(() => {
if (!current) return;
setDetailsOpen(true);
}, [current]);
if (!current) return null;
const swatch = getTierColor(current.color || "indigo").swatch;
const textColor = getContrastText(swatch, current.color || "indigo");
const clickAction = resolveClickAction(current);
const swatch = getTierColor(announcement.color || "indigo").swatch;
const textColor = getContrastText(swatch, announcement.color || "indigo");
const clickAction = resolveClickAction(announcement);
return (
<>
<div role="status" className="w-full shadow-sm px-4 md:px-6" style={{ backgroundColor: swatch }}>
<div
onClick={onClickBanner}
className="relative w-full cursor-pointer rounded-none py-3 flex items-center justify-center gap-4 px-10"
<div role="status" className="w-full shadow-sm px-4 md:px-6" style={{ backgroundColor: swatch }}>
<div className="relative w-full rounded-none py-3 flex items-center justify-center px-10">
<p
className={["text-sm font-semibold leading-snug truncate", clickAction ? "cursor-pointer" : ""].join(" ")}
style={{ color: textColor }}
onClick={clickAction ? () => clickAction.go(navigate) : undefined}
>
<div className="flex items-center gap-3 min-w-0">
<p className="text-sm font-semibold leading-snug truncate" style={{ color: textColor }}>
{current.title || "Announcement"}
</p>
{announcement.title || "Announcement"}
</p>
{clickAction && (
<Button
variant="outline"
size="sm"
className="shrink-0 text-foreground"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
clickAction.go(navigate);
}}
>
{clickAction.label}
</Button>
)}
</div>
{/* Only way to dismiss a sticky alert — closing the details dialog
no longer dismisses it. */}
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
void onDismiss();
}}
onKeyDown={(e) => {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
e.stopPropagation();
void onDismiss();
}}
aria-label="Dismiss sticky announcement"
title="Dismiss"
className="absolute right-2 inline-flex items-center justify-center size-8 rounded-lg cursor-pointer hover:opacity-70 transition-opacity"
style={{ color: textColor }}
>
<X className="size-4" />
</div>
{/* Only way to dismiss a sticky alert. */}
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
void onDismiss();
}}
onKeyDown={(e) => {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
e.stopPropagation();
void onDismiss();
}}
aria-label="Dismiss sticky announcement"
title="Dismiss"
className="absolute right-2 inline-flex items-center justify-center size-8 rounded-lg cursor-pointer hover:opacity-70 transition-opacity"
style={{ color: textColor }}
>
<X className="size-4" />
</div>
</div>
</div>
);
}
<AnnouncementDetailsDialog
open={detailsOpen}
onOpenChange={setDetailsOpen}
announcement={current}
resolveClickAction={resolveClickAction}
navigate={navigate}
/>
</>
export default function AdminStickyAnnouncementBar() {
const { stickyAnnouncements, markSeen } = useAdminNotifications();
const visible = stickyAnnouncements.slice(0, MAX_VISIBLE_STICKY);
if (visible.length === 0) return null;
return (
<div className="w-full flex flex-col">
{visible.map((announcement) => (
<AdminStickyAnnouncementRow
key={announcement.notification_id}
announcement={announcement}
onDismiss={() => markSeen(announcement.notification_id)}
/>
))}
</div>
);
}
@@ -1,58 +0,0 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { resolveAssetSrc } from "@/utils/media.util";
// Shared details view for the sticky announcement bar (client + admin
// variants). Only one sticky alert is ever shown at a time — no carousel,
// no autoplay. Optional per-alert layout image renders alongside the text
// when present, single column when not.
export default function AnnouncementDetailsDialog({
open,
onOpenChange,
announcement,
resolveClickAction,
navigate,
}) {
if (!announcement) return null;
const clickAction = resolveClickAction(announcement);
const imageSrc = announcement.image ? resolveAssetSrc(announcement.image) : null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={["p-0 overflow-hidden gap-0", imageSrc ? "sm:max-w-2xl" : "sm:max-w-md"].join(" ")}>
<div className={imageSrc ? "grid sm:grid-cols-2" : ""}>
<div className="p-6 flex flex-col gap-3 min-w-0">
<DialogHeader className="text-left">
<DialogTitle className="text-lg">
{announcement.title || "Alert"}
</DialogTitle>
<DialogDescription asChild>
<p className="whitespace-pre-wrap text-left text-foreground">
{announcement.message || ""}
</p>
</DialogDescription>
</DialogHeader>
{clickAction && (
<div className="mt-auto pt-4">
<Button
className="self-start"
onClick={() => clickAction.go(navigate)}
>
{clickAction.label}
</Button>
</div>
)}
</div>
{imageSrc && (
<div className="aspect-video sm:aspect-auto sm:h-96 bg-muted flex items-center justify-center overflow-hidden">
<img src={imageSrc} alt="" className="w-full h-full object-cover" />
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -30,7 +30,7 @@ function hasLandingPage(ad) {
* "image" — full-bleed image, clickable through to the ad's redirect/CTA
*
* Props:
* ads — array of advertisement objects { content_mode, badge_label, headline, description, ctas, image, image_url, advertisement_id }
* ads — array of advertisement objects { content_mode, badge_labels, headline, description, ctas, image, image_url, advertisement_id }
* onCtaClick — (ad, cta) => void. cta is undefined for the whole-banner click on image-only ads.
*/
export function Banner({ ads, onCtaClick }) {
@@ -61,6 +61,7 @@ export function Banner({ ads, onCtaClick }) {
{ads.map((ad) => {
const imageSrc = resolveAssetSrc(ad.image) || ad.image_url || null;
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
const badgeLabels = Array.isArray(ad.badge_labels) ? ad.badge_labels : [];
return (
<CarouselItem key={ad.advertisement_id}>
@@ -88,10 +89,14 @@ export function Banner({ ads, onCtaClick }) {
<div className="tier_plans_banner w-full rounded-xl xs:p-5 sm:p-4 lg:p-10 text-white">
<div className="relative w-full items-start flex justify-between">
<div className="md:w-2xl space-y-4 xs:p-1.5 lg:p-0">
{ad.badge_label && (
<Badge className="bg-white text-black">
{ad.badge_label}
</Badge>
{badgeLabels.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
{badgeLabels.map((label, i) => (
<Badge key={i} variant="outline" className="border-white/50 bg-white/10 text-white">
{label}
</Badge>
))}
</div>
)}
{ad.headline && <div className="lg:text-5xl xs:text-4xl font-tier-ads lg:leading-16">{ad.headline}</div>}
{ad.description && <p className="leading-relaxed text-lg">{ad.description}</p>}
@@ -29,7 +29,7 @@ function hasLandingPage(ad) {
* ads are given — callers should not fall back to placeholder copy.
*
* Props:
* ads — array of advertisement objects { badge_label, headline, description, ctas, image, image_url, advertisement_id }
* ads — array of advertisement objects { badge_labels, headline, description, ctas, image, image_url, advertisement_id }
* onCtaClick — (ad, cta) => void, called when any CTA button is clicked
*/
export function Hero({ ads, onCtaClick }) {
@@ -60,6 +60,7 @@ export function Hero({ ads, onCtaClick }) {
{ads.map((ad) => {
const imageSrc = resolveAssetSrc(ad.image) || ad.image_url || null;
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
const badgeLabels = Array.isArray(ad.badge_labels) ? ad.badge_labels : [];
return (
<CarouselItem key={ad.advertisement_id}>
@@ -92,10 +93,14 @@ export function Hero({ ads, onCtaClick }) {
{/* Bottom-left content */}
<div className="absolute bottom-0 left-0 p-6 flex flex-col gap-3 xs:max-w-[280px] sm:max-w-sm lg:max-w-xl">
{ad.badge_label && (
<Badge variant="outline" className="pointer-events-none select-none w-fit border-white/30 bg-black/20 text-white">
<Megaphone /> {ad.badge_label}
</Badge>
{badgeLabels.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
{badgeLabels.map((label, i) => (
<Badge key={i} variant="outline" className="pointer-events-none select-none w-fit border-white/30 bg-black/20 text-white">
<Megaphone /> {label}
</Badge>
))}
</div>
)}
{ad.headline && (
<div className="font-bold text-white xs:text-2xl lg:text-4xl leading-tight tracking-tighter pointer-events-none select-none">
@@ -1,105 +1,77 @@
import { useCallback, useState } from "react";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useNavigate } from "react-router-dom";
import { useClientNotifications } from "@/contexts/ClientNotificationContext";
import { resolveNotificationLink } from "@/components/generic/notificationDisplay";
import { getTierColor, getContrastText } from "@/utils/tierColors";
import AnnouncementDetailsDialog from "@/components/generic/AnnouncementDetailsDialog";
// resolveNotificationLink already gives explicit link_url (the admin "On
// Open" section) precedence over the type-based fallbacks, for broadcasts
// sent before that field existed.
// Up to 2 sticky alerts shown at once, stacked — no dialog on click anymore;
// the centered title text itself is the click target and redirects straight
// to the alert's link (if any).
const MAX_VISIBLE_STICKY = 2;
function resolveClickAction(stickyAnnouncement) {
return resolveNotificationLink(stickyAnnouncement.type, stickyAnnouncement.data);
}
export default function StickyAnnouncementBar() {
function StickyAnnouncementRow({ announcement, onDismiss }) {
const navigate = useNavigate();
const { stickyAnnouncements, markSeen } = useClientNotifications();
const [detailsOpen, setDetailsOpen] = useState(false);
// Only one sticky alert shows at a time — no rotation/autoplay. Dismissing
// it (X on the bar) reveals whichever is next in the queue.
const current = stickyAnnouncements[0];
const onDismiss = useCallback(async () => {
if (!current) return;
await markSeen(current.notification_id);
}, [current, markSeen]);
const onClickBanner = useCallback(() => {
if (!current) return;
setDetailsOpen(true);
}, [current]);
if (!current) return null;
const swatch = getTierColor(current.color || "indigo").swatch;
const textColor = getContrastText(swatch, current.color || "indigo");
const clickAction = resolveClickAction(current);
const swatch = getTierColor(announcement.color || "indigo").swatch;
const textColor = getContrastText(swatch, announcement.color || "indigo");
const clickAction = resolveClickAction(announcement);
return (
<>
<div role="status" className="w-full shadow-sm px-4 md:px-6" style={{ backgroundColor: swatch }}>
<div
onClick={onClickBanner}
className="relative w-full cursor-pointer rounded-none py-3 flex items-center justify-center gap-4 px-10"
<div role="status" className="w-full shadow-sm px-4 md:px-6" style={{ backgroundColor: swatch }}>
<div className="relative w-full rounded-none py-3 flex items-center justify-center px-10">
<p
className={["text-sm font-semibold leading-snug truncate", clickAction ? "cursor-pointer" : ""].join(" ")}
style={{ color: textColor }}
onClick={clickAction ? () => clickAction.go(navigate) : undefined}
>
<div className="flex items-center gap-3 min-w-0">
<p className="text-sm font-semibold leading-snug truncate" style={{ color: textColor }}>
{current.title || "Announcement"}
</p>
{announcement.title || "Announcement"}
</p>
{clickAction && (
<Button
variant="outline"
size="sm"
className="shrink-0 text-foreground"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
clickAction.go(navigate);
}}
>
{clickAction.label}
</Button>
)}
</div>
{/* Only way to dismiss a sticky alert — closing the details dialog
no longer dismisses it. */}
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
void onDismiss();
}}
onKeyDown={(e) => {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
e.stopPropagation();
void onDismiss();
}}
aria-label="Dismiss sticky announcement"
title="Dismiss"
className="absolute right-2 inline-flex items-center justify-center size-8 rounded-lg cursor-pointer hover:opacity-70 transition-opacity"
style={{ color: textColor }}
>
<X className="size-4" />
</div>
{/* Only way to dismiss a sticky alert. */}
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
void onDismiss();
}}
onKeyDown={(e) => {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
e.stopPropagation();
void onDismiss();
}}
aria-label="Dismiss sticky announcement"
title="Dismiss"
className="absolute right-2 inline-flex items-center justify-center size-8 rounded-lg cursor-pointer hover:opacity-70 transition-opacity"
style={{ color: textColor }}
>
<X className="size-4" />
</div>
</div>
</div>
);
}
<AnnouncementDetailsDialog
open={detailsOpen}
onOpenChange={setDetailsOpen}
announcement={current}
resolveClickAction={resolveClickAction}
navigate={navigate}
/>
</>
export default function StickyAnnouncementBar() {
const { stickyAnnouncements, markSeen } = useClientNotifications();
const visible = stickyAnnouncements.slice(0, MAX_VISIBLE_STICKY);
if (visible.length === 0) return null;
return (
<div className="w-full flex flex-col">
{visible.map((announcement) => (
<StickyAnnouncementRow
key={announcement.notification_id}
announcement={announcement}
onDismiss={() => markSeen(announcement.notification_id)}
/>
))}
</div>
);
}