Compare commits

..

12 Commits

Author SHA1 Message Date
60127ec0a7 added AGENTS.md 2026-06-15 17:39:33 +02:00
460f357d48 update UI after select category and subcategory 2026-06-15 17:37:46 +02:00
cea97332b4 implemented step 20 by Gemini
- README documentation
2026-06-15 05:25:10 +02:00
8ed5413116 added loop mode for worker.php 2026-06-15 05:14:21 +02:00
c0f13495ce implemented step 19 by Gemini
- Preview and Export
2026-06-15 05:05:41 +02:00
4135b621c4 smtp and viewr password moved to generated config.php 2026-06-15 04:55:34 +02:00
269cc5f5d5 implemented step 18 by Gemini
- Contact formular and emailer script
2026-06-15 04:49:26 +02:00
029d7a232a implemented step 17 by Gemini
- Rendering CSS assets
2026-06-15 04:42:54 +02:00
d350daa1d8 imlemented step 16 by Gemini
- Template
2026-06-15 04:38:05 +02:00
7efd6b24a5 implemented step 15 by Gemini
- Rendering HTML
2026-06-15 04:30:46 +02:00
4f62bb7aa7 changed timeout for DAIClient to 10 minutes 2026-06-14 16:48:34 +02:00
7f3870a95b changed pooling interval from 3s to 10s 2026-06-14 14:42:30 +02:00
25 changed files with 1363 additions and 109 deletions

58
AGENTS.md Normal file
View File

