Cleanup pass 2
@@ -68,6 +68,7 @@ Run these (in order) before declaring a change done:
|
||||
```bash
|
||||
rm -rf .next # only if you moved/renamed routes or layouts
|
||||
npx tsc --noEmit # type check
|
||||
npm run knip # unused files / exports (local; no remote CI)
|
||||
npx vitest run # unit + component (~185 test files)
|
||||
npx next build # production build + route manifest
|
||||
```
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import CustomMethodCardModalBody from "./CustomMethodCardModalBody";
|
||||
import MethodCardCustomizeModalHeader from "./MethodCardCustomizeModalHeader";
|
||||
import { buildCustomRuleModalKebabMenu } from "./customRuleModalKebabMenu";
|
||||
import { useDiscardCustomizeConfirm } from "../hooks/useDiscardCustomizeConfirm";
|
||||
import {
|
||||
communicationPresetFor,
|
||||
conflictManagementPresetFor,
|
||||
@@ -52,7 +53,6 @@ import {
|
||||
} from "../../../../lib/create/coreValueChipFacet";
|
||||
import {
|
||||
captureMethodCardCustomizeSnapshot,
|
||||
confirmDiscardMethodCardCustomizeSession,
|
||||
isMethodCardCustomizeSessionDirty,
|
||||
type MethodCardCustomizeSnapshot,
|
||||
type MethodCardHeaderDraft,
|
||||
@@ -171,6 +171,8 @@ export function FinalReviewChipEditModal({
|
||||
const tModal = useTranslation(
|
||||
"create.reviewAndComplete.finalReview.chipEditModal",
|
||||
);
|
||||
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } =
|
||||
useDiscardCustomizeConfirm();
|
||||
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
const [modalEditUnlocked, setModalEditUnlocked] = useState(false);
|
||||
@@ -342,32 +344,30 @@ export function FinalReviewChipEditModal({
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const handleModalClose = useCallback(() => {
|
||||
const handleModalClose = useCallback(async () => {
|
||||
if (
|
||||
target &&
|
||||
target.groupKey === "coreValues" &&
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
!(await confirmDiscard(
|
||||
modalEditUnlocked,
|
||||
coreCustomizeSnapshotRef.current,
|
||||
draft?.groupKey === "coreValues" ? draft.value : null,
|
||||
null,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
target &&
|
||||
isMethodFacetGroup(target.groupKey) &&
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
!(await confirmDiscard(
|
||||
modalEditUnlocked,
|
||||
customizeSnapshotRef.current,
|
||||
methodDetailDraftForCustomizeSession(draft),
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -380,17 +380,17 @@ export function FinalReviewChipEditModal({
|
||||
}
|
||||
finalizeModalClose();
|
||||
}, [
|
||||
confirmDiscard,
|
||||
customizeHeaderDraft,
|
||||
draft,
|
||||
draftFieldBlocks,
|
||||
finalizeModalClose,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
replaceState,
|
||||
target,
|
||||
]);
|
||||
|
||||
const handleCancelCustomize = useCallback(() => {
|
||||
const handleCancelCustomize = useCallback(async () => {
|
||||
if (!modalEditUnlocked || !target) {
|
||||
return;
|
||||
}
|
||||
@@ -404,13 +404,12 @@ export function FinalReviewChipEditModal({
|
||||
}
|
||||
if (
|
||||
draft?.groupKey === "coreValues" &&
|
||||
isMethodCardCustomizeSessionDirty(
|
||||
!(await confirmDirtyCustomizeCancel(
|
||||
snap,
|
||||
draft.value,
|
||||
null,
|
||||
customizeHeaderDraft,
|
||||
) &&
|
||||
!window.confirm(modalKebabMenu.discardUnsavedCustomizeChanges)
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -435,13 +434,12 @@ export function FinalReviewChipEditModal({
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isMethodCardCustomizeSessionDirty(
|
||||
!(await confirmDirtyCustomizeCancel(
|
||||
snap,
|
||||
methodDetailDraftForCustomizeSession(draft),
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
) &&
|
||||
!window.confirm(modalKebabMenu.discardUnsavedCustomizeChanges)
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -451,11 +449,11 @@ export function FinalReviewChipEditModal({
|
||||
customizeSnapshotRef.current = null;
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
confirmDirtyCustomizeCancel,
|
||||
customizeHeaderDraft,
|
||||
draft,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
target,
|
||||
]);
|
||||
|
||||
@@ -565,7 +563,7 @@ export function FinalReviewChipEditModal({
|
||||
tCm,
|
||||
]);
|
||||
|
||||
const handleRemoveSelectedFromModal = useCallback(() => {
|
||||
const handleRemoveSelectedFromModal = useCallback(async () => {
|
||||
if (!target || !isMethodFacetGroup(target.groupKey)) {
|
||||
return;
|
||||
}
|
||||
@@ -575,14 +573,13 @@ export function FinalReviewChipEditModal({
|
||||
}
|
||||
onInteract?.();
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
!(await confirmDiscard(
|
||||
modalEditUnlocked,
|
||||
customizeSnapshotRef.current,
|
||||
methodDetailDraftForCustomizeSession(draft),
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -597,32 +594,31 @@ export function FinalReviewChipEditModal({
|
||||
}));
|
||||
finalizeModalClose();
|
||||
}, [
|
||||
confirmDiscard,
|
||||
customizeHeaderDraft,
|
||||
draft,
|
||||
draftFieldBlocks,
|
||||
finalizeModalClose,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
onInteract,
|
||||
replaceState,
|
||||
selectionIdsForTarget,
|
||||
target,
|
||||
]);
|
||||
|
||||
const handleRemoveCoreValueFromModal = useCallback(() => {
|
||||
const handleRemoveCoreValueFromModal = useCallback(async () => {
|
||||
if (!target || target.groupKey !== "coreValues") {
|
||||
return;
|
||||
}
|
||||
onInteract?.();
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
!(await confirmDiscard(
|
||||
modalEditUnlocked,
|
||||
coreCustomizeSnapshotRef.current,
|
||||
draft?.groupKey === "coreValues" ? draft.value : null,
|
||||
null,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -634,17 +630,17 @@ export function FinalReviewChipEditModal({
|
||||
}));
|
||||
finalizeModalClose();
|
||||
}, [
|
||||
confirmDiscard,
|
||||
customizeHeaderDraft,
|
||||
draft,
|
||||
finalizeModalClose,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
onInteract,
|
||||
replaceState,
|
||||
target,
|
||||
]);
|
||||
|
||||
const handleDuplicateCoreValue = useCallback(() => {
|
||||
const handleDuplicateCoreValue = useCallback(async () => {
|
||||
if (
|
||||
!target ||
|
||||
target.groupKey !== "coreValues" ||
|
||||
@@ -659,14 +655,13 @@ export function FinalReviewChipEditModal({
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
!(await confirmDiscard(
|
||||
modalEditUnlocked,
|
||||
coreCustomizeSnapshotRef.current,
|
||||
draft.value,
|
||||
null,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -711,10 +706,10 @@ export function FinalReviewChipEditModal({
|
||||
chipLabel: outcome.newLabel,
|
||||
});
|
||||
}, [
|
||||
confirmDiscard,
|
||||
customizeHeaderDraft,
|
||||
draft,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
modalKebabMenu.duplicateTitleSuffix,
|
||||
onEditTargetChange,
|
||||
onInteract,
|
||||
@@ -1015,6 +1010,7 @@ export function FinalReviewChipEditModal({
|
||||
: showMethodModalPrimary;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Create
|
||||
isOpen={isOpen}
|
||||
onClose={handleModalClose}
|
||||
@@ -1184,6 +1180,8 @@ export function FinalReviewChipEditModal({
|
||||
))}
|
||||
</div>
|
||||
</Create>
|
||||
{confirmDialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ export function useCreateFlowExit({
|
||||
user: { id: string; email: string } | null;
|
||||
/** When save fails, surface the server message in the create shell banner (no leave confirm). */
|
||||
setDraftSaveBannerMessage?: (_message: string | null) => void;
|
||||
/** When exit would discard unsaved work, return true to proceed. Defaults to `window.confirm`. */
|
||||
/** When exit would discard unsaved work, return true to proceed. Defaults to denying leave. */
|
||||
confirmLeave?: () => Promise<boolean>;
|
||||
}): (_options?: { saveDraft?: boolean }) => Promise<void> {
|
||||
return useCallback(
|
||||
@@ -41,12 +41,7 @@ export function useCreateFlowExit({
|
||||
const saveDraft = options?.saveDraft ?? false;
|
||||
|
||||
if (!saveDraft) {
|
||||
const confirmFn =
|
||||
confirmLeave ??
|
||||
(async () => {
|
||||
if (typeof window === "undefined") return true;
|
||||
return window.confirm(messages.create.topNav.leaveConfirmLoss);
|
||||
});
|
||||
const confirmFn = confirmLeave ?? (async () => false);
|
||||
const confirmed = await confirmFn();
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import messages from "../../../../messages/en/index";
|
||||
import { useAsyncConfirm } from "../../../hooks/useAsyncConfirm";
|
||||
import type { CustomMethodCardFieldBlock } from "../../../../lib/create/customMethodCardFieldBlocks";
|
||||
import {
|
||||
confirmDiscardMethodCardCustomizeSession,
|
||||
isMethodCardCustomizeSessionDirty,
|
||||
type MethodCardCustomizeSnapshot,
|
||||
type MethodCardHeaderDraft,
|
||||
} from "../../../../lib/create/methodCardCustomizeSession";
|
||||
|
||||
const copy = messages.create.customRule.modalKebabMenu;
|
||||
|
||||
const confirmOptions = {
|
||||
title: copy.discardUnsavedCustomizeChangesTitle,
|
||||
description: copy.discardUnsavedCustomizeChangesDescription,
|
||||
proceedText: copy.discardUnsavedCustomizeChangesProceed,
|
||||
cancelText: copy.discardUnsavedCustomizeChangesCancel,
|
||||
};
|
||||
|
||||
/**
|
||||
* Create-flow confirm for exiting customize mode with unsaved edits.
|
||||
*
|
||||
* @returns Async helpers plus `confirmDialog` to render once in the screen JSX.
|
||||
*/
|
||||
export function useDiscardCustomizeConfirm() {
|
||||
const { requestConfirm, confirmDialog } = useAsyncConfirm();
|
||||
|
||||
const runConfirm = useCallback(
|
||||
() => requestConfirm(confirmOptions),
|
||||
[requestConfirm],
|
||||
);
|
||||
|
||||
const confirmDiscard = useCallback(
|
||||
async <TDraft,>(
|
||||
modalEditUnlocked: boolean,
|
||||
snapshot: MethodCardCustomizeSnapshot<TDraft> | null,
|
||||
pendingDraft: TDraft | null,
|
||||
draftFieldBlocks: CustomMethodCardFieldBlock[] | null,
|
||||
headerDraft: MethodCardHeaderDraft | null,
|
||||
) =>
|
||||
confirmDiscardMethodCardCustomizeSession(
|
||||
modalEditUnlocked,
|
||||
snapshot,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
headerDraft,
|
||||
runConfirm,
|
||||
),
|
||||
[runConfirm],
|
||||
);
|
||||
|
||||
const confirmDirtyCustomizeCancel = useCallback(
|
||||
async <TDraft,>(
|
||||
snapshot: MethodCardCustomizeSnapshot<TDraft>,
|
||||
pendingDraft: TDraft | null,
|
||||
draftFieldBlocks: CustomMethodCardFieldBlock[] | null,
|
||||
headerDraft: MethodCardHeaderDraft | null,
|
||||
) => {
|
||||
if (
|
||||
!isMethodCardCustomizeSessionDirty(
|
||||
snapshot,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
headerDraft,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return runConfirm();
|
||||
},
|
||||
[runConfirm],
|
||||
);
|
||||
|
||||
return { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog };
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { useState, useCallback, useMemo, useRef } from "react";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
|
||||
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import CardStack from "../../../../components/cards/CardStack";
|
||||
@@ -53,8 +54,6 @@ import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalK
|
||||
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
|
||||
import {
|
||||
captureMethodCardCustomizeSnapshot,
|
||||
confirmDiscardMethodCardCustomizeSession,
|
||||
isMethodCardCustomizeSessionDirty,
|
||||
type MethodCardCustomizeSnapshot,
|
||||
type MethodCardHeaderDraft,
|
||||
} from "../../../../../lib/create/methodCardCustomizeSession";
|
||||
@@ -65,6 +64,8 @@ export function CommunicationMethodsScreen() {
|
||||
const comm = m.create.customRule.communication;
|
||||
const modalKebabMenu = m.create.customRule.modalKebabMenu;
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } =
|
||||
useDiscardCustomizeConfirm();
|
||||
const { state, updateState, replaceState, markCreateFlowInteraction } =
|
||||
useCreateFlow();
|
||||
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
|
||||
@@ -201,16 +202,15 @@ export function CommunicationMethodsScreen() {
|
||||
],
|
||||
);
|
||||
|
||||
const handleCreateModalClose = useCallback(() => {
|
||||
const handleCreateModalClose = useCallback(async () => {
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
!(await confirmDiscard(
|
||||
modalEditUnlocked,
|
||||
customizeSnapshotRef.current,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -241,15 +241,15 @@ export function CommunicationMethodsScreen() {
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
confirmDiscard,
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
replaceState,
|
||||
]);
|
||||
|
||||
const handleCancelCustomize = useCallback(() => {
|
||||
const handleCancelCustomize = useCallback(async () => {
|
||||
if (!modalEditUnlocked) {
|
||||
return;
|
||||
}
|
||||
@@ -262,13 +262,12 @@ export function CommunicationMethodsScreen() {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isMethodCardCustomizeSessionDirty(
|
||||
!(await confirmDirtyCustomizeCancel(
|
||||
snap,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
) &&
|
||||
!window.confirm(modalKebabMenu.discardUnsavedCustomizeChanges)
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -278,27 +277,26 @@ export function CommunicationMethodsScreen() {
|
||||
customizeSnapshotRef.current = null;
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
confirmDirtyCustomizeCancel,
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
]);
|
||||
|
||||
const handleRemoveSelectedFromModal = useCallback(() => {
|
||||
const handleRemoveSelectedFromModal = useCallback(async () => {
|
||||
if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
!(await confirmDiscard(
|
||||
modalEditUnlocked,
|
||||
customizeSnapshotRef.current,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -310,14 +308,14 @@ export function CommunicationMethodsScreen() {
|
||||
pendingCardId,
|
||||
),
|
||||
);
|
||||
handleCreateModalClose();
|
||||
await handleCreateModalClose();
|
||||
}, [
|
||||
confirmDiscard,
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
handleCreateModalClose,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
pendingCardId,
|
||||
selectedIds,
|
||||
@@ -829,6 +827,7 @@ export function CommunicationMethodsScreen() {
|
||||
uploadCreateFlowFile(file, "customMethodAttachment")
|
||||
}
|
||||
/>
|
||||
{confirmDialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useState, useCallback, useMemo, useRef } from "react";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
|
||||
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import CardStack from "../../../../components/cards/CardStack";
|
||||
@@ -50,8 +51,6 @@ import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalK
|
||||
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
|
||||
import {
|
||||
captureMethodCardCustomizeSnapshot,
|
||||
confirmDiscardMethodCardCustomizeSession,
|
||||
isMethodCardCustomizeSessionDirty,
|
||||
type MethodCardCustomizeSnapshot,
|
||||
type MethodCardHeaderDraft,
|
||||
} from "../../../../../lib/create/methodCardCustomizeSession";
|
||||
@@ -62,6 +61,8 @@ export function ConflictManagementScreen() {
|
||||
const cm = m.create.customRule.conflictManagement;
|
||||
const modalKebabMenu = m.create.customRule.modalKebabMenu;
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } =
|
||||
useDiscardCustomizeConfirm();
|
||||
const { state, updateState, replaceState, markCreateFlowInteraction } =
|
||||
useCreateFlow();
|
||||
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
|
||||
@@ -202,16 +203,15 @@ export function ConflictManagementScreen() {
|
||||
],
|
||||
);
|
||||
|
||||
const handleCreateModalClose = useCallback(() => {
|
||||
const handleCreateModalClose = useCallback(async () => {
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
!(await confirmDiscard(
|
||||
modalEditUnlocked,
|
||||
customizeSnapshotRef.current,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -242,15 +242,15 @@ export function ConflictManagementScreen() {
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
confirmDiscard,
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
replaceState,
|
||||
]);
|
||||
|
||||
const handleCancelCustomize = useCallback(() => {
|
||||
const handleCancelCustomize = useCallback(async () => {
|
||||
if (!modalEditUnlocked) {
|
||||
return;
|
||||
}
|
||||
@@ -263,13 +263,12 @@ export function ConflictManagementScreen() {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isMethodCardCustomizeSessionDirty(
|
||||
!(await confirmDirtyCustomizeCancel(
|
||||
snap,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
) &&
|
||||
!window.confirm(modalKebabMenu.discardUnsavedCustomizeChanges)
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -279,27 +278,26 @@ export function ConflictManagementScreen() {
|
||||
customizeSnapshotRef.current = null;
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
confirmDirtyCustomizeCancel,
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
]);
|
||||
|
||||
const handleRemoveSelectedFromModal = useCallback(() => {
|
||||
const handleRemoveSelectedFromModal = useCallback(async () => {
|
||||
if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
!(await confirmDiscard(
|
||||
modalEditUnlocked,
|
||||
customizeSnapshotRef.current,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -311,14 +309,14 @@ export function ConflictManagementScreen() {
|
||||
pendingCardId,
|
||||
),
|
||||
);
|
||||
handleCreateModalClose();
|
||||
await handleCreateModalClose();
|
||||
}, [
|
||||
confirmDiscard,
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
handleCreateModalClose,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
pendingCardId,
|
||||
selectedIds,
|
||||
@@ -828,6 +826,7 @@ export function ConflictManagementScreen() {
|
||||
uploadCreateFlowFile(file, "customMethodAttachment")
|
||||
}
|
||||
/>
|
||||
{confirmDialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useState, useCallback, useMemo, useRef } from "react";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
|
||||
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import CardStack from "../../../../components/cards/CardStack";
|
||||
@@ -51,8 +52,6 @@ import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalK
|
||||
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
|
||||
import {
|
||||
captureMethodCardCustomizeSnapshot,
|
||||
confirmDiscardMethodCardCustomizeSession,
|
||||
isMethodCardCustomizeSessionDirty,
|
||||
type MethodCardCustomizeSnapshot,
|
||||
type MethodCardHeaderDraft,
|
||||
} from "../../../../../lib/create/methodCardCustomizeSession";
|
||||
@@ -63,6 +62,8 @@ export function MembershipMethodsScreen() {
|
||||
const mem = m.create.customRule.membership;
|
||||
const modalKebabMenu = m.create.customRule.modalKebabMenu;
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } =
|
||||
useDiscardCustomizeConfirm();
|
||||
const { state, updateState, replaceState, markCreateFlowInteraction } =
|
||||
useCreateFlow();
|
||||
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
|
||||
@@ -199,16 +200,15 @@ export function MembershipMethodsScreen() {
|
||||
],
|
||||
);
|
||||
|
||||
const handleCreateModalClose = useCallback(() => {
|
||||
const handleCreateModalClose = useCallback(async () => {
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
!(await confirmDiscard(
|
||||
modalEditUnlocked,
|
||||
customizeSnapshotRef.current,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -239,15 +239,15 @@ export function MembershipMethodsScreen() {
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
confirmDiscard,
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
replaceState,
|
||||
]);
|
||||
|
||||
const handleCancelCustomize = useCallback(() => {
|
||||
const handleCancelCustomize = useCallback(async () => {
|
||||
if (!modalEditUnlocked) {
|
||||
return;
|
||||
}
|
||||
@@ -260,13 +260,12 @@ export function MembershipMethodsScreen() {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isMethodCardCustomizeSessionDirty(
|
||||
!(await confirmDirtyCustomizeCancel(
|
||||
snap,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
) &&
|
||||
!window.confirm(modalKebabMenu.discardUnsavedCustomizeChanges)
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -276,27 +275,26 @@ export function MembershipMethodsScreen() {
|
||||
customizeSnapshotRef.current = null;
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
confirmDirtyCustomizeCancel,
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
]);
|
||||
|
||||
const handleRemoveSelectedFromModal = useCallback(() => {
|
||||
const handleRemoveSelectedFromModal = useCallback(async () => {
|
||||
if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
!(await confirmDiscard(
|
||||
modalEditUnlocked,
|
||||
customizeSnapshotRef.current,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -304,14 +302,14 @@ export function MembershipMethodsScreen() {
|
||||
updateState(
|
||||
removeMethodCardFromFacetSelection(state, "membership", pendingCardId),
|
||||
);
|
||||
handleCreateModalClose();
|
||||
await handleCreateModalClose();
|
||||
}, [
|
||||
confirmDiscard,
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
handleCreateModalClose,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
pendingCardId,
|
||||
selectedIds,
|
||||
@@ -821,6 +819,7 @@ export function MembershipMethodsScreen() {
|
||||
uploadCreateFlowFile(file, "customMethodAttachment")
|
||||
}
|
||||
/>
|
||||
{confirmDialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import type { InfoMessageBoxItem } from "../../../../components/controls/InfoMes
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
|
||||
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
||||
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
|
||||
import { DecisionApproachEditFields } from "../../components/methodEditFields";
|
||||
@@ -52,8 +53,6 @@ import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalK
|
||||
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
|
||||
import {
|
||||
captureMethodCardCustomizeSnapshot,
|
||||
confirmDiscardMethodCardCustomizeSession,
|
||||
isMethodCardCustomizeSessionDirty,
|
||||
type MethodCardCustomizeSnapshot,
|
||||
type MethodCardHeaderDraft,
|
||||
} from "../../../../../lib/create/methodCardCustomizeSession";
|
||||
@@ -64,6 +63,8 @@ export function DecisionApproachesScreen() {
|
||||
const da = m.create.customRule.decisionApproaches;
|
||||
const modalKebabMenu = m.create.customRule.modalKebabMenu;
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } =
|
||||
useDiscardCustomizeConfirm();
|
||||
const { state, updateState, replaceState, markCreateFlowInteraction } =
|
||||
useCreateFlow();
|
||||
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
|
||||
@@ -216,16 +217,15 @@ export function DecisionApproachesScreen() {
|
||||
],
|
||||
);
|
||||
|
||||
const handleCreateModalClose = useCallback(() => {
|
||||
const handleCreateModalClose = useCallback(async () => {
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
!(await confirmDiscard(
|
||||
modalEditUnlocked,
|
||||
customizeSnapshotRef.current,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -256,15 +256,15 @@ export function DecisionApproachesScreen() {
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
confirmDiscard,
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
replaceState,
|
||||
]);
|
||||
|
||||
const handleCancelCustomize = useCallback(() => {
|
||||
const handleCancelCustomize = useCallback(async () => {
|
||||
if (!modalEditUnlocked) {
|
||||
return;
|
||||
}
|
||||
@@ -277,13 +277,12 @@ export function DecisionApproachesScreen() {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isMethodCardCustomizeSessionDirty(
|
||||
!(await confirmDirtyCustomizeCancel(
|
||||
snap,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
) &&
|
||||
!window.confirm(modalKebabMenu.discardUnsavedCustomizeChanges)
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -293,27 +292,26 @@ export function DecisionApproachesScreen() {
|
||||
customizeSnapshotRef.current = null;
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
confirmDirtyCustomizeCancel,
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
]);
|
||||
|
||||
const handleRemoveSelectedFromModal = useCallback(() => {
|
||||
const handleRemoveSelectedFromModal = useCallback(async () => {
|
||||
if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
!(await confirmDiscard(
|
||||
modalEditUnlocked,
|
||||
customizeSnapshotRef.current,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -325,14 +323,14 @@ export function DecisionApproachesScreen() {
|
||||
pendingCardId,
|
||||
),
|
||||
);
|
||||
handleCreateModalClose();
|
||||
await handleCreateModalClose();
|
||||
}, [
|
||||
confirmDiscard,
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
handleCreateModalClose,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
pendingCardId,
|
||||
selectedIds,
|
||||
@@ -867,6 +865,7 @@ export function DecisionApproachesScreen() {
|
||||
uploadCreateFlowFile(file, "customMethodAttachment")
|
||||
}
|
||||
/>
|
||||
{confirmDialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import ContentLockup from "../../../../components/type/ContentLockup";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import { buildCoreValueChipOptionsFromDraft } from "../../../../../lib/create/coreValueChipOptionsFromDraft";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
|
||||
import type {
|
||||
CommunityStructureChipSnapshotRow,
|
||||
CoreValueDetailEntry,
|
||||
@@ -19,7 +20,6 @@ import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomize
|
||||
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
|
||||
import {
|
||||
captureMethodCardCustomizeSnapshot,
|
||||
confirmDiscardMethodCardCustomizeSession,
|
||||
isMethodCardCustomizeSessionDirty,
|
||||
type MethodCardCustomizeSnapshot,
|
||||
type MethodCardHeaderDraft,
|
||||
@@ -101,6 +101,8 @@ export function CoreValuesSelectScreen() {
|
||||
[cv.values],
|
||||
);
|
||||
|
||||
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } =
|
||||
useDiscardCustomizeConfirm();
|
||||
const { markCreateFlowInteraction, updateState, replaceState, state } =
|
||||
useCreateFlow();
|
||||
|
||||
@@ -239,7 +241,7 @@ export function CoreValuesSelectScreen() {
|
||||
setModalEditUnlocked(true);
|
||||
}, [activeModalChipId, coreValueOptions, draft, markCreateFlowInteraction]);
|
||||
|
||||
const handleCancelCustomize = useCallback(() => {
|
||||
const handleCancelCustomize = useCallback(async () => {
|
||||
if (!modalEditUnlocked) return;
|
||||
const snap = coreCustomizeSnapshotRef.current;
|
||||
if (!snap) {
|
||||
@@ -247,18 +249,22 @@ export function CoreValuesSelectScreen() {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isMethodCardCustomizeSessionDirty(snap, draft, null, customizeHeaderDraft) &&
|
||||
!window.confirm(modalKebabMenu.discardUnsavedCustomizeChanges)
|
||||
!(await confirmDirtyCustomizeCancel(
|
||||
snap,
|
||||
draft,
|
||||
null,
|
||||
customizeHeaderDraft,
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setDraft(structuredClone(snap.pendingDraft));
|
||||
resetCustomizeSession();
|
||||
}, [
|
||||
confirmDirtyCustomizeCancel,
|
||||
customizeHeaderDraft,
|
||||
draft,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
resetCustomizeSession,
|
||||
]);
|
||||
|
||||
@@ -271,17 +277,16 @@ export function CoreValuesSelectScreen() {
|
||||
);
|
||||
}, [activeModalChipId, customizeHeaderDraft, coreValueOptions]);
|
||||
|
||||
const handleDuplicateCoreChip = useCallback(() => {
|
||||
const handleDuplicateCoreChip = useCallback(async () => {
|
||||
if (!activeModalChipId || !modalSession) return;
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
!(await confirmDiscard(
|
||||
modalEditUnlocked,
|
||||
coreCustomizeSnapshotRef.current,
|
||||
draft,
|
||||
null,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -317,11 +322,11 @@ export function CoreValuesSelectScreen() {
|
||||
);
|
||||
}, [
|
||||
activeModalChipId,
|
||||
confirmDiscard,
|
||||
customizeHeaderDraft,
|
||||
draft,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
modalKebabMenu.duplicateTitleSuffix,
|
||||
modalSession,
|
||||
openModal,
|
||||
@@ -329,16 +334,15 @@ export function CoreValuesSelectScreen() {
|
||||
resetCustomizeSession,
|
||||
]);
|
||||
|
||||
const handleRemoveFromKebab = useCallback(() => {
|
||||
const handleRemoveFromKebab = useCallback(async () => {
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
!(await confirmDiscard(
|
||||
modalEditUnlocked,
|
||||
coreCustomizeSnapshotRef.current,
|
||||
draft,
|
||||
null,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -382,30 +386,27 @@ export function CoreValuesSelectScreen() {
|
||||
finalizeModalDismiss();
|
||||
}, [
|
||||
activeModalChipId,
|
||||
confirmDiscard,
|
||||
coreValueOptions,
|
||||
customizeHeaderDraft,
|
||||
draft,
|
||||
finalizeModalDismiss,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
modalSession,
|
||||
persistCoreValues,
|
||||
replaceState,
|
||||
modalSession,
|
||||
persistCoreValues,
|
||||
]);
|
||||
|
||||
const handleModalDismiss = useCallback(() => {
|
||||
const handleModalDismiss = useCallback(async () => {
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
!(await confirmDiscard(
|
||||
modalEditUnlocked,
|
||||
coreCustomizeSnapshotRef.current,
|
||||
draft,
|
||||
null,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -435,12 +436,12 @@ export function CoreValuesSelectScreen() {
|
||||
finalizeModalDismiss();
|
||||
}, [
|
||||
activeModalChipId,
|
||||
confirmDiscard,
|
||||
coreValueOptions,
|
||||
customizeHeaderDraft,
|
||||
draft,
|
||||
finalizeModalDismiss,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
modalSession,
|
||||
persistCoreValues,
|
||||
replaceState,
|
||||
@@ -645,6 +646,7 @@ export function CoreValuesSelectScreen() {
|
||||
const detailModal = cv.detailModal;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateFlowTwoColumnSelectShell
|
||||
lgVerticalAlign="start"
|
||||
header={
|
||||
@@ -724,5 +726,7 @@ export function CoreValuesSelectScreen() {
|
||||
</Create>
|
||||
)}
|
||||
</CreateFlowTwoColumnSelectShell>
|
||||
{confirmDialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import { RuleView } from "./Rule.view";
|
||||
import type { RuleProps } from "./Rule.types";
|
||||
|
||||
@@ -49,6 +50,9 @@ const RuleContainer = memo<RuleProps>(
|
||||
fluidWidth = false,
|
||||
}) => {
|
||||
const size = sizeProp ?? "L";
|
||||
const t = useTranslation("ruleCard");
|
||||
const cardAriaLabel = t("ariaLabel")?.replace("{title}", title) || title;
|
||||
const recommendedLabel = t("recommendedLabel");
|
||||
|
||||
const handleClick = () => {
|
||||
if (hasBottomLinks) return;
|
||||
@@ -106,6 +110,8 @@ const RuleContainer = memo<RuleProps>(
|
||||
recommended={recommended}
|
||||
templateGridFigmaShell={templateGridFigmaShell}
|
||||
fluidWidth={fluidWidth}
|
||||
cardAriaLabel={cardAriaLabel}
|
||||
recommendedLabel={recommendedLabel}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -107,4 +107,7 @@ export interface RuleViewProps {
|
||||
recommended?: boolean;
|
||||
templateGridFigmaShell?: boolean;
|
||||
fluidWidth?: boolean;
|
||||
/** Interactive card aria-label; supplied by the container from `ruleCard` messages. */
|
||||
cardAriaLabel: string;
|
||||
recommendedLabel: string;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import MultiSelect from "../../controls/MultiSelect";
|
||||
import InlineTextButton from "../../buttons/InlineTextButton";
|
||||
import NavigationLink from "../../navigation/Link";
|
||||
@@ -34,9 +33,10 @@ export function RuleView({
|
||||
recommended = false,
|
||||
templateGridFigmaShell = false,
|
||||
fluidWidth = false,
|
||||
cardAriaLabel,
|
||||
recommendedLabel,
|
||||
}: RuleViewProps) {
|
||||
const t = useTranslation("ruleCard");
|
||||
const ariaLabel = t("ariaLabel")?.replace("{title}", title) || title;
|
||||
const ariaLabel = cardAriaLabel;
|
||||
const interactiveCard = !hasBottomLinks;
|
||||
|
||||
// Size-based styling
|
||||
@@ -306,7 +306,7 @@ export function RuleView({
|
||||
>
|
||||
{showRecommendedTag ? (
|
||||
<Tag variant="templateRecommended">
|
||||
{t("recommendedLabel")}
|
||||
{recommendedLabel}
|
||||
</Tag>
|
||||
) : null}
|
||||
{onTitleClick ? (
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Figma: "localization/LanguageSwitcher" (see registry)
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import LanguageSwitcherView from "./LanguageSwitcher.view";
|
||||
import type { LanguageSwitcherProps } from "./LanguageSwitcher.types";
|
||||
|
||||
const LanguageSwitcherContainer = memo<LanguageSwitcherProps>(
|
||||
({ className }) => {
|
||||
// Future: Add language switching logic here
|
||||
// For now, this is just a UI component
|
||||
|
||||
return <LanguageSwitcherView className={className} />;
|
||||
},
|
||||
);
|
||||
|
||||
LanguageSwitcherContainer.displayName = "LanguageSwitcher";
|
||||
|
||||
export default LanguageSwitcherContainer;
|
||||
@@ -1,9 +0,0 @@
|
||||
export interface LanguageSwitcherProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface Language {
|
||||
code: string;
|
||||
name: string;
|
||||
nativeName: string;
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import type { LanguageSwitcherProps, Language } from "./LanguageSwitcher.types";
|
||||
|
||||
function LanguageSwitcherView({ className = "" }: LanguageSwitcherProps) {
|
||||
const t = useTranslation("languageSwitcher");
|
||||
|
||||
const AVAILABLE_LANGUAGES: Language[] = [
|
||||
{
|
||||
code: "en",
|
||||
name: t("languages.english.name"),
|
||||
nativeName: t("languages.english.nativeName"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<label htmlFor="language-select" className="sr-only">
|
||||
{t("label")}
|
||||
</label>
|
||||
<select
|
||||
id="language-select"
|
||||
className="bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] font-inter text-sm leading-5 font-normal border border-[var(--color-surface-default-secondary)] rounded-[var(--radius-measures-radius-small)] px-[var(--spacing-scale-012)] py-[var(--spacing-scale-008)] focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 cursor-pointer"
|
||||
aria-label={t("ariaLabel")}
|
||||
disabled
|
||||
>
|
||||
{AVAILABLE_LANGUAGES.map((language) => (
|
||||
<option key={language.code} value={language.code}>
|
||||
{language.nativeName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[var(--color-content-default-secondary)] font-inter text-xs leading-4 font-normal mt-[var(--spacing-scale-008)]">
|
||||
{t("comingSoonMessage")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(LanguageSwitcherView);
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default } from "./LanguageSwitcher.container";
|
||||
export type { LanguageSwitcherProps, Language } from "./LanguageSwitcher.types";
|
||||
@@ -5,7 +5,7 @@
|
||||
* File: agv0VBLiBlcnSAaiAORgPR, node 22078-587823
|
||||
*/
|
||||
|
||||
import { memo, useCallback, useEffect, useState, type FormEvent } from "react";
|
||||
import { memo, useCallback, useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
import { AskOrganizerInquiryModalView } from "./AskOrganizerInquiryModal.view";
|
||||
import type { AskOrganizerInquiryModalProps } from "./AskOrganizerInquiryModal.types";
|
||||
import { ORGANIZER_INQUIRY_HONEYPOT_FIELD } from "../../../../lib/organizerInquiryConstants";
|
||||
@@ -14,6 +14,23 @@ import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
const AskOrganizerInquiryModalContainer = memo<AskOrganizerInquiryModalProps>(
|
||||
({ isOpen, onClose }) => {
|
||||
const t = useTranslation("modals.askOrganizerInquiry");
|
||||
const copy = useMemo(
|
||||
() => ({
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
emailLabel: t("emailLabel"),
|
||||
emailPlaceholder: t("emailPlaceholder"),
|
||||
questionLabel: t("questionLabel"),
|
||||
questionPlaceholder: t("questionPlaceholder"),
|
||||
submitButton: t("submitButton"),
|
||||
closeAfterSuccess: t("closeAfterSuccess"),
|
||||
successTitle: t("successTitle"),
|
||||
successDescription: t("successDescription"),
|
||||
ariaDialog: t("ariaDialog"),
|
||||
honeypotLabel: t("honeypotLabel"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
const [email, setEmail] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [honeypot, setHoneypot] = useState("");
|
||||
@@ -102,6 +119,7 @@ const AskOrganizerInquiryModalContainer = memo<AskOrganizerInquiryModalProps>(
|
||||
<AskOrganizerInquiryModalView
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
copy={copy}
|
||||
email={email}
|
||||
message={message}
|
||||
honeypot={honeypot}
|
||||
|
||||
@@ -2,3 +2,35 @@ export interface AskOrganizerInquiryModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export interface AskOrganizerInquiryModalCopy {
|
||||
title: string;
|
||||
description: string;
|
||||
emailLabel: string;
|
||||
emailPlaceholder: string;
|
||||
questionLabel: string;
|
||||
questionPlaceholder: string;
|
||||
submitButton: string;
|
||||
closeAfterSuccess: string;
|
||||
successTitle: string;
|
||||
successDescription: string;
|
||||
ariaDialog: string;
|
||||
honeypotLabel: string;
|
||||
}
|
||||
|
||||
export interface AskOrganizerInquiryModalViewProps
|
||||
extends AskOrganizerInquiryModalProps {
|
||||
copy: AskOrganizerInquiryModalCopy;
|
||||
email: string;
|
||||
message: string;
|
||||
honeypot: string;
|
||||
submitting: boolean;
|
||||
success: boolean;
|
||||
formError: string | null;
|
||||
emailError: boolean;
|
||||
questionError: boolean;
|
||||
onEmailChange: (_v: string) => void;
|
||||
onMessageChange: (_v: string) => void;
|
||||
onHoneypotChange: (_v: string) => void;
|
||||
onSubmit: (_e: import("react").FormEvent<HTMLFormElement>) => void;
|
||||
}
|
||||
|
||||
@@ -1,31 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import type { FormEvent } from "react";
|
||||
import Create from "../Create";
|
||||
import TextInput from "../../controls/TextInput";
|
||||
import TextArea from "../../controls/TextArea";
|
||||
import Button from "../../buttons/Button";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import {
|
||||
ASK_ORGANIZER_INQUIRY_FORM_ID,
|
||||
ORGANIZER_INQUIRY_HONEYPOT_FIELD,
|
||||
} from "../../../../lib/organizerInquiryConstants";
|
||||
import type { AskOrganizerInquiryModalProps } from "./AskOrganizerInquiryModal.types";
|
||||
|
||||
export type AskOrganizerInquiryModalViewProps = AskOrganizerInquiryModalProps & {
|
||||
email: string;
|
||||
message: string;
|
||||
honeypot: string;
|
||||
submitting: boolean;
|
||||
success: boolean;
|
||||
formError: string | null;
|
||||
emailError: boolean;
|
||||
questionError: boolean;
|
||||
onEmailChange: (_v: string) => void;
|
||||
onMessageChange: (_v: string) => void;
|
||||
onHoneypotChange: (_v: string) => void;
|
||||
onSubmit: (_e: FormEvent<HTMLFormElement>) => void;
|
||||
};
|
||||
import type { AskOrganizerInquiryModalViewProps } from "./AskOrganizerInquiryModal.types";
|
||||
|
||||
/**
|
||||
* Figma: Community Rule System — Modal / Ask an Organizer (22078-587823)
|
||||
@@ -33,6 +16,7 @@ export type AskOrganizerInquiryModalViewProps = AskOrganizerInquiryModalProps &
|
||||
export function AskOrganizerInquiryModalView({
|
||||
isOpen,
|
||||
onClose,
|
||||
copy,
|
||||
email,
|
||||
message,
|
||||
honeypot,
|
||||
@@ -46,8 +30,6 @@ export function AskOrganizerInquiryModalView({
|
||||
onHoneypotChange,
|
||||
onSubmit,
|
||||
}: AskOrganizerInquiryModalViewProps) {
|
||||
const t = useTranslation("modals.askOrganizerInquiry");
|
||||
|
||||
const footer = success ? (
|
||||
<div className="w-full px-1">
|
||||
<Button
|
||||
@@ -58,7 +40,7 @@ export function AskOrganizerInquiryModalView({
|
||||
className="w-full !justify-center"
|
||||
onClick={onClose}
|
||||
>
|
||||
{t("closeAfterSuccess")}
|
||||
{copy.closeAfterSuccess}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -72,7 +54,7 @@ export function AskOrganizerInquiryModalView({
|
||||
className="w-full !justify-center"
|
||||
disabled={submitting}
|
||||
>
|
||||
{t("submitButton")}
|
||||
{copy.submitButton}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -82,22 +64,22 @@ export function AskOrganizerInquiryModalView({
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
backdropVariant="blurredYellow"
|
||||
title={t("title")}
|
||||
description={t("description")}
|
||||
title={copy.title}
|
||||
description={copy.description}
|
||||
showBackButton={false}
|
||||
showNextButton={false}
|
||||
stepper={false}
|
||||
ariaLabel={t("ariaDialog")}
|
||||
ariaLabel={copy.ariaDialog}
|
||||
footerContent={footer}
|
||||
footerClassName="!h-auto min-h-[112px] shrink-0 flex flex-col justify-end pb-8 pt-3 px-4"
|
||||
>
|
||||
{success ? (
|
||||
<div className="flex flex-col gap-3 py-2">
|
||||
<p className="font-inter text-[18px] font-semibold leading-[24px] text-[var(--color-content-default-primary)]">
|
||||
{t("successTitle")}
|
||||
{copy.successTitle}
|
||||
</p>
|
||||
<p className="font-inter text-[14px] leading-[20px] text-[var(--color-content-default-secondary)]">
|
||||
{t("successDescription")}
|
||||
{copy.successDescription}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -120,8 +102,8 @@ export function AskOrganizerInquiryModalView({
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
label={t("emailLabel")}
|
||||
placeholder={t("emailPlaceholder")}
|
||||
label={copy.emailLabel}
|
||||
placeholder={copy.emailPlaceholder}
|
||||
value={email}
|
||||
onChange={(e) => onEmailChange(e.target.value)}
|
||||
error={emailError}
|
||||
@@ -131,8 +113,8 @@ export function AskOrganizerInquiryModalView({
|
||||
|
||||
<TextArea
|
||||
name="message"
|
||||
label={t("questionLabel")}
|
||||
placeholder={t("questionPlaceholder")}
|
||||
label={copy.questionLabel}
|
||||
placeholder={copy.questionPlaceholder}
|
||||
value={message}
|
||||
onChange={(e) => onMessageChange(e.target.value)}
|
||||
error={questionError}
|
||||
@@ -146,7 +128,7 @@ export function AskOrganizerInquiryModalView({
|
||||
className="pointer-events-none absolute left-0 top-0 h-px w-px overflow-hidden opacity-0"
|
||||
>
|
||||
<label htmlFor={`${ASK_ORGANIZER_INQUIRY_FORM_ID}-${ORGANIZER_INQUIRY_HONEYPOT_FIELD}`}>
|
||||
{t("honeypotLabel")}
|
||||
{copy.honeypotLabel}
|
||||
</label>
|
||||
<input
|
||||
id={`${ASK_ORGANIZER_INQUIRY_FORM_ID}-${ORGANIZER_INQUIRY_HONEYPOT_FIELD}`}
|
||||
|
||||
@@ -262,14 +262,14 @@ export default function LoginForm({
|
||||
<p className="text-center font-inter text-[14px] leading-[20px] text-[var(--color-content-default-tertiary)]">
|
||||
{t("legalPrefix")}
|
||||
<Link
|
||||
href="#"
|
||||
href={tFooter("legal.termsOfServiceHref")}
|
||||
className="text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2"
|
||||
>
|
||||
{tFooter("legal.termsOfService")}
|
||||
</Link>
|
||||
{t("legalAnd")}
|
||||
<Link
|
||||
href="#"
|
||||
href={tFooter("legal.privacyPolicyHref")}
|
||||
className="text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2"
|
||||
>
|
||||
{tFooter("legal.privacyPolicy")}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import { ModalFooterView } from "./ModalFooter.view";
|
||||
import type { ModalFooterProps } from "./ModalFooter.types";
|
||||
|
||||
@@ -10,7 +11,17 @@ import type { ModalFooterProps } from "./ModalFooter.types";
|
||||
* primary/secondary actions.
|
||||
*/
|
||||
const ModalFooterContainer = memo<ModalFooterProps>((props) => {
|
||||
return <ModalFooterView {...props} />;
|
||||
const t = useTranslation("common");
|
||||
const resolvedBackText = props.backButtonText ?? t("buttons.back");
|
||||
const resolvedNextText = props.nextButtonText ?? t("buttons.next");
|
||||
|
||||
return (
|
||||
<ModalFooterView
|
||||
{...props}
|
||||
backButtonText={resolvedBackText}
|
||||
nextButtonText={resolvedNextText}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
ModalFooterContainer.displayName = "ModalFooter";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import Button from "../../buttons/Button";
|
||||
import Stepper from "../../progress/Stepper";
|
||||
import type { ModalFooterProps } from "./ModalFooter.types";
|
||||
@@ -19,14 +18,6 @@ export function ModalFooterView({
|
||||
footerContent,
|
||||
className = "",
|
||||
}: ModalFooterProps) {
|
||||
const t = useTranslation("common");
|
||||
|
||||
// Use localized defaults if text not provided
|
||||
const defaultBackText = backButtonText || t("buttons.back");
|
||||
const defaultNextText = nextButtonText || t("buttons.next");
|
||||
|
||||
// Determine if stepper should be shown
|
||||
// Defaults to true if currentStep and totalSteps are provided, unless explicitly set to false
|
||||
const shouldShowStepper =
|
||||
stepperProp !== undefined
|
||||
? stepperProp
|
||||
@@ -36,7 +27,6 @@ export function ModalFooterView({
|
||||
<div
|
||||
className={`h-[64px] bg-[var(--color-surface-default-primary)] rounded-bl-[var(--radius-300,12px)] rounded-br-[var(--radius-300,12px)] shrink-0 relative ${className}`}
|
||||
>
|
||||
{/* Back Button - Absolutely positioned bottom left */}
|
||||
{showBackButton && (
|
||||
<div className="absolute left-[16px] top-[12px]">
|
||||
<Button
|
||||
@@ -45,19 +35,17 @@ export function ModalFooterView({
|
||||
size="medium"
|
||||
onClick={onBack}
|
||||
>
|
||||
{defaultBackText}
|
||||
{backButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stepper (Centered) */}
|
||||
{shouldShowStepper && currentStep && totalSteps && (
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<Stepper active={currentStep} totalSteps={totalSteps} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Next Button - Absolutely positioned bottom right */}
|
||||
{showNextButton && (
|
||||
<div className="absolute right-[16px] top-[12px]">
|
||||
<Button
|
||||
@@ -67,12 +55,11 @@ export function ModalFooterView({
|
||||
onClick={onNext}
|
||||
disabled={nextButtonDisabled}
|
||||
>
|
||||
{defaultNextText}
|
||||
{nextButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Footer Content */}
|
||||
{footerContent}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -237,6 +237,16 @@ const CreateFlowTopNavContainer = memo<CreateFlowTopNavProps>(
|
||||
exportPopoverMarkdownLabel={tPopover("downloadMarkdown")}
|
||||
moreOptionsAriaLabel={t("moreOptionsAriaLabel")}
|
||||
actionsMenuAriaLabel={t("actionsMenuAriaLabel")}
|
||||
shareLabel={t("share")}
|
||||
exportLabel={t("export")}
|
||||
editLabel={t("edit")}
|
||||
manageStakeholdersLabel={t("manageStakeholders")}
|
||||
shareAriaLabel={t("shareAriaLabel")}
|
||||
exportAriaLabel={t("exportAriaLabel")}
|
||||
editAriaLabel={t("editAriaLabel")}
|
||||
manageStakeholdersAriaLabel={t("manageStakeholdersAriaLabel")}
|
||||
bannerAriaLabel={t("bannerAriaLabel")}
|
||||
navAriaLabel={t("navAriaLabel")}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -110,4 +110,14 @@ export type CreateFlowTopNavViewProps = CreateFlowTopNavProps & {
|
||||
exportPopoverMarkdownLabel: string;
|
||||
moreOptionsAriaLabel: string;
|
||||
actionsMenuAriaLabel: string;
|
||||
shareLabel: string;
|
||||
exportLabel: string;
|
||||
editLabel: string;
|
||||
manageStakeholdersLabel: string;
|
||||
shareAriaLabel: string;
|
||||
exportAriaLabel: string;
|
||||
editAriaLabel: string;
|
||||
manageStakeholdersAriaLabel: string;
|
||||
bannerAriaLabel: string;
|
||||
navAriaLabel: string;
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@ 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 { CreateFlowTopNavViewProps } from "./CreateFlowTopNav.types";
|
||||
|
||||
const outlineButtonClass =
|
||||
@@ -65,9 +64,17 @@ export function CreateFlowTopNavView({
|
||||
exportPopoverMarkdownLabel,
|
||||
moreOptionsAriaLabel,
|
||||
actionsMenuAriaLabel,
|
||||
shareLabel,
|
||||
exportLabel,
|
||||
editLabel,
|
||||
manageStakeholdersLabel,
|
||||
shareAriaLabel,
|
||||
exportAriaLabel,
|
||||
editAriaLabel,
|
||||
manageStakeholdersAriaLabel,
|
||||
bannerAriaLabel,
|
||||
navAriaLabel,
|
||||
}: CreateFlowTopNavViewProps) {
|
||||
const t = useTranslation("create.topNav");
|
||||
|
||||
const hasSecondaryActions =
|
||||
hasShare ||
|
||||
hasExport ||
|
||||
@@ -83,10 +90,10 @@ export function CreateFlowTopNavView({
|
||||
palette={buttonPalette}
|
||||
size="xsmall"
|
||||
onClick={onShare}
|
||||
ariaLabel={t("shareAriaLabel")}
|
||||
ariaLabel={shareAriaLabel}
|
||||
className={outlineButtonClass}
|
||||
>
|
||||
{t("share")}
|
||||
{shareLabel}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -97,14 +104,14 @@ export function CreateFlowTopNavView({
|
||||
palette={buttonPalette}
|
||||
size="xsmall"
|
||||
type="button"
|
||||
ariaLabel={t("exportAriaLabel")}
|
||||
ariaLabel={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)] ${outlineButtonClass}`}
|
||||
>
|
||||
<span>{t("export")}</span>
|
||||
<span>{exportLabel}</span>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
@@ -166,11 +173,11 @@ export function CreateFlowTopNavView({
|
||||
size="xsmall"
|
||||
onClick={onDuplicate}
|
||||
ariaLabel={
|
||||
duplicateAriaLabel ?? duplicateLabel ?? t("editAriaLabel")
|
||||
duplicateAriaLabel ?? duplicateLabel ?? editAriaLabel
|
||||
}
|
||||
className={outlineButtonClass}
|
||||
>
|
||||
{duplicateLabel ?? t("edit")}
|
||||
{duplicateLabel ?? editLabel}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -180,10 +187,10 @@ export function CreateFlowTopNavView({
|
||||
palette={buttonPalette}
|
||||
size="xsmall"
|
||||
onClick={onEdit}
|
||||
ariaLabel={t("editAriaLabel")}
|
||||
ariaLabel={editAriaLabel}
|
||||
className={outlineButtonClass}
|
||||
>
|
||||
{t("edit")}
|
||||
{editLabel}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -194,10 +201,10 @@ export function CreateFlowTopNavView({
|
||||
size="xsmall"
|
||||
type="button"
|
||||
onClick={onManageStakeholders}
|
||||
ariaLabel={t("manageStakeholdersAriaLabel")}
|
||||
ariaLabel={manageStakeholdersAriaLabel}
|
||||
className={outlineButtonClass}
|
||||
>
|
||||
{t("manageStakeholders")}
|
||||
{manageStakeholdersLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@@ -219,12 +226,12 @@ export function CreateFlowTopNavView({
|
||||
<header
|
||||
className={`bg-black w-full ${className}`}
|
||||
role="banner"
|
||||
aria-label={t("bannerAriaLabel")}
|
||||
aria-label={bannerAriaLabel}
|
||||
>
|
||||
<nav
|
||||
className="flex items-center justify-between mx-auto max-w-[639px] md:max-w-[1920px] px-[var(--spacing-measures-spacing-500,20px)] md:px-[48px] py-[var(--spacing-measures-spacing-300,12px)] md:py-[var(--spacing-measures-spacing-016,16px)]"
|
||||
role="navigation"
|
||||
aria-label={t("navAriaLabel")}
|
||||
aria-label={navAriaLabel}
|
||||
>
|
||||
<Logo size="createFlow" wordmark palette={buttonPalette} />
|
||||
|
||||
|
||||
@@ -192,13 +192,13 @@ const Footer = memo(() => {
|
||||
lg:gap-10
|
||||
lg:text-sm lg:leading-5"
|
||||
>
|
||||
<Link href="#" className={legalLinkClass}>
|
||||
<Link href={t("legal.privacyPolicyHref")} className={legalLinkClass}>
|
||||
{t("legal.privacyPolicy")}
|
||||
</Link>
|
||||
<Link href="#" className={legalLinkClass}>
|
||||
<Link href={t("legal.termsOfServiceHref")} className={legalLinkClass}>
|
||||
{t("legal.termsOfService")}
|
||||
</Link>
|
||||
<Link href="#" className={legalLinkClass}>
|
||||
<Link href={t("legal.cookiesSettingsHref")} className={legalLinkClass}>
|
||||
{t("legal.cookiesSettings")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Figma: "Sections / AskOrganizer" (see registry).
|
||||
*/
|
||||
import { memo, useCallback, useState } from "react";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import { useAnalytics } from "../../../hooks";
|
||||
@@ -43,7 +46,6 @@ const AskOrganizerContainer = memo<AskOrganizerProps>(
|
||||
subtitle,
|
||||
description,
|
||||
buttonText,
|
||||
buttonHref,
|
||||
className = "",
|
||||
variant: variantProp = "centered",
|
||||
onContactClick,
|
||||
@@ -51,7 +53,7 @@ const AskOrganizerContainer = memo<AskOrganizerProps>(
|
||||
const variant = variantProp;
|
||||
const t = useTranslation();
|
||||
const defaultButtonText = buttonText ?? t("askOrganizer.buttonText");
|
||||
const analyticsHref = buttonHref ?? "modal";
|
||||
const ctaAriaLabel = t("askOrganizer.ariaLabel");
|
||||
const { trackEvent, trackCustomEvent } = useAnalytics();
|
||||
const [inquiryOpen, setInquiryOpen] = useState(false);
|
||||
|
||||
@@ -74,33 +76,8 @@ const AskOrganizerContainer = memo<AskOrganizerProps>(
|
||||
|
||||
const labelledBy = title ? "ask-organizer-headline" : undefined;
|
||||
|
||||
const handleContactClick = (
|
||||
event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>,
|
||||
) => {
|
||||
if (buttonHref) {
|
||||
// Legacy link CTA: do not intercept navigation.
|
||||
trackEvent({
|
||||
event: "contact_button_click",
|
||||
category: "engagement",
|
||||
label: "ask_organizer",
|
||||
component: "AskOrganizer",
|
||||
variant: resolvedVariant,
|
||||
});
|
||||
trackCustomEvent(
|
||||
"contact_button_click",
|
||||
{
|
||||
component: "AskOrganizer",
|
||||
variant: resolvedVariant,
|
||||
buttonText: defaultButtonText,
|
||||
buttonHref: analyticsHref,
|
||||
},
|
||||
onContactClick as
|
||||
| ((_data: Record<string, unknown>) => void)
|
||||
| undefined,
|
||||
);
|
||||
return event;
|
||||
}
|
||||
|
||||
const handleContactClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
trackEvent({
|
||||
event: "contact_button_click",
|
||||
@@ -116,7 +93,7 @@ const AskOrganizerContainer = memo<AskOrganizerProps>(
|
||||
component: "AskOrganizer",
|
||||
variant: resolvedVariant,
|
||||
buttonText: defaultButtonText,
|
||||
buttonHref: analyticsHref,
|
||||
buttonHref: "modal",
|
||||
},
|
||||
onContactClick as
|
||||
| ((_data: Record<string, unknown>) => void)
|
||||
@@ -124,8 +101,15 @@ const AskOrganizerContainer = memo<AskOrganizerProps>(
|
||||
);
|
||||
|
||||
setInquiryOpen(true);
|
||||
return event;
|
||||
};
|
||||
},
|
||||
[
|
||||
defaultButtonText,
|
||||
onContactClick,
|
||||
resolvedVariant,
|
||||
trackCustomEvent,
|
||||
trackEvent,
|
||||
],
|
||||
);
|
||||
|
||||
const closeInquiry = useCallback(() => {
|
||||
setInquiryOpen(false);
|
||||
@@ -138,7 +122,7 @@ const AskOrganizerContainer = memo<AskOrganizerProps>(
|
||||
subtitle={subtitle}
|
||||
description={description}
|
||||
buttonText={defaultButtonText}
|
||||
buttonHref={buttonHref}
|
||||
ctaAriaLabel={ctaAriaLabel}
|
||||
className={className}
|
||||
sectionPadding={sectionPadding}
|
||||
contentGap={`${contentGap} ${styles.container}`}
|
||||
|
||||
@@ -12,10 +12,6 @@ export interface AskOrganizerProps {
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
buttonText?: string;
|
||||
/**
|
||||
* @deprecated Modal-only flow (CR-107). Omit; kept optional for Storybook overrides.
|
||||
*/
|
||||
buttonHref?: string;
|
||||
className?: string;
|
||||
/**
|
||||
* Ask organizer variant.
|
||||
@@ -26,7 +22,6 @@ export interface AskOrganizerProps {
|
||||
component: string;
|
||||
variant: string;
|
||||
buttonText: string;
|
||||
buttonHref?: string;
|
||||
timestamp: string;
|
||||
}) => void;
|
||||
}
|
||||
@@ -36,7 +31,7 @@ export interface AskOrganizerViewProps {
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
buttonText: string;
|
||||
buttonHref?: string;
|
||||
ctaAriaLabel: string;
|
||||
className: string;
|
||||
sectionPadding: string;
|
||||
contentGap: string;
|
||||
@@ -44,6 +39,6 @@ export interface AskOrganizerViewProps {
|
||||
variant: AskOrganizerVariant;
|
||||
labelledBy?: string;
|
||||
onContactClick: (
|
||||
_event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>,
|
||||
_event: React.MouseEvent<HTMLButtonElement>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import ContentLockup from "../../type/ContentLockup";
|
||||
import Button from "../../buttons/Button";
|
||||
import type { AskOrganizerViewProps } from "./AskOrganizer.types";
|
||||
@@ -10,7 +9,7 @@ function AskOrganizerView({
|
||||
subtitle,
|
||||
description,
|
||||
buttonText,
|
||||
buttonHref,
|
||||
ctaAriaLabel,
|
||||
className,
|
||||
sectionPadding,
|
||||
contentGap,
|
||||
@@ -19,8 +18,6 @@ function AskOrganizerView({
|
||||
labelledBy,
|
||||
onContactClick,
|
||||
}: AskOrganizerViewProps) {
|
||||
const t = useTranslation();
|
||||
const ariaLabel = t("askOrganizer.ariaLabel");
|
||||
const isUseCaseDetail = variant === "use-case-detail";
|
||||
const lockupVariant =
|
||||
variant === "inverse" || isUseCaseDetail ? "ask-inverse" : "ask";
|
||||
@@ -33,14 +30,13 @@ function AskOrganizerView({
|
||||
<section
|
||||
className={`${sectionPadding} ${className}`}
|
||||
aria-labelledby={labelledBy}
|
||||
aria-label={labelledBy ? undefined : ariaLabel}
|
||||
aria-label={labelledBy ? undefined : ctaAriaLabel}
|
||||
tabIndex={-1}
|
||||
data-figma-node={isUseCaseDetail ? "22015-42624" : "18116-15960"}
|
||||
>
|
||||
<div
|
||||
className={`mx-auto flex w-full min-w-0 max-w-[1280px] flex-col md:min-w-[358px] ${contentGap} ${isUseCaseDetail ? "items-center" : ""}`}
|
||||
>
|
||||
{/* Content Lockup */}
|
||||
<ContentLockup
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
@@ -50,18 +46,16 @@ function AskOrganizerView({
|
||||
titleId={labelledBy}
|
||||
/>
|
||||
|
||||
{/* Button */}
|
||||
<div
|
||||
className={`${buttonContainerClass} flex-wrap gap-y-[var(--spacing-scale-016)]`}
|
||||
>
|
||||
<Button
|
||||
{...(buttonHref ? { href: buttonHref } : {})}
|
||||
size="small"
|
||||
buttonType="filled"
|
||||
palette={buttonPalette}
|
||||
className="!px-[var(--spacing-scale-010)] md:!px-[var(--spacing-scale-016)] md:!py-[var(--spacing-scale-012)] md:!text-[16px] md:!leading-[20px]"
|
||||
onClick={onContactClick}
|
||||
ariaLabel={ariaLabel}
|
||||
ariaLabel={ctaAriaLabel}
|
||||
data-testid="ask-organizer-cta"
|
||||
>
|
||||
{buttonText}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import {
|
||||
ASSETS,
|
||||
getAssetPath,
|
||||
@@ -23,6 +24,14 @@ const ContentBannerContainer = memo<ContentBannerProps>(
|
||||
contentTone,
|
||||
}) => {
|
||||
const variant = variantProp;
|
||||
const tUseCase = useTranslation("pages.useCasesCompletedRule");
|
||||
const ruleCardLinkAriaLabel =
|
||||
variant === "useCase" && rulePreview?.href
|
||||
? tUseCase("ruleCardLinkAriaLabel").replace(
|
||||
"{title}",
|
||||
rulePreview.title,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const resolveHorizontalImage = (blogPost: BlogPost): string => {
|
||||
if (blogPost.frontmatter?.thumbnail?.horizontal) {
|
||||
@@ -71,6 +80,7 @@ const ContentBannerContainer = memo<ContentBannerProps>(
|
||||
backgroundImageSection={backgroundImageSection}
|
||||
rulePreview={rulePreview}
|
||||
contentTone={contentTone}
|
||||
ruleCardLinkAriaLabel={ruleCardLinkAriaLabel}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -41,4 +41,6 @@ export interface ContentBannerViewProps {
|
||||
backgroundImageSection?: string;
|
||||
rulePreview?: ContentBannerRulePreview;
|
||||
contentTone?: ContentContainerToneValue;
|
||||
/** `useCase` only: aria-label for linked rule preview. */
|
||||
ruleCardLinkAriaLabel?: string;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { memo } from "react";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import ContentContainer from "../../content/ContentContainer";
|
||||
import Rule from "../../cards/Rule";
|
||||
import {
|
||||
@@ -155,6 +154,7 @@ function ContentBannerUseCaseView({
|
||||
contentTone = "inverse",
|
||||
leadingImageSrc,
|
||||
leadingImageAlt,
|
||||
ruleCardLinkAriaLabel,
|
||||
}: Pick<
|
||||
ContentBannerViewProps,
|
||||
| "post"
|
||||
@@ -162,8 +162,8 @@ function ContentBannerUseCaseView({
|
||||
| "contentTone"
|
||||
| "leadingImageSrc"
|
||||
| "leadingImageAlt"
|
||||
| "ruleCardLinkAriaLabel"
|
||||
>) {
|
||||
const t = useTranslation("pages.useCasesCompletedRule");
|
||||
if (!rulePreview) {
|
||||
return null;
|
||||
}
|
||||
@@ -198,10 +198,7 @@ function ContentBannerUseCaseView({
|
||||
<Link
|
||||
href={rulePreview.href}
|
||||
className="block w-full rounded-[24px] outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-default-brand-primary)] focus-visible:ring-offset-2"
|
||||
aria-label={t("ruleCardLinkAriaLabel").replace(
|
||||
"{title}",
|
||||
rulePreview.title,
|
||||
)}
|
||||
aria-label={ruleCardLinkAriaLabel ?? rulePreview.title}
|
||||
>
|
||||
<Rule
|
||||
title={rulePreview.title}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import { logger } from "../../../../lib/logger";
|
||||
import { prepareFreshCreateFlowEntry } from "../../../(app)/create/utils/prepareFreshCreateFlowEntry";
|
||||
import {
|
||||
@@ -34,6 +35,8 @@ declare global {
|
||||
const RuleStackContainer = memo<RuleStackProps>(
|
||||
({ className = "", initialGridEntries, translationNamespace, twoColumnsFromMd }) => {
|
||||
const router = useRouter();
|
||||
const namespace = translationNamespace ?? "pages.home.ruleStack";
|
||||
const t = useTranslation(namespace);
|
||||
const [gridEntries, setGridEntries] = useState<TemplateGridCardEntry[] | null>(
|
||||
() => initialGridEntries ?? null,
|
||||
);
|
||||
@@ -107,7 +110,9 @@ const RuleStackContainer = memo<RuleStackProps>(
|
||||
className={className}
|
||||
onTemplateClick={handleTemplateClick}
|
||||
gridEntries={gridEntries}
|
||||
translationNamespace={translationNamespace ?? "pages.home.ruleStack"}
|
||||
sectionTitle={t("title")}
|
||||
sectionSubtitle={t("subtitle")}
|
||||
seeAllTemplatesLabel={t("button.seeAllTemplates")}
|
||||
twoColumnsFromMd={twoColumnsFromMd}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface RuleStackViewProps {
|
||||
onTemplateClick: (_slug: string) => void;
|
||||
/** `null` while loading curated templates from the API. */
|
||||
gridEntries: TemplateGridCardEntry[] | null;
|
||||
translationNamespace: string;
|
||||
sectionTitle: string;
|
||||
sectionSubtitle: string;
|
||||
seeAllTemplatesLabel: string;
|
||||
twoColumnsFromMd?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import SectionHeader from "../../type/SectionHeader";
|
||||
import Button from "../../buttons/Button";
|
||||
import { GovernanceTemplateGrid } from "../GovernanceTemplateGrid";
|
||||
@@ -12,12 +11,11 @@ export function RuleStackView({
|
||||
className,
|
||||
onTemplateClick,
|
||||
gridEntries,
|
||||
translationNamespace,
|
||||
sectionTitle,
|
||||
sectionSubtitle,
|
||||
seeAllTemplatesLabel,
|
||||
twoColumnsFromMd = false,
|
||||
}: RuleStackViewProps) {
|
||||
const t = useTranslation(translationNamespace);
|
||||
const buttonText = t("button.seeAllTemplates");
|
||||
|
||||
return (
|
||||
<section
|
||||
data-figma-node="22085-860413"
|
||||
@@ -35,8 +33,8 @@ export function RuleStackView({
|
||||
`}
|
||||
>
|
||||
<SectionHeader
|
||||
title={t("title")}
|
||||
subtitle={t("subtitle")}
|
||||
title={sectionTitle}
|
||||
subtitle={sectionSubtitle}
|
||||
variant="multi-line"
|
||||
ruleStackDesktopTypeScale
|
||||
twoColumnsFromMd={twoColumnsFromMd}
|
||||
@@ -69,7 +67,7 @@ export function RuleStackView({
|
||||
size="large"
|
||||
href="/templates"
|
||||
>
|
||||
{buttonText}
|
||||
{seeAllTemplatesLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -11,12 +11,13 @@ export { useAnalytics } from "./useAnalytics";
|
||||
export { useComponentId } from "./useComponentId";
|
||||
export { useFormField } from "./useFormField";
|
||||
export { useSchemaData } from "./useSchemaData";
|
||||
export {
|
||||
useMediaQuery,
|
||||
export { useMediaQuery,
|
||||
useIsMobile,
|
||||
useIsDesktop,
|
||||
BREAKPOINTS,
|
||||
} from "./useMediaQuery";
|
||||
export { useAsyncConfirm } from "./useAsyncConfirm";
|
||||
export type { AsyncConfirmOptions } from "./useAsyncConfirm";
|
||||
export type {
|
||||
SchemaOrganization,
|
||||
SchemaWebSite,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import Button from "../components/buttons/Button";
|
||||
import Create from "../components/modals/Create";
|
||||
import type { CreateModalBackdropVariant } from "../components/modals/Create/CreateModalFrame.view";
|
||||
|
||||
export type AsyncConfirmOptions = {
|
||||
title: string;
|
||||
description: string;
|
||||
proceedText: string;
|
||||
cancelText: string;
|
||||
ariaLabel?: string;
|
||||
backdropVariant?: CreateModalBackdropVariant;
|
||||
};
|
||||
|
||||
/**
|
||||
* Promise-based confirm dialog backed by the Create modal shell.
|
||||
*
|
||||
* @returns `requestConfirm` resolves true when the user proceeds, false on cancel.
|
||||
* Render `confirmDialog` once near the root of the consuming component tree.
|
||||
*
|
||||
* @example
|
||||
* const { requestConfirm, confirmDialog } = useAsyncConfirm();
|
||||
* if (!(await requestConfirm({ title: "Leave?", description: "...", proceedText: "Leave", cancelText: "Stay" }))) return;
|
||||
* return <>{confirmDialog}</>;
|
||||
*/
|
||||
export function useAsyncConfirm() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [options, setOptions] = useState<AsyncConfirmOptions | null>(null);
|
||||
const resolverRef = useRef<((proceed: boolean) => void) | null>(null);
|
||||
|
||||
const requestConfirm = useCallback((opts: AsyncConfirmOptions) => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolverRef.current = resolve;
|
||||
setOptions(opts);
|
||||
setOpen(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const close = useCallback((proceed: boolean) => {
|
||||
setOpen(false);
|
||||
const resolve = resolverRef.current;
|
||||
resolverRef.current = null;
|
||||
setOptions(null);
|
||||
resolve?.(proceed);
|
||||
}, []);
|
||||
|
||||
const confirmDialog =
|
||||
open && options ? (
|
||||
<Create
|
||||
isOpen={open}
|
||||
onClose={() => close(false)}
|
||||
title={options.title}
|
||||
description={options.description}
|
||||
showBackButton={false}
|
||||
showNextButton
|
||||
nextButtonText={options.proceedText}
|
||||
onNext={() => close(true)}
|
||||
footerContent={
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
onClick={() => close(false)}
|
||||
>
|
||||
{options.cancelText}
|
||||
</Button>
|
||||
}
|
||||
backdropVariant={options.backdropVariant ?? "blurredYellow"}
|
||||
ariaLabel={options.ariaLabel ?? options.title}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return { requestConfirm, confirmDialog };
|
||||
}
|
||||
@@ -38,17 +38,18 @@ const spaceGrotesk = Space_Grotesk({
|
||||
fallback: ["system-ui", "arial"],
|
||||
});
|
||||
|
||||
/** Viewport and favicon use the Metadata / Viewport APIs; avoid a manual `<head>` with a second viewport `meta` (duplicates Next’s head injection). */
|
||||
const homeMeta = messages.metadata.home;
|
||||
|
||||
/** Viewport and favicon use the Metadata / Viewport APIs; avoid a manual `<head>` with a second viewport `meta` (duplicates Next's head injection). */
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "CommunityRule - Build operating manuals for successful communities",
|
||||
description:
|
||||
"Help your community make important decisions in a way that reflects its unique values.",
|
||||
keywords: ["community", "governance", "decision-making", "operating manual"],
|
||||
title: homeMeta.title,
|
||||
description: homeMeta.description,
|
||||
keywords: [...homeMeta.keywords],
|
||||
authors: [{ name: "Media Economies Design Lab" }],
|
||||
creator: "Media Economies Design Lab",
|
||||
publisher: "Media Economies Design Lab",
|
||||
@@ -65,9 +66,8 @@ export const metadata: Metadata = {
|
||||
canonical: "/",
|
||||
},
|
||||
openGraph: {
|
||||
title: "CommunityRule - Build operating manuals for successful communities",
|
||||
description:
|
||||
"Help your community make important decisions in a way that reflects its unique values.",
|
||||
title: homeMeta.title,
|
||||
description: homeMeta.description,
|
||||
url: "https://communityrule.com",
|
||||
siteName: "CommunityRule",
|
||||
locale: "en_US",
|
||||
@@ -75,9 +75,8 @@ export const metadata: Metadata = {
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "CommunityRule - Build operating manuals for successful communities",
|
||||
description:
|
||||
"Help your community make important decisions in a way that reflects its unique values.",
|
||||
title: homeMeta.title,
|
||||
description: homeMeta.description,
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
|
||||
@@ -213,7 +213,13 @@ All filed in Linear, titled `[Backend] …`, assigned to me, in the
|
||||
Count rows + decide whether to publish a static archive before
|
||||
CR-99 uninstalls the legacy MySQL. Priority: Low.
|
||||
|
||||
## 9. Related docs
|
||||
## 10. Rate limiting (single-instance deploys)
|
||||
|
||||
The app uses an **in-memory** rate limiter in [`lib/server/rateLimit.ts`](../../lib/server/rateLimit.ts) (magic-link requests, organizer inquiry, etc.). This is sufficient for the current **single Cloudron container** per environment.
|
||||
|
||||
**Before horizontal scale-out** (multiple app instances behind a load balancer), replace or back the limiter with a shared store (e.g. Redis) so per-IP / per-user windows apply across instances. Until then, document expected limits in the steady-state runbook ([CR-100](https://linear.app/community-rule/issue/CR-100/backend-steady-state-operator-runbook)).
|
||||
|
||||
## 11. Related docs
|
||||
|
||||
- [`docs/guides/backend-roadmap.md`](backend-roadmap.md) §11
|
||||
(environments) and §8 (Prisma migrations policy).
|
||||
|
||||
@@ -21,6 +21,15 @@ public/
|
||||
blog/ # Per-article SVG thumbnails (see content-creation.md)
|
||||
```
|
||||
|
||||
## Icon sources (two systems)
|
||||
|
||||
| Location | Used for | Resolution |
|
||||
| --- | --- | --- |
|
||||
| `public/assets/icons/` | Static chrome served by URL (`icon-close.svg`, `icon-help.svg`) | `ASSETS.ICON_*` |
|
||||
| `app/components/asset/icon/` | Bundled create-flow / nav SVGs imported by `Icon.tsx` | Webpack import, not `public/` |
|
||||
|
||||
Do not duplicate the same glyph in both places unless migrating between systems.
|
||||
|
||||
## Naming rules
|
||||
|
||||
- **Directories and filenames:** lowercase kebab-case only.
|
||||
@@ -54,6 +63,7 @@ stage. Raster → SVG conversion is tracked in
|
||||
| `marketing/hero-image.png` | HeroBanner | **Design review** — likely keep raster |
|
||||
| `marketing/governance-booklet.pdf` | About / Book | **Done** — PDF (`governanceBookletPath()`) |
|
||||
| `logos/community-rule.svg` | Logo + favicon (`ASSETS.LOGO`) | **Done** — SVG |
|
||||
| `share/*.svg` (×5) | Share modal | **Done** — SVG (`shareIconPath()`) |
|
||||
| `logos/gitlab.svg` | Footer / social | **Done** — SVG |
|
||||
|
||||
## Related docs
|
||||
|
||||
@@ -7,6 +7,7 @@ APIs, and required imports see `.cursor/rules/testing.mdc`.
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
npm run knip # unused files / exports (see knip.json; local only)
|
||||
npm test
|
||||
npx next build
|
||||
npm run e2e # when routes, auth, or critical flows change
|
||||
|
||||
@@ -1,16 +1,37 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@5/schema.json",
|
||||
"entry": [
|
||||
"next.config.mjs",
|
||||
"postcss.config.mjs",
|
||||
"eslint.config.mjs",
|
||||
".storybook/**/*.{js,ts}",
|
||||
"app/**/*.{ts,tsx}",
|
||||
"lib/**/*.{ts,tsx}",
|
||||
"scripts/**/*.{ts,js,mjs}",
|
||||
"prisma/seed.ts"
|
||||
],
|
||||
"project": ["**/*.{ts,tsx,js,jsx}"],
|
||||
"project": ["**/*.{ts,tsx,js,jsx,mjs}"],
|
||||
"ignore": [
|
||||
"tests/**",
|
||||
"stories/**",
|
||||
"**/*.stories.{js,jsx,ts,tsx}",
|
||||
"**/*.disabled"
|
||||
]
|
||||
],
|
||||
"ignoreDependencies": [
|
||||
"critters",
|
||||
"next-intl",
|
||||
"@axe-core/playwright",
|
||||
"@eslint/eslintrc",
|
||||
"@types/mdx",
|
||||
"eslint-config-next",
|
||||
"typescript-eslint",
|
||||
"@storybook/react",
|
||||
"@storybook/nextjs-vite",
|
||||
"@eslint/js",
|
||||
"@next/eslint-plugin-next",
|
||||
"eslint-plugin-react",
|
||||
"eslint-plugin-react-hooks",
|
||||
"postcss-load-config"
|
||||
],
|
||||
"ignoreBinaries": ["tailwindcss", "@svgr/webpack"]
|
||||
}
|
||||
|
||||
@@ -208,8 +208,6 @@ export const ASSETS = {
|
||||
UNION_XLG: "assets/shapes/union-xlg.svg",
|
||||
|
||||
// Alert / UI icons
|
||||
ICON_ALERT: "assets/icons/icon-alert.svg",
|
||||
ICON_CLOSE: "assets/icons/icon-close.svg",
|
||||
ICON_POINTER: "assets/icons/icon-pointer.svg",
|
||||
ICON_HELP: "assets/icons/icon-help.svg",
|
||||
} as const;
|
||||
|
||||
@@ -55,14 +55,14 @@ export function isMethodCardCustomizeSessionDirty<TDraft>(
|
||||
}
|
||||
|
||||
/** For Close / overlay / Escape — skip closing when user cancels the confirm. */
|
||||
export function confirmDiscardMethodCardCustomizeSession<TDraft>(
|
||||
export async function confirmDiscardMethodCardCustomizeSession<TDraft>(
|
||||
modalEditUnlocked: boolean,
|
||||
snapshot: MethodCardCustomizeSnapshot<TDraft> | null,
|
||||
pendingDraft: TDraft | null,
|
||||
draftFieldBlocks: CustomMethodCardFieldBlock[] | null,
|
||||
headerDraft: MethodCardHeaderDraft | null,
|
||||
message: string,
|
||||
): boolean {
|
||||
confirmFn: () => Promise<boolean>,
|
||||
): Promise<boolean> {
|
||||
if (!modalEditUnlocked || snapshot === null) {
|
||||
return true;
|
||||
}
|
||||
@@ -76,5 +76,5 @@ export function confirmDiscardMethodCardCustomizeSession<TDraft>(
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return window.confirm(message);
|
||||
return confirmFn();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"_comment": "FeatureGrid component defaults (shared across pages)",
|
||||
"_comment": "FeatureGrid component defaults (shared across pages). linkHref is a stub until destination pages ship.",
|
||||
"linkText": "Learn more",
|
||||
"linkHref": "#",
|
||||
"ariaLabel": "Feature tools and services",
|
||||
|
||||
@@ -23,9 +23,13 @@
|
||||
"about": "About"
|
||||
},
|
||||
"legal": {
|
||||
"_comment": "privacyPolicyHref, termsOfServiceHref, cookiesSettingsHref are stubs until legal pages ship.",
|
||||
"privacyPolicy": "Privacy Policy",
|
||||
"privacyPolicyHref": "#",
|
||||
"termsOfService": "Terms of Service",
|
||||
"cookiesSettings": "Cookies Settings"
|
||||
"termsOfServiceHref": "#",
|
||||
"cookiesSettings": "Cookies Settings",
|
||||
"cookiesSettingsHref": "#"
|
||||
},
|
||||
"copyright": "© All right reserved"
|
||||
}
|
||||
|
||||
@@ -12,5 +12,9 @@
|
||||
"customizePolicyTitleLabel": "Policy title",
|
||||
"customizePolicyDescriptionLabel": "Description",
|
||||
"cancelCustomize": "Cancel",
|
||||
"discardUnsavedCustomizeChanges": "Discard unsaved changes?"
|
||||
"discardUnsavedCustomizeChanges": "Discard unsaved changes?",
|
||||
"discardUnsavedCustomizeChangesTitle": "Discard unsaved changes?",
|
||||
"discardUnsavedCustomizeChangesDescription": "Your edits in customize mode will be lost.",
|
||||
"discardUnsavedCustomizeChangesProceed": "Discard",
|
||||
"discardUnsavedCustomizeChangesCancel": "Keep editing"
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"storybook:github": "STORYBOOK_BASE_PATH=true storybook dev -p 6006",
|
||||
"storybook:build": "storybook build",
|
||||
"storybook:build:github": "STORYBOOK_BASE_PATH=true storybook build",
|
||||
"knip": "knip",
|
||||
"knip": "knip --include files,exports --exclude duplicates",
|
||||
"test": "vitest run --coverage",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<svg width="19" height="19" viewBox="0 0 19 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.49998 14.2307C9.72883 14.2307 9.92065 14.1533 10.0755 13.9985C10.2303 13.8437 10.3077 13.6519 10.3077 13.4231C10.3077 13.1942 10.2303 13.0024 10.0755 12.8476C9.92065 12.6928 9.72883 12.6154 9.49998 12.6154C9.27112 12.6154 9.0793 12.6928 8.9245 12.8476C8.7697 13.0024 8.6923 13.1942 8.6923 13.4231C8.6923 13.6519 8.7697 13.8437 8.9245 13.9985C9.0793 14.1533 9.27112 14.2307 9.49998 14.2307ZM8.75 10.5769H10.25V4.5769H8.75V10.5769ZM9.50165 19C8.18772 19 6.95268 18.7506 5.79655 18.252C4.6404 17.7533 3.63472 17.0765 2.7795 16.2217C1.92427 15.3669 1.24721 14.3616 0.748325 13.206C0.249442 12.0504 0 10.8156 0 9.50165C0 8.18772 0.249334 6.95268 0.748 5.79655C1.24667 4.6404 1.92342 3.63472 2.77825 2.7795C3.6331 1.92427 4.63834 1.24721 5.79398 0.748326C6.94959 0.249443 8.18437 0 9.4983 0C10.8122 0 12.0473 0.249334 13.2034 0.748001C14.3596 1.24667 15.3652 1.92342 16.2205 2.77825C17.0757 3.6331 17.7527 4.63834 18.2516 5.79398C18.7505 6.94959 19 8.18437 19 9.4983C19 10.8122 18.7506 12.0473 18.252 13.2034C17.7533 14.3596 17.0765 15.3652 16.2217 16.2205C15.3669 17.0757 14.3616 17.7527 13.206 18.2516C12.0504 18.7505 10.8156 19 9.50165 19ZM9.49998 17.5C11.7333 17.5 13.625 16.725 15.175 15.175C16.725 13.625 17.5 11.7333 17.5 9.49998C17.5 7.26664 16.725 5.37498 15.175 3.82498C13.625 2.27498 11.7333 1.49998 9.49998 1.49998C7.26664 1.49998 5.37498 2.27498 3.82498 3.82498C2.27498 5.37498 1.49998 7.26664 1.49998 9.49998C1.49998 11.7333 2.27498 13.625 3.82498 15.175C5.37498 16.725 7.26664 17.5 9.49998 17.5Z" fill="#FEFCC9"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg width="14" height="8" viewBox="0 0 14 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.92822 0L13.8564 7.5H1.95503e-05L6.92822 0Z" fill="black"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 172 B |
@@ -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 |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 82 KiB |
@@ -0,0 +1,18 @@
|
||||
import Shapes from "../../app/components/asset/Shapes";
|
||||
|
||||
export default {
|
||||
title: "Components/Asset/Shapes",
|
||||
component: Shapes,
|
||||
parameters: { layout: "centered" },
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: { type: "select" },
|
||||
options: ["yellow", "purple", "green", "orange"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Yellow = { args: { variant: "yellow" } };
|
||||
export const Purple = { args: { variant: "purple" } };
|
||||
export const Green = { args: { variant: "green" } };
|
||||
export const Orange = { args: { variant: "orange" } };
|
||||
@@ -0,0 +1,36 @@
|
||||
import SelectOption from "../../app/components/controls/SelectOption";
|
||||
|
||||
export default {
|
||||
title: "Components/Controls/SelectOption",
|
||||
component: SelectOption,
|
||||
parameters: { layout: "centered" },
|
||||
argTypes: {
|
||||
size: {
|
||||
control: { type: "select" },
|
||||
options: ["small", "medium", "large"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Default = {
|
||||
args: {
|
||||
children: "Consensus",
|
||||
size: "medium",
|
||||
},
|
||||
};
|
||||
|
||||
export const Selected = {
|
||||
args: {
|
||||
children: "Liquid democracy",
|
||||
selected: true,
|
||||
size: "medium",
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled = {
|
||||
args: {
|
||||
children: "Unavailable option",
|
||||
disabled: true,
|
||||
size: "medium",
|
||||
},
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
import LanguageSwitcher from "../../app/components/localization/LanguageSwitcher";
|
||||
|
||||
export default {
|
||||
title: "Components/Localization/LanguageSwitcher",
|
||||
component: LanguageSwitcher,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
argTypes: {
|
||||
className: {
|
||||
control: "text",
|
||||
description: "Optional wrapper className",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Default = {
|
||||
args: {},
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import React, { useState } from "react";
|
||||
import AskOrganizerInquiryModal from "../../app/components/modals/AskOrganizerInquiry";
|
||||
import Button from "../../app/components/buttons/Button";
|
||||
|
||||
export default {
|
||||
title: "Components/Modals/AskOrganizerInquiry",
|
||||
component: AskOrganizerInquiryModal,
|
||||
};
|
||||
|
||||
function AskOrganizerInquiryStoryHost() {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<Button
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
size="medium"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
Ask an organizer
|
||||
</Button>
|
||||
<AskOrganizerInquiryModal
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Default = {
|
||||
render: () => <AskOrganizerInquiryStoryHost />,
|
||||
};
|
||||
@@ -28,10 +28,6 @@ export default {
|
||||
control: "text",
|
||||
description: "Text for the call-to-action button",
|
||||
},
|
||||
buttonHref: {
|
||||
control: "text",
|
||||
description: "URL for the button link",
|
||||
},
|
||||
variant: {
|
||||
control: { type: "select" },
|
||||
options: ["centered", "left-aligned", "compact", "inverse", "use-case-detail"],
|
||||
@@ -103,15 +99,3 @@ export const UseCaseDetail = {
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
/** Legacy: CTA is a link (no inquiry modal). */
|
||||
export const LinkCta = {
|
||||
args: {
|
||||
title: "Still have questions?",
|
||||
subtitle: "Get answers from an experienced organizer",
|
||||
buttonText: "Ask an organizer",
|
||||
buttonHref: "/contact",
|
||||
variant: "centered",
|
||||
onContactClick: (data) => console.log("Contact clicked:", data),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import Book from "../../app/components/sections/Book";
|
||||
import { getAssetPath, governanceBookletPath } from "../../lib/assetUtils";
|
||||
import messages from "../../messages/en/pages/about.json";
|
||||
|
||||
export default {
|
||||
title: "Components/Sections/Book",
|
||||
component: Book,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
backgrounds: { default: "dark" },
|
||||
},
|
||||
};
|
||||
|
||||
export const Default = {
|
||||
args: {
|
||||
title: messages.book.title,
|
||||
description: messages.book.description,
|
||||
buttonText: messages.book.buttonText,
|
||||
buttonHref: getAssetPath(governanceBookletPath()),
|
||||
imageAlt: messages.book.imageAlt,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import TripleTextBlock from "../../app/components/type/TripleTextBlock";
|
||||
import messages from "../../messages/en/pages/about.json";
|
||||
|
||||
export default {
|
||||
title: "Components/Type/TripleTextBlock",
|
||||
component: TripleTextBlock,
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
|
||||
export const Default = {
|
||||
args: {
|
||||
columns: messages.tripleTextBlock.columns,
|
||||
},
|
||||
};
|
||||
@@ -22,7 +22,6 @@ const config: ComponentTestSuiteConfig<AskOrganizerProps> = {
|
||||
subtitle: "Subtitle",
|
||||
description: "Description",
|
||||
buttonText: "Button",
|
||||
buttonHref: "/link",
|
||||
className: "custom",
|
||||
variant: "centered",
|
||||
},
|
||||
@@ -72,11 +71,9 @@ describe("AskOrganizer (behavioral tests)", () => {
|
||||
});
|
||||
|
||||
it("renders button with custom text", () => {
|
||||
render(
|
||||
<AskOrganizer title="Test" buttonText="Contact" buttonHref="/contact" />,
|
||||
);
|
||||
render(<AskOrganizer title="Test" buttonText="Contact" />);
|
||||
expect(
|
||||
screen.getByRole("link", {
|
||||
screen.getByRole("button", {
|
||||
name: /contact/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useLayoutEffect } from "react";
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import {
|
||||
renderWithProviders as render,
|
||||
screen,
|
||||
@@ -17,6 +17,18 @@ afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
async function confirmDiscardCustomizeEdits() {
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Discard" }),
|
||||
);
|
||||
}
|
||||
|
||||
async function declineDiscardCustomizeEdits() {
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Keep editing" }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts the screen with optional starting state and exposes the latest
|
||||
* `state` to the test harness so we can assert the persistence side of
|
||||
@@ -152,9 +164,6 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
|
||||
|
||||
it("Cancel customize reverts edited preset without persisting (no confirm when unchanged)", async () => {
|
||||
let latest: CreateFlowState = {};
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockImplementation(() => {
|
||||
throw new Error("confirm should not run when customize session is clean");
|
||||
});
|
||||
render(
|
||||
<ScreenWithStateProbe
|
||||
onState={(s) => {
|
||||
@@ -171,20 +180,20 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Customize" }));
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(
|
||||
(within(screen.getByRole("dialog")).getAllByRole(
|
||||
"textbox",
|
||||
)[0] as HTMLTextAreaElement).disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
expect(latest.communicationMethodDetailsById).toBeUndefined();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
expect(screen.queryByRole("button", { name: "Discard" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Cancel customize with edits restores snapshot after confirm", async () => {
|
||||
let latest: CreateFlowState = {};
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
render(
|
||||
<ScreenWithStateProbe
|
||||
onState={(s) => {
|
||||
@@ -216,8 +225,9 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
|
||||
fireEvent.change(textboxes[2], { target: { value: "Edited principle" } });
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
await confirmDiscardCustomizeEdits();
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
(
|
||||
within(screen.getByRole("dialog")).getAllByRole(
|
||||
@@ -225,15 +235,13 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
|
||||
)[0] as HTMLTextAreaElement
|
||||
).value,
|
||||
).toBe("Saved principle");
|
||||
});
|
||||
expect(
|
||||
latest.communicationMethodDetailsById?.signal?.corePrinciple,
|
||||
).toBe("Saved principle");
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("dirty Escape close stays open when user declines discard confirm", async () => {
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
render(
|
||||
<ScreenWithStateProbe
|
||||
onState={() => {
|
||||
@@ -255,11 +263,10 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
|
||||
fireEvent.change(textboxes[2], { target: { value: "Edited principle" } });
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
await screen.findByRole("button", { name: "Keep editing" });
|
||||
await declineDiscardCustomizeEdits();
|
||||
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(confirmSpy).toHaveBeenCalled();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("persists customized policy title for a custom UUID card on Save", async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useLayoutEffect } from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { fireEvent, within } from "@testing-library/react";
|
||||
import {
|
||||
renderWithProviders as render,
|
||||
@@ -11,6 +11,12 @@ import { FinalReviewScreen } from "../../app/(app)/create/screens/review/FinalRe
|
||||
import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext";
|
||||
import type { CreateFlowState } from "../../app/(app)/create/types";
|
||||
|
||||
async function confirmDiscardCustomizeEdits() {
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Discard" }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts the screen with a Customize-style preset selection and exposes the
|
||||
* latest `state` to the test via `onState`. Used by the edit-modal save
|
||||
@@ -521,15 +527,11 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => {
|
||||
target: { value: "Should NOT persist" },
|
||||
});
|
||||
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
try {
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
await confirmDiscardCustomizeEdits();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
} finally {
|
||||
confirmSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(latest.communicationMethodDetailsById).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { describe } from "vitest";
|
||||
import {
|
||||
componentTestSuite,
|
||||
type ComponentTestSuiteConfig,
|
||||
} from "../utils/componentTestSuite";
|
||||
import LanguageSwitcher from "../../app/components/localization/LanguageSwitcher";
|
||||
|
||||
type Props = React.ComponentProps<typeof LanguageSwitcher>;
|
||||
|
||||
const config: ComponentTestSuiteConfig<Props> = {
|
||||
component: LanguageSwitcher,
|
||||
name: "LanguageSwitcher",
|
||||
props: {} as Props,
|
||||
primaryRole: "combobox",
|
||||
testCases: {
|
||||
renders: true,
|
||||
accessibility: true,
|
||||
},
|
||||
};
|
||||
|
||||
describe("LanguageSwitcher", () => {
|
||||
componentTestSuite<Props>(config);
|
||||
});
|
||||
@@ -57,37 +57,35 @@ describe("methodCardCustomizeSession", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("confirmDiscard skips confirm when unlocked but snapshot missing", () => {
|
||||
const spy = vi.spyOn(window, "confirm");
|
||||
it("confirmDiscard skips confirm when unlocked but snapshot missing", async () => {
|
||||
const confirmFn = vi.fn();
|
||||
expect(
|
||||
confirmDiscardMethodCardCustomizeSession(
|
||||
await confirmDiscardMethodCardCustomizeSession(
|
||||
true,
|
||||
null,
|
||||
{ x: 1 },
|
||||
null,
|
||||
null,
|
||||
"msg",
|
||||
confirmFn,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
expect(confirmFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("confirmDiscard runs confirm when dirty", () => {
|
||||
const spy = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
it("confirmDiscard runs confirm when dirty", async () => {
|
||||
const confirmFn = vi.fn().mockResolvedValue(false);
|
||||
const draft = { n: 1 };
|
||||
const snap = captureMethodCardCustomizeSnapshot(draft, null, HEADER_0);
|
||||
expect(
|
||||
confirmDiscardMethodCardCustomizeSession(
|
||||
await confirmDiscardMethodCardCustomizeSession(
|
||||
true,
|
||||
snap,
|
||||
{ n: 2 },
|
||||
null,
|
||||
HEADER_0,
|
||||
"Discard?",
|
||||
confirmFn,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(spy).toHaveBeenCalledWith("Discard?");
|
||||
spy.mockRestore();
|
||||
expect(confirmFn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { markdownToHtml } from "../../lib/content";
|
||||
|
||||
describe("Markdown Processing", () => {
|
||||
describe("markdownToHtml", () => {
|
||||
it("converts basic markdown to HTML", () => {
|
||||
const markdown = "# Heading\n\nThis is a paragraph.";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain("<h1>Heading</h1>");
|
||||
expect(result).toContain("This is a paragraph.");
|
||||
});
|
||||
|
||||
it("converts bold text", () => {
|
||||
const markdown = "This is **bold** text.";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain("<strong>bold</strong>");
|
||||
});
|
||||
|
||||
it("converts italic text", () => {
|
||||
const markdown = "This is *italic* text.";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain("<em>italic</em>");
|
||||
});
|
||||
|
||||
it("converts links", () => {
|
||||
const markdown = "Visit [Google](https://google.com) for search.";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain('<a href="https://google.com">Google</a>');
|
||||
});
|
||||
|
||||
it("converts line breaks to <br> tags", () => {
|
||||
const markdown = "Line 1\nLine 2\nLine 3";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain("Line 1<br>");
|
||||
expect(result).toContain("Line 2<br>");
|
||||
expect(result).toContain("Line 3");
|
||||
});
|
||||
|
||||
it("converts multiple line breaks to paragraph breaks", () => {
|
||||
const markdown = "Paragraph 1\n\nParagraph 2\n\nParagraph 3";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain("<p>Paragraph 1</p>");
|
||||
expect(result).toContain("<p>Paragraph 2</p>");
|
||||
expect(result).toContain("<p>Paragraph 3</p>");
|
||||
});
|
||||
|
||||
it("adds md-gap class to paragraphs", () => {
|
||||
const markdown = "Paragraph 1\n\nParagraph 2";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain('<p class="md-gap">Paragraph 1</p>');
|
||||
expect(result).toContain('<p class="md-gap">Paragraph 2</p>');
|
||||
});
|
||||
|
||||
it("converts unordered lists", () => {
|
||||
const markdown = "- Item 1\n- Item 2\n- Item 3";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain("<ul>");
|
||||
expect(result).toContain("<li>Item 1</li>");
|
||||
expect(result).toContain("<li>Item 2</li>");
|
||||
expect(result).toContain("<li>Item 3</li>");
|
||||
expect(result).toContain("</ul>");
|
||||
});
|
||||
|
||||
it("converts ordered lists", () => {
|
||||
const markdown = "1. First item\n2. Second item\n3. Third item";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain("<ol>");
|
||||
expect(result).toContain("<li>First item</li>");
|
||||
expect(result).toContain("<li>Second item</li>");
|
||||
expect(result).toContain("<li>Third item</li>");
|
||||
expect(result).toContain("</ol>");
|
||||
});
|
||||
|
||||
it("converts code blocks", () => {
|
||||
const markdown = "```javascript\nconst x = 1;\n```";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain("<pre>");
|
||||
expect(result).toContain("<code>");
|
||||
expect(result).toContain("const x = 1;");
|
||||
});
|
||||
|
||||
it("converts inline code", () => {
|
||||
const markdown = "Use `console.log()` to debug.";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain("<code>console.log()</code>");
|
||||
});
|
||||
|
||||
it("converts blockquotes", () => {
|
||||
const markdown = "> This is a quote\n> with multiple lines";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain("<blockquote>");
|
||||
expect(result).toContain("This is a quote");
|
||||
expect(result).toContain("with multiple lines");
|
||||
expect(result).toContain("</blockquote>");
|
||||
});
|
||||
|
||||
it("converts horizontal rules", () => {
|
||||
const markdown = "Text above\n\n---\n\nText below";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain("<hr>");
|
||||
});
|
||||
|
||||
it("handles mixed content", () => {
|
||||
const markdown =
|
||||
"# Title\n\nThis is a **bold** paragraph with a [link](https://example.com).\n\n- List item 1\n- List item 2\n\nAnother paragraph with `code`.";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain("<h1>Title</h1>");
|
||||
expect(result).toContain("<strong>bold</strong>");
|
||||
expect(result).toContain('<a href="https://example.com">link</a>');
|
||||
expect(result).toContain("<ul>");
|
||||
expect(result).toContain("<li>List item 1</li>");
|
||||
expect(result).toContain("<li>List item 2</li>");
|
||||
expect(result).toContain("<code>code</code>");
|
||||
});
|
||||
|
||||
it("handles empty input", () => {
|
||||
const result = markdownToHtml("");
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
it("handles whitespace-only input", () => {
|
||||
const result = markdownToHtml(" \n\n ");
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
it("preserves HTML entities", () => {
|
||||
const markdown = "Use < and > for HTML tags.";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain("<");
|
||||
expect(result).toContain(">");
|
||||
});
|
||||
|
||||
it("handles complex nested structures", () => {
|
||||
const markdown =
|
||||
"# Main Title\n\n## Subtitle\n\nThis is a paragraph with **bold** and *italic* text.\n\n1. First item with `code`\n2. Second item with [link](https://example.com)\n\n> This is a quote\n> with **bold** text\n\n```javascript\nconst example = 'test';\n```";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain("<h1>Main Title</h1>");
|
||||
expect(result).toContain("<h2>Subtitle</h2>");
|
||||
expect(result).toContain("<strong>bold</strong>");
|
||||
expect(result).toContain("<em>italic</em>");
|
||||
expect(result).toContain("<ol>");
|
||||
expect(result).toContain("<code>code</code>");
|
||||
expect(result).toContain('<a href="https://example.com">link</a>');
|
||||
expect(result).toContain("<blockquote>");
|
||||
expect(result).toContain("<pre>");
|
||||
});
|
||||
|
||||
it("handles malformed markdown gracefully", () => {
|
||||
const markdown = "**Unclosed bold\n\n*Unclosed italic\n\n[Unclosed link";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
// Should not throw an error and should handle gracefully
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("converts headings of different levels", () => {
|
||||
const markdown = "# H1\n## H2\n### H3\n#### H4\n##### H5\n###### H6";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain("<h1>H1</h1>");
|
||||
expect(result).toContain("<h2>H2</h2>");
|
||||
expect(result).toContain("<h3>H3</h3>");
|
||||
expect(result).toContain("<h4>H4</h4>");
|
||||
expect(result).toContain("<h5>H5</h5>");
|
||||
expect(result).toContain("<h6>H6</h6>");
|
||||
});
|
||||
|
||||
it("handles tables", () => {
|
||||
const markdown =
|
||||
"| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
|
||||
const result = markdownToHtml(markdown);
|
||||
|
||||
expect(result).toContain("<table>");
|
||||
expect(result).toContain("<thead>");
|
||||
expect(result).toContain("<th>Header 1</th>");
|
||||
expect(result).toContain("<th>Header 2</th>");
|
||||
expect(result).toContain("<tbody>");
|
||||
expect(result).toContain("<td>Cell 1</td>");
|
||||
expect(result).toContain("<td>Cell 2</td>");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const isDatabaseConfiguredMock = vi.fn();
|
||||
const deleteManyMock = vi.fn();
|
||||
const createMock = vi.fn();
|
||||
const getSessionPepperMock = vi.fn();
|
||||
const sendMagicLinkEmailMock = vi.fn();
|
||||
|
||||
vi.mock("../../lib/server/env", () => ({
|
||||
isDatabaseConfigured: () => isDatabaseConfiguredMock(),
|
||||
getSessionPepper: () => getSessionPepperMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/db", () => ({
|
||||
prisma: {
|
||||
magicLinkToken: {
|
||||
deleteMany: (...args: unknown[]) => deleteManyMock(...args),
|
||||
create: (...args: unknown[]) => createMock(...args),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/hash", () => ({
|
||||
hashSessionToken: (token: string) => `hash-${token}`,
|
||||
newSessionToken: () => "plain-token",
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/mail", () => ({
|
||||
sendMagicLinkEmail: (...args: unknown[]) => sendMagicLinkEmailMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/rateLimit", () => ({
|
||||
rateLimitKey: () => ({ ok: true }),
|
||||
}));
|
||||
|
||||
import { POST } from "../../app/api/auth/magic-link/request/route";
|
||||
|
||||
beforeEach(() => {
|
||||
isDatabaseConfiguredMock.mockReset();
|
||||
deleteManyMock.mockReset();
|
||||
createMock.mockReset();
|
||||
getSessionPepperMock.mockReset();
|
||||
sendMagicLinkEmailMock.mockReset();
|
||||
isDatabaseConfiguredMock.mockReturnValue(true);
|
||||
getSessionPepperMock.mockReturnValue("pepper");
|
||||
deleteManyMock.mockResolvedValue(undefined);
|
||||
createMock.mockResolvedValue(undefined);
|
||||
sendMagicLinkEmailMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("POST /api/auth/magic-link/request", () => {
|
||||
it("returns 503 when the database is not configured", async () => {
|
||||
isDatabaseConfiguredMock.mockReturnValue(false);
|
||||
const res = await POST(
|
||||
new NextRequest("https://x.test/api/auth/magic-link/request", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: "a@b.c" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(503);
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 for invalid JSON", async () => {
|
||||
const res = await POST(
|
||||
new NextRequest("https://x.test/api/auth/magic-link/request", {
|
||||
method: "POST",
|
||||
body: "not-json",
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: { code: string } };
|
||||
expect(body.error.code).toBe("invalid_json");
|
||||
});
|
||||
|
||||
it("returns 400 for an invalid email", async () => {
|
||||
const res = await POST(
|
||||
new NextRequest("https://x.test/api/auth/magic-link/request", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: "not-an-email" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: { code: string } };
|
||||
expect(body.error.code).toBe("validation_error");
|
||||
});
|
||||
|
||||
it("creates a token and sends mail for a valid email", async () => {
|
||||
const res = await POST(
|
||||
new NextRequest("https://x.test/api/auth/magic-link/request", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: "Member@Example.com" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ ok: true });
|
||||
expect(deleteManyMock).toHaveBeenCalledWith({
|
||||
where: { email: "member@example.com" },
|
||||
});
|
||||
expect(createMock).toHaveBeenCalled();
|
||||
expect(sendMagicLinkEmailMock).toHaveBeenCalledWith(
|
||||
"member@example.com",
|
||||
expect.stringContaining("/api/auth/magic-link/verify?token="),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 502 and rolls back the token when mail fails", async () => {
|
||||
sendMagicLinkEmailMock.mockRejectedValueOnce(new Error("smtp down"));
|
||||
const res = await POST(
|
||||
new NextRequest("https://x.test/api/auth/magic-link/request", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: "a@b.c" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(502);
|
||||
const body = (await res.json()) as { error: { code: string } };
|
||||
expect(body.error.code).toBe("mail_failed");
|
||||
expect(deleteManyMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const isDatabaseConfiguredMock = vi.fn();
|
||||
const getSessionUserMock = vi.fn();
|
||||
|
||||
vi.mock("../../lib/server/env", () => ({
|
||||
isDatabaseConfigured: () => isDatabaseConfiguredMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/session", () => ({
|
||||
getSessionUser: () => getSessionUserMock(),
|
||||
}));
|
||||
|
||||
import { GET } from "../../app/api/auth/session/route";
|
||||
|
||||
beforeEach(() => {
|
||||
isDatabaseConfiguredMock.mockReset();
|
||||
getSessionUserMock.mockReset();
|
||||
});
|
||||
|
||||
describe("GET /api/auth/session", () => {
|
||||
it("returns 503 when the database is not configured", async () => {
|
||||
isDatabaseConfiguredMock.mockReturnValue(false);
|
||||
const res = await GET(
|
||||
new NextRequest("https://x.test/api/auth/session"),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(503);
|
||||
expect(getSessionUserMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns user null when there is no session", async () => {
|
||||
isDatabaseConfiguredMock.mockReturnValue(true);
|
||||
getSessionUserMock.mockResolvedValueOnce(null);
|
||||
const res = await GET(
|
||||
new NextRequest("https://x.test/api/auth/session"),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ user: null });
|
||||
});
|
||||
|
||||
it("returns the signed-in user id and email", async () => {
|
||||
isDatabaseConfiguredMock.mockReturnValue(true);
|
||||
getSessionUserMock.mockResolvedValueOnce({
|
||||
id: "u1",
|
||||
email: "member@example.com",
|
||||
});
|
||||
const res = await GET(
|
||||
new NextRequest("https://x.test/api/auth/session"),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({
|
||||
user: { id: "u1", email: "member@example.com" },
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards an incoming x-request-id on the response", async () => {
|
||||
isDatabaseConfiguredMock.mockReturnValue(true);
|
||||
getSessionUserMock.mockResolvedValueOnce(null);
|
||||
const res = await GET(
|
||||
new NextRequest("https://x.test/api/auth/session", {
|
||||
headers: { "x-request-id": "req_session-1" },
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(res.headers.get("x-request-id")).toBe("req_session-1");
|
||||
});
|
||||
});
|
||||
@@ -1,310 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
getBlogPostFiles,
|
||||
parseBlogPost,
|
||||
getAllBlogPosts,
|
||||
getBlogPostBySlug,
|
||||
getRelatedBlogPosts,
|
||||
getAllTags,
|
||||
getBlogPostsByTag,
|
||||
} from "../../lib/content.js";
|
||||
|
||||
// Mock fs and path modules
|
||||
vi.mock("fs", () => ({
|
||||
readdirSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("path", () => ({
|
||||
join: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("Content Processing", () => {
|
||||
let mockReaddirSync, mockReadFileSync, mockPathJoin;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Get references to the mocked functions
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
mockReaddirSync = fs.readdirSync;
|
||||
mockReadFileSync = fs.readFileSync;
|
||||
mockPathJoin = path.join;
|
||||
|
||||
// Mock process.cwd to return a predictable path
|
||||
vi.spyOn(process, "cwd").mockReturnValue("/mock/project/root");
|
||||
|
||||
// Mock path.join to return predictable paths
|
||||
if (mockPathJoin && mockPathJoin.mockImplementation) {
|
||||
mockPathJoin.mockImplementation((...args) => args.join("/"));
|
||||
}
|
||||
});
|
||||
|
||||
describe("getBlogPostFiles", () => {
|
||||
it("should return markdown files from content directory", () => {
|
||||
const mockFiles = ["post1.md", "post2.mdx", "image.png", "post3.md"];
|
||||
mockReaddirSync.mockReturnValue(mockFiles);
|
||||
|
||||
const result = getBlogPostFiles();
|
||||
expect(result).toEqual(["post1.md", "post2.mdx", "post3.md"]);
|
||||
expect(mockReaddirSync).toHaveBeenCalledWith(
|
||||
"/mock/project/root/content/blog"
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle directory read errors gracefully", () => {
|
||||
mockReaddirSync.mockImplementation(() => {
|
||||
throw new Error("Directory not found");
|
||||
});
|
||||
|
||||
const result = getBlogPostFiles();
|
||||
expect(result).toEqual([]);
|
||||
expect(mockReaddirSync).toHaveBeenCalledWith(
|
||||
"/mock/project/root/content/blog"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBlogPost", () => {
|
||||
it("should parse a valid markdown file", () => {
|
||||
const mockContent = `---
|
||||
title: "Test Post"
|
||||
description: "A test description that meets the minimum length requirement"
|
||||
author: "Test Author"
|
||||
date: "2025-04-15"
|
||||
tags: ["test"]
|
||||
related: []
|
||||
---
|
||||
# Test Content
|
||||
This is the content.`;
|
||||
|
||||
mockReadFileSync.mockReturnValue(mockContent);
|
||||
|
||||
const result = parseBlogPost("test-post.md");
|
||||
expect(result).toMatchObject({
|
||||
slug: "test-post",
|
||||
frontmatter: {
|
||||
title: "Test Post",
|
||||
description:
|
||||
"A test description that meets the minimum length requirement",
|
||||
author: "Test Author",
|
||||
date: "2025-04-15",
|
||||
tags: ["test"],
|
||||
related: [],
|
||||
},
|
||||
content: "\n# Test Content\nThis is the content.",
|
||||
filePath: "test-post.md",
|
||||
});
|
||||
expect(mockReadFileSync).toHaveBeenCalledWith(
|
||||
"/mock/project/root/content/blog/test-post.md",
|
||||
"utf8"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return null for invalid frontmatter", () => {
|
||||
const mockContent = `---
|
||||
title: "" # Invalid title
|
||||
description: "A test description"
|
||||
author: "Test Author"
|
||||
date: "2025-04-15"
|
||||
---
|
||||
# Test Content`;
|
||||
|
||||
mockReadFileSync.mockReturnValue(mockContent);
|
||||
|
||||
const result = parseBlogPost("invalid-post.md");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle file read errors gracefully", () => {
|
||||
mockReadFileSync.mockImplementation(() => {
|
||||
throw new Error("File not found");
|
||||
});
|
||||
|
||||
const result = parseBlogPost("non-existent-post.md");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllBlogPosts", () => {
|
||||
it("should return all valid blog posts sorted by date", () => {
|
||||
const mockFiles = ["post1.md", "post2.md", "post3.md"];
|
||||
mockReaddirSync.mockReturnValue(mockFiles);
|
||||
|
||||
// Mock fs.readFileSync for each post
|
||||
mockReadFileSync.mockReturnValueOnce(`---
|
||||
title: "Post 1"
|
||||
description: "Desc 1"
|
||||
author: "Author 1"
|
||||
date: "2025-04-10"
|
||||
---
|
||||
# Content 1`).mockReturnValueOnce(`---
|
||||
title: "Post 2"
|
||||
description: "Desc 2"
|
||||
author: "Author 2"
|
||||
date: "2025-04-20"
|
||||
---
|
||||
# Content 2`).mockReturnValueOnce(`---
|
||||
title: "Post 3"
|
||||
description: "Desc 3"
|
||||
author: "Author 3"
|
||||
date: "2025-04-05"
|
||||
---
|
||||
# Content 3`);
|
||||
|
||||
const result = getAllBlogPosts();
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].slug).toBe("post2"); // Latest date
|
||||
expect(result[1].slug).toBe("post1");
|
||||
expect(result[2].slug).toBe("post3"); // Oldest date
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBlogPostBySlug", () => {
|
||||
it("should return blog post for valid slug", () => {
|
||||
const mockFiles = ["test-post.md"];
|
||||
mockReaddirSync.mockReturnValue(mockFiles);
|
||||
|
||||
const mockContent = `---
|
||||
title: "Test Post"
|
||||
description: "A test description that meets the minimum length requirement"
|
||||
author: "Test Author"
|
||||
date: "2025-04-15"
|
||||
---
|
||||
# Test Content`;
|
||||
|
||||
mockReadFileSync.mockReturnValue(mockContent);
|
||||
|
||||
const result = getBlogPostBySlug("test-post");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.slug).toBe("test-post");
|
||||
});
|
||||
|
||||
it("should return null for invalid slug", () => {
|
||||
const mockFiles = ["test-post.md"];
|
||||
mockReaddirSync.mockReturnValue(mockFiles);
|
||||
|
||||
const result = getBlogPostBySlug("invalid-slug");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRelatedBlogPosts", () => {
|
||||
it("should return related posts when slugs are provided", () => {
|
||||
const mockFiles = ["post1.md", "post2.md", "post3.md"];
|
||||
mockReaddirSync.mockReturnValue(mockFiles);
|
||||
|
||||
// Mock content for all posts
|
||||
mockReadFileSync.mockReturnValueOnce(`---
|
||||
title: "Post 1"
|
||||
description: "Desc 1"
|
||||
author: "Author 1"
|
||||
date: "2025-04-10"
|
||||
related: ["post2"]
|
||||
---
|
||||
# Content 1`).mockReturnValueOnce(`---
|
||||
title: "Post 2"
|
||||
description: "Desc 2"
|
||||
author: "Author 2"
|
||||
date: "2025-04-20"
|
||||
---
|
||||
# Content 2`).mockReturnValueOnce(`---
|
||||
title: "Post 3"
|
||||
description: "Desc 3"
|
||||
author: "Author 3"
|
||||
date: "2025-04-05"
|
||||
---
|
||||
# Content 3`);
|
||||
|
||||
const result = getRelatedBlogPosts("post1", ["post2", "post3"], 2);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].slug).toBe("post2");
|
||||
expect(result[1].slug).toBe("post3");
|
||||
});
|
||||
|
||||
it("should fallback to recent posts when no related slugs provided", () => {
|
||||
const mockFiles = ["post1.md", "post2.md", "post3.md"];
|
||||
mockReaddirSync.mockReturnValue(mockFiles);
|
||||
|
||||
const mockContent = `---
|
||||
title: "Post 1"
|
||||
description: "Desc 1"
|
||||
author: "Author 1"
|
||||
date: "2025-04-10"
|
||||
---
|
||||
# Content 1`;
|
||||
|
||||
mockReadFileSync.mockReturnValue(mockContent);
|
||||
|
||||
const result = getRelatedBlogPosts("post1", [], 2);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].slug).toBe("post2"); // Should be the most recent after excluding 'post1'
|
||||
expect(result[1].slug).toBe("post3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllTags", () => {
|
||||
it("should return unique tags from all posts", () => {
|
||||
const mockFiles = ["post1.md", "post2.md"];
|
||||
mockReaddirSync.mockReturnValue(mockFiles);
|
||||
|
||||
const mockContent1 = `---
|
||||
title: "Post 1"
|
||||
description: "Desc 1"
|
||||
author: "Author 1"
|
||||
date: "2025-04-10"
|
||||
tags: ["tagA", "tagB"]
|
||||
---
|
||||
# Content 1`;
|
||||
const mockContent2 = `---
|
||||
title: "Post 2"
|
||||
description: "Desc 2"
|
||||
author: "Author 2"
|
||||
date: "2025-04-20"
|
||||
tags: ["tagB", "tagC"]
|
||||
---
|
||||
# Content 2`;
|
||||
|
||||
mockReadFileSync
|
||||
.mockReturnValueOnce(mockContent1)
|
||||
.mockReturnValueOnce(mockContent2);
|
||||
|
||||
const result = getAllTags();
|
||||
expect(result).toEqual(expect.arrayContaining(["tagA", "tagB", "tagC"]));
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBlogPostsByTag", () => {
|
||||
it("should return posts with matching tag", () => {
|
||||
const mockFiles = ["post1.md", "post2.md"];
|
||||
mockReaddirSync.mockReturnValue(mockFiles);
|
||||
|
||||
const mockContent1 = `---
|
||||
title: "Post 1"
|
||||
description: "Desc 1"
|
||||
author: "Author 1"
|
||||
date: "2025-04-10"
|
||||
tags: ["tagA", "tagB"]
|
||||
---
|
||||
# Content 1`;
|
||||
const mockContent2 = `---
|
||||
title: "Post 2"
|
||||
description: "Desc 2"
|
||||
author: "Author 2"
|
||||
date: "2025-04-20"
|
||||
tags: ["tagB", "tagC"]
|
||||
---
|
||||
# Content 2`;
|
||||
|
||||
mockReadFileSync
|
||||
.mockReturnValueOnce(mockContent1)
|
||||
.mockReturnValueOnce(mockContent2);
|
||||
|
||||
const result = getBlogPostsByTag("tagA");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].slug).toBe("post1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const findFirstRuleMock = vi.fn();
|
||||
const findFirstStakeholderMock = vi.fn();
|
||||
const countMock = vi.fn();
|
||||
const deleteMock = vi.fn();
|
||||
const updateMock = vi.fn();
|
||||
const createInviteMock = vi.fn();
|
||||
const getSessionUserMock = vi.fn();
|
||||
const getSessionPepperMock = vi.fn();
|
||||
const sendInviteEmailMock = vi.fn();
|
||||
|
||||
vi.mock("../../lib/server/env", () => ({
|
||||
isDatabaseConfigured: () => true,
|
||||
getSessionPepper: () => getSessionPepperMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/db", () => ({
|
||||
prisma: {
|
||||
publishedRule: {
|
||||
findFirst: (...args: unknown[]) => findFirstRuleMock(...args),
|
||||
},
|
||||
ruleStakeholder: {
|
||||
findFirst: (...args: unknown[]) => findFirstStakeholderMock(...args),
|
||||
count: (...args: unknown[]) => countMock(...args),
|
||||
delete: (...args: unknown[]) => deleteMock(...args),
|
||||
update: (...args: unknown[]) => updateMock(...args),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/session", () => ({
|
||||
getSessionUser: () => getSessionUserMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/ruleStakeholderInviteOps", () => ({
|
||||
createRuleStakeholderInviteAndSendMail: (...args: unknown[]) =>
|
||||
createInviteMock(...args),
|
||||
stakeholderInviteVerifyUrl: (origin: string, token: string) =>
|
||||
`${origin}/api/invites/rule-stakeholder/verify?token=${encodeURIComponent(token)}`,
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/hash", () => ({
|
||||
hashSessionToken: (token: string) => `hash-${token}`,
|
||||
newSessionToken: () => "invite-token",
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/mail", () => ({
|
||||
sendRuleStakeholderInviteEmail: (...args: unknown[]) =>
|
||||
sendInviteEmailMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/rateLimit", () => ({
|
||||
rateLimitKey: () => ({ ok: true }),
|
||||
}));
|
||||
|
||||
import { DELETE } from "../../app/api/rules/[id]/stakeholders/[stakeholderId]/route";
|
||||
import { POST as POST_RESEND } from "../../app/api/rules/[id]/stakeholders/[stakeholderId]/resend/route";
|
||||
import { POST } from "../../app/api/rules/[id]/stakeholders/route";
|
||||
|
||||
const owner = { id: "owner-1", email: "owner@example.com" };
|
||||
const routeCtx = { params: Promise.resolve({ id: "rule-1" }) };
|
||||
const memberCtx = {
|
||||
params: Promise.resolve({ id: "rule-1", stakeholderId: "st-1" }),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
findFirstRuleMock.mockReset();
|
||||
findFirstStakeholderMock.mockReset();
|
||||
countMock.mockReset();
|
||||
deleteMock.mockReset();
|
||||
updateMock.mockReset();
|
||||
createInviteMock.mockReset();
|
||||
getSessionUserMock.mockReset();
|
||||
getSessionPepperMock.mockReset();
|
||||
sendInviteEmailMock.mockReset();
|
||||
getSessionUserMock.mockResolvedValue(owner);
|
||||
getSessionPepperMock.mockReturnValue("pepper");
|
||||
findFirstRuleMock.mockResolvedValue({ id: "rule-1", title: "My rule" });
|
||||
createInviteMock.mockResolvedValue({ ok: true });
|
||||
sendInviteEmailMock.mockResolvedValue(undefined);
|
||||
updateMock.mockResolvedValue(undefined);
|
||||
deleteMock.mockResolvedValue(undefined);
|
||||
countMock.mockResolvedValue(0);
|
||||
});
|
||||
|
||||
describe("POST /api/rules/[id]/stakeholders", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
getSessionUserMock.mockResolvedValueOnce(null);
|
||||
const res = await POST(
|
||||
new NextRequest("https://x.test/api/rules/rule-1/stakeholders", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: "inv@example.com" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
routeCtx,
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 400 when inviting the owner email", async () => {
|
||||
const res = await POST(
|
||||
new NextRequest("https://x.test/api/rules/rule-1/stakeholders", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: "Owner@Example.com" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
routeCtx,
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: { code: string } };
|
||||
expect(body.error.code).toBe("validation_error");
|
||||
});
|
||||
|
||||
it("creates an invite and returns 201", async () => {
|
||||
findFirstStakeholderMock
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({
|
||||
id: "st-new",
|
||||
email: "inv@example.com",
|
||||
invitedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
});
|
||||
const res = await POST(
|
||||
new NextRequest("https://x.test/api/rules/rule-1/stakeholders", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: "inv@example.com" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
routeCtx,
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(createInviteMock).toHaveBeenCalled();
|
||||
const body = (await res.json()) as {
|
||||
stakeholder: { email: string; status: string };
|
||||
};
|
||||
expect(body.stakeholder.email).toBe("inv@example.com");
|
||||
expect(body.stakeholder.status).toBe("pending");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/rules/[id]/stakeholders/[stakeholderId]", () => {
|
||||
it("returns 403 when the rule belongs to another user", async () => {
|
||||
findFirstStakeholderMock.mockResolvedValueOnce({
|
||||
id: "st-1",
|
||||
rule: { userId: "other-user" },
|
||||
});
|
||||
const res = await DELETE(
|
||||
new NextRequest(
|
||||
"https://x.test/api/rules/rule-1/stakeholders/st-1",
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
memberCtx,
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
expect(deleteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes the stakeholder for the rule owner", async () => {
|
||||
findFirstStakeholderMock.mockResolvedValueOnce({
|
||||
id: "st-1",
|
||||
rule: { userId: owner.id },
|
||||
});
|
||||
const res = await DELETE(
|
||||
new NextRequest(
|
||||
"https://x.test/api/rules/rule-1/stakeholders/st-1",
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
memberCtx,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ ok: true });
|
||||
expect(deleteMock).toHaveBeenCalledWith({ where: { id: "st-1" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/rules/[id]/stakeholders/[stakeholderId]/resend", () => {
|
||||
it("returns 400 when the invite was already accepted", async () => {
|
||||
findFirstStakeholderMock.mockResolvedValueOnce({
|
||||
id: "st-1",
|
||||
email: "inv@example.com",
|
||||
inviteTokenHash: null,
|
||||
inviteExpiresAt: null,
|
||||
rule: { userId: owner.id, title: "My rule" },
|
||||
});
|
||||
const res = await POST_RESEND(
|
||||
new NextRequest(
|
||||
"https://x.test/api/rules/rule-1/stakeholders/st-1/resend",
|
||||
{ method: "POST" },
|
||||
),
|
||||
memberCtx,
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: { code: string } };
|
||||
expect(body.error.code).toBe("validation_error");
|
||||
});
|
||||
|
||||
it("rotates the token and sends a new invite email", async () => {
|
||||
findFirstStakeholderMock.mockResolvedValueOnce({
|
||||
id: "st-1",
|
||||
email: "inv@example.com",
|
||||
inviteTokenHash: "old-hash",
|
||||
inviteExpiresAt: new Date("2026-01-01T00:00:00Z"),
|
||||
rule: { userId: owner.id, title: "My rule" },
|
||||
});
|
||||
const res = await POST_RESEND(
|
||||
new NextRequest(
|
||||
"https://x.test/api/rules/rule-1/stakeholders/st-1/resend",
|
||||
{ method: "POST" },
|
||||
),
|
||||
memberCtx,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ ok: true });
|
||||
expect(updateMock).toHaveBeenCalled();
|
||||
expect(sendInviteEmailMock).toHaveBeenCalledWith(
|
||||
"inv@example.com",
|
||||
expect.stringContaining("/api/invites/rule-stakeholder/verify?token="),
|
||||
"My rule",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { NextRequest } from "next/server";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const getUploadRootFromEnvMock = vi.fn();
|
||||
let uploadRoot: string | null = null;
|
||||
|
||||
vi.mock("../../lib/server/uploads/uploadRoot", () => ({
|
||||
getUploadRootFromEnv: () => getUploadRootFromEnvMock(),
|
||||
}));
|
||||
|
||||
import { GET } from "../../app/api/uploads/[id]/route";
|
||||
|
||||
beforeEach(async () => {
|
||||
uploadRoot = await mkdtemp(path.join(tmpdir(), "cr-upload-test-"));
|
||||
getUploadRootFromEnvMock.mockReset();
|
||||
getUploadRootFromEnvMock.mockImplementation(() => uploadRoot);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
uploadRoot = null;
|
||||
});
|
||||
|
||||
describe("GET /api/uploads/[id]", () => {
|
||||
it("returns 500 when UPLOAD_ROOT is unset", async () => {
|
||||
getUploadRootFromEnvMock.mockReturnValueOnce(null);
|
||||
const res = await GET(
|
||||
new NextRequest("https://x.test/api/uploads/upload-1"),
|
||||
{ params: Promise.resolve({ id: "upload-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("returns 404 when the upload is not found", async () => {
|
||||
const res = await GET(
|
||||
new NextRequest(
|
||||
"https://x.test/api/uploads/550e8400-e29b-41d4-a716-446655440000",
|
||||
),
|
||||
{
|
||||
params: Promise.resolve({
|
||||
id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
}),
|
||||
},
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns the file bytes with content type", async () => {
|
||||
const id = "550e8400-e29b-41d4-a716-446655440000";
|
||||
await writeFile(path.join(uploadRoot!, `${id}.png`), "png-bytes");
|
||||
const res = await GET(
|
||||
new NextRequest(`https://x.test/api/uploads/${id}`),
|
||||
{ params: Promise.resolve({ id }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toBe("image/png");
|
||||
expect(res.headers.get("Cache-Control")).toContain("immutable");
|
||||
expect(await res.text()).toBe("png-bytes");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const isDatabaseConfiguredMock = vi.fn();
|
||||
const getSessionUserMock = vi.fn();
|
||||
const getUploadRootFromEnvMock = vi.fn();
|
||||
|
||||
vi.mock("../../lib/server/env", () => ({
|
||||
isDatabaseConfigured: () => isDatabaseConfiguredMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/session", () => ({
|
||||
getSessionUser: () => getSessionUserMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/uploads/uploadRoot", () => ({
|
||||
getUploadRootFromEnv: () => getUploadRootFromEnvMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/rateLimit", () => ({
|
||||
rateLimitKey: () => ({ ok: true }),
|
||||
}));
|
||||
|
||||
import { POST } from "../../app/api/uploads/route";
|
||||
|
||||
function multipartRequest(opts: {
|
||||
purpose?: string;
|
||||
fileName?: string;
|
||||
fileContent?: string;
|
||||
}): NextRequest {
|
||||
const boundary = "----VitestBoundary";
|
||||
const parts: string[] = [];
|
||||
if (opts.purpose) {
|
||||
parts.push(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="purpose"\r\n\r\n${opts.purpose}\r\n`,
|
||||
);
|
||||
}
|
||||
if (opts.fileName && opts.fileContent !== undefined) {
|
||||
parts.push(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${opts.fileName}"\r\nContent-Type: image/png\r\n\r\n${opts.fileContent}\r\n`,
|
||||
);
|
||||
}
|
||||
parts.push(`--${boundary}--\r\n`);
|
||||
return new NextRequest("https://x.test/api/uploads", {
|
||||
method: "POST",
|
||||
body: parts.join(""),
|
||||
headers: {
|
||||
"content-type": `multipart/form-data; boundary=${boundary}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
isDatabaseConfiguredMock.mockReset();
|
||||
getSessionUserMock.mockReset();
|
||||
getUploadRootFromEnvMock.mockReset();
|
||||
isDatabaseConfiguredMock.mockReturnValue(true);
|
||||
getUploadRootFromEnvMock.mockReturnValue("/tmp/uploads");
|
||||
});
|
||||
|
||||
describe("POST /api/uploads", () => {
|
||||
it("returns 503 when the database is not configured", async () => {
|
||||
isDatabaseConfiguredMock.mockReturnValue(false);
|
||||
const res = await POST(
|
||||
new NextRequest("https://x.test/api/uploads", { method: "POST" }),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
getSessionUserMock.mockResolvedValueOnce(null);
|
||||
const res = await POST(
|
||||
new NextRequest("https://x.test/api/uploads", { method: "POST" }),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 500 when UPLOAD_ROOT is unset", async () => {
|
||||
getSessionUserMock.mockResolvedValueOnce({ id: "u1", email: "a@b.c" });
|
||||
getUploadRootFromEnvMock.mockReturnValueOnce(null);
|
||||
const res = await POST(
|
||||
new NextRequest("https://x.test/api/uploads", { method: "POST" }),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(500);
|
||||
const body = (await res.json()) as { error: { code: string } };
|
||||
expect(body.error.code).toBe("server_misconfigured");
|
||||
});
|
||||
|
||||
it("returns 400 when purpose is missing", async () => {
|
||||
getSessionUserMock.mockResolvedValueOnce({ id: "u1", email: "a@b.c" });
|
||||
const res = await POST(
|
||||
multipartRequest({ fileName: "avatar.png", fileContent: "x" }),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: { code: string } };
|
||||
expect(body.error.code).toBe("validation_error");
|
||||
});
|
||||
});
|
||||