Conversion
Changing a funnel's target — preview what the other target cannot carry, then convert.
Converting moves a funnel to the other target. It is the one action that
changes target (apps/api/src/routes/dashboard/funnels.ts:873) — a PATCH
cannot; see Targets. Nothing is rewritten silently:
the server first says what the other target cannot carry, and the dashboard
shows that answer and nothing else
(apps/dashboard/src/components/funnel-builder/conversion.ts:9-13) — there
is no capability list on the dashboard side to consult, on purpose.
The preview: what would be dropped
GET /dashboard/projects/:projectId/funnels/:funnelId/convert?target=
(apps/api/src/routes/dashboard/funnels.ts:896) judges and changes nothing.
It answers the conversion plan plus a slug suggestion
(apps/api/src/routes/dashboard/funnels.ts:917):
{
"data": {
"from": "web",
"to": "native",
"blockers": [],
"publishedIssues": [],
"unpublishes": false,
"stopsServing": null,
"slug": null
}
}from and to name the direction
(apps/api/src/routes/dashboard/funnels.ts:289-290). blockers is what the
new target cannot carry — non-empty means the conversion is refused
(apps/api/src/routes/dashboard/funnels.ts:237,
apps/api/src/routes/dashboard/funnels.ts:291). publishedIssues is what
the published version carries that the new target cannot — reported, never
refused (apps/api/src/routes/dashboard/funnels.ts:292; the draft is always
judged and always blocks, the published version never blocks —
apps/api/src/routes/dashboard/funnels.ts:283-286). unpublishes is true
when the funnel is live, so converting takes it off the air, and
stopsServing names the slug whose public URL stops
(apps/api/src/routes/dashboard/funnels.ts:293-294). slug is a suggestion
only — offered so the confirmation can show the author the URL before it
exists, while POST mints its own when none is sent
(apps/api/src/routes/dashboard/funnels.ts:911-916); it is set only when
converting to web from another target, and null otherwise
(apps/api/src/routes/dashboard/funnels.ts:913-916).
Each entry carries the judge's fields — code, severity, pageId,
blockId, message (packages/shared/src/funnel/issue.ts:28-34) — plus
which document holds it (source: draft or published) and the page
number counted in that document's own page list, 1-based, or null when the
issue is about the whole funnel
(apps/api/src/services/funnel/conversion.ts:52-55,
apps/dashboard/src/lib/services/funnel-api.ts:65-68).
The list is a difference, not a second verdict: the same document is judged
twice — once as it is, once as it would be — and only the errors the new
target introduces are kept
(apps/api/src/services/funnel/conversion.ts:94-106). A half-built draft
already carrying an error on both targets is not something the conversion
broke, so refusing over it would make converting wait on finishing.
The conversion itself
POST /dashboard/projects/:projectId/funnels/:funnelId/convert
(apps/api/src/routes/dashboard/funnels.ts:921) takes target, an optional
slug, and an optional unpublish acknowledgement
(apps/api/src/routes/dashboard/funnels.ts:190-205). It refuses when there
is nothing to do or something to fix first:
- already on the target: 400
FUNNEL_ALREADY_ON_TARGET(apps/api/src/routes/dashboard/funnels.ts:946-950); - a slug toward native: 400, since a native funnel has no URL
(
apps/api/src/routes/dashboard/funnels.ts:932-934); toward web the slug is the author's, or one minted the way a new funnel's is (apps/api/src/routes/dashboard/funnels.ts:971-974); - draft blockers: 400
FUNNEL_CONVERSION_BLOCKEDcarrying the issues (apps/api/src/routes/dashboard/funnels.ts:953-956,apps/api/src/routes/dashboard/funnels.ts:208); - a live funnel without
unpublish: true: 409FUNNEL_CONVERSION_UNPUBLISHESnaming what stops serving (apps/api/src/routes/dashboard/funnels.ts:958-964,apps/api/src/routes/dashboard/funnels.ts:209).
What survives: everything except the target and the address. The write patch
is { target, slug } — plus status: "draft" when the conversion
unpublishes (apps/api/src/routes/dashboard/funnels.ts:975-979). Screens,
theme and settings are untouched, and the published version is kept: history
and revert still have it, and revert restores it as a draft that the next
publish re-judges (apps/api/src/routes/dashboard/funnels.ts:884-886). The
conversion is audited as funnel.converted
(apps/api/src/routes/dashboard/funnels.ts:988), the published-config cache
for the stopped slug is invalidated
(apps/api/src/routes/dashboard/funnels.ts:1017), and the response is the
converted row (apps/api/src/routes/dashboard/funnels.ts:1019).
The cost is real: a visitor part-way through the live version cannot finish
it (apps/api/src/routes/dashboard/funnels.ts:889-890). The result is a
draft on the new target — nothing is served until a publish, which judges
the draft under the target it will actually run as
(apps/api/src/routes/dashboard/funnels.ts:881-884).
What the author must fix
A non-empty blockers list is the refusal itself
(apps/dashboard/src/lib/services/funnel-api.ts:74). The dialog says it
plainly — remove or replace the listed items first, and nothing has changed
(apps/dashboard/src/components/funnel-builder/conversion-dialog.tsx:121-122).
Each blocker is shown with where it was found and its message
(apps/dashboard/src/components/funnel-builder/conversion-dialog.tsx:126-140);
the page number is the server's, counted in the document the issue was found
in, so a published version's page 2 is not whatever the draft has second
(apps/dashboard/src/components/funnel-builder/conversion-dialog.tsx:22-26).
The published version's issues appear separately — they stop with the live
version, and the draft does not have them
(apps/dashboard/src/components/funnel-builder/conversion-dialog.tsx:154-164);
when nothing is being served they are a notice about what a restore would
face (apps/dashboard/src/components/funnel-builder/conversion-dialog.tsx:166-176).
The dashboard dialog
The builder names where the funnel runs and the one way to change it: the
target was chosen at creation, and converting checks every page against what
the other side can show first
(apps/dashboard/src/components/funnel-builder/conversion-dialog.tsx:60-70).
The dialog moves through saving the latest changes, checking every page,
showing the plan, and converting
(apps/dashboard/src/components/funnel-builder/conversion.ts:44-52). Toward
the web it asks for the address the funnel will be served at
(apps/dashboard/src/components/funnel-builder/conversion-dialog.tsx:178-188);
for a live funnel the confirm button reads "Unpublish and convert"
(apps/dashboard/src/components/funnel-builder/conversion-dialog.tsx:207-215).
A failed conversion is read off the refusal code the server puts in the
error message's JSON — blocked, unpublishes, or slug taken
(apps/dashboard/src/components/funnel-builder/conversion.ts:15-20,
apps/dashboard/src/components/funnel-builder/conversion.ts:84-106).