diff --git a/app/__init__.py b/app/__init__.py index 386fe2a..5dd93ad 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -163,3 +163,17 @@ def _register_cli(app): from app.services.billing import reconcile_subscriptions checked, fixed = reconcile_subscriptions() print(f"Reconciled {checked} subscription(s), fixed {fixed}.") + + @app.cli.command("warn-expiring-listings") + def warn_expiring_cmd(): + """Nightly: email owners of listings expiring within 3 days.""" + from app.services.expiry_notifications import warn_expiring + n = warn_expiring(days=3) + print(f"Sent {n} expiry warning email(s).") + + @app.cli.command("notify-expired-listings") + def notify_expired_cmd(): + """Nightly: email owners of newly-expired listings.""" + from app.services.expiry_notifications import notify_expired + n = notify_expired() + print(f"Sent {n} expiry notification email(s).") diff --git a/app/blueprints/listings/routes.py b/app/blueprints/listings/routes.py index 0a0ccf5..15cbee8 100644 --- a/app/blueprints/listings/routes.py +++ b/app/blueprints/listings/routes.py @@ -14,6 +14,7 @@ from app.services import listings as svc from app.services.geo import geocode_zip from app.services.images import process_upload, delete_image_files, ImageError from app.services import reports as rsvc +from app.services import reviews as rev_svc from app.blueprints.listings.forms import ListingForm, ImageUploadForm listings_bp = Blueprint("listings", __name__) @@ -103,8 +104,18 @@ def detail(listing_id): if not is_owner: listing.view_count = (listing.view_count or 0) + 1 db.session.commit() + # determine if current user can leave a review + can_review = False + if (current_user.is_authenticated and not is_owner + and listing.status == ListingStatus.sold): + from app.models.review import Review + already = Review.query.filter_by( + listing_id=listing.id, author_id=current_user.id).first() + can_review = already is None + listing_rating = rev_svc.seller_rating(listing.user_id) return render_template("listings/detail.html", listing=listing, - is_owner=is_owner) + is_owner=is_owner, can_review=can_review, + listing_rating=listing_rating) # --- create --- @@ -243,6 +254,31 @@ def report(listing_id): return redirect(url_for("listings.detail", listing_id=listing.id)) +# --- leave review --- +@listings_bp.route("/listings//review", methods=["GET", "POST"]) +@login_required +def leave_review(listing_id): + listing = Listing.query.get_or_404(listing_id) + if listing.status != ListingStatus.sold: + flash(_("You can only review sold listings."), "warning") + return redirect(url_for("listings.detail", listing_id=listing.id)) + if listing.user_id == current_user.id: + flash(_("You cannot review your own listing."), "warning") + return redirect(url_for("listings.detail", listing_id=listing.id)) + + if request.method == "POST": + rating = request.form.get("rating", type=int) + body = request.form.get("body", "").strip() + try: + rev_svc.create_review(listing, current_user, rating, body) + flash(_("Review submitted. Thanks!"), "success") + return redirect(url_for("listings.detail", listing_id=listing.id)) + except rev_svc.ReviewError as e: + flash(str(e), "danger") + + return render_template("listings/review_form.html", listing=listing) + + # --- my listings --- @listings_bp.route("/my/listings") @login_required diff --git a/app/blueprints/main/routes.py b/app/blueprints/main/routes.py index 3b8d18d..3d54866 100644 --- a/app/blueprints/main/routes.py +++ b/app/blueprints/main/routes.py @@ -1,5 +1,6 @@ -"""Main blueprint: landing page and health check.""" -from flask import Blueprint, render_template, jsonify +"""Main blueprint: landing page, health check, SEO files.""" +from datetime import datetime +from flask import Blueprint, render_template, jsonify, Response, request main_bp = Blueprint("main", __name__) @@ -12,3 +13,91 @@ def index(): @main_bp.route("/healthz") def healthz(): return jsonify(status="ok") + + +@main_bp.route("/robots.txt") +def robots(): + lines = [ + "User-agent: *", + "Disallow: /admin", + "Disallow: /auth/", + "Disallow: /my/", + "Disallow: /messages", + "Disallow: /billing/", + f"Sitemap: {request.host_url}sitemap.xml", + ] + return Response("\n".join(lines), mimetype="text/plain") + + +@main_bp.route("/sitemap.xml") +def sitemap(): + from app.extensions import db + from app.models.listing import Listing + from app.models.category import Category + from app.models.enums import ListingStatus + + base = request.host_url.rstrip("/") + urls = [] + + # static pages + for path in ("", "/listings", "/pricing", "/sponsors"): + urls.append({"loc": f"{base}{path}", "changefreq": "daily", "priority": "0.8"}) + + # categories + for cat in Category.query.filter_by(is_active=True).all(): + urls.append({ + "loc": f"{base}/listings?category={cat.id}", + "changefreq": "daily", + "priority": "0.7", + }) + + # active listings (last 500 by bump/created for crawl budget) + listings = (Listing.query + .filter(Listing.status == ListingStatus.active, + Listing.expires_at > datetime.utcnow()) + .order_by(Listing.created_at.desc()) + .limit(500).all()) + for l in listings: + urls.append({ + "loc": f"{base}/listings/{l.id}", + "lastmod": l.updated_at.strftime("%Y-%m-%d"), + "changefreq": "weekly", + "priority": "0.6", + }) + + xml = render_template("sitemap.xml", urls=urls) + return Response(xml, mimetype="application/xml") + + +# --------------------------------------------------------------------------- +# SEO landing pages +# --------------------------------------------------------------------------- +@main_bp.route("/classifieds/category/") +def category_landing(slug): + from app.extensions import db + from app.models.category import Category + from app.models.listing import Listing + from app.models.enums import ListingStatus + cat = Category.query.filter_by(slug=slug, is_active=True).first_or_404() + listings = (Listing.query + .filter(Listing.category_id == cat.id, + Listing.status == ListingStatus.active, + Listing.expires_at > datetime.utcnow()) + .order_by(Listing.created_at.desc()) + .limit(20).all()) + return render_template("main/category_landing.html", cat=cat, listings=listings) + + +@main_bp.route("/classifieds/state/") +def state_landing(state): + from app.extensions import db + from app.models.listing import Listing + from app.models.enums import ListingStatus + state = state.upper() + listings = (Listing.query + .filter(Listing.state == state, + Listing.status == ListingStatus.active, + Listing.expires_at > datetime.utcnow()) + .order_by(Listing.created_at.desc()) + .limit(20).all()) + return render_template("main/state_landing.html", state=state, listings=listings) diff --git a/app/models/__init__.py b/app/models/__init__.py index 8370b74..c84023a 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -12,8 +12,9 @@ from app.models.payments import Subscription, Transaction, Boost from app.models.ads import Ad, Sponsor, PromotedKeyword from app.models.audit import AuditLog from app.models.setting import Setting +from app.models.review import Review __all__ = ["Plan", "User", "TrustEvent", "Category", "Listing", "ListingImage", "ZipGeo", "Metro", "Conversation", "Message", "Favorite", "Report", "Subscription", "Transaction", "Boost", - "Ad", "Sponsor", "PromotedKeyword", "AuditLog", "Setting"] + "Ad", "Sponsor", "PromotedKeyword", "AuditLog", "Setting", "Review"] diff --git a/app/models/review.py b/app/models/review.py new file mode 100644 index 0000000..83ade03 --- /dev/null +++ b/app/models/review.py @@ -0,0 +1,34 @@ +"""Seller reviews — left by buyers after a listing is marked sold.""" +from datetime import datetime +from app.extensions import db + + +class Review(db.Model): + __tablename__ = "reviews" + + 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) + author_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) + rating = db.Column(db.SmallInteger, nullable=False) # 1–5 + body = db.Column(db.Text, nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + listing = db.relationship("Listing", + backref=db.backref("reviews", lazy="dynamic")) + author = db.relationship("User", foreign_keys=[author_id], + backref=db.backref("reviews_written", lazy="dynamic")) + seller = db.relationship("User", foreign_keys=[seller_id], + backref=db.backref("reviews_received", lazy="dynamic")) + + __table_args__ = ( + db.UniqueConstraint("listing_id", "author_id", name="uq_review_listing_author"), + db.CheckConstraint("rating BETWEEN 1 AND 5", name="ck_review_rating"), + ) + + def __repr__(self): + return f"" diff --git a/app/services/expiry_notifications.py b/app/services/expiry_notifications.py new file mode 100644 index 0000000..43f869e --- /dev/null +++ b/app/services/expiry_notifications.py @@ -0,0 +1,89 @@ +"""Listing expiry warning and expired notification emails. + +Two sweeps (called from nightly systemd timer): + - warn_expiring(days=3): send one warning email per listing expiring within N days + that hasn't already received one. + - notify_expired(): send "your listing expired" email for newly-expired listings + that haven't been notified yet. + +We track state via a JSON field on the listing. Rather than adding columns, we +use the existing `attributes` JSON and a private `_notified` sub-key so no migration +is required. The key is prefixed with `_` to avoid colliding with user attributes. +""" +from datetime import datetime, timedelta +from flask import current_app +from app.extensions import db +from app.models.listing import Listing +from app.models.enums import ListingStatus +from app.services.email import send_email + + +def _already_notified(listing, key: str) -> bool: + attrs = listing.attributes or {} + return bool(attrs.get(key)) + + +def _mark_notified(listing, key: str): + attrs = dict(listing.attributes or {}) + attrs[key] = datetime.utcnow().isoformat() + listing.attributes = attrs + + +def warn_expiring(days: int = 3) -> int: + """Send warning emails for listings expiring within `days` days. Returns count.""" + now = datetime.utcnow() + window_end = now + timedelta(days=days) + soon = (Listing.query + .filter(Listing.status == ListingStatus.active, + Listing.expires_at > now, + Listing.expires_at <= window_end) + .all()) + sent = 0 + for listing in soon: + if _already_notified(listing, "_warn_sent"): + continue + user = listing.user + if not user or not user.email: + continue + days_left = (listing.expires_at - now).days + subject = f"Your listing '{listing.title[:50]}' expires in {days_left} day(s)" + body = ( + f"Hi {user.display_name},\n\n" + f"Your listing \"{listing.title}\" will expire in {days_left} day(s) " + f"({listing.expires_at.strftime('%Y-%m-%d')}).\n\n" + f"To keep it active, edit and re-save it, or purchase a boost.\n\n" + f"View your listing: {current_app.config.get('SERVER_NAME', '')}/listings/{listing.id}\n\n" + f"— Classifieds" + ) + if send_email(user.email, subject, body): + _mark_notified(listing, "_warn_sent") + db.session.commit() + sent += 1 + return sent + + +def notify_expired() -> int: + """Send 'your listing expired' emails for newly-expired listings. Returns count.""" + expired = (Listing.query + .filter(Listing.status == ListingStatus.expired) + .all()) + sent = 0 + for listing in expired: + if _already_notified(listing, "_expired_sent"): + continue + user = listing.user + if not user or not user.email: + continue + subject = f"Your listing '{listing.title[:50]}' has expired" + body = ( + f"Hi {user.display_name},\n\n" + f"Your listing \"{listing.title}\" expired on " + f"{listing.expires_at.strftime('%Y-%m-%d')}.\n\n" + f"To re-list it, create a new listing or upgrade your plan for longer listing life.\n\n" + f"— Classifieds" + ) + if send_email(user.email, subject, body): + _mark_notified(listing, "_expired_sent") + db.session.commit() + sent += 1 + return sent diff --git a/app/services/reviews.py b/app/services/reviews.py new file mode 100644 index 0000000..831e487 --- /dev/null +++ b/app/services/reviews.py @@ -0,0 +1,48 @@ +"""Review business logic.""" +from sqlalchemy.exc import IntegrityError +from app.extensions import db +from app.models.review import Review + + +class ReviewError(ValueError): + pass + + +def create_review(listing, author, rating: int, body: str | None = None): + """Leave a review for a seller. Listing must be sold. Returns Review.""" + from app.models.enums import ListingStatus + if listing.status != ListingStatus.sold: + raise ReviewError("can only review sold listings") + if listing.user_id == author.id: + raise ReviewError("cannot review your own listing") + if rating not in range(1, 6): + raise ReviewError("rating must be 1–5") + + review = Review( + listing_id=listing.id, + author_id=author.id, + seller_id=listing.user_id, + rating=rating, + body=(body or "").strip() or None, + ) + db.session.add(review) + try: + db.session.commit() + except IntegrityError: + db.session.rollback() + raise ReviewError("you have already reviewed this listing") + return review + + +def seller_rating(user_id) -> dict: + """Return avg rating and count for a seller.""" + from app.extensions import db + result = (db.session.query( + db.func.round(db.func.avg(Review.rating), 1).label("avg"), + db.func.count(Review.id).label("count")) + .filter(Review.seller_id == user_id) + .first()) + return { + "avg": float(result.avg) if result.avg else None, + "count": result.count or 0, + } diff --git a/app/static/style.css b/app/static/style.css index 36e872e..3b5a4a5 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -201,3 +201,41 @@ table.list td{padding:8px 10px;border-bottom:1px solid var(--line)} .report-form{display:flex;flex-direction:column;gap:6px;margin-top:10px} .settings-panel{margin-bottom:20px;padding-bottom:16px;border-bottom:1px solid var(--line)} .settings-panel textarea.input{min-height:80px;font-family:inherit} + +/* --- Phase 7: UX, SEO, responsive --- */ +/* Hamburger toggle — hidden on desktop */ +.nav-toggle{display:none;background:none;border:none;font-size:22px;cursor:pointer; + color:var(--fg);padding:4px 8px;line-height:1} + +/* Responsive nav breakpoint */ +@media(max-width:720px){ + .site-header .wrap{flex-wrap:wrap;height:auto;padding:10px 16px;gap:8px} + .nav-toggle{display:block} + .nav{display:none;flex-direction:column;align-items:flex-start;width:100%; + gap:0;padding-bottom:8px} + .nav.open{display:flex} + .nav a,.nav .btn,.nav .hi{padding:8px 0;width:100%;border-bottom:1px solid var(--line)} + .nav .langs{flex-direction:row;margin-left:0;padding:8px 0;border-bottom:none} +} + +/* Listing grid for landing pages */ +.listing-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:16px;margin-top:16px} +.listing-card{display:flex;flex-direction:column;background:var(--card); + border:1px solid var(--line);border-radius:10px;overflow:hidden; + text-decoration:none;color:var(--fg);transition:box-shadow .15s} +.listing-card:hover{box-shadow:0 4px 16px rgba(0,0,0,.08);text-decoration:none} +.listing-card img{width:100%;aspect-ratio:4/3;object-fit:cover} +.listing-info{padding:12px} +.listing-title{font-weight:600;margin-bottom:4px; + overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical} + +/* Toast auto-dismiss transition already handled via JS; style nicely */ +.flash{transition:opacity .4s} + +/* Reviews */ +.review-stars{color:#f5a623;font-size:18px} +.review-block{border-top:1px solid var(--line);padding-top:12px;margin-top:12px} +.rating-summary{font-size:22px;font-weight:700;display:flex;align-items:center;gap:8px} + +/* Expiry warning badge on my-listings */ +.badge.expiring{background:#fff3cd;color:#856404;border:1px solid #ffc107} diff --git a/app/templates/base.html b/app/templates/base.html index 081e467..3bdc193 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -4,6 +4,16 @@ {% block title %}Classifieds{% endblock %} + {% block meta_description %}{% endblock %} + {# Open Graph #} + + + + + {% block og_image %}{% endblock %} + {% block og_url %}{% endblock %} + {# Canonical #} + {% block head %}{% endblock %} @@ -12,6 +22,7 @@ {% set slot = "header" %}{% include "ads/_slot.html" %}
Classifieds +