Implement share and export components
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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 |
@@ -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";
|
||||
@@ -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 control’s accessible name (e.g. localized). */
|
||||
closeButtonAriaLabel?: string;
|
||||
/** When set, used for the more-options control’s 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";
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
|
||||
@@ -9,7 +9,7 @@ Quick map from the Figma file **Community Rule System** (`agv0VBLiBlcnSAaiAORgPR
|
||||
| [Button](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=497-3016) | `buttons/` | PascalCase package per primitive — **`Button/`**, **`InlineTextButton/`** (see conventions below). |
|
||||
| [Card](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=17865-24349) | `cards/` | One PascalCase package per surface—**`Selection/`** (Figma **Card / CardSelection**), **`CardStack/`**, **`Rule/`** (Figma **Card / Rule**), **`Icon/`**, **`Mini/`**, **`Step/`** (Figma **Card / Step**), **`TemplateReviewCard/`** (see conventions below). |
|
||||
| [Control](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=5944-58611) | `controls/` | Checkbox, radio, text field, select, toggle, switch, incrementer, upload, multi-select, chip, … (see **Control conventions** below). **`InfoMessageBox`** canonical here. |
|
||||
| [Layout](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=21836-20542) | `layout/` | **`List/`**, **`ListEntry/`** + **`listSizeLayout.ts`**. **Tabs** / **Accordion** are in Figma only—**not** in code yet (see **Layout conventions**). |
|
||||
| [Layout](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=21836-20542) | `layout/` | **`List/`**, **`ListEntry/`**, **`ListItem/`** + **`listSizeLayout.ts`**. **Tabs** / **Accordion** are in Figma only—**not** in code yet (see **Layout conventions**). |
|
||||
| [Modals](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=5944-47704) | `modals/` | Alert, Create, Dialog, Login, Tooltip, **`ModalHeader`** / **`ModalFooter`** (see **Modals conventions**). |
|
||||
| [Navigation](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=5944-69518) | `navigation/` | **Footer**, **Top**, **`Menu`** + **`MenuItem`**, **Link** matrix — plus create-flow chrome (see **Navigation conventions**, [**CR-104**](https://linear.app/community-rule/issue/CR-104/backlog-design-system-component-cleanup) §8). |
|
||||
| [Progress](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=21163-24443) | `progress/` | **`Stepper`**, **`ProportionBar`** — see **Progress conventions**. |
|
||||
@@ -65,6 +65,7 @@ Tracks [**CR-104**](https://linear.app/community-rule/issue/CR-104/backlog-desig
|
||||
| --- | --- | --- |
|
||||
| List / list container | **`List/`** | **`List.container.tsx`**, **`List.view.tsx`**, **`List.types.ts`**. |
|
||||
| List item / entry row | **`ListEntry/`** | **`ListEntry.container.tsx`**, **`ListEntry.view.tsx`**, **`ListEntry.types.ts`** (re-exports **`LIST_SIZE_OPTIONS`** consumed by **`List`**). |
|
||||
| Popover / menu list row (lockup) | **`ListItem/`** | **`ListItem.container.tsx`**, **`ListItem.view.tsx`**, **`ListItem.types.ts`** — icon + label **`menuitem`** row (used in **`Popover`**, **`CreateFlowTopNav`** export UI). Distinct from **`List.types`** **`ListItem`** data shape for **`List`**. |
|
||||
| Shared list sizing | **`listSizeLayout.ts`** | Layout constants / classes shared by **`List`** and **`ListEntry`**. |
|
||||
| List edit | — | No **`ListEdit`** package in this repo today; editing flows may be screen-local or future work—confirm in Figma vs product before introducing a shared primitive. |
|
||||
| Tabs | — | **Not implemented.** |
|
||||
|
||||
@@ -1,7 +1,76 @@
|
||||
import type { CreateFlowState } from "../../app/(app)/create/types";
|
||||
import type {
|
||||
CreateFlowMethodCardFacetSection,
|
||||
CreateFlowState,
|
||||
} from "../../app/(app)/create/types";
|
||||
import type { PublishedMethodSelections } from "./buildPublishPayload";
|
||||
import type { StoredLastPublishedRule } from "./lastPublishedRule";
|
||||
|
||||
const PUBLISHED_SELECTION_FIELD_KEYS: readonly (keyof CreateFlowState)[] = [
|
||||
"selectedCoreValueIds",
|
||||
"selectedCommunicationMethodIds",
|
||||
"selectedMembershipMethodIds",
|
||||
"selectedDecisionApproachIds",
|
||||
"selectedConflictManagementIds",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* True when `patch` (from {@link createFlowStateFromPublishedRule}) expects
|
||||
* non-empty facet selections but `state` still has none for that facet.
|
||||
*
|
||||
* Used so `/create/edit-rule` hydration is not skipped after TopNav **Edit**
|
||||
* pre-clears `sections` (which made `sections?.length === 0` look like a
|
||||
* finished hydrate even though method ids were never merged).
|
||||
*/
|
||||
export function isPublishedRuleSelectionMissing(
|
||||
state: CreateFlowState,
|
||||
patch: Partial<CreateFlowState>,
|
||||
): boolean {
|
||||
for (const k of PUBLISHED_SELECTION_FIELD_KEYS) {
|
||||
const desired = patch[k];
|
||||
if (!Array.isArray(desired) || desired.length === 0) continue;
|
||||
const actualRaw = state[k];
|
||||
const actual = Array.isArray(actualRaw) ? actualRaw : [];
|
||||
if (actual.length === 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin flags for method-card facets: compact CardStack slots surface selections
|
||||
* first only when `methodSectionsPinCommitted[facet]` is true (see
|
||||
* `useMethodCardDeckOrdering`). Normal wizard flow sets that on facet **Confirm**.
|
||||
* Hydration paths that seed `selected*` method ids without a confirm (edit-published,
|
||||
* template customize) merge this alongside those ids so pinning matches UX after Confirm.
|
||||
*
|
||||
* Caller should spread onto existing `methodSectionsPinCommitted` so unrelated facets stay
|
||||
* as-is (`{ ...prior, ...this }`).
|
||||
*/
|
||||
export function methodSectionsPinsForHydratedSelections(
|
||||
patch: Partial<CreateFlowState>,
|
||||
): Partial<Record<CreateFlowMethodCardFacetSection, boolean>> {
|
||||
const out: Partial<Record<CreateFlowMethodCardFacetSection, boolean>> = {};
|
||||
if ((patch.selectedCommunicationMethodIds?.length ?? 0) > 0) {
|
||||
out.communication = true;
|
||||
}
|
||||
if ((patch.selectedMembershipMethodIds?.length ?? 0) > 0) {
|
||||
out.membership = true;
|
||||
}
|
||||
if ((patch.selectedDecisionApproachIds?.length ?? 0) > 0) {
|
||||
out.decisionApproaches = true;
|
||||
}
|
||||
if ((patch.selectedConflictManagementIds?.length ?? 0) > 0) {
|
||||
out.conflictManagement = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** @see {@link methodSectionsPinsForHydratedSelections} — published-rule hydrate naming. */
|
||||
export function methodSectionsPinsFromPublishedHydratePatch(
|
||||
patch: Partial<CreateFlowState>,
|
||||
): Partial<Record<CreateFlowMethodCardFacetSection, boolean>> {
|
||||
return methodSectionsPinsForHydratedSelections(patch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rehydrate create-flow fields from a stored published rule so `/create/edit-rule`
|
||||
* can render final-review editors after refresh or when branching from completed.
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import { jsPDF } from "jspdf";
|
||||
|
||||
import type {
|
||||
CommunityRuleEntry,
|
||||
CommunityRuleSection,
|
||||
} from "../../app/components/type/CommunityRule/CommunityRule.types";
|
||||
import type { StoredLastPublishedRule } from "./lastPublishedRule";
|
||||
import { parsePublishedDocumentForCommunityRuleDisplay } from "./publishedDocumentToDisplaySections";
|
||||
|
||||
export function buildPublicRuleUrl(origin: string, ruleId: string): string {
|
||||
const base = origin.replace(/\/$/, "");
|
||||
return `${base}/rules/${encodeURIComponent(ruleId)}`;
|
||||
}
|
||||
|
||||
/** Safe filename fragment from rule title, with stable fallback. */
|
||||
export function exportFilenameBase(rule: StoredLastPublishedRule): string {
|
||||
const fromTitle = rule.title
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48);
|
||||
return fromTitle.length > 0 ? fromTitle : `rule-${rule.id.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
function entryToMarkdown(entry: CommunityRuleEntry): string {
|
||||
const lines: string[] = [`### ${entry.title}`, ""];
|
||||
if (entry.blocks && entry.blocks.length > 0) {
|
||||
for (const b of entry.blocks) {
|
||||
lines.push(`#### ${b.label}`, "", b.body, "");
|
||||
}
|
||||
} else {
|
||||
const body = (entry.body ?? "").trim();
|
||||
if (body.length > 0) {
|
||||
const paras = body.split(/\n\s*\n/);
|
||||
for (const p of paras) {
|
||||
const t = p.trim();
|
||||
if (t.length > 0) lines.push(t, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines.join("\n").trimEnd();
|
||||
}
|
||||
|
||||
export function sectionsToMarkdown(
|
||||
title: string,
|
||||
summary: string | null | undefined,
|
||||
sections: CommunityRuleSection[],
|
||||
): string {
|
||||
const parts: string[] = [`# ${title}`, ""];
|
||||
const sum = typeof summary === "string" ? summary.trim() : "";
|
||||
if (sum.length > 0) {
|
||||
parts.push(sum, "");
|
||||
}
|
||||
for (const sec of sections) {
|
||||
parts.push(`## ${sec.categoryName}`, "");
|
||||
for (const ent of sec.entries) {
|
||||
parts.push(entryToMarkdown(ent), "");
|
||||
}
|
||||
}
|
||||
return `${parts.join("\n").trimEnd()}\n`;
|
||||
}
|
||||
|
||||
function escapeCsvCell(value: string): string {
|
||||
if (/[",\r\n]/.test(value)) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Tabular snapshot of parsed rule content (RFC 4180-style; UTF-8 with BOM when downloaded). */
|
||||
export function sectionsToCsv(
|
||||
title: string,
|
||||
summary: string | null | undefined,
|
||||
sections: CommunityRuleSection[],
|
||||
): string {
|
||||
const rows: string[][] = [
|
||||
["Section", "Entry", "Block label", "Content"],
|
||||
["", "Title", "", title],
|
||||
];
|
||||
const sum = typeof summary === "string" ? summary.trim() : "";
|
||||
if (sum.length > 0) {
|
||||
rows.push(["", "Summary", "", sum]);
|
||||
}
|
||||
for (const sec of sections) {
|
||||
for (const ent of sec.entries) {
|
||||
if (ent.blocks && ent.blocks.length > 0) {
|
||||
for (const b of ent.blocks) {
|
||||
rows.push([sec.categoryName, ent.title, b.label, b.body]);
|
||||
}
|
||||
} else {
|
||||
rows.push([sec.categoryName, ent.title, "", ent.body ?? ""]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return `${rows.map((r) => r.map(escapeCsvCell).join(",")).join("\n")}\n`;
|
||||
}
|
||||
|
||||
export function exportStoredRuleAsMarkdown(rule: StoredLastPublishedRule): string {
|
||||
const sections = parsePublishedDocumentForCommunityRuleDisplay(rule.document);
|
||||
if (sections.length === 0) {
|
||||
throw new Error("exportEmptyDocument");
|
||||
}
|
||||
return sectionsToMarkdown(rule.title, rule.summary, sections);
|
||||
}
|
||||
|
||||
export function exportStoredRuleAsCsv(rule: StoredLastPublishedRule): string {
|
||||
const sections = parsePublishedDocumentForCommunityRuleDisplay(rule.document);
|
||||
if (sections.length === 0) {
|
||||
throw new Error("exportEmptyDocument");
|
||||
}
|
||||
return `\uFEFF${sectionsToCsv(rule.title, rule.summary, sections)}`;
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function paragraphsHtml(text: string): string {
|
||||
const paras = text
|
||||
.trim()
|
||||
.split(/\n\s*\n/)
|
||||
.filter((p) => p.trim().length > 0);
|
||||
if (paras.length === 0) return "";
|
||||
return paras
|
||||
.map((p) => `<p>${escapeHtml(p.trim()).replace(/\n/g, "<br />")}</p>`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function entryToPrintHtml(entry: CommunityRuleEntry): string {
|
||||
let inner = "";
|
||||
if (entry.blocks && entry.blocks.length > 0) {
|
||||
for (const b of entry.blocks) {
|
||||
inner += `<h4 class="block-label">${escapeHtml(b.label)}</h4>`;
|
||||
inner += paragraphsHtml(b.body);
|
||||
}
|
||||
} else {
|
||||
inner += paragraphsHtml(entry.body ?? "");
|
||||
}
|
||||
return `<article class="entry"><h3>${escapeHtml(entry.title)}</h3>${inner}</article>`;
|
||||
}
|
||||
|
||||
/** Full HTML document for static preview or tests; PDF export uses {@link sectionsToPdfBlob}. */
|
||||
export function buildPrintableRuleHtmlDocument(
|
||||
title: string,
|
||||
summary: string | null | undefined,
|
||||
sections: CommunityRuleSection[],
|
||||
): string {
|
||||
const sum = typeof summary === "string" ? summary.trim() : "";
|
||||
const summaryBlock =
|
||||
sum.length > 0 ? `<div class="summary">${paragraphsHtml(sum)}</div>` : "";
|
||||
const body = sections
|
||||
.map(
|
||||
(sec) =>
|
||||
`<section><h2>${escapeHtml(sec.categoryName)}</h2>${sec.entries.map(entryToPrintHtml).join("\n")}</section>`,
|
||||
)
|
||||
.join("\n");
|
||||
const styles = `body{font-family:system-ui,sans-serif;line-height:1.5;margin:2rem auto;max-width:48rem;padding:0 1rem;color:#111;background:#fff}h1{font-size:1.75rem;margin-bottom:.5rem}h2{font-size:1.25rem;margin-top:1.5rem;margin-bottom:.75rem;border-bottom:1px solid #ccc}h3{font-size:1.05rem;margin-top:1rem}h4.block-label{font-size:.95rem;font-weight:600;margin-top:.75rem;margin-bottom:.25rem}p{margin:0 0 .75rem}.summary{color:#333;margin-bottom:1.25rem}@media print{body{margin:0;max-width:none}h2,h3{break-after:avoid}}`;
|
||||
return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"/><title>${escapeHtml(title)}</title><style>${styles}</style></head><body><h1>${escapeHtml(title)}</h1>${summaryBlock}${body}</body></html>`;
|
||||
}
|
||||
|
||||
function splitDisplayParagraphs(text: string): string[] {
|
||||
return text
|
||||
.trim()
|
||||
.split(/\n\s*\n/)
|
||||
.filter((p) => p.trim().length > 0)
|
||||
.map((p) => p.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Same section structure as {@link buildPrintableRuleHtmlDocument}; renders a
|
||||
* simple multi-page PDF (Helvetica, headings + wrapped body text).
|
||||
*/
|
||||
export function sectionsToPdfBlob(
|
||||
title: string,
|
||||
summary: string | null | undefined,
|
||||
sections: CommunityRuleSection[],
|
||||
): Blob {
|
||||
const doc = new jsPDF({ unit: "mm", format: "a4" });
|
||||
const margin = 20;
|
||||
const pageW = doc.internal.pageSize.getWidth();
|
||||
const pageH = doc.internal.pageSize.getHeight();
|
||||
const maxW = pageW - 2 * margin;
|
||||
let y = margin;
|
||||
|
||||
function ensureSpace(h: number): void {
|
||||
if (y + h > pageH - margin) {
|
||||
doc.addPage();
|
||||
y = margin;
|
||||
}
|
||||
}
|
||||
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setFontSize(16);
|
||||
{
|
||||
const lines = doc.splitTextToSize(title, maxW);
|
||||
const dim = doc.getTextDimensions(lines.join("\n"), { maxWidth: maxW });
|
||||
ensureSpace(dim.h + 4);
|
||||
doc.text(lines, margin, y);
|
||||
y += dim.h + 6;
|
||||
}
|
||||
|
||||
const sum = typeof summary === "string" ? summary.trim() : "";
|
||||
if (sum.length > 0) {
|
||||
for (const p of splitDisplayParagraphs(sum)) {
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setFontSize(11);
|
||||
const lines = doc.splitTextToSize(p, maxW);
|
||||
const dim = doc.getTextDimensions(lines.join("\n"), { maxWidth: maxW });
|
||||
ensureSpace(dim.h + 2);
|
||||
doc.text(lines, margin, y);
|
||||
y += dim.h + 3;
|
||||
}
|
||||
y += 2;
|
||||
}
|
||||
|
||||
for (const sec of sections) {
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setFontSize(13);
|
||||
{
|
||||
const lines = doc.splitTextToSize(sec.categoryName, maxW);
|
||||
const dim = doc.getTextDimensions(lines.join("\n"), { maxWidth: maxW });
|
||||
ensureSpace(dim.h + 3);
|
||||
doc.text(lines, margin, y);
|
||||
y += dim.h + 5;
|
||||
}
|
||||
|
||||
for (const ent of sec.entries) {
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setFontSize(11);
|
||||
{
|
||||
const lines = doc.splitTextToSize(ent.title, maxW);
|
||||
const dim = doc.getTextDimensions(lines.join("\n"), { maxWidth: maxW });
|
||||
ensureSpace(dim.h + 2);
|
||||
doc.text(lines, margin, y);
|
||||
y += dim.h + 3;
|
||||
}
|
||||
|
||||
if (ent.blocks && ent.blocks.length > 0) {
|
||||
for (const b of ent.blocks) {
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setFontSize(10);
|
||||
{
|
||||
const lines = doc.splitTextToSize(b.label, maxW);
|
||||
const dim = doc.getTextDimensions(lines.join("\n"), {
|
||||
maxWidth: maxW,
|
||||
});
|
||||
ensureSpace(dim.h + 1);
|
||||
doc.text(lines, margin, y);
|
||||
y += dim.h + 2;
|
||||
}
|
||||
for (const p of splitDisplayParagraphs(b.body)) {
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setFontSize(11);
|
||||
const lines = doc.splitTextToSize(p, maxW);
|
||||
const dim = doc.getTextDimensions(lines.join("\n"), {
|
||||
maxWidth: maxW,
|
||||
});
|
||||
ensureSpace(dim.h + 1);
|
||||
doc.text(lines, margin, y);
|
||||
y += dim.h + 2;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const p of splitDisplayParagraphs(ent.body ?? "")) {
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setFontSize(11);
|
||||
const lines = doc.splitTextToSize(p, maxW);
|
||||
const dim = doc.getTextDimensions(lines.join("\n"), { maxWidth: maxW });
|
||||
ensureSpace(dim.h + 1);
|
||||
doc.text(lines, margin, y);
|
||||
y += dim.h + 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ab = doc.output("arraybuffer") as ArrayBuffer;
|
||||
return new Blob([ab], { type: "application/pdf" });
|
||||
}
|
||||
|
||||
export function buildStoredRulePdfBlob(rule: StoredLastPublishedRule): Blob {
|
||||
const sections = parsePublishedDocumentForCommunityRuleDisplay(rule.document);
|
||||
if (sections.length === 0) {
|
||||
throw new Error("exportEmptyDocument");
|
||||
}
|
||||
return sectionsToPdfBlob(rule.title, rule.summary, sections);
|
||||
}
|
||||
|
||||
export function downloadStoredRuleAsPdf(rule: StoredLastPublishedRule): void {
|
||||
if (typeof document === "undefined") return;
|
||||
const blob = buildStoredRulePdfBlob(rule);
|
||||
const base = exportFilenameBase(rule);
|
||||
downloadBlob(`${base}-community-rule.pdf`, blob);
|
||||
}
|
||||
|
||||
export function downloadBlob(filename: string, blob: Blob): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.rel = "noopener";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function downloadTextFile(
|
||||
filename: string,
|
||||
contents: string,
|
||||
mime: string,
|
||||
): void {
|
||||
const blob = new Blob([contents], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.rel = "noopener";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Pure URL builders + a small navigator for cross-app share flows (mailto,
|
||||
* Slack compose, Discord DMs). Native schemes are allowlisted below.
|
||||
*/
|
||||
|
||||
/** Slack: opens native client default workspace — no prefilled compose (Slack lacks team-agnostic native share URLs). docs: slack://open */
|
||||
export const SLACK_NATIVE_OPEN_URL = "slack://open";
|
||||
|
||||
/**
|
||||
* Discord desktop/mobile client DM hub (@me). Mirrors https://discord.com/channels/@me
|
||||
* (widely referenced community/client pattern: discord://-/channels/@me).
|
||||
*/
|
||||
export const DISCORD_NATIVE_DM_HUB_URL = "discord://-/channels/@me";
|
||||
|
||||
const ALLOWLISTED_NATIVE_NAV_URL = new Set<string>([
|
||||
SLACK_NATIVE_OPEN_URL,
|
||||
DISCORD_NATIVE_DM_HUB_URL,
|
||||
]);
|
||||
|
||||
/** Slack historically exposed a web share endpoint; still useful as primary web compose. */
|
||||
export function buildSlackWebShareUrl(externalUrl: string): string {
|
||||
return `https://slack.com/share?url=${encodeURIComponent(externalUrl)}`;
|
||||
}
|
||||
|
||||
/** Opens Discord in the browser / app; user pastes the rule URL manually. */
|
||||
export const DISCORD_WEB_DM_HUB_URL = "https://discord.com/channels/@me";
|
||||
|
||||
/**
|
||||
* RFC 6068-style mailto href with percent-encoded subject and body.
|
||||
* Body may contain newlines; they are encoded as %0A.
|
||||
*/
|
||||
export function buildMailtoShareHref(parts: {
|
||||
subject: string;
|
||||
body: string;
|
||||
}): string {
|
||||
const subject = encodeURIComponent(parts.subject);
|
||||
const body = encodeURIComponent(parts.body);
|
||||
return `mailto:?subject=${subject}&body=${body}`;
|
||||
}
|
||||
|
||||
export const NATIVE_SHARE_FALLBACK_DELAY_MS = 550;
|
||||
|
||||
/** @internal Injectable timer surface for tests. */
|
||||
export interface NativeFallbackTimers {
|
||||
setTimeout(cb: () => void, ms: number): unknown;
|
||||
clearTimeout(handle: unknown): void;
|
||||
}
|
||||
|
||||
/** @internal Location assign / href navigation for tests. */
|
||||
export interface NativeNavigateDeps {
|
||||
assignLocationHref: (_url: string) => void;
|
||||
getVisibilityState: () => Document["visibilityState"];
|
||||
onVisibilityChange: (_listener: () => void) => void;
|
||||
offVisibilityChange: (_listener: () => void) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns an allowlisted `slack:` / `discord:` URL once, then invokes
|
||||
* `fallback` after `delayMs` if the tab never became hidden (blur / minimize).
|
||||
* Cancels fallback when visibility becomes `"hidden"` (tab backgrounded).
|
||||
*
|
||||
* Not a guarantee the native app opens; web cannot detect install reliably.
|
||||
*/
|
||||
export function scheduleNativeSchemeThenFallback(
|
||||
nativeUrl: string,
|
||||
fallback: () => void,
|
||||
deps: NativeNavigateDeps,
|
||||
timers: NativeFallbackTimers,
|
||||
delayMs = NATIVE_SHARE_FALLBACK_DELAY_MS,
|
||||
): () => void {
|
||||
if (!ALLOWLISTED_NATIVE_NAV_URL.has(nativeUrl)) {
|
||||
fallback();
|
||||
return () => {};
|
||||
}
|
||||
|
||||
let cancelledBecauseHidden = deps.getVisibilityState() === "hidden";
|
||||
|
||||
const onHidden = (): void => {
|
||||
if (deps.getVisibilityState() === "hidden") {
|
||||
cancelledBecauseHidden = true;
|
||||
}
|
||||
};
|
||||
|
||||
deps.onVisibilityChange(onHidden);
|
||||
|
||||
try {
|
||||
deps.assignLocationHref(nativeUrl);
|
||||
} catch {
|
||||
deps.offVisibilityChange(onHidden);
|
||||
fallback();
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const id = timers.setTimeout(() => {
|
||||
deps.offVisibilityChange(onHidden);
|
||||
if (!cancelledBecauseHidden && deps.getVisibilityState() === "visible") {
|
||||
fallback();
|
||||
}
|
||||
}, delayMs);
|
||||
|
||||
return () => {
|
||||
timers.clearTimeout(id);
|
||||
deps.offVisibilityChange(onHidden);
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,18 @@
|
||||
{
|
||||
"toastTitle": "This is what folks see when you share your CommunityRule",
|
||||
"toastDescription": "Your group can use this document as an operating manual."
|
||||
"toastDescription": "Your group can use this document as an operating manual.",
|
||||
"shareNoRuleTitle": "Nothing to share yet",
|
||||
"shareNoRuleDescription": "Open this page from your profile after publishing, or publish a rule first.",
|
||||
"shareLinkCopiedTitle": "Link copied",
|
||||
"shareLinkCopiedDescription": "Paste the link anywhere to share your public CommunityRule.",
|
||||
"shareCopyFailedTitle": "Could not copy link",
|
||||
"shareCopyFailedDescription": "Copy the address from your browser bar, or try again.",
|
||||
"shareSlackFallbackTitle": "Link copied",
|
||||
"shareSlackFallbackDescription": "Slack didn't open in a new tab. Paste this link into Slack to share it.",
|
||||
"shareDiscordPasteTitle": "Link copied",
|
||||
"shareDiscordPasteDescription": "Paste the link into a channel or direct message in Discord.",
|
||||
"exportFailedTitle": "Could not export",
|
||||
"exportFailedDescription": "Something went wrong. Refresh the page and try again.",
|
||||
"exportEmptyDocumentTitle": "Could not export",
|
||||
"exportEmptyDocumentDescription": "Your rule document is not available to export yet."
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ import profile from "./pages/profile.json";
|
||||
import notFoundPage from "./pages/notFoundPage.json";
|
||||
import navigation from "./navigation.json";
|
||||
import metadata from "./metadata.json";
|
||||
import modalsShare from "./modals/share.json";
|
||||
import modalsPopoverExport from "./modals/popoverExport.json";
|
||||
|
||||
// create – stage 1: community
|
||||
import createInformational from "./create/community/informational.json";
|
||||
@@ -106,4 +108,8 @@ export default {
|
||||
},
|
||||
navigation,
|
||||
metadata,
|
||||
modals: {
|
||||
share: modalsShare,
|
||||
popoverExport: modalsPopoverExport,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"menuAriaLabel": "Export format",
|
||||
"downloadPdf": "Download PDF",
|
||||
"downloadCsv": "Download CSV",
|
||||
"downloadMarkdown": "Download Markdown"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"title": "Share this CommunityRule",
|
||||
"description": "Anyone with the link can view this rule.",
|
||||
"copyLink": "Copy link",
|
||||
"signal": "Signal",
|
||||
"slack": "Slack",
|
||||
"discord": "Discord",
|
||||
"email": "Email",
|
||||
"done": "Done",
|
||||
"closeDialogAriaLabel": "Close share dialog",
|
||||
"moreOptionsAriaLabel": "More share options"
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
"ajv": "^8.12.0",
|
||||
"critters": "^0.0.23",
|
||||
"gray-matter": "^4.0.3",
|
||||
"jspdf": "^2.5.2",
|
||||
"next": "^16.0.0",
|
||||
"next-intl": "^3.26.5",
|
||||
"nodemailer": "^8.0.4",
|
||||
@@ -1865,7 +1866,6 @@
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
|
||||
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -6005,6 +6005,13 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/raf": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
||||
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
|
||||
@@ -7599,6 +7606,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/atob": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
|
||||
"integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==",
|
||||
"license": "(MIT OR Apache-2.0)",
|
||||
"bin": {
|
||||
"atob": "bin/atob.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/available-typed-arrays": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||
@@ -7888,6 +7907,16 @@
|
||||
"bare-path": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-arraybuffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -8232,6 +8261,18 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/btoa": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz",
|
||||
"integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==",
|
||||
"license": "(MIT OR Apache-2.0)",
|
||||
"bin": {
|
||||
"btoa": "bin/btoa.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||
@@ -8457,6 +8498,26 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/canvg": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
|
||||
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/raf": "^3.4.0",
|
||||
"core-js": "^3.8.3",
|
||||
"raf": "^3.4.1",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"rgbcolor": "^1.0.1",
|
||||
"stackblur-canvas": "^2.0.0",
|
||||
"svg-pathdata": "^6.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/case-sensitive-paths-webpack-plugin": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz",
|
||||
@@ -9064,6 +9125,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.49.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
|
||||
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/core-js-compat": {
|
||||
"version": "3.45.1",
|
||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.45.1.tgz",
|
||||
@@ -9307,6 +9380,16 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/css-line-break": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/css-loader": {
|
||||
"version": "6.11.0",
|
||||
"resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.11.0.tgz",
|
||||
@@ -10011,6 +10094,13 @@
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "2.5.9",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.9.tgz",
|
||||
"integrity": "sha512-i6mvVmWN4xo9LrhCOZrDgSs9noW6nOahbrmzjRbPF36YPyj5Ue5lgok0MHDWkG7xzpWFO2OYttXdzM7rJxHvNA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
|
||||
@@ -11688,6 +11778,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/figures": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz",
|
||||
@@ -12822,6 +12918,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/html2canvas": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"css-line-break": "^2.1.0",
|
||||
"text-segmentation": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz",
|
||||
@@ -14303,6 +14413,24 @@
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/jspdf": {
|
||||
"version": "2.5.2",
|
||||
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-2.5.2.tgz",
|
||||
"integrity": "sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.2",
|
||||
"atob": "^2.1.2",
|
||||
"btoa": "^1.2.1",
|
||||
"fflate": "^0.8.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"canvg": "^3.0.6",
|
||||
"core-js": "^3.6.0",
|
||||
"dompurify": "^2.5.4",
|
||||
"html2canvas": "^1.0.0-rc.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jsx-ast-utils": {
|
||||
"version": "3.3.5",
|
||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
||||
@@ -17946,6 +18074,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/performance-now": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -18596,6 +18731,16 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/raf": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"performance-now": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/randombytes": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||
@@ -18976,6 +19121,13 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/regex-parser": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.3.1.tgz",
|
||||
@@ -19336,6 +19488,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rgbcolor": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
|
||||
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
|
||||
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8.15"
|
||||
}
|
||||
},
|
||||
"node_modules/rimraf": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||
@@ -20219,6 +20381,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stackblur-canvas": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
|
||||
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.1.14"
|
||||
}
|
||||
},
|
||||
"node_modules/stackframe": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz",
|
||||
@@ -20899,6 +21071,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/svg-pathdata": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
|
||||
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/svgo": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.3.tgz",
|
||||
@@ -21128,6 +21310,16 @@
|
||||
"b4a": "^1.6.4"
|
||||
}
|
||||
},
|
||||
"node_modules/text-segmentation": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/third-party-web": {
|
||||
"version": "0.26.7",
|
||||
"resolved": "https://registry.npmjs.org/third-party-web/-/third-party-web-0.26.7.tgz",
|
||||
@@ -22047,6 +22239,16 @@
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/utrie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"base64-arraybuffer": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"ajv": "^8.12.0",
|
||||
"critters": "^0.0.23",
|
||||
"gray-matter": "^4.0.3",
|
||||
"jspdf": "^2.5.2",
|
||||
"next": "^16.0.0",
|
||||
"next-intl": "^3.26.5",
|
||||
"nodemailer": "^8.0.4",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="38" height="38" viewBox="0 0 38 38" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M32.1897 7.0116C29.693 5.86857 27.0574 5.05767 24.35 4.59961C23.9795 5.2619 23.6443 5.94331 23.3457 6.64098C20.4618 6.20641 17.5291 6.20641 14.6452 6.64098C14.3465 5.94338 14.0113 5.26198 13.641 4.59961C10.9319 5.06154 8.29447 5.87436 5.79531 7.01757C0.833855 14.3581 -0.511119 21.5164 0.161368 28.573C3.06692 30.7198 6.31907 32.3524 9.77644 33.4C10.5556 32.3534 11.2445 31.2426 11.8357 30.0794C10.7114 29.6595 9.6263 29.1414 8.59286 28.5312C8.86484 28.3339 9.13085 28.1307 9.38789 27.9334C12.3949 29.3476 15.677 30.0808 19 30.0808C22.323 30.0808 25.605 29.3476 28.6121 27.9334C28.8721 28.1456 29.1381 28.3489 29.4071 28.5312C28.3716 29.1424 27.2845 29.6615 26.1582 30.0824C26.7494 31.2446 27.4383 32.3545 28.2175 33.4C31.6778 32.3566 34.9325 30.7248 37.8386 28.576C38.2092 21.618 36.0961 14.3791 31.4186 7.0116H32.1897ZM12.6876 24.2332C10.8136 24.2332 9.26535 22.5326 9.26535 20.4404C9.26535 18.3482 10.7598 16.6326 12.6816 16.6326C14.6034 16.6326 16.1397 18.3482 16.1068 20.4404C16.0739 22.5326 14.5974 24.2332 12.6876 24.2332ZM25.3124 24.2332C23.4354 24.2332 21.8932 22.5326 21.8932 20.4404C21.8932 18.3482 23.3876 16.6326 25.3124 16.6326C27.2372 16.6326 28.7615 18.3482 28.7286 20.4404C28.6957 22.5326 27.2222 24.2332 25.3124 24.2332Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 13C10.4295 13.5741 10.9774 14.0491 11.6066 14.3929C12.2357 14.7367 12.9315 14.9411 13.6467 14.9923C14.3618 15.0435 15.0796 14.9403 15.7513 14.6897C16.4231 14.4392 17.0331 14.047 17.54 13.54L20.54 10.54C21.4508 9.59695 21.9548 8.33394 21.9434 7.02296C21.932 5.71198 21.4061 4.45791 20.4791 3.53087C19.5521 2.60383 18.298 2.07799 16.987 2.0666C15.676 2.0552 14.413 2.55918 13.47 3.46997L11.75 5.17997" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14.0002 11.0002C13.5707 10.4261 13.0228 9.95104 12.3936 9.60729C11.7645 9.26353 11.0687 9.05911 10.3535 9.00789C9.63841 8.95667 8.92061 9.05986 8.24885 9.31044C7.5771 9.56103 6.96709 9.95316 6.4602 10.4602L3.4602 13.4602C2.54941 14.4032 2.04544 15.6662 2.05683 16.9772C2.06822 18.2882 2.59407 19.5423 3.52111 20.4693C4.44815 21.3964 5.70221 21.9222 7.01319 21.9336C8.32418 21.945 9.58719 21.441 10.5302 20.5302L12.2402 18.8202" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="mask0_22073_24433" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="36" height="36">
|
||||
<rect width="36" height="36" fill="#D9D9D9"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_22073_24433)">
|
||||
<path d="M30 6H6C4.35 6 3.015 7.35 3.015 9L3 27C3 28.65 4.35 30 6 30H30C31.65 30 33 28.65 33 27V9C33 7.35 31.65 6 30 6ZM30 12L18 19.5L6 12V9L18 16.5L30 9V12Z" fill="white"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 487 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="34" height="34" viewBox="0 0 34 34" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.9186 0.496282L13.3001 2.04183C11.7963 2.41349 10.357 3.00941 9.03056 3.80954L8.21432 2.4427C9.67434 1.55853 11.2607 0.902177 12.9186 0.496282ZM21.081 0.496282L20.6994 2.04183C22.2032 2.41349 23.6425 3.00941 24.969 3.80954L25.7949 2.4427C24.331 1.5593 22.7416 0.903036 21.081 0.496282ZM2.44268 8.20951C1.55933 9.67167 0.903051 11.2594 0.496262 12.9186L2.04181 13.3001C2.41347 11.7963 3.00939 10.357 3.80952 9.03058L2.44268 8.20951ZM1.59263 16.9998C1.59251 16.227 1.65064 15.4552 1.76651 14.6911L0.191983 14.4496C-0.0639943 16.1385 -0.0639943 17.8563 0.191983 19.5451L1.76651 19.3084C1.65082 18.5443 1.5927 17.7726 1.59263 16.9998ZM25.7852 31.552L24.969 30.19C23.6446 30.9909 22.2068 31.5869 20.7042 31.9577L21.0858 33.5033C22.7417 33.0935 24.3261 32.4356 25.7852 31.552ZM32.4069 16.9998C32.4068 17.7726 32.3487 18.5443 32.233 19.3084L33.8075 19.5451C34.0635 17.8563 34.0635 16.1385 33.8075 14.4496L32.233 14.6911C32.3489 15.4552 32.407 16.227 32.4069 16.9998ZM33.5033 21.0762L31.9577 20.6946C31.5869 22.2002 30.991 23.6413 30.19 24.969L31.5569 25.7901C32.4411 24.3266 33.0974 22.7371 33.5033 21.0762ZM19.3084 32.233C17.778 32.4649 16.2215 32.4649 14.6911 32.233L14.4544 33.8076C16.1417 34.0636 17.8579 34.0636 19.5451 33.8076L19.3084 32.233ZM29.4028 26.1378C28.4847 27.3827 27.384 28.4818 26.1378 29.3979L27.0844 30.6827C28.4571 29.6722 29.6715 28.4627 30.6875 27.0941L29.4028 26.1378ZM26.1378 4.5968C27.3841 5.51471 28.4848 6.61542 29.4028 7.86176L30.6875 6.90546C29.675 5.53563 28.4639 4.32451 27.0941 3.31207L26.1378 4.5968ZM4.59678 7.86176C5.51469 6.61542 6.6154 5.51471 7.86174 4.5968L6.90544 3.31207C5.53561 4.32451 4.32448 5.53563 3.31205 6.90546L4.59678 7.86176ZM31.5569 8.20951L30.19 9.03058C30.9909 10.3549 31.5869 11.7927 31.9577 13.2953L33.5033 12.9138C33.0964 11.2562 32.4402 9.66997 31.5569 8.20951ZM14.6911 1.76653C16.2215 1.53467 17.778 1.53467 19.3084 1.76653L19.5451 0.192003C17.8579 -0.0640011 16.1417 -0.0640011 14.4544 0.192003L14.6911 1.76653ZM5.41302 31.1077L2.12391 31.8708L2.89185 28.5817L1.34148 28.2195L0.573539 31.5086C0.52549 31.7125 0.518116 31.9239 0.551839 32.1307C0.585562 32.3374 0.659721 32.5355 0.770073 32.7136C0.880425 32.8917 1.0248 33.0463 1.19495 33.1685C1.3651 33.2908 1.55768 33.3783 1.76168 33.426C2.00024 33.4791 2.24758 33.4791 2.48615 33.426L5.77526 32.6677L5.41302 31.1077ZM1.66991 26.7995L3.22511 27.1569L3.75639 24.8772C2.98055 23.5758 2.40269 22.1661 2.04181 20.6946L0.496262 21.0762C0.843904 22.4847 1.36977 23.8432 2.06112 25.1187L1.66991 26.7995ZM9.10784 30.248L6.82816 30.7793L7.1904 32.3345L8.86634 31.9433C10.1409 32.6367 11.4996 33.1627 12.9089 33.5081L13.2905 31.9626C11.8235 31.597 10.4189 31.0159 9.12232 30.2383L9.10784 30.248ZM16.9998 3.18649C9.36865 3.19132 3.1913 9.37833 3.1913 17.0046C3.19551 19.6022 3.93025 22.1461 5.31159 24.3459L3.98339 30.0162L9.64877 28.688C16.1062 32.7498 24.6357 30.8131 28.6976 24.3604C32.7595 17.9078 30.8275 9.37833 24.3749 5.31162C22.1659 3.92246 19.6093 3.18579 16.9998 3.18649Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.6249 19.8498C6.6249 21.6748 5.1499 23.1498 3.3249 23.1498C1.4999 23.1498 0.0249023 21.6748 0.0249023 19.8498C0.0249023 18.0248 1.4999 16.5498 3.3249 16.5498H6.6249V19.8498ZM8.2749 19.8498C8.2749 18.0248 9.7499 16.5498 11.5749 16.5498C13.3999 16.5498 14.8749 18.0248 14.8749 19.8498V28.0998C14.8749 29.9248 13.3999 31.3998 11.5749 31.3998C9.7499 31.3998 8.2749 29.9248 8.2749 28.0998V19.8498Z" fill="white"/>
|
||||
<path d="M11.575 6.6C9.75 6.6 8.275 5.125 8.275 3.3C8.275 1.475 9.75 0 11.575 0C13.4 0 14.875 1.475 14.875 3.3V6.6H11.575ZM11.575 8.275C13.4 8.275 14.875 9.75 14.875 11.575C14.875 13.4 13.4 14.875 11.575 14.875H3.3C1.475 14.875 0 13.4 0 11.575C0 9.75 1.475 8.275 3.3 8.275H11.575Z" fill="white"/>
|
||||
<path d="M24.7998 11.575C24.7998 9.75 26.2748 8.275 28.0998 8.275C29.9248 8.275 31.3998 9.75 31.3998 11.575C31.3998 13.4 29.9248 14.875 28.0998 14.875H24.7998V11.575ZM23.1498 11.575C23.1498 13.4 21.6748 14.875 19.8498 14.875C18.0248 14.875 16.5498 13.4 16.5498 11.575V3.3C16.5498 1.475 18.0248 0 19.8498 0C21.6748 0 23.1498 1.475 23.1498 3.3V11.575Z" fill="white"/>
|
||||
<path d="M19.8498 24.7998C21.6748 24.7998 23.1498 26.2748 23.1498 28.0998C23.1498 29.9248 21.6748 31.3998 19.8498 31.3998C18.0248 31.3998 16.5498 29.9248 16.5498 28.0998V24.7998H19.8498ZM19.8498 23.1498C18.0248 23.1498 16.5498 21.6748 16.5498 19.8498C16.5498 18.0248 18.0248 16.5498 19.8498 16.5498H28.1248C29.9498 16.5498 31.4248 18.0248 31.4248 19.8498C31.4248 21.6748 29.9498 23.1498 28.1248 23.1498H19.8498Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,41 @@
|
||||
import React, { useState } from "react";
|
||||
import Share from "../../app/components/modals/Share";
|
||||
import { MessagesProvider } from "../../app/contexts/MessagesContext";
|
||||
import messages from "../../messages/en/index";
|
||||
|
||||
/** Figma: Modal / Share — node 22073-30884 (Community Rule System). */
|
||||
export default {
|
||||
title: "modals/Share",
|
||||
component: Share,
|
||||
};
|
||||
|
||||
function ShareStoryHost() {
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<MessagesProvider messages={messages}>
|
||||
<div className="min-h-[100dvh] bg-[var(--color-surface-inverse-brand-primary)] p-6">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md bg-white px-4 py-2 text-sm text-black"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Open Share
|
||||
</button>
|
||||
<Share
|
||||
isOpen={open}
|
||||
onClose={() => setOpen(false)}
|
||||
onCopyLink={() => {}}
|
||||
onEmailShare={() => {}}
|
||||
onSignalShare={() => {}}
|
||||
onSlackShare={() => {}}
|
||||
onDiscordShare={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</MessagesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export const Default = {
|
||||
name: "Modal / Share",
|
||||
render: () => <ShareStoryHost />,
|
||||
};
|
||||
@@ -31,7 +31,7 @@ export default {
|
||||
"After user input (or completed step), use Save & Exit and pass saveDraft: true to onExit",
|
||||
},
|
||||
onShare: { action: "share clicked" },
|
||||
onExport: { action: "export clicked" },
|
||||
onSelectExportFormat: { action: "export format" },
|
||||
onEdit: { action: "edit clicked" },
|
||||
onExit: { action: "exit clicked" },
|
||||
},
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders as render, screen } from "../utils/test-utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { CompletedScreen } from "../../app/(app)/create/screens/completed/CompletedScreen";
|
||||
import { CREATE_FLOW_LAST_PUBLISHED_KEY } from "../../lib/create/lastPublishedRule";
|
||||
import {
|
||||
CREATE_FLOW_COMPLETED_CELEBRATE_QUERY,
|
||||
CREATE_FLOW_COMPLETED_CELEBRATE_VALUE,
|
||||
} from "../../app/(app)/create/utils/flowSteps";
|
||||
|
||||
const storedRuleFixture = {
|
||||
id: "rule-fixture-1",
|
||||
@@ -32,9 +37,18 @@ const storedRuleFixture = {
|
||||
},
|
||||
};
|
||||
|
||||
function mockSearchParams(record?: Record<string, string>) {
|
||||
vi.mocked(useSearchParams).mockReturnValue(
|
||||
new URLSearchParams(record ?? undefined) as NonNullable<
|
||||
ReturnType<typeof useSearchParams>
|
||||
>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("CompletedScreen", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.removeItem(CREATE_FLOW_LAST_PUBLISHED_KEY);
|
||||
mockSearchParams();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -70,7 +84,25 @@ describe("CompletedScreen", () => {
|
||||
expect(screen.getByText("Fixture value title")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders toast alert when page loads", () => {
|
||||
it("does not show post-finalize toast without celebrate query", () => {
|
||||
render(<CompletedScreen />);
|
||||
expect(
|
||||
screen.queryByText(
|
||||
"This is what folks see when you share your CommunityRule",
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(
|
||||
"Your group can use this document as an operating manual.",
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows post-finalize toast in status region when celebrate query is set", () => {
|
||||
mockSearchParams({
|
||||
[CREATE_FLOW_COMPLETED_CELEBRATE_QUERY]:
|
||||
CREATE_FLOW_COMPLETED_CELEBRATE_VALUE,
|
||||
});
|
||||
render(<CompletedScreen />);
|
||||
expect(
|
||||
screen.getByText(
|
||||
@@ -82,10 +114,6 @@ describe("CompletedScreen", () => {
|
||||
"Your group can use this document as an operating manual.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders toast with role status", () => {
|
||||
render(<CompletedScreen />);
|
||||
const statusRegions = screen.getAllByRole("status");
|
||||
expect(statusRegions.length).toBeGreaterThanOrEqual(1);
|
||||
expect(
|
||||
|
||||
@@ -36,7 +36,7 @@ const config: ComponentTestSuiteConfig<CreateFlowTopNavProps> = {
|
||||
hasEdit: true,
|
||||
saveDraftOnExit: true,
|
||||
onShare: vi.fn(),
|
||||
onExport: vi.fn(),
|
||||
onSelectExportFormat: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onExit: vi.fn(),
|
||||
className: "test-class",
|
||||
@@ -66,11 +66,30 @@ describe("CreateFlowTopNav (behavioral tests)", () => {
|
||||
expect(exitButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Exit when saveDraftOnExit is false", () => {
|
||||
render(<CreateFlowTopNav saveDraftOnExit={false} />);
|
||||
const exitButton = screen.getByRole("button", { name: "Exit" });
|
||||
expect(exitButton).toBeInTheDocument();
|
||||
});
|
||||
it.each([
|
||||
["Download Markdown", "markdown"],
|
||||
["Download PDF", "pdf"],
|
||||
["Download CSV", "csv"],
|
||||
] as const)(
|
||||
"opens export menu and calls onSelectExportFormat for %s",
|
||||
async (menuLabel, expectedFormat) => {
|
||||
const user = userEvent.setup();
|
||||
const handleExport = vi.fn();
|
||||
render(
|
||||
<CreateFlowTopNav
|
||||
hasExport={true}
|
||||
onSelectExportFormat={handleExport}
|
||||
/>,
|
||||
);
|
||||
|
||||
const exportButton = screen.getByRole("button", { name: "Export" });
|
||||
await user.click(exportButton);
|
||||
const item = screen.getByRole("menuitem", { name: menuLabel });
|
||||
await user.click(item);
|
||||
|
||||
expect(handleExport).toHaveBeenCalledWith(expectedFormat);
|
||||
},
|
||||
);
|
||||
|
||||
it("renders Share button when hasShare is true", () => {
|
||||
render(<CreateFlowTopNav hasShare={true} />);
|
||||
@@ -86,7 +105,12 @@ describe("CreateFlowTopNav (behavioral tests)", () => {
|
||||
});
|
||||
|
||||
it("renders Export button when hasExport is true", () => {
|
||||
render(<CreateFlowTopNav hasExport={true} />);
|
||||
render(
|
||||
<CreateFlowTopNav
|
||||
hasExport={true}
|
||||
onSelectExportFormat={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const exportButton = screen.getByRole("button", { name: "Export" });
|
||||
expect(exportButton).toBeInTheDocument();
|
||||
});
|
||||
@@ -107,15 +131,4 @@ describe("CreateFlowTopNav (behavioral tests)", () => {
|
||||
|
||||
expect(handleExit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onShare when Share button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleShare = vi.fn();
|
||||
render(<CreateFlowTopNav hasShare={true} onShare={handleShare} />);
|
||||
|
||||
const shareButton = screen.getByRole("button", { name: "Share" });
|
||||
await user.click(shareButton);
|
||||
|
||||
expect(handleShare).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders as render, screen } from "../../utils/test-utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import ListItem from "../../../app/components/layout/ListItem";
|
||||
|
||||
describe("ListItem", () => {
|
||||
it("renders as a menu item with label and icon", () => {
|
||||
render(
|
||||
<div role="menu" aria-label="Test menu">
|
||||
<ListItem
|
||||
showDivider
|
||||
leadingIcon="markdown_copy"
|
||||
label="Download Markdown"
|
||||
onClick={vi.fn()}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
expect(screen.getByRole("menuitem", { name: "Download Markdown" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("invokes onClick when activated", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<div role="menu" aria-label="Test menu">
|
||||
<ListItem
|
||||
showDivider={false}
|
||||
leadingIcon="csv"
|
||||
label="Download CSV"
|
||||
onClick={onClick}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
await user.click(screen.getByRole("menuitem", { name: "Download CSV" }));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders as render, screen } from "../../utils/test-utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import ListItem from "../../../app/components/layout/ListItem";
|
||||
import Popover from "../../../app/components/modals/Popover";
|
||||
|
||||
describe("Popover (export menu)", () => {
|
||||
it("exposes a menu landmark with localized label", () => {
|
||||
render(
|
||||
<Popover id="export-menu" menuAriaLabel="Export format">
|
||||
<ListItem
|
||||
showDivider
|
||||
leadingIcon="markdown_copy"
|
||||
label="Download Markdown"
|
||||
onClick={vi.fn()}
|
||||
/>
|
||||
</Popover>,
|
||||
);
|
||||
expect(screen.getByRole("menu", { name: "Export format" })).toBeTruthy();
|
||||
expect(screen.getByRole("menuitem", { name: "Download Markdown" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("invokes handler when list item clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCsv = vi.fn();
|
||||
render(
|
||||
<Popover id="popover-csv" menuAriaLabel="Pick format">
|
||||
<ListItem
|
||||
showDivider={false}
|
||||
leadingIcon="csv"
|
||||
label="Download CSV"
|
||||
onClick={onCsv}
|
||||
/>
|
||||
</Popover>,
|
||||
);
|
||||
await user.click(screen.getByRole("menuitem", { name: "Download CSV" }));
|
||||
expect(onCsv).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders as render, screen } from "../../utils/test-utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import Share from "../../../app/components/modals/Share";
|
||||
|
||||
const noopHandlers = {
|
||||
onCopyLink: vi.fn(),
|
||||
onEmailShare: vi.fn(),
|
||||
onSignalShare: vi.fn(),
|
||||
onSlackShare: vi.fn(),
|
||||
onDiscordShare: vi.fn(),
|
||||
};
|
||||
|
||||
describe("Share modal", () => {
|
||||
it("does not render dialog when closed", () => {
|
||||
render(
|
||||
<Share isOpen={false} onClose={vi.fn()} {...noopHandlers} />,
|
||||
);
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders localized heading and copy link action when open", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCopyLink = vi.fn();
|
||||
render(
|
||||
<Share
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
{...noopHandlers}
|
||||
onCopyLink={onCopyLink}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: /Share this CommunityRule/ }),
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Copy link" }));
|
||||
expect(onCopyLink).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("invokes channel handlers for Signal, Slack, and Discord", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSignalShare = vi.fn();
|
||||
const onSlackShare = vi.fn();
|
||||
const onDiscordShare = vi.fn();
|
||||
render(
|
||||
<Share
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
{...noopHandlers}
|
||||
onSignalShare={onSignalShare}
|
||||
onSlackShare={onSlackShare}
|
||||
onDiscordShare={onDiscordShare}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Signal" }));
|
||||
await user.click(screen.getByRole("button", { name: "Slack" }));
|
||||
await user.click(screen.getByRole("button", { name: "Discord" }));
|
||||
expect(onSignalShare).toHaveBeenCalledTimes(1);
|
||||
expect(onSlackShare).toHaveBeenCalledTimes(1);
|
||||
expect(onDiscordShare).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onClose when Done is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
render(<Share isOpen={true} onClose={onClose} {...noopHandlers} />);
|
||||
await user.click(screen.getByRole("button", { name: "Done" }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onClose when header overflow (more) is activated, matching modal chrome parity", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
render(<Share isOpen={true} onClose={onClose} {...noopHandlers} />);
|
||||
await user.click(screen.getByRole("button", { name: "More share options" }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
buildCoreValuesPrefillFromTemplateBody,
|
||||
buildTemplateCustomizePrefill,
|
||||
} from "../../lib/create/applyTemplatePrefill";
|
||||
import { methodSectionsPinsForHydratedSelections } from "../../lib/create/publishedDocumentToCreateFlowState";
|
||||
import coreValuesMessages from "../../messages/en/create/customRule/coreValues.json";
|
||||
|
||||
function coreValuePresetId(label: string): string {
|
||||
@@ -153,3 +154,47 @@ describe("buildCoreValuesPrefillFromTemplateBody", () => {
|
||||
expect(prefill.selectedCommunicationMethodIds).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTemplateCustomizePrefill + method card pins", () => {
|
||||
it("derives compact-deck pins for each non-empty method facet prefill", () => {
|
||||
const body = {
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Communication",
|
||||
entries: [{ title: "In-Person Meetings", body: "x" }],
|
||||
},
|
||||
{
|
||||
categoryName: "Membership",
|
||||
entries: [{ title: "Peer Sponsorship", body: "m" }],
|
||||
},
|
||||
{
|
||||
categoryName: "Decision-making",
|
||||
entries: [{ title: "Consensus Decision-Making", body: "d" }],
|
||||
},
|
||||
{
|
||||
categoryName: "Conflict management",
|
||||
entries: [{ title: "Restorative Justice", body: "c" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const prefill = buildTemplateCustomizePrefill(body);
|
||||
expect(methodSectionsPinsForHydratedSelections(prefill)).toEqual({
|
||||
communication: true,
|
||||
membership: true,
|
||||
decisionApproaches: true,
|
||||
conflictManagement: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not set pins when template supplies values only", () => {
|
||||
const prefill = buildTemplateCustomizePrefill({
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Values",
|
||||
entries: [{ title: "Consensus", body: "" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(methodSectionsPinsForHydratedSelections(prefill)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import React from "react";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { MessagesProvider } from "../../../app/contexts/MessagesContext";
|
||||
import messages from "../../../messages/en/index";
|
||||
import { useCompletedRuleShareExport } from "../../../app/(app)/create/hooks/useCompletedRuleShareExport";
|
||||
import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule";
|
||||
import {
|
||||
DISCORD_WEB_DM_HUB_URL,
|
||||
DISCORD_NATIVE_DM_HUB_URL,
|
||||
NATIVE_SHARE_FALLBACK_DELAY_MS,
|
||||
SLACK_NATIVE_OPEN_URL,
|
||||
} from "../../../lib/create/shareChannels";
|
||||
|
||||
vi.mock("../../../lib/create/lastPublishedRule", () => ({
|
||||
readLastPublishedRule: vi.fn(),
|
||||
}));
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
return <MessagesProvider messages={messages}>{children}</MessagesProvider>;
|
||||
}
|
||||
|
||||
describe("useCompletedRuleShareExport", () => {
|
||||
const mockRule = {
|
||||
id: "rule-1",
|
||||
title: "Garden norms",
|
||||
summary: "Be kind.",
|
||||
document: {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(readLastPublishedRule).mockReturnValue(mockRule);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("shareViaSlack opens Slack web share URL when window.open succeeds", async () => {
|
||||
vi.useFakeTimers();
|
||||
const clickSpy = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, "click")
|
||||
.mockImplementation(() => {});
|
||||
const openSpy = vi.spyOn(window, "open").mockReturnValue({} as Window);
|
||||
const setBanner = vi.fn();
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useCompletedRuleShareExport({
|
||||
setActionBanner: setBanner,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sharePublishedRuleViaSlack();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(NATIVE_SHARE_FALLBACK_DELAY_MS + 25);
|
||||
});
|
||||
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
const anchorUnknown = clickSpy.mock.instances.at(-1) as unknown;
|
||||
expect(anchorUnknown).toBeInstanceOf(HTMLAnchorElement);
|
||||
const anchorEl = anchorUnknown as HTMLAnchorElement;
|
||||
expect(anchorEl.getAttribute("href")).toBe(SLACK_NATIVE_OPEN_URL);
|
||||
|
||||
const expectedUrl = `https://slack.com/share?url=${encodeURIComponent(`${window.location.origin}/rules/rule-1`)}`;
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
expectedUrl,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
expect(setBanner).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: "completedShareCopyFailed",
|
||||
status: "danger",
|
||||
}),
|
||||
);
|
||||
clickSpy.mockRestore();
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("shareViaSlack does not show copy-failed banner when fallback is skipped after handoff (no focus)", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
vi.spyOn(window, "open").mockReturnValue(null);
|
||||
const hasFocusSpy = vi.spyOn(document, "hasFocus").mockReturnValue(false);
|
||||
const writeText = vi.fn().mockRejectedValue(new Error("NotAllowedError"));
|
||||
vi.stubGlobal("navigator", {
|
||||
...navigator,
|
||||
share: undefined,
|
||||
canShare: undefined,
|
||||
clipboard: { writeText },
|
||||
});
|
||||
|
||||
const setBanner = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useCompletedRuleShareExport({
|
||||
setActionBanner: setBanner,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sharePublishedRuleViaSlack();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(NATIVE_SHARE_FALLBACK_DELAY_MS + 25);
|
||||
});
|
||||
|
||||
expect(writeText).not.toHaveBeenCalled();
|
||||
expect(setBanner).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ key: "completedShareCopyFailed" }),
|
||||
);
|
||||
hasFocusSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("shareViaSlack suppresses copy-failed banner when clipboard denies after focus loss", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
vi.spyOn(window, "open").mockReturnValue(null);
|
||||
let hasFocusCalls = 0;
|
||||
const hasFocusSpy = vi.spyOn(document, "hasFocus").mockImplementation(() => {
|
||||
hasFocusCalls += 1;
|
||||
return hasFocusCalls <= 2;
|
||||
});
|
||||
const writeText = vi.fn().mockImplementation(async () => {
|
||||
throw new Error("NotAllowedError");
|
||||
});
|
||||
vi.stubGlobal("navigator", {
|
||||
...navigator,
|
||||
share: undefined,
|
||||
canShare: undefined,
|
||||
clipboard: { writeText },
|
||||
});
|
||||
|
||||
const setBanner = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useCompletedRuleShareExport({
|
||||
setActionBanner: setBanner,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sharePublishedRuleViaSlack();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(NATIVE_SHARE_FALLBACK_DELAY_MS + 25);
|
||||
});
|
||||
|
||||
expect(writeText).toHaveBeenCalled();
|
||||
expect(setBanner).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ key: "completedShareCopyFailed" }),
|
||||
);
|
||||
hasFocusSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("shareViaSlack falls back to clipboard when popup blocked and Web Share cannot run", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
vi.spyOn(window, "open").mockReturnValue(null);
|
||||
vi.spyOn(document, "hasFocus").mockReturnValue(true);
|
||||
const share = vi.fn();
|
||||
vi.stubGlobal("navigator", {
|
||||
...navigator,
|
||||
share: share,
|
||||
canShare: vi.fn().mockReturnValue(false),
|
||||
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
});
|
||||
|
||||
const setBanner = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useCompletedRuleShareExport({
|
||||
setActionBanner: setBanner,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sharePublishedRuleViaSlack();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(NATIVE_SHARE_FALLBACK_DELAY_MS + 25);
|
||||
});
|
||||
|
||||
expect(share).not.toHaveBeenCalled();
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
`${window.location.origin}/rules/rule-1`,
|
||||
);
|
||||
expect(setBanner).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: "completedShareSlackFallback",
|
||||
status: "positive",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shareViaSignal uses navigator.share when canShare allows URL-only data", async () => {
|
||||
const share = vi.fn().mockResolvedValue(undefined);
|
||||
vi.stubGlobal("navigator", {
|
||||
...navigator,
|
||||
share: share,
|
||||
canShare: vi.fn().mockImplementation((data: ShareData) => data.url != null),
|
||||
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
});
|
||||
|
||||
const setBanner = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useCompletedRuleShareExport({
|
||||
setActionBanner: setBanner,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sharePublishedRuleViaSignal();
|
||||
});
|
||||
|
||||
expect(share).toHaveBeenCalledWith({
|
||||
url: `${window.location.origin}/rules/rule-1`,
|
||||
});
|
||||
expect(navigator.clipboard.writeText).not.toHaveBeenCalled();
|
||||
expect(setBanner).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shareViaDiscord opens Discord hub and copies link when share unavailable", async () => {
|
||||
vi.useFakeTimers();
|
||||
const clickSpy = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, "click")
|
||||
.mockImplementation(() => {});
|
||||
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
|
||||
vi.stubGlobal("navigator", {
|
||||
...navigator,
|
||||
share: undefined,
|
||||
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
});
|
||||
|
||||
const setBanner = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useCompletedRuleShareExport({
|
||||
setActionBanner: setBanner,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sharePublishedRuleViaDiscord();
|
||||
});
|
||||
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
const anchorUnknown = clickSpy.mock.instances.at(-1) as unknown;
|
||||
expect(anchorUnknown).toBeInstanceOf(HTMLAnchorElement);
|
||||
expect((anchorUnknown as HTMLAnchorElement).getAttribute("href")).toBe(
|
||||
DISCORD_NATIVE_DM_HUB_URL,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(NATIVE_SHARE_FALLBACK_DELAY_MS + 25);
|
||||
});
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
DISCORD_WEB_DM_HUB_URL,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
`${window.location.origin}/rules/rule-1`,
|
||||
);
|
||||
expect(setBanner).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: "completedShareDiscordPaste",
|
||||
status: "positive",
|
||||
}),
|
||||
);
|
||||
clickSpy.mockRestore();
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("onSelectExportFormat pdf triggers download with community-rule pdf filename", () => {
|
||||
vi.mocked(readLastPublishedRule).mockReturnValue({
|
||||
...mockRule,
|
||||
document: {
|
||||
sections: [
|
||||
{ categoryName: "Values", entries: [{ title: "Norm", body: "Text." }] },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const createObjectURL = vi.fn().mockReturnValue("blob:unit-test");
|
||||
const revokeObjectURL = vi.fn();
|
||||
Object.defineProperty(URL, "createObjectURL", {
|
||||
value: createObjectURL,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(URL, "revokeObjectURL", {
|
||||
value: revokeObjectURL,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const clickSpy = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, "click")
|
||||
.mockImplementation(() => {});
|
||||
const setBanner = vi.fn();
|
||||
|
||||
try {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useCompletedRuleShareExport({
|
||||
setActionBanner: setBanner,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.onSelectExportFormat("pdf");
|
||||
});
|
||||
|
||||
expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob));
|
||||
const blob = createObjectURL.mock.calls[0][0] as Blob;
|
||||
expect(blob.type).toBe("application/pdf");
|
||||
|
||||
const anchorUnknown = clickSpy.mock.instances.at(-1) as unknown;
|
||||
expect(anchorUnknown).toBeInstanceOf(HTMLAnchorElement);
|
||||
const anchorEl = anchorUnknown as HTMLAnchorElement;
|
||||
expect(anchorEl.getAttribute("download")).toBe(
|
||||
"garden-norms-community-rule.pdf",
|
||||
);
|
||||
expect(anchorEl.getAttribute("href")).toBe("blob:unit-test");
|
||||
expect(setBanner).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
Reflect.deleteProperty(URL, "createObjectURL");
|
||||
Reflect.deleteProperty(URL, "revokeObjectURL");
|
||||
clickSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { CreateFlowState } from "../../../app/(app)/create/types";
|
||||
import { useCreateFlowFinalize } from "../../../app/(app)/create/hooks/useCreateFlowFinalize";
|
||||
import { publishRule, updatePublishedRule } from "../../../lib/create/api";
|
||||
import { writeLastPublishedRule } from "../../../lib/create/lastPublishedRule";
|
||||
import {
|
||||
CREATE_FLOW_COMPLETED_CELEBRATE_QUERY,
|
||||
CREATE_FLOW_COMPLETED_CELEBRATE_VALUE,
|
||||
} from "../../../app/(app)/create/utils/flowSteps";
|
||||
|
||||
vi.mock("../../../lib/create/buildPublishPayload", () => ({
|
||||
buildPublishPayload: vi.fn(() => ({
|
||||
ok: true as const,
|
||||
title: "Published title",
|
||||
summary: "Published summary",
|
||||
document: {},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../../../lib/create/api", () => ({
|
||||
publishRule: vi.fn(),
|
||||
updatePublishedRule: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../lib/create/lastPublishedRule", () => ({
|
||||
writeLastPublishedRule: vi.fn(),
|
||||
}));
|
||||
|
||||
const emptyState = {} as CreateFlowState;
|
||||
|
||||
describe("useCreateFlowFinalize", () => {
|
||||
const router = { push: vi.fn() };
|
||||
const updateState = vi.fn();
|
||||
const openLogin = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(publishRule).mockReset();
|
||||
vi.mocked(updatePublishedRule).mockReset();
|
||||
vi.mocked(writeLastPublishedRule).mockReset();
|
||||
router.push.mockReset();
|
||||
updateState.mockReset();
|
||||
openLogin.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("routes with celebrate query after initial POST publish", async () => {
|
||||
vi.mocked(publishRule).mockResolvedValue({
|
||||
ok: true,
|
||||
id: "new-rule-id",
|
||||
title: "Published title",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCreateFlowFinalize({
|
||||
state: emptyState,
|
||||
router,
|
||||
openLogin,
|
||||
updateState,
|
||||
loginReturnPath: "/create/final-review",
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.finalize();
|
||||
});
|
||||
|
||||
expect(router.push).toHaveBeenCalledWith(
|
||||
`/create/completed?${CREATE_FLOW_COMPLETED_CELEBRATE_QUERY}=${CREATE_FLOW_COMPLETED_CELEBRATE_VALUE}`,
|
||||
);
|
||||
expect(updatePublishedRule).not.toHaveBeenCalled();
|
||||
expect(writeLastPublishedRule).toHaveBeenCalledWith({
|
||||
id: "new-rule-id",
|
||||
title: "Published title",
|
||||
summary: "Published summary",
|
||||
document: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("routes to /create/completed without celebrate after PATCH update", async () => {
|
||||
vi.mocked(updatePublishedRule).mockResolvedValue({ ok: true });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCreateFlowFinalize({
|
||||
state: {
|
||||
...emptyState,
|
||||
editingPublishedRuleId: " existing-id ",
|
||||
},
|
||||
router,
|
||||
openLogin,
|
||||
updateState,
|
||||
loginReturnPath: "/create/edit-rule",
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.finalize();
|
||||
});
|
||||
|
||||
expect(router.push).toHaveBeenCalledWith("/create/completed");
|
||||
expect(publishRule).not.toHaveBeenCalled();
|
||||
expect(updatePublishedRule).toHaveBeenCalledWith(
|
||||
"existing-id",
|
||||
expect.objectContaining({
|
||||
title: "Published title",
|
||||
summary: "Published summary",
|
||||
document: {},
|
||||
}),
|
||||
);
|
||||
expect(writeLastPublishedRule).toHaveBeenCalledWith({
|
||||
id: "existing-id",
|
||||
title: "Published title",
|
||||
summary: "Published summary",
|
||||
document: {},
|
||||
});
|
||||
expect(updateState).toHaveBeenCalledWith({
|
||||
editingPublishedRuleId: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildPrintableRuleHtmlDocument,
|
||||
buildPublicRuleUrl,
|
||||
buildStoredRulePdfBlob,
|
||||
exportFilenameBase,
|
||||
sectionsToCsv,
|
||||
sectionsToMarkdown,
|
||||
} from "../../../lib/create/ruleExport";
|
||||
import type { CommunityRuleSection } from "../../../app/components/type/CommunityRule/CommunityRule.types";
|
||||
|
||||
async function readBlobAsArrayBuffer(blob: Blob): Promise<ArrayBuffer> {
|
||||
if (typeof blob.arrayBuffer === "function") {
|
||||
return blob.arrayBuffer();
|
||||
}
|
||||
return new Promise<ArrayBuffer>((resolve, reject) => {
|
||||
const r = new FileReader();
|
||||
r.onload = (): void => resolve(r.result as ArrayBuffer);
|
||||
r.onerror = (): void => reject(new Error("FileReader failed"));
|
||||
r.readAsArrayBuffer(blob);
|
||||
});
|
||||
}
|
||||
|
||||
describe("ruleExport", () => {
|
||||
it("buildPublicRuleUrl encodes id and trims origin slash", () => {
|
||||
expect(buildPublicRuleUrl("https://example.com/", "abc/xyz")).toBe(
|
||||
"https://example.com/rules/abc%2Fxyz",
|
||||
);
|
||||
expect(buildPublicRuleUrl("https://example.com", "r1")).toBe(
|
||||
"https://example.com/rules/r1",
|
||||
);
|
||||
});
|
||||
|
||||
it("exportFilenameBase slugifies title", () => {
|
||||
expect(
|
||||
exportFilenameBase({
|
||||
id: "id-1",
|
||||
title: "Mutual Aid Mondays!",
|
||||
document: {},
|
||||
}),
|
||||
).toBe("mutual-aid-mondays");
|
||||
});
|
||||
|
||||
it("exportFilenameBase falls back to id fragment", () => {
|
||||
expect(
|
||||
exportFilenameBase({
|
||||
id: "full-uuid-here",
|
||||
title: " ",
|
||||
document: {},
|
||||
}),
|
||||
).toBe("rule-full-uui");
|
||||
});
|
||||
|
||||
it("sectionsToMarkdown renders title, summary, and sections", () => {
|
||||
const sections: CommunityRuleSection[] = [
|
||||
{
|
||||
categoryName: "Values",
|
||||
entries: [
|
||||
{
|
||||
title: "Solidarity",
|
||||
body: "First paragraph.\n\nSecond paragraph.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const md = sectionsToMarkdown(
|
||||
"My Rule",
|
||||
"Short summary.",
|
||||
sections,
|
||||
);
|
||||
expect(md).toContain("# My Rule");
|
||||
expect(md).toContain("Short summary.");
|
||||
expect(md).toContain("## Values");
|
||||
expect(md).toContain("### Solidarity");
|
||||
expect(md).toContain("First paragraph.");
|
||||
expect(md).toContain("Second paragraph.");
|
||||
});
|
||||
|
||||
it("sectionsToCsv includes header row, title metadata, sections, and quotes commas", () => {
|
||||
const sections: CommunityRuleSection[] = [
|
||||
{
|
||||
categoryName: "Values",
|
||||
entries: [
|
||||
{
|
||||
title: "Solidarity",
|
||||
body: "One, two",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const csv = sectionsToCsv("My Rule", "Sum, mary", sections);
|
||||
expect(csv).toContain("Section,Entry,Block label,Content");
|
||||
expect(csv).toContain('"Sum, mary"');
|
||||
expect(csv).toContain('"One, two"');
|
||||
expect(csv).toContain(",Title,,My Rule");
|
||||
});
|
||||
|
||||
it("buildPrintableRuleHtmlDocument escapes HTML in user content", () => {
|
||||
const sections: CommunityRuleSection[] = [
|
||||
{
|
||||
categoryName: 'Values <x>',
|
||||
entries: [{ title: "Entry", body: "<script>bad()</script>" }],
|
||||
},
|
||||
];
|
||||
const html = buildPrintableRuleHtmlDocument(
|
||||
'Title <t>',
|
||||
null,
|
||||
sections,
|
||||
);
|
||||
expect(html).toContain("<script>");
|
||||
expect(html).not.toContain("<script>bad()");
|
||||
expect(html).toContain("Values <x>");
|
||||
});
|
||||
|
||||
it("buildStoredRulePdfBlob produces application/pdf with PDF magic bytes", async () => {
|
||||
const blob = buildStoredRulePdfBlob({
|
||||
id: "id-1",
|
||||
title: "Garden Norms",
|
||||
summary: "Summary here.",
|
||||
document: {
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Values",
|
||||
entries: [{ title: "Solidarity", body: "Be kind.\n\nShare tools." }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(blob.type).toBe("application/pdf");
|
||||
expect(blob.size).toBeGreaterThan(500);
|
||||
const buf = new Uint8Array(await readBlobAsArrayBuffer(blob));
|
||||
expect(String.fromCharCode(...buf.subarray(0, 5))).toBe("%PDF-");
|
||||
});
|
||||
|
||||
it("buildStoredRulePdfBlob throws exportEmptyDocument when sections empty", () => {
|
||||
expect(() =>
|
||||
buildStoredRulePdfBlob({
|
||||
id: "id-1",
|
||||
title: "T",
|
||||
document: {},
|
||||
}),
|
||||
).toThrowError("exportEmptyDocument");
|
||||
});
|
||||
|
||||
it("export pdf attachment filename matches csv/md convention", () => {
|
||||
const rule = {
|
||||
id: "id-1",
|
||||
title: "Garden norms",
|
||||
document: {
|
||||
sections: [
|
||||
{ categoryName: "X", entries: [{ title: "t", body: "b" }] },
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(`${exportFilenameBase(rule)}-community-rule.pdf`).toBe(
|
||||
"garden-norms-community-rule.pdf",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import {
|
||||
buildMailtoShareHref,
|
||||
buildSlackWebShareUrl,
|
||||
DISCORD_NATIVE_DM_HUB_URL,
|
||||
DISCORD_WEB_DM_HUB_URL,
|
||||
NATIVE_SHARE_FALLBACK_DELAY_MS,
|
||||
type NativeFallbackTimers,
|
||||
scheduleNativeSchemeThenFallback,
|
||||
SLACK_NATIVE_OPEN_URL,
|
||||
} from "../../../lib/create/shareChannels";
|
||||
|
||||
describe("shareChannels", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("buildSlackWebShareUrl encodes the outgoing URL query value", () => {
|
||||
expect(buildSlackWebShareUrl("https://example.com/rules/r1")).toBe(
|
||||
"https://slack.com/share?url=https%3A%2F%2Fexample.com%2Frules%2Fr1",
|
||||
);
|
||||
expect(
|
||||
buildSlackWebShareUrl("https://example.com/rules/a?b=c&d=e"),
|
||||
).toBe(
|
||||
"https://slack.com/share?url=https%3A%2F%2Fexample.com%2Frules%2Fa%3Fb%3Dc%26d%3De",
|
||||
);
|
||||
});
|
||||
|
||||
it("buildMailtoShareHref percent-encodes subject and body including newlines", () => {
|
||||
expect(
|
||||
buildMailtoShareHref({
|
||||
subject: "Hello & welcome",
|
||||
body: "Line one\n\nhttps://x.com/y z",
|
||||
}),
|
||||
).toBe(
|
||||
"mailto:?subject=Hello%20%26%20welcome&body=Line%20one%0A%0Ahttps%3A%2F%2Fx.com%2Fy%20z",
|
||||
);
|
||||
});
|
||||
|
||||
it("buildMailtoShareHref handles unicode", () => {
|
||||
const href = buildMailtoShareHref({
|
||||
subject: "日本語",
|
||||
body: "café ☕",
|
||||
});
|
||||
expect(href.startsWith("mailto:?subject=")).toBe(true);
|
||||
expect(href).toContain(encodeURIComponent("日本語"));
|
||||
expect(href).toContain(encodeURIComponent("café ☕"));
|
||||
});
|
||||
|
||||
it("exposes Discord native + web DM hub URL constants", () => {
|
||||
expect(DISCORD_WEB_DM_HUB_URL).toBe("https://discord.com/channels/@me");
|
||||
expect(DISCORD_NATIVE_DM_HUB_URL).toBe("discord://-/channels/@me");
|
||||
});
|
||||
|
||||
it("scheduleNativeSchemeThenFallback skips native assign and invokes fallback synchronously when URL is not allowlisted", () => {
|
||||
const assign = vi.fn();
|
||||
const fb = vi.fn();
|
||||
const timers: NativeFallbackTimers = {
|
||||
setTimeout: (): unknown => 0,
|
||||
clearTimeout: vi.fn(),
|
||||
};
|
||||
|
||||
scheduleNativeSchemeThenFallback(
|
||||
"javascript:alert(1)",
|
||||
fb,
|
||||
{
|
||||
assignLocationHref: assign,
|
||||
getVisibilityState: (): Document["visibilityState"] => "visible",
|
||||
onVisibilityChange: () => {},
|
||||
offVisibilityChange: () => {},
|
||||
},
|
||||
timers,
|
||||
);
|
||||
|
||||
expect(assign).not.toHaveBeenCalled();
|
||||
expect(fb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("scheduleNativeSchemeThenFallback triggers fallback once after timeout when tab stays visible", () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const assign = vi.fn();
|
||||
const fb = vi.fn();
|
||||
|
||||
scheduleNativeSchemeThenFallback(
|
||||
SLACK_NATIVE_OPEN_URL,
|
||||
fb,
|
||||
{
|
||||
assignLocationHref: assign,
|
||||
getVisibilityState: (): Document["visibilityState"] => "visible",
|
||||
onVisibilityChange: () => {},
|
||||
offVisibilityChange: () => {},
|
||||
},
|
||||
window as unknown as NativeFallbackTimers,
|
||||
NATIVE_SHARE_FALLBACK_DELAY_MS,
|
||||
);
|
||||
|
||||
expect(assign).toHaveBeenCalledWith(SLACK_NATIVE_OPEN_URL);
|
||||
|
||||
vi.advanceTimersByTime(NATIVE_SHARE_FALLBACK_DELAY_MS - 1);
|
||||
expect(fb).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(10);
|
||||
expect(fb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("scheduleNativeSchemeThenFallback cancels fallback when visibility becomes hidden before timeout", () => {
|
||||
vi.useFakeTimers();
|
||||
let vis: Document["visibilityState"] = "visible";
|
||||
const listeners: (() => void)[] = [];
|
||||
const fb = vi.fn();
|
||||
|
||||
scheduleNativeSchemeThenFallback(
|
||||
DISCORD_NATIVE_DM_HUB_URL,
|
||||
fb,
|
||||
{
|
||||
assignLocationHref: vi.fn(),
|
||||
getVisibilityState: (): Document["visibilityState"] => vis,
|
||||
onVisibilityChange: (l: () => void): void => {
|
||||
listeners.push(l);
|
||||
},
|
||||
offVisibilityChange: (l: () => void): void => {
|
||||
const idx = listeners.indexOf(l);
|
||||
if (idx >= 0) listeners.splice(idx, 1);
|
||||
},
|
||||
},
|
||||
window as unknown as NativeFallbackTimers,
|
||||
NATIVE_SHARE_FALLBACK_DELAY_MS,
|
||||
);
|
||||
|
||||
vis = "hidden";
|
||||
listeners.forEach((l) => l());
|
||||
|
||||
vi.advanceTimersByTime(NATIVE_SHARE_FALLBACK_DELAY_MS + 200);
|
||||
expect(fb).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,185 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createFlowStateFromPublishedRule } from "../../lib/create/publishedDocumentToCreateFlowState";
|
||||
import {
|
||||
createFlowStateFromPublishedRule,
|
||||
isPublishedRuleSelectionMissing,
|
||||
methodSectionsPinsForHydratedSelections,
|
||||
methodSectionsPinsFromPublishedHydratePatch,
|
||||
} from "../../lib/create/publishedDocumentToCreateFlowState";
|
||||
import type { CreateFlowState } from "../../app/(app)/create/types";
|
||||
|
||||
describe("isPublishedRuleSelectionMissing", () => {
|
||||
it("is true when published patch has communication ids but state has none", () => {
|
||||
const patch = createFlowStateFromPublishedRule({
|
||||
id: "r",
|
||||
title: "T",
|
||||
summary: "",
|
||||
document: {
|
||||
methodSelections: {
|
||||
communication: [
|
||||
{
|
||||
id: "slack",
|
||||
label: "Slack",
|
||||
sections: {
|
||||
corePrinciple: "",
|
||||
logisticsAdmin: "",
|
||||
codeOfConduct: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const state = {
|
||||
sections: [],
|
||||
title: "T",
|
||||
editingPublishedRuleId: "r",
|
||||
} as CreateFlowState;
|
||||
expect(isPublishedRuleSelectionMissing(state, patch)).toBe(true);
|
||||
});
|
||||
|
||||
it("is false when sections are clear and state already has matching facet ids", () => {
|
||||
const patch = createFlowStateFromPublishedRule({
|
||||
id: "r",
|
||||
title: "T",
|
||||
summary: "",
|
||||
document: {
|
||||
methodSelections: {
|
||||
communication: [
|
||||
{
|
||||
id: "slack",
|
||||
label: "Slack",
|
||||
sections: {
|
||||
corePrinciple: "",
|
||||
logisticsAdmin: "",
|
||||
codeOfConduct: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const state = {
|
||||
sections: [],
|
||||
title: "T",
|
||||
editingPublishedRuleId: "r",
|
||||
selectedCommunicationMethodIds: ["slack"],
|
||||
} as CreateFlowState;
|
||||
expect(isPublishedRuleSelectionMissing(state, patch)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("methodSectionsPinsForHydratedSelections / methodSectionsPinsFromPublishedHydratePatch", () => {
|
||||
it("alias matches hydrated-selection helper output", () => {
|
||||
const partial: Partial<CreateFlowState> = {
|
||||
selectedCommunicationMethodIds: ["a"],
|
||||
selectedConflictManagementIds: ["b"],
|
||||
};
|
||||
expect(methodSectionsPinsFromPublishedHydratePatch(partial)).toEqual(
|
||||
methodSectionsPinsForHydratedSelections(partial),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("methodSectionsPinsFromPublishedHydratePatch", () => {
|
||||
it("sets communication when published patch includes communication ids", () => {
|
||||
const patch = createFlowStateFromPublishedRule({
|
||||
id: "r",
|
||||
title: "T",
|
||||
summary: "",
|
||||
document: {
|
||||
methodSelections: {
|
||||
communication: [
|
||||
{
|
||||
id: "slack",
|
||||
label: "Slack",
|
||||
sections: {
|
||||
corePrinciple: "",
|
||||
logisticsAdmin: "",
|
||||
codeOfConduct: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(methodSectionsPinsFromPublishedHydratePatch(patch)).toEqual({
|
||||
communication: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("sets all four method facets when each has selections on the patch", () => {
|
||||
const patch = createFlowStateFromPublishedRule({
|
||||
id: "r",
|
||||
title: "T",
|
||||
summary: "",
|
||||
document: {
|
||||
methodSelections: {
|
||||
communication: [
|
||||
{
|
||||
id: "slack",
|
||||
label: "S",
|
||||
sections: {
|
||||
corePrinciple: "",
|
||||
logisticsAdmin: "",
|
||||
codeOfConduct: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
membership: [
|
||||
{
|
||||
id: "x",
|
||||
label: "X",
|
||||
sections: {
|
||||
eligibility: "",
|
||||
joiningProcess: "",
|
||||
expectations: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
decisionApproaches: [
|
||||
{
|
||||
id: "d",
|
||||
label: "D",
|
||||
sections: {
|
||||
corePrinciple: "",
|
||||
applicableScope: [],
|
||||
selectedApplicableScope: [],
|
||||
stepByStepInstructions: "",
|
||||
consensusLevel: 0,
|
||||
objectionsDeadlocks: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
conflictManagement: [
|
||||
{
|
||||
id: "c",
|
||||
label: "C",
|
||||
sections: {
|
||||
corePrinciple: "",
|
||||
applicableScope: [],
|
||||
selectedApplicableScope: [],
|
||||
processProtocol: "",
|
||||
restorationFallbacks: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(methodSectionsPinsFromPublishedHydratePatch(patch)).toEqual({
|
||||
communication: true,
|
||||
membership: true,
|
||||
decisionApproaches: true,
|
||||
conflictManagement: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns {} when patch has no method-card selections", () => {
|
||||
expect(methodSectionsPinsFromPublishedHydratePatch({ sections: [] })).toEqual(
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFlowStateFromPublishedRule", () => {
|
||||
it("maps coreValues and methodSelections into draft fields", () => {
|
||||
|
||||