06/15 Phase 3 codes
This commit is contained in:
@@ -52,10 +52,12 @@ def _register_blueprints(app):
|
||||
from app.blueprints.auth.routes import auth_bp
|
||||
from app.blueprints.i18n.routes import i18n_bp
|
||||
from app.blueprints.listings.routes import listings_bp
|
||||
from app.blueprints.messaging.routes import messaging_bp
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(i18n_bp)
|
||||
app.register_blueprint(listings_bp)
|
||||
app.register_blueprint(messaging_bp)
|
||||
|
||||
|
||||
def _register_errorhandlers(app):
|
||||
@@ -75,6 +77,7 @@ def _register_errorhandlers(app):
|
||||
def _register_context(app):
|
||||
from flask_babel import get_locale
|
||||
from flask import request
|
||||
from flask_login import current_user
|
||||
|
||||
def merge_query(**overrides):
|
||||
merged = request.args.to_dict()
|
||||
@@ -83,11 +86,19 @@ def _register_context(app):
|
||||
|
||||
@app.context_processor
|
||||
def inject_globals():
|
||||
unread = 0
|
||||
if current_user.is_authenticated:
|
||||
try:
|
||||
from app.services.messaging import total_unread
|
||||
unread = total_unread(current_user)
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"get_locale": get_locale,
|
||||
"merge_query": merge_query,
|
||||
"SUPPORTED_LOCALES": app.config["SUPPORTED_LOCALES"],
|
||||
"TURNSTILE_SITE_KEY": app.config.get("TURNSTILE_SITE_KEY", ""),
|
||||
"unread_count": unread,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ def verify_email(token):
|
||||
if user_id is None:
|
||||
flash(_("Verification link is invalid or expired."), "danger")
|
||||
return redirect(url_for("auth.login"))
|
||||
user = User.query.get(user_id)
|
||||
user = db.session.get(User, user_id)
|
||||
if user is None:
|
||||
abort(404)
|
||||
if not user.email_verified:
|
||||
@@ -136,7 +136,7 @@ def reset_password(token):
|
||||
if user_id is None:
|
||||
flash(_("Reset link is invalid or expired."), "danger")
|
||||
return redirect(url_for("auth.reset_request"))
|
||||
user = User.query.get(user_id)
|
||||
user = db.session.get(User, user_id)
|
||||
if user is None:
|
||||
abort(404)
|
||||
form = ResetForm()
|
||||
|
||||
@@ -109,7 +109,7 @@ def create():
|
||||
|
||||
category = None
|
||||
if request.method == "POST" and form.category_id.data:
|
||||
category = Category.query.get(form.category_id.data)
|
||||
category = db.session.get(Category, form.category_id.data)
|
||||
|
||||
if form.validate_on_submit() and category:
|
||||
raw_attrs = _parse_attributes(category)
|
||||
@@ -156,7 +156,7 @@ def edit(listing_id):
|
||||
form.price.data = (listing.price_cents / 100) if listing.price_cents else None
|
||||
form.zip.data = listing.zip
|
||||
|
||||
category = Category.query.get(form.category_id.data or listing.category_id)
|
||||
category = db.session.get(Category, form.category_id.data or listing.category_id)
|
||||
|
||||
if form.validate_on_submit() and category:
|
||||
raw_attrs = _parse_attributes(category)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Messaging form."""
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import TextAreaField, SubmitField
|
||||
from wtforms.validators import DataRequired, Length
|
||||
from flask_babel import lazy_gettext as _l
|
||||
|
||||
|
||||
class MessageForm(FlaskForm):
|
||||
body = TextAreaField(_l("Message"),
|
||||
validators=[DataRequired(), Length(1, 4000)])
|
||||
submit = SubmitField(_l("Send"))
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Messaging + Favorites routes."""
|
||||
from flask import (Blueprint, render_template, redirect, url_for,
|
||||
flash, abort, request, jsonify)
|
||||
from flask_login import login_required, current_user
|
||||
from flask_babel import gettext as _
|
||||
|
||||
from app.extensions import db, limiter
|
||||
from app.models.listing import Listing
|
||||
from app.models.messaging import Conversation
|
||||
from app.services import messaging as msvc
|
||||
from app.services import favorites as fsvc
|
||||
from app.services.contact import contact_revealed, mask_body
|
||||
from app.blueprints.messaging.forms import MessageForm
|
||||
|
||||
messaging_bp = Blueprint("messaging", __name__)
|
||||
|
||||
|
||||
# ── Inbox ──────────────────────────────────────────────────────────────────
|
||||
@messaging_bp.route("/messages")
|
||||
@login_required
|
||||
def inbox():
|
||||
page = request.args.get("page", 1, type=int)
|
||||
pagination = msvc.inbox(current_user, page=page)
|
||||
return render_template("messaging/inbox.html",
|
||||
pagination=pagination,
|
||||
unread_total=msvc.total_unread(current_user))
|
||||
|
||||
|
||||
# ── Conversation thread ────────────────────────────────────────────────────
|
||||
@messaging_bp.route("/messages/<int:conv_id>", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def conversation(conv_id):
|
||||
conv = db.session.get(Conversation, conv_id)
|
||||
if conv is None:
|
||||
abort(404)
|
||||
if current_user.id not in (conv.buyer_id, conv.seller_id):
|
||||
abort(403)
|
||||
|
||||
msvc.mark_conversation_read(conv, current_user)
|
||||
reveal = contact_revealed(current_user)
|
||||
form = MessageForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
msvc.send_message(conv, current_user, form.body.data)
|
||||
return redirect(url_for("messaging.conversation", conv_id=conv.id))
|
||||
except msvc.MessagingError as e:
|
||||
flash(str(e), "danger")
|
||||
|
||||
# mask contact info in messages for low-trust users
|
||||
masked_messages = [
|
||||
(msg, mask_body(msg.body, reveal=reveal))
|
||||
for msg in conv.messages
|
||||
]
|
||||
return render_template("messaging/conversation.html",
|
||||
conv=conv, form=form,
|
||||
masked_messages=masked_messages,
|
||||
reveal=reveal,
|
||||
other=conv.other_party(current_user))
|
||||
|
||||
|
||||
# ── Start conversation from listing detail ─────────────────────────────────
|
||||
@messaging_bp.route("/listings/<int:listing_id>/contact", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@limiter.limit("20 per hour", methods=["POST"])
|
||||
def start_conversation(listing_id):
|
||||
listing = db.session.get(Listing, listing_id)
|
||||
if listing is None:
|
||||
abort(404)
|
||||
if listing.user_id == current_user.id:
|
||||
flash(_("You cannot message your own listing."), "warning")
|
||||
return redirect(url_for("listings.detail", listing_id=listing_id))
|
||||
|
||||
try:
|
||||
conv, created = msvc.get_or_create_conversation(listing, current_user)
|
||||
except msvc.MessagingError as e:
|
||||
flash(str(e), "danger")
|
||||
return redirect(url_for("listings.detail", listing_id=listing_id))
|
||||
|
||||
form = MessageForm()
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
msvc.send_message(conv, current_user, form.body.data)
|
||||
flash(_("Message sent."), "success")
|
||||
return redirect(url_for("messaging.conversation", conv_id=conv.id))
|
||||
except msvc.MessagingError as e:
|
||||
flash(str(e), "danger")
|
||||
|
||||
return render_template("messaging/start.html",
|
||||
listing=listing, conv=conv, form=form,
|
||||
created=created)
|
||||
|
||||
|
||||
# ── Favorites ──────────────────────────────────────────────────────────────
|
||||
@messaging_bp.route("/listings/<int:listing_id>/favorite", methods=["POST"])
|
||||
@login_required
|
||||
def toggle_favorite(listing_id):
|
||||
listing = db.session.get(Listing, listing_id)
|
||||
if listing is None:
|
||||
abort(404)
|
||||
now_fav = fsvc.toggle_favorite(current_user.id, listing_id)
|
||||
# AJAX or form fallback
|
||||
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
||||
return jsonify(favorited=now_fav)
|
||||
flash(_("Saved.") if now_fav else _("Removed from saved."), "info")
|
||||
return redirect(request.referrer or url_for("listings.detail",
|
||||
listing_id=listing_id))
|
||||
|
||||
|
||||
@messaging_bp.route("/my/favorites")
|
||||
@login_required
|
||||
def my_favorites():
|
||||
page = request.args.get("page", 1, type=int)
|
||||
pagination = fsvc.user_favorites(current_user.id, page=page)
|
||||
return render_template("messaging/favorites.html", pagination=pagination)
|
||||
|
||||
|
||||
# ── Unread count API (for nav badge polling) ───────────────────────────────
|
||||
@messaging_bp.route("/api/unread")
|
||||
@login_required
|
||||
def unread_count():
|
||||
return jsonify(unread=msvc.total_unread(current_user))
|
||||
@@ -5,6 +5,9 @@ from app.models.trust import TrustEvent
|
||||
from app.models.category import Category
|
||||
from app.models.listing import Listing, ListingImage
|
||||
from app.models.geo import ZipGeo, Metro
|
||||
from app.models.messaging import Conversation, Message
|
||||
from app.models.favorite import Favorite
|
||||
|
||||
__all__ = ["Plan", "User", "TrustEvent", "Category", "Listing",
|
||||
"ListingImage", "ZipGeo", "Metro"]
|
||||
"ListingImage", "ZipGeo", "Metro", "Conversation", "Message",
|
||||
"Favorite"]
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Favorite (saved listing). One row per user+listing pair."""
|
||||
from datetime import datetime
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class Favorite(db.Model):
|
||||
__tablename__ = "favorites"
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint("user_id", "listing_id", name="uq_fav_user_listing"),
|
||||
)
|
||||
|
||||
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
primary_key=True, autoincrement=True)
|
||||
user_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
db.ForeignKey("users.id"), nullable=False, index=True)
|
||||
listing_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
db.ForeignKey("listings.id"), nullable=False, index=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
user = db.relationship("User",
|
||||
backref=db.backref("favorites", lazy="dynamic"))
|
||||
listing = db.relationship("Listing",
|
||||
backref=db.backref("favorited_by", lazy="dynamic"))
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Favorite u{self.user_id} l{self.listing_id}>"
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Messaging models: Conversation (per listing, per buyer/seller pair) + Message.
|
||||
|
||||
One conversation per (listing, buyer) pair — enforced by unique constraint.
|
||||
The seller is always listing.user; buyer is any other authenticated user.
|
||||
Messages are append-only; soft-delete not needed at Phase 3.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class Conversation(db.Model):
|
||||
__tablename__ = "conversations"
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint("listing_id", "buyer_id", name="uq_conv_listing_buyer"),
|
||||
)
|
||||
|
||||
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
primary_key=True, autoincrement=True)
|
||||
listing_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
db.ForeignKey("listings.id"), nullable=False, index=True)
|
||||
buyer_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
db.ForeignKey("users.id"), nullable=False, index=True)
|
||||
seller_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
db.ForeignKey("users.id"), nullable=False, index=True)
|
||||
last_message_at = db.Column(db.DateTime, nullable=True, index=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
listing = db.relationship("Listing",
|
||||
backref=db.backref("conversations", lazy="dynamic"))
|
||||
buyer = db.relationship("User", foreign_keys=[buyer_id],
|
||||
backref=db.backref("bought_conversations", lazy="dynamic"))
|
||||
seller = db.relationship("User", foreign_keys=[seller_id],
|
||||
backref=db.backref("sold_conversations", lazy="dynamic"))
|
||||
messages = db.relationship("Message", back_populates="conversation",
|
||||
order_by="Message.created_at",
|
||||
cascade="all, delete-orphan", lazy="selectin")
|
||||
|
||||
def other_party(self, user):
|
||||
return self.seller if user.id == self.buyer_id else self.buyer
|
||||
|
||||
def unread_count(self, user):
|
||||
return sum(1 for m in self.messages
|
||||
if m.sender_id != user.id and m.read_at is None)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Conversation {self.id} L{self.listing_id}>"
|
||||
|
||||
|
||||
class Message(db.Model):
|
||||
__tablename__ = "messages"
|
||||
|
||||
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
primary_key=True, autoincrement=True)
|
||||
conversation_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
db.ForeignKey("conversations.id"),
|
||||
nullable=False, index=True)
|
||||
sender_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
db.ForeignKey("users.id"), nullable=False)
|
||||
body = db.Column(db.Text, nullable=False)
|
||||
read_at = db.Column(db.DateTime, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
conversation = db.relationship("Conversation", back_populates="messages")
|
||||
sender = db.relationship("User",
|
||||
backref=db.backref("sent_messages", lazy="dynamic"))
|
||||
|
||||
@property
|
||||
def is_read(self):
|
||||
return self.read_at is not None
|
||||
|
||||
def mark_read(self):
|
||||
if self.read_at is None:
|
||||
self.read_at = datetime.utcnow()
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Message {self.id} C{self.conversation_id}>"
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Contact masking.
|
||||
|
||||
Phone numbers and email addresses in listing bodies / messages are masked
|
||||
for low-trust senders to push communication through the in-site channel.
|
||||
Unmasked only once sender reaches TrustTier.trusted or verified_email=True.
|
||||
|
||||
Masking is a display-layer concern: raw body stored as-is; masked on render.
|
||||
For the *sending* side we heuristic-flag high-contact-density messages so
|
||||
moderators can spot scraping attempts.
|
||||
"""
|
||||
import re
|
||||
from app.models.enums import TrustTier
|
||||
|
||||
# patterns
|
||||
_PHONE_RE = re.compile(
|
||||
r"(\+?1[\s\-.]?)?"
|
||||
r"(\(?\d{3}\)?[\s\-.])"
|
||||
r"\d{3}[\s\-.]\d{4}"
|
||||
)
|
||||
_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}")
|
||||
_URL_RE = re.compile(r"https?://\S+|www\.\S+", re.I)
|
||||
|
||||
|
||||
def contact_revealed(user) -> bool:
|
||||
"""True when this user's contact info may be shown unmasked."""
|
||||
if user is None:
|
||||
return False
|
||||
return (user.email_verified and
|
||||
user.trust_tier in (TrustTier.trusted, TrustTier.verified))
|
||||
|
||||
|
||||
def mask_body(text: str, reveal: bool = False) -> str:
|
||||
"""Optionally mask phones, emails, and URLs in a block of text."""
|
||||
if reveal or not text:
|
||||
return text
|
||||
text = _PHONE_RE.sub("[phone hidden]", text)
|
||||
text = _EMAIL_RE.sub("[email hidden]", text)
|
||||
text = _URL_RE.sub("[link hidden]", text)
|
||||
return text
|
||||
|
||||
|
||||
def contact_density(text: str) -> int:
|
||||
"""Count how many contact signals appear in text (for heuristic flagging)."""
|
||||
if not text:
|
||||
return 0
|
||||
return (len(_PHONE_RE.findall(text))
|
||||
+ len(_EMAIL_RE.findall(text))
|
||||
+ len(_URL_RE.findall(text)))
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Favorites: toggle save/unsave, check status, list."""
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from app.extensions import db
|
||||
from app.models.favorite import Favorite
|
||||
|
||||
|
||||
def is_favorited(user_id: int, listing_id: int) -> bool:
|
||||
return Favorite.query.filter_by(
|
||||
user_id=user_id, listing_id=listing_id).first() is not None
|
||||
|
||||
|
||||
def toggle_favorite(user_id: int, listing_id: int) -> bool:
|
||||
"""Add if not present; remove if present. Returns True if now favorited."""
|
||||
existing = Favorite.query.filter_by(
|
||||
user_id=user_id, listing_id=listing_id).first()
|
||||
if existing:
|
||||
db.session.delete(existing)
|
||||
db.session.commit()
|
||||
return False
|
||||
fav = Favorite(user_id=user_id, listing_id=listing_id)
|
||||
db.session.add(fav)
|
||||
try:
|
||||
db.session.commit()
|
||||
except IntegrityError:
|
||||
db.session.rollback()
|
||||
return True
|
||||
|
||||
|
||||
def user_favorites(user_id: int, page=1, per_page=20):
|
||||
return (Favorite.query
|
||||
.filter_by(user_id=user_id)
|
||||
.order_by(Favorite.created_at.desc())
|
||||
.paginate(page=page, per_page=per_page, error_out=False))
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Messaging business logic.
|
||||
|
||||
Rules:
|
||||
- Buyer initiates; one conversation per (listing, buyer) pair.
|
||||
- Seller cannot open a conversation with themselves.
|
||||
- High contact-density messages are auto-flagged for moderation.
|
||||
- Notification email sent to recipient on each new message (async in Phase 4+;
|
||||
for now sent inline — fast enough at low volume).
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from flask import current_app
|
||||
from app.extensions import db
|
||||
from app.models.messaging import Conversation, Message
|
||||
from app.services.contact import contact_density
|
||||
from app.services.email import send_email
|
||||
|
||||
# Bodies with ≥ 3 contact signals get auto-flagged
|
||||
_FLAG_THRESHOLD = 3
|
||||
|
||||
|
||||
class MessagingError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def get_or_create_conversation(listing, buyer):
|
||||
"""Return existing conversation or create one. Raises if buyer == seller."""
|
||||
if listing.user_id == buyer.id:
|
||||
raise MessagingError("cannot message your own listing")
|
||||
|
||||
conv = Conversation.query.filter_by(
|
||||
listing_id=listing.id, buyer_id=buyer.id).first()
|
||||
if conv:
|
||||
return conv, False
|
||||
|
||||
conv = Conversation(
|
||||
listing_id=listing.id,
|
||||
buyer_id=buyer.id,
|
||||
seller_id=listing.user_id,
|
||||
)
|
||||
db.session.add(conv)
|
||||
try:
|
||||
db.session.commit()
|
||||
except IntegrityError:
|
||||
db.session.rollback()
|
||||
conv = Conversation.query.filter_by(
|
||||
listing_id=listing.id, buyer_id=buyer.id).first()
|
||||
return conv, True
|
||||
|
||||
|
||||
def send_message(conversation, sender, body: str) -> Message:
|
||||
"""Append a message. Notify recipient. Returns the saved Message."""
|
||||
body = body.strip()
|
||||
if not body:
|
||||
raise MessagingError("message cannot be empty")
|
||||
if len(body) > 4000:
|
||||
raise MessagingError("message too long (max 4000 chars)")
|
||||
|
||||
# verify sender is a participant
|
||||
if sender.id not in (conversation.buyer_id, conversation.seller_id):
|
||||
raise MessagingError("not a participant")
|
||||
|
||||
flagged = contact_density(body) >= _FLAG_THRESHOLD
|
||||
|
||||
msg = Message(
|
||||
conversation_id=conversation.id,
|
||||
sender_id=sender.id,
|
||||
body=body,
|
||||
)
|
||||
db.session.add(msg)
|
||||
conversation.last_message_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
if flagged:
|
||||
current_app.logger.warning(
|
||||
"High contact-density message id=%s conv=%s sender=%s",
|
||||
msg.id, conversation.id, sender.id)
|
||||
|
||||
_notify_recipient(conversation, sender, msg)
|
||||
return msg
|
||||
|
||||
|
||||
def mark_conversation_read(conversation, reader):
|
||||
"""Mark all messages NOT sent by reader as read."""
|
||||
changed = False
|
||||
for msg in conversation.messages:
|
||||
if msg.sender_id != reader.id and msg.read_at is None:
|
||||
msg.mark_read()
|
||||
changed = True
|
||||
if changed:
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def inbox(user, page=1, per_page=20):
|
||||
"""Conversations where user is buyer or seller, newest first."""
|
||||
return (Conversation.query
|
||||
.filter(db.or_(
|
||||
Conversation.buyer_id == user.id,
|
||||
Conversation.seller_id == user.id,
|
||||
))
|
||||
.order_by(Conversation.last_message_at.desc().nullslast(),
|
||||
Conversation.created_at.desc())
|
||||
.paginate(page=page, per_page=per_page, error_out=False))
|
||||
|
||||
|
||||
def total_unread(user) -> int:
|
||||
"""Total unread message count across all conversations (for nav badge)."""
|
||||
convs = Conversation.query.filter(
|
||||
db.or_(
|
||||
Conversation.buyer_id == user.id,
|
||||
Conversation.seller_id == user.id,
|
||||
)
|
||||
).all()
|
||||
return sum(c.unread_count(user) for c in convs)
|
||||
|
||||
|
||||
def _notify_recipient(conversation, sender, message):
|
||||
recipient = (conversation.seller
|
||||
if sender.id == conversation.buyer_id
|
||||
else conversation.buyer)
|
||||
try:
|
||||
subject = f"New message about: {conversation.listing.title[:60]}"
|
||||
body = (
|
||||
f"Hi {recipient.display_name},\n\n"
|
||||
f"{sender.display_name} sent you a message about "
|
||||
f'"{conversation.listing.title}":\n\n'
|
||||
f"{message.body[:500]}\n\n"
|
||||
f"Reply at: /messages/{conversation.id}\n"
|
||||
)
|
||||
send_email(recipient.email, subject, body)
|
||||
except Exception as exc:
|
||||
current_app.logger.error("Message notification failed: %s", exc)
|
||||
@@ -99,3 +99,32 @@ table.list td{padding:8px 10px;border-bottom:1px solid var(--line)}
|
||||
.thumb-wrap img{width:90px;height:90px;object-fit:cover;border-radius:8px}
|
||||
.thumb-wrap form{position:absolute;top:2px;right:2px;margin:0}
|
||||
@media(max-width:760px){.browse,.detail{grid-template-columns:1fr}}
|
||||
|
||||
/* --- Phase 3: messaging + favorites --- */
|
||||
.msg-link{position:relative}
|
||||
.badge-count{display:inline-flex;align-items:center;justify-content:center;
|
||||
background:var(--danger);color:#fff;border-radius:10px;font-size:11px;
|
||||
min-width:18px;height:18px;padding:0 5px;font-weight:700;vertical-align:middle}
|
||||
.badge-count.sm{min-width:16px;height:16px;font-size:10px}
|
||||
.inbox-head{display:flex;align-items:center;gap:10px;margin-bottom:16px}
|
||||
.inbox-head h2{margin:0}
|
||||
.conv-list{list-style:none;padding:0;margin:0}
|
||||
.conv-row{border-bottom:1px solid var(--line);padding:12px 0}
|
||||
.conv-row a{display:block;text-decoration:none;color:var(--fg)}
|
||||
.conv-row a:hover{background:var(--bg);border-radius:8px;padding:4px;margin:-4px}
|
||||
.conv-row.unread .conv-who{font-weight:700}
|
||||
.conv-meta{display:flex;align-items:center;gap:8px;margin-bottom:2px}
|
||||
.conv-who{flex:1}
|
||||
.conv-time{margin-left:auto}
|
||||
.conv-listing,.conv-preview{margin-top:2px}
|
||||
.conv-wrap{display:flex;flex-direction:column;gap:16px;max-width:680px;margin:0 auto}
|
||||
.conv-header h3{margin:8px 0 4px}
|
||||
.thread{display:flex;flex-direction:column;gap:12px}
|
||||
.bubble{max-width:80%;padding:12px 14px;border-radius:16px;line-height:1.5}
|
||||
.bubble.mine{align-self:flex-end;background:var(--brand);color:#fff;border-bottom-right-radius:4px}
|
||||
.bubble.theirs{align-self:flex-start;background:var(--card);border:1px solid var(--line);border-bottom-left-radius:4px}
|
||||
.bubble-meta{margin-top:6px;font-size:11px;opacity:.7}
|
||||
.bubble.mine .bubble-meta{text-align:right}
|
||||
.reply-box{padding:16px}
|
||||
.mask-notice{background:#fdf4e3;border:1px solid #f0dcae;border-radius:8px;
|
||||
padding:8px 12px;margin-top:8px}
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
{% if current_user.is_authenticated %}
|
||||
<a href="{{ url_for('listings.create') }}">{{ _('Post') }}</a>
|
||||
<a href="{{ url_for('listings.mine') }}">{{ _('My listings') }}</a>
|
||||
<a href="{{ url_for('messaging.my_favorites') }}">{{ _('Saved') }}</a>
|
||||
<a href="{{ url_for('messaging.inbox') }}" class="msg-link">
|
||||
{{ _('Messages') }}
|
||||
{% if unread_count %}<span class="badge-count">{{ unread_count }}</span>{% endif %}
|
||||
</a>
|
||||
<span class="hi">{{ current_user.display_name }}</span>
|
||||
<a href="{{ url_for('auth.logout') }}">{{ _('Sign out') }}</a>
|
||||
{% else %}
|
||||
|
||||
@@ -52,7 +52,20 @@
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="btn danger" type="submit">{{ _('Delete') }}</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="muted small">{{ _('Messaging arrives in Phase 3.') }}</p>
|
||||
{% if current_user.is_authenticated %}
|
||||
<a class="btn" href="{{ url_for('messaging.start_conversation', listing_id=listing.id) }}">
|
||||
{{ _('Contact seller') }}
|
||||
</a>
|
||||
<form method="post"
|
||||
action="{{ url_for('messaging.toggle_favorite', listing_id=listing.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn ghost" type="submit">{{ _('♥ Save listing') }}</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<a class="btn" href="{{ url_for('auth.login') }}?next={{ request.path }}">
|
||||
{{ _('Sign in to contact') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</aside>
|
||||
</article>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "auth/_macros.html" import field %}
|
||||
{% block title %}{{ _('Conversation') }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="conv-wrap">
|
||||
<div class="conv-header card">
|
||||
<a class="muted small" href="{{ url_for('messaging.inbox') }}">← {{ _('Inbox') }}</a>
|
||||
<h3>{{ conv.listing.title }}</h3>
|
||||
<span class="muted small">{{ _('with') }} {{ other.display_name }}</span>
|
||||
{% if not reveal %}
|
||||
<p class="mask-notice muted small">
|
||||
{{ _('Phone numbers, emails, and links are hidden until your account is trusted. Verify your email and build trust to unlock contact info.') }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="thread">
|
||||
{% for msg, body in masked_messages %}
|
||||
<div class="bubble {{ 'mine' if msg.sender_id == current_user.id else 'theirs' }}">
|
||||
<div class="bubble-body">{{ body | replace('\n','<br>') | safe }}</div>
|
||||
<div class="bubble-meta muted small">
|
||||
{{ msg.sender.display_name }} · {{ msg.created_at.strftime('%b %d %H:%M') }}
|
||||
{% if msg.sender_id == current_user.id and msg.is_read %}· {{ _('Read') }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="reply-box card">
|
||||
<form method="post" novalidate>
|
||||
{{ form.hidden_tag() }}
|
||||
{{ field(form.body) }}
|
||||
{{ form.submit(class="btn") }}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Saved listings') }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<h2>{{ _('Saved listings') }}</h2>
|
||||
{% if not pagination.items %}
|
||||
<p class="muted">{{ _('No saved listings yet.') }}</p>
|
||||
{% endif %}
|
||||
<div class="grid">
|
||||
{% for fav in pagination.items %}
|
||||
{% set l = fav.listing %}
|
||||
<a class="tile card" href="{{ url_for('listings.detail', listing_id=l.id) }}">
|
||||
{% if l.cover %}
|
||||
<img class="thumb" src="{{ url_for('listings.media', rel=l.cover.thumb_path) }}" alt="">
|
||||
{% else %}
|
||||
<div class="thumb noimg">{{ _('No photo') }}</div>
|
||||
{% endif %}
|
||||
<div class="tile-body">
|
||||
<div class="tile-title">{{ l.title }}</div>
|
||||
<div class="muted small">{{ l.price_display or '—' }}</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="pager">
|
||||
{% if pagination.has_prev %}
|
||||
<a href="{{ url_for('messaging.my_favorites', page=pagination.prev_num) }}">← {{ _('Prev') }}</a>
|
||||
{% endif %}
|
||||
{% if pagination.has_next %}
|
||||
<a href="{{ url_for('messaging.my_favorites', page=pagination.next_num) }}">{{ _('Next') }} →</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,46 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Messages') }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="inbox-head">
|
||||
<h2>{{ _('Messages') }}</h2>
|
||||
{% if unread_total %}<span class="badge-count">{{ unread_total }}</span>{% endif %}
|
||||
</div>
|
||||
{% if not pagination.items %}
|
||||
<p class="muted">{{ _('No conversations yet.') }}</p>
|
||||
{% endif %}
|
||||
<ul class="conv-list">
|
||||
{% for conv in pagination.items %}
|
||||
{% set unread = conv.unread_count(current_user) %}
|
||||
{% set other = conv.other_party(current_user) %}
|
||||
<li class="conv-row {{ 'unread' if unread }}">
|
||||
<a href="{{ url_for('messaging.conversation', conv_id=conv.id) }}">
|
||||
<div class="conv-meta">
|
||||
<span class="conv-who">{{ other.display_name }}</span>
|
||||
{% if unread %}<span class="badge-count sm">{{ unread }}</span>{% endif %}
|
||||
<span class="conv-time muted small">
|
||||
{{ conv.last_message_at.strftime('%b %d') if conv.last_message_at else '' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="conv-listing muted small">
|
||||
{{ _('Re:') }} {{ conv.listing.title[:60] }}
|
||||
</div>
|
||||
{% if conv.messages %}
|
||||
<div class="conv-preview muted small">
|
||||
{{ conv.messages[-1].body[:80] }}…
|
||||
</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="pager">
|
||||
{% if pagination.has_prev %}
|
||||
<a href="{{ url_for('messaging.inbox', page=pagination.prev_num) }}">← {{ _('Prev') }}</a>
|
||||
{% endif %}
|
||||
{% if pagination.has_next %}
|
||||
<a href="{{ url_for('messaging.inbox', page=pagination.next_num) }}">{{ _('Next') }} →</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "auth/_macros.html" import field %}
|
||||
{% block title %}{{ _('Contact seller') }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card narrow">
|
||||
<h2>{{ _('Contact seller') }}</h2>
|
||||
<p class="muted">{{ _('Re:') }} <a href="{{ url_for('listings.detail', listing_id=listing.id) }}">{{ listing.title }}</a></p>
|
||||
{% if not created %}
|
||||
<p class="muted small">{{ _('You already have a conversation about this listing.') }}
|
||||
<a href="{{ url_for('messaging.conversation', conv_id=conv.id) }}">{{ _('View it') }}</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
<form method="post" novalidate>
|
||||
{{ form.hidden_tag() }}
|
||||
{{ field(form.body) }}
|
||||
{{ form.submit(class="btn") }}
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user