diff --git a/public/ajax.php b/public/ajax.php
index 49f28c4..49ae8cc 100644
--- a/public/ajax.php
+++ b/public/ajax.php
@@ -30,11 +30,23 @@ try {
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');
$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);
}
@@ -44,10 +56,10 @@ try {
sendResponse(false, ['code' => 'MISSING_ACTION', 'message' => 'Action is required.'], 400);
}
- // Check X-User-ID header (except for initSession if we want to allow it)
- $userId = $_SERVER['HTTP_X_USER_ID'] ?? null;
+ // Check X-User-ID header (except for initSession)
+ $userId = $_SERVER['HTTP_X_USER_ID'] ?? $_POST['X-User-ID'] ?? null;
if (!$userId && $action !== 'initSession') {
- sendResponse(false, ['code' => 'UNAUTHORIZED', 'message' => 'X-User-ID header is missing.'], 401);
+ sendResponse(false, ['code' => 'UNAUTHORIZED', 'message' => 'X-User-ID is missing.'], 401);
}
// Router
@@ -109,6 +121,15 @@ try {
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;
+
default:
sendResponse(false, ['code' => 'UNKNOWN_ACTION', 'message' => "Action '$action' is not defined."], 404);
break;
diff --git a/public/css/wizard.css b/public/css/wizard.css
index 0bbde85..5b53ad0 100644
--- a/public/css/wizard.css
+++ b/public/css/wizard.css
@@ -313,6 +313,136 @@ textarea:focus, input[type="text"]:focus, input[type="email"]:focus {
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;
+}
+
+.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;
+}
+
button:disabled {
opacity: 0.5;
cursor: not-allowed;
diff --git a/public/index.html b/public/index.html
index a021f8b..bf7a856 100644
--- a/public/index.html
+++ b/public/index.html
@@ -132,10 +132,83 @@
-
Vizuálny štýl
-
Ako by mal váš nový web vyzerať?
-
diff --git a/public/js/wizard.js b/public/js/wizard.js
index 5a7e1b1..321e80e 100644
--- a/public/js/wizard.js
+++ b/public/js/wizard.js
@@ -12,7 +12,11 @@ const App = {
selection: {
category: null,
subcategory: null,
- customDescription: ''
+ customDescription: '',
+ style: null,
+ palette: null,
+ logo: null,
+ gallery: []
}
},
@@ -187,6 +191,27 @@ const App = {
// 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'));
},
renderCategories() {
@@ -341,6 +366,103 @@ const App = {
});
},
+ 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 = `

+
`;
+ 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 = `

+
`;
+
+ 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'));
@@ -463,6 +585,31 @@ const App = {
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;
+ }
}
if (this.state.currentStep < this.state.totalSteps) {
@@ -492,6 +639,8 @@ const App = {
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;
}
btnNext.disabled = nextDisabled;
diff --git a/src/Actions/ProjectActions.php b/src/Actions/ProjectActions.php
index c7184b5..08a7ec3 100644
--- a/src/Actions/ProjectActions.php
+++ b/src/Actions/ProjectActions.php
@@ -163,6 +163,14 @@ class ProjectActions
$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;
// More steps will be added later
}
@@ -172,4 +180,53 @@ class ProjectActions
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
+ ];
+ }
}