feat: implement 2 free build limits and BYOK modal
Browse files- web/src/components/BillingModal.tsx +85 -0
- web/src/index.css +181 -0
- web/src/pages/DesignStudio.tsx +31 -0
web/src/components/BillingModal.tsx
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react';
|
| 2 |
+
import { api } from '../api';
|
| 3 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
| 4 |
+
|
| 5 |
+
export const BillingModal = ({ isOpen, onClose, onKeySaved }: { isOpen: boolean, onClose: () => void, onKeySaved: () => void }) => {
|
| 6 |
+
const [apiKey, setApiKey] = useState('');
|
| 7 |
+
const [saving, setSaving] = useState(false);
|
| 8 |
+
const [error, setError] = useState('');
|
| 9 |
+
|
| 10 |
+
const handleSaveKey = async () => {
|
| 11 |
+
if (!apiKey.trim()) return;
|
| 12 |
+
setSaving(true);
|
| 13 |
+
setError('');
|
| 14 |
+
try {
|
| 15 |
+
await api.post('/profile/api-key', { api_key: apiKey.trim() });
|
| 16 |
+
onKeySaved();
|
| 17 |
+
onClose();
|
| 18 |
+
} catch (err: any) {
|
| 19 |
+
setError(err.response?.data?.detail || 'Failed to securely store API key.');
|
| 20 |
+
} finally {
|
| 21 |
+
setSaving(false);
|
| 22 |
+
}
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
if (!isOpen) return null;
|
| 26 |
+
|
| 27 |
+
return (
|
| 28 |
+
<AnimatePresence>
|
| 29 |
+
<div className="billing-modal-overlay">
|
| 30 |
+
<motion.div
|
| 31 |
+
className="sci-fi-card billing-modal-content"
|
| 32 |
+
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
| 33 |
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
| 34 |
+
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
| 35 |
+
transition={{ duration: 0.2 }}
|
| 36 |
+
>
|
| 37 |
+
<button className="billing-modal-close" onClick={onClose}>✕</button>
|
| 38 |
+
|
| 39 |
+
<div className="billing-header">
|
| 40 |
+
<div className="billing-icon">⚠️</div>
|
| 41 |
+
<h2 className="billing-title">Build Limit Reached</h2>
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
<p className="billing-sub">
|
| 45 |
+
You've used your 2 free autonomous chip builds! To continue generating RTL and running Silicon validations, you must provide your own LLM API key or upgrade your plan.
|
| 46 |
+
</p>
|
| 47 |
+
|
| 48 |
+
<div className="byok-section">
|
| 49 |
+
<h3 className="byok-title">Bring Your Own Key (BYOK)</h3>
|
| 50 |
+
<p className="byok-desc">
|
| 51 |
+
Enter an OpenAI, Anthropic, or Groq API key. Your key is <strong>AES-256 encrypted at rest</strong> using a secure server-side key and is never logged or exposed.
|
| 52 |
+
</p>
|
| 53 |
+
|
| 54 |
+
<input
|
| 55 |
+
className="byok-input"
|
| 56 |
+
type="password"
|
| 57 |
+
placeholder="sk-..."
|
| 58 |
+
value={apiKey}
|
| 59 |
+
onChange={e => setApiKey(e.target.value)}
|
| 60 |
+
autoFocus
|
| 61 |
+
/>
|
| 62 |
+
|
| 63 |
+
{error && <div className="byok-error">{error}</div>}
|
| 64 |
+
|
| 65 |
+
<button
|
| 66 |
+
className="action-btn byok-submit"
|
| 67 |
+
onClick={handleSaveKey}
|
| 68 |
+
disabled={saving || !apiKey.trim()}
|
| 69 |
+
>
|
| 70 |
+
{saving ? (
|
| 71 |
+
<span>Encrypting & Saving...</span>
|
| 72 |
+
) : (
|
| 73 |
+
<span>Save Encrypted Key →</span>
|
| 74 |
+
)}
|
| 75 |
+
</button>
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
<div className="billing-footer">
|
| 79 |
+
or <a href="mailto:sales@agentic.ai" className="billing-link">contact sales</a> for an Enterprise/Pro Plan.
|
| 80 |
+
</div>
|
| 81 |
+
</motion.div>
|
| 82 |
+
</div>
|
| 83 |
+
</AnimatePresence>
|
| 84 |
+
);
|
| 85 |
+
};
|
web/src/index.css
CHANGED
|
@@ -3482,3 +3482,184 @@ body {
|
|
| 3482 |
color: var(--fail);
|
| 3483 |
border-color: var(--fail-bdr);
|
| 3484 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3482 |
color: var(--fail);
|
| 3483 |
border-color: var(--fail-bdr);
|
| 3484 |
}
|
| 3485 |
+
|
| 3486 |
+
/* ==========================================================================
|
| 3487 |
+
BILLING MODAL (BYOK)
|
| 3488 |
+
========================================================================== */
|
| 3489 |
+
|
| 3490 |
+
.billing-modal-overlay {
|
| 3491 |
+
position: fixed;
|
| 3492 |
+
top: 0;
|
| 3493 |
+
left: 0;
|
| 3494 |
+
width: 100vw;
|
| 3495 |
+
height: 100vh;
|
| 3496 |
+
background: rgba(12, 11, 10, 0.85);
|
| 3497 |
+
backdrop-filter: blur(8px);
|
| 3498 |
+
-webkit-backdrop-filter: blur(8px);
|
| 3499 |
+
z-index: 9999;
|
| 3500 |
+
display: flex;
|
| 3501 |
+
align-items: center;
|
| 3502 |
+
justify-content: center;
|
| 3503 |
+
padding: 1rem;
|
| 3504 |
+
}
|
| 3505 |
+
|
| 3506 |
+
.billing-modal-content {
|
| 3507 |
+
width: 100%;
|
| 3508 |
+
max-width: 480px;
|
| 3509 |
+
position: relative;
|
| 3510 |
+
padding: 2rem !important;
|
| 3511 |
+
background: linear-gradient(180deg, var(--bg-card) 0%, var(--bg) 100%) !important;
|
| 3512 |
+
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05) !important;
|
| 3513 |
+
overflow: visible !important;
|
| 3514 |
+
}
|
| 3515 |
+
|
| 3516 |
+
.billing-modal-close {
|
| 3517 |
+
position: absolute;
|
| 3518 |
+
top: 1.25rem;
|
| 3519 |
+
right: 1.25rem;
|
| 3520 |
+
background: transparent;
|
| 3521 |
+
border: none;
|
| 3522 |
+
color: var(--text-dim);
|
| 3523 |
+
font-size: 1.2rem;
|
| 3524 |
+
cursor: pointer;
|
| 3525 |
+
transition: color var(--fast);
|
| 3526 |
+
padding: 0.2rem;
|
| 3527 |
+
}
|
| 3528 |
+
|
| 3529 |
+
.billing-modal-close:hover {
|
| 3530 |
+
color: var(--text);
|
| 3531 |
+
}
|
| 3532 |
+
|
| 3533 |
+
.billing-header {
|
| 3534 |
+
display: flex;
|
| 3535 |
+
align-items: center;
|
| 3536 |
+
gap: 0.75rem;
|
| 3537 |
+
margin-bottom: 1rem;
|
| 3538 |
+
}
|
| 3539 |
+
|
| 3540 |
+
.billing-icon {
|
| 3541 |
+
font-size: 1.5rem;
|
| 3542 |
+
}
|
| 3543 |
+
|
| 3544 |
+
.billing-title {
|
| 3545 |
+
font-size: 1.25rem;
|
| 3546 |
+
font-weight: 700;
|
| 3547 |
+
color: var(--text);
|
| 3548 |
+
margin: 0;
|
| 3549 |
+
letter-spacing: -0.02em;
|
| 3550 |
+
}
|
| 3551 |
+
|
| 3552 |
+
.billing-sub {
|
| 3553 |
+
color: var(--text-mid);
|
| 3554 |
+
font-size: 0.95rem;
|
| 3555 |
+
line-height: 1.6;
|
| 3556 |
+
margin-bottom: 2rem;
|
| 3557 |
+
}
|
| 3558 |
+
|
| 3559 |
+
.byok-section {
|
| 3560 |
+
background: var(--bg);
|
| 3561 |
+
border: 1px solid var(--border-mid);
|
| 3562 |
+
border-radius: var(--radius-md);
|
| 3563 |
+
padding: 1.5rem;
|
| 3564 |
+
margin-bottom: 1.5rem;
|
| 3565 |
+
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.1);
|
| 3566 |
+
}
|
| 3567 |
+
|
| 3568 |
+
.byok-title {
|
| 3569 |
+
font-size: 0.95rem;
|
| 3570 |
+
font-weight: 600;
|
| 3571 |
+
color: var(--text);
|
| 3572 |
+
margin: 0 0 0.5rem 0;
|
| 3573 |
+
display: flex;
|
| 3574 |
+
align-items: center;
|
| 3575 |
+
gap: 0.5rem;
|
| 3576 |
+
}
|
| 3577 |
+
|
| 3578 |
+
.byok-title::before {
|
| 3579 |
+
content: "🔑";
|
| 3580 |
+
font-size: 1rem;
|
| 3581 |
+
}
|
| 3582 |
+
|
| 3583 |
+
.byok-desc {
|
| 3584 |
+
font-size: 0.82rem;
|
| 3585 |
+
color: var(--text-dim);
|
| 3586 |
+
margin: 0 0 1.25rem 0;
|
| 3587 |
+
line-height: 1.5;
|
| 3588 |
+
}
|
| 3589 |
+
|
| 3590 |
+
.byok-desc strong {
|
| 3591 |
+
color: var(--text-mid);
|
| 3592 |
+
font-weight: 600;
|
| 3593 |
+
}
|
| 3594 |
+
|
| 3595 |
+
.byok-input {
|
| 3596 |
+
width: 100%;
|
| 3597 |
+
background: var(--bg-card);
|
| 3598 |
+
border: 1px solid var(--border);
|
| 3599 |
+
border-radius: var(--radius);
|
| 3600 |
+
padding: 0.85rem 1rem;
|
| 3601 |
+
color: var(--text);
|
| 3602 |
+
font-family: 'Fira Code', monospace;
|
| 3603 |
+
font-size: 0.9rem;
|
| 3604 |
+
outline: none;
|
| 3605 |
+
transition: all var(--fast);
|
| 3606 |
+
margin-bottom: 1rem;
|
| 3607 |
+
}
|
| 3608 |
+
|
| 3609 |
+
.byok-input:focus {
|
| 3610 |
+
border-color: var(--accent);
|
| 3611 |
+
box-shadow: 0 0 0 2px rgba(201, 100, 62, 0.15);
|
| 3612 |
+
}
|
| 3613 |
+
|
| 3614 |
+
.byok-error {
|
| 3615 |
+
color: var(--fail);
|
| 3616 |
+
font-size: 0.85rem;
|
| 3617 |
+
margin-bottom: 1rem;
|
| 3618 |
+
display: flex;
|
| 3619 |
+
align-items: center;
|
| 3620 |
+
gap: 0.4rem;
|
| 3621 |
+
}
|
| 3622 |
+
|
| 3623 |
+
.byok-error::before {
|
| 3624 |
+
content: "✕";
|
| 3625 |
+
font-weight: 700;
|
| 3626 |
+
}
|
| 3627 |
+
|
| 3628 |
+
.byok-submit {
|
| 3629 |
+
width: 100%;
|
| 3630 |
+
background: var(--text);
|
| 3631 |
+
color: var(--bg);
|
| 3632 |
+
border: none;
|
| 3633 |
+
padding: 0.85rem;
|
| 3634 |
+
display: flex;
|
| 3635 |
+
justify-content: center;
|
| 3636 |
+
align-items: center;
|
| 3637 |
+
}
|
| 3638 |
+
|
| 3639 |
+
.byok-submit:not(:disabled):hover {
|
| 3640 |
+
background: #FFF;
|
| 3641 |
+
transform: translateY(-1px);
|
| 3642 |
+
box-shadow: 0 4px 12px rgba(255, 255, 255, 0.15);
|
| 3643 |
+
}
|
| 3644 |
+
|
| 3645 |
+
.byok-submit:disabled {
|
| 3646 |
+
opacity: 0.5;
|
| 3647 |
+
cursor: not-allowed;
|
| 3648 |
+
}
|
| 3649 |
+
|
| 3650 |
+
.billing-footer {
|
| 3651 |
+
text-align: center;
|
| 3652 |
+
font-size: 0.85rem;
|
| 3653 |
+
color: var(--text-dim);
|
| 3654 |
+
}
|
| 3655 |
+
|
| 3656 |
+
.billing-link {
|
| 3657 |
+
color: var(--accent);
|
| 3658 |
+
text-decoration: none;
|
| 3659 |
+
font-weight: 500;
|
| 3660 |
+
transition: color var(--fast);
|
| 3661 |
+
}
|
| 3662 |
+
|
| 3663 |
+
.billing-link:hover {
|
| 3664 |
+
text-decoration: underline;
|
| 3665 |
+
}
|
web/src/pages/DesignStudio.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react';
|
|
| 2 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 3 |
import { BuildMonitor } from '../components/BuildMonitor';
|
| 4 |
import { ChipSummary } from '../components/ChipSummary';
|
|
|
|
| 5 |
import { fetchEventSource } from '@microsoft/fetch-event-source';
|
| 6 |
import { api, API_BASE } from '../api';
|
| 7 |
|
|
@@ -44,6 +45,10 @@ export const DesignStudio = () => {
|
|
| 44 |
const [result, setResult] = useState<any>(null);
|
| 45 |
const [error, setError] = useState('');
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
// Build Options
|
| 48 |
const [skipOpenlane, setSkipOpenlane] = useState(false);
|
| 49 |
const [skipCoverage, setSkipCoverage] = useState(false);
|
|
@@ -74,9 +79,26 @@ export const DesignStudio = () => {
|
|
| 74 |
}
|
| 75 |
}, [prompt]);
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
const handleLaunch = async () => {
|
| 78 |
if (!prompt.trim()) return;
|
| 79 |
setError('');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
try {
|
| 81 |
const res = await api.post(`/build`, {
|
| 82 |
design_name: designName || slugify(prompt),
|
|
@@ -438,6 +460,15 @@ export const DesignStudio = () => {
|
|
| 438 |
)}
|
| 439 |
|
| 440 |
</AnimatePresence>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 441 |
</div>
|
| 442 |
);
|
| 443 |
};
|
|
|
|
| 2 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 3 |
import { BuildMonitor } from '../components/BuildMonitor';
|
| 4 |
import { ChipSummary } from '../components/ChipSummary';
|
| 5 |
+
import { BillingModal } from '../components/BillingModal';
|
| 6 |
import { fetchEventSource } from '@microsoft/fetch-event-source';
|
| 7 |
import { api, API_BASE } from '../api';
|
| 8 |
|
|
|
|
| 45 |
const [result, setResult] = useState<any>(null);
|
| 46 |
const [error, setError] = useState('');
|
| 47 |
|
| 48 |
+
// Billing / Profile State
|
| 49 |
+
const [profile, setProfile] = useState<{ auth_enabled: boolean, plan: string, successful_builds: number, has_byok_key: boolean } | null>(null);
|
| 50 |
+
const [showBillingModal, setShowBillingModal] = useState(false);
|
| 51 |
+
|
| 52 |
// Build Options
|
| 53 |
const [skipOpenlane, setSkipOpenlane] = useState(false);
|
| 54 |
const [skipCoverage, setSkipCoverage] = useState(false);
|
|
|
|
| 79 |
}
|
| 80 |
}, [prompt]);
|
| 81 |
|
| 82 |
+
// Fetch Profile Limits
|
| 83 |
+
useEffect(() => {
|
| 84 |
+
api.get('/profile')
|
| 85 |
+
.then(res => setProfile(res.data))
|
| 86 |
+
.catch(() => setProfile(null)); // Ignored explicitly if no auth
|
| 87 |
+
}, []);
|
| 88 |
+
|
| 89 |
const handleLaunch = async () => {
|
| 90 |
if (!prompt.trim()) return;
|
| 91 |
setError('');
|
| 92 |
+
|
| 93 |
+
// Billing Guard: enforce 2 free successful builds
|
| 94 |
+
if (profile?.auth_enabled) {
|
| 95 |
+
const { plan, successful_builds, has_byok_key } = profile;
|
| 96 |
+
if (plan === 'free' && successful_builds >= 2 && !has_byok_key) {
|
| 97 |
+
setShowBillingModal(true);
|
| 98 |
+
return;
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
try {
|
| 103 |
const res = await api.post(`/build`, {
|
| 104 |
design_name: designName || slugify(prompt),
|
|
|
|
| 460 |
)}
|
| 461 |
|
| 462 |
</AnimatePresence>
|
| 463 |
+
|
| 464 |
+
<BillingModal
|
| 465 |
+
isOpen={showBillingModal}
|
| 466 |
+
onClose={() => setShowBillingModal(false)}
|
| 467 |
+
onKeySaved={() => {
|
| 468 |
+
// Update profile locally to unblock
|
| 469 |
+
setProfile(prev => prev ? { ...prev, has_byok_key: true } : null);
|
| 470 |
+
}}
|
| 471 |
+
/>
|
| 472 |
</div>
|
| 473 |
);
|
| 474 |
};
|