GrantForge Bot
Deploy sha-565ad85979610064f6d1c18ab3b6404357d61073 — source build (no GHCR)
ce8f04a
Raw
History Blame Contribute Delete
49.5 kB
import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Search, Sparkles, Building, ChevronRight, Loader2, CheckCircle, Cpu, Tractor, HardHat, Info } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@clerk/clerk-react';
import { createProject, lookupCompany, matchProgram } from '../../api/client';
import toast from 'react-hot-toast';
import { OnboardingTour } from '../onboarding/OnboardingTour';
import { wizardStep2TourSteps, wizardTourSteps } from '../onboarding/onboardingTours';
import { hasSeenTour, markTourSeen } from '../../utils/onboardingStorage';
import StrategyCascadePanel from '../company/StrategyCascadePanel';
interface Props {
onClose: () => void;
}
const WizardModal: React.FC<Props> = ({ onClose }) => {
const navigate = useNavigate();
const { userId } = useAuth();
const [step, setStep] = useState(1);
const [nip, setNip] = useState('');
const [desc, setDesc] = useState('');
const [isSearching, setIsSearching] = useState(false);
const [companyFound, setCompanyFound] = useState(false);
const [analyzing, setAnalyzing] = useState(false);
const [analysisLogs, setAnalysisLogs] = useState<string[]>([]);
const [selectedProgram, setSelectedProgram] = useState<number | null>(1);
const [expandedProgram, setExpandedProgram] = useState<number | null>(null);
const [companyDetails, setCompanyDetails] = useState<{
name: string;
status: string;
nip?: string;
sme_status?: string;
sme_confidence?: number;
sme_related_entities?: number;
regon?: string;
krs?: string;
pkd?: string[];
sources_used?: string[];
registry_score?: number;
gaps?: string[];
voivodeship?: string;
} | null>(null);
const [recommendedPrograms, setRecommendedPrograms] = useState<any[]>([]);
const [manualEntry, setManualEntry] = useState(false);
const [clarifyingQuestions, setClarifyingQuestions] = useState<string[]>([]);
const [clarificationAnswers, setClarificationAnswers] = useState<string[]>([]);
const [clarificationPanelOpen, setClarificationPanelOpen] = useState(false);
const [runTour, setRunTour] = useState(false);
const [runStep2Tour, setRunStep2Tour] = useState(false);
const [selectedStrategyPath, setSelectedStrategyPath] = useState<string | null>(null);
useEffect(() => {
if (step === 1 && !hasSeenTour('wizard')) {
const timer = window.setTimeout(() => setRunTour(true), 600);
return () => window.clearTimeout(timer);
}
if (step === 2 && !hasSeenTour('wizard')) {
const timer = window.setTimeout(() => setRunStep2Tour(true), 400);
return () => window.clearTimeout(timer);
}
return undefined;
}, [step]);
const handleSearchCompany = async () => {
const cleanedNip = nip.replace(/\D/g, '');
if (cleanedNip.length !== 10) {
toast.error("NIP musi składać się dokładnie z 10 cyfr.");
return;
}
setIsSearching(true);
try {
const data = await lookupCompany(cleanedNip);
setCompanyDetails({
name: data.name,
status: data.status,
nip: cleanedNip,
sme_status: data.sme_status,
sme_confidence: data.sme_confidence,
sme_related_entities: data.sme_related_entities,
regon: data.regon,
krs: data.krs,
pkd: data.pkd,
sources_used: data.sources_used,
registry_score: data.registry_score,
gaps: data.gaps,
voivodeship: data.voivodeship,
});
setCompanyFound(true);
setManualEntry(false);
} catch(e: any) {
const errMsg = e.response?.data?.detail || "Nie znaleziono w bazie GUS. Możesz kontynuować, wpisując nazwę ręcznie.";
toast.error(errMsg);
setCompanyFound(false);
setManualEntry(true);
setCompanyDetails({ name: '', status: 'Dane wprowadzone ręcznie', nip: nip });
} finally {
setIsSearching(false);
}
};
const handleStartAnalysis = async (
userAnswers?: { question: string; answer: string }[],
strategyPathOverride?: string | null,
opts?: { preserveClarifications?: boolean },
) => {
if (!desc && !userAnswers?.length) return;
setStep(3);
setAnalyzing(true);
const isPathRerank = Boolean(opts?.preserveClarifications);
setAnalysisLogs(prev => [
...prev,
userAnswers?.length
? 'Aktualizacja analizy z nowymi danymi...'
: isPathRerank
? 'Przeliczanie matchingu dla wybranej ścieżki strategii...'
: 'Inicjalizowanie silnika RAG...',
]);
const additionalContext = userAnswers
?.map((qa) => (qa.answer ? `Pytanie: ${qa.question}\nOdpowiedź: ${qa.answer}` : ''))
.filter(Boolean)
.join('\n\n');
const finalDesc = additionalContext ? `${desc}\n\n[DOPRECYZOWANIE]:\n${additionalContext}` : desc;
if (additionalContext) setDesc(finalDesc);
const pathForMatch =
strategyPathOverride !== undefined
? strategyPathOverride
: selectedStrategyPath;
try {
const data = await matchProgram(finalDesc, nip, userAnswers, {
strategy_path: pathForMatch || undefined,
});
setRecommendedPrograms(data.programs || []);
if (data.programs && data.programs.length > 0) setSelectedProgram(data.programs[0].id);
if (!opts?.preserveClarifications) {
const nextQuestions = data.clarifying_questions || [];
setClarifyingQuestions(nextQuestions);
setClarificationAnswers(new Array(nextQuestions.length).fill(''));
setClarificationPanelOpen(nextQuestions.length > 0);
}
// Prefer path returned by match (normalized) when present
if (typeof data.strategy_path === 'string' && data.strategy_path) {
setSelectedStrategyPath(data.strategy_path);
}
setAnalysisLogs(prev => [
...prev,
pathForMatch
? `Zakończono generowanie raportu dopasowania (ścieżka: ${pathForMatch}).`
: 'Zakończono generowanie raportu dopasowania.',
]);
} catch(e) {
toast.error("Wystąpił problem ze zgłaszaniem do modelu AI");
} finally {
setAnalyzing(false);
}
};
const renderStepContent = () => {
switch(step) {
case 1:
return (
<motion.div
key="step1"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem', width: '100%' }}
>
<div>
<h3 style={{ fontSize: '1.2rem', marginBottom: '0.4rem', color: 'var(--text-primary)' }}>Podłącz profil firmy</h3>
<p style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
Multi-rejestr: GUS BIR, CEIDG, KRS, Biała Lista MF, SUDOP (pomoc publiczna), Bizraport — podstawa wiarygodnego matchingu.
</p>
</div>
<div style={{ display: 'flex', gap: '1rem', width: '100%' }}>
<div style={{ flex: 1, position: 'relative' }}>
<div style={{ position: 'absolute', left: '1rem', top: '50%', transform: 'translateY(-50%)', color: 'var(--accent-blue)' }}>
<Building size={20} />
</div>
<input
type="text"
className="wizard-input"
placeholder="Wpisz NIP firmy..."
value={nip}
onChange={e => setNip(e.target.value)}
style={{ width: '100%', padding: '1rem 1rem 1rem 3rem', background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(59, 130, 246, 0.4)', borderRadius: '12px', color: '#fff', fontSize: '1.1rem', outline: 'none' }}
onKeyDown={e => e.key === 'Enter' && handleSearchCompany()}
/>
</div>
<button className="btn btn-secondary" onClick={handleSearchCompany} disabled={isSearching || nip.replace(/\D/g, '').length !== 10} style={{ minWidth: '160px' }}>
{isSearching ? <Loader2 size={20} className="spin" /> : <><Search size={18}/> Szukaj w rejestrach</>}
</button>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{import.meta.env.DEV && (
<button
className="btn"
onClick={() => {
setNip('5213641211');
setDesc('Jesteśmy firmą produkcyjną produkującą części do maszyn rolniczych. Planujemy zakup nowoczesnych robotów spawalniczych i obrabiarek CNC dla automatyzacji najcięższych procesów. Budżet projektu to 4.5 mln PLN. Wdrażamy innowację na skalę krajową.');
}}
style={{ fontSize: '0.85rem', color: 'var(--accent-blue)', background: 'rgba(59,130,246,0.1)', border: '1px dashed rgba(59,130,246,0.4)', padding: '0.5rem 1rem' }}
>
<Sparkles size={14} style={{ marginRight: '6px' }} />
Uzupełnij Zgłoszeniem Demo (tylko DEV)
</button>
)}
{!manualEntry && (
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)', cursor: 'pointer', textDecoration: 'underline', marginLeft: 'auto' }} onClick={() => { setManualEntry(true); setCompanyDetails({ name: '', status: 'Dane wprowadzone ręcznie', nip: nip }); }}>
Wprowadź dane ręcznie
</span>
)}
</div>
{companyFound && !manualEntry && companyDetails && (
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} style={{ display: 'flex', flexDirection: 'column', gap: '0.8rem', marginTop: '1rem' }}>
<div style={{ background: 'rgba(16, 185, 129, 0.1)', borderLeft: '4px solid var(--accent-green)', padding: '1rem', borderRadius: '8px' }}>
<div style={{ fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: '0.5rem', color: 'var(--accent-green)' }}><CheckCircle size={18}/> Zweryfikowano w rejestrach</div>
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem', color: 'var(--text-primary)' }}>NIP: {nip.replace(/\D/g, '')}</div>
<div style={{ fontSize: '0.9rem', color: 'var(--text-primary)' }}>{companyDetails.name}</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginTop: '0.2rem' }}>{companyDetails.status}</div>
{(companyDetails.regon || companyDetails.krs) && (
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.35rem' }}>
{companyDetails.regon ? `REGON: ${companyDetails.regon}` : ''}
{companyDetails.regon && companyDetails.krs ? ' · ' : ''}
{companyDetails.krs ? `KRS: ${companyDetails.krs}` : ''}
</div>
)}
{companyDetails.pkd && companyDetails.pkd.length > 0 && (
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.25rem' }}>
PKD: {companyDetails.pkd.slice(0, 5).join(', ')}
</div>
)}
{companyDetails.sources_used && companyDetails.sources_used.length > 0 && (
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: '0.4rem' }}>
Źródła: {companyDetails.sources_used.join(', ')}
{typeof companyDetails.registry_score === 'number' ? ` · wiarygodność rejestrów ${(companyDetails.registry_score * 100).toFixed(0)}%` : ''}
</div>
)}
</div>
{companyDetails.sme_status && (
<div style={{ background: 'rgba(56, 189, 248, 0.1)', borderLeft: '4px solid var(--accent-blue)', padding: '1rem', borderRadius: '8px' }}>
<div style={{ fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: '0.5rem', color: 'var(--accent-blue)' }}><Sparkles size={18}/> Weryfikacja MŚP (GraphRAG)</div>
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem', color: 'var(--text-primary)' }}>Wyliczony status: <strong>{companyDetails.sme_status.toUpperCase()}</strong></div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>Wykryte powiązania kapitałowe/osobowe: <strong>{companyDetails.sme_related_entities || 0}</strong> podmiotów</div>
</div>
)}
{companyDetails.gaps && companyDetails.gaps.length > 0 && (
<div style={{ background: 'rgba(245, 158, 11, 0.1)', borderLeft: '4px solid #f59e0b', padding: '0.85rem 1rem', borderRadius: '8px', fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
<strong style={{ color: '#f59e0b' }}>Luki do uzupełnienia:</strong> {companyDetails.gaps.slice(0, 3).join(' · ')}
</div>
)}
</motion.div>
)}
{manualEntry && (
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: '1rem' }}>
<label style={{ fontSize: '0.9rem', color: 'var(--text-secondary)' }}>Pełna nazwa firmy (podmiotu):</label>
<input
type="text"
value={companyDetails?.name || ''}
onChange={e => setCompanyDetails({ name: e.target.value, status: 'Dane wpisane ręcznie', nip: nip })}
placeholder="Wpisz pełną nazwę przedsiębiorstwa..."
style={{ width: '100%', padding: '1rem', background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(245, 158, 11, 0.4)', borderRadius: '12px', color: '#fff', fontSize: '1.1rem', outline: 'none' }}
/>
</motion.div>
)}
<button
className="btn btn-primary"
style={{ marginTop: '1rem', alignSelf: 'flex-end' }}
onClick={() => setStep(2)}
disabled={(!companyFound && !manualEntry) || (manualEntry && (!companyDetails?.name || companyDetails.name.trim() === ''))}
>
Przejdź dalej <ChevronRight size={18} />
</button>
</motion.div>
);
case 2:
return (
<motion.div
key="step2"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
style={{
display: 'flex',
flexDirection: 'column',
gap: '1.25rem',
width: '100%',
minHeight: 0,
flex: 1,
}}
>
<div>
<h3 style={{ fontSize: '1.2rem', marginBottom: '0.4rem', color: 'var(--text-primary)' }}>Opisz planowaną inwestycję</h3>
<p style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>Napisz swoimi słowami, co zamierzasz zrobić. Sztuczna Inteligencja przejdzie przez tekst wyciągając kryteria kluczowe dla dotacji.</p>
</div>
<div style={{ position: 'relative', width: '100%' }}>
<Sparkles size={20} color="var(--accent-green)" style={{ position: 'absolute', top: '1.2rem', left: '1.2rem' }} />
<textarea
value={desc}
onChange={e => setDesc(e.target.value)}
className="wizard-textarea"
placeholder="Np. Planujemy zakup nowej linii produkcyjnej z robotami spawalniczymi oraz wdrożenie platformy chmurowej AI do przewidywania awarii maszyn. Szacowany budżet inwestycji to ok. 6 mln PLN..."
style={{
width: '100%',
minHeight: '140px',
height: '180px',
maxHeight: '40vh',
padding: '1.2rem 1.2rem 1.2rem 3.2rem',
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(16, 185, 129, 0.4)',
borderRadius: '12px',
color: '#fff',
fontSize: '1.05rem',
lineHeight: '1.5',
outline: 'none',
resize: 'vertical',
}}
/>
</div>
{/* Cascade before match so strategy_path can soft-boost ranking */}
{desc.trim().length >= 20 && (
<div
className="wizard-strategy-panel"
style={{
background: 'rgba(167, 139, 250, 0.06)',
borderRadius: 16,
border: '1px solid rgba(167, 139, 250, 0.22)',
padding: '1rem',
maxHeight: 'min(42vh, 360px)',
overflowY: 'auto',
overflowX: 'hidden',
WebkitOverflowScrolling: 'touch',
overscrollBehavior: 'contain',
}}
>
<StrategyCascadePanel
nip={nip.replace(/\D/g, '')}
initialGoal={desc}
initialRegion={companyDetails?.voivodeship || ''}
companyData={
companyDetails
? {
name: companyDetails.name,
nip: companyDetails.nip || nip.replace(/\D/g, ''),
sme_status: companyDetails.sme_status,
pkd: companyDetails.pkd,
voivodeship: companyDetails.voivodeship,
}
: undefined
}
mode="post"
compact
selectedPathId={selectedStrategyPath}
onPathSelect={(id) => setSelectedStrategyPath(id)}
/>
</div>
)}
<button className="btn btn-primary" style={{ alignSelf: 'flex-end', background: 'linear-gradient(90deg, var(--accent-green), var(--accent-blue))', padding: '1rem 2rem', fontSize: '1.1rem', boxShadow: '0 0 20px rgba(16, 185, 129, 0.3)' }} onClick={() => handleStartAnalysis()} disabled={!desc}>
<Sparkles size={20} /> Rozpocznij Analizę AI
{selectedStrategyPath ? ` · ${selectedStrategyPath}` : ''}
</button>
</motion.div>
);
case 3: {
const activeExpandedProg = expandedProgram ? recommendedPrograms.find((p: any) => p.id === expandedProgram) : null;
return (
<motion.div
key="step3"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '2rem', width: '100%', minHeight: '350px' }}
>
{analyzing ? (
<>
<div style={{ position: 'relative', width: '100px', height: '100px' }}>
<motion.div animate={{ rotate: 360 }} transition={{ repeat: Infinity, duration: 4, ease: 'linear' }} style={{ position: 'absolute', inset: 0, borderRadius: '50%', border: '4px solid transparent', borderTopColor: 'var(--accent-green)', borderRightColor: 'var(--accent-blue)' }}></motion.div>
<motion.div animate={{ rotate: -360 }} transition={{ repeat: Infinity, duration: 3, ease: 'linear' }} style={{ position: 'absolute', inset: '10px', borderRadius: '50%', border: '4px solid transparent', borderTopColor: 'var(--accent-blue)', borderLeftColor: 'var(--accent-green)' }}></motion.div>
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Sparkles size={32} color="var(--accent-green)" />
</div>
</div>
<div style={{ textAlign: 'center', width: '100%' }}>
<h3 style={{ fontSize: '1.5rem', marginBottom: '1rem', color: 'var(--text-primary)' }}>Silnik RAG przetwarza wniosek...</h3>
<div style={{ height: '80px', display: 'flex', flexDirection: 'column', gap: '0.4rem', alignItems: 'center', color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
<AnimatePresence mode="popLayout">
{analysisLogs.slice(-2).map((log, i) => (
<motion.div key={log} initial={{ opacity: 0, y: 10 }} animate={{ opacity: i === 1 ? 1 : 0.5, y: 0 }} exit={{ opacity: 0 }}>
{log}
</motion.div>
))}
</AnimatePresence>
</div>
</div>
</>
) : (
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} style={{ display: 'flex', flexDirection: 'column', width: '100%', gap: '1rem', marginTop: '-1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<h2 style={{ fontSize: '1.4rem', color: 'var(--text-primary)' }}>Wybierz docelowy program dotacyjny</h2>
<div style={{ padding: '0.3rem 0.8rem', background: 'rgba(16,185,129,0.1)', color: 'var(--accent-green)', borderRadius: '20px', fontSize: '0.8rem', fontWeight: 'bold' }}>{recommendedPrograms.length} szans zidentyfikowanych</div>
</div>
<div
style={{
marginBottom: '1rem',
background: 'rgba(167, 139, 250, 0.06)',
borderRadius: 16,
border: '1px solid rgba(167, 139, 250, 0.22)',
padding: '1rem',
}}
>
<StrategyCascadePanel
nip={nip.replace(/\D/g, '')}
initialGoal={desc}
initialRegion={companyDetails?.voivodeship || ''}
companyData={
companyDetails
? {
name: companyDetails.name,
nip: companyDetails.nip || nip.replace(/\D/g, ''),
sme_status: companyDetails.sme_status,
pkd: companyDetails.pkd,
voivodeship: companyDetails.voivodeship,
}
: undefined
}
mode="post"
compact
selectedPathId={selectedStrategyPath}
onPathSelect={(id) => {
const changed = id !== selectedStrategyPath;
setSelectedStrategyPath(id);
// Re-rank only when the user (or primary auto-select) picks a *new* path
if (changed && recommendedPrograms.length > 0 && !analyzing) {
void handleStartAnalysis(undefined, id, {
preserveClarifications: true,
});
}
}}
/>
</div>
{clarifyingQuestions.length > 0 && (
<div style={{ marginBottom: '1rem', background: 'rgba(56, 189, 248, 0.05)', borderRadius: '16px', border: '1px solid rgba(56, 189, 248, 0.2)', overflow: 'hidden' }}>
<div
style={{ padding: '1rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer', background: 'rgba(56, 189, 248, 0.1)' }}
onClick={() => setClarificationPanelOpen(!clarificationPanelOpen)}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Sparkles size={18} color="var(--accent-blue)" />
<strong style={{ color: 'var(--accent-blue)', fontSize: '0.95rem' }}>Doprecyzuj swój projekt, by uzyskać lepsze dopasowanie</strong>
</div>
<ChevronRight size={18} color="var(--accent-blue)" style={{ transform: clarificationPanelOpen ? 'rotate(90deg)' : 'none', transition: '0.3s' }} />
</div>
<AnimatePresence>
{clarificationPanelOpen && (
<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }} exit={{ height: 0, opacity: 0 }}>
<div style={{ padding: '1rem' }}>
<p style={{ color: 'var(--text-secondary)', fontSize: '0.9rem', marginBottom: '1rem' }}>AI zauważyło, że brakuje kilku ważnych informacji. Odpowiedz na poniższe pytania, a system poszuka lepszych dotacji.</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.8rem' }}>
{clarifyingQuestions.map((q, idx) => (
<div key={idx} style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
<span style={{ color: 'var(--text-primary)', fontSize: '0.9rem' }}>{q}</span>
<input
type="text"
value={clarificationAnswers[idx] || ''}
onChange={e => {
const newAnswers = [...clarificationAnswers];
newAnswers[idx] = e.target.value;
setClarificationAnswers(newAnswers);
}}
className="wizard-input"
placeholder="Twoja odpowiedź..."
style={{ width: '100%', padding: '0.7rem', background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(56, 189, 248, 0.3)', borderRadius: '8px', color: '#fff', fontSize: '0.95rem', outline: 'none' }}
/>
</div>
))}
</div>
<button
className="btn btn-primary"
style={{ marginTop: '1rem', width: '100%', background: 'var(--accent-blue)', color: '#000', fontWeight: 'bold' }}
onClick={() => {
const userAnswers = clarifyingQuestions
.map((q, i) => ({
question: q,
answer: (clarificationAnswers[i] || '').trim(),
}))
.filter((qa) => qa.answer);
if (userAnswers.length) handleStartAnalysis(userAnswers);
else toast.error('Podaj przynajmniej jedną odpowiedź.');
}}
>
Zaktualizuj dopasowanie
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(230px, 1fr))', gap: '1rem', maxHeight: '380px', overflowY: 'auto', paddingRight: '0.5rem' }}>
{recommendedPrograms.map((prog) => (
<div key={prog.id}
className="hover-lift"
style={{
borderRadius: '16px',
border: selectedProgram === prog.id ? '2px solid var(--accent-green)' : '1px solid rgba(255,255,255,0.05)',
background: selectedProgram === prog.id ? 'rgba(16, 185, 129, 0.05)' : 'rgba(255,255,255,0.02)',
transition: 'all 0.2s',
boxShadow: selectedProgram === prog.id ? '0 0 20px rgba(16,185,129,0.1)' : 'none',
cursor: 'pointer',
display: 'flex', flexDirection: 'column'
}}
onClick={() => setSelectedProgram(prog.id)}
>
<div style={{ padding: '1.2rem', display: 'flex', flexDirection: 'column', gap: '1rem', flex: 1 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ padding: '0.7rem', borderRadius: '12px', background: 'rgba(255,255,255,0.08)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{prog.type === 'SMART' && <Cpu size={24} color="var(--accent-blue)" />}
{prog.type === 'ARIMR' && <Tractor size={24} color="var(--accent-green)" />}
{prog.type === 'ZUS_BHP' && <HardHat size={24} color="var(--accent-orange)" />}
</div>
<div style={{ width: '22px', height: '22px', borderRadius: '50%', border: '2px solid', borderColor: selectedProgram === prog.id ? 'var(--accent-green)' : 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
{selectedProgram === prog.id && <div style={{ width: '12px', height: '12px', borderRadius: '50%', background: 'var(--accent-green)' }}></div>}
</div>
</div>
<div>
<div style={{ fontWeight: 800, fontSize: '1.1rem', color: 'var(--text-primary)', marginBottom: '0.4rem' }}>{prog.name}</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
{prog.shortDesc}
</div>
</div>
<div style={{ marginTop: 'auto', display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '0.8rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
<div style={{ fontSize: '0.75rem', fontWeight: 'bold', display: 'inline-block', padding: '0.2rem 0.6rem', borderRadius: '4px', background: prog.match > 80 ? 'rgba(16,185,129,0.2)' : prog.match > 60 ? 'rgba(245,158,11,0.2)' : 'rgba(239,68,68,0.2)', color: prog.match > 80 ? 'var(--accent-green)' : prog.match > 60 ? 'var(--accent-orange)' : 'var(--accent-red)', width: 'fit-content' }}>
MATCH: {prog.match}%
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>Maks: <span style={{ color: 'var(--text-primary)', fontWeight: 'bold' }}>{prog.amount}</span></div>
</div>
<button
onClick={(e) => { e.stopPropagation(); setExpandedProgram(expandedProgram === prog.id ? null : prog.id); }}
className="btn hover-lift"
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', padding: '0.4rem', color: 'var(--text-secondary)', borderRadius: '8px' }}
title="Więcej informacji"
>
<Info size={16} />
</button>
</div>
</div>
</div>
))}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '1rem' }}>
<button
className="btn btn-primary"
onClick={async () => {
onClose();
const dynName = desc ? (desc.length > 100 ? `${desc.slice(0, 100)}...` : desc) : `Nowy Projekt - ${new Date().toLocaleDateString()}`;
const recommendedProgram = recommendedPrograms.find(p => p.id === selectedProgram);
const programName = recommendedProgram?.name || 'Nieznany Program';
const programType = recommendedProgram?.type || 'SMART';
const grantAmount = recommendedProgram?.amount || 'Nie określono';
try {
const newProject = await createProject({
title: dynName,
description: desc,
program_type: programType,
program_name: programName,
external_context: {
company_data: companyDetails,
resources: [],
grant_amount: grantAmount,
strategy_path: selectedStrategyPath || undefined,
}
});
const rt = await import('react-hot-toast');
if (newProject?.gsd?.requires_user_confirmation && newProject?.gsd?.hitl_question) {
rt.toast(
`Wymagane potwierdzenie: ${newProject.gsd.hitl_question.title}`,
{ icon: '⚠️', duration: 8000 },
);
} else {
rt.toast.success("Projekt został wygenerowany pomyślnie.");
}
navigate(`/projects/${newProject.id}`, { state: { projectName: dynName } });
} catch (err) {
console.error(err);
import('react-hot-toast').then(rt => rt.toast.error("Błąd podczas tworzenia projektu."));
}
}}
disabled={!selectedProgram}
style={{
padding: '1rem 2rem',
fontWeight: 'bold',
background: selectedProgram ? 'linear-gradient(90deg, var(--accent-green), var(--accent-blue))' : 'rgba(255,255,255,0.05)',
color: selectedProgram ? '#000' : 'rgba(255,255,255,0.4)',
boxShadow: selectedProgram ? '0 0 20px rgba(16,185,129,0.3)' : 'none'
}}
>
{selectedProgram ? 'Przejdź do Generatora Wniosku' : 'Wybierz program aby kontynuować'}
</button>
</div>
{/* MODAL DETALI (OVERLAY) */}
<AnimatePresence>
{activeExpandedProg && (
<motion.div
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
style={{
position: 'absolute',
inset: 0,
background: 'rgba(11, 14, 20, 0.95)',
backdropFilter: 'blur(10px)',
borderRadius: '16px',
padding: '2.5rem',
display: 'flex',
flexDirection: 'column',
zIndex: 20
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '1.5rem' }}>
<h2 style={{ fontSize: '1.8rem', color: 'var(--text-primary)', margin: 0 }}>{activeExpandedProg.name}</h2>
</div>
<div style={{ display: 'flex', gap: '1rem', marginBottom: '2rem' }}>
<span style={{ background: 'rgba(16,185,129,0.1)', color: 'var(--accent-green)', padding: '0.4rem 1rem', borderRadius: '6px', fontSize: '0.9rem', fontWeight: 'bold' }}>
Szansa wg AI: {activeExpandedProg.chance}
</span>
<span style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--text-secondary)', padding: '0.4rem 1rem', borderRadius: '6px', fontSize: '0.9rem' }}>
Kwota wsparcia: {activeExpandedProg.amount}
</span>
</div>
<div style={{ flex: 1, overflowY: 'auto', paddingRight: '1rem' }}>
<div style={{ fontSize: '1.15rem', color: 'var(--text-primary)', marginBottom: '1.5rem', lineHeight: '1.6' }}>
{activeExpandedProg.shortDesc}
</div>
{activeExpandedProg.explanation && (
<div style={{ marginBottom: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div style={{ background: 'rgba(16, 185, 129, 0.05)', padding: '1.2rem', borderRadius: '12px', border: '1px solid rgba(16, 185, 129, 0.2)' }}>
<strong style={{ color: 'var(--accent-green)', display: 'block', marginBottom: '0.6rem', fontSize: '1.05rem' }}>Dlaczego ten program jest dla Ciebie najlepszy?</strong>
<span style={{ color: 'var(--text-primary)', fontSize: '0.95rem', lineHeight: '1.5' }}>{activeExpandedProg.explanation.reason}</span>
</div>
{activeExpandedProg.explanation.criteria && activeExpandedProg.explanation.criteria.length > 0 && (
<div style={{ background: 'rgba(255,255,255,0.03)', padding: '1.2rem', borderRadius: '12px', border: '1px solid rgba(255,255,255,0.05)' }}>
<strong style={{ color: 'var(--text-primary)', display: 'block', marginBottom: '0.6rem', fontSize: '1.05rem' }}>Główne kryteria, które spełniasz:</strong>
<ul style={{ margin: 0, paddingLeft: '1.2rem', color: 'var(--text-secondary)', fontSize: '0.95rem', lineHeight: '1.6' }}>
{activeExpandedProg.explanation.criteria.map((crit: string, idx: number) => (
<li key={idx} style={{ marginBottom: '0.3rem' }}>{crit}</li>
))}
</ul>
</div>
)}
{activeExpandedProg.explanation.risks && (
<div style={{ background: 'rgba(239, 68, 68, 0.05)', padding: '1.2rem', borderRadius: '12px', border: '1px solid rgba(239, 68, 68, 0.2)' }}>
<strong style={{ color: 'var(--accent-red)', display: 'block', marginBottom: '0.6rem', fontSize: '1.05rem' }}>Potencjalne wyzwania do zaadresowania:</strong>
<span style={{ color: 'var(--text-secondary)', fontSize: '0.95rem', lineHeight: '1.5' }}>{activeExpandedProg.explanation.risks}</span>
</div>
)}
</div>
)}
<div style={{ fontSize: '1rem', color: 'var(--text-secondary)', lineHeight: '1.7', background: 'rgba(56, 189, 248, 0.05)', padding: '1.5rem', borderRadius: '16px', border: '1px solid rgba(56, 189, 248, 0.1)' }}>
<strong style={{ color: 'var(--accent-blue)', display: 'block', marginBottom: '0.8rem', fontSize: '1.1rem' }}><Sparkles size={18} style={{ display: 'inline', position: 'relative', top: '3px', marginRight: '6px' }} /> Raport zgodności:</strong>
{activeExpandedProg.fullDesc}
</div>
{activeExpandedProg.regulation_grounding && (
<div style={{ marginTop: '1.5rem', fontSize: '0.95rem', color: 'var(--text-secondary)', lineHeight: '1.6', background: 'rgba(255, 255, 255, 0.02)', padding: '1.5rem', borderRadius: '16px', border: '1px solid rgba(255, 255, 255, 0.05)' }}>
<strong style={{ color: 'var(--text-primary)', display: 'block', marginBottom: '0.8rem', fontSize: '1.05rem' }}>Uzasadnienie regulaminowe (EUR-Lex / PARP):</strong>
<div style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace', fontSize: '0.85rem', color: 'rgba(255,255,255,0.7)', maxHeight: '150px', overflowY: 'auto', padding: '1rem', background: 'rgba(0,0,0,0.3)', borderRadius: '8px' }}>
{activeExpandedProg.regulation_grounding}
</div>
</div>
)}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '1rem', marginTop: '1.5rem', borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '1.5rem' }}>
<button className="btn" onClick={() => setExpandedProgram(null)} style={{ padding: '0.8rem 1.5rem', background: 'transparent', color: 'var(--text-secondary)', border: '1px solid rgba(255,255,255,0.1)' }}>
Wróć do listy
</button>
<button
className="btn btn-primary"
onClick={() => { setSelectedProgram(activeExpandedProg.id); setExpandedProgram(null); }}
style={{ padding: '0.8rem 1.5rem', background: 'var(--accent-green)', color: '#000', fontWeight: 'bold' }}
>
Wybieram i kontynuuję
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)}
</motion.div>
);
}
}
};
return createPortal(
<AnimatePresence>
<motion.div
className="pricing-backdrop wizard-modal-backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.85)',
backdropFilter: 'blur(5px)',
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'center',
zIndex: 9999,
overflowY: 'auto',
overflowX: 'hidden',
padding: '1.5rem 0.75rem',
WebkitOverflowScrolling: 'touch',
}}
onClick={(e) => {
// click outside card closes only if intentional on backdrop
if (e.target === e.currentTarget && step < 3) onClose();
}}
>
<motion.div
className="glass-card wizard-modal-card"
style={{
width: '800px',
maxWidth: '95%',
maxHeight: 'min(92vh, 920px)',
background: '#0B0E14',
padding: '0',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
margin: 'auto 0',
flexShrink: 0,
}}
initial={{ scale: 0.95, y: 20 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.95, y: 20 }}
onClick={(e) => e.stopPropagation()}
>
{/* Header — sticky within card */}
<div style={{
padding: '1.25rem 1.5rem',
borderBottom: '1px solid rgba(255,255,255,0.05)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
background: 'rgba(255,255,255,0.02)',
flexShrink: 0,
}}>
<div>
<h2 style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', fontSize: '1.4rem' }}>
<Sparkles color="var(--accent-blue)" /> Nowy Projekt Dotacyjny
</h2>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.8rem' }} className="wizard-step-indicator">
{[1, 2, 3].map(i => (
<div key={i} style={{ height: '4px', width: '40px', background: i <= step ? 'var(--accent-green)' : 'rgba(255,255,255,0.1)', borderRadius: '2px', transition: '0.3s all' }}></div>
))}
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginLeft: '0.5rem' }}>Krok {step} / 3</span>
</div>
</div>
{step < 3 && <X size={24} style={{ cursor: 'pointer', color: 'var(--text-muted)', alignSelf: 'flex-start' }} onClick={onClose} />}
</div>
{/* Body — scrollable when strategy cascade / programs expand */}
<div
className="wizard-modal-body custom-scrollbar"
style={{
padding: '1.5rem 1.5rem 2rem',
minHeight: '280px',
flex: '1 1 auto',
overflowY: 'auto',
overflowX: 'hidden',
display: 'flex',
WebkitOverflowScrolling: 'touch',
overscrollBehavior: 'contain',
}}
>
<AnimatePresence mode="wait">
{renderStepContent()}
</AnimatePresence>
</div>
{runTour && step === 1 && (
<OnboardingTour
run={runTour}
steps={wizardTourSteps}
onFinish={() => setRunTour(false)}
/>
)}
{runStep2Tour && step === 2 && (
<OnboardingTour
run={runStep2Tour}
steps={wizardStep2TourSteps}
onFinish={() => {
setRunStep2Tour(false);
markTourSeen('wizard');
}}
/>
)}
</motion.div>
</motion.div>
</AnimatePresence>,
document.body
);
};
export default WizardModal;