Compare commits

...

25 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
d07bc89eea implemented step 14 by Gemini
- added LLMpool, worker script
2026-06-14 14:39:51 +02:00
ec698f3f34 implemeted step 13 by Gemini
- added configuracion .env
- added test script for DAI API client
2026-06-14 13:00:26 +02:00
2b9b62b0aa implemented step 12 by Gemini
- Task actions
2026-06-14 12:42:41 +02:00
c01eb30632 implemented step 11
- sections on website
2026-06-14 09:06:03 +02:00
c11f7e4d75 implemented step 10 by Gemini
- added 4. step of wizard for style and images
2026-06-14 08:07:23 +02:00
991ff9de00 implemented step 09 by Gemini
- added 3. step of wizard with smart questions
2026-06-14 07:41:52 +02:00
aeeaddd3bc implemented step 08 by Gemini
addes 2. step of wizard
2026-06-14 04:47:56 +02:00
0e0670574d implemented step 07
- added categories for 1. step in wizard
2026-06-12 19:01:34 +02:00
b4960c4e39 implemented step 06 by Gemini
- added skeleton for wizard
2026-06-12 18:08:20 +02:00
071aa2f5c9 implemented step 05 by Gemini
added action saveConsent and ConsentService
2026-06-12 17:22:20 +02:00
20ff641811 implemented step 04 by Gemini,
- added AJAX actions initSession, createProject, listProjects and getProjectStatus
2026-06-12 16:31:42 +02:00
ed7dfe7795 implemented step 03 by Gemini 2026-06-12 16:11:21 +02:00
3c6344e94f added example for DAIAPI communication 2026-06-12 16:10:07 +02:00
33 changed files with 4155 additions and 5 deletions

2
.env.example Normal file
View File

