06/15 Phase 3 codes

This commit is contained in:
2026-06-15 14:19:14 -04:00
parent c2064b84b4
commit 60526262f0
20 changed files with 802 additions and 7 deletions
+11
View File
@@ -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,
}
+2 -2
View File
@@ -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()
+2 -2
View File
@@ -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)
+11
View File
@@ -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"))
+122
View File
@@ -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))
+4 -1
View File
@@ -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"]
+26
View File
@@ -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}>"
+76
View File
@@ -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}>"
+48
View File
@@ -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)))
+33
View File
@@ -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))
+132
View File
@@ -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)
+29
View File
@@ -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}
+5
View File
@@ -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 %}
+14 -1
View File
@@ -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>
+37
View File
@@ -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') }}">&larr; {{ _('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 %}
+34
View File
@@ -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) }}">&larr; {{ _('Prev') }}</a>
{% endif %}
{% if pagination.has_next %}
<a href="{{ url_for('messaging.my_favorites', page=pagination.next_num) }}">{{ _('Next') }} &rarr;</a>
{% endif %}
</div>
</div>
{% endblock %}
+46
View File
@@ -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) }}">&larr; {{ _('Prev') }}</a>
{% endif %}
{% if pagination.has_next %}
<a href="{{ url_for('messaging.inbox', page=pagination.next_num) }}">{{ _('Next') }} &rarr;</a>
{% endif %}
</div>
</div>
{% endblock %}
+19
View File
@@ -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 %}