feat: Add B2B classifieds interactions (interest, Q&A, context messages)

- Add ClassifiedInterest model for tracking user interest in listings
- Add ClassifiedQuestion model for public Q&A on listings
- Add context_type/context_id to PrivateMessage for B2B linking
- Add interest toggle button and interests list modal
- Add Q&A section with ask/answer/hide functionality
- Update messages to show B2B context badge
- Create migration 034_classified_interactions.sql

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-01-31 21:15:30 +01:00
parent 062a152a50
commit 830ef0ea1e
9 changed files with 1224 additions and 12 deletions

View File

@ -10,7 +10,7 @@ from flask import render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user from flask_login import login_required, current_user
from . import bp from . import bp
from database import SessionLocal, Classified, ClassifiedRead from database import SessionLocal, Classified, ClassifiedRead, ClassifiedInterest, ClassifiedQuestion, User
from sqlalchemy import desc from sqlalchemy import desc
from utils.helpers import sanitize_input from utils.helpers import sanitize_input
@ -144,10 +144,41 @@ def view(classified_id):
).order_by(desc(ClassifiedRead.read_at)).all() ).order_by(desc(ClassifiedRead.read_at)).all()
readers_count = len(readers) readers_count = len(readers)
# Sprawdź czy użytkownik jest zainteresowany
user_interested = db.query(ClassifiedInterest).filter(
ClassifiedInterest.classified_id == classified.id,
ClassifiedInterest.user_id == current_user.id
).first() is not None
# Liczba zainteresowanych
interests_count = db.query(ClassifiedInterest).filter(
ClassifiedInterest.classified_id == classified.id
).count()
# Pobierz pytania (publiczne dla wszystkich, wszystkie dla autora)
questions_query = db.query(ClassifiedQuestion).filter(
ClassifiedQuestion.classified_id == classified.id
)
if classified.author_id != current_user.id and not current_user.is_admin:
questions_query = questions_query.filter(ClassifiedQuestion.is_public == True)
questions = questions_query.order_by(ClassifiedQuestion.created_at.asc()).all()
# Liczba pytań bez odpowiedzi (dla autora)
unanswered_count = 0
if classified.author_id == current_user.id:
unanswered_count = db.query(ClassifiedQuestion).filter(
ClassifiedQuestion.classified_id == classified.id,
ClassifiedQuestion.answer == None
).count()
return render_template('classifieds/view.html', return render_template('classifieds/view.html',
classified=classified, classified=classified,
readers=readers, readers=readers,
readers_count=readers_count) readers_count=readers_count,
user_interested=user_interested,
interests_count=interests_count,
questions=questions,
unanswered_count=unanswered_count)
finally: finally:
db.close() db.close()
@ -221,3 +252,289 @@ def toggle_active(classified_id):
return jsonify({'success': True, 'message': f'Ogłoszenie {status}', 'is_active': classified.is_active}) return jsonify({'success': True, 'message': f'Ogłoszenie {status}', 'is_active': classified.is_active})
finally: finally:
db.close() db.close()
# ============================================================
# INTEREST (ZAINTERESOWANIA)
# ============================================================
@bp.route('/<int:classified_id>/interest', methods=['POST'], endpoint='classifieds_interest')
@login_required
def toggle_interest(classified_id):
"""Toggle zainteresowania ogłoszeniem"""
db = SessionLocal()
try:
classified = db.query(Classified).filter(
Classified.id == classified_id,
Classified.is_active == True
).first()
if not classified:
return jsonify({'success': False, 'error': 'Ogłoszenie nie istnieje lub jest nieaktywne'}), 404
# Nie można być zainteresowanym własnym ogłoszeniem
if classified.author_id == current_user.id:
return jsonify({'success': False, 'error': 'Nie możesz być zainteresowany własnym ogłoszeniem'}), 400
# Sprawdź czy już jest zainteresowany
existing = db.query(ClassifiedInterest).filter(
ClassifiedInterest.classified_id == classified_id,
ClassifiedInterest.user_id == current_user.id
).first()
if existing:
# Usuń zainteresowanie
db.delete(existing)
db.commit()
return jsonify({
'success': True,
'interested': False,
'message': 'Usunięto zainteresowanie'
})
else:
# Dodaj zainteresowanie
message = request.json.get('message', '') if request.is_json else ''
interest = ClassifiedInterest(
classified_id=classified_id,
user_id=current_user.id,
message=message[:255] if message else None
)
db.add(interest)
db.commit()
return jsonify({
'success': True,
'interested': True,
'message': 'Dodano zainteresowanie'
})
finally:
db.close()
@bp.route('/<int:classified_id>/interests', endpoint='classifieds_interests')
@login_required
def list_interests(classified_id):
"""Lista zainteresowanych (tylko dla autora ogłoszenia)"""
db = SessionLocal()
try:
classified = db.query(Classified).filter(
Classified.id == classified_id
).first()
if not classified:
return jsonify({'success': False, 'error': 'Ogłoszenie nie istnieje'}), 404
# Tylko autor może widzieć pełną listę
if classified.author_id != current_user.id and not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
interests = db.query(ClassifiedInterest).filter(
ClassifiedInterest.classified_id == classified_id
).order_by(desc(ClassifiedInterest.created_at)).all()
return jsonify({
'success': True,
'count': len(interests),
'interests': [
{
'id': i.id,
'user_id': i.user_id,
'user_name': i.user.name or i.user.email.split('@')[0],
'user_initial': (i.user.name or i.user.email)[0].upper(),
'company_name': i.user.company.name if i.user.company else None,
'message': i.message,
'created_at': i.created_at.isoformat() if i.created_at else None
}
for i in interests
]
})
finally:
db.close()
# ============================================================
# Q&A (PYTANIA I ODPOWIEDZI)
# ============================================================
@bp.route('/<int:classified_id>/ask', methods=['POST'], endpoint='classifieds_ask')
@login_required
def ask_question(classified_id):
"""Zadaj pytanie do ogłoszenia"""
db = SessionLocal()
try:
classified = db.query(Classified).filter(
Classified.id == classified_id,
Classified.is_active == True
).first()
if not classified:
return jsonify({'success': False, 'error': 'Ogłoszenie nie istnieje lub jest nieaktywne'}), 404
content = ''
if request.is_json:
content = request.json.get('content', '').strip()
else:
content = request.form.get('content', '').strip()
if not content:
return jsonify({'success': False, 'error': 'Treść pytania jest wymagana'}), 400
if len(content) > 2000:
return jsonify({'success': False, 'error': 'Pytanie jest zbyt długie (max 2000 znaków)'}), 400
question = ClassifiedQuestion(
classified_id=classified_id,
author_id=current_user.id,
content=content
)
db.add(question)
db.commit()
return jsonify({
'success': True,
'message': 'Pytanie dodane',
'question': {
'id': question.id,
'content': question.content,
'author_name': current_user.name or current_user.email.split('@')[0],
'created_at': question.created_at.isoformat()
}
})
finally:
db.close()
@bp.route('/<int:classified_id>/question/<int:question_id>/answer', methods=['POST'], endpoint='classifieds_answer')
@login_required
def answer_question(classified_id, question_id):
"""Odpowiedz na pytanie (tylko autor ogłoszenia)"""
db = SessionLocal()
try:
classified = db.query(Classified).filter(
Classified.id == classified_id
).first()
if not classified:
return jsonify({'success': False, 'error': 'Ogłoszenie nie istnieje'}), 404
# Tylko autor ogłoszenia może odpowiadać
if classified.author_id != current_user.id:
return jsonify({'success': False, 'error': 'Tylko autor ogłoszenia może odpowiadać na pytania'}), 403
question = db.query(ClassifiedQuestion).filter(
ClassifiedQuestion.id == question_id,
ClassifiedQuestion.classified_id == classified_id
).first()
if not question:
return jsonify({'success': False, 'error': 'Pytanie nie istnieje'}), 404
answer = ''
if request.is_json:
answer = request.json.get('answer', '').strip()
else:
answer = request.form.get('answer', '').strip()
if not answer:
return jsonify({'success': False, 'error': 'Treść odpowiedzi jest wymagana'}), 400
if len(answer) > 2000:
return jsonify({'success': False, 'error': 'Odpowiedź jest zbyt długa (max 2000 znaków)'}), 400
question.answer = answer
question.answered_by = current_user.id
question.answered_at = datetime.now()
db.commit()
return jsonify({
'success': True,
'message': 'Odpowiedź dodana',
'answer': answer,
'answered_at': question.answered_at.isoformat()
})
finally:
db.close()
@bp.route('/<int:classified_id>/question/<int:question_id>/hide', methods=['POST'], endpoint='classifieds_hide_question')
@login_required
def hide_question(classified_id, question_id):
"""Ukryj/pokaż pytanie (tylko autor ogłoszenia)"""
db = SessionLocal()
try:
classified = db.query(Classified).filter(
Classified.id == classified_id
).first()
if not classified:
return jsonify({'success': False, 'error': 'Ogłoszenie nie istnieje'}), 404
# Tylko autor ogłoszenia lub admin może ukrywać
if classified.author_id != current_user.id and not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
question = db.query(ClassifiedQuestion).filter(
ClassifiedQuestion.id == question_id,
ClassifiedQuestion.classified_id == classified_id
).first()
if not question:
return jsonify({'success': False, 'error': 'Pytanie nie istnieje'}), 404
question.is_public = not question.is_public
db.commit()
status = 'widoczne' if question.is_public else 'ukryte'
return jsonify({
'success': True,
'message': f'Pytanie jest teraz {status}',
'is_public': question.is_public
})
finally:
db.close()
@bp.route('/<int:classified_id>/questions', endpoint='classifieds_questions')
@login_required
def list_questions(classified_id):
"""Lista pytań i odpowiedzi do ogłoszenia"""
db = SessionLocal()
try:
classified = db.query(Classified).filter(
Classified.id == classified_id
).first()
if not classified:
return jsonify({'success': False, 'error': 'Ogłoszenie nie istnieje'}), 404
# Buduj query - autor widzi wszystkie, inni tylko publiczne
query = db.query(ClassifiedQuestion).filter(
ClassifiedQuestion.classified_id == classified_id
)
if classified.author_id != current_user.id and not current_user.is_admin:
query = query.filter(ClassifiedQuestion.is_public == True)
questions = query.order_by(desc(ClassifiedQuestion.created_at)).all()
return jsonify({
'success': True,
'count': len(questions),
'is_owner': classified.author_id == current_user.id,
'questions': [
{
'id': q.id,
'content': q.content,
'author_id': q.author_id,
'author_name': q.author.name or q.author.email.split('@')[0],
'author_initial': (q.author.name or q.author.email)[0].upper(),
'author_company': q.author.company.name if q.author.company else None,
'answer': q.answer,
'answered_at': q.answered_at.isoformat() if q.answered_at else None,
'is_public': q.is_public,
'created_at': q.created_at.isoformat() if q.created_at else None
}
for q in questions
]
})
finally:
db.close()

