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
@@ -73,6 +73,7 @@ import { useAuthModal } from "../../contexts/AuthModalContext";
import { useMessages, useTranslation } from "../../contexts/MessagesContext";
import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer";
import { SignedInDraftHydration } from "./SignedInDraftHydration";
import { CreateFlowPendingAvatarFlush } from "./components/CreateFlowPendingAvatarFlush";
import Alert from "../../components/modals/Alert";
import Share from "../../components/modals/Share";
import {
@@ -561,6 +562,12 @@ function CreateFlowLayoutContent({
<Suspense fallback={null}>
<PostLoginDraftTransfer sessionUser={sessionUser} />
</Suspense>
<Suspense fallback={null}>
<CreateFlowPendingAvatarFlush
sessionUser={sessionUser}
sessionResolved={sessionResolved}
/>
</Suspense>
<Share
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
@@ -0,0 +1,51 @@
"use client";
import { useEffect, useRef } from "react";
import { useCreateFlow } from "../context/CreateFlowContext";
import { uploadCreateFlowFile } from "../../../../lib/create/uploadToServer";
import {
clearPendingCommunityAvatarFile,
readPendingCommunityAvatarFile,
} from "../../../../lib/create/pendingCommunityAvatarUpload";
/**
* After sign-in, uploads a community avatar staged in IndexedDB (anonymous pick)
* and writes `communityAvatarUrl` on success.
*/
export function CreateFlowPendingAvatarFlush({
sessionUser,
sessionResolved,
}: {
sessionUser: { id: string; email: string } | null | undefined;
sessionResolved: boolean;
}) {
const { updateState } = useCreateFlow();
/** One successful flush per signed-in user id (survives React StrictMode remounts). */
const lastFlushedUserIdRef = useRef<string | null>(null);
useEffect(() => {
if (!sessionResolved || !sessionUser) return;
if (lastFlushedUserIdRef.current === sessionUser.id) return;
let cancelled = false;
void (async () => {
const file = await readPendingCommunityAvatarFile();
if (cancelled || !file) return;
try {
const { url } = await uploadCreateFlowFile(file, "communityAvatar");
if (cancelled) return;
await clearPendingCommunityAvatarFile();
updateState({ communityAvatarUrl: url });
lastFlushedUserIdRef.current = sessionUser.id;
} catch {
// Leave pending blob in place so the user can retry after fixing auth / UPLOAD_ROOT.
}
})();
return () => {
cancelled = true;
};
}, [sessionResolved, sessionUser, updateState]);
return null;
}
@@ -10,8 +10,8 @@
* {@link ApplicableScopeField} chip rows, {@link IncrementerBlock}.
*/
import { memo, useCallback, useRef } from "react";
import { useMessages } from "../../../contexts/MessagesContext";
import { memo, useCallback, useRef, useState } from "react";
import { useMessages, useTranslation } from "../../../contexts/MessagesContext";
import Chip from "../../../components/controls/Chip";
import IncrementerBlock from "../../../components/controls/IncrementerBlock";
import InlineTextButton from "../../../components/buttons/InlineTextButton";
@@ -20,6 +20,7 @@ import ApplicableScopeField from "./ApplicableScopeField";
import InputLabel from "../../../components/type/InputLabel";
import type { CustomMethodCardFieldBlock } from "../../../../lib/create/customMethodCardFieldBlocks";
import ModalTextAreaField from "./ModalTextAreaField";
import { uploadCreateFlowFile } from "../../../../lib/create/uploadToServer";
const TEXT_VALUE_MAX = 8000;
@@ -55,7 +56,12 @@ function CustomMethodCardUploadBlockRow({
noFileChosen: string;
}) {
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const tUpload = useTranslation("create.upload");
const [busy, setBusy] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const displayName = block.fileName?.trim() ? block.fileName : noFileChosen;
const hasAsset = Boolean(block.assetUrl?.trim());
const previewAlt = block.fileName?.trim() || block.blockTitle || noFileChosen;
return (
<div className="flex flex-col gap-2">
@@ -65,41 +71,81 @@ function CustomMethodCardUploadBlockRow({
size="s"
palette="default"
/>
<p className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-m)] text-[var(--color-content-default-secondary)]">
{displayName}
</p>
{!hasAsset ? (
<p className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-m)] text-[var(--color-content-default-secondary)]">
{displayName}
</p>
) : null}
{hasAsset ? (
// eslint-disable-next-line @next/next/no-img-element -- same-origin upload URL
<img
src={block.assetUrl!.trim()}
alt={previewAlt}
className="max-h-[160px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain"
/>
) : null}
<input
ref={uploadInputRef}
type="file"
className="sr-only"
tabIndex={-1}
accept="image/jpeg,image/png,image/webp,image/gif,application/pdf"
aria-label={uploadFileInputAriaLabel}
onChange={(e) => {
const file = e.target.files?.[0];
const name = file?.name?.trim();
patch(
mapBlockById(blocks, block.id, (b) =>
b.kind === "upload"
? {
...b,
...(name ? { fileName: name } : {}),
}
: b,
),
);
e.target.value = "";
if (!file) return;
setErrorMessage(null);
setBusy(true);
void (async () => {
try {
const { url } = await uploadCreateFlowFile(
file,
"customMethodAttachment",
);
const name = file.name?.trim();
patch(
mapBlockById(blocks, block.id, (b) =>
b.kind === "upload"
? {
...b,
...(name ? { fileName: name } : {}),
assetUrl: url,
}
: b,
),
);
} catch {
setErrorMessage(tUpload("errors.generic"));
} finally {
setBusy(false);
}
})();
}}
/>
<Upload
hintText={uploadHint}
onClick={() => uploadInputRef.current?.click()}
active={!busy}
hintText={busy ? tUpload("uploading") : uploadHint}
onClick={() => {
if (!busy) uploadInputRef.current?.click();
}}
/>
{block.fileName?.trim() ? (
{errorMessage ? (
<p
className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-s)] text-[var(--color-content-default-secondary)]"
role="alert"
>
{errorMessage}
</p>
) : null}
{block.fileName?.trim() || block.assetUrl?.trim() ? (
<InlineTextButton
onClick={() =>
patch(
mapBlockById(blocks, block.id, (b) =>
b.kind === "upload" ? { ...b, fileName: undefined } : b,
b.kind === "upload"
? { ...b, fileName: undefined, assetUrl: undefined }
: b,
),
)
}
@@ -220,15 +266,30 @@ function CustomMethodCardFieldBlocksSummaryComponent({
return (
<div key={block.id}>
{readOnly ? (
<ModalTextAreaField
label={block.blockTitle}
rows={2}
value={
block.fileName?.trim() ? block.fileName : noFileChosen
}
onChange={() => {}}
disabled
/>
<div className="flex flex-col gap-2">
<InputLabel
label={block.blockTitle}
helpIcon
size="s"
palette="default"
/>
{block.assetUrl?.trim() ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={block.assetUrl.trim()}
alt={
block.fileName?.trim() ||
block.blockTitle ||
noFileChosen
}
className="max-h-[160px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain"
/>
) : (
<p className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-m)] text-[var(--color-content-default-secondary)]">
{noFileChosen}
</p>
)}
</div>
) : (
<CustomMethodCardUploadBlockRow
block={block}
@@ -1,7 +1,10 @@
"use client";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useMessages, useTranslation } from "../../../../contexts/MessagesContext";
import {
useMessages,
useTranslation,
} from "../../../../contexts/MessagesContext";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import { CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS } from "../../../../../lib/create/customMethodCardWizardConstants";
import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types";
@@ -13,9 +16,10 @@ import type { CustomMethodCardWizardProps } from "./CustomMethodCardWizard.types
* `20066:14748`, `20094:48551`, `20066:14361`).
*/
const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
({ isOpen, onClose, onFinalize }) => {
({ isOpen, onClose, onFinalize, onPersistCustomUploadFile }) => {
const m = useMessages();
const t = useTranslation("common");
const tUpload = useTranslation("create.upload");
const w = m.create.customRule.customMethodCardWizard;
const copy = useMemo(
@@ -23,10 +27,23 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
step1: w.steps["1"],
step2: w.steps["2"],
step3: w.steps["3"],
step3BlocksList: w.step3BlocksList,
fieldTypeLabels: {
text: w.addCustomField.fieldTypes.text,
badges: w.addCustomField.fieldTypes.badges,
upload: w.addCustomField.fieldTypes.upload,
proportion: w.addCustomField.fieldTypes.proportion,
},
footerFinalize: w.footer.finalize,
fieldModals: w.fieldModals,
}),
[w.fieldModals, w.footer.finalize, w.steps],
[
w.addCustomField.fieldTypes,
w.fieldModals,
w.footer.finalize,
w.step3BlocksList,
w.steps,
],
);
const fieldBodiesCopy = useMemo(
@@ -58,6 +75,13 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
const [uploadFileName, setUploadFileName] = useState<string | undefined>(
undefined,
);
const [uploadAssetUrl, setUploadAssetUrl] = useState<string | undefined>(
undefined,
);
const [uploadFieldBusy, setUploadFieldBusy] = useState(false);
const [uploadFieldError, setUploadFieldError] = useState<string | null>(
null,
);
const [proportionBlockTitle, setProportionBlockTitle] = useState("");
const [proportionDefault, setProportionDefault] = useState(50);
@@ -70,6 +94,9 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
setBadgeOptions([]);
setUploadBlockTitle("");
setUploadFileName(undefined);
setUploadAssetUrl(undefined);
setUploadFieldBusy(false);
setUploadFieldError(null);
setProportionBlockTitle("");
setProportionDefault(50);
if (fileInputRef.current) {
@@ -135,10 +162,14 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
}
if (fieldTypeModal === "upload") {
const t0 = uploadBlockTitle.trim();
return (
const titleOk =
t0.length > 0 &&
t0.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS
);
t0.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS;
if (!titleOk) return false;
if (onPersistCustomUploadFile) {
return Boolean(uploadAssetUrl?.trim());
}
return true;
}
const t0 = proportionBlockTitle.trim();
return (
@@ -154,6 +185,8 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
proportionDefault,
textBlockTitle,
uploadBlockTitle,
uploadAssetUrl,
onPersistCustomUploadFile,
]);
const headerTitle =
@@ -213,13 +246,35 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
}, [resetFieldTypeDrafts]);
const handleFileChosen = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
setUploadFileName(file?.name);
setUploadAssetUrl(undefined);
setUploadFieldError(null);
if (!file || !onPersistCustomUploadFile) return;
setUploadFieldBusy(true);
try {
const { url } = await onPersistCustomUploadFile(file);
setUploadAssetUrl(url);
} catch {
setUploadFieldError(tUpload("errors.generic"));
} finally {
setUploadFieldBusy(false);
}
},
[],
[onPersistCustomUploadFile, tUpload],
);
const handleClearPendingUpload = useCallback(() => {
setUploadFileName(undefined);
setUploadAssetUrl(undefined);
setUploadFieldError(null);
setUploadFieldBusy(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}, []);
const handleBadgeAddOption = useCallback((label: string) => {
setBadgeOptions((prev) =>
prev.includes(label) ? prev : [...prev, label],
@@ -253,6 +308,9 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
id,
blockTitle: uploadBlockTitle.trim(),
fileName: uploadFileName,
...(uploadAssetUrl?.trim()
? { assetUrl: uploadAssetUrl.trim() }
: {}),
};
break;
default:
@@ -276,6 +334,7 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
textPlaceholderBody,
uploadBlockTitle,
uploadFileName,
uploadAssetUrl,
]);
const handleNext = useCallback(() => {
@@ -337,6 +396,13 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
onUploadBlockTitleChange: setUploadBlockTitle,
fileInputRef,
onFileChosen: handleFileChosen,
onClearPendingUpload: handleClearPendingUpload,
uploadAssetPreviewUrl: uploadAssetUrl,
uploadPersisting:
Boolean(fieldTypeModal === "upload" && uploadFieldBusy),
uploadBusyHint: tUpload("uploading"),
uploadErrorMessage:
fieldTypeModal === "upload" ? uploadFieldError : null,
proportionBlockTitle,
proportionDefault,
onProportionBlockTitleChange: setProportionBlockTitle,
@@ -348,6 +414,8 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
onBack={handleBack}
onNext={handleNext}
stepper={!fieldTypeModal}
draftFieldBlocks={draftFieldBlocks}
onDraftFieldBlocksReorder={setDraftFieldBlocks}
/>
);
},
@@ -21,6 +21,9 @@ export interface CustomMethodCardWizardFieldBodiesCopy {
blockTitlePlaceholder: string;
uploadFileInputAriaLabel: string;
uploadHint: string;
uploadPreviewImageAlt: string;
clearPendingUploadAriaLabel: string;
clearPendingUploadTooltip: string;
};
proportion: {
blockTitleLabel: string;
@@ -35,6 +38,11 @@ export interface CustomMethodCardWizardCopy {
step1: { title: string; description: string; fieldPlaceholder: string };
step2: { title: string; description: string; fieldPlaceholder: string };
step3: { title: string; description: string };
step3BlocksList: {
listLabel: string;
dragHandleAriaLabel: string;
};
fieldTypeLabels: Record<AddCustomFieldType, string>;
footerFinalize: string;
fieldModals: {
addField: string;
@@ -67,6 +75,11 @@ export interface CustomMethodCardWizardProps {
description: string;
fieldBlocks: CustomMethodCardFieldBlock[];
}) => void;
/**
* Persists custom-method upload files to `POST /api/uploads` (purpose
* `customMethodAttachment`). When omitted, upload field only stores `fileName`.
*/
onPersistCustomUploadFile?: (file: File) => Promise<{ url: string }>;
}
export interface CustomMethodCardWizardFieldBodiesViewProps {
@@ -84,6 +97,15 @@ export interface CustomMethodCardWizardFieldBodiesViewProps {
onUploadBlockTitleChange: (_v: string) => void;
fileInputRef: RefObject<HTMLInputElement | null>;
onFileChosen: (e: React.ChangeEvent<HTMLInputElement>) => void;
/** Clears chosen file, preview URL, and related errors so the user can pick again. */
onClearPendingUpload: () => void;
/** When set after a successful upload, shows an inline image preview in the modal. */
uploadAssetPreviewUrl?: string | null;
/** Shown under the upload control while saving to the server. */
uploadPersisting?: boolean;
/** Replaces upload hint text while `uploadPersisting` is true. */
uploadBusyHint?: string;
uploadErrorMessage?: string | null;
proportionBlockTitle: string;
proportionDefault: number;
onProportionBlockTitleChange: (_v: string) => void;
@@ -111,6 +133,8 @@ export interface CustomMethodCardWizardViewProps {
CustomMethodCardWizardFieldBodiesViewProps,
"fieldType" | "copy"
>;
draftFieldBlocks: CustomMethodCardFieldBlock[];
onDraftFieldBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void;
nextDisabled: boolean;
nextLabel: string;
showBackButton: boolean;
@@ -6,6 +6,7 @@ import InputWithCounter from "../../../../components/controls/InputWithCounter";
import TextArea from "../../../../components/controls/TextArea";
import AddCustomField from "../../../../components/controls/AddCustomField";
import { CustomMethodCardWizardFieldBodiesView } from "./CustomMethodCardWizardFieldBodies.view";
import { CustomMethodCardWizardBlocksListView } from "./CustomMethodCardWizardBlocksList.view";
import type { CustomMethodCardWizardViewProps } from "./CustomMethodCardWizard.types";
function CustomMethodCardWizardViewComponent({
@@ -32,6 +33,8 @@ function CustomMethodCardWizardViewComponent({
onBack,
onNext,
stepper,
draftFieldBlocks,
onDraftFieldBlocksReorder,
}: CustomMethodCardWizardViewProps) {
return (
<Create
@@ -79,11 +82,22 @@ function CustomMethodCardWizardViewComponent({
/>
) : null}
{!fieldTypeModal && wizardStep === 3 ? (
<AddCustomField
active={addFieldExpanded}
onPressAdd={onPressAddCustomField}
onSelectFieldType={onSelectFieldType}
/>
<div className="flex w-full flex-col gap-4">
{draftFieldBlocks.length > 0 ? (
<CustomMethodCardWizardBlocksListView
blocks={draftFieldBlocks}
fieldTypeLabels={copy.fieldTypeLabels}
dragHandleAriaLabel={copy.step3BlocksList.dragHandleAriaLabel}
listLabel={copy.step3BlocksList.listLabel}
onBlocksReorder={onDraftFieldBlocksReorder}
/>
) : null}
<AddCustomField
active={addFieldExpanded}
onPressAdd={onPressAddCustomField}
onSelectFieldType={onSelectFieldType}
/>
</div>
) : null}
</Create>
);
@@ -0,0 +1,140 @@
"use client";
import { memo, useCallback, useState, type DragEvent } from "react";
import Icon from "../../../../components/asset/icon";
import { ADD_CUSTOM_FIELD_TYPE_ICONS } from "../../../../components/controls/AddCustomField/AddCustomField.types";
import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import { reorderCustomMethodCardFieldBlocks } from "../../../../../lib/create/reorderCustomMethodCardFieldBlocks";
function DragHandleGlyph({ className }: { className?: string }) {
return (
<svg
width={16}
height={16}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
aria-hidden
>
<circle cx={4} cy={4} r={1.25} fill="currentColor" />
<circle cx={12} cy={4} r={1.25} fill="currentColor" />
<circle cx={4} cy={8} r={1.25} fill="currentColor" />
<circle cx={12} cy={8} r={1.25} fill="currentColor" />
<circle cx={4} cy={12} r={1.25} fill="currentColor" />
<circle cx={12} cy={12} r={1.25} fill="currentColor" />
</svg>
);
}
export interface CustomMethodCardWizardBlocksListViewProps {
blocks: CustomMethodCardFieldBlock[];
fieldTypeLabels: Record<AddCustomFieldType, string>;
dragHandleAriaLabel: string;
listLabel: string;
onBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void;
}
function CustomMethodCardWizardBlocksListViewComponent({
blocks,
fieldTypeLabels,
dragHandleAriaLabel,
listLabel,
onBlocksReorder,
}: CustomMethodCardWizardBlocksListViewProps) {
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
const [overIndex, setOverIndex] = useState<number | null>(null);
const clearDragUi = useCallback(() => {
setDraggingIndex(null);
setOverIndex(null);
}, []);
const handleDragStart = useCallback(
(index: number) => (e: DragEvent) => {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(index));
setDraggingIndex(index);
},
[],
);
const handleDragOver = useCallback((index: number) => {
return (e: DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setOverIndex(index);
};
}, []);
const handleDrop = useCallback(
(index: number) => (e: DragEvent) => {
e.preventDefault();
const from = Number.parseInt(e.dataTransfer.getData("text/plain"), 10);
if (Number.isNaN(from)) {
clearDragUi();
return;
}
onBlocksReorder(
reorderCustomMethodCardFieldBlocks(blocks, from, index),
);
clearDragUi();
},
[blocks, clearDragUi, onBlocksReorder],
);
return (
<ul className="flex list-none flex-col gap-2 p-0" aria-label={listLabel}>
{blocks.map((block, index) => {
const kind = block.kind as AddCustomFieldType;
const typeLabel = fieldTypeLabels[kind];
const isOver = overIndex === index && draggingIndex !== index;
return (
<li
key={block.id}
className={`flex min-h-[52px] items-stretch gap-2 rounded-[var(--measures-radius-medium,8px)] border border-[var(--color-border-default-primary)] bg-[var(--color-surface-default-secondary)] pl-1 pr-3 py-2 transition-shadow ${
isOver
? "ring-2 ring-[var(--color-border-invert-primary)] ring-offset-2 ring-offset-[var(--color-surface-default-primary)]"
: ""
} ${draggingIndex === index ? "opacity-60" : ""}`}
onDragOver={handleDragOver(index)}
onDrop={handleDrop(index)}
>
<button
type="button"
draggable
onDragStart={handleDragStart(index)}
onDragEnd={clearDragUi}
className="flex shrink-0 cursor-grab touch-manipulation items-center justify-center rounded-[var(--measures-radius-200,8px)] border-0 bg-transparent px-1 text-[var(--color-content-default-secondary)] active:cursor-grabbing focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)]"
aria-label={dragHandleAriaLabel}
>
<DragHandleGlyph />
</button>
<span className="flex h-8 w-8 shrink-0 items-center justify-center self-center">
<Icon
name={ADD_CUSTOM_FIELD_TYPE_ICONS[kind]}
size={24}
className="text-[var(--color-content-default-brand-primary,#fefcc9)]"
/>
</span>
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5">
<span className="truncate font-inter text-[14px] font-medium leading-[18px] text-[var(--color-content-default-primary)]">
{block.blockTitle.trim() || typeLabel}
</span>
<span className="font-inter text-[12px] leading-4 text-[var(--color-content-default-secondary)]">
{typeLabel}
</span>
</div>
</li>
);
})}
</ul>
);
}
export const CustomMethodCardWizardBlocksListView = memo(
CustomMethodCardWizardBlocksListViewComponent,
);
CustomMethodCardWizardBlocksListView.displayName =
"CustomMethodCardWizardBlocksListView";
@@ -1,6 +1,7 @@
"use client";
import { memo } from "react";
import { getAssetPath } from "../../../../../lib/assetUtils";
import InputWithCounter from "../../../../components/controls/InputWithCounter";
import TextArea from "../../../../components/controls/TextArea";
import TextInput from "../../../../components/controls/TextInput";
@@ -28,11 +29,19 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
onUploadBlockTitleChange,
fileInputRef,
onFileChosen,
onClearPendingUpload,
uploadAssetPreviewUrl = null,
uploadPersisting = false,
uploadBusyHint,
uploadErrorMessage = null,
proportionBlockTitle,
proportionDefault,
onProportionBlockTitleChange,
onProportionDefaultChange,
}: CustomMethodCardWizardFieldBodiesViewProps) {
const uploadPreviewTrimmed = uploadAssetPreviewUrl?.trim() ?? "";
const hasUploadPreview = uploadPreviewTrimmed.length > 0;
if (fieldType === "text") {
return (
<div className="flex flex-col gap-[var(--spacing-scale-024)]">
@@ -120,10 +129,53 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
showHelpIcon
/>
<Upload
hintText={copy.upload.uploadHint}
onClick={() => fileInputRef.current?.click()}
/>
{hasUploadPreview ? (
<div className="relative inline-block max-w-full">
<button
type="button"
onClick={onClearPendingUpload}
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={copy.upload.clearPendingUploadAriaLabel}
title={copy.upload.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 -- blob or same-origin upload URL */}
<img
src={uploadPreviewTrimmed}
alt={copy.upload.uploadPreviewImageAlt}
className="max-h-[160px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain"
/>
</div>
) : (
<Upload
active={!uploadPersisting}
hintText={
uploadPersisting && uploadBusyHint
? uploadBusyHint
: copy.upload.uploadHint
}
onClick={() => {
if (!uploadPersisting) fileInputRef.current?.click();
}}
/>
)}
{uploadErrorMessage ? (
<p
className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-s)] text-[var(--color-content-default-secondary)]"
role="alert"
>
{uploadErrorMessage}
</p>
) : null}
</div>
);
}
@@ -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>
+5
View File
@@ -108,6 +108,11 @@ export interface CreateFlowState {
communityContext?: string;
/** Email collected on the “Save your progress” step (Figma Flow — Text `20097:14948`). */
communitySaveEmail?: string;
/**
* Public app path for the uploaded community image (e.g. `/api/uploads/{uuid}`).
* Set after successful `POST /api/uploads` with purpose `communityAvatar`.
*/
communityAvatarUrl?: string;
/** Selected chip ids from `community-size` (MultiSelect). */
selectedCommunitySizeIds?: string[];
/** Selected chip ids from `community-structure` (organization types). */
@@ -1,5 +1,6 @@
import type { CreateFlowState } from "../types";
import { migrateLegacyCreateFlowState } from "../../../../lib/create/migrateLegacyCreateFlowState";
import { clearPendingCommunityAvatarFile } from "../../../../lib/create/pendingCommunityAvatarUpload";
/** Anonymous in-progress create flow (local only until magic-link transfer). */
export const CREATE_FLOW_ANONYMOUS_KEY = "create-flow-anonymous" as const;
@@ -53,6 +54,7 @@ export function clearAnonymousCreateFlowStorage(): void {
} catch {
// ignore
}
void clearPendingCommunityAvatarFile();
}
export function setTransferPendingFlag(): void {
+42
View File
@@ -0,0 +1,42 @@
import { readFile } from "node:fs/promises";
import { NextResponse } from "next/server";
import { apiRoute } from "../../../../lib/server/apiRoute";
import {
notFound,
serverMisconfigured,
} from "../../../../lib/server/responses";
import { resolveUploadedFileById } from "../../../../lib/server/uploads/resolveUploadedFile";
import { getUploadRootFromEnv } from "../../../../lib/server/uploads/uploadRoot";
type RouteContext = { params: Promise<{ id: string }> };
/**
* Public read for opaque upload ids (no auth). Unguessable UUID stem;
* do not use for sensitive documents without revisiting policy.
*/
export const GET = apiRoute<RouteContext>(
"uploads.byId",
async (_request, context) => {
if (!getUploadRootFromEnv()) {
return serverMisconfigured(
"File uploads are not configured (UPLOAD_ROOT is unset).",
);
}
const { id } = await context.params;
const resolved = await resolveUploadedFileById(id);
if (!resolved) {
return notFound("Upload not found");
}
const body = await readFile(resolved.absolutePath);
return new NextResponse(new Uint8Array(body), {
status: 200,
headers: {
"Content-Type": resolved.contentType,
"Cache-Control": "public, max-age=31536000, immutable",
"X-Content-Type-Options": "nosniff",
},
});
},
);
+111
View File
@@ -0,0 +1,111 @@
import { NextRequest, NextResponse } from "next/server";
import { isDatabaseConfigured } from "../../../lib/server/env";
import {
dbUnavailable,
errorJson,
serverMisconfigured,
unauthorized,
rateLimited,
} from "../../../lib/server/responses";
import { getSessionUser } from "../../../lib/server/session";
import { apiRoute } from "../../../lib/server/apiRoute";
import { rateLimitKey } from "../../../lib/server/rateLimit";
import { saveCreateFlowUpload } from "../../../lib/server/uploads/saveCreateFlowUpload";
import { getUploadRootFromEnv } from "../../../lib/server/uploads/uploadRoot";
import {
CREATE_FLOW_UPLOAD_MAX_BYTES,
type CreateFlowUploadPurpose,
} from "../../../lib/server/uploads/uploadConstants";
function isPurpose(x: string): x is CreateFlowUploadPurpose {
return x === "communityAvatar" || x === "customMethodAttachment";
}
export const POST = apiRoute("uploads.post", async (request: NextRequest) => {
if (!isDatabaseConfigured()) {
return dbUnavailable();
}
const user = await getSessionUser();
if (!user) {
return unauthorized();
}
if (!getUploadRootFromEnv()) {
return serverMisconfigured(
"File uploads are not configured (UPLOAD_ROOT is unset).",
);
}
const rl = rateLimitKey(`upload:${user.id}`, 5_000);
if (rl.ok === false) {
return rateLimited(rl.retryAfterMs);
}
let formData: FormData;
try {
formData = await request.formData();
} catch {
return errorJson(
"payload_too_large",
"Upload body is too large or malformed.",
413,
);
}
const purposeRaw = formData.get("purpose");
const file = formData.get("file");
if (typeof purposeRaw !== "string" || !isPurpose(purposeRaw)) {
return errorJson(
"validation_error",
"Invalid or missing `purpose` (expected communityAvatar | customMethodAttachment).",
400,
);
}
if (!(file instanceof File)) {
return errorJson(
"validation_error",
"Missing `file` field (multipart file).",
400,
);
}
if (file.size > CREATE_FLOW_UPLOAD_MAX_BYTES) {
return errorJson(
"payload_too_large",
`File exceeds maximum allowed size (${CREATE_FLOW_UPLOAD_MAX_BYTES} bytes).`,
413,
);
}
const buf = Buffer.from(await file.arrayBuffer());
const mimeType = file.type || "application/octet-stream";
const saved = await saveCreateFlowUpload({
purpose: purposeRaw,
buffer: buf,
mimeType,
});
if ("error" in saved) {
if (saved.error === "misconfigured") {
return serverMisconfigured(
"File uploads are not configured (UPLOAD_ROOT is unset).",
);
}
return errorJson(
"validation_error",
"File type or size is not allowed for this upload purpose.",
400,
);
}
return NextResponse.json({
url: saved.urlPath,
id: saved.id,
mimeType: saved.mimeType,
byteLength: saved.byteLength,
});
});
@@ -1,5 +1,15 @@
import type { IconName } from "../../asset/icon";
export type AddCustomFieldType = "text" | "badges" | "upload" | "proportion";
/** Icons for each addable field type (wizard + summaries). */
export const ADD_CUSTOM_FIELD_TYPE_ICONS = {
text: "text_block",
badges: "tags",
upload: "image",
proportion: "number",
} as const satisfies Record<AddCustomFieldType, IconName>;
export interface AddCustomFieldProps {
/** When true, show the 2×2 field-type grid; when false, show the primary CTA. */
active: boolean;
@@ -1,20 +1,14 @@
"use client";
import { memo } from "react";
import Icon, { type IconName } from "../../asset/icon";
import Icon from "../../asset/icon";
import Vertical from "../../buttons/Vertical";
import type {
AddCustomFieldType,
AddCustomFieldViewProps,
import {
ADD_CUSTOM_FIELD_TYPE_ICONS,
type AddCustomFieldType,
type AddCustomFieldViewProps,
} from "./AddCustomField.types";
const FIELD_TYPE_ICONS: Record<AddCustomFieldType, IconName> = {
text: "text_block",
badges: "tags", // tag / chip list (filename: tags.svg)
upload: "image", // image / file upload (filename: image.svg)
proportion: "number", // numeric / proportion field (closest asset: number.svg)
};
function FieldTypeButton({
type,
label,
@@ -32,7 +26,7 @@ function FieldTypeButton({
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center">
<Icon
name={FIELD_TYPE_ICONS[type]}
name={ADD_CUSTOM_FIELD_TYPE_ICONS[type]}
size={32}
className="text-[var(--color-content-default-brand-primary,#fefcc9)]"
/>