mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
added more things
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user