View File

@ -11,7 +11,7 @@ from flask import render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user from flask_login import login_required, current_user
from . import bp from . import bp
from database import SessionLocal, User, PrivateMessage, UserNotification, UserBlock from database import SessionLocal, User, PrivateMessage, UserNotification, UserBlock, Classified
from utils.helpers import sanitize_input from utils.helpers import sanitize_input
@ -80,6 +80,8 @@ def messages_sent():
def messages_new(): def messages_new():
"""Formularz nowej wiadomości""" """Formularz nowej wiadomości"""
recipient_id = request.args.get('to', type=int) recipient_id = request.args.get('to', type=int)
context_type = request.args.get('context_type')
context_id = request.args.get('context_id', type=int)
db = SessionLocal() db = SessionLocal()
try: try:
@ -94,9 +96,27 @@ def messages_new():
if recipient_id: if recipient_id:
recipient = db.query(User).filter(User.id == recipient_id).first() recipient = db.query(User).filter(User.id == recipient_id).first()
# Pobierz kontekst (np. ogłoszenie B2B)
context = None
context_subject = None
if context_type == 'classified' and context_id:
classified = db.query(Classified).filter(Classified.id == context_id).first()
if classified:
context = {
'type': 'classified',
'id': classified.id,
'title': classified.title,
'url': url_for('classifieds.classifieds_view', classified_id=classified.id)
}
context_subject = f"Dotyczy: {classified.title}"
return render_template('messages/compose.html', return render_template('messages/compose.html',
users=users, users=users,
recipient=recipient recipient=recipient,
context=context,
context_type=context_type,
context_id=context_id,
context_subject=context_subject
) )
finally: finally:
db.close() db.close()
@ -109,6 +129,8 @@ def messages_send():
recipient_id = request.form.get('recipient_id', type=int) recipient_id = request.form.get('recipient_id', type=int)
subject = sanitize_input(request.form.get('subject', ''), 255) subject = sanitize_input(request.form.get('subject', ''), 255)
content = request.form.get('content', '').strip() content = request.form.get('content', '').strip()
context_type = request.form.get('context_type')
context_id = request.form.get('context_id', type=int)
if not recipient_id or not content: if not recipient_id or not content:
flash('Odbiorca i treść są wymagane.', 'error') flash('Odbiorca i treść są wymagane.', 'error')
@ -134,7 +156,9 @@ def messages_send():
sender_id=current_user.id, sender_id=current_user.id,
recipient_id=recipient_id, recipient_id=recipient_id,
subject=subject, subject=subject,
content=content content=content,
context_type=context_type if context_type else None,
context_id=context_id if context_id else None
) )
db.add(message) db.add(message)
db.commit() db.commit()
@ -170,7 +194,20 @@ def messages_view(message_id):
message.read_at = datetime.now() message.read_at = datetime.now()
db.commit() db.commit()
return render_template('messages/view.html', message=message) # Pobierz kontekst (np. ogłoszenie B2B)
context = None
if message.context_type == 'classified' and message.context_id:
classified = db.query(Classified).filter(Classified.id == message.context_id).first()
if classified:
context = {
'type': 'classified',
'id': classified.id,
'title': classified.title,
'url': url_for('classifieds.classifieds_view', classified_id=classified.id),
'is_active': classified.is_active
}
return render_template('messages/view.html', message=message, context=context)
finally: finally:
db.close() db.close()

