Compare commits
23 Commits
ed7dfe7795
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 60127ec0a7 | |||
| 460f357d48 | |||
| cea97332b4 | |||
| 8ed5413116 | |||
| c0f13495ce | |||
| 4135b621c4 | |||
| 269cc5f5d5 | |||
| 029d7a232a | |||
| d350daa1d8 | |||
| 7efd6b24a5 | |||
| 4f62bb7aa7 | |||
| 7f3870a95b | |||
| d07bc89eea | |||
| ec698f3f34 | |||
| 2b9b62b0aa | |||
| c01eb30632 | |||
| c11f7e4d75 | |||
| 991ff9de00 | |||
| aeeaddd3bc | |||
| 0e0670574d | |||
| b4960c4e39 | |||
| 071aa2f5c9 | |||
| 20ff641811 |
2
.env.example
Normal file
2
.env.example
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Production default
|
||||||
|
DAIAPI_URL=http://192.168.122.10:9001/run
|
||||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal 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
58
AGENTS.md
Normal 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.*
|
||||||
78
README.md
78
README.md
@ -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
83
data/categories.json
Normal 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"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
105
public/ajax.php
105
public/ajax.php
@ -4,6 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
require_once __DIR__ . '/../vendor/autoload.php';
|
require_once __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
\App\Utils\Config::load(__DIR__ . '/../.env');
|
||||||
|
|
||||||
// Set headers
|
// Set headers
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
@ -30,11 +33,23 @@ try {
|
|||||||
sendResponse(false, ['code' => 'METHOD_NOT_ALLOWED', 'message' => 'Only POST requests are allowed.'], 405);
|
sendResponse(false, ['code' => 'METHOD_NOT_ALLOWED', 'message' => 'Only POST requests are allowed.'], 405);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read JSON input
|
// Read JSON input or FormData
|
||||||
$input = file_get_contents('php://input');
|
$input = file_get_contents('php://input');
|
||||||
$data = json_decode($input, true);
|
$data = json_decode($input, true);
|
||||||
|
|
||||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
// 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);
|
sendResponse(false, ['code' => 'INVALID_JSON', 'message' => 'Invalid JSON input.'], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,18 +59,98 @@ try {
|
|||||||
sendResponse(false, ['code' => 'MISSING_ACTION', 'message' => 'Action is required.'], 400);
|
sendResponse(false, ['code' => 'MISSING_ACTION', 'message' => 'Action is required.'], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check X-User-ID header (except for initSession if we want to allow it)
|
// Check X-User-ID header (except for initSession)
|
||||||
$userId = $_SERVER['HTTP_X_USER_ID'] ?? null;
|
$userId = $_SERVER['HTTP_X_USER_ID'] ?? $_POST['X-User-ID'] ?? null;
|
||||||
if (!$userId && $action !== 'initSession') {
|
if (!$userId && $action !== 'initSession') {
|
||||||
sendResponse(false, ['code' => 'UNAUTHORIZED', 'message' => 'X-User-ID header is missing.'], 401);
|
sendResponse(false, ['code' => 'UNAUTHORIZED', 'message' => 'X-User-ID is missing.'], 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Router
|
// Router
|
||||||
|
$projectActions = new \App\Actions\ProjectActions();
|
||||||
|
$taskActions = new \App\Actions\TaskActions();
|
||||||
|
$consentService = new \App\Services\ConsentService();
|
||||||
|
|
||||||
switch ($action) {
|
switch ($action) {
|
||||||
case 'ping':
|
case 'ping':
|
||||||
sendResponse(true, ['message' => 'pong', 'timestamp' => time()]);
|
sendResponse(true, ['message' => 'pong', 'timestamp' => time()]);
|
||||||
break;
|
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:
|
default:
|
||||||
sendResponse(false, ['code' => 'UNKNOWN_ACTION', 'message' => "Action '$action' is not defined."], 404);
|
sendResponse(false, ['code' => 'UNKNOWN_ACTION', 'message' => "Action '$action' is not defined."], 404);
|
||||||
break;
|
break;
|
||||||
|
|||||||
627
public/css/wizard.css
Normal file
627
public/css/wizard.css
Normal 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
372
public/index.html
Normal 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
891
public/js/wizard.js
Normal 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
68
scripts/worker.php
Normal 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);
|
||||||
311
src/Actions/ProjectActions.php
Normal file
311
src/Actions/ProjectActions.php
Normal 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
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/Actions/TaskActions.php
Normal file
66
src/Actions/TaskActions.php
Normal 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.'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/Prompts/ContentPrompt.php
Normal file
69
src/Prompts/ContentPrompt.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/Services/ConsentService.php
Normal file
60
src/Services/ConsentService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/Services/DAIClient.php
Normal file
72
src/Services/DAIClient.php
Normal 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
175
src/Services/LLMpool.php
Normal 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
188
src/Services/Renderer.php
Normal 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
87
src/Templates/base.php
Normal 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>© <?= 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
239
src/Templates/css/site.css
Normal 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
82
src/Templates/emailer.php
Normal 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/Templates/form-viewer.php
Normal file
95
src/Templates/form-viewer.php
Normal 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> (<<?= htmlspecialchars($data['sender_email']) ?>>)</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
40
src/Templates/js/site.js
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
16
src/Templates/sections/about.php
Normal file
16
src/Templates/sections/about.php
Normal 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>
|
||||||
54
src/Templates/sections/contact.php
Normal file
54
src/Templates/sections/contact.php
Normal 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>
|
||||||
21
src/Templates/sections/faq.php
Normal file
21
src/Templates/sections/faq.php
Normal 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>
|
||||||
20
src/Templates/sections/gallery.php
Normal file
20
src/Templates/sections/gallery.php
Normal 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>
|
||||||
18
src/Templates/sections/hero.php
Normal file
18
src/Templates/sections/hero.php
Normal 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>
|
||||||
13
src/Templates/sections/pricing.php
Normal file
13
src/Templates/sections/pricing.php
Normal 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>
|
||||||
28
src/Templates/sections/services.php
Normal file
28
src/Templates/sections/services.php
Normal 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
49
src/Utils/Config.php
Normal 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
15
src/Utils/Helpers.php
Normal 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
44
tests/debug_dai.php
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user