+
+
+
+ Close
+ {linkUrl && (
+
+ Open Link
+
+ )}
+
+
+
+ >
+ );
+}
diff --git a/src/components/generic/Dialogs/UnsavedChangesDialog.jsx b/src/components/generic/Dialogs/UnsavedChangesDialog.jsx
new file mode 100644
index 0000000..fcca2d3
--- /dev/null
+++ b/src/components/generic/Dialogs/UnsavedChangesDialog.jsx
@@ -0,0 +1,72 @@
+// ─── components/generic/Dialogs/UnsavedChangesDialog.jsx ─────────────────────
+import { useRef } from "react";
+import { AlertTriangle } from "lucide-react";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+
+/**
+ * Generic "leave without saving?" prompt. Rendered by useUnsavedChangesGuard —
+ * see that hook for the intended integration (drop its returned `dialog`
+ * anywhere in the page JSX, no direct usage of this component needed).
+ */
+export function UnsavedChangesDialog({
+ open,
+ onConfirm,
+ onCancel,
+ title = "Unsaved changes",
+ description = "You have unsaved changes. If you leave this page now, they will be lost.",
+ confirmLabel = "Leave without saving",
+ cancelLabel = "Stay on this page",
+}) {
+ // Radix's AlertDialogAction/Cancel both auto-dismiss on click (firing
+ // onOpenChange(false)) *in addition to* their own onClick. Without this
+ // flag, clicking Action fires onConfirm() then onOpenChange(false) fires
+ // onCancel() right behind it — the reset immediately undoes the confirm,
+ // so "Leave without saving" silently does nothing.
+ const confirmedRef = useRef(false);
+
+ const handleConfirm = () => {
+ confirmedRef.current = true;
+ onConfirm?.();
+ };
+
+ const handleOpenChange = (next) => {
+ if (next) return;
+ if (confirmedRef.current) {
+ confirmedRef.current = false;
+ return;
+ }
+ onCancel?.();
+ };
+
+ return (
+
+
+
+
+
+ {title}
+
+ {description}
+
+
+ {cancelLabel}
+
+ {confirmLabel}
+
+
+
+
+ );
+}
diff --git a/src/components/generic/StickyAnnouncementBar.jsx b/src/components/generic/StickyAnnouncementBar.jsx
index 5c605da..09e9a5e 100644
--- a/src/components/generic/StickyAnnouncementBar.jsx
+++ b/src/components/generic/StickyAnnouncementBar.jsx
@@ -1,95 +1,125 @@
-import { useCallback } from "react";
+import { useCallback, useState } from "react";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
+import {
+ AlertDialog, AlertDialogAction, AlertDialogCancel,
+ AlertDialogContent, AlertDialogDescription,
+ AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
import { useNavigate } from "react-router-dom";
import { useClientNotifications } from "@/contexts/ClientNotificationContext";
-import { NotificationIcon, getTypeAccent, resolveNotificationLink } from "@/components/generic/notificationDisplay";
+import { NotificationIcon, getTypeAccent, resolveNotificationLink, resolveStickyStyle } from "@/components/generic/notificationDisplay";
-function resolveStickyStyle(data) {
- // Supports multiple possible shapes without tightly coupling to one admin UI.
- const style = data?.sticky_style ?? data?.stickyStyle ?? data?.stickyColors ?? data?.colors ?? null;
- if (!style) return null;
-
- const background = style.background ?? style.bg ?? style.backgroundColor ?? null;
- const text = style.text ?? style.color ?? style.foreground ?? null;
- const border = style.border ?? style.borderColor ?? null;
-
- if (!background && !text && !border) return null;
-
- return {
- ...(background ? { backgroundColor: background } : null),
- ...(text ? { color: text } : null),
- ...(border ? { borderColor: border } : null),
- };
+// Explicit link_url (from the admin "On Open" section) always wins. Falls back
+// to the type-based resolver for broadcasts sent before that field existed.
+function resolveClickAction(stickyAnnouncement) {
+ const explicitUrl = stickyAnnouncement.data?.linkUrl || null;
+ if (explicitUrl) {
+ return {
+ label: "Open Link",
+ go: (navigate) => (explicitUrl.startsWith("/")
+ ? navigate(explicitUrl)
+ : window.open(explicitUrl, "_blank", "noopener,noreferrer")),
+ };
+ }
+ return resolveNotificationLink(stickyAnnouncement.type, stickyAnnouncement.data);
}
export default function StickyAnnouncementBar() {
const navigate = useNavigate();
const { stickyAnnouncement, markSeen } = useClientNotifications();
+ const [detailsOpen, setDetailsOpen] = useState(false);
const onDismiss = useCallback(async () => {
if (!stickyAnnouncement) return;
await markSeen(stickyAnnouncement.notification_id);
}, [stickyAnnouncement, markSeen]);
- const onClickBanner = useCallback(async () => {
+ // Opening the dialog must NOT mark it seen — markSeen clears
+ // stickyAnnouncement, which would unmount this component (dialog included)
+ // before it ever shows. Only the X button dismisses/marks seen.
+ const onClickBanner = useCallback(() => {
if (!stickyAnnouncement) return;
-
- const id = stickyAnnouncement.notification_id;
- await markSeen(id);
-
- const link = resolveNotificationLink(stickyAnnouncement.type, stickyAnnouncement.data);
- if (link) await link.go(navigate);
- }, [stickyAnnouncement, markSeen, navigate]);
+ setDetailsOpen(true);
+ }, [stickyAnnouncement]);
if (!stickyAnnouncement) return null;
const accentClass = getTypeAccent(stickyAnnouncement.type);
const inlineStyle = resolveStickyStyle(stickyAnnouncement.data);
+ const clickAction = resolveClickAction(stickyAnnouncement);
return (
-
+ <>
-
-
-
-
-
-
-
- {stickyAnnouncement.title || "Announcement"}
-
-
- {stickyAnnouncement.message || ""}
-
-
-
-
-
-
+
+ {/* Full-content view — plain text info, or with an Open Link action
+ when the announcement was created with a link (see AddNotificationBroadcast's
+ "On Open" section). */}
+
+
+
+
+
navigate("/")}>
diff --git a/src/modules/admin/pages/advertisements/AddAdvertisement.jsx b/src/modules/admin/pages/advertisements/AddAdvertisement.jsx
index 9bd26cf..0eba80a 100644
--- a/src/modules/admin/pages/advertisements/AddAdvertisement.jsx
+++ b/src/modules/admin/pages/advertisements/AddAdvertisement.jsx
@@ -12,6 +12,7 @@ import {
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
import { useAuth } from "@/contexts/AuthContext";
+import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { resolveAssetSrc } from "@/utils/media.util";
import { cn } from "@/lib/utils";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
@@ -63,6 +64,21 @@ const schema = z.object({
}
});
+// TODO(ads-6): Rework this wizard to match the target spec:
+// Step 1 Placement — choose ONLY Dashboard, Tier Plans, or Course Details
+// (depends on ads-1 registry re-categorization).
+// Step 2 Content — pick "full image" vs "content + image":
+// full image -> image only
+// content+img -> badge label, headline, description,
+// CTAs, redirect link
+// Step 3 Page Builder — only shown when no redirect link was provided;
+// builds an internal landing page (title, description,
+// body, links, etc.) — new step, doesn't exist yet.
+// Step 4 Scheduling & Display — start date, end date, order, and an
+// active/draft switch labeled "Draft" when off
+// (currently has start/end/order but check the
+// on/off switch's Draft/Inactive labeling matches).
+// Step 5 Review — display all details.
// ─── Steps ────────────────────────────────────────────────────────────────────
// richOnly steps are skipped entirely for placements whose format isn't a
// RICH_CONTENT_TYPES format (banner/popup/sidebar never had CTAs).
@@ -457,7 +473,7 @@ export default function AddAdvertisement() {
getValues,
watch,
setValue,
- formState: { errors },
+ formState: { errors, isDirty },
} = useForm({
resolver: zodResolver(schema),
defaultValues: {
@@ -477,6 +493,12 @@ export default function AddAdvertisement() {
const { fields: ctaFields, append: appendCta, remove: removeCta } = useFieldArray({ control, name: "ctas" });
+ // selectedPage/selectedAsset live outside the form and their setValue()
+ // calls don't pass shouldDirty, so isDirty alone would miss them.
+ const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(
+ isDirty || !!selectedAsset || !!selectedPage
+ );
+
const placement = watch("placement");
const description = watch("description");
const format = PLACEMENT_MAP[placement]?.format;
@@ -522,7 +544,7 @@ export default function AddAdvertisement() {
};
const res = await createAdvertisement(payload);
- if (res) navigate("/admin/advertisements");
+ if (res) { bypassOnce(); navigate("/admin/advertisements"); }
});
return (
@@ -626,6 +648,8 @@ export default function AddAdvertisement() {
setValue("image_asset_id", asset.asset_id, { shouldValidate: true });
}}
/>
+
+ {unsavedChangesDialog}
);
}
diff --git a/src/modules/admin/pages/advertisements/AdvertisementList.jsx b/src/modules/admin/pages/advertisements/AdvertisementList.jsx
index b47c44c..57a0455 100644
--- a/src/modules/admin/pages/advertisements/AdvertisementList.jsx
+++ b/src/modules/admin/pages/advertisements/AdvertisementList.jsx
@@ -27,8 +27,18 @@ export default function AdvertisementList() {
const [typeFilter, setTypeFilter] = useState("all");
const [placementFilter, setPlacementFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState("all");
+ const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
+ // TODO(ads-3): Filters are not actually filtering — the "status" filter in
+ // particular compares against the stored `status` column, but status is only
+ // recomputed on read (see deriveStatus() in
+ // controllers/admin/advertisements.controller.js) and never persisted back
+ // to the DB. An ad that lapsed to "expired" still has status="active" in
+ // the row, so filtering by status here misses/matches the wrong rows.
+ // Needs either persisting the derived status on write/read, or filtering
+ // server-side using the same derivation logic. Also verify type/placement
+ // filters actually round-trip once ads-1/ads-2 land.
useEffect(() => {
const filters = [];
if (typeFilter !== "all") filters.push({ field: "type", value: typeFilter });
@@ -103,6 +113,7 @@ export default function AdvertisementList() {
+ {/* TODO(ads-2): Remove this "All placements" dropdown entirely. */}
-
-
- setSearch(e.target.value)}
- />
+ {/* TODO(ads-5): Verify this already satisfies the spec — search only
+ fires on button click / Enter (`search` state, not `searchInput`,
+ drives the fetch effect above), typing alone does not refetch.
+ Looks done already; double-check then mark complete. */}
+
+ {/* TODO(ads-8): Add pagination controls for this grid — currently
+ always fetches page 1 / limit 24 with no way to reach further
+ pages (see `pagination` from useAdvertisements, already returned
+ by the API but unused here). */}
{/* ── Grid ───────────────────────────────────────────────────── */}
{loading ? (
diff --git a/src/modules/admin/pages/advertisements/EditAdvertisement.jsx b/src/modules/admin/pages/advertisements/EditAdvertisement.jsx
index 4ce87b0..72ec136 100644
--- a/src/modules/admin/pages/advertisements/EditAdvertisement.jsx
+++ b/src/modules/admin/pages/advertisements/EditAdvertisement.jsx
@@ -9,6 +9,7 @@ import { House, Plus, Trash2, ImagePlus } from "lucide-react";
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
import { useAuth } from "@/contexts/AuthContext";
+import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { resolveAssetSrc } from "@/utils/media.util";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
@@ -144,6 +145,8 @@ export default function EditAdvertisement() {
const { fields: ctaFields, append: appendCta, remove: removeCta } = useFieldArray({ control, name: "ctas" });
+ const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
+
const placement = watch("placement");
const description = watch("description");
const format = PLACEMENT_MAP[placement]?.format;
@@ -197,7 +200,7 @@ export default function EditAdvertisement() {
}, [advertisementId]);
const onSubmit = async (values) => {
- if (!isDirty) return navigate(-1);
+ if (!isDirty) { bypassOnce(); return navigate(-1); }
const payload = {
...values,
@@ -209,7 +212,7 @@ export default function EditAdvertisement() {
};
const res = await updateAdvertisement(advertisementId, payload);
- if (res) navigate("/admin/advertisements");
+ if (res) { bypassOnce(); navigate("/admin/advertisements"); }
};
if (!ready) {
@@ -466,6 +469,8 @@ export default function EditAdvertisement() {
setValue("image_asset_id", asset.asset_id, { shouldValidate: true, shouldDirty: true });
}}
/>
+
+ {unsavedChangesDialog}
);
}
\ No newline at end of file
diff --git a/src/modules/admin/pages/assets/AddAsset.jsx b/src/modules/admin/pages/assets/AddAsset.jsx
index 43f1d76..9b1adef 100644
--- a/src/modules/admin/pages/assets/AddAsset.jsx
+++ b/src/modules/admin/pages/assets/AddAsset.jsx
@@ -9,6 +9,7 @@ import { ArrowLeft, UploadCloud, X, FileVideo, FileText, Image, FileAudio } from
import { useAssets } from "@/contexts/AdminAssetsContext";
import { useAuth } from "@/contexts/AuthContext";
+import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -130,7 +131,7 @@ export default function AddAsset() {
watch,
setError,
clearErrors,
- formState: { errors },
+ formState: { errors, isDirty },
} = useForm({
resolver: zodResolver(schema),
defaultValues: {
@@ -145,6 +146,10 @@ export default function AddAsset() {
const isVideo = file?.type?.startsWith("video/");
const isAudio = file?.type?.startsWith("audio/");
+ // setValue("_file", ...) doesn't mark isDirty (no shouldDirty), so a
+ // picked-but-unsubmitted file wouldn't otherwise be caught by the guard.
+ const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty || !!file);
+
// ── Auto-derive file_type from MIME ───────────────────────────────────────
const fileType = file ? resolveFileType(file.type) : null;
@@ -188,7 +193,7 @@ export default function AddAsset() {
createdBy: user?.user_id,
});
- if (result) navigate(-1);
+ if (result) { bypassOnce(); navigate(-1); }
};
return (
@@ -357,6 +362,8 @@ export default function AddAsset() {
+
+ {unsavedChangesDialog}
);
}
\ No newline at end of file
diff --git a/src/modules/admin/pages/assets/EditAsset.jsx b/src/modules/admin/pages/assets/EditAsset.jsx
index 58abec4..418ae51 100644
--- a/src/modules/admin/pages/assets/EditAsset.jsx
+++ b/src/modules/admin/pages/assets/EditAsset.jsx
@@ -9,6 +9,7 @@ import { ArrowLeft, UploadCloud, X, FileVideo, FileText, Image, RefreshCw } from
import { useAssets } from "@/contexts/AdminAssetsContext";
import { useAuth } from "@/contexts/AuthContext";
+import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -177,6 +178,8 @@ export default function EditAsset() {
const isVideo = asset?.file_type === "video";
const hasThumbnailChange = !!thumbnailRef.current;
+ const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty || hasThumbnailChange);
+
const onSubmit = async (data) => {
const result = await updateAsset(
assetId,
@@ -191,6 +194,7 @@ export default function EditAsset() {
);
if (!result) return;
+ bypassOnce();
navigate(-1);
};
@@ -340,6 +344,8 @@ export default function EditAsset() {
Lessons run independently — build content here once,
@@ -28,7 +28,7 @@ export default function LessonLibraryList() {
. Removing a lesson from a unit only detaches it; the lesson stays in this library.
-
+
*/}
diff --git a/src/modules/admin/pages/library/units/AddLibraryUnit.jsx b/src/modules/admin/pages/library/units/AddLibraryUnit.jsx
index 4bbf68e..c3cea80 100644
--- a/src/modules/admin/pages/library/units/AddLibraryUnit.jsx
+++ b/src/modules/admin/pages/library/units/AddLibraryUnit.jsx
@@ -1,66 +1,451 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
-import { useForm, useWatch } from "react-hook-form";
-import { z } from "zod";
+import { useForm, useFieldArray, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
-import { ArrowLeft } from "lucide-react";
+import { z } from "zod";
+import { nanoid } from "nanoid";
+import {
+ ArrowLeft, ChevronLeft, ChevronRight, Check,
+ FileText, BookOpen, LayoutTemplate, ClipboardCheck,
+ Plus, Trash2,
+} from "lucide-react";
import { useLibrary } from "@/contexts/AdminLibraryContext";
import { useAuth } from "@/contexts/AuthContext";
import { PageMeta } from "@/contexts/MetadataContext";
import api from "@/utils/api.util";
+import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
+import { Badge } from "@/components/ui/badge";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import {
+ Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription,
+ DrawerFooter, DrawerClose,
+} from "@/components/ui/drawer";
+import { BlockList, DEFAULT_CONTENT } from "@/components/generic/BlockList";
+import { AddBlockMenu } from "@/components/generic/AddBlockMenu";
+import { PreviewContent, PreviewChrome } from "../../../components/courses/LessonsPreview";
+import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
+
+function makeBlock(type) {
+ return { id: nanoid(), type, content: { ...DEFAULT_CONTENT[type] } };
+}
+
+// ─── Schema ───────────────────────────────────────────────────────────────────
+const lessonSchema = z.object({
+ title: z.string().min(1, "Title is required."),
+ description: z.string().optional(),
+ objectives: z.array(
+ z.object({ value: z.string().min(1, "Objective cannot be empty.") })
+ ).optional(),
+ blocks: z.array(z.any()).optional(),
+});
const schema = z.object({
title: z.string().min(1, "Title is required."),
description: z.string().optional(),
subscription: z.string().optional(),
+ lessons: z.array(lessonSchema).optional(),
});
+const DEFAULT_VALUES = {
+ title: "",
+ description: "",
+ subscription: "",
+ lessons: [],
+};
+
+const STEPS = [
+ { id: 0, label: "Create Unit", icon: FileText },
+ { id: 1, label: "Lessons", icon: BookOpen },
+ { id: 2, label: "Page Builder", icon: LayoutTemplate },
+ { id: 3, label: "Review", icon: ClipboardCheck },
+];
+
+// Fields validated with trigger() before advancing past each step.
+// Empty array means "validate the whole form" (nothing new to check that step).
+const STEP_FIELDS = [["title", "description", "subscription"], ["lessons"], [], []];
+
function FieldError({ message }) {
if (!message) return null;
return
Units run independently — build them here once,
@@ -28,7 +28,7 @@ export default function UnitLibraryList() {
. Removing a unit from a course only detaches it; the unit stays in this library.
+ setValue("link_mode", "info", { shouldDirty: true })}
+ >
+ Text info only
+
+ setValue("link_mode", "link", { shouldDirty: true })}
+ >
+ Include a link
+
+
+
+ {linkMode === "link" ? (
+
+ Link URL
+
+
+
+ Shows an "Open Link" button in the full-content view. Internal paths (starting with /) navigate in-app; anything else opens in a new tab.
+
+
+ ) : (
+
+ The full-content view will show just the title and message, with no action button.
+
Fills in the title and message below — you can still edit them after.
+
+ )}
Title
@@ -171,8 +222,8 @@ export default function EditNotificationBroadcast() {
@@ -210,7 +261,7 @@ export default function EditNotificationBroadcast() {
setValue("show_in_sticky", v === true, { shouldValidate: true })}
+ onCheckedChange={(v) => setValue("show_in_sticky", v === true, { shouldValidate: true, shouldDirty: true })}
/>
Show in Sticky Announcements
@@ -221,7 +272,7 @@ export default function EditNotificationBroadcast() {
setValue("show_in_notifications", v === true, { shouldValidate: true })}
+ onCheckedChange={(v) => setValue("show_in_notifications", v === true, { shouldValidate: true, shouldDirty: true })}
/>
Show in Notifications
@@ -230,6 +281,44 @@ export default function EditNotificationBroadcast() {
+ {showInSticky && (
+
+
+ setValue("link_mode", "info", { shouldDirty: true })}
+ >
+ Text info only
+
+ setValue("link_mode", "link", { shouldDirty: true })}
+ >
+ Include a link
+
+
+
+ {linkMode === "link" ? (
+
+ Link URL
+
+
+
+ Shows an "Open Link" button in the full-content view. Internal paths (starting with /) navigate in-app; anything else opens in a new tab.
+
+
+ ) : (
+
+ The full-content view will show just the title and message, with no action button.
+
@@ -123,22 +137,26 @@ function EditNotificationTemplateInner() {
)}
-
-
-
- This is a system notification — code fires it by referencing this exact type,
- so the type is locked. Label, title and message are still fully editable.
-
-
+ {!isCustom && (
+
+
+
+ This is a system notification — code fires it by referencing this exact type,
+ so the type is locked. Label, title and message are still fully editable.
+
+
+ )}
-
- Type
-
-
Cannot be changed — this is what code looks up.
-
+ {!isCustom && (
+
+ Type
+
+
Cannot be changed — this is what code looks up.
+
+ )}
Label *
@@ -159,11 +177,13 @@ function EditNotificationTemplateInner() {
- Plain text only — no HTML, no conditional logic, just straight{" "}
- {"{{placeholder}}"} tokens.
+ {isCustom
+ ? "Plain text only — this is copied straight into the announcement as-is."
+ : (<>Plain text only — no HTML, no conditional logic, just straight{" "}
+ {"{{placeholder}}"} tokens.>)}
- Every template here is system-triggered — code fires it by referencing its
- exact type, so no template can be added or removed from this screen. Only the title and
- message wording is editable.
+ System templates are locked — code fires them by referencing their exact
+ type, so only the title and message wording is editable, never the type itself, and they
+ can't be deleted.
- Draft vs. Sent: a Sent template is the version actually
- used for real notifications right now. Editing a Sent template doesn't change what goes out
- immediately — it's held as a pending change until you press Publish again.
+ Custom templates (no lock icon) are reusable title/message presets you
+ create — pick one from the "Load from template" dropdown when composing a new announcement
+ to skip retyping recurring wording. You can freely create, edit, and delete these.
- Only plain text is supported — no HTML, no conditional logic, just straight{" "}
- {"{{placeholder}}"} tokens that get swapped
- for real values when the notification fires.
+ Draft vs. Sent (system templates only): a Sent template
+ is the version actually used for real notifications right now. Editing a Sent template
+ doesn't change what goes out immediately — it's held as a pending change until you press{" "}
+ Publish again.