View File

@ -1336,6 +1336,10 @@ class PrivateMessage(Base):
# Dla wątków konwersacji # Dla wątków konwersacji
parent_id = Column(Integer, ForeignKey('private_messages.id')) parent_id = Column(Integer, ForeignKey('private_messages.id'))
# Kontekst powiązania (np. ogłoszenie B2B, temat forum)
context_type = Column(String(50)) # 'classified', 'forum_topic', etc.
context_id = Column(Integer) # ID powiązanego obiektu
created_at = Column(DateTime, default=datetime.now) created_at = Column(DateTime, default=datetime.now)
sender = relationship('User', foreign_keys=[sender_id], backref='sent_messages') sender = relationship('User', foreign_keys=[sender_id], backref='sent_messages')
@ -1411,6 +1415,57 @@ class ClassifiedRead(Base):
return f"<ClassifiedRead classified={self.classified_id} user={self.user_id}>" return f"<ClassifiedRead classified={self.classified_id} user={self.user_id}>"
class ClassifiedInterest(Base):
"""Zainteresowania użytkowników ogłoszeniami B2B"""
__tablename__ = 'classified_interests'
id = Column(Integer, primary_key=True)
classified_id = Column(Integer, ForeignKey('classifieds.id', ondelete='CASCADE'), nullable=False)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
message = Column(String(255)) # opcjonalna krótka notatka
created_at = Column(DateTime, default=datetime.now)
# Relationships
classified = relationship('Classified', backref='interests')
user = relationship('User')
# Unique constraint - użytkownik może być zainteresowany tylko raz
__table_args__ = (
UniqueConstraint('classified_id', 'user_id', name='uq_classified_interest'),
)
def __repr__(self):
return f"<ClassifiedInterest classified={self.classified_id} user={self.user_id}>"
class ClassifiedQuestion(Base):
"""Publiczne pytania i odpowiedzi do ogłoszeń B2B"""
__tablename__ = 'classified_questions'
id = Column(Integer, primary_key=True)
classified_id = Column(Integer, ForeignKey('classifieds.id', ondelete='CASCADE'), nullable=False)
author_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
content = Column(Text, nullable=False)
# Odpowiedź właściciela ogłoszenia
answer = Column(Text)
answered_by = Column(Integer, ForeignKey('users.id'))
answered_at = Column(DateTime)
# Widoczność
is_public = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.now)
# Relationships
classified = relationship('Classified', backref='questions')
author = relationship('User', foreign_keys=[author_id])
answerer = relationship('User', foreign_keys=[answered_by])
def __repr__(self):
return f"<ClassifiedQuestion id={self.id} classified={self.classified_id}>"
class CompanyContact(Base): class CompanyContact(Base):
"""Multiple contacts (phones, emails) per company with source tracking""" """Multiple contacts (phones, emails) per company with source tracking"""
__tablename__ = 'company_contacts' __tablename__ = 'company_contacts'

