nordabiz/templates/classifieds/new.html
Maciej Pienczyn e1a16e2542
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
fix(classifieds): scope Quill submit handler to actual form
document.querySelector('form') was matching the company switcher form
in the navbar, not the classifieds form. Quill content was therefore
never synced into the hidden description textarea, server saw it empty
and rejected the submit with a misleading 'fill all fields' error.

Added id='classifiedForm' to both new.html and edit.html and switched
selectors to getElementById.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:42:56 +02:00

536 lines
20 KiB
HTML
Executable File

{% extends "base.html" %}
{% block title %}Nowe ogłoszenie - Norda Biznes Partner{% endblock %}
{% block head_extra %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/quill.snow.css') }}">
<script src="{{ url_for('static', filename='js/vendor/quill.js') }}"></script>
{% endblock %}
{% block extra_css %}
<style>
.quill-container {
border: 1px solid var(--border);
border-radius: var(--radius);
}
.quill-container .ql-toolbar {
border-top-left-radius: var(--radius);
border-top-right-radius: var(--radius);
}
.quill-container .ql-container {
border-bottom-left-radius: var(--radius);
border-bottom-right-radius: var(--radius);
font-size: var(--font-size-base);
}
.quill-container .ql-editor {
min-height: 150px;
}
/* Highlight required fields that failed validation. Applied by server
on POST validation error and by client JS on Quill empty submit. */
.field-error,
input.field-error,
select.field-error,
.type-selector.field-error,
.quill-container.field-error {
border: 2px solid #dc2626 !important;
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.15);
}
:invalid:not(:focus):not(:placeholder-shown),
select:invalid:not(:focus) {
border: 2px solid #dc2626;
}
.form-container {
max-width: 700px;
margin: 0 auto;
}
.form-header {
margin-bottom: var(--spacing-xl);
}
.form-header h1 {
font-size: var(--font-size-3xl);
color: var(--text-primary);
}
.form-card {
background: var(--surface);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
box-shadow: var(--shadow);
}
.form-group {
margin-bottom: var(--spacing-lg);
}
.form-group label {
display: block;
font-weight: 500;
margin-bottom: var(--spacing-xs);
color: var(--text-primary);
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-base);
transition: var(--transition);
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-md);
}
.form-hint {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-top: var(--spacing-xs);
}
.form-actions {
display: flex;
gap: var(--spacing-md);
margin-top: var(--spacing-xl);
}
.back-link {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
color: var(--text-secondary);
text-decoration: none;
margin-bottom: var(--spacing-lg);
}
.back-link:hover {
color: var(--primary);
}
.type-selector {
display: flex;
gap: var(--spacing-md);
}
.type-option {
flex: 1;
position: relative;
}
.type-option input {
position: absolute;
opacity: 0;
}
.type-option label {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--spacing-lg);
border: 2px solid var(--border);
border-radius: var(--radius-lg);
cursor: pointer;
transition: var(--transition);
}
.type-option input:checked + label {
border-color: var(--primary);
background: rgba(37, 99, 235, 0.05);
}
.type-option label:hover {
border-color: var(--primary);
}
.type-icon {
font-size: 24px;
margin-bottom: var(--spacing-sm);
}
.info-box {
background: var(--background);
border-radius: var(--radius);
padding: var(--spacing-md);
margin-bottom: var(--spacing-lg);
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* Upload dropzone */
.upload-dropzone-mini {
border: 2px dashed var(--border);
border-radius: var(--radius);
padding: var(--spacing-md);
text-align: center;
background: var(--background);
transition: var(--transition);
cursor: pointer;
margin-bottom: var(--spacing-md);
}
.upload-dropzone-mini:hover,
.upload-dropzone-mini.drag-over {
border-color: var(--primary);
background: rgba(37, 99, 235, 0.05);
}
.upload-dropzone-mini p {
color: var(--text-secondary);
font-size: var(--font-size-sm);
margin: 0;
}
.upload-dropzone-mini .mobile-only { display: none; }
@media (max-width: 768px) {
.upload-dropzone-mini .desktop-only { display: none; }
.upload-dropzone-mini .mobile-only { display: block; font-size: var(--font-size-base); padding: var(--spacing-sm) 0; }
}
.upload-previews-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: var(--spacing-sm);
margin-bottom: var(--spacing-md);
}
.upload-preview-item {
position: relative;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--spacing-xs);
background: var(--surface);
}
.upload-preview-item img {
width: 100%;
height: 80px;
object-fit: cover;
border-radius: var(--radius-sm);
}
.upload-preview-item .preview-info {
font-size: 10px;
color: var(--text-secondary);
margin-top: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.upload-preview-item .remove-preview {
position: absolute;
top: -6px;
right: -6px;
width: 20px;
height: 20px;
background: var(--error);
color: white;
border: none;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
line-height: 1;
}
.upload-preview-item .remove-preview:hover { background: #c53030; }
.upload-counter {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--spacing-xs);
}
.upload-counter.limit-reached { color: var(--error); font-weight: 600; }
</style>
{% endblock %}
{% block content %}
<div class="form-container">
<a href="{{ url_for('classifieds.classifieds_index') }}" class="back-link">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Powrót do tablicy
</a>
<div class="form-header">
<h1>Nowe ogłoszenie</h1>
<p class="text-muted">Dodaj ogłoszenie biznesowe dla członków Norda Biznes</p>
</div>
<div class="form-card">
<div class="info-box">
Ogłoszenie będzie widoczne przez 30 dni. Po tym czasie wygaśnie automatycznie.
</div>
<form id="classifiedForm" method="POST" action="{{ url_for('classifieds.classifieds_new') }}" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if has_multiple_companies %}
<div class="form-group">
<label for="company_id">Ogłoszenie w imieniu firmy</label>
<select id="company_id" name="company_id" class="form-control" style="border: 2px solid var(--primary); background: var(--bg-secondary);">
{% for uc in user_companies %}
{% if uc.company %}
<option value="{{ uc.company_id }}" {% if uc.company_id == active_company_id %}selected{% endif %}>
{{ uc.company.name }}{% if uc.company_id == active_company_id %} (aktywna){% endif %}
</option>
{% endif %}
{% endfor %}
</select>
<small style="color: var(--text-secondary); margin-top: 4px; display: block;">Ogłoszenie będzie widoczne na profilu wybranej firmy</small>
</div>
{% else %}
<input type="hidden" name="company_id" value="{{ active_company_id }}">
{% endif %}
<div class="form-group">
<label>Typ ogłoszenia *</label>
<div class="type-selector{% if missing_fields and missing_fields.listing_type %} field-error{% endif %}">
<div class="type-option">
<input type="radio" id="type_szukam" name="listing_type" value="szukam" required {% if form_data and form_data.get('listing_type') == 'szukam' %}checked{% endif %}>
<label for="type_szukam">
<span class="type-icon">🔍</span>
<strong>Szukam</strong>
<span style="font-size: var(--font-size-xs); color: var(--text-secondary);">Potrzebuję usług, produktów</span>
</label>
</div>
<div class="type-option">
<input type="radio" id="type_oferuje" name="listing_type" value="oferuje" required {% if form_data and form_data.get('listing_type') == 'oferuje' %}checked{% endif %}>
<label for="type_oferuje">
<span class="type-icon"></span>
<strong>Oferuję</strong>
<span style="font-size: var(--font-size-xs); color: var(--text-secondary);">Mam do zaoferowania</span>
</label>
</div>
</div>
</div>
<div class="form-group">
<label for="category">Kategoria *</label>
<select id="category" name="category" required class="{% if missing_fields and missing_fields.category %}field-error{% endif %}">
<option value="">Wybierz kategorię...</option>
<option value="uslugi" {% if form_data and form_data.get('category') == 'uslugi' %}selected{% endif %}>Usługi profesjonalne</option>
<option value="produkty" {% if form_data and form_data.get('category') == 'produkty' %}selected{% endif %}>Produkty, materiały</option>
<option value="wspolpraca" {% if form_data and form_data.get('category') == 'wspolpraca' %}selected{% endif %}>Propozycje współpracy</option>
<option value="praca" {% if form_data and form_data.get('category') == 'praca' %}selected{% endif %}>Oferty pracy, zlecenia</option>
<option value="inne" {% if form_data and form_data.get('category') == 'inne' %}selected{% endif %}>Inne</option>
</select>
</div>
<div class="form-group">
<label for="title">Tytuł ogłoszenia *</label>
<input type="text" id="title" name="title" required maxlength="255" placeholder="np. Szukam firmy do wykonania strony www" value="{{ form_data.get('title', '') if form_data else '' }}" class="{% if missing_fields and missing_fields.title %}field-error{% endif %}">
</div>
<div class="form-group">
<label>Opis *</label>
<div id="quill-editor" class="quill-container{% if missing_fields and missing_fields.description %} field-error{% endif %}"></div>
<textarea id="description" name="description" style="display:none;">{{ form_data.get('description', '') if form_data else '' }}</textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="budget_info">Budżet / Cena</label>
<input type="text" id="budget_info" name="budget_info" maxlength="255" placeholder="np. 5000-10000 PLN lub 'do negocjacji'" value="{{ form_data.get('budget_info', '') if form_data else '' }}">
</div>
<div class="form-group">
<label for="location_info">Lokalizacja</label>
<input type="text" id="location_info" name="location_info" maxlength="255" placeholder="np. Wejherowo, Cala Polska, Online" value="{{ form_data.get('location_info', '') if form_data else '' }}">
</div>
</div>
<div class="form-group">
<label>Zdjęcia (opcjonalnie)</label>
<div class="upload-counter" id="uploadCounter"></div>
<div class="upload-previews-container" id="previewsContainer"></div>
<div class="upload-dropzone-mini" id="dropzone">
<p class="desktop-only">Przeciągnij obrazy lub kliknij tutaj (max 10 plików, JPG/PNG/GIF do 5MB)</p>
<p class="mobile-only">📷 Dodaj zdjęcie z galerii</p>
<input type="file" id="attachmentInput" name="attachments[]" accept="image/jpeg,image/png,image/gif" multiple style="display: none;">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" id="submitBtn">Dodaj ogłoszenie</button>
<a href="{{ url_for('classifieds.classifieds_index') }}" class="btn btn-secondary">Anuluj</a>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_js %}
var quill = new Quill('#quill-editor', {
theme: 'snow',
placeholder: 'Opisz szczegółowo czego szukasz lub co oferujesz...',
modules: {
toolbar: [
['bold', 'italic'],
[{'list': 'ordered'}, {'list': 'bullet'}],
['link'],
['clean']
]
}
});
// Sync Quill content to hidden textarea on form submit + validate non-empty.
// Note: hidden textarea cannot use `required` (browser cannot show validation
// UI on display:none fields, which silently blocks submit).
(function() {
var qc = document.getElementById('quill-editor');
// Restore from server-rendered textarea (e.g. after POST validation error)
var initialDesc = document.getElementById('description').value;
if (initialDesc) { quill.root.innerHTML = initialDesc; }
// Clear error highlight as soon as user starts typing
quill.on('text-change', function() { qc && qc.classList.remove('field-error'); });
document.getElementById('classifiedForm').addEventListener('submit', function(e) {
var html = quill.root.innerHTML;
var empty = (html === '<p><br></p>' || quill.getText().trim() === '');
if (empty) {
e.preventDefault();
qc && qc.classList.add('field-error');
qc && qc.scrollIntoView({behavior: 'smooth', block: 'center'});
quill.focus();
return;
}
qc && qc.classList.remove('field-error');
document.getElementById('description').value = html;
});
})();
(function() {
const dropzone = document.getElementById('dropzone');
if (!dropzone) return;
const fileInput = document.getElementById('attachmentInput');
const previewsContainer = document.getElementById('previewsContainer');
const uploadCounter = document.getElementById('uploadCounter');
const MAX_FILES = 10;
const MAX_SIZE = 5 * 1024 * 1024;
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif'];
let filesMap = new Map();
let fileIdCounter = 0;
dropzone.addEventListener('click', () => fileInput.click());
dropzone.addEventListener('dragover', (e) => {
e.preventDefault();
dropzone.classList.add('drag-over');
});
dropzone.addEventListener('dragleave', () => dropzone.classList.remove('drag-over'));
dropzone.addEventListener('drop', (e) => {
e.preventDefault();
dropzone.classList.remove('drag-over');
addFiles(Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('image/')));
});
fileInput.addEventListener('change', (e) => {
addFiles(Array.from(e.target.files));
fileInput.value = '';
});
// Paste from clipboard
document.addEventListener('paste', (e) => {
const desc = document.getElementById('description');
if (document.activeElement !== desc) return;
const items = e.clipboardData?.items;
if (!items) return;
const pastedFiles = [];
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
e.preventDefault();
const file = items[i].getAsFile();
if (file) pastedFiles.push(file);
}
}
if (pastedFiles.length > 0) addFiles(pastedFiles);
});
function addFiles(newFiles) {
const availableSlots = MAX_FILES - filesMap.size;
if (availableSlots <= 0) return;
newFiles.slice(0, availableSlots).forEach(file => {
if (file.size > MAX_SIZE || !ALLOWED_TYPES.includes(file.type)) return;
const fileId = 'file_' + (fileIdCounter++);
filesMap.set(fileId, file);
createPreview(fileId, file);
});
updateCounter();
syncFilesToInput();
}
function createPreview(fileId, file) {
const preview = document.createElement('div');
preview.className = 'upload-preview-item';
preview.dataset.fileId = fileId;
const img = document.createElement('img');
const info = document.createElement('div');
info.className = 'preview-info';
info.textContent = file.name.substring(0, 15) + (file.name.length > 15 ? '...' : '') + ' (' + formatFileSize(file.size) + ')';
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'remove-preview';
removeBtn.innerHTML = '&times;';
removeBtn.onclick = () => { filesMap.delete(fileId); preview.remove(); updateCounter(); syncFilesToInput(); };
preview.appendChild(img);
preview.appendChild(info);
preview.appendChild(removeBtn);
previewsContainer.appendChild(preview);
const reader = new FileReader();
reader.onload = (e) => { img.src = e.target.result; };
reader.readAsDataURL(file);
}
function updateCounter() {
const count = filesMap.size;
if (count === 0) {
uploadCounter.textContent = '';
uploadCounter.classList.remove('limit-reached');
dropzone.style.display = 'block';
} else {
uploadCounter.textContent = 'Wybrano: ' + count + '/' + MAX_FILES + ' plików';
uploadCounter.classList.toggle('limit-reached', count >= MAX_FILES);
dropzone.style.display = count >= MAX_FILES ? 'none' : 'block';
}
}
function syncFilesToInput() {
try {
const dataTransfer = new DataTransfer();
filesMap.forEach(file => dataTransfer.items.add(file));
fileInput.files = dataTransfer.files;
} catch (e) {}
}
// FormData fallback for mobile
const form = dropzone.closest('form');
form.addEventListener('submit', function(e) {
if (filesMap.size === 0) return;
if (fileInput.files && fileInput.files.length > 0) return;
e.preventDefault();
const formData = new FormData(form);
formData.delete('attachments[]');
filesMap.forEach(file => formData.append('attachments[]', file));
fetch(form.action, { method: 'POST', body: formData })
.then(resp => { if (resp.redirected) { window.location.href = resp.url; } else { window.location.reload(); } })
.catch(() => alert('Błąd wysyłania'));
});
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
})();
{% endblock %}