Walk Create Community through the Figma screens and restore a chosen logo after reload, so a guest can leave without saving until they reach the email step.
This commit is contained in:
1 parent
ca1f58a50c
commit
2348a9e94f
61 files changed
+1110
-421
No files matched your search
@@ -116,6 +116,7 @@ export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode {
|
||||
stateField="title"
|
||||
maxLength={48}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
case "community-structure":
|
||||
@@ -128,6 +129,8 @@ export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode {
|
||||
maxLength={200}
|
||||
mainAlign="center"
|
||||
multiline
|
||||
locationField
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
case "community-size":
|
||||
@@ -144,6 +147,7 @@ export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode {
|
||||
inputType="email"
|
||||
showCharacterCount={false}
|
||||
headerJustification="center"
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
case "review":
|
||||
|
||||
@@ -16,18 +16,9 @@ export function InformationalScreen() {
|
||||
const copy = useMessages().create.community.informational;
|
||||
|
||||
const items = [
|
||||
{
|
||||
title: copy.steps["0"].title,
|
||||
description: copy.steps["0"].description,
|
||||
},
|
||||
{
|
||||
title: copy.steps["1"].title,
|
||||
description: copy.steps["1"].description,
|
||||
},
|
||||
{
|
||||
title: copy.steps["2"].title,
|
||||
description: copy.steps["2"].description,
|
||||
},
|
||||
{ title: copy.steps["0"].title },
|
||||
{ title: copy.steps["1"].title },
|
||||
{ title: copy.steps["2"].title },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Rule from "../../../../components/cards/Rule";
|
||||
import Button from "../../../../components/buttons/Button";
|
||||
import { useTranslation } from "../../../../contexts/MessagesContext";
|
||||
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
|
||||
import { useMessages, useTranslation } from "../../../../contexts/MessagesContext";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowLgUp } from "../../hooks/useCreateFlowLgUp";
|
||||
@@ -19,11 +20,67 @@ import {
|
||||
} from "../../../../../lib/assetUtils";
|
||||
import { methodSectionsPinsForHydratedSelections } from "../../../../../lib/create/publishedDocumentToCreateFlowState";
|
||||
import { CREATE_ROUTES, createFlowStepPath } from "../../utils/createFlowPaths";
|
||||
import { CREATE_FLOW_REVIEW_RETURN_QUERY_KEY } from "../../utils/flowSteps";
|
||||
import type { CreateFlowStep } from "../../types";
|
||||
|
||||
function firstId(ids: readonly string[] | undefined): string | undefined {
|
||||
return ids?.find((id) => id.length > 0);
|
||||
}
|
||||
|
||||
function chipLabel(
|
||||
id: string | undefined,
|
||||
rows: readonly { id: string; label: string }[] | undefined,
|
||||
snapshots: readonly { id: string; label: string }[] | undefined,
|
||||
): string | undefined {
|
||||
if (!id) return undefined;
|
||||
const fromSnap = snapshots?.find((row) => row.id === id)?.label.trim();
|
||||
if (fromSnap) return fromSnap;
|
||||
return rows?.find((row) => row.id === id)?.label;
|
||||
}
|
||||
|
||||
function reviewSizeValue(
|
||||
id: string | undefined,
|
||||
sizes: readonly { id: string; label: string }[],
|
||||
peopleTemplate: string,
|
||||
): string | undefined {
|
||||
if (!id) return undefined;
|
||||
const row = sizes.find((item) => item.id === id);
|
||||
if (!row) return undefined;
|
||||
if (id === "oneMember") return row.label;
|
||||
return peopleTemplate.replace("{label}", row.label);
|
||||
}
|
||||
|
||||
function CommunityReviewAnswerRow({
|
||||
label,
|
||||
value,
|
||||
editLabel,
|
||||
onEdit,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
editLabel: string;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex w-full items-start justify-between gap-[var(--space-400)] py-[var(--space-300)]">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-[var(--space-100)]">
|
||||
<p className="text-small-label text-[color:var(--color-content-default-secondary)]">
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-medium-paragraph text-[color:var(--color-content-default-primary)]">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
<InlineTextButton onClick={onEdit}>{editLabel}</InlineTextButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Create Community review — Figma `19706:12135` (`/create/review`; two columns from `lg:`; column caps in `createFlowLayoutTokens`). */
|
||||
export function CommunityReviewScreen() {
|
||||
const router = useRouter();
|
||||
const lgUp = useCreateFlowLgUp();
|
||||
const messages = useMessages();
|
||||
const t = useTranslation("create.community.review");
|
||||
const { state, updateState } = useCreateFlow();
|
||||
/**
|
||||
@@ -97,6 +154,32 @@ export function CommunityReviewScreen() {
|
||||
? state.communityAvatarUrl.trim()
|
||||
: null;
|
||||
|
||||
const structure = messages.create.community.communityStructure;
|
||||
const sizes = messages.create.community.communitySize.communitySizes;
|
||||
const kindValue = chipLabel(
|
||||
firstId(state.selectedOrganizationTypeIds),
|
||||
structure.organizationTypes,
|
||||
state.communityStructureChipSnapshots?.organizationTypes,
|
||||
);
|
||||
const sizeValue = reviewSizeValue(
|
||||
firstId(state.selectedCommunitySizeIds),
|
||||
sizes,
|
||||
t("rows.sizePeople"),
|
||||
);
|
||||
const maturityValue = chipLabel(
|
||||
firstId(state.selectedMaturityIds),
|
||||
structure.maturityOptions,
|
||||
state.communityStructureChipSnapshots?.maturity,
|
||||
);
|
||||
|
||||
const goEdit = (step: CreateFlowStep) => {
|
||||
router.push(
|
||||
createFlowStepPath(step, {
|
||||
[CREATE_FLOW_REVIEW_RETURN_QUERY_KEY]: "review",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
if (state.pendingTemplateAction || !reviewReady) {
|
||||
return null;
|
||||
}
|
||||
@@ -143,7 +226,7 @@ export function CommunityReviewScreen() {
|
||||
description={t("header.description")}
|
||||
/>
|
||||
</div>
|
||||
<div className={CREATE_FLOW_MD_UP_GRID_CELL_CLASS}>
|
||||
<div className={`flex flex-col ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}>
|
||||
<Rule
|
||||
title={cardTitle}
|
||||
description={cardDescription}
|
||||
@@ -156,6 +239,32 @@ export function CommunityReviewScreen() {
|
||||
logoAlt={cardTitle}
|
||||
className="rounded-[24px]"
|
||||
/>
|
||||
<div className="mt-[var(--space-600)] flex w-full flex-col divide-y divide-[var(--color-border-default-secondary)]">
|
||||
{kindValue ? (
|
||||
<CommunityReviewAnswerRow
|
||||
label={t("rows.kind")}
|
||||
value={kindValue}
|
||||
editLabel={t("rows.edit")}
|
||||
onEdit={() => goEdit("community-structure")}
|
||||
/>
|
||||
) : null}
|
||||
{sizeValue ? (
|
||||
<CommunityReviewAnswerRow
|
||||
label={t("rows.size")}
|
||||
value={sizeValue}
|
||||
editLabel={t("rows.edit")}
|
||||
onEdit={() => goEdit("community-size")}
|
||||
/>
|
||||
) : null}
|
||||
{maturityValue ? (
|
||||
<CommunityReviewAnswerRow
|
||||
label={t("rows.maturity")}
|
||||
value={maturityValue}
|
||||
editLabel={t("rows.edit")}
|
||||
onEdit={() => goEdit("community-structure")}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CreateFlowStepShell>
|
||||
|
||||
@@ -5,6 +5,7 @@ import MultiSelect from "../../../../components/controls/MultiSelect";
|
||||
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowContinueAttempt } from "../../context/CreateFlowContinueAttemptContext";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
|
||||
|
||||
@@ -48,6 +49,7 @@ export function CommunitySizeSelectScreen() {
|
||||
const m = useMessages();
|
||||
const cs = m.create.community.communitySize;
|
||||
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
|
||||
const { attempted } = useCreateFlowContinueAttempt();
|
||||
|
||||
const [communitySizeOptions, setCommunitySizeOptions] = useState<
|
||||
ChipOption[]
|
||||
@@ -87,6 +89,9 @@ export function CommunitySizeSelectScreen() {
|
||||
persistSelection(withSoleSelection(communitySizeOptions, nextId));
|
||||
};
|
||||
|
||||
const showSizeError =
|
||||
attempted && soleSizeId(state.selectedCommunitySizeIds) == null;
|
||||
|
||||
const multiSelectBlock = (
|
||||
<MultiSelect
|
||||
formHeader={false}
|
||||
@@ -94,6 +99,7 @@ export function CommunitySizeSelectScreen() {
|
||||
options={communitySizeOptions}
|
||||
onChipClick={handleCommunitySizeClick}
|
||||
addButton={false}
|
||||
errorMessage={showSizeError ? cs.pickOneError : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import MultiSelect from "../../../../components/controls/MultiSelect";
|
||||
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowContinueAttempt } from "../../context/CreateFlowContinueAttemptContext";
|
||||
import type { CommunityStructureChipSnapshotRow } from "../../types";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
|
||||
@@ -73,10 +74,29 @@ function applySavedSelection(
|
||||
);
|
||||
}
|
||||
|
||||
function withSoleSelection(
|
||||
options: ChipOption[],
|
||||
selectedId: string | null,
|
||||
): ChipOption[] {
|
||||
return options.map((opt) =>
|
||||
opt.state === "custom"
|
||||
? opt
|
||||
: {
|
||||
...opt,
|
||||
state:
|
||||
opt.id === selectedId
|
||||
? ("selected" as const)
|
||||
: ("unselected" as const),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function selectedIdsFromOptions(options: ChipOption[]): string[] {
|
||||
return options
|
||||
.filter((o) => o.state === "selected")
|
||||
.map((o) => o.id);
|
||||
return options.filter((o) => o.state === "selected").map((o) => o.id);
|
||||
}
|
||||
|
||||
function firstSelectedId(options: ChipOption[]): string | null {
|
||||
return selectedIdsFromOptions(options)[0] ?? null;
|
||||
}
|
||||
|
||||
function chipOptionsToSnapshotRows(
|
||||
@@ -103,22 +123,46 @@ function snapshotRowsToChipOptions(
|
||||
}));
|
||||
}
|
||||
|
||||
/** Create Community step 3 — Figma `20094:18244` (responsive grid + column caps via `createFlowLayoutTokens`). */
|
||||
function organizationIdsForPersist(
|
||||
mainId: string | null,
|
||||
otherIds: readonly string[],
|
||||
): string[] {
|
||||
if (!mainId) return [];
|
||||
return [mainId, ...otherIds.filter((id) => id !== mainId)];
|
||||
}
|
||||
|
||||
/** Create Community type step — Figma `25224:105767`. */
|
||||
export function CommunityStructureSelectScreen() {
|
||||
const m = useMessages();
|
||||
const cs = m.create.community.communityStructure;
|
||||
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
|
||||
const { attempted } = useCreateFlowContinueAttempt();
|
||||
|
||||
const [organizationTypeOptions, setOrganizationTypeOptions] = useState<
|
||||
ChipOption[]
|
||||
>(() => {
|
||||
const savedKindIds = state.selectedOrganizationTypeIds ?? [];
|
||||
const savedMainIndex = savedKindIds.findIndex((id) => id.length > 0);
|
||||
const savedMainId =
|
||||
savedMainIndex >= 0 ? savedKindIds[savedMainIndex]! : null;
|
||||
const savedOtherIds =
|
||||
savedMainIndex >= 0 ? savedKindIds.slice(savedMainIndex + 1) : [];
|
||||
|
||||
const [mainKindOptions, setMainKindOptions] = useState<ChipOption[]>(() => {
|
||||
const fromSnap = snapshotRowsToChipOptions(
|
||||
state.communityStructureChipSnapshots?.organizationTypes,
|
||||
);
|
||||
return withSoleSelection(
|
||||
fromSnap ?? chipRowsFromLabels(cs.organizationTypes),
|
||||
savedMainId,
|
||||
);
|
||||
});
|
||||
|
||||
const [otherKindOptions, setOtherKindOptions] = useState<ChipOption[]>(() => {
|
||||
const fromSnap = snapshotRowsToChipOptions(
|
||||
state.communityStructureChipSnapshots?.otherOrganizationTypes,
|
||||
);
|
||||
if (fromSnap) return fromSnap;
|
||||
return applySavedSelection(
|
||||
chipRowsFromLabels(cs.organizationTypes),
|
||||
state.selectedOrganizationTypeIds,
|
||||
savedOtherIds,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -137,29 +181,48 @@ export function CommunityStructureSelectScreen() {
|
||||
const fromSnap = snapshotRowsToChipOptions(
|
||||
state.communityStructureChipSnapshots?.maturity,
|
||||
);
|
||||
if (fromSnap) return fromSnap;
|
||||
return applySavedSelection(
|
||||
chipRowsFromLabels(cs.maturityOptions),
|
||||
state.selectedMaturityIds,
|
||||
);
|
||||
const base = fromSnap ?? chipRowsFromLabels(cs.maturityOptions);
|
||||
const maturityId =
|
||||
(state.selectedMaturityIds ?? []).find((id) => id.length > 0) ?? null;
|
||||
return withSoleSelection(base, maturityId);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fromSnap = snapshotRowsToChipOptions(
|
||||
state.communityStructureChipSnapshots?.organizationTypes,
|
||||
);
|
||||
if (fromSnap) {
|
||||
setOrganizationTypeOptions(fromSnap);
|
||||
return;
|
||||
}
|
||||
setOrganizationTypeOptions((prev) =>
|
||||
applySavedSelection(prev, state.selectedOrganizationTypeIds),
|
||||
const mainId =
|
||||
(state.selectedOrganizationTypeIds ?? []).find((id) => id.length > 0) ??
|
||||
null;
|
||||
setMainKindOptions(
|
||||
withSoleSelection(
|
||||
fromSnap ?? chipRowsFromLabels(cs.organizationTypes),
|
||||
mainId,
|
||||
),
|
||||
);
|
||||
}, [
|
||||
cs.organizationTypes,
|
||||
state.communityStructureChipSnapshots?.organizationTypes,
|
||||
state.selectedOrganizationTypeIds,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const fromSnap = snapshotRowsToChipOptions(
|
||||
state.communityStructureChipSnapshots?.otherOrganizationTypes,
|
||||
);
|
||||
const ids = state.selectedOrganizationTypeIds ?? [];
|
||||
const mainId = ids.find((id) => id.length > 0);
|
||||
const otherIds = mainId ? ids.slice(ids.indexOf(mainId) + 1) : [];
|
||||
if (fromSnap) {
|
||||
setOtherKindOptions(fromSnap);
|
||||
return;
|
||||
}
|
||||
setOtherKindOptions((prev) => applySavedSelection(prev, otherIds));
|
||||
}, [
|
||||
state.communityStructureChipSnapshots?.otherOrganizationTypes,
|
||||
state.selectedOrganizationTypeIds,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const fromSnap = snapshotRowsToChipOptions(
|
||||
state.communityStructureChipSnapshots?.scale,
|
||||
@@ -178,53 +241,50 @@ export function CommunityStructureSelectScreen() {
|
||||
const fromSnap = snapshotRowsToChipOptions(
|
||||
state.communityStructureChipSnapshots?.maturity,
|
||||
);
|
||||
const maturityId =
|
||||
(state.selectedMaturityIds ?? []).find((id) => id.length > 0) ?? null;
|
||||
if (fromSnap) {
|
||||
setMaturityOptions(fromSnap);
|
||||
setMaturityOptions(withSoleSelection(fromSnap, maturityId));
|
||||
return;
|
||||
}
|
||||
setMaturityOptions((prev) =>
|
||||
applySavedSelection(prev, state.selectedMaturityIds),
|
||||
);
|
||||
setMaturityOptions((prev) => withSoleSelection(prev, maturityId));
|
||||
}, [
|
||||
state.communityStructureChipSnapshots?.maturity,
|
||||
state.selectedMaturityIds,
|
||||
]);
|
||||
|
||||
const organizationCustomHandlers = useMemo(
|
||||
const mainKindCustomHandlers = useMemo(
|
||||
() =>
|
||||
createListCustomHandlers(
|
||||
setOrganizationTypeOptions,
|
||||
setMainKindOptions,
|
||||
"unselected",
|
||||
markCreateFlowInteraction,
|
||||
),
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
const scaleCustomHandlers = useMemo(
|
||||
const otherKindCustomHandlers = useMemo(
|
||||
() =>
|
||||
createListCustomHandlers(
|
||||
setScaleOptions,
|
||||
"unselected",
|
||||
markCreateFlowInteraction,
|
||||
),
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
const maturityCustomHandlers = useMemo(
|
||||
() =>
|
||||
createListCustomHandlers(
|
||||
setMaturityOptions,
|
||||
setOtherKindOptions,
|
||||
"unselected",
|
||||
markCreateFlowInteraction,
|
||||
),
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const persistOrg = (next: ChipOption[]) => {
|
||||
const persistKinds = (main: ChipOption[], others: ChipOption[]) => {
|
||||
markCreateFlowInteraction();
|
||||
setOrganizationTypeOptions(next);
|
||||
setMainKindOptions(main);
|
||||
setOtherKindOptions(others);
|
||||
const mainId = firstSelectedId(main);
|
||||
updateState({
|
||||
selectedOrganizationTypeIds: selectedIdsFromOptions(next),
|
||||
selectedOrganizationTypeIds: organizationIdsForPersist(
|
||||
mainId,
|
||||
selectedIdsFromOptions(others),
|
||||
),
|
||||
communityStructureChipSnapshots: {
|
||||
organizationTypes: chipOptionsToSnapshotRows(next),
|
||||
organizationTypes: chipOptionsToSnapshotRows(main),
|
||||
otherOrganizationTypes: chipOptionsToSnapshotRows(others),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -251,9 +311,16 @@ export function CommunityStructureSelectScreen() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleOrganizationTypeClick = (chipId: string) => {
|
||||
persistOrg(
|
||||
organizationTypeOptions.map((opt) =>
|
||||
const handleMainKindClick = (chipId: string) => {
|
||||
const current = firstSelectedId(mainKindOptions);
|
||||
const nextId = current === chipId ? null : chipId;
|
||||
persistKinds(withSoleSelection(mainKindOptions, nextId), otherKindOptions);
|
||||
};
|
||||
|
||||
const handleOtherKindClick = (chipId: string) => {
|
||||
persistKinds(
|
||||
mainKindOptions,
|
||||
otherKindOptions.map((opt) =>
|
||||
opt.id === chipId
|
||||
? {
|
||||
...opt,
|
||||
@@ -284,49 +351,49 @@ export function CommunityStructureSelectScreen() {
|
||||
};
|
||||
|
||||
const handleMaturityClick = (chipId: string) => {
|
||||
persistMaturity(
|
||||
maturityOptions.map((opt) =>
|
||||
opt.id === chipId
|
||||
? {
|
||||
...opt,
|
||||
state:
|
||||
opt.state === "selected"
|
||||
? ("unselected" as const)
|
||||
: ("selected" as const),
|
||||
}
|
||||
: opt,
|
||||
),
|
||||
);
|
||||
const current = firstSelectedId(maturityOptions);
|
||||
const nextId = current === chipId ? null : chipId;
|
||||
persistMaturity(withSoleSelection(maturityOptions, nextId));
|
||||
};
|
||||
|
||||
const showMainError = attempted && firstSelectedId(mainKindOptions) == null;
|
||||
const showMaturityError =
|
||||
attempted && firstSelectedId(maturityOptions) == null;
|
||||
|
||||
const multiSelectBlock = (
|
||||
<>
|
||||
<MultiSelect
|
||||
label={cs.organizationMultiSelect.label}
|
||||
label={cs.mainKindMultiSelect.label}
|
||||
helperText={cs.mainKindMultiSelect.helperText}
|
||||
size="s"
|
||||
options={organizationTypeOptions}
|
||||
onChipClick={handleOrganizationTypeClick}
|
||||
{...organizationCustomHandlers}
|
||||
options={mainKindOptions}
|
||||
onChipClick={handleMainKindClick}
|
||||
{...mainKindCustomHandlers}
|
||||
addButton
|
||||
errorMessage={showMainError ? cs.pickOneError : undefined}
|
||||
/>
|
||||
<MultiSelect
|
||||
label={cs.otherKindMultiSelect.label}
|
||||
size="s"
|
||||
options={otherKindOptions}
|
||||
onChipClick={handleOtherKindClick}
|
||||
{...otherKindCustomHandlers}
|
||||
addButton
|
||||
addButtonText={cs.organizationMultiSelect.addButtonText}
|
||||
/>
|
||||
<MultiSelect
|
||||
label={cs.scaleMultiSelect.label}
|
||||
size="s"
|
||||
options={scaleOptions}
|
||||
onChipClick={handleScaleClick}
|
||||
{...scaleCustomHandlers}
|
||||
addButton
|
||||
addButtonText={cs.scaleMultiSelect.addButtonText}
|
||||
addButton={false}
|
||||
/>
|
||||
<MultiSelect
|
||||
label={cs.maturityMultiSelect.label}
|
||||
size="s"
|
||||
options={maturityOptions}
|
||||
onChipClick={handleMaturityClick}
|
||||
{...maturityCustomHandlers}
|
||||
addButton
|
||||
addButtonText={cs.maturityMultiSelect.addButtonText}
|
||||
addButton={false}
|
||||
errorMessage={showMaturityError ? cs.pickOneError : undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import TextArea from "../../../../components/controls/TextArea";
|
||||
import type { HeaderLockupJustificationValue } from "../../../../components/type/HeaderLockup/HeaderLockup.types";
|
||||
import { useTranslation } from "../../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowContinueAttempt } from "../../context/CreateFlowContinueAttemptContext";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import {
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
} from "../../components/CreateFlowStepShell";
|
||||
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
|
||||
import { CREATE_FLOW_COMMUNITY_SAVE_FORM_ID } from "../../utils/createFlowPaths";
|
||||
import { isValidCreateFlowSaveEmail } from "../../../../../lib/create/isValidCreateFlowSaveEmail";
|
||||
import type { CreateFlowTextStateField } from "../../types";
|
||||
|
||||
type Props = {
|
||||
@@ -31,6 +33,9 @@ type Props = {
|
||||
headerJustification?: HeaderLockupJustificationValue;
|
||||
/** Top spacing under top chrome (`CreateFlowStepShell` / `CreateFlowContentTopBelowMd`). */
|
||||
contentTopBelowMd?: CreateFlowContentTopBelowMd;
|
||||
autoFocus?: boolean;
|
||||
/** Optional city/region field under the description. */
|
||||
locationField?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -47,35 +52,38 @@ export function CreateFlowTextFieldScreen({
|
||||
showCharacterCount = true,
|
||||
headerJustification = "left",
|
||||
contentTopBelowMd = "space-1400",
|
||||
autoFocus = false,
|
||||
locationField = false,
|
||||
}: Props) {
|
||||
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
|
||||
const { attempted } = useCreateFlowContinueAttempt();
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const t = useTranslation(messageNamespace);
|
||||
|
||||
const readFromState = (): string => {
|
||||
const raw = state[stateField];
|
||||
const readFromState = (field: CreateFlowTextStateField): string => {
|
||||
const raw = state[field];
|
||||
return typeof raw === "string" ? raw : "";
|
||||
};
|
||||
|
||||
const [value, setValue] = useState(() => readFromState());
|
||||
const [value, setValue] = useState(() => readFromState(stateField));
|
||||
const [locationValue, setLocationValue] = useState(() =>
|
||||
readFromState("communityLocation"),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const incoming = readFromState();
|
||||
const incoming = readFromState(stateField);
|
||||
if (incoming.length === 0) return;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync when context hydrates from server/local
|
||||
setValue((prev) => (prev === "" ? incoming : prev));
|
||||
}, [state, stateField]);
|
||||
|
||||
const characterCount = value.length;
|
||||
const hint =
|
||||
showCharacterCount === false
|
||||
? false
|
||||
: t("characterCountTemplate")
|
||||
.replace("{current}", String(characterCount))
|
||||
.replace("{max}", String(maxLength));
|
||||
|
||||
const mainItems =
|
||||
mainAlign === "center" ? "items-center" : "items-start";
|
||||
useEffect(() => {
|
||||
if (!locationField) return;
|
||||
const incoming = readFromState("communityLocation");
|
||||
if (incoming.length === 0) return;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync when context hydrates from server/local
|
||||
setLocationValue((prev) => (prev === "" ? incoming : prev));
|
||||
}, [locationField, state]);
|
||||
|
||||
const isEmail = inputType === "email";
|
||||
const persistValue = (next: string) => {
|
||||
@@ -83,6 +91,42 @@ export function CreateFlowTextFieldScreen({
|
||||
markCreateFlowInteraction();
|
||||
updateState({ [stateField]: next } as Record<string, string>);
|
||||
};
|
||||
const persistLocation = (next: string) => {
|
||||
setLocationValue(next);
|
||||
markCreateFlowInteraction();
|
||||
updateState({ communityLocation: next });
|
||||
};
|
||||
|
||||
const nameError =
|
||||
required &&
|
||||
!isEmail &&
|
||||
!multiline &&
|
||||
attempted &&
|
||||
value.trim().length === 0;
|
||||
const emailError =
|
||||
isEmail &&
|
||||
attempted &&
|
||||
!isValidCreateFlowSaveEmail(value);
|
||||
const showFieldError = nameError || emailError;
|
||||
const errorCopy = nameError
|
||||
? t("errorMessage")
|
||||
: emailError
|
||||
? t("errorMessage")
|
||||
: "";
|
||||
|
||||
const characterCount = value.length;
|
||||
const hint = showFieldError
|
||||
? errorCopy
|
||||
: isEmail
|
||||
? t("skipHint")
|
||||
: showCharacterCount === false
|
||||
? false
|
||||
: t("characterCountTemplate")
|
||||
.replace("{current}", String(characterCount))
|
||||
.replace("{max}", String(maxLength));
|
||||
|
||||
const mainItems =
|
||||
mainAlign === "center" ? "items-center" : "items-start";
|
||||
|
||||
const fieldLabel = t("inputLabel");
|
||||
|
||||
@@ -101,7 +145,7 @@ export function CreateFlowTextFieldScreen({
|
||||
justification={headerJustification}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<div className="flex w-full flex-col gap-[var(--space-800)]">
|
||||
{multiline ? (
|
||||
<TextArea
|
||||
className="!transition-none"
|
||||
@@ -119,6 +163,7 @@ export function CreateFlowTextFieldScreen({
|
||||
textHint={hint}
|
||||
maxLength={maxLength}
|
||||
required={required}
|
||||
autoFocus={autoFocus}
|
||||
rows={4}
|
||||
/>
|
||||
) : (
|
||||
@@ -149,13 +194,33 @@ export function CreateFlowTextFieldScreen({
|
||||
formHeader={false}
|
||||
showHelpIcon={false}
|
||||
textHint={hint}
|
||||
error={showFieldError}
|
||||
maxLength={maxLength}
|
||||
required={required || isEmail}
|
||||
autoComplete={isEmail ? "email" : undefined}
|
||||
inputMode={isEmail ? "email" : undefined}
|
||||
form={isEmail ? CREATE_FLOW_COMMUNITY_SAVE_FORM_ID : undefined}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
)}
|
||||
{locationField ? (
|
||||
<TextInput
|
||||
className="!transition-none"
|
||||
type="text"
|
||||
label={t("locationLabel")}
|
||||
placeholder={t("locationPlaceholder")}
|
||||
value={locationValue}
|
||||
onChange={(e) => persistLocation(e.target.value)}
|
||||
onBlur={() => {
|
||||
const trimmed = locationValue.trim();
|
||||
if (trimmed !== locationValue) persistLocation(trimmed);
|
||||
}}
|
||||
inputSize={mdUp ? "medium" : "small"}
|
||||
formHeader
|
||||
showHelpIcon={false}
|
||||
textHint={t("locationHint")}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CreateFlowStepShell>
|
||||
|
||||
@@ -14,7 +14,6 @@ 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 { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
|
||||
import {
|
||||
createFlowUploadFailureMessageKey,
|
||||
uploadCreateFlowFile,
|
||||
@@ -34,7 +33,7 @@ function hasCommunityAvatarUrl(url: string | undefined): boolean {
|
||||
return typeof url === "string" && url.trim().length > 0;
|
||||
}
|
||||
|
||||
/** Create Community — Figma Flow — Upload `20094:41524`. */
|
||||
/** Create Community — Figma Flow — Upload. */
|
||||
export function CommunityUploadScreen() {
|
||||
const m = useMessages();
|
||||
const u = m.create.community.communityUpload;
|
||||
@@ -44,6 +43,7 @@ export function CommunityUploadScreen() {
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [signedIn, setSignedIn] = useState<boolean | null>(null);
|
||||
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
|
||||
const [localFileName, setLocalFileName] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
@@ -104,6 +104,10 @@ export function CommunityUploadScreen() {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return objectUrl;
|
||||
});
|
||||
setLocalFileName(file.name);
|
||||
if (!state.communityAvatarFileName) {
|
||||
updateState({ communityAvatarFileName: file.name });
|
||||
}
|
||||
} catch {
|
||||
// Missing IndexedDB / quota: leave the picker empty.
|
||||
}
|
||||
@@ -111,11 +115,15 @@ export function CommunityUploadScreen() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [state.communityAvatarFileName, updateState]);
|
||||
|
||||
const resolveUploadError = useCallback(
|
||||
(err: unknown) => tUpload(createFlowUploadFailureMessageKey(err)),
|
||||
[tUpload],
|
||||
(err: unknown) => {
|
||||
const key = createFlowUploadFailureMessageKey(err);
|
||||
if (key === "errors.tooLarge") return u.fileTooLarge;
|
||||
return tUpload(key);
|
||||
},
|
||||
[tUpload, u.fileTooLarge],
|
||||
);
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
@@ -126,7 +134,7 @@ export function CommunityUploadScreen() {
|
||||
markCreateFlowInteraction();
|
||||
setErrorMessage(null);
|
||||
|
||||
if (signedIn) {
|
||||
if (signedIn === true) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const { url } = await uploadCreateFlowFile(file, "communityAvatar");
|
||||
@@ -134,7 +142,11 @@ export function CommunityUploadScreen() {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
});
|
||||
updateState({ communityAvatarUrl: url });
|
||||
setLocalFileName(file.name);
|
||||
updateState({
|
||||
communityAvatarUrl: url,
|
||||
communityAvatarFileName: file.name,
|
||||
});
|
||||
} catch (err) {
|
||||
setErrorMessage(resolveUploadError(err));
|
||||
} finally {
|
||||
@@ -143,26 +155,28 @@ export function CommunityUploadScreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (signedIn === false) {
|
||||
const validated = await validateCreateFlowUploadFile(
|
||||
file,
|
||||
"communityAvatar",
|
||||
const validated = await validateCreateFlowUploadFile(
|
||||
file,
|
||||
"communityAvatar",
|
||||
);
|
||||
if (validated.ok === false) {
|
||||
setErrorMessage(
|
||||
validated.reason === "tooLarge"
|
||||
? u.fileTooLarge
|
||||
: tUpload(messageKeyForCreateFlowUploadReason(validated.reason)),
|
||||
);
|
||||
if (validated.ok === false) {
|
||||
setErrorMessage(
|
||||
tUpload(messageKeyForCreateFlowUploadReason(validated.reason)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await storePendingCommunityAvatarFile(file);
|
||||
setLocalPreviewUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return URL.createObjectURL(file);
|
||||
});
|
||||
} catch {
|
||||
setErrorMessage(tUpload("errors.generic"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await storePendingCommunityAvatarFile(file);
|
||||
setLocalPreviewUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return URL.createObjectURL(file);
|
||||
});
|
||||
setLocalFileName(file.name);
|
||||
updateState({ communityAvatarFileName: file.name });
|
||||
} catch {
|
||||
setErrorMessage(tUpload("errors.generic"));
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -170,28 +184,23 @@ export function CommunityUploadScreen() {
|
||||
resolveUploadError,
|
||||
signedIn,
|
||||
tUpload,
|
||||
u.fileTooLarge,
|
||||
updateState,
|
||||
],
|
||||
);
|
||||
|
||||
const handleClearPendingUpload = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
setErrorMessage(null);
|
||||
setLocalPreviewUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
});
|
||||
if (hasCommunityAvatarUrl(state.communityAvatarUrl)) {
|
||||
updateState({ communityAvatarUrl: undefined });
|
||||
}
|
||||
void clearPendingCommunityAvatarFile();
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}, [markCreateFlowInteraction, state.communityAvatarUrl, updateState]);
|
||||
const handlePickFile = useCallback(() => {
|
||||
if (!busy) fileInputRef.current?.click();
|
||||
}, [busy]);
|
||||
|
||||
const displaySrc = serverAvatarUrl ?? localPreviewUrl;
|
||||
const hasPreview = typeof displaySrc === "string" && displaySrc.length > 0;
|
||||
const fileName =
|
||||
localFileName ??
|
||||
(typeof state.communityAvatarFileName === "string" &&
|
||||
state.communityAvatarFileName.trim().length > 0
|
||||
? state.communityAvatarFileName.trim()
|
||||
: undefined);
|
||||
|
||||
return (
|
||||
<CreateFlowStepShell
|
||||
@@ -214,60 +223,28 @@ export function CommunityUploadScreen() {
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
accept={COMMUNITY_AVATAR_ACCEPT}
|
||||
aria-label={u.hintText}
|
||||
aria-label={hasPreview ? u.replaceButton : 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)}
|
||||
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(--radius-200)] 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>
|
||||
<Upload
|
||||
hasLabel={false}
|
||||
active={!busy}
|
||||
state={
|
||||
errorMessage ? "error" : busy ? "disabled" : "default"
|
||||
}
|
||||
hintText={
|
||||
errorMessage
|
||||
? errorMessage
|
||||
: busy
|
||||
? u.uploadingLabel
|
||||
: u.hintText
|
||||
}
|
||||
buttonLabel={hasPreview ? u.replaceButton : undefined}
|
||||
previewSrc={hasPreview ? displaySrc ?? undefined : undefined}
|
||||
previewAlt={u.previewAlt}
|
||||
fileName={hasPreview ? fileName : undefined}
|
||||
onClick={handlePickFile}
|
||||
/>
|
||||
</div>
|
||||
</CreateFlowStepShell>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user