feat(editor-toolbar): in-place Pro upgrade prompt + progressive overflow menu
Replace the hard /pricing redirect on 402 with a window-dispatched 'velxio-pro-upgrade-prompt' event so private overlays can surface an in-editor upgrade modal instead of bouncing the user out of context. Move BOM, Schematic image and firmware upload buttons into a "..." More menu next to the existing Export ZIP icon, freeing two button slots in the inline toolbar. Mark the two premium items with a small "PRO" pill so free-plan users know they're gated before they click — Notion- / Linear-style discoverability cue. Also wire Import + Export ZIP to fall back into that same menu once the toolbar container drops below 320 / 280 px (container queries on the editor pane width). Mobile / narrow-split layouts keep full feature parity through the dropdown instead of overflowing into a horizontally scrolling row. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
309e2f8735
commit
8ecbe89609
|
|
@ -188,6 +188,38 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* ── Progressive overflow ──────────────────────────────────────────────
|
||||
The overflow menu starts with three items (BOM, Schematic, Firmware)
|
||||
plus two hidden ones (Import, Export) declared up top. As the toolbar
|
||||
narrows we drop inline buttons into the menu instead, so mobile users
|
||||
always reach every action — they just open the "..." menu to find it.
|
||||
|
||||
Selectors are intentionally compound (.tb-overflow-menu .tb-…) to beat
|
||||
the later-defined `.tb-overflow-item { display: flex }` baseline.
|
||||
Container width thresholds reflect the editor pane width (toolbar lives
|
||||
inside it), not the viewport — at a 50/50 desktop split a 1280px
|
||||
viewport gives the toolbar ~376px to work with. */
|
||||
.tb-overflow-menu .tb-overflow-import,
|
||||
.tb-overflow-menu .tb-overflow-export {
|
||||
display: none;
|
||||
}
|
||||
@container editor-toolbar (max-width: 320px) {
|
||||
.tb-btn-import-inline {
|
||||
display: none;
|
||||
}
|
||||
.tb-overflow-menu .tb-overflow-import {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
@container editor-toolbar (max-width: 280px) {
|
||||
.tb-btn-export-inline {
|
||||
display: none;
|
||||
}
|
||||
.tb-overflow-menu .tb-overflow-export {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
/* Legacy lib icon-only button (for overflow items) */
|
||||
.tb-btn-lib {
|
||||
color: #00b8d4;
|
||||
|
|
@ -427,6 +459,25 @@
|
|||
color: #00b8d4;
|
||||
}
|
||||
|
||||
.tb-overflow-label {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Pro pill — signals premium items in the overflow menu BEFORE the user
|
||||
clicks. Used in place of a hidden gate so free users aren't surprised
|
||||
by an upgrade prompt on what looked like a free button. */
|
||||
.tb-overflow-pro {
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(135deg, #ffd54f 0%, #ffb300 100%);
|
||||
color: #1a1a1a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Error detail bar (for long compiler errors) ── */
|
||||
.toolbar-error-detail {
|
||||
background: #1a0000;
|
||||
|
|
|
|||
|
|
@ -148,6 +148,26 @@ export const EditorToolbar = ({
|
|||
const firmwareInputRef = useRef<HTMLInputElement>(null);
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const [missingLibHint, setMissingLibHint] = useState(false);
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
const moreMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!moreMenuOpen) return;
|
||||
const onClickOutside = (e: MouseEvent) => {
|
||||
if (moreMenuRef.current && !moreMenuRef.current.contains(e.target as Node)) {
|
||||
setMoreMenuOpen(false);
|
||||
}
|
||||
};
|
||||
const onEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setMoreMenuOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onClickOutside);
|
||||
document.addEventListener('keydown', onEsc);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onClickOutside);
|
||||
document.removeEventListener('keydown', onEsc);
|
||||
};
|
||||
}, [moreMenuOpen]);
|
||||
|
||||
// Compile All / Run All — runs sequentially, logs to console (no dialog)
|
||||
const [compileAllRunning, setCompileAllRunning] = useState(false);
|
||||
|
|
@ -897,7 +917,12 @@ export const EditorToolbar = ({
|
|||
credentials: 'include',
|
||||
});
|
||||
if (resp.status === 402) {
|
||||
window.location.href = '/pricing?from=screenshot_export';
|
||||
// Fire the in-place upgrade modal instead of bouncing to /pricing —
|
||||
// keeps the user in the editor with full context. The pro overlay's
|
||||
// UpgradeGate listens for this event and opens UpgradePromptModal.
|
||||
window.dispatchEvent(new CustomEvent('velxio-pro-upgrade-prompt', {
|
||||
detail: { componentName: 'Schematic screenshot export' },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (resp.status === 401) {
|
||||
|
|
@ -944,11 +969,12 @@ export const EditorToolbar = ({
|
|||
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';
|
||||
// Fire the in-place upgrade modal instead of bouncing to /pricing —
|
||||
// keeps the user in the editor with full context. The pro overlay's
|
||||
// UpgradeGate listens for this event and opens UpgradePromptModal.
|
||||
window.dispatchEvent(new CustomEvent('velxio-pro-upgrade-prompt', {
|
||||
detail: { componentName: 'BOM export' },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (resp.status === 401) {
|
||||
|
|
@ -1282,12 +1308,11 @@ export const EditorToolbar = ({
|
|||
<span className="tb-libraries-label">{t('editor.toolbar.libraries.label')}</span>
|
||||
</button>
|
||||
|
||||
{/* Import zip — was previously hidden in a 3-dot overflow menu;
|
||||
inlined since there's space and the discoverability cost
|
||||
outweighed the toolbar savings. */}
|
||||
{/* Import zip — inline by default; container query at narrow
|
||||
widths swaps this for the corresponding overflow-menu item. */}
|
||||
<button
|
||||
onClick={() => importInputRef.current?.click()}
|
||||
className="tb-btn"
|
||||
className="tb-btn tb-btn-import-inline"
|
||||
title={t('editor.toolbar.import')}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
|
|
@ -1298,7 +1323,7 @@ export const EditorToolbar = ({
|
|||
</button>
|
||||
<button
|
||||
onClick={() => handleExport()}
|
||||
className="tb-btn"
|
||||
className="tb-btn tb-btn-export-inline"
|
||||
title={t('editor.toolbar.export')}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
|
|
@ -1307,41 +1332,111 @@ export const EditorToolbar = ({
|
|||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* Overflow "More" menu — collects the secondary actions
|
||||
(BOM, Schematic image, Upload firmware) so the toolbar no
|
||||
longer overflows on narrow widths. The two Pro items show
|
||||
a small "PRO" pill in the menu so users know they're
|
||||
premium BEFORE clicking, instead of being surprised by an
|
||||
upgrade prompt. */}
|
||||
<div className="tb-overflow-wrap" ref={moreMenuRef}>
|
||||
<button
|
||||
onClick={() => handleExportBom()}
|
||||
className="tb-btn"
|
||||
title={t('editor.toolbar.exportBom')}
|
||||
onClick={() => setMoreMenuOpen((v) => !v)}
|
||||
className={`tb-btn tb-btn-overflow${moreMenuOpen ? ' tb-btn-overflow-active' : ''}`}
|
||||
title={t('editor.toolbar.more', 'More')}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={moreMenuOpen}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="5" cy="12" r="1.8" />
|
||||
<circle cx="12" cy="12" r="1.8" />
|
||||
<circle cx="19" cy="12" r="1.8" />
|
||||
</svg>
|
||||
</button>
|
||||
{moreMenuOpen && (
|
||||
<div className="tb-overflow-menu" role="menu">
|
||||
{/* Responsive items — hidden by default, shown via
|
||||
container query when the toolbar is too narrow to
|
||||
keep their inline twins. Keeps mobile users from
|
||||
losing access to Import / Export entirely. */}
|
||||
<button
|
||||
className="tb-overflow-item tb-overflow-import"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setMoreMenuOpen(false);
|
||||
importInputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
<span className="tb-overflow-label">{t('editor.toolbar.importLabel', 'Import project')}</span>
|
||||
</button>
|
||||
<button
|
||||
className="tb-overflow-item tb-overflow-export"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setMoreMenuOpen(false);
|
||||
handleExport();
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
<span className="tb-overflow-label">{t('editor.toolbar.exportLabel', 'Export project (.zip)')}</span>
|
||||
</button>
|
||||
<button
|
||||
className="tb-overflow-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setMoreMenuOpen(false);
|
||||
handleExportBom();
|
||||
}}
|
||||
>
|
||||
{/* 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>
|
||||
<span className="tb-overflow-label">{t('editor.toolbar.exportBomLabel', 'Bill of Materials (CSV)')}</span>
|
||||
<span className="tb-overflow-pro">PRO</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleExportScreenshot()}
|
||||
className="tb-btn"
|
||||
title={t('editor.toolbar.exportScreenshot')}
|
||||
className="tb-overflow-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setMoreMenuOpen(false);
|
||||
handleExportScreenshot();
|
||||
}}
|
||||
>
|
||||
{/* Camera/image icon — circuit-to-image export */}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
|
||||
<circle cx="12" cy="13" r="4" />
|
||||
</svg>
|
||||
<span className="tb-overflow-label">{t('editor.toolbar.exportScreenshotLabel', 'Schematic image (PNG)')}</span>
|
||||
<span className="tb-overflow-pro">PRO</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => firmwareInputRef.current?.click()}
|
||||
className="tb-btn"
|
||||
title={t('editor.toolbar.uploadFirmware')}
|
||||
className="tb-overflow-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setMoreMenuOpen(false);
|
||||
firmwareInputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
|
||||
<line x1="12" y1="15" x2="12" y2="22" />
|
||||
<polyline points="8 18 12 22 16 18" />
|
||||
</svg>
|
||||
<span className="tb-overflow-label">{t('editor.toolbar.uploadFirmwareLabel', 'Upload firmware')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="tb-divider" />
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue