Our proposal screen arrives already written, and every part of it has a Change button instead of an empty field
Nakodo emails creators and businesses on a brand's behalf, which means somewhere in the product the brand has to say what it wants and what it gives in return. A fee, a product, a trial, a commission, a free month. That
Nakodo emails creators and businesses on a brand's behalf, which means somewhere in the product the brand has to say what it wants and what it gives in return. A fee, a product, a trial, a commission, a free month. That screen started life as the usual thing: a card with two textareas labelled "What you'd like" and "What they get", a save button, and a blank page.
That version is easy to build and hard to finish, because it asks somebody to write marketing copy from a standing start, in a product they signed up to five minutes ago, before they have seen a single creator. The standing rule in this codebase is now the opposite:
Anything readable off the brand's own site is found automatically and shown as found, with a Change button. Never an empty box.
This post is the 941 lines that rule turned into, and the five pieces of state that carry it.
Nothing is asked for that can be read
The editor is one client component, opened from two places: the campaign setup flow and the outreach tab of a running campaign. Its props are the whole world it needs, including what we already know about the brand:
export type ProposalEditorProps = {
campaignId: string;
kind: CampaignKind;
domain: string;
platforms: Platform[];
range: { min: number; max: number }; // the brief's follower range
currency: Currency;
saved: Proposal | null; // the brand's own, once there is one
suggestion: ProposalSuggestion | null;
pricing: string; // the brand's prices as found on its site, for what a customer is worth
suggest: boolean; // ask for a suggestion when there's neither
fits: Audience[]; // the campaign's good fits so far
approval: "review" | "auto";
mode: "setup" | "outreach";
};
saved and suggestion are the two sources, in that order of authority, and startingDraft is the whole merge:
function startingDraft(p: ProposalEditorProps): Draft {
const creators = p.kind === "creators";
const s = p.saved ?? p.suggestion;
if (!s) return { idea: "", ask: "", offer: "", terms: creators ? plainTerms(p.range, p.currency) : null };
return {
idea: s.idea ?? "",
ask: creators ? "" : s.ask ?? "",
offer: creators ? "" : s.offer ?? "",
terms: creators ? (s.terms ?? plainTerms(p.range, p.currency, s.ask, s.offer)) : null,
};
}
The interesting line is the last one. Creator campaigns used to have one ask and one offer for everybody; now they have a set of terms per size of creator. Rather than migrating a jsonb column, the old shape is upgraded on read: plainTerms(range, currency, s.ask, s.offer) builds a tier per size and seeds every one of them with the single ask and offer that were saved before tiers existed. A brand that saved a proposal in September opens this screen and sees their own words, repeated across the sizes, ready to differ where they want them to.
A migration in a component is not a thing to be proud of in general. It is right here because the old shape is still a legal shape for a business campaign, so the code has to understand both anyway, and the alternative is a database rewrite plus a component that understands both.
The first open writes the whole thing while you wait
With nothing saved and nothing suggested, the screen does not render a form. It asks for a suggestion immediately, once:
const [writing, setWriting] = useState(!saved && !props.suggestion && props.suggest);
const asked = useRef(false);
// No proposal and no suggestion: ask for one straight away, once.
useEffect(() => {
if (!writing || asked.current) return;
asked.current = true;
askForSuggestion();
}, []);
The ref rather than the empty dependency array is what makes that safe. An effect with no dependencies still runs twice in React's development mode, and this one spends real money, so the guard has to be a value that survives a second run.
While it works, the screen is an honest wait rather than a spinner:
<EmptyState illustration={<Notebook size="m" className="illo-loop" />} title="Writing your proposal.">
Nakodo is working out what to ask {w.many} for and what to offer them, from what it read on {domain}. This usually takes 10 to 20 seconds.
<Asides lines={creators ? WRITING_ASIDES : BUSINESS_ASIDES} className="mt-1" />
</EmptyState>
Three things in there are deliberate. The domain is named, because "from what it read on acme.com" tells the reader where the answer is coming from and sets up the Change buttons they are about to use. The estimate is a range with a unit, because "10 to 20 seconds" is a promise the code can keep and "please wait" is not. And Asides rotates a few lines of genuinely useful context, which is the only honest thing to put in a wait that long:
const WRITING_ASIDES = [
"Smaller creators often say yes to the product and commission. Bigger ones expect a fee.",
"Fees come from each creator's typical views at market rates, not from their follower count.",
"A business buyer is usually worth a little more per viewer than a shopper.",
];
Failure has its own branch, with two ways out rather than one: try again, or write my own. A screen whose only recovery is a retry button is a screen that can trap somebody behind a provider outage.
A suggestion that is only on screen is not saved
// Nothing saved yet counts as unsaved: a suggestion only takes effect once saved.
const [dirty, setDirty] = useState(!saved);
This is the most important line in the file. The suggestion arrives, fills the screen, and reads exactly like a finished document, so it would be entirely reasonable for the reader to assume it is already in effect. It is not: nothing is written to the campaign until Save. Initialising dirty from !saved means the bar at the bottom says "You have unsaved changes" from the first paint, and the browser asks before the tab closes:
// Leaving with unsaved edits asks first.
useEffect(() => {
if (!dirty || writing) return;
const warn = (e: BeforeUnloadEvent) => e.preventDefault();
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [dirty, writing]);
!writing in the guard because a half-written proposal that does not exist yet is not something worth warning about, and the warning would land while the reader is watching a progress state they did not cause.
Facts, with Change on them
Each part of the proposal is a card. Closed, it is a description list. Open, it is fields with a Done button. The state is one union:
// What's open for changing: the idea, a business's ask and offer, or a size.
type Open = "idea" | "business" | CreatorSize | null;
One union rather than a boolean per section, so only one thing is ever open and the rest of the page stays readable while you type. The closed view:
<dl className="space-y-3">
<Fact label="Ask">{draft.ask.trim() ? cap(draft.ask.trim()) : <Unset />}</Fact>
<Fact label="They get">{draft.offer.trim() ? cap(draft.offer.trim()) : <Unset />}</Fact>
</dl>
Unset is 1 line and exists to be rare:
export const Unset = () => <span className="text-muted-foreground">Not set yet</span>;
It is what "never an empty box" looks like when the box genuinely is empty: still a sentence, in the muted colour, in the same layout as a filled fact, rather than an input waiting for you.
The Change button carries the distinction the reader cares about:
<Button variant="ghost" size="sm" onClick={onClick} aria-label={`${empty ? "Add" : "Change"} ${label}`}>
<PencilIcon />
{empty ? "Add" : "Change"}
</Button>
aria-label spells out which thing is being changed, because fifteen buttons on one page all named "Change" are useless in a list of landmarks, and the visible label stays short because the card's title is right next to it.
Section, ChangeButton, Editing, Fact, Unset and Segmented live in one 122-line file, imported by four screens: the proposal, the campaign details, the reply settings and the setup flow. That is the whole reason those three screens feel like one screen.
Segmented controls in there are real radios with no JavaScript:
<label className="cursor-pointer rounded-md px-3 py-1.5 text-sm text-muted-foreground has-checked:bg-card has-checked:font-medium has-checked:text-foreground has-focus-visible:outline-2">
<input type="radio" name={name} value={o.value} checked={value === o.value} onChange={() => onChange(o.value)} className="sr-only" />
{o.label}
</label>
The input is visually hidden rather than replaced, inside a fieldset with a legend, so arrow keys, the focus ring and the accessible name all come from the platform. has-checked: and has-focus-visible: do the styling that a custom toggle would otherwise need state for.
Typing something by hand changes the rules for that field
This is the detail that took three tries to get right. Each creator size has a content format (a mention, a dedicated video, a Reel) and an ask written in words. Choosing a different format should update the words, because the words describe the format. But if you wrote your own ask, a format change must not throw your sentence away:
// Sizes whose ask was typed by hand: a new format no longer replaces it.
const [typedAsks, setTypedAsks] = useState<Set<CreatorSize>>(() => new Set(saved ? draft.terms?.tiers.map((t) => t.size) : []));
const chooseFormat = (tier: Tier, format: AskFormat) => {
const suggested = suggestion?.terms?.tiers.find((t) => t.size === tier.size);
editTier(tier.size, {
format,
...(!typedAsks.has(tier.size) && { ask: suggested?.format === format ? suggested.ask : DEFAULT_ASKS[format] }),
});
};
Two rules in four lines. A size the reader has typed into is left alone for ever. A size they have not gets new words, and if the format they just picked is the one that was originally suggested, the words that come back are the suggested ones rather than the generic default, so clicking away and clicking back is not destructive.
The initial value of that set is the other half of it: when a proposal was loaded from the database, every tier counts as typed by hand, because we have no idea which parts of it the brand wrote and have to assume all of it.
Reasons expire when the draft stops resembling the suggestion
The preview panel beside the editor shows the suggestion's reasoning: why this ask, why this offer. Those sentences are about the suggestion, so they stop being true once the proposal has been rewritten:
// Whether the draft still mostly says what Nakodo suggested, so its reasons
// still apply.
function followsSuggestion(d: Draft, s: ProposalSuggestion): boolean {
if (d.idea === s.idea) return true;
if (!d.terms) return d.ask === s.ask || d.offer === s.offer;
return d.terms.tiers.some((t) => s.terms?.tiers.find((x) => x.size === t.size)?.ask === t.ask);
}
reasons={suggestion && followsSuggestion(draft, suggestion) ? suggestion.reasons : []}
It is a deliberately loose test: any one part still matching counts. A strict equality check would drop the explanation the moment somebody fixed a typo, and the explanation is most useful precisely while they are editing. The honest failure mode of loose is an explanation that outlives one of the three things it explains, which is better than an explanation that vanishes when you touch anything.
One level of undo, in one piece of state
There is a "Suggest again" button, which throws the current proposal away and asks for a new one. That needs an undo, and undo in a form usually turns into a history stack nobody asked for:
const [undo, setUndo] = useState<Draft | null>(null);
// in askForSuggestion:
setUndo(replacing ? draft : null);
One slot. It holds the draft as it was immediately before the replacement, and the button only exists while it is full:
{undo && (
<Button variant="ghost" onClick={() => { setDraft(undo); setUndo(null); }}>
<Undo2Icon /> Undo
</Button>
)}
The draft is a plain object of strings and small arrays, so storing a copy of it costs nothing and restoring it is one setState. The first suggestion sets undo to null, because there is nothing to go back to and a disabled Undo button is a worse answer than no Undo button.
Saving, and the question that only exists on one of the two routes
const [saving, runSave] = useAction();
const next = mode === "setup" ? `/campaigns/new/${campaignId}` : `/campaigns/${campaignId}/outreach`;
const save = () =>
runSave(
() => saveProposalAction(campaignId, asInput(draft)),
(r) => {
setDirty(false);
setOpen(null);
if (mode === "outreach" && r.redraft) setRedraft(r.redraft);
else router.push(next);
},
);
useAction is an 18-line file over useTransition that runs a server action, turns its result into a toast, and calls onOk only on success. Every mutating button in the app goes through it, which is why the whole codebase has one idea of what "saving" looks like.
mode decides where Cancel and a successful save go, so the same component is a step in a wizard on one route and a settings screen on another, without either route knowing about the other.
And then the case that only exists in the running-campaign route. Changing the proposal while emails are already drafted and waiting means those drafts were written from details that are now out of date, so instead of navigating away, the save opens a dialog:
Rewrite the 7 emails waiting to go out?
They were written from your old details. Rewritten emails wait for your approval again. It takes about a minute.
with a checkbox that only appears if some of those drafts were edited by hand:
{edited > 0 && (
<Label htmlFor={id}>
{edited === total ? `Replace your own edits too (you edited all ${edited})` : ...}
</Label>
)}
Default off. The thing a person typed is the thing a bulk action must not quietly destroy, which is the same rule as typedAsks, two screens apart.
What the shape is worth
Five pieces of state carry all of it: draft, open, dirty, typedAsks and undo. No form library, no reducer, no field-level validation layer. The reason that stays manageable is that the screen is not really a form. It is a document that arrives written, with the parts you can change marked, and a single save at the bottom.
If you want the public version of the thinking behind the numbers in it, the sponsorship cost calculator is the same arithmetic with nothing hidden, and the pricing page describes what Nakodo does with a proposal once it is saved. The suggestion's own instructions and the fee tables stay in the app, where they belong.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.