diff --git a/app/(app)/create/hooks/useMethodCardDeckOrdering.ts b/app/(app)/create/hooks/useMethodCardDeckOrdering.ts index af2242c..bf20b0b 100644 --- a/app/(app)/create/hooks/useMethodCardDeckOrdering.ts +++ b/app/(app)/create/hooks/useMethodCardDeckOrdering.ts @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo } from "react"; +import { useMemo } from "react"; import { useCreateFlow } from "../context/CreateFlowContext"; import type { CreateFlowMethodCardFacetSection } from "../types"; import { @@ -18,14 +18,17 @@ type MethodEntry = { id: string; label: string; supportText: string }; /** * Applies score ranking, compact-slot rules, optional “pinned selection” showcase - * order, and clears the pin draft flag when a section loses all selections. + * order. Rows stay pinned across navigation while `methodSectionsPinCommitted` is true + * and the section still has selections; we do **not** clear the flag when selection + * arrays briefly go empty during draft hydration (`replaceState` / merge flashes) — + * display order already ignores the pin until `pinActive` is true again. */ export function useMethodCardDeckOrdering( section: RecommendationSection, methods: readonly MethodEntry[], selectedIds: readonly string[], ) { - const { state, setMethodSectionsPinCommitted } = useCreateFlow(); + const { state } = useCreateFlow(); const facetKey = section as CreateFlowMethodCardFacetSection; const { scoresBySlug, hasAnyFacets } = useFacetRecommendations(section); @@ -33,17 +36,6 @@ export function useMethodCardDeckOrdering( state.methodSectionsPinCommitted?.[facetKey] === true; const pinActive = Boolean(pinStored && selectedIds.length > 0); - useEffect(() => { - if (selectedIds.length > 0) return; - if (!pinStored) return; - setMethodSectionsPinCommitted(facetKey, false); - }, [ - facetKey, - pinStored, - selectedIds.length, - setMethodSectionsPinCommitted, - ]); - const rankedMethods = useMemo( () => rankMethodsByScore(methods, scoresBySlug), [methods, scoresBySlug], diff --git a/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx b/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx index 0b3e624..deab1da 100644 --- a/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx +++ b/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx @@ -6,6 +6,7 @@ import type { ChipOption } from "../../../../components/controls/MultiSelect/Mul import Create from "../../../../components/modals/Create"; import ContentLockup from "../../../../components/type/ContentLockup"; import { useMessages } from "../../../../contexts/MessagesContext"; +import { buildCoreValueChipOptionsFromDraft } from "../../../../../lib/create/coreValueChipOptionsFromDraft"; import { useCreateFlow } from "../../context/CreateFlowContext"; import type { CommunityStructureChipSnapshotRow, @@ -57,31 +58,6 @@ function normalizeCoreValuePresets( }); } -function chipRowsFromPresets(presets: readonly CoreValuePreset[]): ChipOption[] { - return presets.map((row, i) => ({ - id: String(i + 1), - label: row.label, - state: "unselected" as const, - })); -} - -function applySavedSelection( - options: ChipOption[], - saved: string[] | undefined, -): ChipOption[] { - const selected = new Set(saved ?? []); - return options.map((opt) => - opt.state === "custom" - ? opt - : { - ...opt, - state: selected.has(opt.id) - ? ("selected" as const) - : ("unselected" as const), - }, - ); -} - function selectedIdsFromOptions(options: ChipOption[]): string[] { return options .filter((o) => o.state === "selected") @@ -98,19 +74,6 @@ function chipOptionsToSnapshotRows( })); } -function snapshotRowsToChipOptions( - rows: CommunityStructureChipSnapshotRow[] | undefined, -): ChipOption[] | null { - if (!Array.isArray(rows) || rows.length === 0) return null; - return rows.map((r) => ({ - id: r.id, - label: r.label, - ...(r.state !== undefined - ? { state: r.state as ChipOption["state"] } - : {}), - })); -} - const EMPTY_DETAIL: CoreValueDetailEntry = { meaning: "", signals: "" }; /** Create Custom — Core Values (Figma `20264:68378`). Up to five selections; preset list + custom chips. */ @@ -124,15 +87,12 @@ export function CoreValuesSelectScreen() { const { markCreateFlowInteraction, updateState, state } = useCreateFlow(); - const [coreValueOptions, setCoreValueOptions] = useState( - () => { - const fromSnap = snapshotRowsToChipOptions(state.coreValuesChipsSnapshot); - if (fromSnap) return fromSnap; - return applySavedSelection( - chipRowsFromPresets(presets), - state.selectedCoreValueIds, - ); - }, + const [coreValueOptions, setCoreValueOptions] = useState(() => + buildCoreValueChipOptionsFromDraft( + presets, + state.coreValuesChipsSnapshot, + state.selectedCoreValueIds, + ), ); const [activeModalChipId, setActiveModalChipId] = useState( @@ -142,15 +102,18 @@ export function CoreValuesSelectScreen() { const [draft, setDraft] = useState(EMPTY_DETAIL); useEffect(() => { - const fromSnap = snapshotRowsToChipOptions(state.coreValuesChipsSnapshot); - if (fromSnap) { - setCoreValueOptions(fromSnap); - return; - } - setCoreValueOptions((prev) => - applySavedSelection(prev, state.selectedCoreValueIds), + setCoreValueOptions( + buildCoreValueChipOptionsFromDraft( + presets, + state.coreValuesChipsSnapshot, + state.selectedCoreValueIds, + ), ); - }, [state.coreValuesChipsSnapshot, state.selectedCoreValueIds]); + }, [ + presets, + state.coreValuesChipsSnapshot, + state.selectedCoreValueIds, + ]); /** Sync chips to create-flow draft. Never call `updateState` from inside a `setCoreValueOptions` updater — defer with `queueMicrotask`. */ const syncCoreValuesToDraft = useCallback( diff --git a/app/(app)/profile/_components/ProfilePage.view.tsx b/app/(app)/profile/_components/ProfilePage.view.tsx index 1906b5d..b485643 100644 --- a/app/(app)/profile/_components/ProfilePage.view.tsx +++ b/app/(app)/profile/_components/ProfilePage.view.tsx @@ -83,13 +83,6 @@ export type ProfilePageViewProps = { const profileSectionHeadingClass = "font-bricolage text-base font-bold leading-[22px] text-[var(--color-content-default-primary)] md:font-inter md:text-xl md:font-bold md:leading-7 xl:font-bricolage-grotesque xl:font-bold xl:text-[28px] xl:leading-9"; -/** - * Sticky `top` for page content below the product {@link Top} (standard variant). - * Must match `Top.view.tsx`: nav `h` 40px → `lg` 84px → `xl` 88px, plus `header` `border-b` (+1px). - */ -const stickyBelowTopTopClass = - "top-[41px] lg:top-[85px] xl:top-[89px]"; - export type ProfilePageSignedOutViewProps = { onSignIn: () => void; /** `min-width: 1024px` — welcome uses {@link HeaderLockup} `L` per Figma `21962:17220`. */ @@ -113,8 +106,8 @@ export function ProfilePageSignedOutView({
{profileLgUp ? ( @@ -266,8 +259,8 @@ export function ProfilePageView({
{profileLgUp ? ( diff --git a/app/components/controls/MultiSelect/MultiSelect.view.tsx b/app/components/controls/MultiSelect/MultiSelect.view.tsx index 46f8004..391ffb5 100644 --- a/app/components/controls/MultiSelect/MultiSelect.view.tsx +++ b/app/components/controls/MultiSelect/MultiSelect.view.tsx @@ -89,9 +89,9 @@ function MultiSelectView({ className={ !addButtonText ? // Circular button with border (Rule style) - `bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))] border-[1.25px] ${isInverse ? "border-[var(--color-border-default-primary,#141414)]" : "border-[var(--color-border-default-tertiary,#464646)]"} border-solid flex items-center justify-center ${isSmall ? "size-[30px]" : "size-[40px]"} rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity` + `cursor-pointer bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))] border-[1.25px] ${isInverse ? "border-[var(--color-border-default-primary,#141414)]" : "border-[var(--color-border-default-tertiary,#464646)]"} border-solid flex items-center justify-center ${isSmall ? "size-[30px]" : "size-[40px]"} rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity` : // Text add control (default palette: white label + brand “+”; inverse: inverse primary for both) - `flex items-center justify-center overflow-hidden rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity ${ + `cursor-pointer flex items-center justify-center overflow-hidden rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity ${ isSmall ? "gap-[var(--measures-spacing-100,4px)] px-[var(--measures-spacing-300,12px)] py-[var(--measures-spacing-200,8px)]" : "gap-[var(--measures-spacing-150,6px)] px-[var(--space-400,16px)] py-[var(--measures-spacing-300,12px)]" diff --git a/lib/create/coreValueChipOptionsFromDraft.ts b/lib/create/coreValueChipOptionsFromDraft.ts new file mode 100644 index 0000000..fa89cb3 --- /dev/null +++ b/lib/create/coreValueChipOptionsFromDraft.ts @@ -0,0 +1,96 @@ +import type { ChipOption } from "../../app/components/controls/MultiSelect/MultiSelect.types"; +import type { + CreateFlowState, +} from "../../app/(app)/create/types"; + +type CoreValuePreset = { label: string; meaning: string; signals: string }; + +function chipRowsFromPresets(presets: readonly CoreValuePreset[]): ChipOption[] { + return presets.map((row, i) => ({ + id: String(i + 1), + label: row.label, + state: "unselected" as const, + })); +} + +function applySavedSelectionToPresetsOnly( + options: ChipOption[], + saved: string[] | undefined, +): ChipOption[] { + const selected = new Set(saved ?? []); + return options.map((opt) => + opt.state === "custom" + ? opt + : { + ...opt, + state: selected.has(opt.id) + ? ("selected" as const) + : ("unselected" as const), + }, + ); +} + +/** Valid MultiSelect chip state from snapshot JSON. */ +function normalizeChipState(s: unknown): ChipOption["state"] | undefined { + return s === "selected" || + s === "unselected" || + s === "custom" || + s === "error" + ? (s as ChipOption["state"]) + : undefined; +} + +/** + * Build the core-values MultiSelect chip list shown in-create. + * + * The published-rule hydration path writes only **selected** rows into + * `coreValuesChipsSnapshot`. Editing must still show every preset ("1"..N) plus + * custom ids from the snapshot. Card-deck pin / ordering features do not apply + * here. + */ +export function buildCoreValueChipOptionsFromDraft( + presets: readonly CoreValuePreset[], + snapshot: CreateFlowState["coreValuesChipsSnapshot"], + selectedCoreValueIds: CreateFlowState["selectedCoreValueIds"], +): ChipOption[] { + const presetBase = chipRowsFromPresets(presets); + const presetIdSet = new Set(presetBase.map((p) => p.id)); + const selected = new Set(selectedCoreValueIds ?? []); + + if (!snapshot?.length) { + return applySavedSelectionToPresetsOnly(presetBase, selectedCoreValueIds); + } + + const snapById = new Map(snapshot.map((r) => [r.id, r] as const)); + + const presetRows: ChipOption[] = presetBase.map((opt) => { + const row = snapById.get(opt.id); + if (!row) { + return { + ...opt, + state: selected.has(opt.id) ? ("selected" as const) : ("unselected" as const), + }; + } + const normalized = normalizeChipState(row.state); + const effectiveState = + normalized ?? + (selected.has(opt.id) ? ("selected" as const) : ("unselected" as const)); + const label = + typeof row.label === "string" && row.label.trim().length > 0 + ? row.label + : opt.label; + return { ...opt, label, state: effectiveState }; + }); + + const customRows: ChipOption[] = snapshot + .filter((r) => !presetIdSet.has(r.id)) + .map((r) => ({ + id: r.id, + label: r.label, + state: + normalizeChipState(r.state) ?? + (selected.has(r.id) ? ("selected" as const) : ("unselected" as const)), + })); + + return [...presetRows, ...customRows]; +} diff --git a/tests/unit/coreValueChipOptionsFromDraft.test.ts b/tests/unit/coreValueChipOptionsFromDraft.test.ts new file mode 100644 index 0000000..d8ff03a --- /dev/null +++ b/tests/unit/coreValueChipOptionsFromDraft.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; + +import type { CommunityStructureChipSnapshotRow } from "../../app/(app)/create/types"; +import { buildCoreValueChipOptionsFromDraft } from "../../lib/create/coreValueChipOptionsFromDraft"; + +const PRESETS = [ + { label: "A", meaning: "am", signals: "as" }, + { label: "B", meaning: "bm", signals: "bs" }, + { label: "C", meaning: "cm", signals: "cs" }, +] as const; + +describe("buildCoreValueChipOptionsFromDraft", () => { + it("shows every preset plus selection when snapshot only has selected rows (edit / hydrate)", () => { + const snapshot: CommunityStructureChipSnapshotRow[] = [ + { id: "1", label: "A", state: "selected" }, + { id: "3", label: "C", state: "selected" }, + ]; + const out = buildCoreValueChipOptionsFromDraft( + PRESETS, + snapshot, + ["1", "3"], + ); + expect(out.map((r) => r.id)).toEqual(["1", "2", "3"]); + expect(out.map((r) => r.label)).toEqual(["A", "B", "C"]); + expect(out.map((r) => r.state)).toEqual(["selected", "unselected", "selected"]); + }); + + it("merges preset labels/state from snapshot and appends non-preset (custom) rows", () => { + const snapshot: CommunityStructureChipSnapshotRow[] = [ + { id: "1", label: "Edited A", state: "unselected" }, + { id: "uuid-1", label: "Custom", state: "selected" }, + ]; + const out = buildCoreValueChipOptionsFromDraft(PRESETS, snapshot, []); + expect(out).toHaveLength(4); + expect(out.filter((r) => r.id === "1")[0]?.label).toBe("Edited A"); + expect(out.filter((r) => r.id === "uuid-1")[0]).toMatchObject({ + id: "uuid-1", + label: "Custom", + state: "selected", + }); + }); + + it("uses selectedCoreValueIds when snapshot row lacks a valid state string", () => { + const snapshot: CommunityStructureChipSnapshotRow[] = [ + { id: "2", label: "B", state: "garbage" }, + ]; + const out = buildCoreValueChipOptionsFromDraft( + PRESETS, + snapshot, + ["2"], + ); + expect(out.map((r) => ({ id: r.id, state: r.state }))).toContainEqual({ + id: "2", + state: "selected", + }); + }); + + it("with empty snapshot derives from presets and selected ids only", () => { + const out = buildCoreValueChipOptionsFromDraft( + PRESETS, + undefined, + ["2"], + ); + expect(out.map((r) => r.id)).toEqual(["1", "2", "3"]); + expect(out.filter((r) => r.state === "selected").map((r) => r.id)).toEqual( + ["2"], + ); + }); +});