Implement share and export components
This commit is contained in:
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user