feat(editor): add BOM-export button + handleExportBom flow
Phase 3 D3.1 — front-end half of the BOM export. The toolbar gains a
new spreadsheet-icon button next to the existing project-export button.
On click:
1. POST is NOT used — the backend endpoint is GET-based and streams a
CSV. We just open the URL.
2. 402 (Pro-required) routes the user to /pricing?from=bom_export
so the page can show the right upgrade narrative.
3. 401 routes to /login with redirect-back.
4. 200 triggers a Blob download with Content-Disposition filename.
i18n key editor.toolbar.exportBom added in en/es/pt-br/zh-cn — the
" — Pro" suffix on the tooltip hints at the gating without forcing the
user to discover it only on click.
The button is shown to everyone, not hidden by plan. Free/Maker users
clicking it gets the 402 route to /pricing, which is intentional — that
is the upgrade-discovery funnel we want, not a silent locked icon.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
6df53a0d32
commit
b5a80a65b9
|
|
@ -881,6 +881,53 @@ export const EditorToolbar = ({
|
|||
}
|
||||
};
|
||||
|
||||
// Phase 3 D3.1 — BOM export. Pro-tier-gated by the backend (402 if not pro).
|
||||
// We let everyone click; the 402 response feeds the upgrade prompt below
|
||||
// so free/maker users hit the funnel naturally instead of an obviously-
|
||||
// locked button (which they'd just dismiss).
|
||||
const handleExportBom = async () => {
|
||||
const projectId = currentProject?.id;
|
||||
if (!projectId) {
|
||||
setMessage({ type: 'error', text: 'Save the project before exporting a BOM.' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`/api/pro/projects/${projectId}/bom.csv`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (resp.status === 402) {
|
||||
// Pro-required. Bounce to /pricing with a hint so the page can
|
||||
// surface the right upgrade message. (The backend returned a
|
||||
// structured `detail.error = 'pro_required'` payload but for the
|
||||
// MVP we just route to pricing.)
|
||||
window.location.href = '/pricing?from=bom_export';
|
||||
return;
|
||||
}
|
||||
if (resp.status === 401) {
|
||||
window.location.href = `/login?redirect=${encodeURIComponent(window.location.pathname)}`;
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
setMessage({ type: 'error', text: 'BOM export failed.' });
|
||||
return;
|
||||
}
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
// Filename comes from Content-Disposition; pick a fallback.
|
||||
const cd = resp.headers.get('Content-Disposition') || '';
|
||||
const m = /filename="?([^"]+)"?/.exec(cd);
|
||||
a.download = m ? m[1] : `bom-${projectId}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'BOM export failed.' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleFirmwareUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (firmwareInputRef.current) firmwareInputRef.current.value = '';
|
||||
|
|
@ -1212,6 +1259,19 @@ export const EditorToolbar = ({
|
|||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleExportBom()}
|
||||
className="tb-btn"
|
||||
title={t('editor.toolbar.exportBom')}
|
||||
>
|
||||
{/* Spreadsheet / list icon — distinguishes the BOM from the
|
||||
generic project Export ZIP that sits next to it. */}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
<line x1="9" y1="4" x2="9" y2="20" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => firmwareInputRef.current?.click()}
|
||||
className="tb-btn"
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@
|
|||
"message": "Missing library? Install it from the",
|
||||
"cta": "Library Manager",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
},
|
||||
"exportBom": "Export Bill of Materials (CSV — Pro)"
|
||||
},
|
||||
"fileExplorer": {
|
||||
"workspace": "WORKSPACE",
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@
|
|||
"message": "¿Falta una librería? Instálala desde el",
|
||||
"cta": "Gestor de librerías",
|
||||
"dismiss": "Descartar"
|
||||
}
|
||||
},
|
||||
"exportBom": "Exportar lista de materiales (CSV — Pro)"
|
||||
},
|
||||
"fileExplorer": {
|
||||
"workspace": "ESPACIO DE TRABAJO",
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@
|
|||
"message": "Faltando biblioteca? Instale-a no",
|
||||
"cta": "Gerenciador de Bibliotecas",
|
||||
"dismiss": "Dispensar"
|
||||
}
|
||||
},
|
||||
"exportBom": "Exportar lista de materiais (CSV — Pro)"
|
||||
},
|
||||
"fileExplorer": {
|
||||
"workspace": "ESPAÇO DE TRABALHO",
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@
|
|||
"message": "缺少库?从",
|
||||
"cta": "库管理器",
|
||||
"dismiss": "忽略"
|
||||
}
|
||||
},
|
||||
"exportBom": "导出物料清单 (CSV — Pro)"
|
||||
},
|
||||
"fileExplorer": {
|
||||
"workspace": "工作区",
|
||||
|
|
|
|||
Loading…
Reference in New Issue