Implement share and export components

This commit is contained in:
adilallo
2026-04-29 22:27:46 -06:00
parent a31a36c926
commit a37a72c71d
58 changed files with 3153 additions and 117 deletions
+108 -11
View File
@@ -13,6 +13,7 @@ import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
import { useCreateFlowExit } from "./hooks/useCreateFlowExit";
import { useCreateFlowFinalize } from "./hooks/useCreateFlowFinalize";
import { useTemplateReviewActions } from "./hooks/useTemplateReviewActions";
import { useCompletedRuleShareExport } from "./hooks/useCompletedRuleShareExport";
import CreateFlowFooter from "../../components/navigation/CreateFlowFooter";
import CreateFlowTopNav from "../../components/navigation/CreateFlowTopNav";
import {
@@ -41,7 +42,12 @@ import {
clearAnonymousCreateFlowStorage,
setTransferPendingFlag,
} from "./utils/anonymousDraftStorage";
import { createFlowStateFromPublishedRule } from "../../../lib/create/publishedDocumentToCreateFlowState";
import type { CreateFlowMethodCardFacetSection } from "./types";
import {
createFlowStateFromPublishedRule,
isPublishedRuleSelectionMissing,
methodSectionsPinsFromPublishedHydratePatch,
} from "../../../lib/create/publishedDocumentToCreateFlowState";
import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule";
import { deleteServerDraft } from "../../../lib/create/api";
import messages from "../../../messages/en/index";
@@ -60,6 +66,7 @@ import { useMessages, useTranslation } from "../../contexts/MessagesContext";
import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer";
import { SignedInDraftHydration } from "./SignedInDraftHydration";
import Alert from "../../components/modals/Alert";
import Share from "../../components/modals/Share";
import {
CreateFlowDraftSaveBannerProvider,
useCreateFlowDraftSaveBanner,
@@ -152,6 +159,37 @@ function CreateFlowLayoutContent({
>(null);
const [communitySaveMagicLinkSuccess, setCommunitySaveMagicLinkSuccess] =
useState(false);
const [completedFlowBanner, setCompletedFlowBanner] = useState<{
key: string;
status: "positive" | "danger";
title: string;
description?: string;
} | null>(null);
const [shareModalOpen, setShareModalOpen] = useState(false);
const {
copyPublishedRuleLink,
mailtoPublishedRule,
sharePublishedRuleViaSignal,
sharePublishedRuleViaSlack,
sharePublishedRuleViaDiscord,
onSelectExportFormat: onCompletedExportFormat,
} = useCompletedRuleShareExport({
setActionBanner: setCompletedFlowBanner,
});
const handleOpenCompletedShareModal = () => {
if (!readLastPublishedRule()) {
setCompletedFlowBanner({
key: "completedShareNoRule",
status: "danger",
title: create.reviewAndComplete.completed.shareNoRuleTitle,
description: create.reviewAndComplete.completed.shareNoRuleDescription,
});
return;
}
setShareModalOpen(true);
};
const loginReturnPath =
currentStep === "edit-rule"
@@ -267,14 +305,47 @@ function CreateFlowLayoutContent({
const titleOk =
typeof state.title === "string" && state.title.trim().length > 0;
const sectionsClear = (state.sections?.length ?? 0) === 0;
/** Stale template `sections` (e.g. Values-only) makes final-review rows wrong; re-hydrate until cleared. */
if (titleOk && editingId === last.id && sectionsClear) {
const patch = createFlowStateFromPublishedRule(last);
const pinPatch = methodSectionsPinsFromPublishedHydratePatch(patch);
const METHOD_CARD_PIN_FACETS: readonly CreateFlowMethodCardFacetSection[] =
["communication", "membership", "decisionApproaches", "conflictManagement"];
const needsPinMerge = METHOD_CARD_PIN_FACETS.some(
(key) =>
pinPatch[key] === true &&
state.methodSectionsPinCommitted?.[key] !== true,
);
/**
* Skip repeat merges once template `sections` are cleared **and** published
* facet selections are present. Without the selection check, TopNav **Edit**
* (`sections: []` before navigate) matched only `sectionsClear` and skipped
* the merge — method-card steps saw empty `selected*Ids` until a confirm.
*
* Still merge {@link methodSectionsPinsFromPublishedHydratePatch}: selections
* may already match draft state while compact CardStack pins stayed false
* (pins are normally set only on facet **Confirm**).
*/
if (
titleOk &&
editingId === last.id &&
sectionsClear &&
!isPublishedRuleSelectionMissing(state, patch)
) {
if (needsPinMerge) {
updateState({
methodSectionsPinCommitted: {
...state.methodSectionsPinCommitted,
...pinPatch,
},
});
}
return;
}
updateState({
...createFlowStateFromPublishedRule(last),
/** Keep UI-only facet pin flags across published re-hydration (wizard draft field; not stored on publish). */
methodSectionsPinCommitted: state.methodSectionsPinCommitted,
...patch,
methodSectionsPinCommitted: {
...state.methodSectionsPinCommitted,
...pinPatch,
},
});
}, [
currentStep,
@@ -286,6 +357,12 @@ function CreateFlowLayoutContent({
state.sections?.length,
]);
useEffect(() => {
if (currentStep !== "completed") {
setCompletedFlowBanner(null);
}
}, [currentStep]);
const handleCommunitySaveMagicLinkSubmit = useCallback(async () => {
setCommunitySaveMagicLinkError(null);
setCommunitySaveMagicLinkSuccess(false);
@@ -381,11 +458,7 @@ function CreateFlowLayoutContent({
undefined));
/**
* Top banner stack rendered above the main column when any of the
* shell-level statuses are active. Each entry maps to one `<Alert>`;
* we filter out empty messages so the wrapper only mounts when at
* least one banner is actually showing. Order here is the visual
* stacking order (top → bottom).
* Top banner stack above the main column; order is top → bottom.
*/
const topBanners: Array<{
key: string;
@@ -440,6 +513,15 @@ function CreateFlowLayoutContent({
onClose: () => setCommunitySaveMagicLinkSuccess(false),
}
: null,
completedFlowBanner
? {
key: `completedFlow-${completedFlowBanner.key}`,
status: completedFlowBanner.status,
title: completedFlowBanner.title,
description: completedFlowBanner.description,
onClose: () => setCompletedFlowBanner(null),
}
: null,
].filter((b): b is NonNullable<typeof b> => b !== null);
return (
@@ -475,11 +557,26 @@ function CreateFlowLayoutContent({
<Suspense fallback={null}>
<PostLoginDraftTransfer sessionUser={sessionUser} />
</Suspense>
<Share
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
onCopyLink={() => void copyPublishedRuleLink()}
onEmailShare={mailtoPublishedRule}
onSignalShare={() => void sharePublishedRuleViaSignal()}
onSlackShare={() => void sharePublishedRuleViaSlack()}
onDiscordShare={() => void sharePublishedRuleViaDiscord()}
/>
<CreateFlowTopNav
hasShare={isCompletedStep}
hasExport={isCompletedStep}
hasEdit={isCompletedStep}
saveDraftOnExit={saveDraftOnExit}
onShare={
isCompletedStep ? () => void handleOpenCompletedShareModal() : undefined
}
onSelectExportFormat={
isCompletedStep ? onCompletedExportFormat : undefined
}
onEdit={
isCompletedStep
? () => {
@@ -0,0 +1,374 @@
"use client";
import { useCallback } from "react";
import { useTranslation } from "../../../contexts/MessagesContext";
import { readLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
import {
buildMailtoShareHref,
buildSlackWebShareUrl,
DISCORD_NATIVE_DM_HUB_URL,
DISCORD_WEB_DM_HUB_URL,
scheduleNativeSchemeThenFallback,
SLACK_NATIVE_OPEN_URL,
type NativeFallbackTimers,
type NativeNavigateDeps,
} from "../../../../lib/create/shareChannels";
import {
buildPublicRuleUrl,
downloadStoredRuleAsPdf,
downloadTextFile,
exportFilenameBase,
exportStoredRuleAsCsv,
exportStoredRuleAsMarkdown,
} from "../../../../lib/create/ruleExport";
export type CompletedFlowActionBanner = {
key: string;
status: "positive" | "danger";
title: string;
description?: string;
};
function browserNativeShareNavigateDeps(win: Window): NativeNavigateDeps {
return {
assignLocationHref: (url: string): void => {
// Transient <a>: same-tab custom-protocol handshake as location.href without replacing the SPA.
const anchor = win.document.createElement("a");
anchor.href = url;
anchor.rel = "noreferrer noopener";
anchor.style.position = "absolute";
anchor.style.left = "-9999px";
win.document.body.appendChild(anchor);
anchor.click();
anchor.remove();
},
getVisibilityState: (): Document["visibilityState"] =>
win.document.visibilityState,
onVisibilityChange: (listener: () => void): void => {
win.document.addEventListener("visibilitychange", listener);
},
offVisibilityChange: (listener: () => void): void => {
win.document.removeEventListener("visibilitychange", listener);
},
};
}
function browserNativeTimers(win: Window): NativeFallbackTimers {
return {
setTimeout: (cb: () => void, ms: number): unknown => win.setTimeout(cb, ms),
clearTimeout: (handle: unknown): void =>
win.clearTimeout(
handle as ReturnType<typeof win.setTimeout>,
),
};
}
/**
* After native app handoff, the page can stay `visibilityState === "visible"` while
* focus moves to the other app. Skip clipboard fallbacks in that case to avoid
* `NotAllowedError` noise when Slack/compose already succeeded.
*/
function shouldSkipShareClipboardFallback(win: Window): boolean {
return (
win.document.visibilityState === "hidden" || !win.document.hasFocus()
);
}
function resolvePublishedRuleShareContext(windowObj: Window): {
url: string;
title: string;
text: string;
} | null {
const rule = readLastPublishedRule();
if (!rule) return null;
const url = buildPublicRuleUrl(windowObj.location.origin, rule.id);
const summary =
typeof rule.summary === "string" ? rule.summary.trim() : "";
const text = summary.length > 0 ? summary : rule.title;
return { url, title: rule.title, text };
}
/**
* Share / export handlers for the completed step (`readLastPublishedRule`).
*/
export function useCompletedRuleShareExport({
setActionBanner,
}: {
setActionBanner: (_: CompletedFlowActionBanner | null) => void;
}): {
copyPublishedRuleLink: () => Promise<void>;
mailtoPublishedRule: () => void;
sharePublishedRuleViaSignal: () => Promise<void>;
sharePublishedRuleViaSlack: () => Promise<void>;
sharePublishedRuleViaDiscord: () => Promise<void>;
onSelectExportFormat: (_format: "pdf" | "csv" | "markdown") => void;
} {
const t = useTranslation("create.reviewAndComplete.completed");
const bannerNoRule = useCallback(() => {
setActionBanner({
key: "completedShareNoRule",
status: "danger",
title: t("shareNoRuleTitle"),
description: t("shareNoRuleDescription"),
});
}, [setActionBanner, t]);
const bannerCopied = useCallback(() => {
setActionBanner({
key: "completedShareCopied",
status: "positive",
title: t("shareLinkCopiedTitle"),
description: t("shareLinkCopiedDescription"),
});
}, [setActionBanner, t]);
const bannerCopyFailed = useCallback(() => {
setActionBanner({
key: "completedShareCopyFailed",
status: "danger",
title: t("shareCopyFailedTitle"),
description: t("shareCopyFailedDescription"),
});
}, [setActionBanner, t]);
const copyUrlToClipboard = useCallback(
async (
url: string,
banner?: () => void,
options?: { suppressFailureWhenDocumentNotFocused?: boolean },
) => {
try {
await navigator.clipboard.writeText(url);
(banner ?? bannerCopied)();
} catch {
if (
options?.suppressFailureWhenDocumentNotFocused === true &&
typeof window !== "undefined" &&
shouldSkipShareClipboardFallback(window)
) {
return;
}
bannerCopyFailed();
}
},
[bannerCopied, bannerCopyFailed],
);
const copyPublishedRuleLink = useCallback(async () => {
if (typeof window === "undefined") return;
const ctx = resolvePublishedRuleShareContext(window);
if (!ctx) {
bannerNoRule();
return;
}
await copyUrlToClipboard(ctx.url);
}, [bannerNoRule, copyUrlToClipboard]);
const mailtoPublishedRule = useCallback(() => {
if (typeof window === "undefined") return;
const ctx = resolvePublishedRuleShareContext(window);
if (!ctx) {
bannerNoRule();
return;
}
const body = `${ctx.text}\n\n${ctx.url}`;
window.location.href = buildMailtoShareHref({
subject: ctx.title,
body,
});
}, [bannerNoRule]);
const tryNavigatorShareAbortOk = useCallback(
async (data: ShareData): Promise<boolean> => {
if (typeof navigator.share !== "function") return false;
const can =
typeof navigator.canShare !== "function" || navigator.canShare(data);
if (!can) return false;
try {
await navigator.share(data);
return true;
} catch (e) {
const err = e as { name?: string };
if (err?.name === "AbortError") return true;
return false;
}
},
[],
);
/** Prefer URL-only share data when the platform allows it (common on mobile). */
const shareViaWebShareApiOrFalse = useCallback(
async (ctx: { url: string; title: string; text: string }) => {
const urlOnly: ShareData = { url: ctx.url };
if (await tryNavigatorShareAbortOk(urlOnly)) return true;
const full: ShareData = {
title: ctx.title,
text: ctx.text,
url: ctx.url,
};
return tryNavigatorShareAbortOk(full);
},
[tryNavigatorShareAbortOk],
);
const sharePublishedRuleViaSignal = useCallback(async () => {
if (typeof window === "undefined") return;
const ctx = resolvePublishedRuleShareContext(window);
if (!ctx) {
bannerNoRule();
return;
}
if (await shareViaWebShareApiOrFalse(ctx)) return;
await copyUrlToClipboard(ctx.url);
}, [bannerNoRule, copyUrlToClipboard, shareViaWebShareApiOrFalse]);
const sharePublishedRuleViaSlack = useCallback(async () => {
if (typeof window === "undefined") return;
const ctx = resolvePublishedRuleShareContext(window);
if (!ctx) {
bannerNoRule();
return;
}
const runSlackWebComposeFallback = async (): Promise<void> => {
const slackUrl = buildSlackWebShareUrl(ctx.url);
const popup = window.open(
slackUrl,
"_blank",
"noopener,noreferrer",
);
if (popup) return;
if (shouldSkipShareClipboardFallback(window)) return;
if (await shareViaWebShareApiOrFalse(ctx)) return;
if (shouldSkipShareClipboardFallback(window)) return;
await copyUrlToClipboard(
ctx.url,
() =>
setActionBanner({
key: "completedShareSlackFallback",
status: "positive",
title: t("shareSlackFallbackTitle"),
description: t("shareSlackFallbackDescription"),
}),
{ suppressFailureWhenDocumentNotFocused: true },
);
};
scheduleNativeSchemeThenFallback(
SLACK_NATIVE_OPEN_URL,
() => void runSlackWebComposeFallback(),
browserNativeShareNavigateDeps(window),
browserNativeTimers(window),
);
}, [
bannerNoRule,
copyUrlToClipboard,
shareViaWebShareApiOrFalse,
setActionBanner,
t,
]);
const sharePublishedRuleViaDiscord = useCallback(async () => {
if (typeof window === "undefined") return;
const ctx = resolvePublishedRuleShareContext(window);
if (!ctx) {
bannerNoRule();
return;
}
if (await shareViaWebShareApiOrFalse(ctx)) return;
try {
await navigator.clipboard.writeText(ctx.url);
setActionBanner({
key: "completedShareDiscordPaste",
status: "positive",
title: t("shareDiscordPasteTitle"),
description: t("shareDiscordPasteDescription"),
});
} catch {
bannerCopyFailed();
}
scheduleNativeSchemeThenFallback(
DISCORD_NATIVE_DM_HUB_URL,
() =>
void window.open(
DISCORD_WEB_DM_HUB_URL,
"_blank",
"noopener,noreferrer",
),
browserNativeShareNavigateDeps(window),
browserNativeTimers(window),
);
}, [
bannerCopyFailed,
bannerNoRule,
shareViaWebShareApiOrFalse,
setActionBanner,
t,
]);
const onSelectExportFormat = useCallback(
(format: "pdf" | "csv" | "markdown") => {
if (typeof window === "undefined") return;
const rule = readLastPublishedRule();
if (!rule) {
setActionBanner({
key: "completedExportNoRule",
status: "danger",
title: t("shareNoRuleTitle"),
description: t("shareNoRuleDescription"),
});
return;
}
const base = exportFilenameBase(rule);
try {
if (format === "pdf") {
downloadStoredRuleAsPdf(rule);
} else if (format === "csv") {
const csv = exportStoredRuleAsCsv(rule);
downloadTextFile(
`${base}-community-rule.csv`,
csv,
"text/csv;charset=utf-8",
);
} else {
const md = exportStoredRuleAsMarkdown(rule);
downloadTextFile(
`${base}-community-rule.md`,
md,
"text/markdown;charset=utf-8",
);
}
} catch (e) {
const msg = e instanceof Error && e.message === "exportEmptyDocument";
setActionBanner({
key: "completedExportFailed",
status: "danger",
title: msg ? t("exportEmptyDocumentTitle") : t("exportFailedTitle"),
description: msg
? t("exportEmptyDocumentDescription")
: t("exportFailedDescription"),
});
}
},
[setActionBanner, t],
);
return {
copyPublishedRuleLink,
mailtoPublishedRule,
sharePublishedRuleViaSignal,
sharePublishedRuleViaSlack,
sharePublishedRuleViaDiscord,
onSelectExportFormat,
};
}
@@ -6,6 +6,10 @@ import { publishRule, updatePublishedRule } from "../../../../lib/create/api";
import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
import messages from "../../../../messages/en/index";
import type { CreateFlowState } from "../types";
import {
CREATE_FLOW_COMPLETED_CELEBRATE_QUERY,
CREATE_FLOW_COMPLETED_CELEBRATE_VALUE,
} from "../utils/flowSteps";
type AppRouterLike = { push: (_href: string) => void };
@@ -16,25 +20,13 @@ type OpenLogin = (args: {
}) => void;
export type UseCreateFlowFinalizeResult = {
/** Set when publish fails (validation, server error, or empty server message). Reset on each `finalize()` invocation. */
publishBannerMessage: string | null;
setPublishBannerMessage: (_message: string | null) => void;
/** True from the moment the publish request fires until the response resolves. */
isPublishing: boolean;
/**
* Build a publish payload from the current `CreateFlowState`, post it to
* `publishRule` (or PATCH when editing a published rule), and route to
* `/create/completed` on success.
*/
finalize: () => Promise<void>;
};
/**
* Encapsulates the Final Review → publish flow that previously lived inline
* in `CreateFlowLayoutClient`. Keeps publish state (banner + in-flight flag)
* co-located with the publish handler so the layout shell only has to wire
* the resulting message into its banner stack.
*/
/** Final Review → publish: banner + `isPublishing`, consumed by `CreateFlowLayoutClient`. */
export function useCreateFlowFinalize({
state,
router,
@@ -120,7 +112,9 @@ export function useCreateFlowFinalize({
summary: summary ?? null,
document: ruleDocument,
});
router.push("/create/completed");
router.push(
`/create/completed?${CREATE_FLOW_COMPLETED_CELEBRATE_QUERY}=${CREATE_FLOW_COMPLETED_CELEBRATE_VALUE}`,
);
return;
}
if (publishResult.status === 401) {
@@ -3,6 +3,7 @@
import { useCallback, useMemo, useState } from "react";
import { buildTemplateCustomizePrefill } from "../../../../lib/create/applyTemplatePrefill";
import { loadTemplateReviewBySlug } from "../../../../lib/create/loadTemplateReviewBySlug";
import { methodSectionsPinsForHydratedSelections } from "../../../../lib/create/publishedDocumentToCreateFlowState";
import { stripCustomRuleSelectionFields } from "../../../../lib/create/stripCustomRuleSelectionFields";
import messages from "../../../../messages/en/index";
import type {
@@ -98,10 +99,15 @@ export function useTemplateReviewActions({
return;
}
const prefill = buildTemplateCustomizePrefill(loaded.template.body);
const pinPatch = methodSectionsPinsForHydratedSelections(prefill);
const hasCommunityName =
typeof state.title === "string" && state.title.trim().length > 0;
updateState({
...prefill,
methodSectionsPinCommitted: {
...state.methodSectionsPinCommitted,
...pinPatch,
},
templateReviewBackSlug: undefined,
...(hasCommunityName
? { pendingTemplateAction: undefined }
@@ -115,7 +121,13 @@ export function useTemplateReviewActions({
router.push(
hasCommunityName ? "/create/core-values" : "/create/informational",
);
}, [router, state.title, templateReviewSlug, updateState]);
}, [
router,
state.methodSectionsPinCommitted,
state.title,
templateReviewSlug,
updateState,
]);
const handleUseWithoutChanges = useCallback(async () => {
if (!templateReviewSlug) return;
@@ -170,6 +182,9 @@ export function useTemplateReviewActions({
const hasCommunityName =
typeof prev.title === "string" && prev.title.trim().length > 0;
const pinPatch =
methodSectionsPinsForHydratedSelections(customizePrefill);
return {
...base,
...(hasValuesSeed
@@ -204,6 +219,7 @@ export function useTemplateReviewActions({
}
: {}),
sections: sectionsWithoutValues,
methodSectionsPinCommitted: pinPatch,
templateReviewBackSlug: templateReviewSlug,
...(hasCommunityName
? { pendingTemplateAction: undefined }
@@ -18,6 +18,22 @@ import {
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
} from "../../components/createFlowLayoutTokens";
import {
CREATE_FLOW_COMPLETED_CELEBRATE_QUERY,
CREATE_FLOW_COMPLETED_CELEBRATE_VALUE,
} from "../../utils/flowSteps";
function emptyCompletedDocumentState(): {
headerTitle: string;
headerDescription: string | undefined;
documentSections: CommunityRuleSection[];
} {
return {
headerTitle: "",
headerDescription: undefined,
documentSections: [],
};
}
function initialCompletedUi(
ruleIdFromUrl: string | null,
@@ -27,26 +43,14 @@ function initialCompletedUi(
documentSections: CommunityRuleSection[];
} {
if (ruleIdFromUrl) {
return {
headerTitle: "",
headerDescription: undefined,
documentSections: [],
};
return emptyCompletedDocumentState();
}
if (typeof sessionStorage === "undefined") {
return {
headerTitle: "",
headerDescription: undefined,
documentSections: [],
};
return emptyCompletedDocumentState();
}
const stored = readLastPublishedRule();
if (!stored) {
return {
headerTitle: "",
headerDescription: undefined,
documentSections: [],
};
return emptyCompletedDocumentState();
}
const parsed = parsePublishedDocumentForCommunityRuleDisplay(stored.document);
if (parsed.length === 0) {
@@ -69,12 +73,17 @@ export function CompletedScreen() {
const router = useRouter();
const searchParams = useSearchParams();
const ruleIdParam = searchParams.get("ruleId");
const celebrateInUrl =
searchParams.get(CREATE_FLOW_COMPLETED_CELEBRATE_QUERY) ===
CREATE_FLOW_COMPLETED_CELEBRATE_VALUE;
const mdUp = useCreateFlowMdUp();
const m = useMessages();
const completed = m.create.reviewAndComplete.completed;
const initial = initialCompletedUi(ruleIdParam);
const [toastDismissed, setToastDismissed] = useState(false);
/** Latch: toast copy should survive `router.replace` after we strip `?celebrate=1`. */
const [showCelebrateToast] = useState(celebrateInUrl);
const [headerTitle, setHeaderTitle] = useState(initial.headerTitle);
const [headerDescription, setHeaderDescription] = useState<
string | undefined
@@ -126,7 +135,12 @@ export function CompletedScreen() {
};
}, [ruleIdParam, router]);
const toast = !toastDismissed ? (
useEffect(() => {
if (!celebrateInUrl) return;
router.replace("/create/completed");
}, [celebrateInUrl, router]);
const toast = showCelebrateToast && !toastDismissed ? (
<div
className="fixed bottom-0 left-0 right-0 z-10 w-full"
role="status"
@@ -16,6 +16,7 @@ import {
getAssetPath,
vectorMarkPath,
} from "../../../../../lib/assetUtils";
import { methodSectionsPinsForHydratedSelections } from "../../../../../lib/create/publishedDocumentToCreateFlowState";
/**
* Targets for a `pendingTemplateAction` redirect. Customize resumes the
@@ -58,9 +59,27 @@ export function CommunityReviewScreen() {
const target = PENDING_TEMPLATE_REDIRECT_TARGET[pending.mode];
if (!target) return;
firedRedirectRef.current = true;
updateState({ pendingTemplateAction: undefined });
const pinMerge =
pending.mode === "customize"
? {
methodSectionsPinCommitted: {
...state.methodSectionsPinCommitted,
...methodSectionsPinsForHydratedSelections(state),
},
}
: {};
updateState({ pendingTemplateAction: undefined, ...pinMerge });
router.replace(target);
}, [router, state.pendingTemplateAction, updateState]);
}, [
router,
state.pendingTemplateAction,
state.methodSectionsPinCommitted,
state.selectedCommunicationMethodIds,
state.selectedMembershipMethodIds,
state.selectedDecisionApproachIds,
state.selectedConflictManagementIds,
updateState,
]);
const cardTitle =
typeof state.title === "string" && state.title.trim().length > 0
@@ -137,7 +137,7 @@ export const CREATE_FLOW_SCREEN_REGISTRY: Record<
},
completed: {
layoutKind: "completed",
figmaNodeId: "20907-213286",
figmaNodeId: "20907-213288",
messageNamespace: "create.reviewAndComplete.completed",
centeredBodyBelowMd: false,
},
+4
View File
@@ -161,6 +161,10 @@ export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE = "1" as const;
export const TEMPLATES_FACET_RECOMMEND_QUERY = "recommendTemplates" as const;
export const TEMPLATES_FACET_RECOMMEND_VALUE = "1" as const;
/** `/create/completed?celebrate=1` — post-finalize toast; set only after **initial** POST publish, not PATCH updates. */
export const CREATE_FLOW_COMPLETED_CELEBRATE_QUERY = "celebrate" as const;
export const CREATE_FLOW_COMPLETED_CELEBRATE_VALUE = "1" as const;
/** `/create/{step}?reviewReturn=…` — set when opening a custom-rule step from final-review or edit-rule via + */
export const CREATE_FLOW_REVIEW_RETURN_QUERY_KEY = "reviewReturn" as const;