View File

@ -0,0 +1,76 @@
-- Migration: B2B Classified Interactions
-- Date: 2026-01-31
-- Description: Dodanie tabel dla interakcji z ogłoszeniami B2B:
-- - classified_interests (zainteresowania)
-- - classified_questions (pytania i odpowiedzi publiczne)
-- - context_type/context_id w private_messages (powiązanie wiadomości z ogłoszeniem)
-- ============================================================
-- 1. Tabela zainteresowań ogłoszeniami
-- ============================================================
CREATE TABLE IF NOT EXISTS classified_interests (
id SERIAL PRIMARY KEY,
classified_id INTEGER NOT NULL REFERENCES classifieds(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
message VARCHAR(255), -- opcjonalna krótka notatka
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(classified_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_classified_interests_classified ON classified_interests(classified_id);
CREATE INDEX IF NOT EXISTS idx_classified_interests_user ON classified_interests(user_id);
COMMENT ON TABLE classified_interests IS 'Zainteresowania użytkowników ogłoszeniami B2B';
COMMENT ON COLUMN classified_interests.message IS 'Opcjonalna krótka notatka do zainteresowania';
-- ============================================================
-- 2. Tabela pytań i odpowiedzi publicznych
-- ============================================================
CREATE TABLE IF NOT EXISTS classified_questions (
id SERIAL PRIMARY KEY,
classified_id INTEGER NOT NULL REFERENCES classifieds(id) ON DELETE CASCADE,
author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
-- Odpowiedź właściciela ogłoszenia
answer TEXT,
answered_by INTEGER REFERENCES users(id),
answered_at TIMESTAMP,
-- Widoczność
is_public BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_classified_questions_classified ON classified_questions(classified_id);
CREATE INDEX IF NOT EXISTS idx_classified_questions_author ON classified_questions(author_id);
CREATE INDEX IF NOT EXISTS idx_classified_questions_unanswered ON classified_questions(classified_id) WHERE answer IS NULL;
COMMENT ON TABLE classified_questions IS 'Publiczne pytania i odpowiedzi do ogłoszeń B2B';
COMMENT ON COLUMN classified_questions.is_public IS 'Czy pytanie jest widoczne publicznie (autor ogłoszenia może ukryć)';
-- ============================================================
-- 3. Kontekst w wiadomościach prywatnych
-- ============================================================
-- Dodaj kolumny kontekstu do private_messages
ALTER TABLE private_messages
ADD COLUMN IF NOT EXISTS context_type VARCHAR(50),
ADD COLUMN IF NOT EXISTS context_id INTEGER;
CREATE INDEX IF NOT EXISTS idx_private_messages_context ON private_messages(context_type, context_id) WHERE context_type IS NOT NULL;
COMMENT ON COLUMN private_messages.context_type IS 'Typ powiązanego obiektu: classified, forum_topic, etc.';
COMMENT ON COLUMN private_messages.context_id IS 'ID powiązanego obiektu';
-- ============================================================
-- 4. Uprawnienia
-- ============================================================
GRANT ALL ON TABLE classified_interests TO nordabiz_app;
GRANT ALL ON TABLE classified_questions TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE classified_interests_id_seq TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE classified_questions_id_seq TO nordabiz_app;

View File

@ -301,6 +301,309 @@
font-weight: 500; font-weight: 500;
margin-left: var(--spacing-sm); 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);
}
</style> </style>
{% endblock %} {% endblock %}
@ -391,10 +694,30 @@
{% endif %} {% endif %}
</div> </div>
{% if classified.author_id != current_user.id %} {% if classified.author_id != current_user.id %}
<a href="{{ url_for('messages_new', to=classified.author_id) }}" class="btn btn-primary">Skontaktuj sie</a> <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_new', to=classified.author_id, context_type='classified', context_id=classified.id) }}" class="btn btn-primary">Skontaktuj sie</a>
</div>
{% endif %} {% endif %}
</div> </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"> <div class="stats-bar">
<span>{{ classified.views_count }} wyswietlen</span> <span>{{ classified.views_count }} wyswietlen</span>
<span>Dodano: {{ classified.created_at.strftime('%d.%m.%Y %H:%M') }}</span> <span>Dodano: {{ classified.created_at.strftime('%d.%m.%Y %H:%M') }}</span>
@ -423,6 +746,75 @@
</div> </div>
{% endif %} {% endif %}
</div> </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">
<div class="question-avatar" style="background: hsl({{ (q.author_id * 137) % 360 }}, 65%, 50%);">
{{ (q.author.name or q.author.email)[0]|upper }}
</div>
<div class="question-meta">
<div class="question-author">
{{ q.author.name or q.author.email.split('@')[0] }}
{% 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.strftime('%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 {{ classified.author.name or classified.author.email.split('@')[0] }}</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> </div>
<!-- Universal Confirm Modal --> <!-- Universal Confirm Modal -->
@ -442,9 +834,22 @@
<div id="toastContainer" style="position: fixed; top: 80px; right: 20px; z-index: 1100; display: flex; flex-direction: column; gap: 10px;"></div> <div id="toastContainer" style="position: fixed; top: 80px; right: 20px; z-index: 1100; display: flex; flex-direction: column; gap: 10px;"></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> <style>
.modal-overlay#confirmModal { 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, .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 { display: flex; } .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 { 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.success { border-left-color: var(--success); }
.toast.error { border-left-color: var(--error); } .toast.error { border-left-color: var(--error); }
@ -587,4 +992,169 @@ async function toggleActive() {
showToast('Błąd połączenia', '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: {
'Content-Type': 'application/json',
'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">${new Date(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');
}
}
{% endblock %} {% endblock %}

View File

@ -105,6 +105,47 @@
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
color: var(--text-secondary); color: var(--text-secondary);
} }
.context-info {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-md);
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: var(--radius);
margin-bottom: var(--spacing-lg);
}
.context-icon {
width: 40px;
height: 40px;
border-radius: var(--radius);
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
}
.context-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.context-title {
font-weight: 500;
color: var(--text-primary);
}
.context-title a {
color: var(--primary);
text-decoration: none;
}
.context-title a:hover {
text-decoration: underline;
}
</style> </style>
{% endblock %} {% endblock %}
@ -134,8 +175,26 @@
</div> </div>
{% endif %} {% endif %}
{% if context %}
<div class="context-info">
<div class="context-icon">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
</div>
<div>
<div class="context-label">Dotyczy ogloszenia B2B:</div>
<div class="context-title"><a href="{{ context.url }}" target="_blank">{{ context.title }}</a></div>
</div>
</div>
{% endif %}
<form method="POST" action="{{ url_for('messages_send') }}"> <form method="POST" action="{{ url_for('messages_send') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if context_type %}
<input type="hidden" name="context_type" value="{{ context_type }}">
<input type="hidden" name="context_id" value="{{ context_id }}">
{% endif %}
{% if recipient %} {% if recipient %}
<input type="hidden" name="recipient_id" value="{{ recipient.id }}"> <input type="hidden" name="recipient_id" value="{{ recipient.id }}">
@ -153,7 +212,7 @@
<div class="form-group"> <div class="form-group">
<label for="subject">Temat</label> <label for="subject">Temat</label>
<input type="text" id="subject" name="subject" maxlength="255" placeholder="Temat wiadomosci (opcjonalnie)"> <input type="text" id="subject" name="subject" maxlength="255" placeholder="Temat wiadomosci (opcjonalnie)" value="{{ context_subject or '' }}">
</div> </div>
<div class="form-group"> <div class="form-group">

