Add button and custom modal flow implemented

This commit is contained in:
adilallo
2026-05-07 21:15:27 -06:00
parent dee2dd800e
commit 26bcd61ea3
43 changed files with 1444 additions and 81 deletions
@@ -31,6 +31,7 @@ import {
} from "../../components/createFlowLayoutTokens";
import { CommunicationMethodEditFields } from "../../components/methodEditFields";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
import { communicationPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom";
@@ -146,6 +147,8 @@ export function CommunicationMethodsScreen() {
const onCustomFieldBlocksChange = useCustomMethodCardFieldBlocksChange(
createModalOpen ? pendingCardId : null,
);
const customModalReadOnly =
pendingCardId !== null && selectedIds.includes(pendingCardId);
const handleCreateModalClose = useCallback(() => {
setCreateModalOpen(false);
@@ -293,7 +296,9 @@ export function CommunicationMethodsScreen() {
key={pendingCardId}
cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById}
onFieldBlocksChange={onCustomFieldBlocksChange}
onFieldBlocksChange={
customModalReadOnly ? undefined : onCustomFieldBlocksChange
}
/>
) : (
<CommunicationMethodEditFields
@@ -309,6 +314,9 @@ export function CommunicationMethodsScreen() {
isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard}
onFinalize={handleFinalizeCustomCard}
onPersistCustomUploadFile={(file) =>
uploadCreateFlowFile(file, "customMethodAttachment")
}
/>
</>
);
@@ -28,6 +28,7 @@ import {
} from "../../components/createFlowLayoutTokens";
import { ConflictManagementEditFields } from "../../components/methodEditFields";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
import { conflictManagementPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom";
@@ -147,6 +148,8 @@ export function ConflictManagementScreen() {
const onCustomFieldBlocksChange = useCustomMethodCardFieldBlocksChange(
createModalOpen ? pendingCardId : null,
);
const customModalReadOnly =
pendingCardId !== null && selectedIds.includes(pendingCardId);
const handleCreateModalClose = useCallback(() => {
setCreateModalOpen(false);
@@ -294,7 +297,9 @@ export function ConflictManagementScreen() {
key={pendingCardId}
cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById}
onFieldBlocksChange={onCustomFieldBlocksChange}
onFieldBlocksChange={
customModalReadOnly ? undefined : onCustomFieldBlocksChange
}
/>
) : (
<ConflictManagementEditFields
@@ -310,6 +315,9 @@ export function ConflictManagementScreen() {
isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard}
onFinalize={handleFinalizeCustomCard}
onPersistCustomUploadFile={(file) =>
uploadCreateFlowFile(file, "customMethodAttachment")
}
/>
</>
);
@@ -29,6 +29,7 @@ import {
} from "../../components/createFlowLayoutTokens";
import { MembershipMethodEditFields } from "../../components/methodEditFields";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
import { membershipPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom";
@@ -144,6 +145,8 @@ export function MembershipMethodsScreen() {
const onCustomFieldBlocksChange = useCustomMethodCardFieldBlocksChange(
createModalOpen ? pendingCardId : null,
);
const customModalReadOnly =
pendingCardId !== null && selectedIds.includes(pendingCardId);
const handleCreateModalClose = useCallback(() => {
setCreateModalOpen(false);
@@ -287,7 +290,9 @@ export function MembershipMethodsScreen() {
key={pendingCardId}
cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById}
onFieldBlocksChange={onCustomFieldBlocksChange}
onFieldBlocksChange={
customModalReadOnly ? undefined : onCustomFieldBlocksChange
}
/>
) : (
<MembershipMethodEditFields
@@ -303,6 +308,9 @@ export function MembershipMethodsScreen() {
isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard}
onFinalize={handleFinalizeCustomCard}
onPersistCustomUploadFile={(file) =>
uploadCreateFlowFile(file, "customMethodAttachment")
}
/>
</>
);
@@ -86,6 +86,12 @@ export function CommunityReviewScreen() {
? state.communityContext.trim()
: undefined;
const avatarUrl =
typeof state.communityAvatarUrl === "string" &&
state.communityAvatarUrl.trim().length > 0
? state.communityAvatarUrl.trim()
: null;
return (
<CreateFlowStepShell
variant="wideGridLoosePadding"
@@ -109,7 +115,9 @@ export function CommunityReviewScreen() {
size={lgUp ? "L" : "M"}
expanded={false}
backgroundColor="bg-[var(--color-teal-teal50,#c9fef9)]"
logoUrl={getAssetPath(vectorMarkPath("mutual-aid"))}
logoUrl={
avatarUrl ?? getAssetPath(vectorMarkPath("mutual-aid"))
}
logoAlt={cardTitle}
className="rounded-[24px]"
/>
@@ -30,6 +30,7 @@ import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import { DecisionApproachEditFields } from "../../components/methodEditFields";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
import { decisionApproachPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom";
@@ -143,6 +144,8 @@ export function DecisionApproachesScreen() {
const onCustomFieldBlocksChange = useCustomMethodCardFieldBlocksChange(
createModalOpen ? pendingCardId : null,
);
const customModalReadOnly =
pendingCardId !== null && selectedIds.includes(pendingCardId);
const handleToggleExpand = useCallback(() => {
markCreateFlowInteraction();
@@ -333,7 +336,9 @@ export function DecisionApproachesScreen() {
key={pendingCardId}
cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById}
onFieldBlocksChange={onCustomFieldBlocksChange}
onFieldBlocksChange={
customModalReadOnly ? undefined : onCustomFieldBlocksChange
}
/>
) : (
<DecisionApproachEditFields
@@ -349,6 +354,9 @@ export function DecisionApproachesScreen() {
isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard}
onFinalize={handleFinalizeCustomCard}
onPersistCustomUploadFile={(file) =>
uploadCreateFlowFile(file, "customMethodAttachment")
}
/>
</>
);
@@ -1,21 +1,145 @@
"use client";
import {
useCallback,
useEffect,
useRef,
useState,
type ChangeEvent,
} from "react";
import Upload from "../../../../components/controls/Upload";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useMessages, useTranslation } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
import { fetchAuthSession } from "../../../../../lib/create/api";
import { getAssetPath } from "../../../../../lib/assetUtils";
import {
UploadToServerError,
uploadCreateFlowFile,
} from "../../../../../lib/create/uploadToServer";
import {
clearPendingCommunityAvatarFile,
storePendingCommunityAvatarFile,
} from "../../../../../lib/create/pendingCommunityAvatarUpload";
/** Create Community — Figma Flow — Upload `20094:41524`. */
export function CommunityUploadScreen() {
const m = useMessages();
const u = m.create.community.communityUpload;
const { markCreateFlowInteraction } = useCreateFlow();
const { markCreateFlowInteraction, state, updateState } = useCreateFlow();
const tUpload = useTranslation("create.upload");
const handleUploadClick = () => {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [signedIn, setSignedIn] = useState<boolean | null>(null);
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void fetchAuthSession().then(({ user }) => {
if (!cancelled) setSignedIn(Boolean(user));
});
return () => {
cancelled = true;
};
}, []);
useEffect(
() => () => {
if (localPreviewUrl) URL.revokeObjectURL(localPreviewUrl);
},
[localPreviewUrl],
);
const resolveUploadError = useCallback(
(err: unknown) => {
if (err instanceof UploadToServerError) {
if (err.status === 413) return tUpload("errors.tooLarge");
if (err.status === 401) return tUpload("errors.unauthorized");
if (err.code === "server_misconfigured") {
return tUpload("errors.misconfigured");
}
}
return tUpload("errors.generic");
},
[tUpload],
);
const handleFileChange = useCallback(
async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
markCreateFlowInteraction();
setErrorMessage(null);
if (signedIn) {
setBusy(true);
try {
const { url } = await uploadCreateFlowFile(file, "communityAvatar");
setLocalPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
updateState({ communityAvatarUrl: url });
} catch (err) {
setErrorMessage(resolveUploadError(err));
} finally {
setBusy(false);
}
return;
}
if (signedIn === false) {
try {
await storePendingCommunityAvatarFile(file);
setLocalPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return URL.createObjectURL(file);
});
} catch {
setErrorMessage(tUpload("errors.generic"));
}
}
},
[
markCreateFlowInteraction,
resolveUploadError,
signedIn,
tUpload,
updateState,
],
);
const handleClearPendingUpload = useCallback(() => {
markCreateFlowInteraction();
};
setErrorMessage(null);
setLocalPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
if (
typeof state.communityAvatarUrl === "string" &&
state.communityAvatarUrl.trim().length > 0
) {
updateState({ communityAvatarUrl: undefined });
}
// Clear any anonymous staged blob so the post-sign-in flush won't resurrect it.
void clearPendingCommunityAvatarFile();
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}, [markCreateFlowInteraction, state.communityAvatarUrl, updateState]);
const displaySrc =
typeof state.communityAvatarUrl === "string" &&
state.communityAvatarUrl.trim().length > 0
? state.communityAvatarUrl.trim()
: localPreviewUrl;
const hasPreview = typeof displaySrc === "string" && displaySrc.length > 0;
return (
<CreateFlowStepShell
@@ -32,13 +156,65 @@ export function CommunityUploadScreen() {
justification="center"
/>
</div>
<div className="w-full">
<Upload
active={true}
showHelpIcon={false}
hintText={u.hintText}
onClick={handleUploadClick}
/>
<input
ref={fileInputRef}
type="file"
className="sr-only"
tabIndex={-1}
accept="image/jpeg,image/png,image/webp,image/gif"
aria-label={u.hintText}
onChange={handleFileChange}
/>
<div className="flex w-full flex-col items-center gap-3">
{hasPreview ? (
<div className="relative inline-block max-w-full">
<button
type="button"
onClick={handleClearPendingUpload}
className="absolute right-[8px] top-[8px] z-[1] flex h-[32px] w-[32px] cursor-pointer items-center justify-center rounded-full bg-[var(--color-surface-default-secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]"
aria-label={u.clearPendingUploadAriaLabel}
title={u.clearPendingUploadTooltip}
>
{/* eslint-disable-next-line @next/next/no-img-element -- matches ModalHeader close control */}
<img
src={getAssetPath("assets/Icon_Close.svg")}
alt=""
className="h-[16px] w-[16px]"
style={{
filter: "brightness(0) invert(1)",
}}
/>
</button>
{/* eslint-disable-next-line @next/next/no-img-element -- user/device file or same-origin upload URL */}
<img
src={displaySrc ?? ""}
alt={u.previewAlt}
className="max-h-[200px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain"
/>
</div>
) : (
<Upload
active={!busy}
showHelpIcon={false}
hintText={busy ? u.uploadingLabel : u.hintText}
onClick={() => {
if (!busy) fileInputRef.current?.click();
}}
/>
)}
{signedIn === false ? (
<p className="max-w-[474px] text-center font-[family-name:var(--font-body)] text-[length:var(--font-size-body-s)] text-[var(--color-content-default-tertiary)]">
{u.signInToUploadNote}
</p>
) : null}
{errorMessage ? (
<p
className="max-w-[474px] text-center font-[family-name:var(--font-body)] text-[length:var(--font-size-body-s)] text-[var(--color-content-default-secondary)]"
role="alert"
>
{errorMessage}
</p>
) : null}
</div>
</div>
</CreateFlowStepShell>