06/18 Phase 7

This commit is contained in:
2026-06-18 09:29:33 -04:00
parent d1383a9835
commit d54e5e2127
16 changed files with 563 additions and 8 deletions
+14
View File
@@ -163,3 +163,17 @@ def _register_cli(app):
from app.services.billing import reconcile_subscriptions from app.services.billing import reconcile_subscriptions
checked, fixed = reconcile_subscriptions() checked, fixed = reconcile_subscriptions()
print(f"Reconciled {checked} subscription(s), fixed {fixed}.") 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).")
+37 -1
View File
@@ -14,6 +14,7 @@ from app.services import listings as svc
from app.services.geo import geocode_zip from app.services.geo import geocode_zip
from app.services.images import process_upload, delete_image_files, ImageError from app.services.images import process_upload, delete_image_files, ImageError
from app.services import reports as rsvc from app.services import reports as rsvc
from app.services import reviews as rev_svc
from app.blueprints.listings.forms import ListingForm, ImageUploadForm from app.blueprints.listings.forms import ListingForm, ImageUploadForm
listings_bp = Blueprint("listings", __name__) listings_bp = Blueprint("listings", __name__)
@@ -103,8 +104,18 @@ def detail(listing_id):
if not is_owner: if not is_owner:
listing.view_count = (listing.view_count or 0) + 1 listing.view_count = (listing.view_count or 0) + 1
db.session.commit() 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, 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 --- # --- create ---
@@ -243,6 +254,31 @@ def report(listing_id):
return redirect(url_for("listings.detail", listing_id=listing.id)) return redirect(url_for("listings.detail", listing_id=listing.id))
# --- leave review ---
@listings_bp.route("/listings/<int:listing_id>/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 --- # --- my listings ---
@listings_bp.route("/my/listings") @listings_bp.route("/my/listings")
@login_required @login_required
+91 -2
View File
@@ -1,5 +1,6 @@
"""Main blueprint: landing page and health check.""" """Main blueprint: landing page, health check, SEO files."""
from flask import Blueprint, render_template, jsonify from datetime import datetime
from flask import Blueprint, render_template, jsonify, Response, request
main_bp = Blueprint("main", __name__) main_bp = Blueprint("main", __name__)
@@ -12,3 +13,91 @@ def index():
@main_bp.route("/healthz") @main_bp.route("/healthz")
def healthz(): def healthz():
return jsonify(status="ok") 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/<slug>")
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/<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)
+2 -1
View File
@@ -12,8 +12,9 @@ from app.models.payments import Subscription, Transaction, Boost
from app.models.ads import Ad, Sponsor, PromotedKeyword from app.models.ads import Ad, Sponsor, PromotedKeyword
from app.models.audit import AuditLog from app.models.audit import AuditLog
from app.models.setting import Setting from app.models.setting import Setting
from app.models.review import Review
__all__ = ["Plan", "User", "TrustEvent", "Category", "Listing", __all__ = ["Plan", "User", "TrustEvent", "Category", "Listing",
"ListingImage", "ZipGeo", "Metro", "Conversation", "Message", "ListingImage", "ZipGeo", "Metro", "Conversation", "Message",
"Favorite", "Report", "Subscription", "Transaction", "Boost", "Favorite", "Report", "Subscription", "Transaction", "Boost",
"Ad", "Sponsor", "PromotedKeyword", "AuditLog", "Setting"] "Ad", "Sponsor", "PromotedKeyword", "AuditLog", "Setting", "Review"]
+34
View File
@@ -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) # 15
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"<Review L{self.listing_id} by u{self.author_id} {self.rating}*>"
+89
View File
@@ -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
+48
View File
@@ -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 15")
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,
}
+38
View File
@@ -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} .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{margin-bottom:20px;padding-bottom:16px;border-bottom:1px solid var(--line)}
.settings-panel textarea.input{min-height:80px;font-family:inherit} .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}
+25
View File
@@ -4,6 +4,16 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Classifieds{% endblock %}</title> <title>{% block title %}Classifieds{% endblock %}</title>
{% block meta_description %}<meta name="description" content="Classifieds — buy, sell, find jobs and services in your local Vietnamese and Hispanic community across the US.">{% endblock %}
{# Open Graph #}
<meta property="og:site_name" content="Classifieds">
<meta property="og:title" content="{% block og_title %}Classifieds{% endblock %}">
<meta property="og:description" content="{% block og_description %}Buy, sell, find jobs and services in your local community.{% endblock %}">
<meta property="og:type" content="{% block og_type %}website{% endblock %}">
{% block og_image %}{% endblock %}
{% block og_url %}<meta property="og:url" content="{{ request.url }}">{% endblock %}
{# Canonical #}
<link rel="canonical" href="{{ request.base_url }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>
@@ -12,6 +22,7 @@
{% set slot = "header" %}{% include "ads/_slot.html" %} {% set slot = "header" %}{% include "ads/_slot.html" %}
<div class="wrap"> <div class="wrap">
<a class="brand" href="{{ url_for('main.index') }}">Classifieds</a> <a class="brand" href="{{ url_for('main.index') }}">Classifieds</a>
<button class="nav-toggle" aria-label="{{ _('Menu') }}" onclick="document.querySelector('.nav').classList.toggle('open')">&#9776;</button>
<nav class="nav"> <nav class="nav">
<a href="{{ url_for('listings.browse') }}">{{ _('Browse') }}</a> <a href="{{ url_for('listings.browse') }}">{{ _('Browse') }}</a>
<a href="{{ url_for('payments.pricing') }}">{{ _('Pricing') }}</a> <a href="{{ url_for('payments.pricing') }}">{{ _('Pricing') }}</a>
@@ -60,5 +71,19 @@
{% set slot = "footer" %}{% include "ads/_slot.html" %} {% set slot = "footer" %}{% include "ads/_slot.html" %}
<div class="wrap">© {{ 2025 }} Classifieds · <a href="{{ url_for('ads.sponsor_directory') }}">{{ _('Sponsors') }}</a></div> <div class="wrap">© {{ 2025 }} Classifieds · <a href="{{ url_for('ads.sponsor_directory') }}">{{ _('Sponsors') }}</a></div>
</footer> </footer>
{% block scripts %}{% endblock %}
<script>
// Auto-dismiss flash toasts after 5 s
document.querySelectorAll('.flash').forEach(function(el) {
setTimeout(function(){ el.style.opacity='0'; setTimeout(function(){ el.remove(); }, 400); }, 5000);
el.style.transition = 'opacity .4s';
});
// Close mobile nav on link click
document.querySelectorAll('.nav a').forEach(function(a) {
a.addEventListener('click', function() {
document.querySelector('.nav').classList.remove('open');
});
});
</script>
</body> </body>
</html> </html>
+2 -1
View File
@@ -1,5 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ _('Classifieds — Home') }}{% endblock %} {% block title %}{{ _('Classifieds — Buy, Sell &amp; Find Services') }}{% endblock %}
{% block meta_description %}<meta name="description" content="{{ _('Free classifieds for Vietnamese and Hispanic communities across the US. Buy, sell, find jobs, services, and more.') }}">{% endblock %}
{% block content %} {% block content %}
<section class="hero"> <section class="hero">
<h1>{{ _('Find what you need. Post what you offer.') }}</h1> <h1>{{ _('Find what you need. Post what you offer.') }}</h1>
+7 -1
View File
@@ -1,5 +1,11 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ _('Browse listings') }}{% endblock %} {% block title %}
{% if filters.get('q') %}{{ _('Results for "%(q)s"', q=filters.get('q')) }} — Classifieds
{% elif filters.get('category') %}{{ _('Browse listings') }} — Classifieds
{% else %}{{ _('Browse listings') }} — Classifieds
{% endif %}
{% endblock %}
{% block meta_description %}<meta name="description" content="{{ _('Browse local classifieds — for sale, jobs, services, and more.') }}">{% endblock %}
{% block content %} {% block content %}
<div class="browse"> <div class="browse">
<aside class="filters card"> <aside class="filters card">
+58 -2
View File
@@ -1,5 +1,27 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ listing.title }}{% endblock %} {% block title %}{{ listing.title }} — Classifieds{% endblock %}
{% block meta_description %}<meta name="description" content="{{ (listing.body[:155] | replace('"', '&quot;') | replace('\n', ' ')) }}...">{% endblock %}
{% block og_title %}{{ listing.title }}{% endblock %}
{% block og_description %}{{ listing.body[:200] | replace('\n', ' ') }}{% endblock %}
{% block og_type %}product{% endblock %}
{% block og_image %}
{% if listing.images %}
<meta property="og:image" content="{{ request.host_url }}{{ url_for('listings.media', rel=listing.images[0].path)[1:] }}">
{% endif %}
{% endblock %}
{% block head %}
<script type="application/ld+json">
{
"@context": "https://schema.org/",
"@type": "Product",
"name": {{ listing.title | tojson }},
"description": {{ (listing.body[:500] | replace('\n', ' ')) | tojson }},
{% if listing.images %}"image": "{{ request.host_url }}{{ url_for('listings.media', rel=listing.images[0].path)[1:] }}",{% endif %}
{% if listing.price_cents %}"offers": {"@type": "Offer", "price": "{{ '%.2f'|format(listing.price_cents/100) }}", "priceCurrency": "USD"},{% endif %}
"seller": {"@type": "Person", "name": {{ listing.user.display_name | tojson }}}
}
</script>
{% endblock %}
{% block content %} {% block content %}
<article class="detail"> <article class="detail">
<div class="detail-main card"> <div class="detail-main card">
@@ -17,7 +39,7 @@
{% if listing.images %} {% if listing.images %}
<div class="gallery"> <div class="gallery">
{% for img in listing.images %} {% for img in listing.images %}
<img src="{{ url_for('listings.media', rel=img.path) }}" alt=""> <img src="{{ url_for('listings.media', rel=img.path) }}" alt="{{ listing.title }}" loading="{{ 'eager' if loop.first else 'lazy' }}">
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
@@ -39,6 +61,12 @@
<div class="seller"> <div class="seller">
<strong>{{ listing.user.display_name }}</strong> <strong>{{ listing.user.display_name }}</strong>
{% if listing.user.verified %}<span class="badge ok">{{ _('Verified') }}</span>{% endif %} {% if listing.user.verified %}<span class="badge ok">{{ _('Verified') }}</span>{% endif %}
{% if listing_rating and listing_rating.avg %}
<div class="review-stars" style="font-size:14px">
{{ '★' * (listing_rating.avg | round | int) }}{{ '☆' * (5 - (listing_rating.avg | round | int)) }}
<span class="muted" style="font-size:12px">({{ listing_rating.count }})</span>
</div>
{% endif %}
</div> </div>
{% if is_owner %} {% if is_owner %}
<a class="btn" href="{{ url_for('listings.edit', listing_id=listing.id) }}">{{ _('Edit') }}</a> <a class="btn" href="{{ url_for('listings.edit', listing_id=listing.id) }}">{{ _('Edit') }}</a>
@@ -78,7 +106,35 @@
{{ _('Sign in to contact') }} {{ _('Sign in to contact') }}
</a> </a>
{% endif %} {% endif %}
{# Leave-review button for sold listings when viewer had a conversation #}
{% if current_user.is_authenticated and listing.status.value == 'sold'
and not is_owner and can_review %}
<a class="btn ghost" href="{{ url_for('listings.leave_review', listing_id=listing.id) }}">
{{ _('Leave a review') }}
</a>
{% endif %}
{% endif %} {% endif %}
</aside> </aside>
</article> </article>
{# Reviews section #}
{% if listing.reviews.count() %}
<section class="card" style="margin-top:16px">
<h3>{{ _('Reviews') }} ({{ listing.reviews.count() }})</h3>
{% set rating_info = listing_rating %}
{% if rating_info and rating_info.avg %}
<div class="rating-summary">
<span class="review-stars">{{ '★' * (rating_info.avg | round | int) }}{{ '☆' * (5 - (rating_info.avg | round | int)) }}</span>
{{ '%.1f'|format(rating_info.avg) }} / 5 &nbsp;<span class="muted small">({{ rating_info.count }})</span>
</div>
{% endif %}
{% for review in listing.reviews.order_by(none).all() %}
<div class="review-block">
<div class="review-stars">{{ '★' * review.rating }}{{ '☆' * (5 - review.rating) }}</div>
<div class="muted small">{{ review.author.display_name }} · {{ review.created_at.strftime('%Y-%m-%d') }}</div>
{% if review.body %}<p>{{ review.body }}</p>{% endif %}
</div>
{% endfor %}
</section>
{% endif %}
{% endblock %} {% endblock %}
+32
View File
@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}{{ _('Leave a review') }} — {{ listing.title }}{% endblock %}
{% block content %}
<div class="card narrow">
<h2>{{ _('Leave a review for "%(t)s"', t=listing.title) }}</h2>
<p class="muted">{{ _('Sold by %(name)s', name=listing.user.display_name) }}</p>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="field">
<label>{{ _('Rating') }}</label>
<div class="rating-picker">
{% for v in [5, 4, 3, 2, 1] %}
<label>
<input type="radio" name="rating" value="{{ v }}" required>
{{ '★' * v }}{{ '☆' * (5 - v) }}
</label>
{% endfor %}
</div>
</div>
<div class="field">
<label>{{ _('Comment (optional)') }}</label>
<textarea class="input" name="body" rows="4" maxlength="1000"></textarea>
</div>
<button class="btn" type="submit">{{ _('Submit review') }}</button>
<a class="btn ghost" href="{{ url_for('listings.detail', listing_id=listing.id) }}">{{ _('Cancel') }}</a>
</form>
</div>
{% endblock %}
+43
View File
@@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block title %}{{ cat.name }} {{ _('classifieds') }} — Classifieds{% endblock %}
{% block meta_description %}<meta name="description" content="{{ _('Browse %(cat)s listings near you — buy, sell, and connect with your local community.', cat=cat.name) }}">{% endblock %}
{% block og_title %}{{ cat.name }} {{ _('classifieds') }}{% endblock %}
{% block content %}
<div class="card">
<h1>{{ cat.name }} {{ _('listings') }}</h1>
<p class="muted">
{{ _('%(n)s active listings', n=listings|length) }} ·
<a href="{{ url_for('listings.browse', category=cat.id) }}">{{ _('See all with filters') }}</a>
</p>
{% if not listings %}
<p class="muted">{{ _('No listings yet in this category.') }}</p>
{% else %}
<div class="listing-grid">
{% for l in listings %}
<a class="listing-card" href="{{ url_for('listings.detail', listing_id=l.id) }}">
{% if l.images %}
<img src="{{ url_for('listings.media', rel=l.images[0].thumb_path) }}"
alt="{{ l.title }}" loading="lazy">
{% endif %}
<div class="listing-info">
<div class="listing-title">{{ l.title }}</div>
{% if l.price_display %}<div class="price">{{ l.price_display }}</div>{% endif %}
<div class="muted small">{{ l.city or '' }}{% if l.city and l.state %}, {% endif %}{{ l.state or '' }}</div>
</div>
</a>
{% endfor %}
</div>
{% endif %}
{% if cat.children %}
<h2>{{ _('Subcategories') }}</h2>
<ul>
{% for sub in cat.children %}
{% if sub.is_active %}
<li><a href="{{ url_for('listings.browse', category=sub.id) }}">{{ sub.name }}</a></li>
{% endif %}
{% endfor %}
</ul>
{% endif %}
</div>
{% endblock %}
+32
View File
@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}{{ state }} {{ _('classifieds') }} — Classifieds{% endblock %}
{% block meta_description %}<meta name="description" content="{{ _('Browse local classifieds in %(state)s — buy, sell, find jobs and services.', state=state) }}">{% endblock %}
{% block og_title %}{{ state }} {{ _('classifieds') }}{% endblock %}
{% block content %}
<div class="card">
<h1>{{ state }} {{ _('listings') }}</h1>
<p class="muted">
{{ _('%(n)s active listings', n=listings|length) }} ·
<a href="{{ url_for('listings.browse', state=state) }}">{{ _('See all with filters') }}</a>
</p>
{% if not listings %}
<p class="muted">{{ _('No listings in %(state)s yet.', state=state) }}</p>
{% else %}
<div class="listing-grid">
{% for l in listings %}
<a class="listing-card" href="{{ url_for('listings.detail', listing_id=l.id) }}">
{% if l.images %}
<img src="{{ url_for('listings.media', rel=l.images[0].thumb_path) }}"
alt="{{ l.title }}" loading="lazy">
{% endif %}
<div class="listing-info">
<div class="listing-title">{{ l.title }}</div>
{% if l.price_display %}<div class="price">{{ l.price_display }}</div>{% endif %}
<div class="muted small">{{ l.city or '' }}{% if l.city and l.state %}, {% endif %}{{ l.state or '' }}</div>
</div>
</a>
{% endfor %}
</div>
{% endif %}
</div>
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
{% for u in urls %}
<url>
<loc>{{ u.loc }}</loc>
{% if u.get('lastmod') %}<lastmod>{{ u.lastmod }}</lastmod>{% endif %}
<changefreq>{{ u.changefreq }}</changefreq>
<priority>{{ u.priority }}</priority>
</url>
{% endfor %}
</urlset>