View File

@ -143,6 +143,23 @@
margin-left: var(--spacing-sm); margin-left: var(--spacing-sm);
} }
.context-badge-small {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
background: #eff6ff;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
color: var(--primary);
margin-left: var(--spacing-sm);
}
.context-badge-small svg {
width: 12px;
height: 12px;
}
.pagination { .pagination {
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -192,7 +209,15 @@
{{ (msg.sender.name or msg.sender.email)[0].upper() }} {{ (msg.sender.name or msg.sender.email)[0].upper() }}
</div> </div>
<div class="message-content"> <div class="message-content">
<div class="message-subject">{{ msg.subject or '(brak tematu)' }}</div> <div class="message-subject">
{{ msg.subject or '(brak tematu)' }}
{% if msg.context_type == 'classified' %}
<span class="context-badge-small">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
B2B
</span>
{% endif %}
</div>
<div class="message-preview">{{ msg.content[:80] }}{% if msg.content|length > 80 %}...{% endif %}</div> <div class="message-preview">{{ msg.content[:80] }}{% if msg.content|length > 80 %}...{% endif %}</div>
</div> </div>
<div class="message-meta"> <div class="message-meta">

View File

@ -130,6 +130,23 @@
color: var(--success); color: var(--success);
} }
.context-badge-small {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
background: #eff6ff;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
color: var(--primary);
margin-left: var(--spacing-sm);
}
.context-badge-small svg {
width: 12px;
height: 12px;
}
.pagination { .pagination {
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -177,7 +194,15 @@
{{ (msg.recipient.name or msg.recipient.email)[0].upper() }} {{ (msg.recipient.name or msg.recipient.email)[0].upper() }}
</div> </div>
<div class="message-content"> <div class="message-content">
<div class="message-subject">{{ msg.subject or '(brak tematu)' }}</div> <div class="message-subject">
{{ msg.subject or '(brak tematu)' }}
{% if msg.context_type == 'classified' %}
<span class="context-badge-small">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
B2B
</span>
{% endif %}
</div>
<div class="message-preview">{{ msg.content[:80] }}{% if msg.content|length > 80 %}...{% endif %}</div> <div class="message-preview">{{ msg.content[:80] }}{% if msg.content|length > 80 %}...{% endif %}</div>
</div> </div>
<div class="message-meta"> <div class="message-meta">

View File

@ -138,6 +138,44 @@
margin-top: var(--spacing-sm); margin-top: var(--spacing-sm);
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
} }
.context-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: var(--radius);
font-size: var(--font-size-sm);
color: var(--primary);
margin-bottom: var(--spacing-md);
}
.context-badge svg {
width: 16px;
height: 16px;
}
.context-badge a {
color: var(--primary);
text-decoration: none;
font-weight: 500;
}
.context-badge a:hover {
text-decoration: underline;
}
.context-badge.inactive {
background: #f3f4f6;
border-color: #d1d5db;
color: var(--text-secondary);
}
.context-badge.inactive a {
color: var(--text-secondary);
}
</style> </style>
{% endblock %} {% endblock %}
@ -151,6 +189,16 @@
</a> </a>
<div class="message-card"> <div class="message-card">
{% if context %}
<div class="context-badge {% if not context.is_active %}inactive{% endif %}">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
Dotyczy ogloszenia: <a href="{{ context.url }}">{{ context.title }}</a>
{% if not context.is_active %}<span style="color: var(--text-secondary);">(nieaktywne)</span>{% endif %}
</div>
{% endif %}
<div class="message-header"> <div class="message-header">
<div> <div>
<div class="message-subject">{{ message.subject or '(brak tematu)' }}</div> <div class="message-subject">{{ message.subject or '(brak tematu)' }}</div>