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:
parent
062a152a50
commit
830ef0ea1e
@ -10,7 +10,7 @@ from flask import render_template, request, redirect, url_for, flash, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from . import bp
|
||||
from database import SessionLocal, Classified, ClassifiedRead
|
||||
from database import SessionLocal, Classified, ClassifiedRead, ClassifiedInterest, ClassifiedQuestion, User
|
||||
from sqlalchemy import desc
|
||||
from utils.helpers import sanitize_input
|
||||
|
||||
@ -144,10 +144,41 @@ def view(classified_id):
|
||||
).order_by(desc(ClassifiedRead.read_at)).all()
|
||||
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',
|
||||
classified=classified,
|
||||
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:
|
||||
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})
|
||||
finally:
|
||||
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()
|
||||
|
||||
@ -11,7 +11,7 @@ from flask import render_template, request, redirect, url_for, flash, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -80,6 +80,8 @@ def messages_sent():
|
||||
def messages_new():
|
||||
"""Formularz nowej wiadomości"""
|
||||
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()
|
||||
try:
|
||||
@ -94,9 +96,27 @@ def messages_new():
|
||||
if recipient_id:
|
||||
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',
|
||||
users=users,
|
||||
recipient=recipient
|
||||
recipient=recipient,
|
||||
context=context,
|
||||
context_type=context_type,
|
||||
context_id=context_id,
|
||||
context_subject=context_subject
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
@ -109,6 +129,8 @@ def messages_send():
|
||||
recipient_id = request.form.get('recipient_id', type=int)
|
||||
subject = sanitize_input(request.form.get('subject', ''), 255)
|
||||
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:
|
||||
flash('Odbiorca i treść są wymagane.', 'error')
|
||||
@ -134,7 +156,9 @@ def messages_send():
|
||||
sender_id=current_user.id,
|
||||
recipient_id=recipient_id,
|
||||
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.commit()
|
||||
@ -170,7 +194,20 @@ def messages_view(message_id):
|
||||
message.read_at = datetime.now()
|
||||
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:
|
||||
db.close()
|
||||
|
||||
|
||||
55
database.py
55
database.py
@ -1336,6 +1336,10 @@ class PrivateMessage(Base):
|
||||
# Dla wątków konwersacji
|
||||
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)
|
||||
|
||||
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}>"
|
||||
|
||||
|
||||
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):
|
||||
"""Multiple contacts (phones, emails) per company with source tracking"""
|
||||
__tablename__ = 'company_contacts'
|
||||
|
||||
76
database/migrations/034_classified_interactions.sql
Normal file
76
database/migrations/034_classified_interactions.sql
Normal 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;
|
||||
@ -301,6 +301,309 @@
|
||||
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);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@ -391,10 +694,30 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% 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 %}
|
||||
</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.strftime('%d.%m.%Y %H:%M') }}</span>
|
||||
@ -423,6 +746,75 @@
|
||||
</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">
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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()">×</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 { 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, .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); }
|
||||
@ -587,4 +992,169 @@ async function toggleActive() {
|
||||
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 %}
|
||||
|
||||
@ -105,6 +105,47 @@
|
||||
font-size: var(--font-size-sm);
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
@ -134,8 +175,26 @@
|
||||
</div>
|
||||
{% 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') }}">
|
||||
<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 %}
|
||||
<input type="hidden" name="recipient_id" value="{{ recipient.id }}">
|
||||
@ -153,7 +212,7 @@
|
||||
|
||||
<div class="form-group">
|
||||
<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 class="form-group">
|
||||
|
||||
@ -143,6 +143,23 @@
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@ -192,7 +209,15 @@
|
||||
{{ (msg.sender.name or msg.sender.email)[0].upper() }}
|
||||
</div>
|
||||
<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>
|
||||
<div class="message-meta">
|
||||
|
||||
@ -130,6 +130,23 @@
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@ -177,7 +194,15 @@
|
||||
{{ (msg.recipient.name or msg.recipient.email)[0].upper() }}
|
||||
</div>
|
||||
<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>
|
||||
<div class="message-meta">
|
||||
|
||||
@ -138,6 +138,44 @@
|
||||
margin-top: var(--spacing-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>
|
||||
{% endblock %}
|
||||
|
||||
@ -151,6 +189,16 @@
|
||||
</a>
|
||||
|
||||
<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>
|
||||
<div class="message-subject">{{ message.subject or '(brak tematu)' }}</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user