@ -0,0 +1,2 @@
# Production default
DAIAPI_URL=http://192.168.122.10:9001/run

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
/data/admin/*.json
/data/consent/*.json
/data/llm/*.json
/data/projects/*.json
/data/users/*.json
/vendor/
.env

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

83
data/categories.json Normal file
View File

@ -0,0 +1,83 @@
{
"categories": [
{
"id": "gastro",
"name": "Gastro",
"subcategories": [
{"id": "restaurant", "name": "Reštaurácia"},
{"id": "pizza", "name": "Pizzeria"},
{"id": "cafe", "name": "Kaviareň / Bistro"},
{"id": "bar", "name": "Bar / Pub"},
{"id": "catering", "name": "Catering"}
],
"smart_questions": [
{"id": "delivery", "name": "Ponúkate donášku?", "type": "boolean"},
{"id": "reservation", "name": "Prijímate rezervácie vopred?", "type": "boolean"},
{"id": "parking", "name": "Máte k dispozícii parkovisko pre zákazníkov?", "type": "boolean"},
{"id": "terrace", "name": "Máte letnú terasu?", "type": "boolean"}
]
},
{
"id": "beauty",
"name": "Krása a Zdravie",
"subcategories": [
{"id": "barber", "name": "Barber / Kaderníctvo"},
{"id": "cosmetics", "name": "Kozmetický salón"},
{"id": "nails", "name": "Manikúra / Pedikúra"},
{"id": "fitness", "name": "Fitness / Gym"},
{"id": "dentist", "name": "Zubár / Poliklinika"}
],
"smart_questions": [
{"id": "booking_online", "name": "Umožňujete online objednávky?", "type": "boolean"},
{"id": "gift_vouchers", "name": "Predávate darčekové poukážky?", "type": "boolean"},
{"id": "first_visit_discount", "name": "Ponúkate zľavu na prvú návštevu?", "type": "boolean"},
{"id": "parking", "name": "Je v blízkosti parkovanie?", "type": "boolean"}
]
},
{
"id": "crafts",
"name": "Remeslá a Služby",
"subcategories": [
{"id": "plumber", "name": "Inštalatér"},
{"id": "electrician", "name": "Elektrikár"},
{"id": "builder", "name": "Stavebné práce"},
{"id": "mechanic", "name": "Autoservis"},
{"id": "cleaning", "name": "Upratovacie služby"}
],
"smart_questions": [
{"id": "emergency_service", "name": "Poskytujete havarijnú službu 24/7?", "type": "boolean"},
{"id": "warranty", "name": "Poskytujete na prácu predĺženú záruku?", "type": "boolean"},
{"id": "free_quote", "name": "Je obhliadka a cenová ponuka zdarma?", "type": "boolean"},
{"id": "materials_included", "name": "Zabezpečujete aj nákup materiálu?", "type": "boolean"}
]
},
{
"id": "professional",
"name": "Odborné služby",
"subcategories": [
{"id": "lawyer", "name": "Právnik / Advokát"},
{"id": "accountant", "name": "Účtovník"},
{"id": "consulting", "name": "Konzultant / Kouč"},
{"id": "marketing", "name": "Marketingová agentúra"},
{"id": "it_services", "name": "IT služby / Software"}
],
"smart_questions": [
{"id": "online_consultation", "name": "Umožňujete online konzultácie?", "type": "boolean"},
{"id": "fixed_prices", "name": "Máte fixné cenníky služieb?", "type": "boolean"},
{"id": "international", "name": "Pôsobíte aj medzinárodne?", "type": "boolean"},
{"id": "languages", "name": "V akých jazykoch komunikujete?", "type": "text"}
]
},
{
"id": "other",
"name": "Iné",
"subcategories": [
{"id": "custom", "name": "Vlastná kategória"}
],
"smart_questions": [
{"id": "unique_selling_point", "name": "V čom ste jedinečný oproti konkurencii?", "type": "text"},
{"id": "target_audience", "name": "Kto sú vaši hlavní zákazníci?", "type": "text"}
]
}
]
}

View File

@ -36,9 +36,51 @@ Implementovať klienta pre DAIAPI a vytvoriť generátor promptov, ktorý na zá
Žiadne.
## API a dátové štruktúry
DAIAPI typicky očakáva:
- URL: `http://webwizard.test:port/v1/chat/completions` (podľa lokálneho nastavenia).
- Formát: JSON (messages, temperature, atď.).
DAIAPI je proprietarne API, ktore sa da pouzivat volanim na VPN adresu 10.2.8.1 a port 9001, prakticka implementacia v PHP:
```php
function daiAPIrun(string $prompt): ?string
{
$url = "http://192.168.122.10:9001/run";
$payload = json_encode([
"prompt" => $prompt
]);
if ($payload === false) {
return null;
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"Content-Length: " . strlen($payload)
],
CURLOPT_POSTFIELDS => $payload,
CURLOPT_TIMEOUT => 60,
]);
$response = curl_exec($ch);
if ($response === false) {
curl_close($ch);
return null;
}
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
return null;
}
$data = json_decode($response, true);
if (!is_array($data)) {
return null;
}
// očakávame: { "success": true, "answer": "...", ... }
if (empty($data["success"]) || !isset($data["answer"])) {
return null;
}
return $data["answer"];
}
```
## Frontend požiadavky
Nerelevantné.

164
public/ajax.php Normal file
View File

@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
// Load configuration
\App\Utils\Config::load(__DIR__ . '/../.env');
// Set headers
header('Content-Type: application/json; charset=utf-8');
// Error handling
set_error_handler(function ($errno, $errstr, $errfile, $errline) {
throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
});
function sendResponse(bool $success, $dataOrError, int $httpStatus = 200): void {
http_response_code($httpStatus);
$response = ['success' => $success];
if ($success) {
$response['data'] = $dataOrError;
} else {
$response['error'] = $dataOrError;
}
echo json_encode($response, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
exit;
}
try {
// Only POST allowed
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendResponse(false, ['code' => 'METHOD_NOT_ALLOWED', 'message' => 'Only POST requests are allowed.'], 405);
}
// Read JSON input or FormData
$input = file_get_contents('php://input');
$data = json_decode($input, true);
// If multipart/form-data, action and project_id are in $_POST
if (!$data && !empty($_POST)) {
$data = [
'action' => $_POST['action'] ?? null,
'project_id' => $_POST['project_id'] ?? null,
'payload' => $_POST['payload'] ?? []
];
if (is_string($data['payload'])) {
$data['payload'] = json_decode($data['payload'], true);
}
}
if (!$data && $_SERVER['REQUEST_METHOD'] === 'POST') {
sendResponse(false, ['code' => 'INVALID_JSON', 'message' => 'Invalid JSON input.'], 400);
}
// Validate action
$action = $data['action'] ?? null;
if (!$action) {
sendResponse(false, ['code' => 'MISSING_ACTION', 'message' => 'Action is required.'], 400);
}
// Check X-User-ID header (except for initSession)
$userId = $_SERVER['HTTP_X_USER_ID'] ?? $_POST['X-User-ID'] ?? null;
if (!$userId && $action !== 'initSession') {
sendResponse(false, ['code' => 'UNAUTHORIZED', 'message' => 'X-User-ID is missing.'], 401);
}
// Router
$projectActions = new \App\Actions\ProjectActions();
$taskActions = new \App\Actions\TaskActions();
$consentService = new \App\Services\ConsentService();
switch ($action) {
case 'ping':
sendResponse(true, ['message' => 'pong', 'timestamp' => time()]);
break;
case 'initSession':
sendResponse(true, $projectActions->initSession());
break;
case 'getCategories':
$storage = new \App\Services\FileStorage();
sendResponse(true, $storage->get('categories.json'));
break;
case 'createProject':
sendResponse(true, $projectActions->createProject($userId));
break;
case 'listProjects':
sendResponse(true, $projectActions->listProjects($userId));
break;
case 'getProjectStatus':
$projectId = $data['project_id'] ?? null;
if (!$projectId) {
sendResponse(false, ['code' => 'MISSING_PROJECT_ID', 'message' => 'Project ID is required.'], 400);
}
sendResponse(true, $projectActions->getProjectStatus($userId, $projectId));
break;
case 'saveStep':
$projectId = $data['project_id'] ?? null;
$step = (int)($data['payload']['step'] ?? 0);
$payloadData = $data['payload']['data'] ?? null;
if (!$projectId || !$step || !$payloadData) {
sendResponse(false, ['code' => 'MISSING_DATA', 'message' => 'Project ID, step and data are required.'], 400);
}
$success = $projectActions->saveStep($userId, $projectId, $step, $payloadData);
sendResponse($success, ['message' => 'Step saved successfully.']);
break;
case 'saveConsent':
$projectId = $data['project_id'] ?? null;
$consentText = $data['payload']['consent_text'] ?? null;
if (!$projectId || !$consentText) {
sendResponse(false, ['code' => 'MISSING_DATA', 'message' => 'Project ID and consent text are required.'], 400);
}
$success = $consentService->saveConsent($projectId, $userId, $consentText);
sendResponse($success, ['message' => 'Consent saved successfully.']);
break;
case 'uploadAsset':
$projectId = $data['project_id'] ?? null;
if (!$projectId || empty($_FILES['file'])) {
sendResponse(false, ['code' => 'MISSING_DATA', 'message' => 'Project ID and file are required.'], 400);
}
$result = $projectActions->uploadAsset($userId, $projectId, $_FILES['file']);
sendResponse(true, $result);
break;
case 'generateWebsite':
$projectId = $data['project_id'] ?? null;
if (!$projectId) {
sendResponse(false, ['code' => 'MISSING_PROJECT_ID', 'message' => 'Project ID is required.'], 400);
}
$result = $taskActions->generateWebsite($userId, $projectId);
sendResponse(true, $result);
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:
sendResponse(false, ['code' => 'UNKNOWN_ACTION', 'message' => "Action '$action' is not defined."], 404);
break;
}
} catch (Throwable $e) {
sendResponse(false, [
'code' => 'INTERNAL_SERVER_ERROR',
'message' => $e->getMessage()
], 500);
}

627
public/css/wizard.css Normal file
View File

@ -0,0 +1,627 @@
:root {
--primary-color: #2563eb;
--primary-hover: #1d4ed8;
--bg-color: #f8fafc;
--card-bg: #ffffff;
--text-main: #1e293b;
--text-muted: #64748b;
--border-color: #e2e8f0;
--success-color: #22c55e;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg-color);
color: var(--text-main);
line-height: 1.5;
display: flex;
flex-direction: column;
min-height: 100vh;
}
header {
background-color: var(--card-bg);
border-bottom: 1px solid var(--border-color);
padding: 1rem 2rem;
text-align: center;
}
main {
flex: 1;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 2rem;
}
.wizard-container {
background-color: var(--card-bg);
width: 100%;
max-width: 800px;
border-radius: 0.75rem;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
overflow: hidden;
}
.progress-bar {
height: 0.5rem;
background-color: var(--border-color);
width: 100%;
}
.progress-fill {
height: 100%;
background-color: var(--primary-color);
width: 0%;
transition: width 0.3s ease;
}
.wizard-content {
padding: 2.5rem;
}
.step {
display: none;
}
.step.active {
display: block;
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
h2 {
margin-bottom: 1rem;
font-size: 1.5rem;
}
p.step-description {
color: var(--text-muted);
margin-bottom: 2rem;
}
.wizard-footer {
padding: 1.5rem 2.5rem;
background-color: #f1f5f9;
display: flex;
justify-content: space-between;
border-top: 1px solid var(--border-color);
}
button {
padding: 0.625rem 1.25rem;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
font-size: 0.875rem;
}
.btn-secondary {
background-color: transparent;
border: 1px solid var(--border-color);
color: var(--text-main);
}
.btn-secondary:hover {
background-color: #e2e8f0;
}
.btn-primary {
background-color: var(--primary-color);
border: 1px solid var(--primary-color);
color: white;
}
.btn-primary:hover {
background-color: var(--primary-hover);
}
/* Category Cards */
.category-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.category-card {
border: 2px solid var(--border-color);
border-radius: 0.5rem;
padding: 1.5rem 1rem;
text-align: center;
cursor: pointer;
transition: all 0.2s;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.category-card:hover {
border-color: var(--primary-color);
background-color: #eff6ff;
}
.category-card.selected {
border-color: var(--primary-color);
background-color: #eff6ff;
box-shadow: 0 0 0 1px var(--primary-color);
}
.category-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.category-name {
font-weight: 600;
font-size: 0.9375rem;
}
/* Subcategories */
.subcategory-section {
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px dashed var(--border-color);
}
.subcategory-chips {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1rem;
}
.chip {
padding: 0.5rem 1rem;
border: 1px solid var(--border-color);
border-radius: 2rem;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
background-color: white;
}
.chip:hover {
border-color: var(--primary-color);
color: var(--primary-color);
}
.chip.selected {
background-color: var(--primary-color);
border-color: var(--primary-color);
color: white;
}
/* Form controls */
.form-group {
margin-top: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
font-size: 0.875rem;
}
textarea, input[type="text"], input[type="email"], input[type="password"] {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 0.375rem;
font-family: inherit;
font-size: 0.875rem;
}
textarea:focus, input[type="text"]:focus, input[type="email"]:focus, input[type="password"]:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.form-section {
margin-bottom: 2.5rem;
}
.form-section h3 {
font-size: 1.125rem;
margin-bottom: 1.25rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color);
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-hint {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.5rem;
}
.checkbox-group {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem;
background-color: #f8fafc;
border-radius: 0.375rem;
border: 1px solid var(--border-color);
}
.checkbox-group input[type="checkbox"] {
margin-top: 0.25rem;
width: 1rem;
height: 1rem;
cursor: pointer;
}
.checkbox-group label {
margin-bottom: 0;
cursor: pointer;
font-weight: 400;
}
/* Service Items */
.service-item {
background-color: #f8fafc;
border: 1px solid var(--border-color);
border-radius: 0.5rem;
padding: 1.25rem;
margin-bottom: 1rem;
position: relative;
}
.service-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.btn-remove-service {
background: none;
border: none;
color: #ef4444;
cursor: pointer;
padding: 0.25rem;
font-size: 1.25rem;
line-height: 1;
}
.btn-remove-service:hover {
color: #dc2626;
}
/* Smart Questions */
.smart-question-item {
margin-bottom: 1.5rem;
}
/* Palette Grid */
.palette-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.palette-card {
border: 2px solid var(--border-color);
border-radius: 0.5rem;
padding: 1rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
flex-direction: column;
gap: 0.75rem;
background-color: white;
}
.palette-card:hover {
border-color: var(--primary-color);
}
.palette-card.selected {
border-color: var(--primary-color);
box-shadow: 0 0 0 1px var(--primary-color);
}
.palette-colors {
display: flex;
height: 1.5rem;
border-radius: 0.25rem;
overflow: hidden;
}
.palette-colors span {
flex: 1;
}
/* Upload UI */
.upload-box {
border: 2px dashed var(--border-color);
border-radius: 0.5rem;
padding: 2rem;
text-align: center;
cursor: pointer;
transition: all 0.2s;
background-color: #f8fafc;
position: relative;
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
}
.upload-box:hover {
border-color: var(--primary-color);
background-color: #eff6ff;
}
.upload-box.mini {
padding: 1rem;
min-height: 80px;
width: 80px;
font-size: 1.5rem;
color: var(--text-muted);
}
.image-preview {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
padding: 0.5rem;
}
.image-preview img {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 0.25rem;
}
/* Checkbox Grid */
.checkbox-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
}
/* Radio Group Tabs */
.radio-group-tabs {
display: flex;
gap: 0.5rem;
background-color: #f1f5f9;
padding: 0.25rem;
border-radius: 0.5rem;
margin-top: 0.5rem;
}
.radio-group-tabs input[type="radio"] {
display: none;
}
.radio-group-tabs label {
flex: 1;
text-align: center;
padding: 0.5rem;
cursor: pointer;
border-radius: 0.375rem;
transition: all 0.2s;
font-size: 0.875rem;
margin-bottom: 0;
}
.radio-group-tabs input[type="radio"]:checked + label {
background-color: white;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
color: var(--primary-color);
font-weight: 600;
}
.gallery-upload-grid {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.gallery-previews {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.gallery-item {
width: 80px;
height: 80px;
border-radius: 0.5rem;
border: 1px solid var(--border-color);
overflow: hidden;
position: relative;
}
.gallery-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.btn-remove-asset {
position: absolute;
top: -5px;
right: -5px;
background: #ef4444;
color: white;
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
cursor: pointer;
border: none;
padding: 0;
}
/* Generation Loader */
.generation-loader {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
padding: 3rem 0;
}
.spinner {
width: 3rem;
height: 3rem;
border: 4px solid var(--border-color);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
#generation-status {
font-weight: 500;
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 {
opacity: 0.5;
cursor: not-allowed;
}
.hidden {
display: none;
}
@media (max-width: 640px) {
main {
padding: 1rem;
}
.wizard-content {
padding: 1.5rem;
}
.wizard-footer {
padding: 1rem 1.5rem;
}
}

372
public/index.html Normal file
View File

@ -0,0 +1,372 @@
<!DOCTYPE html>
<html lang="sk">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebWizard - AI Website Concierge</title>
<link rel="stylesheet" href="css/wizard.css">
</head>
<body>
<header>
<h1>WebWizard</h1>
</header>
<main>
<div class="wizard-container">
<div class="progress-bar">
<div class="progress-fill"></div>
</div>
<div class="wizard-content">
<!-- Step 1: Segmentácia biznisu -->
<div class="step active" data-step="1">
<h2>Oblasť podnikania</h2>
<p class="step-description">V čom podnikáte? Pomôže nám to prispôsobiť váš web.</p>
<div id="category-list" class="category-grid">
<!-- Categories will be injected here -->
</div>
<div id="subcategory-container" class="subcategory-section hidden">
<h3>Vyberte podkategóriu</h3>
<div id="subcategory-list" class="subcategory-chips">
<!-- Subcategories will be injected here -->
</div>
<div id="custom-category-group" class="form-group hidden">
<label for="custom-description">Popíšte vašu oblasť podnikania</label>
<textarea id="custom-description" rows="3" placeholder="Napr. Súkromná materská škola, Prenájom jácht..."></textarea>
</div>
</div>
</div>
<!-- Step 2: Identita, kontakt a GDPR -->
<div class="step" data-step="2">
<h2>Identita a kontakt</h2>
<p class="step-description">Základné informácie o vašej firme a povinný GDPR súhlas.</p>
<div class="form-section">
<h3>Identita firmy</h3>
<div class="form-group">
<label for="business-name">Názov firmy / Značky *</label>
<input type="text" id="business-name" placeholder="Napr. Pizza Marco" required>
</div>
<div class="form-group">
<label for="business-tagline">Slogan (voliteľné)</label>
<input type="text" id="business-tagline" placeholder="Napr. Najlepšia pizza v meste">
</div>
<div class="form-group">
<label for="business-description">Stručný popis podnikania</label>
<textarea id="business-description" rows="2" placeholder="Napr. Rodinná pizzeria so zameraním na tradičné recepty."></textarea>
</div>
</div>
<div class="form-section">
<h3>Kontaktné údaje</h3>
<div class="form-grid">
<div class="form-group">
<label for="contact-email">Email</label>
<input type="email" id="contact-email" placeholder="email@priklad.sk">
</div>
<div class="form-group">
<label for="contact-phone">Telefón</label>
<input type="text" id="contact-phone" placeholder="+421 ...">
</div>
</div>
<div class="form-grid">
<div class="form-group">
<label for="contact-address">Adresa (ulica a číslo)</label>
<input type="text" id="contact-address" placeholder="Hlavná 123">
</div>
<div class="form-group">
<label for="contact-city">Mesto</label>
<input type="text" id="contact-city" placeholder="Bratislava">
</div>
</div>
<div class="form-grid">
<div class="form-group">
<label for="contact-facebook">Facebook (URL)</label>
<input type="text" id="contact-facebook" placeholder="https://facebook.com/...">
</div>
<div class="form-group">
<label for="contact-instagram">Instagram (URL)</label>
<input type="text" id="contact-instagram" placeholder="https://instagram.com/...">
</div>
</div>
<p class="form-hint">* Aspoň jeden z údajov (Email / Telefón) je povinný.</p>
</div>
<div class="form-section gdpr-section">
<div class="checkbox-group">
<input type="checkbox" id="gdpr-consent">
<label for="gdpr-consent">Súhlasím so spracovaním zadaných údajov za účelom vytvorenia webovej stránky. *</label>
</div>
</div>
</div>
<!-- Step 3: Služby a smart otázky -->
<div class="step" data-step="3">
<h2>Služby a doplňujúce otázky</h2>
<p class="step-description">Čo presne ponúkate vašim zákazníkom? AI na základe týchto dát pripraví texty.</p>
<div class="form-section">
<h3>Vaše hlavné služby</h3>
<div id="services-list">
<!-- Service items will be added here -->
</div>
<button id="btn-add-service" class="btn-secondary" style="margin-top: 1rem;">+ Pridať službu</button>
<div class="form-group" style="margin-top: 2rem;">
<label for="pricing-note">Poznámka k cenám (voliteľné)</label>
<input type="text" id="pricing-note" placeholder="Napr. Ceny sú orientačné, Konečná cena po obhliadke...">
</div>
</div>
<div id="smart-questions-container" class="form-section">
<h3>Doplňujúce informácie</h3>
<div id="smart-questions-list">
<!-- Smart questions will be injected here -->
</div>
</div>
</div>
<!-- Step 4: Vizuálny štýl -->
<div class="step" data-step="4">
<h2>Vizuálny štýl a assety</h2>
<p class="step-description">Ako by mal váš web vyzerať? Vyberte si štýl a nahrajte vlastné obrázky.</p>
<div class="form-section">
<h3>Dizajnový smer</h3>
<div id="style-grid" class="category-grid">
<div class="category-card" data-style="minimal">
<div class="category-icon"></div>
<div class="category-name">Minimalistický</div>
<div class="form-hint" style="font-size: 0.7rem;">Čistý, veľa bieleho priestoru</div>
</div>
<div class="category-card" data-style="modern">
<div class="category-icon">🎨</div>
<div class="category-name">Moderný</div>
<div class="form-hint" style="font-size: 0.7rem;">Svieži, výrazné prvky</div>
</div>
<div class="category-card" data-style="premium">
<div class="category-icon">💎</div>
<div class="category-name">Premium</div>
<div class="form-hint" style="font-size: 0.7rem;">Elegantný, tmavé témy</div>
</div>
</div>
</div>
<div class="form-section">
<h3>Farebná paleta</h3>
<div id="palette-list" class="palette-grid">
<div class="palette-card" data-palette="blue-gray">
<div class="palette-colors">
<span style="background: #2563eb;"></span>
<span style="background: #64748b;"></span>
<span style="background: #f8fafc;"></span>
</div>
<span>Profesionálna modrá</span>
</div>
<div class="palette-card" data-palette="nature-green">
<div class="palette-colors">
<span style="background: #059669;"></span>
<span style="background: #fbbf24;"></span>
<span style="background: #fdfdfd;"></span>
</div>
<span>Prírodná zelená</span>
</div>
<div class="palette-card" data-palette="elegant-gold">
<div class="palette-colors">
<span style="background: #111827;"></span>
<span style="background: #d4af37;"></span>
<span style="background: #ffffff;"></span>
</div>
<span>Elegantná zlatá</span>
</div>
</div>
</div>
<div class="form-section">
<h3>Vaše assety</h3>
<div class="upload-container">
<label>Logo firmy</label>
<div class="upload-box" id="logo-upload-box">
<input type="file" id="logo-input" accept="image/*" class="hidden">
<div class="upload-placeholder">
<span>Kliknite pre nahranie loga</span>
</div>
<div id="logo-preview" class="image-preview hidden"></div>
</div>
</div>
<div class="upload-container" style="margin-top: 2rem;">
<label>Fotografie do galérie (max 5)</label>
<div class="gallery-upload-grid">
<div id="gallery-previews" class="gallery-previews"></div>
<div class="upload-box mini" id="gallery-add-box">
<input type="file" id="gallery-input" accept="image/*" multiple class="hidden">
<span>+</span>
</div>
</div>
</div>
</div>
</div>
<!-- Step 5: Moduly -->
<div class="step" data-step="5">
<h2>Moduly a funkcie webu</h2>
<p class="step-description">Vyberte si sekcie, ktoré chcete mať na úvodnej stránke a nastavte kontaktný formulár.</p>
<div class="form-section">
<h3>Sekcie domovskej stránky</h3>
<div class="checkbox-grid">
<div class="checkbox-group">
<input type="checkbox" id="mod-hero" data-module="hero" checked disabled>
<label for="mod-hero">Hero sekcia (Úvod)</label>
</div>
<div class="checkbox-group">
<input type="checkbox" id="mod-about" data-module="about">
<label for="mod-about">O nás</label>
</div>
<div class="checkbox-group">
<input type="checkbox" id="mod-services" data-module="services" checked>
<label for="mod-services">Služby</label>
</div>
<div class="checkbox-group">
<input type="checkbox" id="mod-pricing" data-module="pricing">
<label for="mod-pricing">Cenník</label>
</div>
<div class="checkbox-group">
<input type="checkbox" id="mod-gallery" data-module="gallery">
<label for="mod-gallery">Galéria</label>
</div>
<div class="checkbox-group">
<input type="checkbox" id="mod-faq" data-module="faq">
<label for="mod-faq">Časté otázky (FAQ)</label>
</div>
<div class="checkbox-group">
<input type="checkbox" id="mod-contact" data-module="contact" checked disabled>
<label for="mod-contact">Kontakt</label>
</div>
</div>
</div>
<div class="form-section">
<h3>Kontaktný formulár</h3>
<div class="checkbox-group" style="margin-bottom: 1.5rem;">
<input type="checkbox" id="form-enabled" checked>
<label for="form-enabled">Aktivovať kontaktný formulár na webe</label>
</div>
<div id="form-config-container">
<label>Spôsob spracovania správ</label>
<div class="radio-group-tabs">
<input type="radio" id="mode-local" name="form-mode" value="local" checked>
<label for="mode-local">Lokálny mód (Prezeranie v administrácii)</label>
<input type="radio" id="mode-smtp" name="form-mode" value="smtp">
<label for="mode-smtp">Email (SMTP odosielanie)</label>
</div>
<div id="config-local" class="form-group" style="margin-top: 1.5rem;">
<label for="local-password">Heslo pre prístup k správam *</label>
<input type="password" id="local-password" placeholder="Zadajte bezpečné heslo">
<p class="form-hint">Týmto heslom sa budete prihlasovať do zabezpečeného priečinka so správami.</p>
</div>
<div id="config-smtp" class="hidden" style="margin-top: 1.5rem;">
<div class="form-grid">
<div class="form-group">
<label for="smtp-host">SMTP Host</label>
<input type="text" id="smtp-host" placeholder="smtp.priklad.sk">
</div>
<div class="form-group">
<label for="smtp-port">Port</label>
<input type="text" id="smtp-port" placeholder="465">
</div>
</div>
<div class="form-grid">
<div class="form-group">
<label for="smtp-user">Užívateľské meno</label>
<input type="text" id="smtp-user" placeholder="meno@domena.sk">
</div>
<div class="form-group">
<label for="smtp-pass">Heslo</label>
<input type="password" id="smtp-pass">
</div>
</div>
<div class="form-group">
<label for="smtp-recipient">Cieľový email pre správy</label>
<input type="email" id="smtp-recipient" placeholder="info@vasadomena.sk">
</div>
</div>
</div>
</div>
</div>
<!-- Step 6: Generovanie -->
<div class="step" data-step="6">
<h2>Generovanie obsahu</h2>
<p class="step-description">Naša AI teraz pripravuje váš textový obsah a štruktúru webu. Môže to trvať niekoľko desiatok sekúnd.</p>
<div class="generation-loader">
<div class="spinner"></div>
<p id="generation-status">Čakám na zaradenie do fronty...</p>
</div>
</div>
<!-- Step 7: Preview -->
<div class="step" data-step="7">
<div class="step-header-with-actions">
<div>
<h2>Náhľad vášho webu</h2>
<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>
<!-- Step 8: Export -->
<div class="step" data-step="8">
<div class="success-screen">
<div class="success-icon">🎉</div>
<h2>Váš web je pripravený!</h2>
<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 class="wizard-footer">
<button id="btn-prev" class="btn-secondary" disabled>Späť</button>
<button id="btn-next" class="btn-primary">Pokračovať</button>
</div>
</div>
</main>
<script src="js/wizard.js"></script>
</body>
</html>

891
public/js/wizard.js Normal file
View File

@ -0,0 +1,891 @@
/**
* WebWizard Frontend Logic
*/
const App = {
state: {
userId: localStorage.getItem('ww_user_id'),
currentStep: 1,
totalSteps: 8,
project: null,
categories: [],
selection: {
category: null,
subcategory: null,
customDescription: '',
style: null,
palette: null,
logo: null,
gallery: []
}
},
async init() {
console.log('Initializing WebWizard...');
if (!this.state.userId) {
await this.initSession();
}
await this.loadCategories();
this.bindEvents();
// If we don't have a project, try to load existing or create one
if (!this.state.project) {
await this.loadLastProject();
}
this.showStep(this.state.currentStep);
},
async loadLastProject() {
try {
const response = await this.apiCall('listProjects');
if (response.success && response.data.length > 0) {
// Load the most recent project
const lastProject = response.data[0];
const statusResponse = await this.apiCall('getProjectStatus', {}, lastProject.project_id);
if (statusResponse.success) {
this.state.project = statusResponse.data;
this.state.currentStep = this.state.project.current_step || 1;
console.log('Loaded existing project:', this.state.project.project_id);
this.syncSelectionWithProject();
// Resume polling if generating
const pollingStatuses = ['queued', 'generating', 'rendering'];
if (pollingStatuses.includes(this.state.project.status)) {
this.state.currentStep = 6;
this.startPolling();
}
}
} else {
await this.createProject();
}
} catch (error) {
console.error('Failed to load projects:', error);
await this.createProject();
}
},
syncSelectionWithProject() {
if (this.state.project && this.state.project.wizard_data) {
const wd = this.state.project.wizard_data;
// Step 1
if (wd.business_category) {
const bc = wd.business_category;
this.state.selection.category = bc.group;
this.state.selection.subcategory = bc.subcategory;
this.state.selection.customDescription = bc.custom_description || '';
if (this.state.selection.category) {
this.selectCategory(this.state.selection.category);
this.state.selection.subcategory = bc.subcategory;
this.renderSubcategories(this.state.selection.category);
}
}
// Step 2
if (wd.identity) {
document.getElementById('business-name').value = wd.identity.business_name || '';
document.getElementById('business-tagline').value = wd.identity.tagline || '';
document.getElementById('business-description').value = wd.identity.description || '';
}
if (wd.contact) {
document.getElementById('contact-email').value = wd.contact.email || '';
document.getElementById('contact-phone').value = wd.contact.phone || '';
document.getElementById('contact-address').value = wd.contact.address || '';
document.getElementById('contact-city').value = wd.contact.city || '';
document.getElementById('contact-facebook').value = wd.contact.socials?.facebook || '';
document.getElementById('contact-instagram').value = wd.contact.socials?.instagram || '';
}
// Step 3
if (wd.services && wd.services.items) {
const list = document.getElementById('services-list');
list.innerHTML = '';
wd.services.items.forEach(item => this.addServiceItem(item));
document.getElementById('pricing-note').value = wd.services.pricing_note || '';
} else {
this.addServiceItem();
}
// Step 4
if (wd.visuals) {
this.state.selection.style = wd.visuals.style;
this.state.selection.palette = wd.visuals.palette;
this.updateStyleSelection();
this.updatePaletteSelection();
}
if (wd.assets) {
this.state.selection.logo = wd.assets.logo;
this.state.selection.gallery = wd.assets.gallery || [];
if (this.state.selection.logo) this.renderLogoPreview();
this.renderGalleryPreviews();
}
// Step 5
if (wd.modules) {
const m = wd.modules;
if (m.sections) {
m.sections.forEach(sec => {
const el = document.querySelector(`input[data-module="${sec}"]`);
if (el) el.checked = true;
});
}
if (m.contact_form) {
document.getElementById('form-enabled').checked = m.contact_form.enabled;
document.getElementById('form-config-container').classList.toggle('hidden', !m.contact_form.enabled);
if (m.contact_form.mode) {
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-smtp').classList.toggle('hidden', m.contact_form.mode !== 'smtp');
}
if (m.contact_form.smtp) {
document.getElementById('smtp-host').value = m.contact_form.smtp.host || '';
document.getElementById('smtp-port').value = m.contact_form.smtp.port || '';
document.getElementById('smtp-user').value = m.contact_form.smtp.user || '';
document.getElementById('smtp-recipient').value = m.contact_form.smtp.recipient || '';
}
}
}
}
},
async initSession() {
try {
const response = await this.apiCall('initSession');
if (response.success) {
this.state.userId = response.data.user_id;
localStorage.setItem('ww_user_id', this.state.userId);
console.log('Session initialized:', this.state.userId);
}
} catch (error) {
console.error('Failed to initialize session:', error);
alert('Chyba pri inicializácii session.');
}
},
async createProject() {
try {
const response = await this.apiCall('createProject');
if (response.success) {
this.state.project = response.data;
console.log('Project created:', this.state.project.project_id);
}
} catch (error) {
console.error('Failed to create project:', error);
}
},
async loadCategories() {
try {
const response = await this.apiCall('getCategories');
if (response.success) {
this.state.categories = response.data.categories;
this.renderCategories();
}
} catch (error) {
console.error('Failed to load categories:', error);
}
},
async apiCall(action, payload = {}, projectId = null) {
const body = {
action,
project_id: projectId || (this.state.project ? this.state.project.project_id : null),
payload
};
const headers = {
'Content-Type': 'application/json'
};
if (this.state.userId) {
headers['X-User-ID'] = this.state.userId;
}
const response = await fetch('/ajax.php', {
method: 'POST',
headers,
body: JSON.stringify(body)
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error.message || 'API Error');
}
return result;
},
bindEvents() {
document.getElementById('btn-next').addEventListener('click', () => this.nextStep());
document.getElementById('btn-prev').addEventListener('click', () => this.prevStep());
document.getElementById('custom-description').addEventListener('input', (e) => {
this.state.selection.customDescription = e.target.value;
});
// Step 2 validation listeners
['business-name', 'gdpr-consent', 'contact-email', 'contact-phone'].forEach(id => {
const el = document.getElementById(id);
if (el) {
el.addEventListener('input', () => this.updateUI());
el.addEventListener('change', () => this.updateUI());
}
});
// Step 3 events
document.getElementById('btn-add-service').addEventListener('click', () => this.addServiceItem());
// Step 4 events
document.querySelectorAll('#style-grid .category-card').forEach(card => {
card.addEventListener('click', () => {
this.state.selection.style = card.getAttribute('data-style');
this.updateStyleSelection();
});
});
document.querySelectorAll('#palette-list .palette-card').forEach(card => {
card.addEventListener('click', () => {
this.state.selection.palette = card.getAttribute('data-palette');
this.updatePaletteSelection();
});
});
document.getElementById('logo-upload-box').addEventListener('click', () => document.getElementById('logo-input').click());
document.getElementById('logo-input').addEventListener('change', (e) => this.handleFileUpload(e, 'logo'));
document.getElementById('gallery-add-box').addEventListener('click', () => document.getElementById('gallery-input').click());
document.getElementById('gallery-input').addEventListener('change', (e) => this.handleFileUpload(e, 'gallery'));
// Step 5 events
document.getElementById('form-enabled').addEventListener('change', (e) => {
document.getElementById('form-config-container').classList.toggle('hidden', !e.target.checked);
});
document.querySelectorAll('input[name="form-mode"]').forEach(radio => {
radio.addEventListener('change', (e) => {
document.getElementById('config-local').classList.toggle('hidden', e.target.value !== 'local');
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() {
const container = document.getElementById('category-list');
container.innerHTML = '';
const icons = {
gastro: '🍕',
beauty: '✨',
crafts: '🛠️',
professional: '💼',
other: '❓'
};
this.state.categories.forEach(cat => {
const card = document.createElement('div');
card.className = 'category-card';
if (this.state.selection.category === cat.id) card.classList.add('selected');
card.innerHTML = `
<div class="category-icon">${icons[cat.id] || '📁'}</div>
<div class="category-name">${cat.name}</div>
`;
card.addEventListener('click', () => this.selectCategory(cat.id));
container.appendChild(card);
});
},
selectCategory(categoryId) {
this.state.selection.category = categoryId;
this.state.selection.subcategory = null;
this.renderCategories();
this.renderSubcategories(categoryId);
this.renderSmartQuestions(categoryId);
document.getElementById('subcategory-container').classList.remove('hidden');
if (categoryId === 'other') {
document.getElementById('custom-category-group').classList.remove('hidden');
} else {
document.getElementById('custom-category-group').classList.add('hidden');
}
this.updateUI();
},
renderSubcategories(categoryId) {
const container = document.getElementById('subcategory-list');
container.innerHTML = '';
const category = this.state.categories.find(c => c.id === categoryId);
if (!category) return;
category.subcategories.forEach(sub => {
const chip = document.createElement('div');
chip.className = 'chip';
if (this.state.selection.subcategory === sub.id) chip.classList.add('selected');
chip.textContent = sub.name;
chip.addEventListener('click', () => {
this.state.selection.subcategory = sub.id;
this.renderSubcategories(categoryId);
this.updateUI();
});
container.appendChild(chip);
});
},
addServiceItem(data = {}) {
const container = document.getElementById('services-list');
const itemDiv = document.createElement('div');
itemDiv.className = 'service-item';
itemDiv.innerHTML = `
<div class="service-item-header">
<span class="service-number">Služba</span>
<button class="btn-remove-service" title="Odstrániť">×</button>
</div>
<div class="form-group">
<label>Názov služby *</label>
<input type="text" class="service-name" placeholder="Napr. Klasická masáž" value="${data.name || ''}" required>
</div>
<div class="form-grid">
<div class="form-group">
<label>Cena od (€)</label>
<input type="text" class="service-price" placeholder="25" value="${data.price_from || ''}">
</div>
<div class="form-group">
<label>Stručný popis</label>
<input type="text" class="service-desc" placeholder="Trvanie 45 minút..." value="${data.description || ''}">
</div>
</div>
`;
itemDiv.querySelector('.btn-remove-service').addEventListener('click', () => {
if (container.querySelectorAll('.service-item').length > 1) {
itemDiv.remove();
} else {
alert('Zadajte aspoň jednu službu.');
}
});
container.appendChild(itemDiv);
},
renderSmartQuestions(categoryId) {
const container = document.getElementById('smart-questions-list');
container.innerHTML = '';
const category = this.state.categories.find(c => c.id === categoryId);
if (!category || !category.smart_questions) {
document.getElementById('smart-questions-container').classList.add('hidden');
return;
}
document.getElementById('smart-questions-container').classList.remove('hidden');
category.smart_questions.forEach(q => {
const qDiv = document.createElement('div');
qDiv.className = 'smart-question-item';
if (q.type === 'boolean') {
qDiv.innerHTML = `
<div class="checkbox-group">
<input type="checkbox" id="sq-${q.id}" data-id="${q.id}">
<label for="sq-${q.id}">${q.name}</label>
</div>
`;
} else {
qDiv.innerHTML = `
<div class="form-group">
<label for="sq-${q.id}">${q.name}</label>
<input type="text" id="sq-${q.id}" data-id="${q.id}" placeholder="Vaša odpoveď...">
</div>
`;
}
container.appendChild(qDiv);
// Apply existing answer if available
if (this.state.project && this.state.project.wizard_data.smart_answers) {
const answer = this.state.project.wizard_data.smart_answers[q.id];
if (answer !== undefined) {
const input = qDiv.querySelector(`#sq-${q.id}`);
if (q.type === 'boolean') {
input.checked = !!answer;
} else {
input.value = answer;
}
}
}
});
},
updateStyleSelection() {
document.querySelectorAll('#style-grid .category-card').forEach(card => {
card.classList.toggle('selected', card.getAttribute('data-style') === this.state.selection.style);
});
this.updateUI();
},
updatePaletteSelection() {
document.querySelectorAll('#palette-list .palette-card').forEach(card => {
card.classList.toggle('selected', card.getAttribute('data-palette') === this.state.selection.palette);
});
this.updateUI();
},
async handleFileUpload(event, target) {
const files = event.target.files;
if (!files.length) return;
for (const file of files) {
const formData = new FormData();
formData.append('action', 'uploadAsset');
formData.append('project_id', this.state.project.project_id);
formData.append('file', file);
formData.append('X-User-ID', this.state.userId);
try {
const response = await fetch('/ajax.php', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
if (target === 'logo') {
this.state.selection.logo = result.data.path;
this.renderLogoPreview();
} else {
if (this.state.selection.gallery.length < 5) {
this.state.selection.gallery.push(result.data.path);
this.renderGalleryPreviews();
} else {
alert('Maximálne 5 fotografií.');
}
}
} else {
alert('Upload zlyhal: ' + result.error.message);
}
} catch (error) {
console.error('Upload error:', error);
alert('Chyba pri nahrávaní.');
}
}
this.updateUI();
},
renderLogoPreview() {
const preview = document.getElementById('logo-preview');
preview.innerHTML = `<img src="/${this.state.selection.logo}" alt="Logo">
<button class="btn-remove-asset" id="btn-remove-logo">×</button>`;
preview.classList.remove('hidden');
document.querySelector('#logo-upload-box .upload-placeholder').classList.add('hidden');
document.getElementById('btn-remove-logo').addEventListener('click', (e) => {
e.stopPropagation();
this.removeAsset('logo');
});
},
renderGalleryPreviews() {
const container = document.getElementById('gallery-previews');
container.innerHTML = '';
this.state.selection.gallery.forEach((path, index) => {
const item = document.createElement('div');
item.className = 'gallery-item';
item.innerHTML = `<img src="/${path}" alt="Gallery ${index+1}">
<button class="btn-remove-asset" data-index="${index}">×</button>`;
item.querySelector('.btn-remove-asset').addEventListener('click', (e) => {
e.stopPropagation();
this.removeAsset('gallery', index);
});
container.appendChild(item);
});
},
removeAsset(target, index = null) {
if (target === 'logo') {
this.state.selection.logo = null;
document.getElementById('logo-preview').classList.add('hidden');
document.querySelector('#logo-upload-box .upload-placeholder').classList.remove('hidden');
} else {
this.state.selection.gallery.splice(index, 1);
this.renderGalleryPreviews();
}
this.updateUI();
},
showStep(n) {
const steps = document.querySelectorAll('.step');
steps.forEach(step => step.classList.remove('active'));
const activeStep = document.querySelector(`.step[data-step="${n}"]`);
if (activeStep) {
activeStep.classList.add('active');
}
this.state.currentStep = n;
// Specific step logic
if (n === 7) {
this.loadPreview();
}
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() {
if (this.state.currentStep === 1) {
if (!this.state.selection.category || !this.state.selection.subcategory) {
alert('Prosím, vyberte kategóriu aj podkategóriu.');
return;
}
try {
await this.apiCall('saveStep', {
step: 1,
data: {
business_category: {
group: this.state.selection.category,
subcategory: this.state.selection.subcategory,
custom_description: this.state.selection.category === 'other' ? this.state.selection.customDescription : null
}
}
});
} catch (error) {
console.error('Save step failed:', error);
alert('Nepodarilo sa uložiť dáta.');
return;
}
} else if (this.state.currentStep === 2) {
const businessName = document.getElementById('business-name').value;
const tagline = document.getElementById('business-tagline').value;
const description = document.getElementById('business-description').value;
const email = document.getElementById('contact-email').value;
const phone = document.getElementById('contact-phone').value;
const address = document.getElementById('contact-address').value;
const city = document.getElementById('contact-city').value;
const gdpr = document.getElementById('gdpr-consent').checked;
if (!businessName || !gdpr || (!email && !phone)) {
alert('Prosím, vyplňte povinné údaje a zaškrtnite GDPR súhlas.');
return;
}
try {
// 1. Save Consent
await this.apiCall('saveConsent', {
consent_text: document.querySelector('label[for="gdpr-consent"]').textContent
});
// 2. Save Step 2
await this.apiCall('saveStep', {
step: 2,
data: {
identity: {
business_name: businessName,
tagline: tagline,
description: description
},
contact: {
email: email,
phone: phone,
address: address,
city: city,
socials: {
facebook: document.getElementById('contact-facebook').value,
instagram: document.getElementById('contact-instagram').value
}
}
}
});
} catch (error) {
console.error('Save step 2 failed:', error);
alert('Nepodarilo sa uložiť dáta: ' + error.message);
return;
}
} else if (this.state.currentStep === 3) {
const serviceItems = [];
document.querySelectorAll('.service-item').forEach(item => {
const name = item.querySelector('.service-name').value;
if (name) {
serviceItems.push({
name: name,
price_from: item.querySelector('.service-price').value,
description: item.querySelector('.service-desc').value
});
}
});
const smartAnswers = {};
document.querySelectorAll('.smart-question-item input').forEach(input => {
const id = input.getAttribute('data-id');
if (input.type === 'checkbox') {
smartAnswers[id] = input.checked;
} else {
smartAnswers[id] = input.value;
}
});
try {
await this.apiCall('saveStep', {
step: 3,
data: {
services: {
items: serviceItems,
pricing_note: document.getElementById('pricing-note').value
},
smart_answers: smartAnswers
}
});
} catch (error) {
console.error('Save step 3 failed:', error);
alert('Nepodarilo sa uložiť dáta: ' + error.message);
return;
}
} else if (this.state.currentStep === 4) {
if (!this.state.selection.style || !this.state.selection.palette) {
alert('Prosím, vyberte vizuálny štýl a farebnú paletu.');
return;
}
try {
await this.apiCall('saveStep', {
step: 4,
data: {
visuals: {
style: this.state.selection.style,
palette: this.state.selection.palette
},
assets: {
logo: this.state.selection.logo,
gallery: this.state.selection.gallery
}
}
});
} catch (error) {
console.error('Save step 4 failed:', error);
alert('Nepodarilo sa uložiť dáta: ' + error.message);
return;
}
} else if (this.state.currentStep === 5) {
const sections = [];
document.querySelectorAll('input[data-module]:checked').forEach(el => {
sections.push(el.getAttribute('data-module'));
});
const formEnabled = document.getElementById('form-enabled').checked;
const formMode = document.querySelector('input[name="form-mode"]:checked').value;
const modulesData = {
pages: ['home'],
sections: sections,
contact_form: {
enabled: formEnabled,
mode: formMode,
smtp: formMode === 'smtp' ? {
host: document.getElementById('smtp-host').value,
port: document.getElementById('smtp-port').value,
user: document.getElementById('smtp-user').value,
pass: document.getElementById('smtp-pass').value,
recipient: document.getElementById('smtp-recipient').value
} : null,
local_viewer: formMode === 'local' ? {
password: document.getElementById('local-password').value
} : null
}
};
try {
await this.apiCall('saveStep', {
step: 5,
data: {
modules: modulesData
}
});
} catch (error) {
console.error('Save step 5 failed:', error);
alert('Nepodarilo sa uložiť dáta: ' + error.message);
return;
}
}
if (this.state.currentStep < this.state.totalSteps) {
this.showStep(this.state.currentStep + 1);
// Trigger generation if entering step 6
if (this.state.currentStep === 6) {
this.startWebsiteGeneration();
}
}
},
async startWebsiteGeneration() {
try {
const result = await this.apiCall('generateWebsite');
if (result.success) {
this.startPolling();
}
} catch (error) {
console.error('Failed to start generation:', error);
document.getElementById('generation-status').textContent = 'Chyba: ' + error.message;
}
},
startPolling() {
if (this.pollingInterval) clearInterval(this.pollingInterval);
this.pollingInterval = setInterval(async () => {
try {
const response = await this.apiCall('getProjectStatus');
if (response.success) {
const project = response.data;
this.state.project = project;
this.updateGenerationStatus(project.status);
if (project.status === 'ready' || project.status === 'failed') {
clearInterval(this.pollingInterval);
if (project.status === 'ready') {
// Move to preview step
this.showStep(7);
} else {
document.getElementById('generation-status').textContent = 'Generovanie zlyhalo. Skúste to neskôr.';
}
}
}
} catch (error) {
console.error('Polling error:', error);
}
}, 5000);
},
updateGenerationStatus(status) {
const statusEl = document.getElementById('generation-status');
const messages = {
'queued': 'Zaradené do fronty, čakám na AI...',
'generating': 'AI práve píše texty pre váš web...',
'rendering': 'Skladáme výslednú stránku...',
'ready': 'Hotovo! Pripravujem náhľad...',
'failed': 'Nastal problém pri generovaní.'
};
statusEl.textContent = messages[status] || 'Spracovávam...';
},
prevStep() {
if (this.state.currentStep > 1) {
this.showStep(this.state.currentStep - 1);
}
},
updateUI() {
const btnPrev = document.getElementById('btn-prev');
const btnNext = document.getElementById('btn-next');
btnPrev.disabled = this.state.currentStep === 1;
// Validation for Next button
let nextDisabled = false;
if (this.state.currentStep === 1) {
nextDisabled = !this.state.selection.category || !this.state.selection.subcategory;
} else if (this.state.currentStep === 2) {
const name = document.getElementById('business-name').value;
const email = document.getElementById('contact-email').value;
const phone = document.getElementById('contact-phone').value;
const gdpr = document.getElementById('gdpr-consent').checked;
nextDisabled = !name || !gdpr || (!email && !phone);
} else if (this.state.currentStep === 4) {
nextDisabled = !this.state.selection.style || !this.state.selection.palette;
} else if (this.state.currentStep === 5) {
const formEnabled = document.getElementById('form-enabled').checked;
if (formEnabled) {
const mode = document.querySelector('input[name="form-mode"]:checked').value;
if (mode === 'local') {
nextDisabled = !document.getElementById('local-password').value;
} else {
nextDisabled = !document.getElementById('smtp-host').value || !document.getElementById('smtp-recipient').value;
}
}
}
btnNext.disabled = nextDisabled;
// Hide footer in generating step
document.querySelector('.wizard-footer').classList.toggle('hidden', this.state.currentStep === 6);
if (this.state.currentStep === this.state.totalSteps) {
btnNext.textContent = 'Dokončiť';
} else {
btnNext.textContent = 'Pokračovať';
}
const progressFill = document.querySelector('.progress-fill');
const percent = ((this.state.currentStep - 1) / (this.state.totalSteps - 1)) * 100;
progressFill.style.width = `${percent}%`;
}
};
document.addEventListener('DOMContentLoaded', () => App.init());

68
scripts/worker.php Normal file
View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/**
* LLMpool Worker CLI Script
* This script processes the AI generation queue.
*/
require_once __DIR__ . '/../vendor/autoload.php';
// Load configuration from root .env
\App\Utils\Config::load(__DIR__ . '/../.env');
use App\Services\LLMpool;
// Ensure this script is only run from CLI
if (php_sapi_name() !== 'cli') {
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';
$fp = fopen($lockFile, 'w');
if (!flock($fp, LOCK_EX | LOCK_NB)) {
die("[" . date('Y-m-d H:i:s') . "] Worker is already running. Skipping." . PHP_EOL);
}
if ($isLoop) {
echo "[" . date('Y-m-d H:i:s') . "] Worker started in loop mode (interval: {$loopInterval}s)" . PHP_EOL;
} else {
echo "[" . date('Y-m-d H:i:s') . "] Starting LLMpool worker (single run)..." . 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);
fclose($fp);
unlink($lockFile);

View File

@ -0,0 +1,311 @@
<?php
declare(strict_types=1);
namespace App\Actions;
use App\Services\FileStorage;
use Exception;
class ProjectActions
{
private FileStorage $storage;
public function __construct()
{
$this->storage = new FileStorage();
}
/**
* Initializes a new user session.
*/
public function initSession(): array
{
$userId = 'u_' . bin2hex(random_bytes(8));
$userData = [
'user_id' => $userId,
'created_at' => date('c'),
'projects' => []
];
$this->storage->put("users/{$userId}.json", $userData);
return ['user_id' => $userId];
}
/**
* Creates a new project for a user.
*/
public function createProject(string $userId): array
{
$userPath = "users/{$userId}.json";
$userData = $this->storage->get($userPath);
if (!$userData) {
throw new Exception("User not found.", 404);
}
$projectId = 'p_' . bin2hex(random_bytes(8));
$projectData = [
'project_id' => $projectId,
'user_id' => $userId,
'status' => 'draft',
'current_step' => 1,
'wizard_data' => [
'identity' => [],
'contact' => [],
'services' => [],
'visuals' => [],
'modules' => [],
'assets' => [],
'language' => [],
'business_category' => [],
'smart_answers' => []
],
'created_at' => date('c'),
'updated_at' => date('c')
];
$this->storage->put("projects/{$projectId}.json", $projectData);
// Update user's project list
$userData['projects'][] = $projectId;
$this->storage->put($userPath, $userData);
return $projectData;
}
/**
* Lists all projects for a user.
*/
public function listProjects(string $userId): array
{
$userData = $this->storage->get("users/{$userId}.json");
if (!$userData) {
throw new Exception("User not found.", 404);
}
$projects = [];
foreach ($userData['projects'] as $projectId) {
$projectData = $this->storage->get("projects/{$projectId}.json");
if ($projectData) {
$projects[] = [
'project_id' => $projectData['project_id'],
'status' => $projectData['status'],
'business_name' => $projectData['wizard_data']['identity']['business_name'] ?? 'Unnamed Project',
'created_at' => $projectData['created_at'],
'updated_at' => $projectData['updated_at']
];
}
}
return $projects;
}
/**
* Returns full project data.
*/
public function getProjectStatus(string $userId, string $projectId): array
{
$projectData = $this->storage->get("projects/{$projectId}.json");
if (!$projectData) {
throw new Exception("Project not found.", 404);
}
if ($projectData['user_id'] !== $userId) {
throw new Exception("Unauthorized access to project.", 403);
}
return $projectData;
}
/**
* Saves data for a specific wizard step.
*/
public function saveStep(string $userId, string $projectId, int $step, array $data): bool
{
$projectPath = "projects/{$projectId}.json";
$projectData = $this->getProjectStatus($userId, $projectId);
switch ($step) {
case 1:
if (!isset($data['business_category'])) {
throw new Exception("Missing business_category data.", 400);
}
$projectData['wizard_data']['business_category'] = $data['business_category'];
break;
case 2:
// Verify GDPR consent
$consentService = new \App\Services\ConsentService();
if (!$consentService->hasConsent($projectId)) {
throw new Exception("GDPR consent is required before saving identity and contact data.", 403);
}
if (empty($data['identity']['business_name'])) {
throw new Exception("Business name is required.", 400);
}
if (empty($data['contact']['email']) && empty($data['contact']['phone'])) {
throw new Exception("Either email or phone is required.", 400);
}
$projectData['wizard_data']['identity'] = $data['identity'];
$projectData['wizard_data']['contact'] = $data['contact'];
break;
case 3:
if (!isset($data['services']) || !isset($data['smart_answers'])) {
throw new Exception("Missing services or smart_answers data.", 400);
}
$projectData['wizard_data']['services'] = $data['services'];
$projectData['wizard_data']['smart_answers'] = $data['smart_answers'];
break;
case 4:
if (!isset($data['visuals']) || !isset($data['assets'])) {
throw new Exception("Missing visuals or assets data.", 400);
}
$projectData['wizard_data']['visuals'] = $data['visuals'];
$projectData['wizard_data']['assets'] = $data['assets'];
break;
case 5:
if (!isset($data['modules'])) {
throw new Exception("Missing modules data.", 400);
}
$modules = $data['modules'];
// Secure local password if present
if ($modules['contact_form']['enabled'] && $modules['contact_form']['mode'] === 'local') {
if (!empty($modules['contact_form']['local_viewer']['password'])) {
$modules['contact_form']['local_viewer']['password_hash'] = password_hash(
$modules['contact_form']['local_viewer']['password'],
PASSWORD_DEFAULT
);
// Never store plain-text password
unset($modules['contact_form']['local_viewer']['password']);
} else {
// Keep existing hash if password not provided (updating other fields)
$oldHash = $projectData['wizard_data']['modules']['contact_form']['local_viewer']['password_hash'] ?? null;
if ($oldHash) {
$modules['contact_form']['local_viewer']['password_hash'] = $oldHash;
}
}
}
$projectData['wizard_data']['modules'] = $modules;
break;
// More steps will be added later
}
$projectData['current_step'] = max($projectData['current_step'], $step + 1);
$projectData['updated_at'] = date('c');
return $this->storage->put($projectPath, $projectData);
}
/**
* Handles secure asset upload.
*/
public function uploadAsset(string $userId, string $projectId, array $file): array
{
// 1. Ownership check
$this->getProjectStatus($userId, $projectId);
// 2. Validation
$maxSize = 2 * 1024 * 1024; // 2MB
if ($file['size'] > $maxSize) {
throw new Exception("File is too large (max 2MB).", 400);
}
$allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml'];
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($file['tmp_name']);
if (!in_array($mimeType, $allowedMimeTypes)) {
throw new Exception("Invalid file type. Allowed: JPG, PNG, WEBP, SVG.", 400);
}
$extension = pathinfo($file['name'], PATHINFO_EXTENSION);
$allowedExtensions = ['jpg', 'jpeg', 'png', 'webp', 'svg'];
if (!in_array(strtolower($extension), $allowedExtensions)) {
throw new Exception("Invalid file extension.", 400);
}
// 3. Prepare storage
$uploadDir = __DIR__ . "/../../exports/{$projectId}/assets/images";
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0777, true);
}
// 4. Secure filename
$filename = bin2hex(random_bytes(8)) . '.' . $extension;
$targetPath = $uploadDir . '/' . $filename;
if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
throw new Exception("Failed to move uploaded file.", 500);
}
return [
'path' => "exports/{$projectId}/assets/images/{$filename}",
'filename' => $filename,
'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

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Actions;
use App\Services\FileStorage;
use App\Services\ConsentService;
use Exception;
class TaskActions
{
private FileStorage $storage;
private ProjectActions $projectActions;
public function __construct()
{
$this->storage = new FileStorage();
$this->projectActions = new ProjectActions();
}
/**
* Creates a new AI generation task for a project.
*/
public function generateWebsite(string $userId, string $projectId): array
{
// 1. Verify project ownership and status
$projectData = $this->projectActions->getProjectStatus($userId, $projectId);
// 2. Prevent duplicate queuing
if ($projectData['status'] === 'queued' || $projectData['status'] === 'generating') {
return ['message' => 'Generation already in progress.', 'status' => $projectData['status']];
}
// 3. Verify GDPR consent
$consentService = new ConsentService();
if (!$consentService->hasConsent($projectId)) {
throw new Exception("GDPR consent is required for AI generation.", 403);
}
// 4. Create Task
$taskId = 't_' . bin2hex(random_bytes(8));
$taskData = [
'task_id' => $taskId,
'project_id' => $projectId,
'status' => 'queued',
'attempt_count' => 0,
'max_attempts' => 3,
'created_at' => date('c'),
'wizard_data' => $projectData['wizard_data']
];
$this->storage->put("llm/{$taskId}.json", $taskData);
// 5. Update Project Status
$projectData['status'] = 'queued';
$projectData['updated_at'] = date('c');
$this->storage->put("projects/{$projectId}.json", $projectData);
return [
'task_id' => $taskId,
'status' => 'queued',
'message' => 'Project successfully queued for generation.'
];
}
}

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Prompts;
class ContentPrompt
{
/**
* Generates a prompt for the AI based on wizard data.
*/
public function generate(array $wizardData): string
{
$businessName = $wizardData['identity']['business_name'] ?? 'neznáma firma';
$group = $wizardData['business_category']['group'] ?? 'univerzálne';
$subcategory = $wizardData['business_category']['subcategory'] ?? '';
$tagline = $wizardData['identity']['tagline'] ?? '';
$description = $wizardData['identity']['description'] ?? '';
$servicesItems = $wizardData['services']['items'] ?? [];
$servicesStr = "";
foreach ($servicesItems as $item) {
$servicesStr .= "- {$item['name']}: {$item['description']} (od {$item['price_from']} EUR)\n";
}
$smartAnswers = $wizardData['smart_answers'] ?? [];
$answersStr = "";
foreach ($smartAnswers as $id => $val) {
$valStr = is_bool($val) ? ($val ? 'Áno' : 'Nie') : $val;
$answersStr .= "- $id: $valStr\n";
}
$prompt = "Si profesionálny copywriter a marketingový špecialista. Tvojou úlohou je vytvoriť obsah pre novú webovú stránku pre klienta: \"$businessName\".\n\n";
$prompt .= "KONTEXT:\n";
$prompt .= "- Oblasť: $group / $subcategory\n";
$prompt .= "- Slogan: $tagline\n";
$prompt .= "- Popis: $description\n";
$prompt .= "- Ponúkané služby:\n$servicesStr\n";
$prompt .= "- Doplnujúce fakty:\n$answersStr\n\n";
$prompt .= "POŽIADAVKY NA OBSAH:\n";
$prompt .= "1. Píš v slovenskom jazyku, tónom, ktorý sa hodí pre daný segment (napr. priateľský pre kaviareň, profesionálny pre advokáta).\n";
$prompt .= "2. Vytvor SEO titulok a meta popis pre domovskú stránku.\n";
$prompt .= "3. Vytvor texty pre Hero sekciu (nadpis a podnadpis).\n";
$prompt .= "4. Vytvor sekciu 'O nás' (cca 3 odseky).\n";
$prompt .= "5. Rozšír popisy služieb na atraktívne marketingové texty.\n";
$prompt .= "6. Navrhni 3-5 otázok a odpovedí pre FAQ sekciu na základe kontextu.\n\n";
$prompt .= "STRIKTNÉ PRAVIDLÁ:\n";
$prompt .= "- Odpovedaj VÝHRADNE vo formáte JSON.\n";
$prompt .= "- V žiadnom prípade NEPOUŽÍVAJ HTML tagy (žiadne <div>, <p>, <h1> atď.).\n";
$prompt .= "- Obsah musí byť pripravený na priame vloženie do šablóny.\n\n";
$prompt .= "FORMÁT ODPOVEDE (JSON):\n";
$prompt .= "{\n";
$prompt .= " \"seo\": { \"title\": \"...\", \"description\": \"...\" },\n";
$prompt .= " \"hero\": { \"title\": \"...\", \"subtitle\": \"...\" },\n";
$prompt .= " \"about\": { \"title\": \"O nás\", \"text\": \"...\" },\n";
$prompt .= " \"services\": [\n";
$prompt .= " { \"id\": \"...\", \"title\": \"...\", \"text\": \"...\", \"price_info\": \"...\" }\n";
$prompt .= " ],\n";
$prompt .= " \"faq\": [\n";
$prompt .= " { \"question\": \"...\", \"answer\": \"...\" }\n";
$prompt .= " ]\n";
$prompt .= "}\n";
return $prompt;
}
}

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Exception;
class ConsentService
{
private FileStorage $storage;
private const CONSENT_VERSION = 'webwizard-mvp-2026-06-12';
public function __construct()
{
$this->storage = new FileStorage();
}
/**
* Saves user consent for a specific project.
*/
public function saveConsent(string $projectId, string $userId, string $consentText): bool
{
// Verify project exists and belongs to user
$projectData = $this->storage->get("projects/{$projectId}.json");
if (!$projectData) {
throw new Exception("Project not found.", 404);
}
if ($projectData['user_id'] !== $userId) {
throw new Exception("Unauthorized access to project.", 403);
}
$consentData = [
'project_id' => $projectId,
'user_id' => $userId,
'consent_text_version' => self::CONSENT_VERSION,
'consent_text' => $consentText,
'accepted' => true,
'accepted_at' => gmdate('Y-m-d\TH:i:s\Z')
];
return $this->storage->put("consent/{$projectId}.json", $consentData);
}
/**
* Checks if a project has a valid consent record.
*/
public function hasConsent(string $projectId): bool
{
return $this->storage->exists("consent/{$projectId}.json");
}
/**
* Returns the current consent version.
*/
public function getVersion(): string
{
return self::CONSENT_VERSION;
}
}

View File

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Exception;
class DAIClient
{
private string $apiUrl;
private int $timeout = 600;
public function __construct(?string $apiUrl = null)
{
if ($apiUrl !== null) {
$this->apiUrl = $apiUrl;
} else {
$this->apiUrl = \App\Utils\Config::get('DAIAPI_URL', 'http://192.168.122.10:9001/run');
}
}
/**
* Sends a prompt to the DAIAPI and returns the raw answer string.
*/
public function sendRequest(string $prompt): ?string
{
$payload = json_encode([
'prompt' => $prompt
]);
if ($payload === false) {
error_log("DAIClient Error: Failed to encode JSON payload.");
return null;
}
$ch = curl_init($this->apiUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Content-Length: ' . strlen($payload)
],
CURLOPT_POSTFIELDS => $payload,
CURLOPT_TIMEOUT => $this->timeout,
]);
$response = curl_exec($ch);
$error = curl_error($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($response === false) {
error_log("DAIClient Error: cURL error: $error");
return null;
}
if ($httpCode !== 200) {
error_log("DAIClient Error: API returned HTTP code $httpCode. Response: $response");
return null;
}
$data = json_decode($response, true);
if (!is_array($data) || empty($data['success']) || !isset($data['answer'])) {
error_log("DAIClient Error: Invalid API response format: " . $response);
return null;
}
return $data['answer'];
}
}

175
src/Services/LLMpool.php Normal file
View File

@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Actions\ProjectActions;
use App\Prompts\ContentPrompt;
use Exception;
use Throwable;
class LLMpool
{
private FileStorage $storage;
private DAIClient $daiClient;
private ContentPrompt $promptGenerator;
private Renderer $renderer;
public function __construct()
{
$this->storage = new FileStorage();
$this->daiClient = new DAIClient();
$this->promptGenerator = new ContentPrompt();
$this->renderer = new Renderer();
}
/**
* Processes all queued tasks.
*/
public function processQueue(): void
{
$tasksDir = realpath(__DIR__ . '/../../data/llm');
$files = glob($tasksDir . '/t_*.json');
foreach ($files as $file) {
$this->processTask(basename($file));
}
}
/**
* Processes a single task by its filename.
*/
public function processTask(string $taskFilename): bool
{
$taskPath = "llm/$taskFilename";
$task = $this->storage->get($taskPath);
if (!$task || $task['status'] !== 'queued') {
return false;
}
// 1. Lock task (atomic update to 'generating')
$task['status'] = 'generating';
$task['attempt_count']++;
$task['started_at'] = date('c');
$this->storage->put($taskPath, $task);
$projectId = $task['project_id'];
// Update project status to 'generating'
$projectData = $this->storage->get("projects/{$projectId}.json");
if ($projectData) {
$projectData['status'] = 'generating';
$this->storage->put("projects/{$projectId}.json", $projectData);
}
$projectActions = new ProjectActions();
try {
// 2. Generate Prompt
$prompt = $this->promptGenerator->generate($task['wizard_data']);
// 3. Call AI
$aiResponse = $this->daiClient->sendRequest($prompt);
if ($aiResponse === null) {
throw new Exception("AI API returned no answer.");
}
// Update project status to 'rendering'
if ($projectData) {
$projectData['status'] = 'rendering';
$this->storage->put("projects/{$projectId}.json", $projectData);
}
// 4. Validate and Parse
$content = $this->validateAndParseResponse($aiResponse);
// 5. Update Project
$projectData = $this->storage->get("projects/{$projectId}.json");
if (!$projectData) {
throw new Exception("Project $projectId not found during processing.");
}
$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['current_step'] = 7; // Automatically ready for preview
$projectData['updated_at'] = date('c');
$this->storage->put("projects/{$projectId}.json", $projectData);
// 7. Finish Task (Delete or Archive)
$this->storage->delete($taskPath);
error_log("LLMpool: Task $taskFilename processed successfully for project $projectId.");
return true;
} catch (Throwable $e) {
error_log("LLMpool Error processing task $taskFilename: " . $e->getMessage());
$task['status'] = 'queued'; // Back to queue for retry
$task['last_error'] = $e->getMessage();
if ($task['attempt_count'] >= $task['max_attempts']) {
$task['status'] = 'failed';
// Update project status to failed as well
$projectData = $this->storage->get("projects/{$projectId}.json");
if ($projectData) {
$projectData['status'] = 'failed';
$projectData['last_error'] = $e->getMessage();
$this->storage->put("projects/{$projectId}.json", $projectData);
}
}
$this->storage->put($taskPath, $task);
return false;
}
}
/**
* Validates AI output: must be JSON, no HTML, has required sections.
*/
private function validateAndParseResponse(string $rawResponse): array
{
// Strip potential markdown code blocks if AI included them
$jsonStr = trim($rawResponse);
if (strpos($jsonStr, '```json') === 0) {
$jsonStr = substr($jsonStr, 7);
}
if (str_ends_with($jsonStr, '```')) {
$jsonStr = substr($jsonStr, 0, -3);
}
$jsonStr = trim($jsonStr);
$data = json_decode($jsonStr, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new Exception("Invalid JSON format from AI: " . json_last_error_msg());
}
// Check for HTML tags
if (preg_match('/<[a-z\/][^>]*>/i', $jsonStr)) {
throw new Exception("AI response contains prohibited HTML tags.");
}
// Check required sections
$required = ['seo', 'hero', 'about', 'services'];
foreach ($required as $section) {
if (!isset($data[$section])) {
throw new Exception("AI response is missing required section: $section");
}
}
return $data;
}
}

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>

49
src/Utils/Config.php Normal file
View File

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Utils;
class Config
{
/**
* Loads environment variables from a .env file.
*/
public static function load(string $path): void
{
if (!file_exists($path)) {
return;
}
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (strpos(trim($line), '#') === 0) {
continue;
}
list($name, $value) = explode('=', $line, 2);
$name = trim($name);
$value = trim($value);
if (!array_key_exists($name, $_SERVER) && !array_key_exists($name, $_ENV)) {
putenv(sprintf('%s=%s', $name, $value));
$_ENV[$name] = $value;
$_SERVER[$name] = $value;
}
}
}
/**
* Retrieves an environment variable or a default value.
*/
public static function get(string $key, $default = null)
{
$value = getenv($key);
if ($value === false) {
return $default;
}
return $value;
}
}

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');
}
}

44
tests/debug_dai.php Normal file
View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
// Load configuration
\App\Utils\Config::load(__DIR__ . '/../.env');
use App\Services\DAIClient;
/**
* CLI Debug skript pre overenie konektivity k DAIAPI
*/
echo "--- DAIAPI Connectivity Test ---" . PHP_EOL;
$client = new DAIClient();
$testPrompt = "Ahoj, aký je dnes deň a napíš mi v jednej krátkej vete, čo si o sebe myslíš ako o AI?";
echo "Odosielam testovací prompt: \"$testPrompt\"" . PHP_EOL;
echo "Čakám na odpoveď (timeout 60s)..." . PHP_EOL;
$startTime = microtime(true);
$answer = $client->sendRequest($testPrompt);
$endTime = microtime(true);
$duration = round($endTime - $startTime, 2);
echo PHP_EOL;
if ($answer !== null) {
echo "[SUCCESS] Pripojenie je funkčné!" . PHP_EOL;
echo "[INFO] Čas odpovede: {$duration}s" . PHP_EOL;
echo "--- ODPOVEĎ OD AI ---" . PHP_EOL;
echo $answer . PHP_EOL;
echo "---------------------" . PHP_EOL;
} else {
echo "[ERROR] Nepodarilo sa získať odpoveď od DAIAPI." . PHP_EOL;
echo "[DEBUG] Skontrolujte, či:" . PHP_EOL;
echo "1. Ste pripojený k VPN/sieti, kde beží DAIAPI." . PHP_EOL;
echo "2. Adresa http://192.168.122.10:9001 je dostupná." . PHP_EOL;
echo "3. API server nie je preťažený." . PHP_EOL;
}