@ -0,0 +1,58 @@
# WebWizard - Agent's Project Guide
Welcome! This file provides a high-level overview of the **WebWizard** project to help you understand the architecture, conventions, and workflows.
## 📝 Project Overview
WebWizard is an **AI-driven website concierge** (MVP) that allows users to create static websites through a guided wizard. It collects business information, uses AI (DAIAPI) to generate marketing copy and SEO metadata, and then renders a complete, standalone static website for export.
## 🛠️ Technical Stack
- **Backend:** PHP 8.2+ (Pure PHP, no external frameworks like Laravel or Symfony).
- **Frontend:** Vanilla JavaScript (ES6+), CSS Grid/Flexbox (No UI libraries like Bootstrap).
- **Data Persistence:** File-based JSON storage in the `data/` directory. No database.
- **Autoloading:** Composer (PSR-4, Namespace: `App\`).
- **Indentation:** **STRICTLY TABS** (Global preference).
## 🏗️ Architecture & Workflow
The application follows a linear data-gathering process:
1. **Wizard (Frontend)**: 8 steps in `public/index.html` managed by `public/js/wizard.js`.
2. **API Layer**: All communication goes through `public/ajax.php` (POST with JSON payload).
3. **Actions**: Logic residing in `src/Actions/` (e.g., `ProjectActions`, `TaskActions`).
4. **AI Queuing**: When wizard finishes, a task is created in `data/llm/`.
5. **Worker**: `scripts/worker.php` processes the queue, calls `DAIClient`, and stores generated content.
6. **Renderer**: `src/Services/Renderer.php` combines user data + AI content + PHP templates (`src/Templates/`) into a static site in `exports/<project_id>/`.
7. **Export**: The final site is zipped for download.
## 📂 Directory Structure
- `public/`: Web root. Contains `index.html`, `ajax.php`, `css/`, and `js/`.
- `src/`: PHP source code (`App\` namespace).
- `Actions/`: API controllers/handlers.
- `Services/`: Business logic (FileStorage, Renderer, LLMpool, DAIClient).
- `Templates/`: PHP šablóny for the generated websites.
- `Utils/`: Helpers and Configuration loader.
- `data/`: Protected data storage (Users, Projects, Consent, AI tasks). Protected by `.htaccess`.
- `exports/`: Destination for generated websites. Subfolders are project-specific.
- `scripts/`: CLI scripts (e.g., the `worker.php`).
- `tests/`: Debug and connectivity test scripts.
## ⚙️ Configuration
- `.env`: Local environment settings (e.g., `DAIAPI_URL`).
- `Config.php`: Utility to load and access these settings.
## 🔒 Security Principles
- **Protected Data**: Direct web access to `data/` and `exports/` subfolders (except public assets) is blocked.
- **Sanitization**: All output in templates uses the `e()` helper (htmlspecialchars).
- **Exports**: Generated sites use `config.php` (not JSON) to hide SMTP credentials.
- **Passwords**: Local viewer passwords are hashed using `password_hash()`.
## 🤖 AI Integration (DAIAPI)
- Connects to a local/VPN API at `http://10.2.8.1:9001/run` (DEV) or `http://192.168.122.10:9001/run` (PROD).
- Prompt logic is in `src/Prompts/ContentPrompt.php`. It forces JSON output and forbids HTML tags.
## 🚀 Common Commands
- **Run Worker (Continuous)**: `php scripts/worker.php --loop`
- **Test AI Connectivity**: `php tests/debug_dai.php`
- **Regenerate Autoloader**: `composer dump-autoload`
---
*Always respect the "No Frameworks" and "Tabs only" rules. Keep the code surgical and sémantic.*

View File

@ -1,3 +1,77 @@
# WebWizard # WebWizard - AI Website Concierge (MVP)
Simple wizard for create website with LLM. WebWizard je inteligentný asistent na tvorbu statických webových stránok pre malé podniky a živnostníkov. Pomocou intuitívneho wizardu a umelej inteligencie (DAIAPI) premení vaše základné informácie na profesionálny, responzívny web pripravený na nasadenie.
## 🚀 Hlavné funkcie
- **Inteligentný Wizard**: 8 krokov od definície biznisu až po finálny export.
- **AI Content Generation**: Automatické písanie marketingových textov a SEO metadát cez DAIAPI.
- **Vizuálna personalizácia**: Výber z preddefinovaných farebných paliet a dizajnových štýlov.
- **Správa Assetov**: Asynchrónne nahrávanie loga a fotogalérie.
- **Kontaktný formulár**: Podpora SMTP odosielania alebo lokálneho ukladania správ s vlastným prehliadačom.
- **Statický Export**: Kompletný balíček webu v ZIP formáte pripravený na FTP upload.
## 🛠️ Technický Stack
- **Backend**: PHP 8.2+ (bez frameworkov, čistý kód).
- **Frontend**: Vanilla JavaScript (ES6+), CSS Grid/Flexbox.
- **Dáta**: Súborový systém (JSON), žiadna databáza.
- **AI**: Integrácia s lokálnym DAIAPI.
## 📋 Požiadavky
- Webový server (Apache/Nginx) s podporou PHP 8.2.
- PHP rozšírenia: `curl`, `zip`, `fileinfo`.
- Povolený zápis (write permissions) do priečinkov `data/` a `exports/`.
## ⚙️ Inštalácia
1. Naklonujte repozitár na váš server.
2. Spustite `composer install` pre vygenerovanie autoloadera.
3. Skopírujte `.env.example` do `.env` a nastavte URL pre vaše DAIAPI:
```ini
DAIAPI_URL=http://10.2.8.1:9001/run
```
4. Nastavte Document Root vášho webservera do priečinka `public/`.
### Príklad NGINX konfigurácie
Pre správne fungovanie (najmä prístup k náhľadom v `/exports`) odporúčame nasledovnú konfiguráciu:
```nginx
server {
listen 80;
server_name webwizard.test;
root /cesta/k/projektu/public;
# Sprístupnenie vygenerovaných webov pre náhľad
location /exports {
alias /cesta/k/projektu/exports;
}
location / {
index index.php index.html;
try_files $uri $uri/ /index.php?$query_string;
}
include php.conf; # alebo vaša konfigurácia pre PHP-FPM
}
```
## 🤖 Spustenie Workera
Pre spracovanie AI úloh na pozadí je potrebné spustiť worker skript:
**Manuálne (DEV):**
```powershell
php scripts/worker.php --loop
```
**Cez Cron (PROD):**
Nastavte spúšťanie každú minútu:
```cron
* * * * * /usr/bin/php /cesta/k/projektu/scripts/worker.php >> /cesta/k/projektu/data/worker.log 2>&1
```
## 🔒 Bezpečnosť
- Všetky vstupné dáta sú sanitizované.
- Prístup do `data/` je blokovaný cez `.htaccess`.
- Citlivé konfigurácie exportovaných webov sú uložené v `.php` súboroch.
- Heslá pre prezeranie správ sú hašované pomocou `password_hash()`.
## 📄 Licencia
MIT

View File

@ -143,6 +143,14 @@ try {
sendResponse(true, $result); sendResponse(true, $result);
break; break;
case 'exportWebsite':
$projectId = $data['project_id'] ?? null;
if (!$projectId) {
sendResponse(false, ['code' => 'MISSING_PROJECT_ID', 'message' => 'Project ID is required.'], 400);
}
sendResponse(true, $projectActions->exportWebsite($userId, $projectId));
break;
default: default:
sendResponse(false, ['code' => 'UNKNOWN_ACTION', 'message' => "Action '$action' is not defined."], 404); sendResponse(false, ['code' => 'UNKNOWN_ACTION', 'message' => "Action '$action' is not defined."], 404);
break; break;

View File

@ -509,6 +509,102 @@ textarea:focus, input[type="text"]:focus, input[type="email"]:focus, input[type=
color: var(--primary-color); color: var(--primary-color);
} }
/* Preview Step */
.step-header-with-actions {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1.5rem;
}
.device-toggle {
display: flex;
background-color: #f1f5f9;
padding: 0.25rem;
border-radius: 0.5rem;
white-space: nowrap;
}
.btn-toggle {
padding: 0.4rem 0.8rem;
border-radius: 0.375rem;
font-size: 0.75rem;
border: none;
background: transparent;
color: var(--text-muted);
}
.btn-toggle.active {
background-color: white;
color: var(--primary-color);
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
font-weight: 600;
}
.preview-window {
border: 1px solid var(--border-color);
border-radius: 0.5rem;
background-color: #e2e8f0;
overflow: hidden;
transition: all 0.3s ease;
margin: 0 auto;
}
.preview-window iframe {
width: 100%;
height: 600px;
border: none;
background-color: white;
display: block;
}
.preview-window.mobile {
max-width: 375px;
}
/* Success / Export Screen */
.success-screen {
text-align: center;
padding: 1rem 0;
}
.success-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.export-actions {
margin: 2.5rem 0;
}
.btn-large {
padding: 1rem 2rem;
font-size: 1.125rem;
}
.instructions-box {
background-color: #f8fafc;
border: 1px solid var(--border-color);
border-radius: 0.75rem;
padding: 2rem;
text-align: left;
}
.instructions-box h3 {
margin-bottom: 1rem;
font-size: 1rem;
}
.instructions-box ol {
padding-left: 1.25rem;
color: #475569;
}
.instructions-box li {
margin-bottom: 0.75rem;
}
button:disabled { button:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;

View File

@ -317,19 +317,45 @@
<!-- Step 7: Preview --> <!-- Step 7: Preview -->
<div class="step" data-step="7"> <div class="step" data-step="7">
<h2>Náhľad webu</h2> <div class="step-header-with-actions">
<p class="step-description">Pozrite si, ako vyzerá váš nový web.</p> <div>
<div class="form-placeholder"> <h2>Náhľad vášho webu</h2>
<p>Obsah pre krok 7...</p> <p class="step-description">Pozrite si, ako vyzerá váš nový web. Môžete prepínať medzi zobrazením pre počítač a mobil.</p>
</div>
<div class="device-toggle">
<button class="btn-toggle active" data-view="desktop">💻 PC</button>
<button class="btn-toggle" data-view="mobile">📱 Mobil</button>
</div>
</div>
<div id="preview-container" class="preview-window">
<iframe id="preview-iframe" src="about:blank"></iframe>
</div> </div>
</div> </div>
<!-- Step 8: Export --> <!-- Step 8: Export -->
<div class="step" data-step="8"> <div class="step" data-step="8">
<h2>Hotovo!</h2> <div class="success-screen">
<p class="step-description">Váš web je pripravený na stiahnutie.</p> <div class="success-icon">🎉</div>
<div class="form-placeholder"> <h2>Váš web je pripravený!</h2>
<p>Obsah pre krok 8...</p> <p>Gratulujeme! Váš nový web bol úspešne vygenerovaný a je pripravený na stiahnutie.</p>
<div class="export-actions">
<button id="btn-download-zip" class="btn-primary btn-large">Stiahnuť web (ZIP)</button>
<div style="margin-top: 1rem;">
<button id="btn-view-live" class="btn-secondary btn-small">👁️ Pozrieť LIVE v novom okne</button>
</div>
</div>
<div class="instructions-box">
<h3>Čo robiť po stiahnutí?</h3>
<ol>
<li>Rozbaľte stiahnutý .zip archív vo vašom počítači.</li>
<li>Nahrajte všetky súbory a priečinky na váš webhosting cez FTP.</li>
<li>Váš web bude okamžite funkčný na vašej doméne.</li>
<li>Ak ste zvolili <strong>Lokálny mód</strong> formulára, správy nájdete na adrese <code>/form-viewer.php</code>.</li>
</ol>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -22,14 +22,14 @@ const App = {
async init() { async init() {
console.log('Initializing WebWizard...'); console.log('Initializing WebWizard...');
if (!this.state.userId) { if (!this.state.userId) {
await this.initSession(); await this.initSession();
} }
await this.loadCategories(); await this.loadCategories();
this.bindEvents(); this.bindEvents();
// If we don't have a project, try to load existing or create one // If we don't have a project, try to load existing or create one
if (!this.state.project) { if (!this.state.project) {
await this.loadLastProject(); await this.loadLastProject();
@ -70,14 +70,14 @@ const App = {
syncSelectionWithProject() { syncSelectionWithProject() {
if (this.state.project && this.state.project.wizard_data) { if (this.state.project && this.state.project.wizard_data) {
const wd = this.state.project.wizard_data; const wd = this.state.project.wizard_data;
// Step 1 // Step 1
if (wd.business_category) { if (wd.business_category) {
const bc = wd.business_category; const bc = wd.business_category;
this.state.selection.category = bc.group; this.state.selection.category = bc.group;
this.state.selection.subcategory = bc.subcategory; this.state.selection.subcategory = bc.subcategory;
this.state.selection.customDescription = bc.custom_description || ''; this.state.selection.customDescription = bc.custom_description || '';
if (this.state.selection.category) { if (this.state.selection.category) {
this.selectCategory(this.state.selection.category); this.selectCategory(this.state.selection.category);
this.state.selection.subcategory = bc.subcategory; this.state.selection.subcategory = bc.subcategory;
@ -136,7 +136,7 @@ const App = {
if (m.contact_form) { if (m.contact_form) {
document.getElementById('form-enabled').checked = m.contact_form.enabled; document.getElementById('form-enabled').checked = m.contact_form.enabled;
document.getElementById('form-config-container').classList.toggle('hidden', !m.contact_form.enabled); document.getElementById('form-config-container').classList.toggle('hidden', !m.contact_form.enabled);
if (m.contact_form.mode) { if (m.contact_form.mode) {
document.querySelector(`input[name="form-mode"][value="${m.contact_form.mode}"]`).checked = true; document.querySelector(`input[name="form-mode"][value="${m.contact_form.mode}"]`).checked = true;
document.getElementById('config-local').classList.toggle('hidden', m.contact_form.mode !== 'local'); document.getElementById('config-local').classList.toggle('hidden', m.contact_form.mode !== 'local');
@ -224,7 +224,7 @@ const App = {
bindEvents() { bindEvents() {
document.getElementById('btn-next').addEventListener('click', () => this.nextStep()); document.getElementById('btn-next').addEventListener('click', () => this.nextStep());
document.getElementById('btn-prev').addEventListener('click', () => this.prevStep()); document.getElementById('btn-prev').addEventListener('click', () => this.prevStep());
document.getElementById('custom-description').addEventListener('input', (e) => { document.getElementById('custom-description').addEventListener('input', (e) => {
this.state.selection.customDescription = e.target.value; this.state.selection.customDescription = e.target.value;
}); });
@ -273,6 +273,24 @@ const App = {
document.getElementById('config-smtp').classList.toggle('hidden', e.target.value !== 'smtp'); document.getElementById('config-smtp').classList.toggle('hidden', e.target.value !== 'smtp');
}); });
}); });
// Step 7 events
document.querySelectorAll('.btn-toggle').forEach(btn => {
btn.addEventListener('click', (e) => {
document.querySelectorAll('.btn-toggle').forEach(b => b.classList.remove('active'));
e.target.classList.add('active');
const view = e.target.getAttribute('data-view');
document.getElementById('preview-container').className = 'preview-window ' + view;
});
});
// Step 8 events
document.getElementById('btn-download-zip').addEventListener('click', () => this.downloadWebsite());
document.getElementById('btn-view-live').addEventListener('click', () => {
if (this.state.project) {
window.open(`/exports/${this.state.project.project_id}/index.html`, '_blank');
}
});
}, },
renderCategories() { renderCategories() {
@ -291,7 +309,7 @@ const App = {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'category-card'; card.className = 'category-card';
if (this.state.selection.category === cat.id) card.classList.add('selected'); if (this.state.selection.category === cat.id) card.classList.add('selected');
card.innerHTML = ` card.innerHTML = `
<div class="category-icon">${icons[cat.id] || '📁'}</div> <div class="category-icon">${icons[cat.id] || '📁'}</div>
<div class="category-name">${cat.name}</div> <div class="category-name">${cat.name}</div>
@ -305,18 +323,20 @@ const App = {
selectCategory(categoryId) { selectCategory(categoryId) {
this.state.selection.category = categoryId; this.state.selection.category = categoryId;
this.state.selection.subcategory = null; this.state.selection.subcategory = null;
this.renderCategories(); this.renderCategories();
this.renderSubcategories(categoryId); this.renderSubcategories(categoryId);
this.renderSmartQuestions(categoryId); this.renderSmartQuestions(categoryId);
document.getElementById('subcategory-container').classList.remove('hidden'); document.getElementById('subcategory-container').classList.remove('hidden');
if (categoryId === 'other') { if (categoryId === 'other') {
document.getElementById('custom-category-group').classList.remove('hidden'); document.getElementById('custom-category-group').classList.remove('hidden');
} else { } else {
document.getElementById('custom-category-group').classList.add('hidden'); document.getElementById('custom-category-group').classList.add('hidden');
} }
this.updateUI();
}, },
renderSubcategories(categoryId) { renderSubcategories(categoryId) {
@ -335,6 +355,7 @@ const App = {
chip.addEventListener('click', () => { chip.addEventListener('click', () => {
this.state.selection.subcategory = sub.id; this.state.selection.subcategory = sub.id;
this.renderSubcategories(categoryId); this.renderSubcategories(categoryId);
this.updateUI();
}); });
container.appendChild(chip); container.appendChild(chip);
@ -345,7 +366,7 @@ const App = {
const container = document.getElementById('services-list'); const container = document.getElementById('services-list');
const itemDiv = document.createElement('div'); const itemDiv = document.createElement('div');
itemDiv.className = 'service-item'; itemDiv.className = 'service-item';
itemDiv.innerHTML = ` itemDiv.innerHTML = `
<div class="service-item-header"> <div class="service-item-header">
<span class="service-number">Služba</span> <span class="service-number">Služba</span>
@ -487,7 +508,7 @@ const App = {
<button class="btn-remove-asset" id="btn-remove-logo">×</button>`; <button class="btn-remove-asset" id="btn-remove-logo">×</button>`;
preview.classList.remove('hidden'); preview.classList.remove('hidden');
document.querySelector('#logo-upload-box .upload-placeholder').classList.add('hidden'); document.querySelector('#logo-upload-box .upload-placeholder').classList.add('hidden');
document.getElementById('btn-remove-logo').addEventListener('click', (e) => { document.getElementById('btn-remove-logo').addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
this.removeAsset('logo'); this.removeAsset('logo');
@ -502,12 +523,12 @@ const App = {
item.className = 'gallery-item'; item.className = 'gallery-item';
item.innerHTML = `<img src="/${path}" alt="Gallery ${index+1}"> item.innerHTML = `<img src="/${path}" alt="Gallery ${index+1}">
<button class="btn-remove-asset" data-index="${index}">×</button>`; <button class="btn-remove-asset" data-index="${index}">×</button>`;
item.querySelector('.btn-remove-asset').addEventListener('click', (e) => { item.querySelector('.btn-remove-asset').addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
this.removeAsset('gallery', index); this.removeAsset('gallery', index);
}); });
container.appendChild(item); container.appendChild(item);
}); });
}, },
@ -534,9 +555,48 @@ const App = {
} }
this.state.currentStep = n; this.state.currentStep = n;
// Specific step logic
if (n === 7) {
this.loadPreview();
}
this.updateUI(); this.updateUI();
}, },
loadPreview() {
const iframe = document.getElementById('preview-iframe');
const timestamp = new Date().getTime();
iframe.src = `/exports/${this.state.project.project_id}/index.html?t=${timestamp}`;
},
async downloadWebsite() {
const btn = document.getElementById('btn-download-zip');
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Pripravujem archív...';
try {
const result = await this.apiCall('exportWebsite');
if (result.success) {
// Trigger download
const link = document.createElement('a');
link.href = '/' + result.data.download_url;
link.download = result.data.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
} catch (error) {
console.error('Export failed:', error);
alert('Chyba pri generovaní ZIP archívu.');
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
},
async nextStep() { async nextStep() {
if (this.state.currentStep === 1) { if (this.state.currentStep === 1) {
if (!this.state.selection.category || !this.state.selection.subcategory) { if (!this.state.selection.category || !this.state.selection.subcategory) {
@ -679,7 +739,7 @@ const App = {
const formEnabled = document.getElementById('form-enabled').checked; const formEnabled = document.getElementById('form-enabled').checked;
const formMode = document.querySelector('input[name="form-mode"]:checked').value; const formMode = document.querySelector('input[name="form-mode"]:checked').value;
const modulesData = { const modulesData = {
pages: ['home'], pages: ['home'],
sections: sections, sections: sections,
@ -727,7 +787,7 @@ const App = {
try { try {
const result = await this.apiCall('generateWebsite'); const result = await this.apiCall('generateWebsite');
if (result.success) { if (result.success) {
console.log('Generation started:', result.data.task_id);
this.startPolling(); this.startPolling();
} }
} catch (error) { } catch (error) {
@ -760,7 +820,7 @@ const App = {
} catch (error) { } catch (error) {
console.error('Polling error:', error); console.error('Polling error:', error);
} }
}, 3000); }, 5000);
}, },
updateGenerationStatus(status) { updateGenerationStatus(status) {
@ -786,7 +846,7 @@ const App = {
const btnNext = document.getElementById('btn-next'); const btnNext = document.getElementById('btn-next');
btnPrev.disabled = this.state.currentStep === 1; btnPrev.disabled = this.state.currentStep === 1;
// Validation for Next button // Validation for Next button
let nextDisabled = false; let nextDisabled = false;
if (this.state.currentStep === 1) { if (this.state.currentStep === 1) {

View File

@ -19,6 +19,10 @@ if (php_sapi_name() !== 'cli') {
die("This script can only be run from the command line." . PHP_EOL); die("This script can only be run from the command line." . PHP_EOL);
} }
// Parse arguments
$isLoop = in_array('--loop', $argv);
$loopInterval = 5; // seconds
$lockFile = sys_get_temp_dir() . '/webwizard_worker.lock'; $lockFile = sys_get_temp_dir() . '/webwizard_worker.lock';
$fp = fopen($lockFile, 'w'); $fp = fopen($lockFile, 'w');
@ -26,17 +30,39 @@ if (!flock($fp, LOCK_EX | LOCK_NB)) {
die("[" . date('Y-m-d H:i:s') . "] Worker is already running. Skipping." . PHP_EOL); die("[" . date('Y-m-d H:i:s') . "] Worker is already running. Skipping." . PHP_EOL);
} }
echo "[" . date('Y-m-d H:i:s') . "] Starting LLMpool worker..." . PHP_EOL; if ($isLoop) {
echo "[" . date('Y-m-d H:i:s') . "] Worker started in loop mode (interval: {$loopInterval}s)" . PHP_EOL;
try { } else {
$worker = new LLMpool(); echo "[" . date('Y-m-d H:i:s') . "] Starting LLMpool worker (single run)..." . PHP_EOL;
$worker->processQueue();
} catch (Throwable $e) {
echo "[" . date('Y-m-d H:i:s') . "] FATAL ERROR: " . $e->getMessage() . PHP_EOL;
} }
echo "[" . date('Y-m-d H:i:s') . "] Finished LLMpool worker." . PHP_EOL; $worker = new LLMpool();
/**
* Main processing logic encapsulated to avoid duplication
*/
$processCycle = function() use ($worker) {
echo "[" . date('Y-m-d H:i:s') . "] Cycle started: Processing queue..." . PHP_EOL;
try {
$worker->processQueue();
} catch (Throwable $e) {
echo "[" . date('Y-m-d H:i:s') . "] FATAL ERROR: " . $e->getMessage() . PHP_EOL;
}
echo "[" . date('Y-m-d H:i:s') . "] Cycle finished." . PHP_EOL;
};
// Execution based on mode
if ($isLoop) {
while (true) {
$processCycle();
sleep($loopInterval);
}
} else {
$processCycle();
echo "[" . date('Y-m-d H:i:s') . "] Finished LLMpool worker." . PHP_EOL;
}
// These are only reached in non-loop mode (or if loop is broken by signal)
flock($fp, LOCK_UN); flock($fp, LOCK_UN);
fclose($fp); fclose($fp);
unlink($lockFile); unlink($lockFile);

View File

@ -257,4 +257,55 @@ class ProjectActions
'mime_type' => $mimeType 'mime_type' => $mimeType
]; ];
} }
/**
* Creates a ZIP archive of the generated website.
*/
public function exportWebsite(string $userId, string $projectId): array
{
// 1. Verify ownership
$this->getProjectStatus($userId, $projectId);
$projectExportDir = __DIR__ . "/../../exports/{$projectId}";
if (!is_dir($projectExportDir)) {
throw new Exception("Generated website not found. Please generate it first.", 404);
}
$zipFileName = "web-" . $projectId . ".zip";
$zipPath = $projectExportDir . "/" . $zipFileName;
$zip = new \ZipArchive();
if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
throw new Exception("Could not create ZIP archive.", 500);
}
// Recursively add files
$files = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($projectExportDir, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($files as $name => $file) {
// Skip the ZIP file itself and temporary wizard files if any
if (!$file->isDir()) {
$filePath = $file->getRealPath();
$relativePath = substr($filePath, strlen($projectExportDir) + 1);
// Don't include the zip we are currently creating
if ($relativePath === $zipFileName) continue;
// Don't include .htaccess if it's in the root (optional, but data/.htaccess should stay)
// Actually, we want to include all site files.
$zip->addFile($filePath, $relativePath);
}
}
$zip->close();
return [
'download_url' => "exports/{$projectId}/" . $zipFileName,
'filename' => $zipFileName
];
}
} }

View File

@ -9,7 +9,7 @@ use Exception;
class DAIClient class DAIClient
{ {
private string $apiUrl; private string $apiUrl;
private int $timeout = 120; private int $timeout = 600;
public function __construct(?string $apiUrl = null) public function __construct(?string $apiUrl = null)
{ {

View File

@ -14,12 +14,14 @@ class LLMpool
private FileStorage $storage; private FileStorage $storage;
private DAIClient $daiClient; private DAIClient $daiClient;
private ContentPrompt $promptGenerator; private ContentPrompt $promptGenerator;
private Renderer $renderer;
public function __construct() public function __construct()
{ {
$this->storage = new FileStorage(); $this->storage = new FileStorage();
$this->daiClient = new DAIClient(); $this->daiClient = new DAIClient();
$this->promptGenerator = new ContentPrompt(); $this->promptGenerator = new ContentPrompt();
$this->renderer = new Renderer();
} }
/** /**
@ -91,12 +93,21 @@ class LLMpool
} }
$projectData['content']['generated'] = $content; $projectData['content']['generated'] = $content;
$this->storage->put("projects/{$projectId}.json", $projectData);
// 5. Render static site
if (!$this->renderer->render($projectId)) {
throw new Exception("Rendering failed.");
}
// 6. Update Project Status
$projectData = $this->storage->get("projects/{$projectId}.json");
$projectData['status'] = 'ready'; // Transition to ready $projectData['status'] = 'ready'; // Transition to ready
$projectData['current_step'] = 7; // Automatically ready for preview $projectData['current_step'] = 7; // Automatically ready for preview
$projectData['updated_at'] = date('c'); $projectData['updated_at'] = date('c');
$this->storage->put("projects/{$projectId}.json", $projectData); $this->storage->put("projects/{$projectId}.json", $projectData);
// 6. Finish Task (Delete or Archive) // 7. Finish Task (Delete or Archive)
$this->storage->delete($taskPath); $this->storage->delete($taskPath);
error_log("LLMpool: Task $taskFilename processed successfully for project $projectId."); error_log("LLMpool: Task $taskFilename processed successfully for project $projectId.");

188
src/Services/Renderer.php Normal file
View File

@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Exception;
require_once __DIR__ . '/../Utils/Helpers.php';
class Renderer
{
private FileStorage $storage;
private string $exportsPath;
private string $templatesPath;
private array $palettes = [
'blue-gray' => [
'primary' => '#2563eb',
'secondary' => '#64748b',
'bg' => '#f8fafc',
'text' => '#1e293b'
],
'nature-green' => [
'primary' => '#059669',
'secondary' => '#fbbf24',
'bg' => '#fdfdfd',
'text' => '#064e3b'
],
'elegant-gold' => [
'primary' => '#d4af37',
'secondary' => '#111827',
'bg' => '#ffffff',
'text' => '#111827'
]
];
private array $styles = [
'minimal' => [
'radius' => '0px',
'shadow' => 'none',
'padding' => '4rem 1rem'
],
'modern' => [
'radius' => '0.5rem',
'shadow' => '0 4px 6px -1px rgb(0 0 0 / 0.1)',
'padding' => '6rem 1.5rem'
],
'premium' => [
'radius' => '1.5rem',
'shadow' => '0 10px 15px -3px rgb(0 0 0 / 0.1)',
'padding' => '8rem 2rem'
]
];
public function __construct()
{
$this->storage = new FileStorage();
$this->exportsPath = realpath(__DIR__ . '/../../exports');
$this->templatesPath = realpath(__DIR__ . '/../Templates');
}
/**
* Renders a complete static website for a project.
*/
public function render(string $projectId): bool
{
$projectData = $this->storage->get("projects/{$projectId}.json");
if (!$projectData || empty($projectData['content']['generated'])) {
return false;
}
$projectExportDir = $this->exportsPath . DIRECTORY_SEPARATOR . $projectId;
$assetsDir = $projectExportDir . DIRECTORY_SEPARATOR . 'assets';
$cssDir = $assetsDir . DIRECTORY_SEPARATOR . 'css';
$jsDir = $assetsDir . DIRECTORY_SEPARATOR . 'js';
// 1. Create directory structure
if (!is_dir($cssDir)) mkdir($cssDir, 0777, true);
if (!is_dir($jsDir)) mkdir($jsDir, 0777, true);
// 2. Copy static assets
copy($this->templatesPath . '/css/site.css', $cssDir . '/site.css');
copy($this->templatesPath . '/js/site.js', $jsDir . '/site.js');
// 3. Handle Contact Form Logic
$formConfig = $projectData['wizard_data']['modules']['contact_form'] ?? [];
if (!empty($formConfig['enabled'])) {
// Copy handler
copy($this->templatesPath . '/emailer.php', $projectExportDir . '/ajax.php');
// Create config for handler (PHP file is not web-accessible)
$siteConfig = [
'site_name' => $projectData['wizard_data']['identity']['business_name'],
'form_mode' => $formConfig['mode'] ?? 'local',
'smtp' => $formConfig['smtp'] ?? null,
'local_password_hash' => $formConfig['local_viewer']['password_hash'] ?? null
];
$configContent = "<?php\nreturn " . var_export($siteConfig, true) . ";\n";
file_put_contents($projectExportDir . '/config.php', $configContent);
if (($siteConfig['form_mode'] ?? 'local') === 'local') {
// Copy viewer
copy($this->templatesPath . '/form-viewer.php', $projectExportDir . '/form-viewer.php');
// Create messages dir
$msgDir = $projectExportDir . '/messages';
if (!is_dir($msgDir)) mkdir($msgDir, 0777, true);
file_put_contents($msgDir . '/.htaccess', "Deny from all");
}
}
// 4. Prepare relative paths for assets
if (!empty($projectData['wizard_data']['assets']['logo'])) {
$projectData['wizard_data']['assets']['logo'] = $this->makePathRelative($projectData['wizard_data']['assets']['logo'], $projectId);
}
if (!empty($projectData['wizard_data']['assets']['gallery'])) {
foreach ($projectData['wizard_data']['assets']['gallery'] as $key => $path) {
$projectData['wizard_data']['assets']['gallery'][$key] = $this->makePathRelative($path, $projectId);
}
}
// 4. Render Content Sections
$sectionsHtml = '';
$selectedSections = $projectData['wizard_data']['modules']['sections'] ?? [];
foreach ($selectedSections as $sectionId) {
try {
$sectionsHtml .= $this->renderTemplate("sections/{$sectionId}", [
'project' => $projectData
]);
} catch (Exception $e) {
error_log("Renderer: Failed to render section $sectionId: " . $e->getMessage());
}
}
// 4. Prepare Visual Variables
$visuals = $projectData['wizard_data']['visuals'] ?? [];
$palette = $this->palettes[$visuals['palette'] ?? 'blue-gray'] ?? $this->palettes['blue-gray'];
$style = $this->styles[$visuals['style'] ?? 'modern'] ?? $this->styles['modern'];
// 5. Render HTML base template
$html = $this->renderTemplate('base', [
'project' => $projectData,
'content' => $sectionsHtml,
'css_vars' => [
'primary' => $palette['primary'],
'secondary' => $palette['secondary'],
'bg' => $palette['bg'],
'text' => $palette['text'],
'radius' => $style['radius'],
'shadow' => $style['shadow'],
'padding' => $style['padding']
]
]);
// 6. Save to exports
return file_put_contents($projectExportDir . DIRECTORY_SEPARATOR . 'index.html', $html) !== false;
}
/**
* Makes an absolute or project-root relative path relative to the project's export directory.
*/
private function makePathRelative(string $path, string $projectId): string
{
$prefix = "exports/{$projectId}/";
if (strpos($path, $prefix) === 0) {
return substr($path, strlen($prefix));
}
return $path;
}
/**
* Internal helper to render a PHP template with variables.
*/
private function renderTemplate(string $templateName, array $vars = []): string
{
$templateFile = $this->templatesPath . DIRECTORY_SEPARATOR . $templateName . '.php';
if (!file_exists($templateFile)) {
throw new Exception("Template not found: $templateName");
}
extract($vars);
ob_start();
include $templateFile;
return ob_get_clean();
}
}

87
src/Templates/base.php Normal file
View File

@ -0,0 +1,87 @@
<?php
/**
* @var array $project
* @var string $content
*/
$seo = $project['content']['generated']['seo'] ?? [];
$identity = $project['wizard_data']['identity'] ?? [];
$contact = $project['wizard_data']['contact'] ?? [];
$visuals = $project['wizard_data']['visuals'] ?? [];
$assets = $project['wizard_data']['assets'] ?? [];
?>
<!DOCTYPE html>
<html lang="sk">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($seo['title'] ?? $identity['business_name'] ?? 'Môj Web') ?></title>
<meta name="description" content="<?= e($seo['description'] ?? '') ?>">
<!-- Open Graph -->
<meta property="og:title" content="<?= e($seo['title'] ?? '') ?>">
<meta property="og:description" content="<?= e($seo['description'] ?? '') ?>">
<meta property="og:type" content="website">
<!-- Assets -->
<link rel="stylesheet" href="assets/css/site.css">
<style>
:root {
<?php if (!empty($css_vars)): ?>
--primary-color: <?= $css_vars['primary'] ?>;
--secondary-color: <?= $css_vars['secondary'] ?>;
--bg-color: <?= $css_vars['bg'] ?>;
--text-color: <?= $css_vars['text'] ?>;
--radius: <?= $css_vars['radius'] ?>;
--shadow: <?= $css_vars['shadow'] ?>;
--section-padding: <?= $css_vars['padding'] ?>;
<?php endif; ?>
}
</style>
</head>
<body>
<header>
<nav>
<div class="container">
<div class="logo">
<?php if (!empty($assets['logo'])): ?>
<img src="<?= e($assets['logo']) ?>" alt="<?= e($identity['business_name']) ?>">
<?php else: ?>
<strong><?= e($identity['business_name']) ?></strong>
<?php endif; ?>
</div>
<ul class="nav-links">
<!-- Navigation will be dynamic in later steps -->
</ul>
</div>
</nav>
</header>
<main id="content">
<?= $content ?>
</main>
<footer>
<div class="container">
<div class="footer-grid">
<div class="footer-info">
<h4><?= e($identity['business_name']) ?></h4>
<p><?= e($identity['tagline']) ?></p>
</div>
<div class="footer-contact">
<p><?= e($contact['email']) ?></p>
<p><?= e($contact['phone']) ?></p>
<p><?= e($contact['address']) ?>, <?= e($contact['city']) ?></p>
</div>
</div>
<div class="footer-bottom">
<p>&copy; <?= date('Y') ?> <?= e($identity['business_name']) ?>. Všetky práva vyhradené.</p>
</div>
</div>
</footer>
<script src="assets/js/site.js"></script>
</body>
</html>

239
src/Templates/css/site.css Normal file
View File

@ -0,0 +1,239 @@
/* Reset & Base Styles */
:root {
--primary-color: #2563eb;
--secondary-color: #64748b;
--text-color: #1f2937;
--bg-color: #ffffff;
--radius: 0.5rem;
--shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
--section-padding: 5rem 1.5rem;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: var(--text-color);
background-color: var(--bg-color);
line-height: 1.6;
transition: background-color 0.3s ease, color 0.3s ease;
}
h1, h2, h3 {
line-height: 1.2;
margin-bottom: 1.5rem;
color: var(--text-color);
}
p {
margin-bottom: 1rem;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1.5rem;
}
section {
padding: var(--section-padding);
}
/* Common Components */
.btn {
display: inline-block;
padding: 0.75rem 1.5rem;
border-radius: var(--radius);
font-weight: 600;
text-decoration: none;
transition: all 0.2s;
cursor: pointer;
border: 2px solid transparent;
}
.btn-primary {
background-color: var(--primary-color);
color: white;
box-shadow: var(--shadow);
}
.btn-secondary {
background-color: transparent;
border-color: var(--secondary-color);
color: var(--secondary-color);
}
.btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
/* Header & Nav */
header {
padding: 1.5rem 0;
background-color: var(--bg-color);
border-bottom: 1px solid rgba(0,0,0,0.05);
}
nav .container {
display: flex;
justify-content: space-between;
align-items: center;
}
.logo img {
height: 2.5rem;
}
/* Footer */
footer {
background-color: var(--secondary-color);
color: white;
padding: 4rem 0;
margin-top: 2rem;
}
footer h4, footer p {
color: white;
}
/* Section specific */
.section-header {
text-align: center;
margin-bottom: 3rem;
}
/* Hero */
.hero {
background-color: var(--bg-color);
text-align: center;
padding: calc(var(--section-padding) * 1.5);
border-bottom: 1px solid rgba(0,0,0,0.05);
}
.hero h1 {
font-size: 3.5rem;
font-weight: 900;
letter-spacing: -0.025em;
}
.hero .subtitle {
font-size: 1.5rem;
color: var(--secondary-color);
margin-bottom: 2.5rem;
max-width: 800px;
margin-left: auto;
margin-right: auto;
}
.hero-actions {
display: flex;
gap: 1rem;
justify-content: center;
}
/* Services */
.services-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
}
.service-card {
padding: 2.5rem;
border: 1px solid rgba(0,0,0,0.05);
background-color: white;
border-radius: var(--radius);
box-shadow: var(--shadow);
transition: all 0.3s ease;
}
.service-card:hover {
transform: translateY(-8px);
}
.price {
font-weight: 800;
font-size: 1.25rem;
color: var(--primary-color);
margin-top: 1.5rem;
}
/* Gallery */
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
}
.gallery-item img {
width: 100%;
height: 300px;
object-fit: cover;
border-radius: var(--radius);
box-shadow: var(--shadow);
}
/* FAQ */
.faq-list {
max-width: 800px;
margin: 0 auto;
}
.faq-item {
margin-bottom: 2rem;
border-bottom: 1px solid rgba(0,0,0,0.05);
padding-bottom: 2rem;
}
.faq-item h3 {
font-size: 1.25rem;
margin-bottom: 1rem;
}
/* Contact */
.contact-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 5rem;
}
.contact-form {
background-color: white;
padding: 2.5rem;
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.contact-form .form-group {
margin-bottom: 1.25rem;
}
.contact-form input, .contact-form textarea {
width: 100%;
padding: 1rem;
border: 1px solid #e5e7eb;
border-radius: var(--radius);
font-family: inherit;
}
.contact-form input:focus, .contact-form textarea:focus {
outline: none;
border-color: var(--primary-color);
}
@media (max-width: 768px) {
.contact-grid {
grid-template-columns: 1fr;
}
.hero h1 {
font-size: 2.5rem;
}
.hero-actions {
flex-direction: column;
}
}

82
src/Templates/emailer.php Normal file
View File

@ -0,0 +1,82 @@
<?php
/**
* Universal Form Handler for WebWizard exported sites.
* Handles both SMTP mailing and local file storage.
*/
header('Content-Type: application/json; charset=utf-8');
$config = [];
if (file_exists('config.php')) {
$config = include 'config.php';
}
function sendResponse(bool $success, string $message) {
echo json_encode(['success' => $success, 'message' => $message]);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendResponse(false, 'Method not allowed.');
}
// Honeypot spam protection
if (!empty($_POST['website'])) {
sendResponse(true, 'Spam detected.'); // Fake success for bots
}
$name = trim($_POST['name'] ?? '');
$email = trim($_POST['email'] ?? '');
$message = trim($_POST['message'] ?? '');
if (!$name || !$email || !$message) {
sendResponse(false, 'Prosím vyplňte všetky povinné polia.');
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
sendResponse(false, 'Neplatná emailová adresa.');
}
$mode = $config['form_mode'] ?? 'local';
if ($mode === 'local') {
// Save to messages directory
$msgDir = 'messages';
if (!is_dir($msgDir)) {
mkdir($msgDir, 0777, true);
}
$msgId = date('Ymd-His') . '-' . bin2hex(random_bytes(4));
$msgData = [
'id' => $msgId,
'timestamp' => date('c'),
'sender_name' => $name,
'sender_email' => $email,
'message' => $message
];
if (file_put_contents($msgDir . '/' . $msgId . '.json', json_encode($msgData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE))) {
sendResponse(true, 'Vaša správa bola úspešne uložená.');
} else {
sendResponse(false, 'Nepodarilo sa uložiť správu.');
}
} else {
// SMTP / Mail mode
$to = $config['smtp']['recipient'] ?? '';
if (!$to) {
sendResponse(false, 'Cieľový email nie je nakonfigurovaný.');
}
$subject = "Nová správa z webu: " . ($config['site_name'] ?? 'WebWizard');
$body = "Meno: $name\nEmail: $email\n\nSpráva:\n$message";
$headers = "From: webwizard@{$_SERVER['HTTP_HOST']}\r\n" .
"Reply-To: $email\r\n" .
"X-Mailer: PHP/" . phpversion();
// Using standard mail() as fallback for MVP if no complex SMTP is provided
if (mail($to, $subject, $body, $headers)) {
sendResponse(true, 'Vaša správa bola úspešne odoslaná.');
} else {
sendResponse(false, 'Nepodarilo sa odoslať email.');
}
}

View File

@ -0,0 +1,95 @@
<?php
/**
* Local Message Viewer for WebWizard exported sites.
*/
session_start();
$config = [];
if (file_exists('config.php')) {
$config = include 'config.php';
}
$passwordHash = $config['local_password_hash'] ?? null;
// Handle Logout
if (isset($_GET['logout'])) {
session_destroy();
header('Location: form-viewer.php');
exit;
}
// Handle Login
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['password'])) {
if ($passwordHash && password_verify($_POST['password'], $passwordHash)) {
$_SESSION['ww_authorized'] = true;
} else {
$error = 'Nesprávne heslo.';
}
}
$isAuthorized = $_SESSION['ww_authorized'] ?? false;
?>
<!DOCTYPE html>
<html lang="sk">
<head>
<meta charset="UTF-8">
<title>Prehliadač správ - WebWizard</title>
<style>
body { font-family: sans-serif; background: #f4f4f4; padding: 2rem; color: #333; }
.container { max-width: 800px; margin: 0 auto; background: #fff; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
h1 { border-bottom: 2px solid #eee; padding-bottom: 1rem; }
.msg-item { border: 1px solid #eee; padding: 1rem; margin-bottom: 1rem; border-radius: 4px; }
.msg-header { display: flex; justify-content: space-between; font-size: 0.85rem; color: #666; margin-bottom: 0.5rem; }
.msg-sender { font-weight: bold; color: #2563eb; }
.login-form { text-align: center; padding: 2rem 0; }
input[type="password"] { padding: 0.75rem; width: 250px; border: 1px solid #ddd; border-radius: 4px; }
button { padding: 0.75rem 1.5rem; background: #2563eb; color: #fff; border: none; border-radius: 4px; cursor: pointer; }
.error { color: #dc2626; margin-bottom: 1rem; }
.no-messages { text-align: center; color: #666; padding: 2rem; }
.logout-link { float: right; font-size: 0.9rem; color: #666; }
</style>
</head>
<body>
<div class="container">
<?php if (!$isAuthorized): ?>
<div class="login-form">
<h1>Prihlásenie</h1>
<?php if ($error): ?><div class="error"><?= $error ?></div><?php endif; ?>
<form method="POST">
<input type="password" name="password" placeholder="Heslo k správam" required autofocus>
<button type="submit">Vstúpiť</button>
</form>
</div>
<?php else: ?>
<a href="?logout=1" class="logout-link">Odhlásiť sa</a>
<h1>Prijaté správy</h1>
<?php
$files = glob('messages/*.json');
rsort($files); // Newest first
if (empty($files)): ?>
<p class="no-messages">Zatiaľ ste neprijali žiadne správy.</p>
<?php else:
foreach ($files as $file):
$data = json_decode(file_get_contents($file), true);
if ($data): ?>
<div class="msg-item">
<div class="msg-header">
<span><?= htmlspecialchars($data['timestamp']) ?></span>
<span>ID: <?= htmlspecialchars($data['id']) ?></span>
</div>
<div>Od: <span class="msg-sender"><?= htmlspecialchars($data['sender_name']) ?></span> (&lt;<?= htmlspecialchars($data['sender_email']) ?>&gt;)</div>
<div style="margin-top: 1rem; white-space: pre-wrap;"><?= htmlspecialchars($data['message']) ?></div>
</div>
<?php endif;
endforeach;
endif; ?>
<?php endif; ?>
</div>
</body>
</html>

40
src/Templates/js/site.js Normal file
View File

@ -0,0 +1,40 @@
/**
* WebWizard Site Scripts
*/
document.addEventListener('DOMContentLoaded', () => {
const contactForm = document.getElementById('site-contact-form');
if (contactForm) {
contactForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(contactForm);
const submitBtn = contactForm.querySelector('button[type="submit"]');
const originalBtnText = submitBtn.textContent;
submitBtn.disabled = true;
submitBtn.textContent = 'Odosielam...';
try {
const response = await fetch(contactForm.action, {
method: 'POST',
body: formData
});
const result = await response.json();
alert(result.message);
if (result.success) {
contactForm.reset();
}
} catch (error) {
console.error('Form submission error:', error);
alert('Vyskytla sa chyba pri odosielaní formulára. Skúste to prosím neskôr.');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = originalBtnText;
}
});
}
});

View File

@ -0,0 +1,16 @@
<?php
/**
* @var array $project
*/
$about = $project['content']['generated']['about'] ?? [];
?>
<section id="about" class="about">
<div class="container">
<div class="section-header">
<h2><?= e($about['title'] ?? 'O nás') ?></h2>
</div>
<div class="about-content">
<?= nl2br(e($about['text'] ?? $project['wizard_data']['identity']['description'])) ?>
</div>
</div>
</section>

View File

@ -0,0 +1,54 @@
<?php
/**
* @var array $project
*/
$contact = $project['wizard_data']['contact'] ?? [];
$formEnabled = $project['wizard_data']['modules']['contact_form']['enabled'] ?? false;
?>
<section id="contact" class="contact">
<div class="container">
<div class="section-header">
<h2>Kontakt</h2>
</div>
<div class="contact-grid">
<div class="contact-info">
<h3>Kde nás nájdete</h3>
<p><strong>Adresa:</strong> <?= e($contact['address']) ?>, <?= e($contact['city']) ?></p>
<p><strong>Email:</strong> <a href="mailto:<?= e($contact['email']) ?>"><?= e($contact['email']) ?></a></p>
<p><strong>Telefón:</strong> <a href="tel:<?= e($contact['phone']) ?>"><?= e($contact['phone']) ?></a></p>
<?php if (!empty($contact['socials']['facebook']) || !empty($contact['socials']['instagram'])): ?>
<div class="socials">
<?php if ($contact['socials']['facebook']): ?>
<a href="<?= e($contact['socials']['facebook']) ?>" target="_blank" class="social-link">Facebook</a>
<?php endif; ?>
<?php if ($contact['socials']['instagram']): ?>
<a href="<?= e($contact['socials']['instagram']) ?>" target="_blank" class="social-link">Instagram</a>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<?php if ($formEnabled): ?>
<div class="contact-form">
<h3>Napíšte nám</h3>
<form action="ajax.php" method="POST" id="site-contact-form">
<!-- Honeypot -->
<input type="text" name="website" style="display:none !important" tabindex="-1" autocomplete="off">
<div class="form-group">
<input type="text" name="name" placeholder="Vaše meno" required>
</div>
<div class="form-group">
<input type="email" name="email" placeholder="Váš email" required>
</div>
<div class="form-group">
<textarea name="message" placeholder="Vaša správa" rows="4" required></textarea>
</div>
<button type="submit" class="btn btn-primary">Odoslať správu</button>
</form>
</div>
<?php endif; ?>
</div>
</div>
</section>

View File

@ -0,0 +1,21 @@
<?php
/**
* @var array $project
*/
$faq = $project['content']['generated']['faq'] ?? [];
?>
<section id="faq" class="faq">
<div class="container">
<div class="section-header">
<h2>Časté otázky (FAQ)</h2>
</div>
<div class="faq-list">
<?php foreach ($faq as $item): ?>
<div class="faq-item">
<h3><?= e($item['question']) ?></h3>
<p><?= e($item['answer']) ?></p>
</div>
<?php endforeach; ?>
</div>
</div>
</section>

View File

@ -0,0 +1,20 @@
<?php
/**
* @var array $project
*/
$gallery = $project['wizard_data']['assets']['gallery'] ?? [];
?>
<section id="gallery" class="gallery">
<div class="container">
<div class="section-header">
<h2>Galéria</h2>
</div>
<div class="gallery-grid">
<?php foreach ($gallery as $imagePath): ?>
<div class="gallery-item">
<img src="<?= e($imagePath) ?>" alt="Galéria - <?= e($project['wizard_data']['identity']['business_name']) ?>" loading="lazy">
</div>
<?php endforeach; ?>
</div>
</div>
</section>

View File

@ -0,0 +1,18 @@
<?php
/**
* @var array $project
*/
$hero = $project['content']['generated']['hero'] ?? [];
?>
<section id="hero" class="hero">
<div class="container">
<div class="hero-content">
<h1><?= e($hero['title'] ?? $project['wizard_data']['identity']['business_name']) ?></h1>
<p class="subtitle"><?= e($hero['subtitle'] ?? $project['wizard_data']['identity']['tagline']) ?></p>
<div class="hero-actions">
<a href="#contact" class="btn btn-primary">Kontaktujte nás</a>
<a href="#services" class="btn btn-secondary">Naše služby</a>
</div>
</div>
</div>
</section>

View File

@ -0,0 +1,13 @@
<?php
/**
* @var array $project
*/
?>
<section id="pricing" class="pricing">
<div class="container">
<div class="section-header">
<h2>Cenník</h2>
</div>
<p>Informácie o cenách našich služieb nájdete v sekcii Služby alebo nás kontaktujte pre individuálnu ponuku.</p>
</div>
</section>

View File

@ -0,0 +1,28 @@
<?php
/**
* @var array $project
*/
$services = $project['content']['generated']['services'] ?? [];
$pricingNote = $project['wizard_data']['services']['pricing_note'] ?? '';
?>
<section id="services" class="services">
<div class="container">
<div class="section-header">
<h2>Naše služby</h2>
</div>
<div class="services-grid">
<?php foreach ($services as $service): ?>
<article class="service-card">
<h3><?= e($service['title']) ?></h3>
<p><?= e($service['text']) ?></p>
<?php if (!empty($service['price_info'])): ?>
<div class="price"><?= e($service['price_info']) ?></div>
<?php endif; ?>
</article>
<?php endforeach; ?>
</div>
<?php if ($pricingNote): ?>
<p class="pricing-note"><em><?= e($pricingNote) ?></em></p>
<?php endif; ?>
</div>
</section>

15
src/Utils/Helpers.php Normal file
View File

@ -0,0 +1,15 @@
<?php
/**
* Global helper functions for templates.
*/
if (!function_exists('e')) {
/**
* Securely escapes a value for HTML output.
*/
function e($value): string
{
return htmlspecialchars((string)($value ?? ''), ENT_QUOTES, 'UTF-8');
}
}

View File

@ -1,72 +0,0 @@
<?php
require 'vendor/autoload.php';
// Load configuration
\App\Utils\Config::load(__DIR__ . '/../.env');
use App\Services\LLMpool;
use App\Services\FileStorage;
use App\Actions\ProjectActions;
echo "Testing LLMpool Worker (Step 14)..." . PHP_EOL . PHP_EOL;
$storage = new FileStorage();
$projectActions = new ProjectActions();
// 1. Create a dummy project
$userId = 'u_test_worker';
$userData = [
'user_id' => $userId,
'created_at' => date('c'),
'projects' => []
];
$storage->put("users/{$userId}.json", $userData);
$project = $projectActions->createProject($userId);
$projectId = $project['project_id'];
// Mock some wizard data
$project['wizard_data'] = [
'business_category' => ['group' => 'gastro', 'subcategory' => 'cafe'],
'identity' => ['business_name' => 'Kaviareň u Robota', 'tagline' => 'Káva s dušou'],
'services' => ['items' => [['name' => 'Espresso', 'description' => 'Silná káva', 'price_from' => '2']]],
'smart_answers' => ['terrace' => true]
];
$storage->put("projects/{$projectId}.json", $project);
// 2. Create a dummy task
$taskId = 't_test_worker';
$taskData = [
'task_id' => $taskId,
'project_id' => $projectId,
'status' => 'queued',
'attempt_count' => 0,
'max_attempts' => 3,
'created_at' => date('c'),
'wizard_data' => $project['wizard_data']
];
$storage->put("llm/{$taskId}.json", $taskData);
echo "[INFO] Task created: $taskId for project $projectId" . PHP_EOL;
// 3. Run Worker
$worker = new LLMpool();
echo "[PROCESS] Running worker for task $taskId.json..." . PHP_EOL;
// Since we call real AI, it might take time
$success = $worker->processTask($taskId . '.json');
if ($success) {
echo "[SUCCESS] Worker finished successfully." . PHP_EOL;
$updatedProject = $storage->get("projects/{$projectId}.json");
echo "[INFO] Project status: " . $updatedProject['status'] . PHP_EOL;
echo "[INFO] Generated content keys: " . implode(', ', array_keys($updatedProject['content']['generated'])) . PHP_EOL;
} else {
echo "[FAIL] Worker failed to process task." . PHP_EOL;
$task = $storage->get("llm/{$taskId}.json");
if ($task) {
echo "[DEBUG] Task status: " . $task['status'] . PHP_EOL;
echo "[DEBUG] Last error: " . ($task['last_error'] ?? 'N/A') . PHP_EOL;
}
}