nordabiz/templates/classifieds/view.html
Maciej Pienczyn 2bf5c780e2
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
feat: Quill rich text editor in B2B classifieds + expiry email notifier
- Replace textarea with Quill editor in new/edit classified forms
- Sanitize HTML with sanitize_html() on save (XSS prevention)
- Render HTML in classified detail view, strip tags in list view
- New script: classified_expiry_notifier.py sends email 3 days before
  expiry with link to extend. Run daily via cron at 8:00.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:53:54 +02:00

1269 lines
44 KiB
HTML
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block title %}{{ classified.title }} - Norda Biznes Partner{% endblock %}
{% block extra_css %}
<style>
.classified-container {
max-width: 800px;
margin: 0 auto;
}
.classified-card {
background: var(--surface);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
box-shadow: var(--shadow);
margin-bottom: var(--spacing-xl);
}
.classified-card.szukam {
border-top: 4px solid var(--warning);
}
.classified-card.oferuje {
border-top: 4px solid var(--success);
}
.classified-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--spacing-lg);
}
.classified-type {
display: inline-block;
padding: 4px 12px;
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
font-weight: 600;
text-transform: uppercase;
}
.classified-type.szukam {
background: #fef3c7;
color: #92400e;
}
.classified-type.oferuje {
background: #dcfce7;
color: #166534;
}
.classified-category {
display: inline-block;
padding: 4px 12px;
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
font-weight: 500;
text-transform: uppercase;
margin-left: var(--spacing-sm);
}
.category-uslugi {
background: #dbeafe;
color: #1e40af;
}
.category-produkty {
background: #fef3c7;
color: #92400e;
}
.category-wspolpraca {
background: #dcfce7;
color: #166534;
}
.category-praca {
background: #fce7f3;
color: #9d174d;
}
.category-inne {
background: #f3f4f6;
color: #374151;
}
.category-nieruchomosci {
background: #e0e7ff;
color: #3730a3;
}
.classified-title {
font-size: var(--font-size-2xl);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-lg);
}
.classified-description {
line-height: 1.8;
color: var(--text-primary);
margin-bottom: var(--spacing-xl);
white-space: pre-wrap;
}
.classified-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-lg);
padding: var(--spacing-lg);
background: var(--background);
border-radius: var(--radius);
margin-bottom: var(--spacing-xl);
}
.detail-item {
display: flex;
align-items: flex-start;
gap: var(--spacing-sm);
}
.detail-item svg {
width: 20px;
height: 20px;
color: var(--primary);
flex-shrink: 0;
margin-top: 2px;
}
.detail-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.detail-value {
font-weight: 500;
color: var(--text-primary);
}
.author-card {
display: flex;
align-items: center;
gap: var(--spacing-lg);
padding: var(--spacing-lg);
background: var(--background);
border-radius: var(--radius);
}
.author-avatar {
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: var(--font-size-lg);
}
.author-info {
flex: 1;
}
.author-name {
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-xs);
}
.author-company {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.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);
}
.stats-bar {
display: flex;
justify-content: space-between;
font-size: var(--font-size-sm);
color: var(--text-secondary);
padding-top: var(--spacing-lg);
border-top: 1px solid var(--border);
margin-top: var(--spacing-lg);
}
.seen-by-section {
margin-top: var(--spacing-lg);
padding-top: var(--spacing-lg);
border-top: 1px solid var(--border);
}
.seen-by-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--spacing-sm);
}
.seen-by-avatars {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.reader-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
cursor: default;
position: relative;
}
.reader-avatar[data-name]::after {
content: attr(data-name);
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: #1a1a2e;
color: #ffffff;
padding: 6px 12px;
border-radius: 8px;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 100;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
letter-spacing: 0.2px;
}
.reader-avatar[data-name]:hover::after {
opacity: 1;
}
.reader-avatar.more {
background: var(--text-secondary);
font-size: 10px;
}
.close-btn {
margin-left: auto;
}
/* Admin actions */
.admin-actions {
display: flex;
gap: var(--spacing-sm);
margin-left: auto;
}
.admin-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 500;
cursor: pointer;
border: 1px solid;
transition: all 0.2s;
}
.admin-btn svg {
width: 16px;
height: 16px;
}
.admin-btn-delete {
background: #fef2f2;
color: #dc2626;
border-color: #fecaca;
}
.admin-btn-delete:hover {
background: #fee2e2;
border-color: #f87171;
}
.admin-btn-toggle {
background: #f5f5f5;
color: #525252;
border-color: #d4d4d4;
}
.admin-btn-toggle:hover {
background: #e5e5e5;
border-color: #a3a3a3;
}
.admin-btn-toggle.inactive {
background: #fef3c7;
color: #92400e;
border-color: #fcd34d;
}
.inactive-badge {
background: #fef2f2;
color: #dc2626;
padding: 4px 10px;
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
font-weight: 500;
margin-left: var(--spacing-sm);
}
/* Interest button */
.interest-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 16px;
border-radius: var(--radius);
font-weight: 500;
cursor: pointer;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-primary);
transition: all 0.2s;
}
.interest-btn:hover {
border-color: var(--primary);
background: #eff6ff;
}
.interest-btn.interested {
background: #dcfce7;
border-color: #22c55e;
color: #166534;
}
.interest-btn svg {
width: 18px;
height: 18px;
}
.interests-info {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-top: var(--spacing-sm);
}
.interests-info a {
color: var(--primary);
text-decoration: none;
}
.interests-info a:hover {
text-decoration: underline;
}
/* Q&A Section */
.qa-section {
background: var(--surface);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
box-shadow: var(--shadow);
margin-bottom: var(--spacing-xl);
}
.qa-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-lg);
}
.qa-header h2 {
font-size: var(--font-size-lg);
font-weight: 600;
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.qa-badge {
background: var(--warning);
color: white;
padding: 2px 8px;
border-radius: 10px;
font-size: var(--font-size-xs);
font-weight: 600;
}
.qa-form {
background: var(--background);
border-radius: var(--radius);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-lg);
}
.qa-form textarea {
width: 100%;
padding: var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius);
resize: vertical;
min-height: 80px;
font-family: inherit;
}
.qa-form textarea:focus {
outline: none;
border-color: var(--primary);
}
.qa-form-actions {
display: flex;
justify-content: flex-end;
margin-top: var(--spacing-md);
}
.question-item {
border-bottom: 1px solid var(--border);
padding: var(--spacing-lg) 0;
}
.question-item:last-child {
border-bottom: none;
}
.question-header {
display: flex;
align-items: center;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-sm);
}
.question-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
}
.question-meta {
flex: 1;
}
.question-author {
font-weight: 500;
font-size: var(--font-size-sm);
}
.question-date {
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.question-content {
margin-left: 40px;
line-height: 1.6;
}
.question-hidden {
opacity: 0.5;
}
.question-hidden .question-content::before {
content: "[Ukryte] ";
color: var(--warning);
font-weight: 500;
}
.answer-box {
margin-left: 40px;
margin-top: var(--spacing-md);
padding: var(--spacing-md);
background: #f0fdf4;
border-left: 3px solid #22c55e;
border-radius: 0 var(--radius) var(--radius) 0;
}
.answer-label {
font-size: var(--font-size-xs);
color: #166534;
font-weight: 600;
margin-bottom: var(--spacing-xs);
}
.answer-content {
line-height: 1.6;
}
.answer-form {
margin-left: 40px;
margin-top: var(--spacing-md);
}
.answer-form textarea {
width: 100%;
padding: var(--spacing-sm);
border: 1px solid var(--border);
border-radius: var(--radius);
resize: vertical;
min-height: 60px;
font-family: inherit;
font-size: var(--font-size-sm);
}
.answer-form-actions {
display: flex;
gap: var(--spacing-sm);
margin-top: var(--spacing-sm);
}
.pending-badge {
display: inline-block;
background: #fef3c7;
color: #92400e;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
margin-left: var(--spacing-sm);
}
.question-actions {
display: flex;
gap: var(--spacing-sm);
}
.question-action-btn {
padding: 4px 8px;
font-size: var(--font-size-xs);
border-radius: var(--radius-sm);
cursor: pointer;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-secondary);
}
.question-action-btn:hover {
background: var(--background);
}
/* Contact actions */
.contact-actions {
display: flex;
gap: var(--spacing-sm);
flex-wrap: wrap;
}
/* Interests modal */
.interests-list {
max-height: 400px;
overflow-y: auto;
}
.interest-item {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-md);
border-bottom: 1px solid var(--border);
}
.interest-item:last-child {
border-bottom: none;
}
.interest-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
}
.interest-info {
flex: 1;
}
.interest-name {
font-weight: 500;
}
.interest-company {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.interest-message {
font-size: var(--font-size-sm);
color: var(--text-secondary);
font-style: italic;
margin-top: 4px;
}
.interest-date {
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.no-questions {
text-align: center;
padding: var(--spacing-xl);
color: var(--text-secondary);
}
/* Classified image gallery */
.classified-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--spacing-sm);
margin: var(--spacing-lg) 0;
}
.classified-gallery img {
width: 100%;
height: 140px;
object-fit: cover;
border-radius: var(--radius);
cursor: pointer;
transition: var(--transition);
border: 1px solid var(--border);
}
.classified-gallery img:hover {
transform: scale(1.03);
box-shadow: var(--shadow-md);
}
.lightbox {
display: none;
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0, 0, 0, 0.9);
z-index: 1000;
justify-content: center;
align-items: center;
cursor: pointer;
}
.lightbox.active { display: flex; }
.lightbox img { max-width: 90%; max-height: 90%; object-fit: contain; }
</style>
{% endblock %}
{% block content %}
<div class="classified-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="classified-card {{ classified.listing_type }}">
<div class="classified-header">
<div>
<span class="classified-type {{ classified.listing_type }}">{{ 'Szukam' if classified.listing_type == 'szukam' else 'Oferuję' }}</span>
<span class="classified-category category-{{ classified.category }}">{{ classified.category|replace('uslugi', 'Usługi')|replace('produkty', 'Produkty')|replace('wspolpraca', 'Współpraca')|replace('praca', 'Praca')|replace('inne', 'Inne')|replace('nieruchomosci', 'Nieruchomości') }}</span>
{% if not classified.is_active %}
<span class="inactive-badge">Nieaktywne</span>
{% endif %}
</div>
{% if classified.is_expired %}
<span style="display:inline-block; background:#fef2f2; color:#dc2626; font-size:var(--font-size-sm); font-weight:600; padding:4px 12px; border-radius:var(--radius); border:1px solid #fecaca;">Wygasło</span>
{% endif %}
{% if classified.author_id == current_user.id %}
<a href="{{ url_for('classifieds.classifieds_edit', classified_id=classified.id) }}" class="btn btn-primary btn-sm">Edytuj</a>
{% if classified.is_expired or not classified.is_active %}
<button class="btn btn-primary btn-sm" onclick="extendClassified()" style="background:#10b981;border-color:#10b981;">Przedłuż o 30 dni</button>
{% endif %}
<button class="btn btn-secondary btn-sm close-btn" onclick="closeClassified()">Zamknij ogłoszenie</button>
{% endif %}
{% if current_user.is_authenticated and current_user.can_access_admin_panel() %}
<div class="admin-actions">
<button type="button" class="admin-btn admin-btn-toggle {% if not classified.is_active %}inactive{% endif %}" onclick="toggleActive()" title="{% if classified.is_active %}Dezaktywuj{% else %}Aktywuj{% endif %}">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
{% if classified.is_active %}
<path d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/>
{% else %}
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
{% endif %}
</svg>
{% if classified.is_active %}Dezaktywuj{% else %}Aktywuj{% endif %}
</button>
<button type="button" class="admin-btn admin-btn-delete" onclick="deleteClassified()" title="Usuń ogłoszenie">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
Usuń
</button>
</div>
{% endif %}
</div>
<h1 class="classified-title">{{ classified.title }}</h1>
<div class="classified-description">{{ classified.description|safe }}</div>
{% if classified.attachments %}
<div class="classified-gallery">
{% for att in classified.attachments %}
{% if att.is_image %}
<img src="{{ att.url }}" alt="{{ att.original_filename }}" onclick="openLightbox(this.src)" loading="lazy">
{% endif %}
{% endfor %}
</div>
{% endif %}
{% if classified.budget_info or classified.location_info %}
<div class="classified-details">
{% if classified.budget_info %}
<div class="detail-item">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div>
<div class="detail-label">Budżet / Cena</div>
<div class="detail-value">{{ classified.budget_info }}</div>
</div>
</div>
{% endif %}
{% if classified.location_info %}
<div class="detail-item">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
<circle cx="12" cy="10" r="3"></circle>
</svg>
<div>
<div class="detail-label">Lokalizacja</div>
<div class="detail-value">{{ classified.location_info }}</div>
</div>
</div>
{% endif %}
</div>
{% endif %}
<div class="author-card">
<a href="{{ url_for('public.user_profile', user_id=classified.author_id) }}" class="author-avatar" style="text-decoration:none;color:inherit;">
{% if classified.author.avatar_path %}<img src="{{ url_for('static', filename=classified.author.avatar_path) }}" alt="" style="width:100%;height:100%;border-radius:50%;object-fit:cover;">{% else %}{{ (classified.author.name or classified.author.email)[0].upper() }}{% endif %}
</a>
<div class="author-info">
<div class="author-name"><a href="{{ url_for('public.user_profile', user_id=classified.author_id) }}" style="color:inherit;text-decoration:none;" onmouseover="this.style.textDecoration='underline'" onmouseout="this.style.textDecoration='none'">{{ classified.author.name or classified.author.email.split('@')[0] }}</a></div>
{% if classified.company %}
<div class="author-company">{{ classified.company.name }}</div>
{% endif %}
</div>
{% if classified.author_id != current_user.id %}
<div class="contact-actions">
<button type="button" class="interest-btn {% if user_interested %}interested{% endif %}" id="interestBtn" onclick="toggleInterest()">
<svg fill="{% if user_interested %}currentColor{% else %}none{% endif %}" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"/>
</svg>
<span id="interestBtnText">{% if user_interested %}Zainteresowany{% else %}Jestem zainteresowany{% endif %}</span>
</button>
<a href="{{ url_for('messages.conversations_page') }}?new_to={{ classified.author_id }}&ctx=classified&ctx_title={{ classified.title|urlencode }}" class="btn btn-primary">Skontaktuj sie</a>
</div>
{% endif %}
</div>
{% if classified.author_id != current_user.id and interests_count > 0 %}
<div class="interests-info">
{{ interests_count }} {{ 'osoba zainteresowana' if interests_count == 1 else 'osoby zainteresowane' if interests_count < 5 else 'osob zainteresowanych' }}
</div>
{% endif %}
{% if classified.author_id == current_user.id and interests_count > 0 %}
<div class="interests-info">
<a href="#" onclick="showInterestsModal(); return false;">{{ interests_count }} {{ 'osoba zainteresowana' if interests_count == 1 else 'osoby zainteresowane' if interests_count < 5 else 'osob zainteresowanych' }} - zobacz liste</a>
</div>
{% endif %}
<div class="stats-bar">
<span>{{ classified.views_count }} wyswietlen</span>
<span>Dodano: {{ classified.created_at|local_time('%d.%m.%Y %H:%M') }}</span>
{% if classified.expires_at %}
<span>Wygasa: {{ classified.expires_at|local_time('%d.%m.%Y') }}</span>
{% endif %}
</div>
{% if readers %}
<div class="seen-by-section">
<div class="seen-by-label">Widziane przez {{ readers_count }} {{ 'osobę' if readers_count == 1 else 'osoby' if readers_count < 5 else 'osób' }}:</div>
<div class="seen-by-avatars">
{% for read in readers[:20] %}
<div class="reader-avatar"
data-name="{{ read.user.name or read.user.email.split('@')[0] }}{% if current_user.is_authenticated and read.user.id == current_user.id %} (Ty){% endif %}"
style="{% if not read.user.avatar_path %}background: hsl({{ (read.user.id * 137) % 360 }}, 65%, 50%);{% endif %}">
{% if read.user.avatar_path %}<img src="{{ url_for('static', filename=read.user.avatar_path) }}" alt="" style="width:100%;height:100%;border-radius:50%;object-fit:cover;">{% else %}{{ (read.user.name or read.user.email)[0]|upper }}{% endif %}
</div>
{% endfor %}
{% if readers_count > 20 %}
<div class="reader-avatar more" title="i {{ readers_count - 20 }} innych">
+{{ readers_count - 20 }}
</div>
{% endif %}
</div>
</div>
{% endif %}
</div>
<!-- Sekcja Pytania i Odpowiedzi -->
<div class="qa-section">
<div class="qa-header">
<h2>
Pytania i odpowiedzi
{% if classified.author_id == current_user.id and unanswered_count > 0 %}
<span class="qa-badge">{{ unanswered_count }} nowych</span>
{% endif %}
</h2>
</div>
{% if classified.author_id != current_user.id %}
<div class="qa-form">
<textarea id="questionContent" placeholder="Zadaj pytanie sprzedajacemu..." maxlength="2000"></textarea>
<div class="qa-form-actions">
<button type="button" class="btn btn-primary" onclick="askQuestion()">Zadaj pytanie</button>
</div>
</div>
{% endif %}
<div id="questionsList">
{% if questions %}
{% for q in questions %}
<div class="question-item {% if not q.is_public %}question-hidden{% endif %}" id="question-{{ q.id }}">
<div class="question-header">
<a href="{{ url_for('public.user_profile', user_id=q.author_id) }}" class="question-avatar" style="text-decoration:none;color:inherit;{% if not q.author.avatar_path %}background: hsl({{ (q.author_id * 137) % 360 }}, 65%, 50%);{% endif %}">
{% if q.author.avatar_path %}<img src="{{ url_for('static', filename=q.author.avatar_path) }}" alt="" style="width:100%;height:100%;border-radius:50%;object-fit:cover;">{% else %}{{ (q.author.name or q.author.email)[0]|upper }}{% endif %}
</a>
<div class="question-meta">
<div class="question-author">
<a href="{{ url_for('public.user_profile', user_id=q.author_id) }}" style="color:inherit;text-decoration:none;" onmouseover="this.style.textDecoration='underline'" onmouseout="this.style.textDecoration='none'">{{ q.author.name or q.author.email.split('@')[0] }}</a>
{% if q.author.company %}<span style="color: var(--text-secondary); font-weight: normal;"> - {{ q.author.company.name }}</span>{% endif %}
{% if not q.answer %}<span class="pending-badge">Oczekuje na odpowiedz</span>{% endif %}
</div>
<div class="question-date">{{ q.created_at|local_time('%d.%m.%Y %H:%M') }}</div>
</div>
{% if classified.author_id == current_user.id %}
<div class="question-actions">
<button type="button" class="question-action-btn" onclick="toggleQuestionVisibility({{ q.id }})" title="{% if q.is_public %}Ukryj{% else %}Pokaz{% endif %}">
{% if q.is_public %}Ukryj{% else %}Pokaz{% endif %}
</button>
</div>
{% endif %}
</div>
<div class="question-content">{{ q.content }}</div>
{% if q.answer %}
<div class="answer-box">
<div class="answer-label">Odpowiedz od <a href="{{ url_for('public.user_profile', user_id=classified.author_id) }}" style="color:inherit;text-decoration:none;" onmouseover="this.style.textDecoration='underline'" onmouseout="this.style.textDecoration='none'">{{ classified.author.name or classified.author.email.split('@')[0] }}</a></div>
<div class="answer-content">{{ q.answer }}</div>
</div>
{% elif classified.author_id == current_user.id %}
<div class="answer-form" id="answerForm-{{ q.id }}">
<textarea id="answerContent-{{ q.id }}" placeholder="Napisz odpowiedz..." maxlength="2000"></textarea>
<div class="answer-form-actions">
<button type="button" class="btn btn-primary btn-sm" onclick="answerQuestion({{ q.id }})">Odpowiedz</button>
</div>
</div>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="no-questions">
Brak pytan. {% if classified.author_id != current_user.id %}Badz pierwszy i zadaj pytanie!{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
<!-- Universal Confirm Modal -->
<div class="modal-overlay" id="confirmModal">
<div class="modal" style="max-width: 420px; background: var(--surface); border-radius: var(--radius-lg); padding: var(--spacing-xl);">
<div style="text-align: center; margin-bottom: var(--spacing-lg);">
<div class="modal-icon" id="confirmModalIcon" style="font-size: 3em; margin-bottom: var(--spacing-md);"></div>
<h3 id="confirmModalTitle" style="margin-bottom: var(--spacing-sm);">Potwierdzenie</h3>
<p class="modal-description" id="confirmModalMessage" style="color: var(--text-secondary);"></p>
</div>
<div class="modal-actions" style="display: flex; gap: var(--spacing-sm); justify-content: center;">
<button type="button" class="btn btn-secondary" id="confirmModalCancel">Anuluj</button>
<button type="button" class="btn btn-primary" id="confirmModalOk">OK</button>
</div>
</div>
</div>
<div id="toastContainer" style="position: fixed; top: 80px; right: 20px; z-index: 1100; display: flex; flex-direction: column; gap: 10px;"></div>
<div class="lightbox" id="lightbox" onclick="closeLightbox()">
<img id="lightboxImage" src="" alt="Powiększony obraz">
</div>
<!-- Interests Modal -->
<div class="modal-overlay" id="interestsModal">
<div class="modal" style="max-width: 500px; background: var(--surface); border-radius: var(--radius-lg); padding: var(--spacing-xl);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--spacing-lg);">
<h3 style="margin: 0;">Zainteresowani ogłoszeniem</h3>
<button type="button" style="background: none; border: none; font-size: 1.5em; cursor: pointer; color: var(--text-secondary);" onclick="closeInterestsModal()">&times;</button>
</div>
<div class="interests-list" id="interestsList">
<div style="text-align: center; padding: var(--spacing-lg); color: var(--text-secondary);">Ładowanie...</div>
</div>
</div>
</div>
<style>
.modal-overlay#confirmModal, .modal-overlay#interestsModal { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 1050; align-items: center; justify-content: center; }
.modal-overlay#confirmModal.active, .modal-overlay#interestsModal.active { display: flex; }
.toast { padding: 12px 20px; border-radius: var(--radius); background: var(--surface); border-left: 4px solid var(--primary); box-shadow: 0 4px 12px rgba(0,0,0,0.15); display: flex; align-items: center; gap: 10px; animation: toastIn 0.3s ease; }
.toast.success { border-left-color: var(--success); }
.toast.error { border-left-color: var(--error); }
@keyframes toastIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@keyframes toastOut { from { opacity: 1; } to { opacity: 0; } }
.btn-danger { background: #dc2626; color: white; border: none; }
.btn-danger:hover { background: #b91c1c; }
.btn-warning { background: #f59e0b; color: white; border: none; }
.btn-warning:hover { background: #d97706; }
.btn-success { background: #10b981; color: white; border: none; }
.btn-success:hover { background: #059669; }
</style>
{% endblock %}
{% block extra_js %}
const csrfToken = '{{ csrf_token() }}';
let confirmResolve = null;
function showConfirm(message, options = {}) {
return new Promise(resolve => {
confirmResolve = resolve;
document.getElementById('confirmModalIcon').textContent = options.icon || '❓';
document.getElementById('confirmModalTitle').textContent = options.title || 'Potwierdzenie';
document.getElementById('confirmModalMessage').innerHTML = message;
document.getElementById('confirmModalOk').textContent = options.okText || 'OK';
document.getElementById('confirmModalOk').className = 'btn ' + (options.okClass || 'btn-primary');
document.getElementById('confirmModal').classList.add('active');
});
}
function closeConfirm(result) {
document.getElementById('confirmModal').classList.remove('active');
if (confirmResolve) { confirmResolve(result); confirmResolve = null; }
}
document.getElementById('confirmModalOk').addEventListener('click', () => closeConfirm(true));
document.getElementById('confirmModalCancel').addEventListener('click', () => closeConfirm(false));
document.getElementById('confirmModal').addEventListener('click', e => { if (e.target.id === 'confirmModal') closeConfirm(false); });
function showToast(message, type = 'info', duration = 4000) {
const container = document.getElementById('toastContainer');
const icons = { success: '✓', error: '✕', warning: '⚠', info: '' };
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `<span style="font-size:1.2em">${icons[type]||''}</span><span>${message}</span>`;
container.appendChild(toast);
setTimeout(() => { toast.style.animation = 'toastOut 0.3s ease forwards'; setTimeout(() => toast.remove(), 300); }, duration);
}
async function closeClassified() {
const confirmed = await showConfirm('Czy na pewno chcesz zamknąć to ogłoszenie?', {
icon: '🔒',
title: 'Zamykanie ogłoszenia',
okText: 'Zamknij',
okClass: 'btn-warning'
});
if (!confirmed) return;
try {
const response = await fetch('{{ url_for("classifieds.classifieds_close", classified_id=classified.id) }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (data.success) {
showToast('Ogłoszenie zostało zamknięte', 'success');
setTimeout(() => window.location.href = '{{ url_for("classifieds.classifieds_index") }}', 1500);
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia', 'error');
}
}
// Admin functions
async function deleteClassified() {
const confirmed = await showConfirm('Czy na pewno chcesz usunąć to ogłoszenie?<br><br><strong>Ta operacja jest nieodwracalna.</strong>', {
icon: '🗑️',
title: 'Usuń ogłoszenie',
okText: 'Usuń',
okClass: 'btn-danger'
});
if (!confirmed) return;
try {
const response = await fetch('{{ url_for("classifieds.classifieds_delete", classified_id=classified.id) }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (data.success) {
showToast('Ogłoszenie usunięte', 'success');
setTimeout(() => window.location.href = '{{ url_for("classifieds.classifieds_index") }}', 1500);
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia', 'error');
}
}
async function toggleActive() {
const isActive = {{ 'true' if classified.is_active else 'false' }};
const action = isActive ? 'dezaktywować' : 'aktywować';
const confirmed = await showConfirm(`Czy na pewno chcesz ${action} to ogłoszenie?`, {
icon: isActive ? '🚫' : '✅',
title: isActive ? 'Dezaktywuj ogłoszenie' : 'Aktywuj ogłoszenie',
okText: isActive ? 'Dezaktywuj' : 'Aktywuj',
okClass: isActive ? 'btn-warning' : 'btn-success'
});
if (!confirmed) return;
try {
const response = await fetch('{{ url_for("classifieds.classifieds_toggle_active", classified_id=classified.id) }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (data.success) {
showToast(data.message, 'success');
setTimeout(() => location.reload(), 1500);
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia', 'error');
}
}
// ============================================================
// INTEREST (ZAINTERESOWANIA)
// ============================================================
async function toggleInterest() {
try {
const response = await fetch('{{ url_for("classifieds.classifieds_interest", classified_id=classified.id) }}', {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (data.success) {
const btn = document.getElementById('interestBtn');
const btnText = document.getElementById('interestBtnText');
const svg = btn.querySelector('svg');
if (data.interested) {
btn.classList.add('interested');
btnText.textContent = 'Zainteresowany';
svg.setAttribute('fill', 'currentColor');
} else {
btn.classList.remove('interested');
btnText.textContent = 'Jestem zainteresowany';
svg.setAttribute('fill', 'none');
}
showToast(data.message, 'success');
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia', 'error');
}
}
async function showInterestsModal() {
document.getElementById('interestsModal').classList.add('active');
document.getElementById('interestsList').innerHTML = '<div style="text-align: center; padding: var(--spacing-lg); color: var(--text-secondary);">Ładowanie...</div>';
try {
const response = await fetch('{{ url_for("classifieds.classifieds_interests", classified_id=classified.id) }}');
const data = await response.json();
if (data.success) {
if (data.interests.length === 0) {
document.getElementById('interestsList').innerHTML = '<div style="text-align: center; padding: var(--spacing-lg); color: var(--text-secondary);">Brak zainteresowanych</div>';
} else {
document.getElementById('interestsList').innerHTML = data.interests.map(i => `
<div class="interest-item">
<div class="interest-avatar" style="background: hsl(${(i.user_id * 137) % 360}, 65%, 50%);">
${i.user_initial}
</div>
<div class="interest-info">
<div class="interest-name">${i.user_name}</div>
${i.company_name ? `<div class="interest-company">${i.company_name}</div>` : ''}
${i.message ? `<div class="interest-message">"${i.message}"</div>` : ''}
</div>
<div class="interest-date">${parseUTC(i.created_at).toLocaleDateString('pl-PL')}</div>
</div>
`).join('');
}
} else {
document.getElementById('interestsList').innerHTML = '<div style="text-align: center; padding: var(--spacing-lg); color: var(--error);">Błąd ładowania</div>';
}
} catch (error) {
document.getElementById('interestsList').innerHTML = '<div style="text-align: center; padding: var(--spacing-lg); color: var(--error);">Błąd połączenia</div>';
}
}
function closeInterestsModal() {
document.getElementById('interestsModal').classList.remove('active');
}
document.getElementById('interestsModal').addEventListener('click', e => {
if (e.target.id === 'interestsModal') closeInterestsModal();
});
// ============================================================
// Q&A (PYTANIA I ODPOWIEDZI)
// ============================================================
async function askQuestion() {
const content = document.getElementById('questionContent').value.trim();
if (!content) {
showToast('Wpisz treść pytania', 'warning');
return;
}
try {
const response = await fetch('{{ url_for("classifieds.classifieds_ask", classified_id=classified.id) }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ content })
});
const data = await response.json();
if (data.success) {
showToast('Pytanie dodane', 'success');
document.getElementById('questionContent').value = '';
setTimeout(() => location.reload(), 1000);
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia', 'error');
}
}
async function answerQuestion(questionId) {
const content = document.getElementById(`answerContent-${questionId}`).value.trim();
if (!content) {
showToast('Wpisz treść odpowiedzi', 'warning');
return;
}
try {
const response = await fetch(`/b2b/{{ classified.id }}/question/${questionId}/answer`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ answer: content })
});
const data = await response.json();
if (data.success) {
showToast('Odpowiedź dodana', 'success');
setTimeout(() => location.reload(), 1000);
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia', 'error');
}
}
async function toggleQuestionVisibility(questionId) {
try {
const response = await fetch(`/b2b/{{ classified.id }}/question/${questionId}/hide`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (data.success) {
showToast(data.message, 'success');
setTimeout(() => location.reload(), 1000);
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia', 'error');
}
}
async function extendClassified() {
try {
const resp = await fetch('{{ url_for("classifieds.classifieds_extend", classified_id=classified.id) }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken }
});
const data = await resp.json();
if (data.success) {
showToast('Ogłoszenie przedłużone do ' + data.new_expires, 'success');
setTimeout(() => location.reload(), 1000);
} else {
showToast(data.error || 'Błąd', 'error');
}
} catch (e) {
showToast('Błąd połączenia', 'error');
}
}
function openLightbox(src) {
document.getElementById('lightboxImage').src = src;
document.getElementById('lightbox').classList.add('active');
}
function closeLightbox() {
document.getElementById('lightbox').classList.remove('active');
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeLightbox();
});
{% endblock %}