diff --git a/.github/workflows/code.yml b/.github/workflows/code.yml index 7704696..1c037bd 100644 --- a/.github/workflows/code.yml +++ b/.github/workflows/code.yml @@ -24,6 +24,10 @@ jobs: skip-build: true - command: build skip-build: true + - command: test + skip-build: true + # The cross-repo contract suite requires a sibling server checkout. + args: --exclude "tests/e2e/**" --exclude tests/api/cross-repo-contract.test.js fail-fast: false @@ -42,7 +46,37 @@ jobs: with: skip-yarn-build: ${{ matrix.mode.skip-build }} - - run: yarn ${{ matrix.mode.command }} + - run: yarn ${{ matrix.mode.command }} ${{ matrix.mode.args }} + + playwright: + name: playwright + runs-on: ubuntu-24.04 + timeout-minutes: 25 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + lfs: true + + - uses: ./.github/actions/setup + with: + skip-yarn-build: true + + - name: Install Chromium + run: yarn playwright install --with-deps chromium + + - name: Run proposal journeys + run: yarn test:e2e + + - name: Upload browser artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: proposal-wizard-playwright + path: test-results/playwright + if-no-files-found: ignore check-cache: name: Cache integrity check diff --git a/.gitignore b/.gitignore index c898689..7b48a12 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,8 @@ node_modules # production dist storybook-static +playwright-report +test-results # ts *.tsbuildinfo diff --git a/package.json b/package.json index 196f4e0..9b3fedc 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "build": "rsbuild build", "preview": "rsbuild preview", "test": "RSTEST=1 rstest", + "test:e2e": "playwright test", "typecheck": "tsc -p tsconfig.json --noEmit", "prettier:check": "prettier --list-different .", "prettier:fix": "prettier --write ." @@ -18,6 +19,12 @@ "@polkadot/util": "^14.0.1", "@polkadot/util-crypto": "^14.0.1", "@radix-ui/react-slot": "^1.2.4", + "@tiptap/core": "^3.28.0", + "@tiptap/extension-link": "^3.28.0", + "@tiptap/markdown": "^3.28.0", + "@tiptap/pm": "^3.28.0", + "@tiptap/react": "^3.28.0", + "@tiptap/starter-kit": "^3.28.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.554.0", @@ -29,6 +36,7 @@ "zod": "^4.2.1" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@rsbuild/core": "^1.6.6", "@rsbuild/plugin-react": "^1.4.2", "@rstest/core": "^0.7.9", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..d0f3d4d --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e", + outputDir: "./test-results/playwright", + fullyParallel: false, + retries: 0, + reporter: "line", + use: { + baseURL: "http://127.0.0.1:4189", + colorScheme: "dark", + reducedMotion: "reduce", + screenshot: "only-on-failure", + trace: "retain-on-failure", + }, + webServer: { + command: "yarn dev --host 127.0.0.1 --port 4189", + url: "http://127.0.0.1:4189", + reuseExistingServer: true, + timeout: 120_000, + }, +}); diff --git a/rstest.config.ts b/rstest.config.ts index 9d89faa..9e78036 100644 --- a/rstest.config.ts +++ b/rstest.config.ts @@ -19,6 +19,7 @@ const rstestServerPlugin = (): RsbuildPlugin => ({ }); export default defineConfig({ + exclude: ["tests/e2e/**"], testMatch: ["tests/**/*.test.js"], environment: "node", browser: { enabled: false }, diff --git a/src/app/AppSidebar.css b/src/app/AppSidebar.css index d440bbf..47418e6 100644 --- a/src/app/AppSidebar.css +++ b/src/app/AppSidebar.css @@ -442,7 +442,7 @@ padding: 0.95rem 1rem; border-right: 0; border-bottom: 1px solid var(--sidebar-border); - overflow: visible; + overflow: clip; } .sidebar__brand { diff --git a/src/app/MainAtmosphere.css b/src/app/MainAtmosphere.css index ea9e433..22beed7 100644 --- a/src/app/MainAtmosphere.css +++ b/src/app/MainAtmosphere.css @@ -3,7 +3,7 @@ inset: 0 0 0 var(--app-sidebar-width, 260px); z-index: 0; pointer-events: none; - overflow: hidden; + overflow: clip; contain: paint; } diff --git a/src/components/AttachmentList.tsx b/src/components/AttachmentList.tsx index 45c4767..361de06 100644 --- a/src/components/AttachmentList.tsx +++ b/src/components/AttachmentList.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; +import { safeExternalHref } from "@/lib/safeExternalHref"; import { cn } from "@/lib/utils"; import { Surface } from "@/components/Surface"; @@ -29,34 +30,37 @@ export function AttachmentList({ >

