Skip to content

Commit cebb234

Browse files
committed
feat(propose): structured input fields per proposal template
Replaces markdown-skeleton template pre-fill with labeled input fields. Each of the 6 templates now renders per-section textareas + a structured budget repeater (label + amount + currency, auto-totaled per currency). Field values compile to the same markdown shape stored in `description`, so onchain payload and step-3 preview are unchanged. Wizard branches validation on template mode via an imperative handle on ProposalDetailsForm. Blank `/propose` keeps the legacy markdown textarea.
1 parent 0908f74 commit cebb234

7 files changed

Lines changed: 570 additions & 611 deletions

File tree

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
"use client";
2+
3+
import { Plus, Trash2 } from "lucide-react";
4+
import { useFieldArray, useFormContext, useWatch } from "react-hook-form";
5+
import { Button } from "@/components/ui/button";
6+
import { Input } from "@/components/ui/input";
7+
import { Label } from "@/components/ui/label";
8+
import {
9+
Select,
10+
SelectContent,
11+
SelectItem,
12+
SelectTrigger,
13+
SelectValue,
14+
} from "@/components/ui/select";
15+
import type { BudgetRow } from "@/lib/proposal-template-schemas";
16+
17+
export interface BudgetRepeaterProps {
18+
/** Dotted path within the form state, e.g. "budget" or "templateFields.budget". */
19+
name: string;
20+
/** Per-row error accessor. */
21+
getRowError?: (index: number) => { label?: string; amount?: string } | undefined;
22+
/** Top-level error (e.g. "add at least one line"). */
23+
topLevelError?: string;
24+
}
25+
26+
const CURRENCIES = ["ETH", "USDC"] as const;
27+
28+
export function BudgetRepeater({ name, getRowError, topLevelError }: BudgetRepeaterProps) {
29+
const { control, register } = useFormContext();
30+
const { fields, append, remove } = useFieldArray({ control, name });
31+
const rows = (useWatch({ control, name }) as BudgetRow[] | undefined) ?? [];
32+
33+
const totals = new Map<"ETH" | "USDC", number>();
34+
for (const r of rows) {
35+
if (!r || typeof r.amount !== "number" || r.amount <= 0) continue;
36+
if (!r.label?.trim?.()) continue;
37+
const cur = (r.currency ?? "ETH") as "ETH" | "USDC";
38+
totals.set(cur, (totals.get(cur) ?? 0) + r.amount);
39+
}
40+
const totalsStr =
41+
totals.size === 0
42+
? "—"
43+
: [...totals.entries()]
44+
.sort(([a], [b]) => (a < b ? -1 : 1))
45+
.map(([cur, sum]) => `${Number(sum.toFixed(6))} ${cur}`)
46+
.join(" · ");
47+
48+
return (
49+
<div className="space-y-3">
50+
{fields.length === 0 ? (
51+
<p className="text-sm text-muted-foreground italic">
52+
No budget lines yet. Add one to request funding.
53+
</p>
54+
) : (
55+
<div className="space-y-2">
56+
{fields.map((field, index) => {
57+
const rowErr = getRowError?.(index);
58+
return (
59+
<div
60+
key={field.id}
61+
className="grid grid-cols-[1fr_140px_110px_auto] gap-2 items-start"
62+
>
63+
<div>
64+
<Input
65+
placeholder="Line item (e.g. Sponsorship fee)"
66+
aria-label={`Budget line ${index + 1} label`}
67+
{...register(`${name}.${index}.label` as const)}
68+
/>
69+
{rowErr?.label ? (
70+
<p className="text-xs text-red-500 mt-1">{rowErr.label}</p>
71+
) : null}
72+
</div>
73+
<div>
74+
<Input
75+
type="number"
76+
step="any"
77+
min={0}
78+
placeholder="Amount"
79+
aria-label={`Budget line ${index + 1} amount`}
80+
{...register(`${name}.${index}.amount` as const, { valueAsNumber: true })}
81+
/>
82+
{rowErr?.amount ? (
83+
<p className="text-xs text-red-500 mt-1">{rowErr.amount}</p>
84+
) : null}
85+
</div>
86+
<CurrencySelect name={`${name}.${index}.currency` as const} />
87+
<Button
88+
type="button"
89+
size="icon"
90+
variant="ghost"
91+
aria-label={`Remove budget line ${index + 1}`}
92+
onClick={() => remove(index)}
93+
>
94+
<Trash2 className="h-4 w-4" />
95+
</Button>
96+
</div>
97+
);
98+
})}
99+
</div>
100+
)}
101+
102+
<div className="flex items-center justify-between pt-1">
103+
<Button
104+
type="button"
105+
size="sm"
106+
variant="outline"
107+
onClick={() => append({ label: "", amount: 0, currency: "ETH" })}
108+
>
109+
<Plus className="h-4 w-4 mr-1" />
110+
Add line
111+
</Button>
112+
<div className="text-sm">
113+
<span className="text-muted-foreground mr-1">Total:</span>
114+
<span className="font-semibold tabular-nums">{totalsStr}</span>
115+
</div>
116+
</div>
117+
118+
{topLevelError ? <p className="text-xs text-red-500">{topLevelError}</p> : null}
119+
</div>
120+
);
121+
}
122+
123+
function CurrencySelect({ name }: { name: string }) {
124+
const { setValue, control } = useFormContext();
125+
const value = useWatch({ control, name }) as string | undefined;
126+
return (
127+
<div>
128+
<Label className="sr-only" htmlFor={name}>
129+
Currency
130+
</Label>
131+
<Select
132+
value={value ?? "ETH"}
133+
onValueChange={(v) => setValue(name, v, { shouldDirty: true })}
134+
>
135+
<SelectTrigger id={name}>
136+
<SelectValue />
137+
</SelectTrigger>
138+
<SelectContent>
139+
{CURRENCIES.map((c) => (
140+
<SelectItem key={c} value={c}>
141+
{c}
142+
</SelectItem>
143+
))}
144+
</SelectContent>
145+
</Select>
146+
</div>
147+
);
148+
}

0 commit comments

Comments
 (0)