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
@@ -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>
);
}