{title}

); diff --git a/src/components/ProposalNarrative.css b/src/components/ProposalNarrative.css new file mode 100644 index 0000000..ef9d0ae --- /dev/null +++ b/src/components/ProposalNarrative.css @@ -0,0 +1,196 @@ +.proposal-narrative { + display: grid; + gap: 0.75rem; + color: var(--muted); + font-size: 0.875rem; + line-height: 1.65; +} + +.proposal-narrative__heading { + margin: 0.5rem 0 0; + color: var(--text); + font-size: 0.9375rem; + font-weight: 700; + line-height: 1.4; +} + +.proposal-authoring__section-heading { + border-bottom: 1px solid color-mix(in srgb, var(--primary) 55%, transparent); + padding-bottom: 0.5rem; + color: var(--text); + font-size: 1rem; + line-height: 1.35; +} + +.proposal-narrative__paragraph { + margin: 0; +} + +.proposal-narrative__list { + margin: 0; + padding-left: 1.25rem; +} + +.proposal-narrative__list li + li { + margin-top: 0.375rem; +} + +ul.proposal-narrative__list { + list-style: disc outside; +} + +ol.proposal-narrative__list { + list-style: decimal outside; +} + +.proposal-narrative__quote { + margin: 0; + border-left: 2px solid var(--primary); + padding-left: 0.875rem; + color: var(--text); +} + +.proposal-narrative-editor { + overflow: hidden; + border: 1px solid var(--surface-glass-border); + border-radius: 8px; + background: var(--control-glass-bg); + box-shadow: var(--shadow-control); + transition: + border-color 160ms ease, + background-color 160ms ease; +} + +.proposal-narrative-editor:focus-within { + border-color: var(--primary-dim); + background: var(--control-glass-hover-bg); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-dim) 55%, transparent); +} + +.proposal-narrative-editor__toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + border-bottom: 1px solid var(--surface-glass-border); + padding: 0.375rem; +} + +.proposal-narrative-editor__toolbar button { + min-height: 2rem; + padding-right: 0.5rem; + padding-left: 0.5rem; + font-size: 0.75rem; +} + +.proposal-narrative-editor__input { + display: block; + width: 100%; + border: 0; + outline: 0; + background: transparent; + min-height: calc(var(--proposal-narrative-editor-rows, 7) * 1.6em + 1.5rem); + padding: 0.75rem; + color: var(--text); + font-size: 0.875rem; + line-height: 1.6; + white-space: pre-wrap; +} + +.proposal-narrative-editor__input p.is-editor-empty:first-child::before { + position: absolute; + content: attr(data-placeholder); + color: var(--muted); + opacity: 0.75; + pointer-events: none; +} + +.proposal-narrative-editor__input :is(h2, h3, p, blockquote, ul, ol) { + margin-top: 0; + margin-bottom: 0.75rem; +} + +.proposal-narrative-editor__input ul, +.proposal-narrative-editor__input ol { + padding-left: 1.25rem; +} + +.proposal-narrative-editor__input ul { + list-style: disc outside; +} + +.proposal-narrative-editor__input ol { + list-style: decimal outside; +} + +.proposal-narrative-editor__input li + li { + margin-top: 0.25rem; +} + +.proposal-narrative-editor__input:focus { + outline: 0; +} + +.proposal-narrative-editor__input p.is-editor-empty:first-child { + position: relative; +} + +.proposal-narrative-editor__input :is(h2, h3) { + color: var(--text); + font-size: 0.9375rem; + font-weight: 700; +} + +.proposal-narrative-editor__input blockquote { + border-left: 2px solid var(--primary); + padding-left: 0.75rem; +} + +.proposal-narrative-editor__input code { + border-radius: 3px; + background: var(--control-glass-hover-bg); + padding: 0.1rem 0.25rem; +} + +.proposal-narrative-editor__input a { + color: var(--primary); + text-decoration: underline; +} + +.proposal-narrative-editor__link-form { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + border-top: 1px solid var(--surface-glass-border); + padding: 0.5rem 0.75rem; +} + +.proposal-narrative-editor__link-form input { + min-width: min(100%, 16rem); + flex: 1 1 12rem; + border: 1px solid var(--surface-glass-border); + border-radius: 6px; + background: var(--panel-alt); + padding: 0.375rem 0.5rem; + color: var(--text); + font-size: 0.75rem; +} + +.proposal-narrative-editor__link-form input:focus-visible { + outline: 2px solid var(--primary-dim); + outline-offset: 1px; +} + +.proposal-narrative-editor__hint { + margin: 0; + border-top: 1px solid var(--surface-glass-border); + padding: 0.5rem 0.75rem; + color: var(--muted); + font-size: 0.75rem; + line-height: 1.45; +} + +@media (prefers-reduced-motion: reduce) { + .proposal-narrative-editor { + transition: none; + } +} diff --git a/src/components/ProposalNarrative.tsx b/src/components/ProposalNarrative.tsx new file mode 100644 index 0000000..0f41361 --- /dev/null +++ b/src/components/ProposalNarrative.tsx @@ -0,0 +1,211 @@ +import { lazy, Suspense } from "react"; + +import { safeExternalHref } from "@/lib/safeExternalHref"; +import { cn } from "@/lib/utils"; +import "./ProposalNarrative.css"; + +export type ProposalNarrativeValue = string | string[]; + +export type ProposalNarrativeEditorProps = { + id: string; + onChange: (value: string) => void; + placeholder: string; + rows?: number; + value: string; +}; + +type NarrativeBlock = + | { type: "heading"; level: 2 | 3; text: string } + | { type: "ordered-list"; items: string[] } + | { type: "unordered-list"; items: string[] } + | { type: "quote"; text: string } + | { type: "paragraph"; text: string }; + +function narrativeSource(value: ProposalNarrativeValue): string { + return Array.isArray(value) ? value.join("\n\n") : value; +} + +function cleanText(value: string): string { + return value.replace(/\r\n?/g, "\n").trim(); +} + +function parseNarrative(value: ProposalNarrativeValue): NarrativeBlock[] { + const lines = cleanText(narrativeSource(value)).split("\n"); + const blocks: NarrativeBlock[] = []; + let paragraph: string[] = []; + let list: { + type: "ordered-list" | "unordered-list"; + items: string[]; + } | null = null; + + const flushParagraph = () => { + const text = paragraph.join(" ").replace(/\s+/g, " ").trim(); + if (text) blocks.push({ type: "paragraph", text }); + paragraph = []; + }; + const flushList = () => { + if (list && list.items.length > 0) blocks.push(list); + list = null; + }; + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line) { + flushParagraph(); + flushList(); + continue; + } + + const heading = /^(#{1,3})\s+(.+)$/.exec(line); + if (heading) { + flushParagraph(); + flushList(); + blocks.push({ + type: "heading", + level: heading[1].length >= 3 ? 3 : 2, + text: heading[2], + }); + continue; + } + + const quote = /^>\s*(.+)$/.exec(line); + if (quote) { + flushParagraph(); + flushList(); + blocks.push({ type: "quote", text: quote[1] }); + continue; + } + + const unordered = /^(?:[-*+])\s+(.+)$/.exec(line); + const ordered = /^\d+[.)]\s+(.+)$/.exec(line); + if (unordered || ordered) { + flushParagraph(); + const type = ordered ? "ordered-list" : "unordered-list"; + if (!list || list.type !== type) { + flushList(); + list = { type, items: [] }; + } + list.items.push((ordered ?? unordered)![1]); + continue; + } + + flushList(); + paragraph.push(line); + } + + flushParagraph(); + flushList(); + return blocks; +} + +export function safeNarrativeHref(rawHref: string): string | null { + return safeExternalHref(rawHref); +} + +function NarrativeInline({ text }: { text: string }) { + const parts = text.split(/(`[^`]+`|\[[^\]]+\]\([^\s)]+\))/g); + return ( + <> + {parts.map((part, index) => { + if (part.startsWith("`") && part.endsWith("`")) { + return ( + + {part.slice(1, -1)} + + ); + } + const link = /^\[([^\]]+)\]\(([^\s)]+)\)$/.exec(part); + if (link) { + const href = safeNarrativeHref(link[2]); + return href ? ( + + {link[1]} + + ) : ( + {link[1]} + ); + } + return part; + })} + + ); +} + +export function ProposalNarrative({ + className, + value, +}: { + className?: string; + value: ProposalNarrativeValue; +}) { + const blocks = parseNarrative(value); + if (blocks.length === 0) return null; + + return ( +
+ {blocks.map((block, index) => { + const key = `${block.type}-${index}`; + if (block.type === "heading") { + const Tag = block.level === 2 ? "h3" : "h4"; + return ( + + + + ); + } + if (block.type === "quote") { + return ( +
+ +
+ ); + } + if (block.type === "ordered-list" || block.type === "unordered-list") { + const Tag = block.type === "ordered-list" ? "ol" : "ul"; + return ( + + {block.items.map((item, itemIndex) => ( +
  • + +
  • + ))} +
    + ); + } + return ( +

    + +

    + ); + })} +
    + ); +} + +const TiptapNarrativeEditor = lazy(() => import("./ProposalNarrativeEditor")); + +export function ProposalNarrativeEditor(props: ProposalNarrativeEditorProps) { + return ( + + } + > + + + ); +} diff --git a/src/components/ProposalNarrativeEditor.tsx b/src/components/ProposalNarrativeEditor.tsx new file mode 100644 index 0000000..58f067c --- /dev/null +++ b/src/components/ProposalNarrativeEditor.tsx @@ -0,0 +1,207 @@ +import { useEffect, useId, useRef, useState } from "react"; + +import type { Editor } from "@tiptap/core"; +import Link from "@tiptap/extension-link"; +import { Markdown } from "@tiptap/markdown"; +import { EditorContent, useEditor } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; + +import { Button } from "@/components/primitives/button"; +import { + safeNarrativeHref, + type ProposalNarrativeEditorProps, +} from "./ProposalNarrative"; + +const narrativeExtensions = [ + StarterKit.configure({ + bold: false, + codeBlock: false, + heading: { levels: [2, 3] }, + horizontalRule: false, + italic: false, + strike: false, + }), + Link.configure({ + autolink: true, + defaultProtocol: "https", + linkOnPaste: true, + openOnClick: false, + protocols: ["http", "https", "mailto"], + validate: (url) => safeNarrativeHref(url) !== null, + }), + Markdown, +]; + +const editorCommands = [ + { + label: "Heading", + run: (editor: Editor) => + editor.chain().focus().toggleHeading({ level: 2 }).run(), + }, + { + label: "List", + run: (editor: Editor) => editor.chain().focus().toggleBulletList().run(), + }, + { + label: "Numbered list", + run: (editor: Editor) => editor.chain().focus().toggleOrderedList().run(), + }, + { + label: "Quote", + run: (editor: Editor) => editor.chain().focus().toggleBlockquote().run(), + }, + { + label: "Code", + run: (editor: Editor) => editor.chain().focus().toggleCode().run(), + }, +]; + +export default function ProposalNarrativeEditor({ + id, + onChange, + placeholder, + rows = 7, + value, +}: ProposalNarrativeEditorProps) { + const linkInputRef = useRef(null); + const descriptionId = useId(); + const linkInputId = useId(); + const [linkUrl, setLinkUrl] = useState(null); + const emittedValueRef = useRef(value); + const pendingParentValueRef = useRef(null); + const editor = useEditor({ + content: value, + contentType: "markdown", + editorProps: { + attributes: { + "aria-describedby": descriptionId, + "aria-label": placeholder, + "aria-multiline": "true", + class: "proposal-narrative-editor__input", + "data-placeholder": placeholder, + id, + role: "textbox", + style: `--proposal-narrative-editor-rows: ${rows}`, + }, + }, + extensions: narrativeExtensions, + immediatelyRender: false, + onUpdate: ({ editor: updatedEditor }) => { + const nextValue = updatedEditor.getMarkdown(); + emittedValueRef.current = nextValue; + pendingParentValueRef.current = nextValue; + onChange(nextValue); + }, + }); + + useEffect(() => { + if (!editor) return; + if (value === pendingParentValueRef.current) { + pendingParentValueRef.current = null; + emittedValueRef.current = value; + return; + } + if ( + pendingParentValueRef.current !== null || + value === emittedValueRef.current + ) { + return; + } + editor.commands.setContent(value, { + contentType: "markdown", + emitUpdate: false, + }); + emittedValueRef.current = value; + }, [editor, value]); + + const saveLink = () => { + if (!editor || !linkUrl) return; + const href = safeNarrativeHref(linkUrl); + if (!href) return; + editor.chain().focus().extendMarkRange("link").setLink({ href }).run(); + setLinkUrl(null); + }; + + useEffect(() => { + if (linkUrl !== null) linkInputRef.current?.focus(); + }, [linkUrl]); + + return ( +
    +
    + {editorCommands.map(({ label, run }) => ( + + ))} + +
    + + {linkUrl !== null ? ( +
    { + event.preventDefault(); + saveLink(); + }} + > + + setLinkUrl(event.target.value)} + placeholder="https://example.org" + /> + + +
    + ) : null} +

    + Use the formatting controls or standard editor shortcuts to structure + the proposal. The saved proposal remains portable Markdown. +

    +
    + ); +} diff --git a/src/components/ProposalSections.tsx b/src/components/ProposalSections.tsx index b2c8ec3..70d3976 100644 --- a/src/components/ProposalSections.tsx +++ b/src/components/ProposalSections.tsx @@ -10,7 +10,11 @@ import { StatTile } from "@/components/StatTile"; import { Surface } from "@/components/Surface"; import { TitledSurface } from "@/components/TitledSurface"; import { formatDateTime } from "@/lib/dateTime"; +import { formatProposalType } from "@/lib/proposalTypes"; import { Link } from "react-router"; +import { ProposalNarrative } from "@/components/ProposalNarrative"; +import { SYSTEM_ACTIONS } from "@/pages/proposals/proposalCreation/templates/systemActions"; +import type { ProposalAuthoringDetailsDto } from "@/types/api"; export type ProposalSummaryStat = { label: string; @@ -62,8 +66,295 @@ type ProposalSummaryCardProps = { attachments: AttachmentItem[]; showExecutionPlan?: boolean; showBudgetScope?: boolean; + authoring?: ProposalAuthoringDetailsDto; }; +function DetailFacts({ + items, +}: { + items: Array<{ label: string; value: ReactNode }>; +}) { + const visibleItems = items.filter( + (item) => + item.value !== null && item.value !== undefined && item.value !== "", + ); + if (visibleItems.length === 0) return null; + + return ( +
    + {visibleItems.map((item) => ( + +
    {item.label}
    +
    + {item.value} +
    +
    + ))} +
    + ); +} + +function NarrativeSurface({ title, value }: { title: string; value: string }) { + if (!value.trim()) return null; + return ( + + + + ); +} + +function AuthoringSurface({ + children, + title, +}: { + children: ReactNode; + title: string; +}) { + return ( + + {children} + + ); +} + +function NarrativeBlock({ title, value }: { title: string; value: string }) { + if (!value.trim()) return null; + return ( +
    +

    {title}

    + +
    + ); +} + +function ProposalAuthoringCard({ + attachments, + authoring, + budgetScope, + executionPlan, + overview, + showBudgetScope, + showExecutionPlan, +}: Pick< + ProposalSummaryCardProps, + | "attachments" + | "authoring" + | "budgetScope" + | "executionPlan" + | "overview" + | "showBudgetScope" + | "showExecutionPlan" +>) { + if (!authoring) return null; + const actionId = authoring.systemAction?.action; + const actionMeta = + actionId && actionId in SYSTEM_ACTIONS + ? SYSTEM_ACTIONS[actionId as keyof typeof SYSTEM_ACTIONS] + : null; + const showLegacyExecutionPlan = + showExecutionPlan ?? executionPlan.some((item) => item.trim().length > 0); + const showLegacyBudget = showBudgetScope ?? budgetScope.trim().length > 0; + + return ( +
    + + + + + {authoring.kind === "system" ? ( + <> + + + {authoring.systemAction?.genesisMembers.length ? ( +
    +

    + Genesis members +

    +
      + {authoring.systemAction.genesisMembers.map((address) => ( + + + + ))} +
    +
    + ) : null} +
    + + + + + ) : ( + <> + +
    + + +
    +
    + + +
    + + {authoring.outputs.length ? ( +
    +

    Where

    + ({ + id: output.id, + title: output.label, + href: output.href, + actionLabel: output.href ? "Open" : "Planned", + }))} + title="Outputs" + /> +
    + ) : null} +
    +
    + + {authoring.timeline.length || authoring.budgetItems.length ? ( + +
    + {authoring.timeline.length ? ( +
    +

    When

    +
      + {authoring.timeline.map((milestone) => ( + +

      + {milestone.title} +

      +

      + {milestone.timeframe ?? "Timeline not specified"} + {milestone.budgetHmnd + ? ` · ${milestone.budgetHmnd} HMND` + : ""} +

      +
      + ))} +
    +
    + ) : null} + {authoring.budgetItems.length ? ( +
    +

    Budget

    +
      + {authoring.budgetItems.map((item, index) => ( + + + {item.description || "Budget line"} + + + {item.amountHmnd ? `${item.amountHmnd} HMND` : "—"} + + + ))} +
    +
    + ) : null} +
    +
    + ) : null} + + )} + + {authoring.aboutMe ? ( + + ) : null} + + {authoring.kind === "project" && + !authoring.timeline.length && + showLegacyExecutionPlan ? ( + + ) : null} + {authoring.kind === "project" && + !authoring.budgetItems.length && + showLegacyBudget ? ( + +

    {budgetScope}

    +
    + ) : null} + +
    + ); +} + function canonicalizeProposalText(value: string): string { return value .toLowerCase() @@ -76,6 +367,19 @@ function canonicalizeProposalText(value: string): string { .trim(); } +function hasVisibleAuthoring(authoring: ProposalAuthoringDetailsDto): boolean { + return Boolean( + authoring.what.trim() || + authoring.why.trim() || + authoring.how.trim() || + authoring.aboutMe.trim() || + authoring.outputs.length || + authoring.timeline.length || + authoring.budgetItems.length || + authoring.systemAction?.action, + ); +} + export function ProposalSummaryCard({ summary, stats, @@ -85,6 +389,7 @@ export function ProposalSummaryCard({ attachments, showExecutionPlan, showBudgetScope, + authoring, }: ProposalSummaryCardProps) { const normalizedSummary = summary.replace(/\s+/g, " ").trim(); const normalizedOverview = overview.replace(/\s+/g, " ").trim(); @@ -99,6 +404,8 @@ export function ProposalSummaryCard({ const renderExecutionPlan = showExecutionPlan ?? executionPlan.some((item) => item.trim().length > 0); const renderBudgetScope = showBudgetScope ?? normalizedBudgetScope.length > 0; + const visibleAuthoring = + authoring && hasVisibleAuthoring(authoring) ? authoring : undefined; return (
    @@ -116,26 +423,34 @@ export function ProposalSummaryCard({ ))} )} -
    - -

    {overview}

    -
    - {renderExecutionPlan ? ( - -
      - {executionPlan.map((item) => ( -
    • {item}
    • - ))} -
    + {visibleAuthoring ? ( + + ) : ( +
    + + - ) : null} - {renderBudgetScope ? ( - -

    {budgetScope}

    -
    - ) : null} - -
    + {renderExecutionPlan ? ( + + + + ) : null} + {renderBudgetScope ? ( + +

    {budgetScope}

    +
    + ) : null} + +
    + )}
    ); } @@ -144,16 +459,20 @@ type ProposalTeamMilestonesCardProps = { teamLocked: ProposalTeamMember[]; openSlots: ProposalOpenSlot[]; milestonesDetail: ProposalMilestoneDetail[]; + sectionTitle?: string; + showMilestones?: boolean; }; export function ProposalTeamMilestonesCard({ teamLocked, openSlots, milestonesDetail, + sectionTitle = "Team & milestones", + showMilestones = true, }: ProposalTeamMilestonesCardProps) { return (
    - Team & milestones + {sectionTitle}
      @@ -214,34 +533,36 @@ export function ProposalTeamMilestonesCard({
    - -
      - {milestonesDetail.map((ms) => ( - -

      {ms.title}

      -

      {ms.desc}

      -
      - ))} - {milestonesDetail.length === 0 && ( - - No milestones defined yet. - - )} -
    -
    + {showMilestones ? ( + +
      + {milestonesDetail.map((ms) => ( + +

      {ms.title}

      +

      {ms.desc}

      +
      + ))} + {milestonesDetail.length === 0 && ( + + No milestones defined yet. + + )} +
    +
    + ) : null}
    ); } diff --git a/src/lib/safeExternalHref.ts b/src/lib/safeExternalHref.ts new file mode 100644 index 0000000..f914884 --- /dev/null +++ b/src/lib/safeExternalHref.ts @@ -0,0 +1,10 @@ +const SAFE_EXTERNAL_PROTOCOLS = new Set(["http:", "https:", "mailto:"]); + +export function safeExternalHref(rawHref: string): string | null { + try { + const href = new URL(rawHref.trim()); + return SAFE_EXTERNAL_PROTOCOLS.has(href.protocol) ? href.href : null; + } catch { + return null; + } +} diff --git a/src/pages/proposals/ProposalChamber.tsx b/src/pages/proposals/ProposalChamber.tsx index 6d38da8..754cfa9 100644 --- a/src/pages/proposals/ProposalChamber.tsx +++ b/src/pages/proposals/ProposalChamber.tsx @@ -227,6 +227,7 @@ const ProposalChamber: React.FC = () => { executionPlan={proposal.executionPlan} budgetScope={proposal.budgetScope} attachments={proposal.attachments} + authoring={proposal.authoring} showExecutionPlan={proposal.formationEligible} showBudgetScope={proposal.formationEligible} teamLocked={ diff --git a/src/pages/proposals/ProposalChamberVeto.tsx b/src/pages/proposals/ProposalChamberVeto.tsx index e423ee9..814b56f 100644 --- a/src/pages/proposals/ProposalChamberVeto.tsx +++ b/src/pages/proposals/ProposalChamberVeto.tsx @@ -178,6 +178,7 @@ const ProposalChamberVeto: React.FC = () => { executionPlan={proposal.executionPlan} budgetScope={proposal.budgetScope} attachments={proposal.attachments} + authoring={proposal.authoring} showExecutionPlan={proposal.formationEligible} showBudgetScope={proposal.formationEligible} /> diff --git a/src/pages/proposals/ProposalCitizenVeto.tsx b/src/pages/proposals/ProposalCitizenVeto.tsx index 74b4a24..0eeff69 100644 --- a/src/pages/proposals/ProposalCitizenVeto.tsx +++ b/src/pages/proposals/ProposalCitizenVeto.tsx @@ -150,6 +150,7 @@ const ProposalCitizenVeto: React.FC = () => { executionPlan={proposal.executionPlan} budgetScope={proposal.budgetScope} attachments={proposal.attachments} + authoring={proposal.authoring} showExecutionPlan={proposal.formationEligible} showBudgetScope={proposal.formationEligible} /> diff --git a/src/pages/proposals/ProposalCreation.tsx b/src/pages/proposals/ProposalCreation.tsx index 1a5cd09..952fbb1 100644 --- a/src/pages/proposals/ProposalCreation.tsx +++ b/src/pages/proposals/ProposalCreation.tsx @@ -1,69 +1,146 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useSearchParams } from "react-router"; + +import { useAuth } from "@/app/auth/AuthContext"; import { PageHint } from "@/components/PageHint"; import { SIM_AUTH_ENABLED } from "@/lib/featureFlags"; -import { useAuth } from "@/app/auth/AuthContext"; -import { formatProposalSubmitError } from "@/lib/proposalSubmitErrors"; -import { initiativeOptionsWithSelection } from "@/lib/initiativeUi"; +import { apiProposalSubmitToPool } from "@/lib/apiClient"; import { toTimestampMs } from "@/lib/dateTime"; +import { initiativeOptionsWithSelection } from "@/lib/initiativeUi"; +import { formatProposalSubmitError } from "@/lib/proposalSubmitErrors"; +import { usePrefersReducedMotion } from "@/lib/usePrefersReducedMotion"; +import { + ProposalCreationLineageMessage, + ProposalCreationMessages, +} from "./proposalCreation/ProposalCreationMessages"; +import { + WizardActions, + WizardHeader, + WizardProgress, + WizardRecovery, + WizardSummary, + WizardWorkspace, +} from "./proposalCreation/ProposalWizardShell"; import { - apiProposalDraftDelete, - apiProposalDraftSave, - apiProposalSubmitToPool, -} from "@/lib/apiClient"; -import { ProposalCreationLineageMessage } from "./proposalCreation/ProposalCreationMessages"; -import { ProposalCreationStepCard } from "./proposalCreation/ProposalCreationStepCard"; -import { ProposalCreationToolbar } from "./proposalCreation/ProposalCreationToolbar"; + applyPresetToDraft, + getProposalPreset, + PROPOSAL_PRESETS, +} from "./proposalCreation/presets/registry"; import { - clearDraftStorage, - loadDraft, - loadServerDraftId, - loadStep, - persistDraft, - persistServerDraftId, - persistStep, - persistTemplateId, -} from "./proposalCreation/storage"; -import { draftToApiForm } from "./proposalCreation/toApiForm"; + createProposalWizardSessionRepository, + type ProposalWizardSessionV2, +} from "./proposalCreation/sessionStorage"; +import { proposalSubmitErrorStep } from "./proposalCreation/submitErrorRouting"; +import { BudgetStep } from "./proposalCreation/steps/BudgetStep"; +import { IntentStep } from "./proposalCreation/steps/IntentStep"; +import { PlanStep } from "./proposalCreation/steps/PlanStep"; +import { ProjectEssentialsStep } from "./proposalCreation/steps/ProjectEssentialsStep"; +import { ReviewStep } from "./proposalCreation/steps/ReviewStep"; +import { SystemChangeStep } from "./proposalCreation/steps/SystemChangeStep"; import { DEFAULT_DRAFT, - isStepKey, type ProposalDraftForm, - type StepKey, } from "./proposalCreation/types"; -import { - DEFAULT_PRESET_ID, - PROPOSAL_PRESETS, -} from "./proposalCreation/presets/registry"; import { useProposalDraftHydration, type ProposalDraftHydrationResult, } from "./proposalCreation/useProposalDraftHydration"; +import { useProposalWizardSave } from "./proposalCreation/useProposalWizardSave"; import { useProposalCreationComputed } from "./proposalCreation/useProposalCreationComputed"; -import { useProposalCreationPreset } from "./proposalCreation/useProposalCreationPreset"; import { useProposalCreationReferenceData } from "./proposalCreation/useProposalCreationReferenceData"; +import { + createWizardState, + pathDefinition, + pathIdForDraft, + reachableWizardSteps, + normalizeWizardStepId, + resolveRequestedWizardStep, + stepDefinition, + transitionWizard, + validateWizardStep, + type WizardContext, + type WizardEffect, + type WizardEvent, +} from "./proposalCreation/wizardModel"; + +function initialSession( + repository: ReturnType, + searchParams: URLSearchParams, +): ProposalWizardSessionV2 { + repository.migrateLegacy(); + const requestedDraftId = (searchParams.get("draftId") ?? "").trim(); + if (requestedDraftId) { + return ( + repository.findByDraftId(requestedDraftId) ?? + repository.create({ draftId: requestedDraftId }) + ); + } + const requestedSessionId = (searchParams.get("session") ?? "").trim(); + if (requestedSessionId) { + const requested = repository.get(requestedSessionId); + if (requested) return requested; + } + const resubmitsProposalId = ( + searchParams.get("resubmitsProposalId") ?? "" + ).trim(); + return repository.create({ + ...(resubmitsProposalId ? { resubmitsProposalId } : {}), + }); +} + +function initialWizardStateForSession( + session: ProposalWizardSessionV2, + searchParams: URLSearchParams, +) { + const pathId = pathIdForDraft(session.form, session.templateId); + const requestedStep = normalizeWizardStepId( + searchParams.get("step") ?? session.lastVisitedStep, + session.templateId, + ); + const stepId = resolveRequestedWizardStep(pathId, requestedStep, { + draft: session.form, + presetId: session.presetId, + tierBlocked: false, + }); + return createWizardState(pathId, stepId); +} const ProposalCreation: React.FC = () => { const auth = useAuth(); + const prefersReducedMotion = usePrefersReducedMotion(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); - const [draft, setDraft] = useState(() => loadDraft()); - const { - presetId, - setPresetId, - setTemplateKind, - skipNextApply: skipNextPresetApply, - templateKind, - } = useProposalCreationPreset(setDraft); - const [attemptedNext, setAttemptedNext] = useState(false); - const [savedAt, setSavedAt] = useState(null); - const [serverDraftId, setServerDraftId] = useState(() => - loadServerDraftId(), + const repository = useMemo( + () => createProposalWizardSessionRepository(window.localStorage), + [], + ); + const [session, setSession] = useState(() => + initialSession(repository, searchParams), + ); + const sessionRef = useRef(session); + const [draft, setDraft] = useState(session.form); + const [presetId, setPresetId] = useState(session.presetId); + const [templateKind, setTemplateKind] = useState<"project" | "system">( + session.templateId, + ); + const [wizardState, setWizardState] = useState(() => + initialWizardStateForSession(session, searchParams), + ); + const [savedAt, setSavedAt] = useState( + session.serverSavedAt + ? toTimestampMs(session.serverSavedAt, Date.now()) + : null, ); - const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); - const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); + const [recoverableSessions, setRecoverableSessions] = useState(() => + repository.listRecoverable(session.sessionId), + ); + const headingRef = useRef(null); + const submitInFlight = useRef(false); + const observedQueryRef = useRef(searchParams.toString()); + const pendingInternalQueryRef = useRef(null); + const { chamberOptions, chambers, @@ -74,57 +151,13 @@ const ProposalCreation: React.FC = () => { authEnabled: auth.enabled, authenticated: auth.authenticated, }); - const requestedDraftId = (searchParams.get("draftId") ?? "").trim(); - const requestedResubmitsProposalId = ( - searchParams.get("resubmitsProposalId") ?? "" - ).trim(); - const handleDraftLoaded = useCallback( - ({ - draft: nextDraft, - draftId, - presetId: nextPresetId, - templateKind: nextTemplateKind, - }: ProposalDraftHydrationResult) => { - skipNextPresetApply(); - setTemplateKind(nextTemplateKind); - setPresetId(nextPresetId); - setDraft(nextDraft); - setServerDraftId(draftId); - setSavedAt(Date.now()); - setSaveError(null); - setSubmitError(null); - }, - [setPresetId, setTemplateKind, skipNextPresetApply], - ); - const { loadDraftError, loadingDraftId } = useProposalDraftHydration({ - navigate, - onDraftLoaded: handleDraftLoaded, - requestedDraftId, - }); - - useEffect(() => { - const handle = window.setTimeout(() => { - persistDraft(draft); - }, 250); - return () => window.clearTimeout(handle); - }, [draft]); - - const stepParam = (searchParams.get("step") ?? "").trim(); - const desiredStep: StepKey = - stepParam === "review" - ? "review" - : isStepKey(stepParam) - ? stepParam - : loadStep(); const { budgetTotal, - computed, + budgetValid, currentTier, - guardedComputed, requiredTier, selectedChamber, - template, tierBlocked, tierEligible, } = useProposalCreationComputed({ @@ -133,12 +166,29 @@ const ProposalCreation: React.FC = () => { templateKind, tierProgress, }); - useEffect(() => { - persistTemplateId(template.id); - }, [template.id]); - - const step: StepKey = desiredStep; + const wizardContext = useMemo( + () => ({ draft, presetId, tierBlocked }), + [draft, presetId, tierBlocked], + ); + const currentPathId = pathIdForDraft(draft, templateKind); + const currentPath = pathDefinition(currentPathId); + const currentStep = stepDefinition(currentPathId, wizardState.stepId); + const reachableSteps = reachableWizardSteps(currentPathId, wizardContext); + const currentStepIndex = currentPath.steps.findIndex( + (step) => step.id === wizardState.stepId, + ); + const isReview = wizardState.stepId === "review"; + const canAct = !SIM_AUTH_ENABLED || (auth.authenticated && auth.eligible); + const fullPathValid = currentPath.steps.every( + (step) => validateWizardStep(step.id, wizardContext).valid, + ); + const submitDisabled = + !fullPathValid || + !fullPathValid || + !canAct || + tierBlocked || + wizardState.submitStatus === "submitting"; const selectedInitiative = useMemo(() => { if (!draft.initiativeId) return null; const initiative = initiatives.find( @@ -155,222 +205,585 @@ const ProposalCreation: React.FC = () => { () => initiativeOptionsWithSelection(initiativeOptions, draft.initiativeId), [draft.initiativeId, initiativeOptions], ); + const selectedPreset = PROPOSAL_PRESETS.find( + (preset) => preset.id === presetId, + ); + const availableChamberIds = useMemo(() => { + const ids = chamberOptions.map((option) => option.value); + return ids.some((id) => id.toLowerCase() === "general") + ? ids + : [...ids, "general"]; + }, [chamberOptions]); + const requestedDraftId = (searchParams.get("draftId") ?? "").trim(); + const requestedSessionId = (searchParams.get("session") ?? "").trim(); + const requestedStep = searchParams.get("step") ?? ""; + const textareaClassName = + "w-full rounded-lg border border-[color:var(--surface-glass-border)] bg-[color:var(--control-glass-bg)] px-3 py-2 text-sm text-text shadow-[var(--shadow-control)] transition supports-[backdrop-filter]:backdrop-blur-md hover:border-[color:var(--surface-glass-hover-border)] hover:bg-[color:var(--control-glass-hover-bg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--primary-dim)] focus-visible:ring-offset-2 focus-visible:ring-offset-panel"; - useEffect(() => { - if (requestedDraftId) return; - setDraft((prev) => { - const nextLineage = requestedResubmitsProposalId || undefined; - if (prev.resubmitsProposalId === nextLineage) return prev; - return { - ...prev, - resubmitsProposalId: nextLineage, - }; - }); - }, [requestedDraftId, requestedResubmitsProposalId]); + const runEffects = useCallback( + (effects: WizardEffect[]) => { + const behavior = prefersReducedMotion ? "auto" : "smooth"; + window.requestAnimationFrame(() => { + for (const effect of effects) { + if (effect.type === "focus-step") { + headingRef.current?.focus({ preventScroll: true }); + headingRef.current?.scrollIntoView({ + block: "nearest", + behavior, + }); + } else { + const field = document.getElementById(effect.fieldId); + field?.focus({ preventScroll: true }); + field?.scrollIntoView({ block: "center", behavior }); + } + } + }); + }, + [prefersReducedMotion], + ); - useEffect(() => { - if (searchParams.get("step") === step) return; - const next = new URLSearchParams(searchParams); - next.set("step", step); - setSearchParams(next, { replace: true }); - }, [searchParams, setSearchParams, step]); + const send = useCallback( + (event: WizardEvent) => { + setWizardState((current) => { + const result = transitionWizard(current, event, wizardContext); + runEffects(result.effects); + return result.state; + }); + }, + [runEffects, wizardContext], + ); + + const persistCurrentSession = useCallback( + (overrides?: Partial, updateReactState = true) => { + const saved = repository.save({ + ...sessionRef.current, + ...overrides, + form: overrides?.form ?? draft, + templateId: overrides?.templateId ?? templateKind, + presetId: overrides?.presetId ?? presetId, + pathId: pathIdForDraft( + overrides?.form ?? draft, + overrides?.templateId ?? templateKind, + ), + lastVisitedStep: overrides?.lastVisitedStep ?? wizardState.stepId, + }); + sessionRef.current = saved; + if (updateReactState) setSession(saved); + return saved; + }, + [draft, presetId, repository, templateKind, wizardState.stepId], + ); useEffect(() => { - persistStep(step); - }, [step]); + if (wizardState.pathId === currentPathId) return; + send({ type: "PATH_CHANGED", pathId: currentPathId }); + }, [currentPathId, send, wizardState.pathId]); - const textareaClassName = - "w-full rounded-xl border border-[color:var(--surface-glass-border)] bg-[color:var(--control-glass-bg)] px-3 py-2 text-sm text-text shadow-[var(--shadow-control)] transition supports-[backdrop-filter]:backdrop-blur-md hover:border-[color:var(--surface-glass-hover-border)] hover:bg-[color:var(--control-glass-hover-bg)] " + - "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--primary-dim)] focus-visible:ring-offset-2 focus-visible:ring-offset-panel"; - - const goToStep = (next: StepKey) => { - persistDraft(draft); - persistStep(next); - setAttemptedNext(false); - const params = new URLSearchParams(searchParams); - params.set("step", next); - setSearchParams(params, { replace: true }); - window.scrollTo({ top: 0, behavior: "smooth" }); - }; + useEffect(() => { + const handle = window.setTimeout(() => { + persistCurrentSession(undefined, false); + send({ type: "LOCAL_SAVE_COMPLETED" }); + }, 250); + return () => window.clearTimeout(handle); + }, [ + draft, + persistCurrentSession, + presetId, + send, + templateKind, + wizardState.stepId, + ]); - const onNext = () => { - setAttemptedNext(true); - const next = template.getNextStep(step, guardedComputed); - if (next) return goToStep(next); - }; + useEffect(() => { + const currentQuery = searchParams.toString(); + if ( + currentQuery !== observedQueryRef.current && + currentQuery !== pendingInternalQueryRef.current + ) { + return; + } + const next = new URLSearchParams(searchParams); + next.set("session", session.sessionId); + next.set("step", wizardState.stepId); + if (session.draftId) next.set("draftId", session.draftId); + else next.delete("draftId"); + if (session.resubmitsProposalId) { + next.set("resubmitsProposalId", session.resubmitsProposalId); + } else { + next.delete("resubmitsProposalId"); + } + if (next.toString() !== searchParams.toString()) { + pendingInternalQueryRef.current = next.toString(); + setSearchParams(next, { replace: true }); + } + }, [ + searchParams, + session.draftId, + session.resubmitsProposalId, + session.sessionId, + setSearchParams, + wizardState.stepId, + ]); - const onBack = () => { - persistDraft(draft); - setAttemptedNext(false); - const prev = template.getPrevStep(step); - if (prev) return goToStep(prev); - navigate("/app/proposals"); - }; + const activateSession = useCallback( + (nextSession: ProposalWizardSessionV2, requestedStep?: string) => { + const nextDraft = nextSession.form; + const nextPathId = pathIdForDraft(nextDraft, nextSession.templateId); + const nextContext: WizardContext = { + draft: nextDraft, + presetId: nextSession.presetId, + tierBlocked: false, + }; + const resolvedStep = resolveRequestedWizardStep( + nextPathId, + normalizeWizardStepId( + requestedStep ?? nextSession.lastVisitedStep, + nextSession.templateId, + ), + nextContext, + ); + sessionRef.current = nextSession; + setSession(nextSession); + setDraft(nextDraft); + setPresetId(nextSession.presetId); + setTemplateKind(nextSession.templateId); + setWizardState(createWizardState(nextPathId, resolvedStep)); + setSavedAt( + nextSession.serverSavedAt + ? toTimestampMs(nextSession.serverSavedAt, Date.now()) + : null, + ); + setSaveError(null); + setSubmitError(null); + setRecoverableSessions(repository.listRecoverable(nextSession.sessionId)); + runEffects([{ type: "focus-step", stepId: resolvedStep }]); + }, + [repository, runEffects], + ); - useEffect(() => { - if (template.stepOrder.includes(step)) return; - const fallback = template.stepOrder[0] ?? "essentials"; - const params = new URLSearchParams(searchParams); - params.set("step", fallback); - setSearchParams(params, { replace: true }); - }, [searchParams, setSearchParams, step, template.id, template.stepOrder]); + const handleDraftLoaded = useCallback( + ({ + draft: nextDraft, + draftId, + presetId: nextPresetId, + templateKind: nextTemplateKind, + }: ProposalDraftHydrationResult) => { + const nextPathId = pathIdForDraft(nextDraft, nextTemplateKind); + const requestedWizardStep = normalizeWizardStepId( + requestedStep, + nextTemplateKind, + ); + const resolvedStep = resolveRequestedWizardStep( + nextPathId, + requestedWizardStep, + { + draft: nextDraft, + presetId: nextPresetId, + tierBlocked: false, + }, + ); + const saved = repository.save({ + ...sessionRef.current, + draftId, + form: nextDraft, + presetId: nextPresetId, + templateId: nextTemplateKind, + pathId: nextPathId, + lastVisitedStep: resolvedStep, + serverSavedAt: new Date().toISOString(), + }); + activateSession(saved, resolvedStep); + setSavedAt(Date.now()); + }, + [activateSession, repository, requestedStep], + ); useEffect(() => { - if (!tierBlocked || step === "essentials") return; - const params = new URLSearchParams(searchParams); - params.set("step", "essentials"); - setSearchParams(params, { replace: true }); - setAttemptedNext(true); - }, [searchParams, setSearchParams, step, tierBlocked]); - - const resetDraft = () => { - clearDraftStorage(); - setDraft(DEFAULT_DRAFT); - setPresetId(DEFAULT_PRESET_ID); - setTemplateKind("project"); - setAttemptedNext(false); - setSavedAt(null); - setSaveError(null); - setSubmitError(null); - const idToDelete = serverDraftId; - setServerDraftId(null); - const params = new URLSearchParams(searchParams); - params.set("step", "essentials"); - params.delete("draftId"); - setSearchParams(params, { replace: true }); + const currentQuery = searchParams.toString(); + if (currentQuery === observedQueryRef.current) return; + if (currentQuery === pendingInternalQueryRef.current) { + observedQueryRef.current = currentQuery; + pendingInternalQueryRef.current = null; + return; + } + observedQueryRef.current = currentQuery; + if (requestedDraftId) return; if ( - idToDelete && - (!SIM_AUTH_ENABLED || (auth.authenticated && auth.eligible)) + requestedSessionId && + requestedSessionId !== sessionRef.current.sessionId ) { - void apiProposalDraftDelete({ draftId: idToDelete }).catch(() => null); + const nextSession = repository.get(requestedSessionId); + const resubmitsProposalId = ( + searchParams.get("resubmitsProposalId") ?? "" + ).trim(); + persistCurrentSession(undefined, false); + activateSession( + nextSession ?? + repository.create({ + ...(resubmitsProposalId ? { resubmitsProposalId } : {}), + }), + requestedStep, + ); + return; } - }; - const saveDraftNow = async () => { - persistDraft(draft); - persistStep(step); - setSavedAt(Date.now()); - setSaveError(null); - - const canWrite = !SIM_AUTH_ENABLED || (auth.authenticated && auth.eligible); - if (!canWrite) { - setSaveError("Saved locally. Connect and verify to sync drafts."); + if (!requestedSessionId) { + const resubmitsProposalId = ( + searchParams.get("resubmitsProposalId") ?? "" + ).trim(); + persistCurrentSession(undefined, false); + activateSession( + repository.create({ + ...(resubmitsProposalId ? { resubmitsProposalId } : {}), + }), + requestedStep, + ); return; } - setSaving(true); - try { - const res = await apiProposalDraftSave({ - ...(serverDraftId ? { draftId: serverDraftId } : {}), - form: draftToApiForm(draft, { templateId: template.id }), - }); - setServerDraftId(res.draftId); - persistServerDraftId(res.draftId); - setSavedAt(toTimestampMs(res.updatedAt, Date.now())); - } catch (error) { - setSaveError((error as Error).message); - } finally { - setSaving(false); + if (requestedSessionId !== sessionRef.current.sessionId) return; + + const nextStep = resolveRequestedWizardStep( + currentPathId, + normalizeWizardStepId(requestedStep, templateKind), + wizardContext, + ); + if (nextStep === wizardState.stepId) return; + setWizardState((current) => ({ + ...current, + attemptedStepId: null, + pathId: currentPathId, + stepId: nextStep, + })); + runEffects([{ type: "focus-step", stepId: nextStep }]); + }, [ + activateSession, + currentPathId, + persistCurrentSession, + repository, + requestedDraftId, + requestedSessionId, + requestedStep, + runEffects, + searchParams, + templateKind, + wizardContext, + wizardState.stepId, + ]); + + const { loadDraftError, loadingDraftId } = useProposalDraftHydration({ + navigate, + onDraftLoaded: handleDraftLoaded, + requestedDraftId, + }); + + const handleTemplateChange = (nextTemplate: "project" | "system") => { + setPresetId(""); + setTemplateKind(nextTemplate); + setDraft((previous) => + nextTemplate === "system" + ? { + ...previous, + chamberId: "general", + formationEligible: false, + proposalType: "administrative", + metaGovernance: undefined, + } + : { + ...previous, + formationEligible: true, + proposalType: "basic", + metaGovernance: undefined, + }, + ); + }; + + const handlePresetChange = (nextPresetId: string) => { + if (!nextPresetId) { + setPresetId(""); + return; } + const preset = getProposalPreset(nextPresetId); + setPresetId(preset.id); + setTemplateKind(preset.templateId); + setDraft((previous) => { + const next = applyPresetToDraft(previous, preset); + return preset.templateId === "system" + ? { ...next, chamberId: "general" } + : next; + }); }; - const canAct = !SIM_AUTH_ENABLED || (auth.authenticated && auth.eligible); - const submitDisabled = !guardedComputed.canSubmit || !canAct || tierBlocked; + const saveDraftNow = useProposalWizardSave({ + canAct, + draft, + onSaveError: setSaveError, + onSavedAt: setSavedAt, + onSessionSynced: setSession, + persistSession: persistCurrentSession, + presetId, + repository, + send, + sessionRef, + templateId: templateKind, + }); const submitProposal = async () => { - if (!canAct || tierBlocked || submitting) return; + if (submitDisabled || submitInFlight.current) return; + const submittingSession = sessionRef.current; + submitInFlight.current = true; setSubmitError(null); - setSaving(false); - setSaveError(null); - setSubmitting(true); + send({ type: "SUBMIT_REQUESTED" }); try { - let draftId = serverDraftId; - if (!draftId) { - const saved = await apiProposalDraftSave({ - form: draftToApiForm(draft, { - templateId: template.id, - }), - }); - draftId = saved.draftId; - setServerDraftId(draftId); - persistServerDraftId(draftId); - } else { - await apiProposalDraftSave({ - draftId, - form: draftToApiForm(draft, { - templateId: template.id, - }), - }); - } - const res = await apiProposalSubmitToPool({ draftId }); - clearDraftStorage(); - navigate(`/app/proposals/${res.proposalId}/pp`); + const draftId = await saveDraftNow(); + if (!draftId) throw new Error("Draft could not be synchronized."); + const response = await apiProposalSubmitToPool({ draftId }); + if (sessionRef.current.sessionId !== submittingSession.sessionId) return; + repository.remove(submittingSession.sessionId); + if (submittingSession.legacyRecovery) repository.clearLegacy(); + navigate(`/app/proposals/${response.proposalId}/pp`, { replace: true }); } catch (error) { - setSubmitError(formatProposalSubmitError(error)); + if (sessionRef.current.sessionId !== submittingSession.sessionId) return; + const message = formatProposalSubmitError(error); + setSubmitError(message); + const targetStep = proposalSubmitErrorStep( + error, + currentPathId, + wizardContext, + ); + if (targetStep) { + send({ type: "STEP_REQUESTED", stepId: targetStep }); + } + send({ type: "SUBMIT_FAILED" }); } finally { - setSubmitting(false); + submitInFlight.current = false; + } + }; + + const handleContinue = () => { + if (isReview) { + void submitProposal(); + return; + } + send({ type: "CONTINUE_REQUESTED" }); + }; + + const handleStartOver = () => { + if (submitInFlight.current) return; + if (!window.confirm("Start a clean proposal? The server draft is kept.")) { + return; + } + if (session.draftId) { + const clean = repository.create(); + activateSession(clean, "intent"); + return; } + const cleanDraft = structuredClone(DEFAULT_DRAFT); + const { + legacyRecovery: _legacyRecovery, + resubmitsProposalId: _resubmitsProposalId, + ...currentSession + } = sessionRef.current; + if (session.legacyRecovery) repository.clearLegacy(); + const clean = repository.save({ + ...currentSession, + form: cleanDraft, + presetId: "", + templateId: "project", + pathId: "project-formation", + lastVisitedStep: "intent", + }); + activateSession(clean, "intent"); + }; + + const handleSaveAndExit = async () => { + if (submitInFlight.current) return; + const savingSessionId = sessionRef.current.sessionId; + const draftId = await saveDraftNow(); + if (sessionRef.current.sessionId !== savingSessionId) return; + navigate( + draftId || sessionRef.current.draftId + ? "/app/proposals/drafts" + : "/app/proposals", + ); + }; + + const handleRecover = (nextSession: ProposalWizardSessionV2) => { + if (submitInFlight.current) return; + activateSession(nextSession); }; + const handleDiscardRecovery = (sessionId: string) => { + if (submitInFlight.current) return; + const discarded = repository.get(sessionId); + repository.remove(sessionId); + if (discarded?.legacyRecovery) repository.clearLegacy(); + setRecoverableSessions(repository.listRecoverable(session.sessionId)); + }; + + const attemptedNext = wizardState.attemptedStepId === wizardState.stepId; + const submitting = wizardState.submitStatus === "submitting"; + const summaryInitiative = selectedInitiative?.title ?? null; + const wizardAnnouncement = attemptedNext + ? `${currentStep.title}. Complete the highlighted required field before continuing.` + : `${currentStep.title}. Step ${currentStepIndex + 1} of ${currentPath.steps.length}. ${currentStep.description}`; + return ( -
    +
    +

    + {wizardAnnouncement} +

    - void saveDraftNow()} - onStepChange={goToStep} - savedAt={savedAt} - saving={saving} - serverDraftId={serverDraftId} - step={step} + void saveDraftNow()} + onSaveAndExit={() => void handleSaveAndExit()} + onStartOver={handleStartOver} + pathLabel={presetId ? currentPath.label : "Not chosen"} + saveStatus={wizardState.saveStatus} + saving={wizardState.saveStatus === "syncing"} + submitting={submitting} + /> + + send({ type: "STEP_REQUESTED", stepId })} submitting={submitting} - template={template} - tierBlocked={tierBlocked} /> - void submitProposal()} - onTemplateChange={(next) => { - setTemplateKind(next); - if (presetId !== "") setPresetId(""); - }} - presetId={presetId} - presets={PROPOSAL_PRESETS} - proposerAddress={auth.address ?? null} requiredTier={requiredTier} saveError={saveError} - selectedChamber={selectedChamber} - selectedInitiative={selectedInitiative} - setDraft={setDraft} - step={step} - submitDisabled={submitDisabled} submitError={submitError} - submitting={submitting} - template={template} - templateKind={templateKind} - textareaClassName={textareaClassName} tierBlocked={tierBlocked} - tierEligible={tierEligible} /> + +
    + + {wizardState.stepId === "intent" ? ( + + ) : null} + + {wizardState.stepId === "essentials" ? ( + + ) : null} + + {wizardState.stepId === "system-change" ? ( + + ) : null} + + {wizardState.stepId === "plan" || + wizardState.stepId === "rationale" ? ( + + ) : null} + + {wizardState.stepId === "funding" ? ( + + ) : null} + + {wizardState.stepId === "review" ? ( + + ) : null} + + 0} + continueDisabled={isReview && submitDisabled} + continueLabel={ + isReview + ? wizardState.submitStatus === "submitting" + ? "Submitting" + : "Submit proposal" + : "Continue" + } + onBack={() => send({ type: "BACK_REQUESTED" })} + onContinue={handleContinue} + submitting={submitting} + /> + + + +
    + + {savedAt ? ( +

    + Last saved {new Date(savedAt).toLocaleTimeString()} +

    + ) : null}
    ); }; diff --git a/src/pages/proposals/ProposalDraft.tsx b/src/pages/proposals/ProposalDraft.tsx index 63c6736..5de10c9 100644 --- a/src/pages/proposals/ProposalDraft.tsx +++ b/src/pages/proposals/ProposalDraft.tsx @@ -4,14 +4,12 @@ import { Link, useNavigate, useParams } from "react-router"; import { Button } from "@/components/primitives/button"; import { Card } from "@/components/primitives/card"; import { PageHint } from "@/components/PageHint"; -import { SIM_AUTH_ENABLED } from "@/lib/featureFlags"; import { useAuth } from "@/app/auth/AuthContext"; -import { formatProposalSubmitError } from "@/lib/proposalSubmitErrors"; import { parseRatioPair } from "@/lib/dtoParsers"; import { apiProposalDraft, + apiProposalDraftDelete, apiProposalStatus, - apiProposalSubmitToPool, } from "@/lib/apiClient"; import { formatLoadError } from "@/lib/errorFormatting"; import type { ProposalDraftDetailDto } from "@/types/api"; @@ -24,13 +22,12 @@ const ProposalDraft: React.FC = () => { const [draftDetails, setDraftDetails] = useState(null); const [loadError, setLoadError] = useState(null); - const [submitError, setSubmitError] = useState(null); - const [submitting, setSubmitting] = useState(false); + const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); const { left: filledSlots, right: totalSlots } = parseRatioPair( draftDetails?.teamSlots ?? "0 / 0", ); const openSlots = Math.max((totalSlots || 0) - (filledSlots || 0), 0); - const canAct = !SIM_AUTH_ENABLED || (auth.authenticated && auth.eligible); const submittedDraft = Boolean(draftDetails?.submittedProposalId); useEffect(() => { @@ -74,8 +71,8 @@ const ProposalDraft: React.FC = () => {
    {id ? ( ) : null} @@ -109,43 +106,44 @@ const ProposalDraft: React.FC = () => {
    {id && !submittedDraft ? ( - + <> + + + ) : null} - {draftDetails.submittedProposalId ? (
    - {submitError ? ( - - Submit failed: {formatLoadError(submitError)} + {deleteError ? ( + + Delete failed: {formatLoadError(deleteError)} ) : null} {draftDetails.submittedProposalId ? ( diff --git a/src/pages/proposals/ProposalDrafts.tsx b/src/pages/proposals/ProposalDrafts.tsx index 69a7091..c2b0f39 100644 --- a/src/pages/proposals/ProposalDrafts.tsx +++ b/src/pages/proposals/ProposalDrafts.tsx @@ -164,10 +164,8 @@ const ProposalDrafts: React.FC = () => { View diff --git a/src/pages/proposals/ProposalFinished.tsx b/src/pages/proposals/ProposalFinished.tsx index 90a7204..75c5981 100644 --- a/src/pages/proposals/ProposalFinished.tsx +++ b/src/pages/proposals/ProposalFinished.tsx @@ -96,6 +96,7 @@ const ProposalFinished: React.FC = () => { executionPlan={proposal.executionPlan} budgetScope={proposal.budgetScope} attachments={proposal.attachments} + authoring={proposal.authoring} showExecutionPlan={proposal.formationEligible} showBudgetScope={proposal.formationEligible} teamLocked={showFormationDetails ? proposal.lockedTeam : undefined} diff --git a/src/pages/proposals/ProposalFormation.tsx b/src/pages/proposals/ProposalFormation.tsx index 12d2d0f..784c0d7 100644 --- a/src/pages/proposals/ProposalFormation.tsx +++ b/src/pages/proposals/ProposalFormation.tsx @@ -170,6 +170,7 @@ const ProposalFormation: React.FC = () => { executionPlan={project.executionPlan} budgetScope={project.budgetScope} attachments={project.attachments} + authoring={project.authoring} teamLocked={project.lockedTeam} openSlots={project.openSlots} milestonesDetail={project.milestonesDetail} diff --git a/src/pages/proposals/ProposalPP.tsx b/src/pages/proposals/ProposalPP.tsx index 9a6f201..0b19e3a 100644 --- a/src/pages/proposals/ProposalPP.tsx +++ b/src/pages/proposals/ProposalPP.tsx @@ -184,6 +184,7 @@ const ProposalPP: React.FC = () => { executionPlan={proposal.executionPlan} budgetScope={proposal.budgetScope} attachments={proposal.attachments} + authoring={proposal.authoring} showExecutionPlan={proposal.formationEligible} showBudgetScope={proposal.formationEligible} teamLocked={ diff --git a/src/pages/proposals/ProposalReferendum.tsx b/src/pages/proposals/ProposalReferendum.tsx index 79aaeb2..945152c 100644 --- a/src/pages/proposals/ProposalReferendum.tsx +++ b/src/pages/proposals/ProposalReferendum.tsx @@ -195,6 +195,7 @@ const ProposalReferendum: React.FC = () => { executionPlan={proposal.executionPlan} budgetScope={proposal.budgetScope} attachments={proposal.attachments} + authoring={proposal.authoring} showExecutionPlan={proposal.formationEligible} showBudgetScope={proposal.formationEligible} teamLocked={ diff --git a/src/pages/proposals/proposalCreation/EditableLinkList.tsx b/src/pages/proposals/proposalCreation/EditableLinkList.tsx new file mode 100644 index 0000000..4ebdb1c --- /dev/null +++ b/src/pages/proposals/proposalCreation/EditableLinkList.tsx @@ -0,0 +1,58 @@ +import { Button } from "@/components/primitives/button"; +import { Input } from "@/components/primitives/input"; + +import type { LinkItem } from "./types"; + +type EditableLinkListProps = { + emptyMessage?: string; + items: LinkItem[]; + labelPlaceholder: string; + onChange: (id: string, field: "label" | "url", value: string) => void; + onRemove: (id: string) => void; + urlPlaceholder: string; +}; + +export function EditableLinkList({ + emptyMessage, + items, + labelPlaceholder, + onChange, + onRemove, + urlPlaceholder, +}: EditableLinkListProps) { + if (items.length === 0) { + return emptyMessage ? ( +

    {emptyMessage}

    + ) : null; + } + + return ( +
    + {items.map((item) => ( +
    + onChange(item.id, "label", event.target.value)} + placeholder={labelPlaceholder} + /> + onChange(item.id, "url", event.target.value)} + placeholder={urlPlaceholder} + /> + +
    + ))} +
    + ); +} diff --git a/src/pages/proposals/proposalCreation/ProposalCreationMessages.tsx b/src/pages/proposals/proposalCreation/ProposalCreationMessages.tsx index 84468b0..7c4a739 100644 --- a/src/pages/proposals/proposalCreation/ProposalCreationMessages.tsx +++ b/src/pages/proposals/proposalCreation/ProposalCreationMessages.tsx @@ -1,4 +1,4 @@ -import { Card } from "@/components/primitives/card"; +import { GlassyCard } from "@/components/GlassyCard"; import { TierLabel } from "@/components/TierLabel"; import { formatLoadError } from "@/lib/errorFormatting"; @@ -7,7 +7,6 @@ type ProposalCreationMessagesProps = { loadDraftError: string | null; loadingDraftId: string | null; requiredTier: string; - resubmitsProposalId?: string; saveError: string | null; submitError: string | null; tierBlocked: boolean; @@ -20,12 +19,12 @@ export function ProposalCreationLineageMessage({ }) { if (!resubmitsProposalId) return null; return ( - + This draft is marked as a reconsideration of decision lineage{" "} {resubmitsProposalId} . Submit it only if you intend this proposal to count as the same decision lineage. - + ); } @@ -41,27 +40,27 @@ export function ProposalCreationMessages({ return ( <> {saveError ? ( -
    +
    {formatLoadError(saveError)}
    ) : null} {loadingDraftId ? ( -
    +
    Loading draft for editing…
    ) : null} {loadDraftError ? ( -
    +
    Draft load failed: {formatLoadError(loadDraftError)}
    ) : null} {submitError ? ( -
    +
    Submit failed: {formatLoadError(submitError)}
    ) : null} {tierBlocked ? ( -
    +
    Selected proposal type requires . Your tier is . Choose an eligible type to continue. diff --git a/src/pages/proposals/proposalCreation/ProposalCreationStepCard.tsx b/src/pages/proposals/proposalCreation/ProposalCreationStepCard.tsx deleted file mode 100644 index 1ca39a1..0000000 --- a/src/pages/proposals/proposalCreation/ProposalCreationStepCard.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@/components/primitives/card"; -import { Button } from "@/components/primitives/button"; -import type { ChamberDto } from "@/types/api"; -import { BudgetStep } from "./steps/BudgetStep"; -import { EssentialsStep } from "./steps/EssentialsStep"; -import { PlanStep } from "./steps/PlanStep"; -import { ReviewStep } from "./steps/ReviewStep"; -import type { ProposalDraftForm, StepKey } from "./types"; -import type { WizardComputed, WizardTemplate } from "./templates/types"; -import { ProposalCreationMessages } from "./ProposalCreationMessages"; - -type ProposalCreationStepCardProps = { - attemptedNext: boolean; - budgetTotal: number; - canAct: boolean; - computed: WizardComputed; - currentTier: string | null; - draft: ProposalDraftForm; - guardedComputed: WizardComputed; - initiativeOptions: Array<{ value: string; label: string }>; - loadDraftError: string | null; - loadingDraftId: string | null; - onBack: () => void; - onNext: () => void; - onPresetChange: (presetId: string) => void; - onSubmit: () => void; - onTemplateChange: (template: "project" | "system") => void; - proposerAddress: string | null; - requiredTier: string; - saveError: string | null; - selectedChamber: ChamberDto | null; - selectedInitiative?: { id: string; title: string } | null; - setDraft: React.Dispatch>; - step: StepKey; - submitDisabled: boolean; - submitError: string | null; - submitting: boolean; - template: WizardTemplate; - templateKind: "project" | "system"; - textareaClassName: string; - tierBlocked: boolean; - tierEligible: boolean; - chamberOptions: Array<{ value: string; label: string }>; - presetId: string; - presets: Parameters[0]["presets"]; -}; - -export function ProposalCreationStepCard({ - attemptedNext, - budgetTotal, - canAct, - chamberOptions, - computed, - currentTier, - draft, - guardedComputed, - initiativeOptions, - loadDraftError, - loadingDraftId, - onBack, - onNext, - onPresetChange, - onSubmit, - onTemplateChange, - presetId, - presets, - proposerAddress, - requiredTier, - saveError, - selectedChamber, - selectedInitiative, - setDraft, - step, - submitDisabled, - submitError, - submitting, - template, - templateKind, - textareaClassName, - tierBlocked, - tierEligible, -}: ProposalCreationStepCardProps) { - return ( - - - - Create proposal · {template.stepTitles[step]} - -

    - Changes autosave locally. Eligible human nodes can save drafts to the - simulation backend (see Drafts). -

    -
    - - - - - {step === "essentials" ? ( - - ) : null} - - {step === "plan" ? ( - - ) : null} - - {step === "budget" ? ( - - ) : null} - - {step === "review" ? ( - - ) : null} - -
    - -
    - {step === "review" ? ( - - ) : ( - - )} -
    -
    -
    -
    - ); -} diff --git a/src/pages/proposals/proposalCreation/ProposalCreationToolbar.tsx b/src/pages/proposals/proposalCreation/ProposalCreationToolbar.tsx deleted file mode 100644 index 4a60463..0000000 --- a/src/pages/proposals/proposalCreation/ProposalCreationToolbar.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { Link } from "react-router"; - -import { Button } from "@/components/primitives/button"; -import { Tabs } from "@/components/primitives/tabs"; -import { formatTime } from "@/lib/dateTime"; -import type { WizardTemplate } from "./templates/types"; -import { isStepKey, type StepKey } from "./types"; - -type ProposalCreationToolbarProps = { - onBackToProposalsHref: string; - onResetDraft: () => void; - onSaveDraft: () => void; - onStepChange: (step: StepKey) => void; - savedAt: number | null; - saving: boolean; - serverDraftId: string | null; - step: StepKey; - submitting: boolean; - template: WizardTemplate; - tierBlocked: boolean; -}; - -export function ProposalCreationToolbar({ - onBackToProposalsHref, - onResetDraft, - onSaveDraft, - onStepChange, - savedAt, - saving, - serverDraftId, - step, - submitting, - template, - tierBlocked, -}: ProposalCreationToolbarProps) { - return ( -
    -
    - - - - {savedAt ? ( - - Saved {formatTime(savedAt)} - - ) : null} - {serverDraftId ? ( - - ) : null} -
    - - { - if (!isStepKey(value) && value !== "review") return; - if (tierBlocked && value !== "essentials") return; - onStepChange(value as StepKey); - }} - options={template.stepOrder.map((key) => ({ - value: key, - label: template.stepTabLabels[key], - }))} - className="w-full max-w-xl justify-between" - /> -
    - ); -} diff --git a/src/pages/proposals/proposalCreation/ProposalWizard.css b/src/pages/proposals/proposalCreation/ProposalWizard.css new file mode 100644 index 0000000..aa280dc --- /dev/null +++ b/src/pages/proposals/proposalCreation/ProposalWizard.css @@ -0,0 +1,402 @@ +.proposal-wizard { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.proposal-wizard__header { + display: grid; + gap: 1.25rem; + padding: 1.25rem; +} + +.proposal-wizard__header-actions { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.5rem; +} + +.proposal-wizard__header-actions > * { + min-height: 2.5rem; + min-width: 0; + font-size: 0.875rem; + opacity: 0.92; +} + +.proposal-wizard__progress { + display: grid; + grid-auto-columns: minmax(9rem, 1fr); + grid-auto-flow: column; + gap: 0.5rem; + overflow-x: auto; + padding: 0.125rem 0.125rem 0.5rem; + scrollbar-width: none; +} + +.proposal-wizard__progress::-webkit-scrollbar { + display: none; +} + +.proposal-wizard__progress-step { + display: grid; + min-height: 3.25rem; + grid-template-columns: 1.75rem minmax(0, 1fr); + align-items: center; + gap: 0.625rem; + border: 1px solid var(--surface-glass-border); + border-radius: 8px; + background: var(--surface-glass-bg); + padding: 0.625rem 0.75rem; + color: var(--muted); + text-align: left; + backdrop-filter: blur(14px); + transition: + border-color 160ms ease, + background-color 160ms ease, + color 160ms ease; +} + +.proposal-wizard__progress-step:hover:not(:disabled) { + border-color: var(--surface-glass-hover-border); + background: var(--surface-glass-hover-bg); + color: var(--text); +} + +.proposal-wizard__progress-step.is-current { + border-color: var(--primary); + background: var(--control-glass-hover-bg); + color: var(--text); +} + +.proposal-wizard__progress-step.is-complete { + color: var(--text); +} + +.proposal-wizard__progress-step.is-locked { + cursor: not-allowed; + opacity: 0.5; +} + +.proposal-wizard__progress-index { + display: grid; + width: 1.75rem; + height: 1.75rem; + place-items: center; + border-radius: 999px; + background: var(--panel-alt); + font-size: 0.6875rem; + font-weight: 700; +} + +.proposal-wizard__progress-label { + min-width: 0; + font-size: 0.8125rem; + font-weight: 600; + line-height: 1.2; + overflow-wrap: anywhere; +} + +.proposal-wizard__body { + display: grid; + gap: 1rem; +} + +.proposal-wizard__workspace { + min-width: 0; +} + +.proposal-wizard__workspace-heading { + border-bottom: 1px solid var(--surface-glass-border); + padding: 1.25rem; +} + +.proposal-wizard__workspace-content { + padding: 1.25rem; +} + +.proposal-wizard__field-section + .proposal-wizard__field-section { + border-top: 1px solid var(--surface-glass-border); + padding-top: 1.5rem; +} + +.proposal-wizard__embedded-section { + border-top: 1px solid var(--surface-glass-border); + padding-top: 1.25rem; +} + +.proposal-wizard__embedded-section:first-child { + border-top: 0; + padding-top: 0; +} + +.proposal-wizard__collection-row { + border: 1px solid var(--surface-glass-border); + border-radius: 8px; + background: var(--control-glass-bg); + padding: 0.75rem; +} + +.proposal-wizard__notice, +.proposal-wizard__total { + border: 1px solid var(--surface-glass-border); + border-radius: 8px; + background: var(--control-glass-bg); + padding: 0.75rem 1rem; +} + +.proposal-wizard__notice { + font-size: 0.75rem; + color: var(--muted); +} + +.proposal-wizard__total { + display: flex; + align-items: center; + justify-content: space-between; +} + +.proposal-wizard__eligibility { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 0.5rem 1rem; + border-top: 1px solid var(--surface-glass-border); + padding-top: 1rem; + font-size: 0.75rem; + color: var(--muted); +} + +.proposal-wizard__readonly { + display: flex; + min-height: 3rem; + flex-direction: column; + justify-content: center; + gap: 0.125rem; + border: 1px solid var(--surface-glass-border); + border-radius: 8px; + background: var(--control-glass-bg); + padding: 0.625rem 0.75rem; + color: var(--text); +} + +.proposal-wizard__readonly span { + font-size: 0.75rem; + color: var(--muted); +} + +.proposal-wizard__review-title h3, +.proposal-wizard__review-subheading, +.proposal-wizard__review-narratives h3 { + margin: 0; + color: var(--text); + font-size: 0.875rem; + font-weight: 700; + line-height: 1.4; +} + +.proposal-wizard__review-title h3 { + font-size: 1.0625rem; +} + +.proposal-wizard__review-title p { + margin: 0.375rem 0 0; + color: var(--muted); + font-size: 0.875rem; + line-height: 1.6; +} + +.proposal-wizard__review-facts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); + gap: 0.5rem; + margin: 0; +} + +.proposal-wizard__review-fact { + min-width: 0; + border: 1px solid var(--surface-glass-border); + border-radius: 8px; + background: var(--control-glass-bg); + padding: 0.75rem; +} + +.proposal-wizard__review-fact dt { + color: var(--muted); + font-size: 0.6875rem; + line-height: 1.3; +} + +.proposal-wizard__review-fact dd { + min-width: 0; + margin: 0.25rem 0 0; + color: var(--text); + font-size: 0.875rem; + font-weight: 600; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.proposal-wizard__review-narratives { + display: grid; + gap: 1.25rem; +} + +.proposal-wizard__review-narratives > div, +.proposal-wizard__review-subheading + .proposal-narrative { + display: grid; + gap: 0.625rem; +} + +.proposal-wizard__review-list { + display: grid; + gap: 0.5rem; + margin: 0; + padding: 0; + list-style: none; +} + +.proposal-wizard__review-item { + display: grid; + gap: 0.125rem; + min-width: 0; + border: 1px solid var(--surface-glass-border); + border-radius: 8px; + background: var(--control-glass-bg); + padding: 0.625rem 0.75rem; + color: var(--text); + font-size: 0.875rem; + overflow-wrap: anywhere; +} + +.proposal-wizard__review-item a { + color: var(--primary); + font-weight: 600; + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--primary) 55%, transparent); + text-underline-offset: 0.125rem; +} + +.proposal-wizard__review-item small { + color: var(--muted); + font-size: 0.75rem; + line-height: 1.45; +} + +.proposal-wizard__summary { + padding: 1.25rem; +} + +.proposal-wizard__summary-row { + display: grid; + grid-template-columns: minmax(5rem, 0.7fr) minmax(0, 1.3fr); + gap: 0.75rem; + border-top: 1px solid var(--surface-glass-border); + padding-top: 0.75rem; + font-size: 0.8125rem; +} + +.proposal-wizard__summary-row dt { + color: var(--muted); +} + +.proposal-wizard__summary-row dd { + min-width: 0; + color: var(--text); + font-weight: 600; + overflow-wrap: anywhere; +} + +.proposal-wizard__actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + border-top: 1px solid var(--surface-glass-border); + margin-top: 1.5rem; + padding-top: 1rem; +} + +.proposal-wizard__actions > * { + min-width: 7.5rem; + min-height: 2.75rem; +} + +.proposal-wizard__recovery { + display: grid; + gap: 1rem; + padding: 1rem 1.25rem; +} + +.proposal-wizard__recovery-list { + display: grid; + gap: 0.5rem; +} + +.proposal-wizard__recovery-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + border-top: 1px solid var(--surface-glass-border); + padding-top: 0.75rem; + font-size: 0.8125rem; +} + +.proposal-wizard__recovery-row strong, +.proposal-wizard__recovery-row span { + display: block; + overflow-wrap: anywhere; +} + +.proposal-wizard__recovery-row span { + margin-top: 0.125rem; + color: var(--muted); +} + +@media (min-width: 1024px) { + .proposal-wizard__header { + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + padding: 1.5rem; + } + + .proposal-wizard__header-actions { + width: 23rem; + } +} + +@media (min-width: 768px) { + .proposal-wizard__workspace-heading, + .proposal-wizard__workspace-content { + padding: 1.5rem; + } +} + +@media (min-width: 1180px) { + .proposal-wizard__body { + grid-template-columns: minmax(0, 1fr) 18rem; + align-items: start; + } + + .proposal-wizard__summary { + position: sticky; + top: 1rem; + } +} + +@media (max-width: 639px) { + .proposal-wizard__header-actions { + grid-template-columns: 1fr; + } + + .proposal-wizard__recovery-row { + align-items: stretch; + flex-direction: column; + } +} + +@media (prefers-reduced-motion: reduce) { + .proposal-wizard__progress-step { + transition: none; + } +} diff --git a/src/pages/proposals/proposalCreation/ProposalWizardShell.tsx b/src/pages/proposals/proposalCreation/ProposalWizardShell.tsx new file mode 100644 index 0000000..568b120 --- /dev/null +++ b/src/pages/proposals/proposalCreation/ProposalWizardShell.tsx @@ -0,0 +1,325 @@ +import type { ReactNode, RefObject } from "react"; + +import { GlassyCard } from "@/components/GlassyCard"; +import { GlassyStatusChip } from "@/components/GlassySection"; +import { Button } from "@/components/primitives/button"; +import { cn } from "@/lib/utils"; +import type { ProposalWizardSessionV2 } from "./sessionStorage"; +import type { + WizardPathDefinition, + WizardSaveStatus, + WizardStepId, +} from "./wizardModel"; +import "./ProposalWizard.css"; + +type WizardHeaderProps = { + onSave: () => void; + onSaveAndExit: () => void; + onStartOver: () => void; + pathLabel: string; + saveStatus: WizardSaveStatus; + saving: boolean; + submitting: boolean; +}; + +const saveStatusLabel: Record = { + idle: "Not saved", + "saved-local": "Saved locally", + syncing: "Syncing", + synced: "Synced", + "sync-error": "Local copy safe", +}; + +export function WizardHeader({ + onSave, + onSaveAndExit, + onStartOver, + pathLabel, + saveStatus, + saving, + submitting, +}: WizardHeaderProps) { + const busy = saving || submitting; + return ( + +
    +
    + {pathLabel} + + {saveStatusLabel[saveStatus]} + +
    +

    + Proposal Wizard +

    +

    + Build the proposal in order. Completed steps remain available while + the next requirement stays visible. +

    +
    +
    + + + +
    +
    + ); +} + +type WizardProgressProps = { + currentStepId: WizardStepId; + onStepChange: (stepId: WizardStepId) => void; + path: WizardPathDefinition; + reachableStepIds: WizardStepId[]; + submitting: boolean; +}; + +export function WizardProgress({ + currentStepId, + onStepChange, + path, + reachableStepIds, + submitting, +}: WizardProgressProps) { + const currentIndex = path.steps.findIndex( + (step) => step.id === currentStepId, + ); + return ( + + ); +} + +type WizardWorkspaceProps = { + children: ReactNode; + description: string; + headingRef: RefObject; + title: string; +}; + +export function WizardWorkspace({ + children, + description, + headingRef, + title, +}: WizardWorkspaceProps) { + return ( + +
    +

    + {title} +

    +

    {description}

    +
    +
    {children}
    +
    + ); +} + +type WizardSummaryProps = { + budgetTotal: number; + chamber: string; + initiative?: string | null; + preset: string; + title: string; +}; + +export function WizardSummary({ + budgetTotal, + chamber, + initiative, + preset, + title, +}: WizardSummaryProps) { + const rows = [ + ["Title", title || "Not set"], + ["Preset", preset || "Not selected"], + ["Chamber", chamber || "Not selected"], + ["Initiative", initiative || "None"], + [ + "Budget", + budgetTotal > 0 ? `${budgetTotal.toLocaleString()} HMND` : "None", + ], + ]; + return ( + + ); +} + +type WizardActionsProps = { + backLabel: string; + canGoBack: boolean; + continueLabel: string; + continueDisabled?: boolean; + onBack: () => void; + onContinue: () => void; + submitting: boolean; +}; + +export function WizardActions({ + backLabel, + canGoBack, + continueDisabled = false, + continueLabel, + onBack, + onContinue, + submitting, +}: WizardActionsProps) { + return ( +
    + + +
    + ); +} + +type WizardRecoveryProps = { + onDiscard: (sessionId: string) => void; + onRecover: (session: ProposalWizardSessionV2) => void; + sessions: ProposalWizardSessionV2[]; + submitting: boolean; +}; + +export function WizardRecovery({ + onDiscard, + onRecover, + sessions, + submitting, +}: WizardRecoveryProps) { + if (sessions.length === 0) return null; + return ( + +
    +

    + Unfinished proposal +

    +

    + Continue saved local work or leave it untouched and keep this new + proposal. +

    +
    +
    + {sessions.slice(0, 3).map((session) => ( +
    +
    + {session.form.title || "Untitled proposal"} + {new Date(session.updatedAt).toLocaleString()} +
    +
    + + +
    +
    + ))} +
    +
    + ); +} diff --git a/src/pages/proposals/proposalCreation/WizardFieldSection.tsx b/src/pages/proposals/proposalCreation/WizardFieldSection.tsx new file mode 100644 index 0000000..ef50e31 --- /dev/null +++ b/src/pages/proposals/proposalCreation/WizardFieldSection.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +type WizardFieldSectionProps = { + children: ReactNode; + className?: string; + description?: ReactNode; + title: ReactNode; +}; + +export function WizardFieldSection({ + children, + className, + description, + title, +}: WizardFieldSectionProps) { + return ( +
    +
    +

    {title}

    + {description ? ( +

    {description}

    + ) : null} +
    +
    {children}
    +
    + ); +} diff --git a/src/pages/proposals/proposalCreation/presets/registry.ts b/src/pages/proposals/proposalCreation/presets/registry.ts index a25c0cb..c2b81a8 100644 --- a/src/pages/proposals/proposalCreation/presets/registry.ts +++ b/src/pages/proposals/proposalCreation/presets/registry.ts @@ -1,5 +1,4 @@ -import type { ProposalDraftForm } from "../types"; -import type { WizardTemplateId } from "../templates/types"; +import type { ProposalDraftForm, ProposalTemplateId } from "../types"; import { isTierEligible, requiredTierForProposalType, @@ -68,7 +67,7 @@ export type ProposalPreset = { id: ProposalPresetId; label: string; description: string; - templateId: WizardTemplateId; + templateId: ProposalTemplateId; proposalType: ProposalDraftForm["proposalType"]; formationEligible: boolean; recommendedChamber?: string; diff --git a/src/pages/proposals/proposalCreation/sessionStorage.ts b/src/pages/proposals/proposalCreation/sessionStorage.ts new file mode 100644 index 0000000..155ea1e --- /dev/null +++ b/src/pages/proposals/proposalCreation/sessionStorage.ts @@ -0,0 +1,488 @@ +import { getProposalPreset, inferPresetIdFromDraft } from "./presets/registry"; +import { DEFAULT_DRAFT, type ProposalDraftForm } from "./types"; +import { + firstIncompleteWizardStep, + pathIdForDraft, + type WizardPathId, + type WizardStepId, +} from "./wizardModel"; + +const SESSION_STORE_KEY = "vortex:proposalWizard:sessions:v2"; +const MIGRATION_RECEIPT_KEY = "vortex:proposalWizard:migrated:v2"; +const LEGACY_DRAFT_KEY = "vortex:proposalCreation:draft"; +const LEGACY_TEMPLATE_KEY = "vortex:proposalCreation:template"; +const LEGACY_PRESET_KEY = "vortex:proposalCreation:preset"; +const LEGACY_SERVER_DRAFT_ID_KEY = "vortex:proposalCreation:serverDraftId"; + +export type StorageLike = Pick; + +export type ProposalWizardSessionV2 = { + version: 2; + sessionId: string; + draftId?: string; + resubmitsProposalId?: string; + templateId: "project" | "system"; + presetId: string; + pathId: WizardPathId; + lastVisitedStep: WizardStepId; + form: ProposalDraftForm; + legacyRecovery?: boolean; + localRevision: number; + serverSavedAt?: string; + updatedAt: string; +}; + +type SessionStoreV2 = { + version: 2; + sessions: Record; +}; + +type SessionRepositoryOptions = { + createId?: () => string; + now?: () => string; +}; + +function isWizardStepId(value: unknown): value is WizardStepId { + return ( + value === "intent" || + value === "essentials" || + value === "plan" || + value === "funding" || + value === "system-change" || + value === "rationale" || + value === "review" + ); +} + +export function mergeProposalWizardServerSave(input: { + draftId: string; + latest: ProposalWizardSessionV2; + requested: ProposalWizardSessionV2; + serverSavedAt: string; +}) { + if (input.latest.sessionId !== input.requested.sessionId) { + throw new Error("Cannot merge a server save into another wizard session."); + } + return { + changedDuringSync: + input.latest.localRevision > input.requested.localRevision, + session: { + ...input.latest, + draftId: input.draftId, + serverSavedAt: input.serverSavedAt, + }, + }; +} + +function cloneDraft(draft: ProposalDraftForm): ProposalDraftForm { + return structuredClone(draft); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function stringValue(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function optionalStringValue(value: unknown): string | undefined { + const normalized = stringValue(value).trim(); + return normalized || undefined; +} + +function normalizeTimelineItems(value: unknown): ProposalDraftForm["timeline"] { + if (!Array.isArray(value)) return cloneDraft(DEFAULT_DRAFT).timeline; + return value.flatMap((item, index) => { + if (!isRecord(item)) return []; + return [ + { + id: optionalStringValue(item.id) ?? `milestone-${index + 1}`, + title: stringValue(item.title), + timeframe: stringValue(item.timeframe), + budgetHmnd: stringValue(item.budgetHmnd), + }, + ]; + }); +} + +function normalizeLinkItems( + value: unknown, + fallback: ProposalDraftForm["outputs"], + prefix: string, +): ProposalDraftForm["outputs"] { + if (!Array.isArray(value)) return structuredClone(fallback); + return value.flatMap((item, index) => { + if (!isRecord(item)) return []; + return [ + { + id: optionalStringValue(item.id) ?? `${prefix}-${index + 1}`, + label: stringValue(item.label), + url: stringValue(item.url), + }, + ]; + }); +} + +function normalizeOpenSlotNeeds( + value: unknown, +): ProposalDraftForm["openSlotNeeds"] { + if (!Array.isArray(value)) return []; + return value.flatMap((item, index) => { + if (!isRecord(item)) return []; + return [ + { + id: optionalStringValue(item.id) ?? `slot-${index + 1}`, + title: stringValue(item.title), + desc: stringValue(item.desc), + }, + ]; + }); +} + +function normalizeBudgetItems( + value: unknown, +): ProposalDraftForm["budgetItems"] { + if (!Array.isArray(value)) return cloneDraft(DEFAULT_DRAFT).budgetItems; + return value.flatMap((item, index) => { + if (!isRecord(item)) return []; + return [ + { + id: optionalStringValue(item.id) ?? `budget-${index + 1}`, + description: stringValue(item.description), + amount: stringValue(item.amount), + }, + ]; + }); +} + +function normalizeMetaGovernance( + value: unknown, +): ProposalDraftForm["metaGovernance"] { + if (!isRecord(value)) return undefined; + const action = value.action; + if ( + action !== "chamber.create" && + action !== "chamber.rename" && + action !== "chamber.dissolve" && + action !== "chamber.censure" && + action !== "governor.censure" + ) { + return undefined; + } + return { + action, + ...(optionalStringValue(value.chamberId) + ? { chamberId: optionalStringValue(value.chamberId) } + : {}), + ...(optionalStringValue(value.targetAddress) + ? { targetAddress: optionalStringValue(value.targetAddress) } + : {}), + ...(optionalStringValue(value.title) + ? { title: optionalStringValue(value.title) } + : {}), + ...(typeof value.multiplier === "number" && + Number.isFinite(value.multiplier) + ? { multiplier: value.multiplier } + : {}), + ...(Array.isArray(value.genesisMembers) + ? { + genesisMembers: value.genesisMembers + .filter((member): member is string => typeof member === "string") + .map((member) => member.trim()) + .filter(Boolean), + } + : {}), + }; +} + +export function normalizeSessionDraft(parsed: unknown): ProposalDraftForm { + const source: Record = isRecord(parsed) ? parsed : {}; + const proposalType = source.proposalType; + const metaGovernance = normalizeMetaGovernance(source.metaGovernance); + return { + ...cloneDraft(DEFAULT_DRAFT), + title: stringValue(source.title), + chamberId: stringValue(source.chamberId), + ...(optionalStringValue(source.resubmitsProposalId) + ? { resubmitsProposalId: optionalStringValue(source.resubmitsProposalId) } + : {}), + ...(optionalStringValue(source.initiativeId) + ? { initiativeId: optionalStringValue(source.initiativeId) } + : {}), + summary: stringValue(source.summary), + what: stringValue(source.what), + why: stringValue(source.why), + how: stringValue(source.how), + formationEligible: metaGovernance + ? false + : source.formationEligible !== false, + ...(optionalStringValue(source.presetId) + ? { presetId: optionalStringValue(source.presetId) } + : {}), + proposalType: + proposalType === "basic" || + proposalType === "fee" || + proposalType === "monetary" || + proposalType === "core" || + proposalType === "administrative" || + proposalType === "dao-core" + ? proposalType + : DEFAULT_DRAFT.proposalType, + metaGovernance, + timeline: normalizeTimelineItems(source.timeline), + outputs: normalizeLinkItems( + source.outputs, + DEFAULT_DRAFT.outputs, + "output", + ), + openSlotNeeds: normalizeOpenSlotNeeds(source.openSlotNeeds), + budgetItems: normalizeBudgetItems(source.budgetItems), + aboutMe: stringValue(source.aboutMe), + attachments: normalizeLinkItems(source.attachments, [], "attachment"), + agreeRules: source.agreeRules === true, + confirmBudget: source.confirmBudget === true, + }; +} + +function fallbackId(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID(); + } + return `proposal-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +function emptyStore(): SessionStoreV2 { + return { version: 2, sessions: {} }; +} + +function parseStore(storage: StorageLike): SessionStoreV2 { + try { + const raw = storage.getItem(SESSION_STORE_KEY); + if (!raw) return emptyStore(); + const parsed = JSON.parse(raw) as Partial; + if (parsed.version !== 2 || !parsed.sessions) return emptyStore(); + const sessions: Record = {}; + for (const [id, value] of Object.entries(parsed.sessions)) { + if ( + !isRecord(value) || + value.version !== 2 || + value.sessionId !== id || + !value.form + ) { + continue; + } + const templateId: "project" | "system" = + value.templateId === "system" ? "system" : "project"; + const form = normalizeSessionDraft(value.form); + sessions[id] = { + version: 2, + sessionId: id, + ...(optionalStringValue(value.draftId) + ? { draftId: optionalStringValue(value.draftId) } + : {}), + ...(optionalStringValue(value.resubmitsProposalId) + ? { + resubmitsProposalId: optionalStringValue( + value.resubmitsProposalId, + ), + } + : {}), + templateId, + presetId: stringValue(value.presetId), + form, + pathId: pathIdForDraft(form, templateId), + lastVisitedStep: isWizardStepId(value.lastVisitedStep) + ? value.lastVisitedStep + : "intent", + ...(value.legacyRecovery === true ? { legacyRecovery: true } : {}), + localRevision: + typeof value.localRevision === "number" && + Number.isFinite(value.localRevision) + ? Math.max(0, Math.floor(value.localRevision)) + : 0, + ...(optionalStringValue(value.serverSavedAt) + ? { serverSavedAt: optionalStringValue(value.serverSavedAt) } + : {}), + updatedAt: stringValue(value.updatedAt), + }; + } + return { version: 2, sessions }; + } catch { + return emptyStore(); + } +} + +function draftHasMeaningfulContent(draft: ProposalDraftForm): boolean { + return Boolean( + draft.title.trim() || + draft.summary.trim() || + draft.what.trim() || + draft.why.trim() || + draft.how.trim() || + draft.chamberId.trim() || + draft.initiativeId || + draft.resubmitsProposalId || + draft.metaGovernance, + ); +} + +export function createProposalWizardSessionRepository( + storage: StorageLike, + options: SessionRepositoryOptions = {}, +) { + const createId = options.createId ?? fallbackId; + const now = options.now ?? (() => new Date().toISOString()); + + function writeStore(store: SessionStoreV2) { + storage.setItem(SESSION_STORE_KEY, JSON.stringify(store)); + } + + function create(input?: { + draftId?: string; + form?: ProposalDraftForm; + legacyRecovery?: boolean; + presetId?: string; + resubmitsProposalId?: string; + templateId?: "project" | "system"; + }): ProposalWizardSessionV2 { + const store = parseStore(storage); + const sessionId = createId(); + const form = normalizeSessionDraft(input?.form); + const resubmitsProposalId = + input?.resubmitsProposalId ?? form.resubmitsProposalId; + if (resubmitsProposalId) form.resubmitsProposalId = resubmitsProposalId; + const templateId = + input?.templateId ?? (form.metaGovernance ? "system" : "project"); + const session: ProposalWizardSessionV2 = { + version: 2, + sessionId, + ...(input?.draftId ? { draftId: input.draftId } : {}), + ...(resubmitsProposalId ? { resubmitsProposalId } : {}), + templateId, + presetId: input?.presetId ?? "", + pathId: pathIdForDraft(form, templateId), + lastVisitedStep: "intent", + form, + ...(input?.legacyRecovery ? { legacyRecovery: true } : {}), + localRevision: 0, + updatedAt: now(), + }; + store.sessions[sessionId] = session; + writeStore(store); + return session; + } + + function get(sessionId: string): ProposalWizardSessionV2 | null { + return parseStore(storage).sessions[sessionId] ?? null; + } + + function findByDraftId(draftId: string): ProposalWizardSessionV2 | null { + return ( + Object.values(parseStore(storage).sessions).find( + (session) => session.draftId === draftId, + ) ?? null + ); + } + + function save(session: ProposalWizardSessionV2): ProposalWizardSessionV2 { + const store = parseStore(storage); + const next: ProposalWizardSessionV2 = { + ...session, + version: 2, + form: normalizeSessionDraft(session.form), + pathId: pathIdForDraft(session.form, session.templateId), + localRevision: session.localRevision + 1, + updatedAt: now(), + }; + store.sessions[next.sessionId] = next; + writeStore(store); + return next; + } + + function remove(sessionId: string) { + const store = parseStore(storage); + delete store.sessions[sessionId]; + writeStore(store); + } + + function listRecoverable( + exceptSessionId?: string, + ): ProposalWizardSessionV2[] { + return Object.values(parseStore(storage).sessions) + .filter( + (session) => + session.sessionId !== exceptSessionId && + (Boolean(session.draftId) || draftHasMeaningfulContent(session.form)), + ) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + } + + function migrateLegacy(): ProposalWizardSessionV2 | null { + if (storage.getItem(MIGRATION_RECEIPT_KEY)) return null; + storage.setItem(MIGRATION_RECEIPT_KEY, now()); + try { + const rawDraft = storage.getItem(LEGACY_DRAFT_KEY); + if (!rawDraft) return null; + const form = normalizeSessionDraft( + JSON.parse(rawDraft) as Partial, + ); + const draftId = + storage.getItem(LEGACY_SERVER_DRAFT_ID_KEY)?.trim() || undefined; + if (!draftId && !draftHasMeaningfulContent(form)) return null; + const storedTemplate = storage.getItem(LEGACY_TEMPLATE_KEY); + const templateId = + storedTemplate === "system" || form.metaGovernance + ? "system" + : "project"; + const inferredPresetId = inferPresetIdFromDraft(form); + const storedPreset = storage.getItem(LEGACY_PRESET_KEY)?.trim(); + const presetId = + storedPreset && + getProposalPreset(storedPreset).templateId === templateId + ? storedPreset + : inferredPresetId; + const session = create({ + ...(draftId ? { draftId } : {}), + form, + legacyRecovery: true, + presetId, + resubmitsProposalId: form.resubmitsProposalId, + templateId, + }); + return save({ + ...session, + lastVisitedStep: firstIncompleteWizardStep(session.pathId, { + draft: session.form, + presetId: session.presetId, + tierBlocked: false, + }), + }); + } catch { + return null; + } + } + + function clearLegacy() { + storage.removeItem(LEGACY_DRAFT_KEY); + storage.removeItem("vortex:proposalCreation:step"); + storage.removeItem(LEGACY_TEMPLATE_KEY); + storage.removeItem(LEGACY_PRESET_KEY); + storage.removeItem(LEGACY_SERVER_DRAFT_ID_KEY); + } + + return { + create, + clearLegacy, + findByDraftId, + get, + listRecoverable, + migrateLegacy, + remove, + save, + }; +} + +export type ProposalWizardSessionRepository = ReturnType< + typeof createProposalWizardSessionRepository +>; diff --git a/src/pages/proposals/proposalCreation/steps/BudgetStep.tsx b/src/pages/proposals/proposalCreation/steps/BudgetStep.tsx index 3a07f04..e329198 100644 --- a/src/pages/proposals/proposalCreation/steps/BudgetStep.tsx +++ b/src/pages/proposals/proposalCreation/steps/BudgetStep.tsx @@ -42,7 +42,7 @@ export function BudgetStep(props: { draft.timeline.map((item, idx) => (
    @@ -63,6 +63,7 @@ export function BudgetStep(props: {
    (
    @@ -131,7 +132,7 @@ export function BudgetStep(props: { )}
    -
    +

    Total

    {budgetTotal.toLocaleString()} HMND diff --git a/src/pages/proposals/proposalCreation/steps/EssentialsStep.tsx b/src/pages/proposals/proposalCreation/steps/EssentialsStep.tsx deleted file mode 100644 index 694940b..0000000 --- a/src/pages/proposals/proposalCreation/steps/EssentialsStep.tsx +++ /dev/null @@ -1,768 +0,0 @@ -import { useMemo, useState } from "react"; -import type React from "react"; -import { Label } from "@/components/primitives/label"; -import { Badge } from "@/components/primitives/badge"; -import { Select } from "@/components/primitives/select"; -import { Input } from "@/components/primitives/input"; -import { TierLabel } from "@/components/TierLabel"; -import type { ProposalDraftForm } from "../types"; -import { - getSystemActionMeta, - type SystemActionId, -} from "../templates/systemActions"; -import { - filterPresetsForEligibility, - getPresetCategory, - type ProposalPreset, -} from "../presets/registry"; -import { - isTierEligible, - requiredTierForProposalType, -} from "@/lib/proposalTypes"; - -const PROPOSAL_TYPE_OPTIONS: Array<{ - value: ProposalDraftForm["proposalType"]; - label: string; - helper: string; -}> = [ - { - value: "basic", - label: "Basic", - helper: "Routine proposals that do not change core system parameters.", - }, - { - value: "fee", - label: "Fee distribution", - helper: "Adjust fee/treasury allocation rules.", - }, - { - value: "monetary", - label: "Monetary system", - helper: "Token issuance, emission, or monetary policy changes.", - }, - { - value: "core", - label: "Core infrastructure", - helper: "Protocol and infrastructure-level changes.", - }, - { - value: "administrative", - label: "Administrative", - helper: "Governance operations (e.g., chamber lifecycle).", - }, - { - value: "dao-core", - label: "DAO core", - helper: "Changes to the governance protocol itself.", - }, -]; - -export function EssentialsStep(props: { - attemptedNext: boolean; - chamberOptions: { value: string; label: string }[]; - draft: ProposalDraftForm; - initiativeOptions: { value: string; label: string }[]; - setDraft: React.Dispatch>; - templateId: "project" | "system"; - onTemplateChange: (templateId: "project" | "system") => void; - presetId: string; - presets: ProposalPreset[]; - onPresetChange: (presetId: string) => void; - textareaClassName: string; - requiredTier: string; - currentTier: string | null; - tierEligible: boolean; -}) { - const { - attemptedNext, - chamberOptions, - draft, - initiativeOptions, - setDraft, - templateId, - onTemplateChange, - presetId, - presets, - onPresetChange, - textareaClassName, - requiredTier, - currentTier, - tierEligible, - } = props; - const [hasChosenKind, setHasChosenKind] = useState(false); - const [hasChosenType, setHasChosenType] = useState(false); - - const isSystemProposal = templateId === "system"; - const hasGeneralOption = chamberOptions.some( - (opt) => opt.value === "general", - ); - const systemAction = draft.metaGovernance?.action as - | SystemActionId - | undefined; - const systemActionMeta = systemAction - ? getSystemActionMeta(systemAction) - : null; - const selectedPreset = presets.find((preset) => preset.id === presetId); - const selectedPresetCategory = selectedPreset - ? getPresetCategory(selectedPreset) - : null; - const availableChamberIds = useMemo(() => { - const ids = chamberOptions.map((opt) => opt.value); - if (!ids.some((id) => id.trim().toLowerCase() === "general")) { - ids.push("general"); - } - return ids; - }, [chamberOptions]); - const tierAndChamberEligiblePresets = useMemo( - () => - filterPresetsForEligibility({ - presets, - currentTier, - availableChamberIds, - selectedPresetId: presetId, - systemProposalType: - hasChosenKind && hasChosenType && isSystemProposal - ? draft.proposalType - : null, - }), - [ - availableChamberIds, - currentTier, - draft.proposalType, - hasChosenKind, - hasChosenType, - isSystemProposal, - presetId, - presets, - ], - ); - - const eligibleByKind = useMemo( - () => - tierAndChamberEligiblePresets.filter( - (preset) => preset.templateId === templateId, - ), - [templateId, tierAndChamberEligiblePresets], - ); - - const selectedType = hasChosenType ? draft.proposalType : null; - const eligibleByType = useMemo( - () => - selectedType - ? eligibleByKind.filter( - (preset) => preset.proposalType === selectedType, - ) - : [], - [eligibleByKind, selectedType], - ); - - const projectTypePresets = useMemo(() => { - if (!hasChosenKind || !hasChosenType || isSystemProposal || !selectedType) { - return []; - } - return eligibleByKind.filter( - (preset) => preset.proposalType === selectedType, - ); - }, [ - eligibleByKind, - hasChosenKind, - hasChosenType, - isSystemProposal, - selectedType, - ]); - const hasFormationVariants = useMemo(() => { - if (projectTypePresets.length === 0) return false; - const hasWithFormation = projectTypePresets.some( - (preset) => preset.formationEligible, - ); - const hasWithoutFormation = projectTypePresets.some( - (preset) => !preset.formationEligible, - ); - return hasWithFormation && hasWithoutFormation; - }, [projectTypePresets]); - const formationModeValue = selectedPreset?.formationEligible - ? "formation" - : "policy"; - const presetOptions = useMemo(() => { - if (!hasChosenKind || !hasChosenType) return []; - const base = eligibleByType; - if (base.length === 0 || isSystemProposal || !hasFormationVariants) { - return base; - } - const wantsFormation = - selectedPreset?.formationEligible ?? draft.formationEligible !== false; - const byMode = base.filter( - (preset) => preset.formationEligible === wantsFormation, - ); - return byMode.length > 0 ? byMode : base; - }, [ - draft.formationEligible, - eligibleByType, - hasChosenKind, - hasChosenType, - hasFormationVariants, - isSystemProposal, - selectedPreset?.formationEligible, - ]); - const proposalTypeOptions = useMemo( - () => - PROPOSAL_TYPE_OPTIONS.filter((option) => - isSystemProposal ? option.value !== "basic" : true, - ).map((option) => { - const optionRequiredTier = requiredTierForProposalType(option.value); - const eligible = - currentTier === null - ? true - : isTierEligible(currentTier, optionRequiredTier); - return { - ...option, - requiredTier: optionRequiredTier, - eligible, - }; - }), - [currentTier, isSystemProposal], - ); - - return ( -

    -
    - - -

    - System changes affect simulation variables directly (e.g., chamber - creation). Project proposals describe work outside the system. -

    -
    - -
    - - -

    - {hasChosenType - ? PROPOSAL_TYPE_OPTIONS.find( - (option) => option.value === draft.proposalType, - )?.helper - : "Choose proposal type to continue."} - - Required tier: . - {currentTier ? ( - - {" "} - Your tier: . - - ) : ( - Connect a wallet to verify eligibility. - )} - -

    -
    - -
    - - -

    - {selectedPresetCategory ? ( - - - {selectedPresetCategory} - - - ) : null} - {selectedPreset?.description ?? - "Choose kind and type first, then select a preset."} - {selectedPreset?.recommendedChamber ? ( - - Recommended chamber: {selectedPreset.recommendedChamber}. - - ) : null} - - Presets are filtered by kind, type, tier, and chamber access. - -

    -
    - {!isSystemProposal ? ( -
    - - -

    - {hasFormationVariants - ? "Choose Formation (project with milestones) or Policy." - : "This type has a fixed mode and cannot be switched."} -

    -
    - ) : null} - -
    -
    - - - setDraft((prev) => ({ - ...prev, - title: e.target.value, - })) - } - placeholder="Proposal title" - /> - {attemptedNext && draft.title.trim().length === 0 ? ( -

    Title is required.

    - ) : null} -
    -
    - - - {isSystemProposal ? ( -

    - System proposals must target General chamber. -

    - ) : null} -
    -
    - -
    - - -

    - Optional provenance tag. It does not change quorum, voting power, CM, - MM, chamber membership, or proposal lifecycle rules. -

    -
    - - {isSystemProposal ? ( -
    -

    System change

    -
    -
    - -
    - {systemActionMeta?.label ?? "No preset selected"} -
    -

    - {systemActionMeta?.description ?? - "Select a system preset for this type to set an executable action."} -

    -
    - {systemActionMeta?.requiresChamberId ? ( -
    - - { - const chamberId = e.target.value; - setDraft((prev) => ({ - ...prev, - metaGovernance: { - ...(prev.metaGovernance ?? { - action: "chamber.create", - chamberId: "", - targetAddress: "", - title: "", - genesisMembers: [], - }), - chamberId, - }, - chamberId: "general", - })); - }} - placeholder="e.g., engineering" - /> - {attemptedNext && - (draft.metaGovernance?.chamberId ?? "").trim().length === 0 ? ( -

    - Target chamber id is required. -

    - ) : null} -
    - ) : null} - {systemActionMeta?.requiresTargetAddress ? ( -
    - - { - const targetAddress = e.target.value; - setDraft((prev) => ({ - ...prev, - metaGovernance: { - ...(prev.metaGovernance ?? { - action: "governor.censure", - targetAddress: "", - }), - targetAddress, - }, - chamberId: "general", - })); - }} - placeholder="hm..." - /> - {attemptedNext && - (draft.metaGovernance?.targetAddress ?? "").trim().length === - 0 ? ( -

    - Target governor address is required. -

    - ) : null} -
    - ) : null} -
    - - {systemActionMeta?.requiresTitle || - systemActionMeta?.showMultiplier || - systemActionMeta?.showGenesisMembers ? ( -
    -
    - {systemActionMeta?.requiresTitle ? ( -
    - - { - const title = e.target.value; - setDraft((prev) => ({ - ...prev, - metaGovernance: { - ...(prev.metaGovernance ?? { - action: "chamber.create", - chamberId: "", - targetAddress: "", - title: "", - genesisMembers: [], - }), - title, - }, - chamberId: "general", - })); - }} - placeholder="Engineering chamber" - /> - {attemptedNext && - (draft.metaGovernance?.title ?? "").trim().length === 0 ? ( -

    - Title is required for chamber creation. -

    - ) : null} -
    - ) : null} - {systemActionMeta?.showMultiplier ? ( -
    - - { - const raw = e.target.value.trim(); - const multiplier = - raw.length === 0 ? undefined : Number(raw); - setDraft((prev) => ({ - ...prev, - metaGovernance: { - ...(prev.metaGovernance ?? { - action: "chamber.create", - chamberId: "", - targetAddress: "", - title: "", - genesisMembers: [], - }), - multiplier: - multiplier === undefined || - Number.isNaN(multiplier) - ? undefined - : multiplier, - }, - chamberId: "general", - })); - }} - placeholder="e.g., 3" - inputMode="decimal" - /> -
    - ) : null} -
    - {systemActionMeta?.showGenesisMembers ? ( -
    - -