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;
+9
View File
@@ -7,18 +7,24 @@ import ContentCopyIcon from "./content_copy.svg";
import EditIcon from "./edit.svg";
import ExclamationIcon from "./exclamation.svg";
import ChevronRightIcon from "./chevron_right.svg";
import CsvIcon from "./csv.svg";
import LogOutIcon from "./log_out.svg";
import MailIcon from "./mail.svg";
import MarkdownCopyIcon from "./markdown_copy.svg";
import PictureAsPdfIcon from "./picture_as_pdf.svg";
import WarningIcon from "./warning.svg";
export const ICON_NAME_OPTIONS = [
"arrow_back",
"chevron_right",
"content_copy",
"csv",
"edit",
"exclamation",
"log_out",
"mail",
"markdown_copy",
"picture_as_pdf",
"warning",
] as const;
@@ -33,10 +39,13 @@ const iconMap: Record<IconName, SvgComponent> = {
arrow_back: ArrowBackIcon,
chevron_right: ChevronRightIcon,
content_copy: ContentCopyIcon,
csv: CsvIcon,
edit: EditIcon,
exclamation: ExclamationIcon,
log_out: LogOutIcon,
mail: MailIcon,
markdown_copy: MarkdownCopyIcon,
picture_as_pdf: PictureAsPdfIcon,
warning: WarningIcon,
};
+13
View File
@@ -0,0 +1,13 @@
<svg
width="24"
height="24"
viewBox="0 0 20 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid meet"
>
<path
d="M3.75 11H6.75V9.5H4.25V6.5H6.75V5H3.75C3.46667 5 3.22917 5.09583 3.0375 5.2875C2.84583 5.47917 2.75 5.71667 2.75 6V10C2.75 10.2833 2.84583 10.5208 3.0375 10.7125C3.22917 10.9042 3.46667 11 3.75 11ZM7.65 11H10.65C10.9333 11 11.1708 10.9042 11.3625 10.7125C11.5542 10.5208 11.65 10.2833 11.65 10V8.5C11.65 8.21667 11.5542 7.95417 11.3625 7.7125C11.1708 7.47083 10.9333 7.35 10.65 7.35H9.15V6.5H11.65V5H8.65C8.36667 5 8.12917 5.09583 7.9375 5.2875C7.74583 5.47917 7.65 5.71667 7.65 6V7.5C7.65 7.78333 7.74583 8.0375 7.9375 8.2625C8.12917 8.4875 8.36667 8.6 8.65 8.6H10.15V9.5H7.65V11ZM14.25 11H15.75L17.5 5H16L15 8.45L14 5H12.5L14.25 11ZM2 16C1.45 16 0.979167 15.8042 0.5875 15.4125C0.195833 15.0208 0 14.55 0 14V2C0 1.45 0.195833 0.979167 0.5875 0.5875C0.979167 0.195833 1.45 0 2 0H18C18.55 0 19.0208 0.195833 19.4125 0.5875C19.8042 0.979167 20 1.45 20 2V14C20 14.55 19.8042 15.0208 19.4125 15.4125C19.0208 15.8042 18.55 16 18 16H2ZM2 14H18V2H2V14Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,13 @@
<svg
width="24"
height="24"
viewBox="0 0 17 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid meet"
>
<path
d="M6 16C5.45 16 4.97917 15.8042 4.5875 15.4125C4.19583 15.0208 4 14.55 4 14V2C4 1.45 4.19583 0.979167 4.5875 0.5875C4.97917 0.195833 5.45 0 6 0H15C15.55 0 16.0208 0.195833 16.4125 0.5875C16.8042 0.979167 17 1.45 17 2V14C17 14.55 16.8042 15.0208 16.4125 15.4125C16.0208 15.8042 15.55 16 15 16H6ZM6 14H15V2H6V14ZM2 20C1.45 20 0.979167 19.8042 0.5875 19.4125C0.195833 19.0208 0 18.55 0 18V4H2V18H13V20H2ZM7.25 11H8.75V6.5H9.75V9.5H11.25V6.5H12.25V11H13.75V6C13.75 5.71667 13.6542 5.47917 13.4625 5.2875C13.2708 5.09583 13.0333 5 12.75 5H8.25C7.96667 5 7.72917 5.09583 7.5375 5.2875C7.34583 5.47917 7.25 5.71667 7.25 6V11Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 814 B

