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
Previously when server validation failed (e.g. missing required field), the whole form re-rendered with all values cleared — user had to retype everything. Also Quill empty-content showed an alert dialog. Now: - Server-side: form_data + missing_fields passed to template; values re-populate inputs, missing fields get .field-error class (red border) - Quill empty: red border on the editor container instead of alert, cleared as soon as user starts typing - Other required fields (radio, select, title): same .field-error treatment plus :invalid CSS for live HTML5 feedback Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
536 lines
20 KiB
HTML
Executable File
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 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.querySelector('form').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 = '×';
|
|
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 %}
|