06/18 Phase 7
This commit is contained in:
@@ -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).")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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"<Review L{self.listing_id} by u{self.author_id} {self.rating}*>"
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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')">☰</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>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}{{ _('Classifieds — Home') }}{% endblock %}
|
{% block title %}{{ _('Classifieds — Buy, Sell & 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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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('"', '"') | 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 <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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user