@@ -0,0 +1,19 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask
id="picture_as_pdf_icon_mask"
style="mask-type:alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="24"
height="24"
>
<rect width="24" height="24" fill="#D9D9D9" />
</mask>
<g mask="url(#picture_as_pdf_icon_mask)">
<path
d="M9 12.5H10V10.5H11C11.2833 10.5 11.5208 10.4042 11.7125 10.2125C11.9042 10.0208 12 9.78333 12 9.5V8.5C12 8.21667 11.9042 7.97917 11.7125 7.7875C11.5208 7.59583 11.2833 7.5 11 7.5H9V12.5ZM10 9.5V8.5H11V9.5H10ZM13 12.5H15C15.2833 12.5 15.5208 12.4042 15.7125 12.2125C15.9042 12.0208 16 11.7833 16 11.5V8.5C16 8.21667 15.9042 7.97917 15.7125 7.7875C15.5208 7.59583 15.2833 7.5 15 7.5H13V12.5ZM14 11.5V8.5H15V11.5H14ZM17 12.5H18V10.5H19V9.5H18V8.5H19V7.5H17V12.5ZM8 18C7.45 18 6.97917 17.8042 6.5875 17.4125C6.19583 17.0208 6 16.55 6 16V4C6 3.45 6.19583 2.97917 6.5875 2.5875C6.97917 2.19583 7.45 2 8 2H20C20.55 2 21.0208 2.19583 21.4125 2.5875C21.8042 2.97917 22 3.45 22 4V16C22 16.55 21.8042 17.0208 21.4125 17.4125C21.0208 17.8042 20.55 18 20 18H8ZM8 16H20V4H8V16ZM4 22C3.45 22 2.97917 21.8042 2.5875 21.4125C2.19583 21.0208 2 20.55 2 20V6H4V20H18V22H4Z"
fill="currentColor"
/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+8 -8
View File
@@ -115,21 +115,21 @@ const Button = memo<ButtonProps>(
const variantStyles: Record<string, string> = {
filled:
"bg-[var(--color-surface-inverse-primary)] text-[var(--color-content-inverse-primary)] border-[1.5px] border-transparent hover:bg-[var(--color-surface-inverse-primary)] hover:text-[var(--color-content-invert-brand-primary)] hover:border-[var(--color-border-invert-brand-primary)] hover:scale-[1.02] focus:bg-[var(--color-surface-inverse-primary)] focus:text-[var(--color-content-invert-brand-primary)] focus:outline-none focus:border-transparent focus:shadow-[0_0_0px_2px_var(--color-border-default-primary),0_0_0px_4px_var(--color-border-invert-primary)] focus:scale-[1.02] active:bg-[var(--color-surface-invert-brand-primary)] active:text-[var(--color-content-invert-primary)] active:border-[var(--color-border-invert-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-invert-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
"bg-[var(--color-surface-inverse-primary)] text-[var(--color-content-inverse-primary)] border-[1.5px] border-transparent hover:bg-[var(--color-surface-inverse-primary)] hover:text-[var(--color-content-invert-brand-primary)] hover:border-[var(--color-border-invert-brand-primary)] hover:scale-[1.02] focus-visible:bg-[var(--color-surface-inverse-primary)] focus-visible:text-[var(--color-content-invert-brand-primary)] focus-visible:outline-none focus-visible:border-transparent focus-visible:shadow-[0_0_0px_2px_var(--color-border-default-primary),0_0_0px_4px_var(--color-border-invert-primary)] focus-visible:scale-[1.02] active:bg-[var(--color-surface-invert-brand-primary)] active:text-[var(--color-content-invert-primary)] active:border-[var(--color-border-invert-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-invert-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
"filled-inverse":
"bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border-[1.5px] border-transparent hover:bg-[var(--color-surface-default-primary)] hover:text-[var(--color-content-default-brand-primary)] hover:border-[var(--color-border-default-brand-primary)] hover:scale-[1.02] focus:bg-[var(--color-surface-default-primary)] focus:text-[var(--color-content-default-brand-primary)] focus:outline-none focus:border-transparent focus:shadow-[0_0_0px_2px_var(--color-border-invert-primary),0_0_0px_4px_var(--color-border-default-primary)] focus:scale-[1.02] active:bg-[var(--color-surface-default-brand-primary)] active:text-[var(--color-content-default-primary)] active:border-[var(--color-border-default-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-invert-secondary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
"bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border-[1.5px] border-transparent hover:bg-[var(--color-surface-default-primary)] hover:text-[var(--color-content-default-brand-primary)] hover:border-[var(--color-border-default-brand-primary)] hover:scale-[1.02] focus-visible:bg-[var(--color-surface-default-primary)] focus-visible:text-[var(--color-content-default-brand-primary)] focus-visible:outline-none focus-visible:border-transparent focus-visible:shadow-[0_0_0px_2px_var(--color-border-invert-primary),0_0_0px_4px_var(--color-border-default-primary)] focus-visible:scale-[1.02] active:bg-[var(--color-surface-default-brand-primary)] active:text-[var(--color-content-default-primary)] active:border-[var(--color-border-default-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-invert-secondary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
outline:
"bg-transparent text-[var(--color-content-default-primary)] border-[1.5px] border-[var(--color-border-invert-primary)] hover:bg-transparent hover:text-[var(--color-content-default-brand-primary)] hover:border-[1.5px] hover:border-[var(--color-border-default-brand-primary)] hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-content-default-primary)] focus:outline-none focus:border-[1.5px] focus:border-[var(--color-border-invert-primary)] focus:shadow-[0_0_0px_2px_var(--color-border-default-primary),0_0_0px_4px_var(--color-border-invert-primary)] focus:scale-[1.02] active:bg-transparent active:text-[var(--color-content-default-primary)] active:border-[1.5px] active:border-[var(--color-border-default-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-invert-tertiary)] disabled:border-[1.5px] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
"bg-transparent text-[var(--color-content-default-primary)] border-[1.5px] border-[var(--color-border-invert-primary)] hover:bg-transparent hover:text-[var(--color-content-default-brand-primary)] hover:border-[1.5px] hover:border-[var(--color-border-default-brand-primary)] hover:scale-[1.02] focus-visible:bg-transparent focus-visible:text-[var(--color-content-default-primary)] focus-visible:outline-none focus-visible:border-[1.5px] focus-visible:border-[var(--color-border-invert-primary)] focus-visible:shadow-[0_0_0px_2px_var(--color-border-default-primary),0_0_0px_4px_var(--color-border-invert-primary)] focus-visible:scale-[1.02] active:bg-transparent active:text-[var(--color-content-default-primary)] active:border-[1.5px] active:border-[var(--color-border-default-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-invert-tertiary)] disabled:border-[1.5px] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
"outline-inverse":
"bg-transparent text-[var(--color-content-invert-primary)] border-[1.5px] border-[var(--color-border-default-primary)] hover:bg-transparent hover:text-[var(--color-content-invert-brand-primary)] hover:border-[1.5px] hover:border-[var(--color-border-invert-brand-primary)] hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-content-invert-primary)] focus:outline-none focus:border-[1.5px] focus:border-[var(--color-border-default-primary)] focus:shadow-[0_0_0px_2px_var(--color-border-invert-primary),0_0_0px_4px_var(--color-border-default-primary)] focus:scale-[1.02] active:bg-transparent active:text-[var(--color-content-invert-primary)] active:border-[1.5px] active:border-[var(--color-border-invert-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-invert-secondary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-[1.5px] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
"bg-transparent text-[var(--color-content-invert-primary)] border-[1.5px] border-[var(--color-border-default-primary)] hover:bg-transparent hover:text-[var(--color-content-invert-brand-primary)] hover:border-[1.5px] hover:border-[var(--color-border-invert-brand-primary)] hover:scale-[1.02] focus-visible:bg-transparent focus-visible:text-[var(--color-content-invert-primary)] focus-visible:outline-none focus-visible:border-[1.5px] focus-visible:border-[var(--color-border-default-primary)] focus-visible:shadow-[0_0_0px_2px_var(--color-border-invert-primary),0_0_0px_4px_var(--color-border-default-primary)] focus-visible:scale-[1.02] active:bg-transparent active:text-[var(--color-content-invert-primary)] active:border-[1.5px] active:border-[var(--color-border-invert-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-invert-secondary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-[1.5px] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
ghost:
"bg-transparent text-[var(--color-content-default-brand-primary)] border-[1.5px] border-transparent hover:bg-transparent hover:text-[var(--color-content-default-primary)] hover:border-transparent hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-content-default-brand-primary)] focus:outline-none focus:border-transparent focus:shadow-[0_0_0px_2px_var(--color-border-default-primary),0_0_0px_4px_var(--color-border-invert-primary)] focus:scale-[1.02] active:bg-transparent active:text-[var(--color-content-default-primary)] active:border-[var(--color-border-default-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-invert-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
"bg-transparent text-[var(--color-content-default-brand-primary)] border-[1.5px] border-transparent hover:bg-transparent hover:text-[var(--color-content-default-primary)] hover:border-transparent hover:scale-[1.02] focus-visible:bg-transparent focus-visible:text-[var(--color-content-default-brand-primary)] focus-visible:outline-none focus-visible:border-transparent focus-visible:shadow-[0_0_0px_2px_var(--color-border-default-primary),0_0_0px_4px_var(--color-border-invert-primary)] focus-visible:scale-[1.02] active:bg-transparent active:text-[var(--color-content-default-primary)] active:border-[var(--color-border-default-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-invert-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
"ghost-inverse":
"bg-transparent text-[var(--color-content-invert-primary)] border-[1.5px] border-transparent hover:bg-transparent hover:text-[var(--color-content-invert-primary)] hover:border-transparent hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-content-invert-brand-primary)] focus:outline-none focus:border-transparent focus:shadow-[0_0_0px_2px_var(--color-border-invert-primary),0_0_0px_4px_var(--color-border-default-primary)] focus:scale-[1.02] active:bg-[var(--color-surface-invert-brand-primary)] active:text-[var(--color-content-invert-primary)] active:border-[var(--color-border-invert-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-invert-secondary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
"bg-transparent text-[var(--color-content-invert-primary)] border-[1.5px] border-transparent hover:bg-transparent hover:text-[var(--color-content-invert-primary)] hover:border-transparent hover:scale-[1.02] focus-visible:bg-transparent focus-visible:text-[var(--color-content-invert-brand-primary)] focus-visible:outline-none focus-visible:border-transparent focus-visible:shadow-[0_0_0px_2px_var(--color-border-invert-primary),0_0_0px_4px_var(--color-border-default-primary)] focus-visible:scale-[1.02] active:bg-[var(--color-surface-invert-brand-primary)] active:text-[var(--color-content-invert-primary)] active:border-[var(--color-border-invert-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-invert-secondary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
danger:
"bg-transparent text-[var(--color-border-default-negative-primary)] border border-[var(--color-border-default-negative-primary)] hover:bg-[var(--color-surface-invert-negative-secondary)] hover:text-[var(--color-border-default-negative-primary)] hover:border-[var(--color-border-default-negative-primary)] hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-border-default-negative-primary)] focus:outline-none focus:border-[var(--color-border-default-negative-primary)] focus:shadow-[0_0_0px_2px_var(--color-border-default-primary),0_0_0px_4px_var(--color-border-invert-primary)] focus:scale-[1.02] active:bg-[var(--color-surface-invert-negative-primary)] active:text-[var(--color-content-invert-negative-primary)] active:border-[1.5px] active:border-[var(--color-border-default-negative-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-inverse-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
"bg-transparent text-[var(--color-border-default-negative-primary)] border border-[var(--color-border-default-negative-primary)] hover:bg-[var(--color-surface-invert-negative-secondary)] hover:text-[var(--color-border-default-negative-primary)] hover:border-[var(--color-border-default-negative-primary)] hover:scale-[1.02] focus-visible:bg-transparent focus-visible:text-[var(--color-border-default-negative-primary)] focus-visible:outline-none focus-visible:border-[var(--color-border-default-negative-primary)] focus-visible:shadow-[0_0_0px_2px_var(--color-border-default-primary),0_0_0px_4px_var(--color-border-invert-primary)] focus-visible:scale-[1.02] active:bg-[var(--color-surface-invert-negative-primary)] active:text-[var(--color-content-invert-negative-primary)] active:border-[1.5px] active:border-[var(--color-border-default-negative-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-inverse-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
"danger-inverse":
"bg-transparent text-[var(--color-content-invert-negative-primary)] border border-[var(--color-border-invert-negative-primary)] hover:bg-transparent hover:text-[var(--color-content-invert-negative-primary)] hover:border-[var(--color-border-invert-negative-primary)] hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-content-invert-negative-primary)] focus:outline-none focus:border-[var(--color-border-invert-negative-primary)] focus:shadow-[0_0_0px_2px_var(--color-border-invert-primary),0_0_0px_4px_var(--color-border-default-primary)] focus:scale-[1.02] active:bg-[var(--color-surface-default-negative-primary)] active:text-[var(--color-content-default-primary)] active:border-[1.5px] active:border-[var(--color-border-default-negative-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-inverse-secondary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
"bg-transparent text-[var(--color-content-invert-negative-primary)] border border-[var(--color-border-invert-negative-primary)] hover:bg-transparent hover:text-[var(--color-content-invert-negative-primary)] hover:border-[var(--color-border-invert-negative-primary)] hover:scale-[1.02] focus-visible:bg-transparent focus-visible:text-[var(--color-content-invert-negative-primary)] focus-visible:outline-none focus-visible:border-[var(--color-border-invert-negative-primary)] focus-visible:shadow-[0_0_0px_2px_var(--color-border-invert-primary),0_0_0px_4px_var(--color-border-default-primary)] focus-visible:scale-[1.02] active:bg-[var(--color-surface-default-negative-primary)] active:text-[var(--color-content-default-primary)] active:border-[1.5px] active:border-[var(--color-border-default-negative-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-inverse-secondary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
};
const hoverOutlineStyles: Record<string, string> = {
@@ -0,0 +1,17 @@
"use client";
/**
* Figma: Community Rule System — "Add Custom Field/Popover" (List-item/lockup)
* https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=20887-175710
*/
import { memo } from "react";
import { ListItemView } from "./ListItem.view";
import type { ListItemProps } from "./ListItem.types";
const ListItem = memo<ListItemProps>((props) => {
return <ListItemView {...props} />;
});
ListItem.displayName = "ListItem";
export default ListItem;
@@ -0,0 +1,10 @@
import type { IconName } from "../../asset/icon";
export type ListItemProps = {
label: string;
leadingIcon: IconName;
onClick: () => void;
/** Bottom divider between rows — false on the final row per Figma. */
showDivider: boolean;
className?: string;
};
@@ -0,0 +1,35 @@
"use client";
import { memo } from "react";
import Icon from "../../asset/icon";
import type { ListItemProps } from "./ListItem.types";
export const ListItemView = memo(function ListItemView({
label,
leadingIcon,
onClick,
showDivider,
className = "",
}: ListItemProps) {
const dividerClass = showDivider
? "border-b border-solid border-[var(--color-border-default-tertiary)]"
: "";
return (
<button
type="button"
role="menuitem"
onClick={onClick}
className={`relative flex w-full shrink-0 cursor-pointer items-center gap-[6px] px-[4px] py-[16px] text-left hover:bg-[var(--color-surface-default-tertiary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)] ${dividerClass} ${className}`}
>
<span className="flex size-6 shrink-0 items-center justify-center overflow-visible text-[var(--color-content-default-primary)]">
<Icon name={leadingIcon} size={24} />
</span>
<span className="min-w-0 flex-1 text-left font-inter text-[12px] font-normal leading-4 whitespace-normal text-[var(--color-content-default-primary)]">
{label}
</span>
</button>
);
});
ListItemView.displayName = "ListItemView";
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./ListItem.container";
export type { ListItemProps } from "./ListItem.types";
@@ -3,5 +3,9 @@ export interface ModalHeaderProps {
onMoreOptions?: () => void;
showCloseButton?: boolean;
showMoreOptionsButton?: boolean;
/** When set, used for the close controls accessible name (e.g. localized). */
closeButtonAriaLabel?: string;
/** When set, used for the more-options controls accessible name (e.g. localized). */
moreOptionsAriaLabel?: string;
className?: string;
}
@@ -9,6 +9,8 @@ export function ModalHeaderView({
onMoreOptions,
showCloseButton = true,
showMoreOptionsButton = true,
closeButtonAriaLabel = "Close dialog",
moreOptionsAriaLabel = "More options",
className = "",
}: ModalHeaderProps) {
return (
@@ -21,7 +23,7 @@ export function ModalHeaderView({
type="button"
onClick={onClose}
className={`${iconButtonClass} left-[24px] top-[12px]`}
aria-label="Close dialog"
aria-label={closeButtonAriaLabel}
>
{/* eslint-disable-next-line @next/next/no-img-element -- icon asset */}
<img
@@ -41,7 +43,7 @@ export function ModalHeaderView({
type="button"
onClick={onMoreOptions}
className={`${iconButtonClass} right-[24px] top-[12px]`}
aria-label="More options"
aria-label={moreOptionsAriaLabel}
>
<svg
width="16"
@@ -0,0 +1,17 @@
"use client";
/**
* Figma: Community Rule System — Export popover (Community Rule System · 21998:22612)
* https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=21998-22612
*/
import { memo } from "react";
import { PopoverView } from "./Popover.view";
import type { PopoverProps } from "./Popover.types";
const Popover = memo<PopoverProps>((props) => {
return <PopoverView {...props} />;
});
Popover.displayName = "Popover";
export default Popover;
@@ -0,0 +1,8 @@
import type { ReactNode } from "react";
export type PopoverProps = {
id: string;
menuAriaLabel: string;
children: ReactNode;
className?: string;
};
@@ -0,0 +1,25 @@
"use client";
import { memo } from "react";
import type { PopoverProps } from "./Popover.types";
export const PopoverView = memo(function PopoverView({
id,
menuAriaLabel,
children,
className = "",
}: PopoverProps) {
return (
<div
id={id}
role="menu"
aria-label={menuAriaLabel}
data-figma-node="21998:22612"
className={`flex min-w-[171px] w-max max-w-[calc(100vw-32px)] flex-col items-stretch overflow-hidden rounded-[var(--radius-300)] bg-[var(--color-surface-default-secondary)] px-[12px] [filter:drop-shadow(0px_0px_6px_rgba(254,252,201,0.2))] ${className}`}
>
{children}
</div>
);
});
PopoverView.displayName = "PopoverView";
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./Popover.container";
export type { PopoverProps } from "./Popover.types";
@@ -0,0 +1,43 @@
"use client";
/**
* Figma: Community Rule System — "Modal / Share"
* https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22073-30884
*/
import { memo, useId, useRef } from "react";
import { useTranslation } from "../../../contexts/MessagesContext";
import { useCreateModalA11y } from "../Create/useCreateModalA11y";
import { ShareView } from "./Share.view";
import type { ShareProps } from "./Share.types";
const ShareContainer = memo<ShareProps>((props) => {
const dialogRef = useRef<HTMLDivElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
const titleId = useId();
const t = useTranslation("modals.share");
useCreateModalA11y(props.isOpen, props.onClose, dialogRef);
return (
<ShareView
{...props}
dialogRef={dialogRef}
overlayRef={overlayRef}
titleId={titleId}
title={t("title")}
description={t("description")}
copyLinkLabel={t("copyLink")}
signalLabel={t("signal")}
slackLabel={t("slack")}
discordLabel={t("discord")}
emailLabel={t("email")}
doneLabel={t("done")}
closeDialogAriaLabel={t("closeDialogAriaLabel")}
moreOptionsAriaLabel={t("moreOptionsAriaLabel")}
/>
);
});
ShareContainer.displayName = "Share";
export default ShareContainer;
@@ -0,0 +1,37 @@
import type { ReactNode, RefObject } from "react";
import type { CreateModalBackdropVariant } from "../Create/CreateModalFrame.view";
export type ShareProps = {
isOpen: boolean;
onClose: () => void;
onCopyLink: () => void | Promise<void>;
onEmailShare: () => void;
onSignalShare: () => void | Promise<void>;
onSlackShare: () => void | Promise<void>;
onDiscordShare: () => void | Promise<void>;
className?: string;
backdropVariant?: CreateModalBackdropVariant;
};
export type ShareViewProps = ShareProps & {
dialogRef: RefObject<HTMLDivElement | null>;
overlayRef: RefObject<HTMLDivElement | null>;
titleId: string;
title: string;
description: string;
copyLinkLabel: string;
signalLabel: string;
slackLabel: string;
discordLabel: string;
emailLabel: string;
doneLabel: string;
closeDialogAriaLabel: string;
moreOptionsAriaLabel: string;
};
export type ShareChannelTileProps = {
label: string;
onClick: () => void | Promise<void>;
circleClassName: string;
icon: ReactNode;
};
+165
View File
@@ -0,0 +1,165 @@
"use client";
import Image from "next/image";
import { memo } from "react";
import ContentLockup from "../../type/ContentLockup";
import Button from "../../buttons/Button";
import ModalHeader from "../ModalHeader";
import ModalFooter from "../ModalFooter";
import { CreateModalFrameView } from "../Create/CreateModalFrame.view";
import type { ShareChannelTileProps, ShareViewProps } from "./Share.types";
/** Decorative glyphs in `public/assets/Share/` — sizes match prior inline SVGs within the 60×60 circles. */
function ShareAssetIcon(props: {
src:
| "/assets/Share/Discord.svg"
| "/assets/Share/Link.svg"
| "/assets/Share/Mail.svg"
| "/assets/Share/Signal.svg"
| "/assets/Share/Slack.svg";
width: number;
height: number;
}) {
const { src, width, height } = props;
return (
<Image
src={src}
alt=""
width={width}
height={height}
className="shrink-0"
unoptimized
aria-hidden
/>
);
}
function ShareChannelTile({ label, onClick, circleClassName, icon }: ShareChannelTileProps) {
return (
<button
type="button"
onClick={() => void onClick()}
className="flex w-16 shrink-0 flex-col items-center gap-2 rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]"
>
<div
className={`flex h-[60px] w-[60px] items-center justify-center rounded-full border border-solid ${circleClassName}`}
>
{icon}
</div>
<span className="max-w-[4.5rem] text-center font-inter text-[12px] font-medium leading-4 text-[var(--color-content-default-tertiary)]">
{label}
</span>
</button>
);
}
export const ShareView = memo(function ShareView({
isOpen,
onClose,
onCopyLink,
onEmailShare,
onSignalShare,
onSlackShare,
onDiscordShare,
className = "",
backdropVariant = "default",
dialogRef,
overlayRef,
titleId,
title,
description,
copyLinkLabel,
signalLabel,
slackLabel,
discordLabel,
emailLabel,
doneLabel,
closeDialogAriaLabel,
moreOptionsAriaLabel,
}: ShareViewProps) {
return (
<CreateModalFrameView
isOpen={isOpen}
onOverlayClick={onClose}
backdropVariant={backdropVariant}
className={`max-h-[90vh] w-[min(546px,calc(100vw-32px))] max-w-[546px] min-h-0 ${className}`}
ariaLabel={title}
ariaLabelledBy={titleId}
overlayRef={overlayRef}
dialogRef={dialogRef}
>
<ModalHeader
onClose={onClose}
onMoreOptions={onClose}
closeButtonAriaLabel={closeDialogAriaLabel}
moreOptionsAriaLabel={moreOptionsAriaLabel}
/>
<div className="shrink-0 bg-[var(--color-surface-default-primary)] px-[24px] py-[12px]">
<ContentLockup
title={title}
description={description}
variant="modal"
alignment="left"
titleId={titleId}
/>
</div>
<div className="scrollbar-design flex min-h-0 flex-1 flex-col overflow-x-clip overflow-y-auto px-[24px] pb-6 pt-0">
<div className="flex flex-wrap gap-4">
<ShareChannelTile
label={copyLinkLabel}
onClick={onCopyLink}
circleClassName="border-[#444444] bg-[#333333]"
icon={<ShareAssetIcon src="/assets/Share/Link.svg" width={24} height={24} />}
/>
<ShareChannelTile
label={signalLabel}
onClick={onSignalShare}
circleClassName="border-[#3a76f0] bg-[#3a76f0]"
icon={<ShareAssetIcon src="/assets/Share/Signal.svg" width={26} height={26} />}
/>
<ShareChannelTile
label={slackLabel}
onClick={onSlackShare}
circleClassName="border-[#4a154b] bg-[#4a154b]"
icon={<ShareAssetIcon src="/assets/Share/Slack.svg" width={26} height={26} />}
/>
<ShareChannelTile
label={discordLabel}
onClick={onDiscordShare}
circleClassName="border-[#5865f2] bg-[#5865f2]"
icon={<ShareAssetIcon src="/assets/Share/Discord.svg" width={30} height={30} />}
/>
<ShareChannelTile
label={emailLabel}
onClick={onEmailShare}
circleClassName="border-[var(--color-surface-default-brand-kiwi)] bg-[var(--color-surface-default-brand-kiwi)]"
icon={<ShareAssetIcon src="/assets/Share/Mail.svg" width={24} height={24} />}
/>
</div>
</div>
<ModalFooter
showBackButton={false}
showNextButton={false}
stepper={false}
footerContent={
<div className="absolute right-[16px] top-[12px] flex max-w-[calc(100%-32px)] flex-wrap items-center justify-end gap-3">
<Button
buttonType="filled"
palette="default"
size="medium"
type="button"
onClick={onClose}
>
{doneLabel}
</Button>
</div>
}
/>
</CreateModalFrameView>
);
});
ShareView.displayName = "ShareView";
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./Share.container";
export type { ShareProps } from "./Share.types";
@@ -2,12 +2,14 @@
import { memo } from "react";
import { useRouter } from "next/navigation";
import { useTranslation } from "../../../contexts/MessagesContext";
import { CreateFlowTopNavView } from "./CreateFlowTopNav.view";
import type { CreateFlowTopNavProps } from "./CreateFlowTopNav.types";
/**
* Figma: Utility / CreateFlowTopNav — wizard header (create-flow chrome).
* Exit, optional share / export / edit; strings in `messages/en/create/topNav.json`.
* Export menu: Community Rule System — node 21998:22612 (`messages/en/modals/popoverExport.json`).
*/
const CreateFlowTopNavContainer = memo<CreateFlowTopNavProps>(
({
@@ -16,13 +18,14 @@ const CreateFlowTopNavContainer = memo<CreateFlowTopNavProps>(
hasEdit = false,
saveDraftOnExit = false,
onShare,
onExport,
onSelectExportFormat,
onEdit,
onExit,
buttonPalette,
className = "",
}) => {
const router = useRouter();
const tPopover = useTranslation("modals.popoverExport");
const handleExit = (options?: { saveDraft?: boolean }) => {
if (onExit) {
@@ -40,11 +43,15 @@ const CreateFlowTopNavContainer = memo<CreateFlowTopNavProps>(
hasEdit={hasEdit}
saveDraftOnExit={saveDraftOnExit}
onShare={onShare}
onExport={onExport}
onSelectExportFormat={onSelectExportFormat}
onEdit={onEdit}
onExit={handleExit}
buttonPalette={buttonPalette}
className={className}
exportPopoverMenuAriaLabel={tPopover("menuAriaLabel")}
exportPopoverPdfLabel={tPopover("downloadPdf")}
exportPopoverCsvLabel={tPopover("downloadCsv")}
exportPopoverMarkdownLabel={tPopover("downloadMarkdown")}
/>
);
},
@@ -32,9 +32,9 @@ export interface CreateFlowTopNavProps {
*/
onShare?: () => void;
/**
* Callback when Export button is clicked
* Callback when user picks an export format from the Export menu.
*/
onExport?: () => void;
onSelectExportFormat?: (_format: "pdf" | "csv" | "markdown") => void;
/**
* Callback when Edit button is clicked
*/
@@ -54,3 +54,11 @@ export interface CreateFlowTopNavProps {
*/
className?: string;
}
/** Resolved copy for the export popover; supplied by the container. */
export type CreateFlowTopNavViewProps = CreateFlowTopNavProps & {
exportPopoverMenuAriaLabel: string;
exportPopoverPdfLabel: string;
exportPopoverCsvLabel: string;
exportPopoverMarkdownLabel: string;
};
@@ -1,9 +1,12 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import Logo from "../../asset/Logo";
import Button from "../../buttons/Button";
import ListItem from "../../layout/ListItem";
import Popover from "../../modals/Popover";
import { useTranslation } from "../../../contexts/MessagesContext";
import type { CreateFlowTopNavProps } from "./CreateFlowTopNav.types";
import type { CreateFlowTopNavViewProps } from "./CreateFlowTopNav.types";
const exitButtonFigmaClass =
"!rounded-[var(--radius-measures-radius-full,9999px)] !border-[1.25px] !px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!text-[12px] md:!leading-[14px]";
@@ -14,14 +17,44 @@ export function CreateFlowTopNavView({
hasEdit = false,
saveDraftOnExit = false,
onShare,
onExport,
onSelectExportFormat,
onEdit,
onExit,
buttonPalette = "default",
className = "",
}: CreateFlowTopNavProps) {
exportPopoverMenuAriaLabel,
exportPopoverPdfLabel,
exportPopoverCsvLabel,
exportPopoverMarkdownLabel,
}: CreateFlowTopNavViewProps) {
const t = useTranslation("create.topNav");
const exitButtonText = saveDraftOnExit ? t("saveAndExit") : t("exit");
const [exportMenuOpen, setExportMenuOpen] = useState(false);
const exportWrapRef = useRef<HTMLDivElement>(null);
const exportMenuId = useId();
useEffect(() => {
if (!exportMenuOpen) return;
const onDoc = (e: MouseEvent) => {
if (
exportWrapRef.current &&
!exportWrapRef.current.contains(e.target as Node)
) {
setExportMenuOpen(false);
}
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [exportMenuOpen]);
useEffect(() => {
if (!exportMenuOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setExportMenuOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [exportMenuOpen]);
return (
<header
@@ -50,32 +83,74 @@ export function CreateFlowTopNavView({
</Button>
)}
{hasExport && (
<Button
buttonType="outline"
palette={buttonPalette}
size="xsmall"
onClick={onExport}
ariaLabel={t("exportAriaLabel")}
className="justify-center gap-[var(--spacing-scale-002,2px)] !pl-[var(--spacing-scale-012,12px)] !pr-[var(--spacing-scale-006,6px)] md:!pr-[var(--spacing-scale-006,6px)] !text-[10px] md:!text-[12px] !leading-[12px] md:!leading-[14px] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]"
>
<span>{t("export")}</span>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0 md:w-[14px] md:h-[14px]"
aria-hidden="true"
{hasExport && onSelectExportFormat ? (
<div className="relative" ref={exportWrapRef}>
<Button
buttonType="outline"
palette={buttonPalette}
size="xsmall"
type="button"
ariaLabel={t("exportAriaLabel")}
aria-haspopup="menu"
aria-expanded={exportMenuOpen}
aria-controls={exportMenuId}
onClick={() => setExportMenuOpen((o) => !o)}
className="justify-center gap-[var(--spacing-scale-002,2px)] !pl-[var(--spacing-scale-012,12px)] !pr-[var(--spacing-scale-006,6px)] md:!pr-[var(--spacing-scale-006,6px)] !text-[10px] md:!text-[12px] !leading-[12px] md:!leading-[14px] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]"
>
<path d="M19 9l-7 7-7-7" />
</svg>
</Button>
)}
<span>{t("export")}</span>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0 md:w-[14px] md:h-[14px]"
aria-hidden="true"
>
<path d="M19 9l-7 7-7-7" />
</svg>
</Button>
{exportMenuOpen ? (
<div className="absolute right-0 top-[calc(100%+var(--spacing-measures-spacing-200,8px))] z-[300]">
<Popover
id={exportMenuId}
menuAriaLabel={exportPopoverMenuAriaLabel}
>
<ListItem
showDivider
leadingIcon="picture_as_pdf"
label={exportPopoverPdfLabel}
onClick={() => {
onSelectExportFormat("pdf");
setExportMenuOpen(false);
}}
/>
<ListItem
showDivider
leadingIcon="csv"
label={exportPopoverCsvLabel}
onClick={() => {
onSelectExportFormat("csv");
setExportMenuOpen(false);
}}
/>
<ListItem
showDivider={false}
leadingIcon="markdown_copy"
label={exportPopoverMarkdownLabel}
onClick={() => {
onSelectExportFormat("markdown");
setExportMenuOpen(false);
}}
/>
</Popover>
</div>
) : null}
</div>
) : null}
{hasEdit && (
<Button
@@ -106,3 +181,5 @@ export function CreateFlowTopNavView({
</header>
);
}
CreateFlowTopNavView.displayName = "CreateFlowTopNavView";