Compare commits
10
Commits
68a842d6b6
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b17e4bb209 | ||
|
|
9ba7595920 | ||
|
|
505158b2e0 | ||
|
|
a5c800e87c | ||
|
|
00a9ba7770 | ||
|
|
936d860342 | ||
|
|
61d6d33242 | ||
|
|
ae544af6e1 | ||
|
|
d54e5e2127 | ||
|
|
d1383a9835 |
@@ -4,5 +4,6 @@ __pycache__/
|
||||
.env
|
||||
dev.db
|
||||
*.mo
|
||||
!app/translations/**/*.mo
|
||||
instance/
|
||||
.DS_Store
|
||||
|
||||
@@ -42,7 +42,7 @@ Each category has subcategories and a JSON-driven `field_schema`
|
||||
| Reverse proxy / static | Nginx |
|
||||
| Process manager | systemd |
|
||||
| OS | Ubuntu 22.04 Server |
|
||||
| Background jobs | APScheduler or RQ (expiry sweep, email, image processing) |
|
||||
| Background jobs | systemd timers (expiry/boost/keyword sweeps). RQ planned — not yet installed |
|
||||
| Payments | Stripe (Billing + Payment Intents + Tax + Customer Portal) |
|
||||
| Email | SMTP relay via Brevo (smart relay, existing infra) |
|
||||
| i18n | Flask-Babel |
|
||||
@@ -102,7 +102,7 @@ Nginx
|
||||
- `classifieds.service` — Gunicorn web process
|
||||
- `classifieds-expire.service` + `classifieds-expire.timer` — hourly: listing expiry
|
||||
- `classifieds-nightly.service` + `classifieds-nightly.timer` — 2am: boost expiry +
|
||||
promoted keyword cleanup + subscription reconcile
|
||||
promoted keyword cleanup + subscription reconcile + expiry warning/expired emails
|
||||
|
||||
---
|
||||
|
||||
@@ -127,7 +127,7 @@ classifieds/
|
||||
│ ├── models/
|
||||
│ │ ├── __init__.py # Exports all models (import order matters)
|
||||
│ │ ├── enums.py # Role, UserStatus, TrustTier, TrustEventType,
|
||||
│ │ │ # ListingStatus, Lang
|
||||
│ │ │ # ListingStatus, Lang, ReportReason
|
||||
│ │ ├── plan.py # Plan (tier config JSON)
|
||||
│ │ ├── user.py # User (Argon2, RBAC helpers, Flask-Login)
|
||||
│ │ ├── trust.py # TrustEvent (append-only trust ledger)
|
||||
@@ -137,16 +137,26 @@ classifieds/
|
||||
│ │ ├── messaging.py # Conversation + Message
|
||||
│ │ ├── favorite.py # Favorite
|
||||
│ │ ├── payments.py # Subscription, Transaction, Boost
|
||||
│ │ └── ads.py # Ad, Sponsor, PromotedKeyword
|
||||
│ │ ├── ads.py # Ad, Sponsor, PromotedKeyword
|
||||
│ │ ├── report.py # Report (user-submitted listing reports)
|
||||
│ │ ├── audit.py # AuditLog (append-only admin action trail)
|
||||
│ │ ├── setting.py # Setting (key/JSON-value admin runtime config)
|
||||
│ │ └── review.py # Review (1–5 star seller reviews, unique per listing+author)
|
||||
│ │
|
||||
│ ├── blueprints/
|
||||
│ │ ├── auth/ # register, login, logout, verify-email, reset
|
||||
│ │ ├── main/ # index, /healthz
|
||||
│ │ ├── main/ # index, /healthz, /robots.txt, /sitemap.xml,
|
||||
│ │ │ # /classifieds/category/<slug>, /classifieds/state/<state>
|
||||
│ │ ├── i18n/ # /lang/<code> locale switcher
|
||||
│ │ ├── listings/ # browse+promoted, detail, create, edit, delete, images
|
||||
│ │ ├── listings/ # browse+promoted, detail, create, edit, delete, images,
|
||||
│ │ │ # report, review
|
||||
│ │ ├── messaging/ # inbox, conversation, start, favorites, /api/unread
|
||||
│ │ ├── payments/ # pricing, checkout, portal, webhook, boost, billing
|
||||
│ │ └── ads/ # click tracking, sponsor directory, inject_ads()
|
||||
│ │ ├── ads/ # click tracking, sponsor directory, inject_ads()
|
||||
│ │ └── admin/ # full admin backend (dashboard, users, listings,
|
||||
│ │ # reports, categories, plans, ads, sponsors,
|
||||
│ │ # promoted-keywords, transactions, analytics,
|
||||
│ │ # audit log, settings)
|
||||
│ │
|
||||
│ ├── services/
|
||||
│ │ ├── email.py # send_email (SMTP or dev console)
|
||||
@@ -155,34 +165,54 @@ classifieds/
|
||||
│ │ ├── geo.py # geocode_zip, haversine_mi, bounding_box
|
||||
│ │ ├── field_schema.py # validate_attributes, hot_values
|
||||
│ │ ├── images.py # process_upload, delete_image_files
|
||||
│ │ ├── listings.py # create/update/browse/search/radius/expiry
|
||||
│ │ ├── listings.py # create/update/browse/search/radius/expiry +
|
||||
│ │ │ # keyword blocklist enforcement
|
||||
│ │ ├── messaging.py # conversations, send, mark-read, inbox, unread
|
||||
│ │ ├── favorites.py # toggle, is_favorited, user_favorites
|
||||
│ │ ├── contact.py # mask_body, contact_revealed, contact_density
|
||||
│ │ ├── billing.py # Stripe checkout, portal, webhooks, sync, reconcile
|
||||
│ │ └── ads.py # get_ad, record_impression/click, promoted_listings,
|
||||
│ │ # active_sponsors, expire_promoted_keywords
|
||||
│ │ ├── ads.py # get_ad, record_impression/click, promoted_listings,
|
||||
│ │ │ # active_sponsors, expire_promoted_keywords
|
||||
│ │ ├── reports.py # create_report (auto-flag at threshold), ReportError
|
||||
│ │ ├── moderation.py # flag_queue, approve, hide, remove (+ audit_log)
|
||||
│ │ ├── audit.py # log_action — append AuditLog row, caller commits
|
||||
│ │ ├── settings.py # get_setting, set_setting (runtime config from DB)
|
||||
│ │ ├── admin_dashboard.py # KPI queries: active_listings, new_users, mrr,
|
||||
│ │ │ # revenue_30d, flag_queue_depth
|
||||
│ │ ├── admin_users.py # search_query, set_status, set_tier, adjust_trust
|
||||
│ │ ├── expiry_notifications.py # warn_expiring(days), notify_expired — email sweeps
|
||||
│ │ └── reviews.py # create_review, seller_rating
|
||||
│ │
|
||||
│ ├── utils/
|
||||
│ │ ├── __init__.py # RBAC decorators: role_required, admin_required,
|
||||
│ │ │ # moderator_required
|
||||
│ │ ├── security.py # hash_password, verify_password, generate_token,
|
||||
│ │ │ # read_token (itsdangerous)
|
||||
│ │ └── text.py # normalize() — accent-insensitive (phở→pho, ñ→n)
|
||||
│ │ ├── text.py # normalize() — accent-insensitive (phở→pho, ñ→n)
|
||||
│ │ └── time.py # utcnow() — naive-UTC helper (replaces datetime.utcnow)
|
||||
│ │
|
||||
│ ├── templates/
|
||||
│ │ ├── base.html # Layout: nav + header/footer ad slots + lang switcher
|
||||
│ │ ├── base.html # Layout: responsive nav, OG/meta blocks, toast JS,
|
||||
│ │ │ # header/footer ad slots, lang switcher
|
||||
│ │ ├── index.html # Landing page
|
||||
│ │ ├── sitemap.xml # XML sitemap template
|
||||
│ │ ├── auth/ # login, register, reset_request, reset, _macros
|
||||
│ │ ├── listings/ # browse (sidebar+inline ads), detail, form, mine
|
||||
│ │ ├── listings/ # browse, detail (JSON-LD, reviews), form, mine,
|
||||
│ │ │ # review_form
|
||||
│ │ ├── main/ # category_landing.html, state_landing.html
|
||||
│ │ ├── messaging/ # inbox, conversation, start, favorites
|
||||
│ │ ├── payments/ # pricing, billing, boost, success
|
||||
│ │ ├── ads/ # _slot.html (reusable ad slot partial)
|
||||
│ │ ├── sponsors/ # directory.html
|
||||
│ │ └── errors/ # 403, 404, 500
|
||||
│ │ ├── admin/ # dashboard, users, user_detail, listings, reports,
|
||||
│ │ │ # categories, category_schema, plans, plan_edit,
|
||||
│ │ │ # ads, ad_edit, sponsors, sponsor_edit,
|
||||
│ │ │ # promoted_keywords, promoted_keyword_new,
|
||||
│ │ │ # transactions, analytics, audit, settings, _nav
|
||||
│ │ └── errors/ # 403, 404, 500, maintenance
|
||||
│ │
|
||||
│ ├── static/
|
||||
│ │ └── style.css # Single CSS file (Phase 1–5 accumulated)
|
||||
│ │ └── style.css # Single CSS file (Phase 1–7 accumulated)
|
||||
│ │
|
||||
│ └── translations/ # Flask-Babel .po/.mo for vi + es
|
||||
│
|
||||
@@ -192,23 +222,28 @@ classifieds/
|
||||
│ ├── classifieds.service # systemd: Gunicorn web
|
||||
│ ├── classifieds-expire.service # systemd: oneshot listing expiry
|
||||
│ ├── classifieds-expire.timer # systemd: hourly
|
||||
│ ├── classifieds-nightly.service # systemd: boost expiry + keyword cleanup + reconcile
|
||||
│ ├── classifieds-nightly.service # systemd: boost expiry + keyword cleanup +
|
||||
│ │ # subscription reconcile + expiry emails
|
||||
│ ├── classifieds-nightly.timer # systemd: 2am daily
|
||||
│ ├── gunicorn.conf.py # workers, socket, timeouts
|
||||
│ └── nginx.conf.sample # HTTPS redirect + proxy + static + media
|
||||
│
|
||||
└── tests/
|
||||
└── test_smoke.py # 58-check integration test (SQLite + in-memory Redis)
|
||||
└── test_smoke.py # Integration test (SQLite + in-memory Redis)
|
||||
# isolated temp media dir; all Phase 1–6 checks green
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Route Map (34 routes, Phase 1–5)
|
||||
## 5. Route Map (70+ routes, Phase 1–7)
|
||||
|
||||
### Public & Auth (Phase 1)
|
||||
| Method | Path | Blueprint | Auth |
|
||||
|---|---|---|---|
|
||||
| GET | `/` | main | — |
|
||||
| GET | `/healthz` | main | — |
|
||||
| GET | `/robots.txt` | main | — |
|
||||
| GET | `/sitemap.xml` | main | — |
|
||||
| GET/POST | `/auth/register` | auth | — |
|
||||
| GET/POST | `/auth/login` | auth | — |
|
||||
| GET | `/auth/logout` | auth | login |
|
||||
@@ -216,6 +251,16 @@ classifieds/
|
||||
| GET/POST | `/auth/reset` | auth | — |
|
||||
| GET/POST | `/auth/reset/<token>` | auth | — |
|
||||
| GET | `/lang/<code>` | i18n | — |
|
||||
|
||||
### SEO Landing Pages (Phase 7)
|
||||
| Method | Path | Blueprint | Auth |
|
||||
|---|---|---|---|
|
||||
| GET | `/classifieds/category/<slug>` | main | — |
|
||||
| GET | `/classifieds/state/<state>` | main | — |
|
||||
|
||||
### Listings (Phase 2–7)
|
||||
| Method | Path | Blueprint | Auth |
|
||||
|---|---|---|---|
|
||||
| GET | `/listings` | listings | — |
|
||||
| GET | `/listings/new` | listings | login |
|
||||
| GET | `/listings/<id>` | listings | — |
|
||||
@@ -223,25 +268,79 @@ classifieds/
|
||||
| POST | `/listings/<id>/delete` | listings | login+owner |
|
||||
| POST | `/listings/<id>/sold` | listings | login+owner |
|
||||
| POST | `/listings/<id>/images/<img_id>/delete` | listings | login+owner |
|
||||
| GET/POST | `/listings/<id>/contact` | messaging | login |
|
||||
| POST | `/listings/<id>/favorite` | messaging | login |
|
||||
| GET/POST | `/listings/<id>/boost` | payments | login+owner |
|
||||
| GET | `/listings/<id>/boost/success` | payments | login |
|
||||
| POST | `/listings/<id>/report` | listings | login |
|
||||
| GET/POST | `/listings/<id>/review` | listings | login |
|
||||
| GET | `/media/<path>` | listings | — |
|
||||
| GET | `/my/listings` | listings | login |
|
||||
|
||||
### Messaging & Social (Phase 3)
|
||||
| Method | Path | Blueprint | Auth |
|
||||
|---|---|---|---|
|
||||
| GET/POST | `/listings/<id>/contact` | messaging | login |
|
||||
| POST | `/listings/<id>/favorite` | messaging | login |
|
||||
| GET | `/my/favorites` | messaging | login |
|
||||
| GET | `/my/billing` | payments | login |
|
||||
| GET | `/messages` | messaging | login |
|
||||
| GET/POST | `/messages/<id>` | messaging | login+participant |
|
||||
| GET | `/api/unread` | messaging | login |
|
||||
|
||||
### Payments (Phase 4)
|
||||
| Method | Path | Blueprint | Auth |
|
||||
|---|---|---|---|
|
||||
| GET/POST | `/listings/<id>/boost` | payments | login+owner |
|
||||
| GET | `/listings/<id>/boost/success` | payments | login |
|
||||
| GET | `/my/billing` | payments | login |
|
||||
| GET | `/pricing` | payments | — |
|
||||
| GET | `/billing/subscribe/<slug>` | payments | login |
|
||||
| GET | `/billing/portal` | payments | login |
|
||||
| GET | `/billing/success` | payments | login |
|
||||
| POST | `/billing/webhook` | payments | CSRF-exempt |
|
||||
|
||||
### Ads & Sponsors (Phase 5)
|
||||
| Method | Path | Blueprint | Auth |
|
||||
|---|---|---|---|
|
||||
| GET | `/ads/<id>/click` | ads | — |
|
||||
| GET | `/sponsors` | ads | — |
|
||||
|
||||
### Admin (Phase 6)
|
||||
| Method | Path | Blueprint | Auth |
|
||||
|---|---|---|---|
|
||||
| GET | `/admin` | admin | admin |
|
||||
| GET | `/admin/users` | admin | admin |
|
||||
| GET | `/admin/users/<id>` | admin | admin |
|
||||
| POST | `/admin/users/<id>/status` | admin | admin |
|
||||
| POST | `/admin/users/<id>/tier` | admin | admin |
|
||||
| POST | `/admin/users/<id>/trust` | admin | admin |
|
||||
| POST | `/admin/users/<id>/impersonate` | admin | admin |
|
||||
| GET | `/admin/listings` | admin | admin/mod |
|
||||
| POST | `/admin/listings/<id>/approve` | admin | admin/mod |
|
||||
| POST | `/admin/listings/<id>/hide` | admin | admin/mod |
|
||||
| POST | `/admin/listings/<id>/remove` | admin | admin/mod |
|
||||
| GET | `/admin/reports` | admin | admin/mod |
|
||||
| POST | `/admin/reports/<id>/resolve` | admin | admin/mod |
|
||||
| GET | `/admin/categories` | admin | admin |
|
||||
| GET/POST | `/admin/categories/new` | admin | admin |
|
||||
| GET/POST | `/admin/categories/<id>/edit` | admin | admin |
|
||||
| GET/POST | `/admin/categories/<id>/schema` | admin | admin |
|
||||
| GET | `/admin/plans` | admin | admin |
|
||||
| GET/POST | `/admin/plans/<id>/edit` | admin | admin |
|
||||
| GET | `/admin/ads` | admin | admin |
|
||||
| GET/POST | `/admin/ads/new` | admin | admin |
|
||||
| GET/POST | `/admin/ads/<id>/edit` | admin | admin |
|
||||
| POST | `/admin/ads/<id>/delete` | admin | admin |
|
||||
| POST | `/admin/ads/<id>/toggle` | admin | admin |
|
||||
| GET | `/admin/sponsors` | admin | admin |
|
||||
| GET/POST | `/admin/sponsors/new` | admin | admin |
|
||||
| GET/POST | `/admin/sponsors/<id>/edit` | admin | admin |
|
||||
| POST | `/admin/sponsors/<id>/delete` | admin | admin |
|
||||
| GET | `/admin/promoted-keywords` | admin | admin |
|
||||
| GET/POST | `/admin/promoted-keywords/new` | admin | admin |
|
||||
| POST | `/admin/promoted-keywords/<id>/delete` | admin | admin |
|
||||
| GET | `/admin/transactions` | admin | admin |
|
||||
| POST | `/admin/transactions/<id>/refund` | admin | admin |
|
||||
| GET | `/admin/analytics` | admin | admin |
|
||||
| GET | `/admin/audit` | admin | admin |
|
||||
| GET/POST | `/admin/settings` | admin | admin |
|
||||
|
||||
---
|
||||
|
||||
## 6. User Roles & RBAC
|
||||
@@ -405,10 +504,22 @@ audit_log id, actor_id→users, action, target_type, target_id,
|
||||
|
||||
settings key PK, value JSON
|
||||
(registration_open, ads_enabled, maintenance_mode,
|
||||
flag_threshold, new_user_trust_gate_days, etc.)
|
||||
flag_threshold, keyword_blocklist, new_user_trust_gate_days,
|
||||
contact_density_threshold)
|
||||
```
|
||||
|
||||
### Reviews (Phase 7)
|
||||
```
|
||||
reviews id, listing_id→listings, author_id→users, seller_id→users,
|
||||
rating SMALLINT (1–5, CHECK CONSTRAINT),
|
||||
body TEXT nullable, created_at
|
||||
UNIQUE(listing_id, author_id)
|
||||
```
|
||||
|
||||
### Deferred
|
||||
```
|
||||
listing_translations id, listing_id→listings, lang, title, body, cached_at
|
||||
(Phase 7: auto-translate cache)
|
||||
(Phase 7: auto-translate cache — not yet implemented)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -486,6 +597,49 @@ listing_translations id, listing_id→listings, lang, title, body, cached_at
|
||||
- `active_sponsors(tier, category_id)` — running sponsors filtered by tier/category
|
||||
- `expire_promoted_keywords()` — removes expired rows; nightly timer
|
||||
|
||||
### `services/reports.py` (Phase 6)
|
||||
- `create_report(listing, reporter, reason, note)` — writes Report, auto-flags listing
|
||||
when distinct reporter count ≥ `flag_threshold` setting (default 5)
|
||||
- `ReportError` — raised for duplicate reports or self-reports
|
||||
|
||||
### `services/moderation.py` (Phase 6)
|
||||
- `flag_queue(page, per_page)` — listings sorted by `flag_count × recency`
|
||||
- `approve(listing, actor)` — clears flags, sets active, logs to audit_log
|
||||
- `hide(listing, actor)` — sets flagged status, logs
|
||||
- `remove(listing, actor)` — sets removed status, logs
|
||||
|
||||
### `services/audit.py` (Phase 6)
|
||||
- `log_action(actor, action, target_type, target_id, meta)` — appends AuditLog row
|
||||
|
||||
### `services/settings.py` (Phase 6)
|
||||
- `get_setting(key, default)` — reads from `settings` table, cached in app context
|
||||
- `set_setting(key, value)` — upserts, invalidates cache
|
||||
- Keys: `registration_open`, `ads_enabled`, `maintenance_mode`, `flag_threshold`,
|
||||
`keyword_blocklist` (list), `new_user_trust_gate_days`, `contact_density_threshold`
|
||||
|
||||
### `services/admin_dashboard.py` (Phase 6)
|
||||
- `kpis()` → dict with `active_listings`, `new_users_7d`, `new_users_30d`,
|
||||
`mrr_cents`, `revenue_30d_cents`, `flag_queue_depth`
|
||||
- `signups_per_day(days)`, `listings_per_day(days)`, `revenue_per_day(days)` → lists
|
||||
- `top_categories(n)` — by active listing count
|
||||
|
||||
### `services/admin_users.py` (Phase 6)
|
||||
- `search_query(q, role, status, tier)` — filtered User query
|
||||
- `set_status(user, status, actor)` — ban/suspend/activate + audit log
|
||||
- `set_tier(user, plan, actor)` — tier override + audit log
|
||||
- `adjust_trust(user, delta, actor)` — adds trust event, recomputes tier + audit log
|
||||
|
||||
### `services/expiry_notifications.py` (Phase 7)
|
||||
- `warn_expiring(days=3)` — emails listing owners whose listings expire within N days;
|
||||
marks `_warn_sent` in `attributes` JSON to prevent duplicates
|
||||
- `notify_expired()` — emails owners of newly-expired listings;
|
||||
marks `_expired_sent` in `attributes` JSON; both are CLI commands via `__init__.py`
|
||||
|
||||
### `services/reviews.py` (Phase 7)
|
||||
- `create_review(listing, author, rating, body)` — validates sold status + not-self;
|
||||
catches `IntegrityError` for duplicate review
|
||||
- `seller_rating(user_id)` → `{"avg": float|None, "count": int}`
|
||||
|
||||
---
|
||||
|
||||
## 11. i18n (Trilingual: EN / VI / ES)
|
||||
@@ -557,6 +711,9 @@ Phase 6: flag threshold → auto-flip, keyword blocklist, reports queue, audit_l
|
||||
- Contact masking for low-trust users
|
||||
- Signed time-limited tokens for email verify + password reset
|
||||
- ProxyFix for correct `is_secure` + client-IP behind Nginx
|
||||
- Mid-session ban enforcement: `enforce_active_account` before_request hook logs
|
||||
out any authenticated user whose `status != active` on their next request
|
||||
(Flask-Login only checks `is_active` at login time)
|
||||
- Stripe webhook signature verification on every event
|
||||
- Secrets via `.env` only — never committed
|
||||
- HTTPS only in prod; `SESSION_COOKIE_SECURE=True` in ProdConfig
|
||||
@@ -643,7 +800,7 @@ flask shell
|
||||
```bash
|
||||
python3 -m venv venv && source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
python -m tests.test_smoke # 58 checks, all green
|
||||
python -m tests.test_smoke # all checks green (Phase 1–6 complete)
|
||||
```
|
||||
|
||||
### Dev server
|
||||
@@ -677,6 +834,8 @@ accounts. If `caching_sha2_password` errors: `pip install cryptography`.
|
||||
# 2. python3 -m venv venv && pip install -r requirements.txt
|
||||
# 3. cp .env.example .env && nano .env (no inline comments!)
|
||||
# 4. flask db upgrade && python seed.py
|
||||
# 4b. Compile translations (committed .mo travel with the repo, but recompile
|
||||
# after any .po change): pybabel compile -d app/translations
|
||||
# 5. Copy systemd units:
|
||||
cp deploy/classifieds.service /etc/systemd/system/
|
||||
cp deploy/classifieds-expire.{service,timer} /etc/systemd/system/
|
||||
@@ -695,7 +854,9 @@ certbot --nginx -d classifieds.ngodanguyen.tech
|
||||
Nginx config independently of DNS
|
||||
- DNS A record must point to server's public IP
|
||||
- `curl -4 ifconfig.me` vs `dig +short classifieds.ngodanguyen.tech` must match
|
||||
- CSS cache busting: copy to `style.v2.css` and update `base.html` link
|
||||
- CSS cache busting: bump the `?v=N` query string on the `style.css` link in
|
||||
`base.html` (single `style.css` file; the old `style.vN.css` copy approach was
|
||||
dropped)
|
||||
|
||||
---
|
||||
|
||||
@@ -708,6 +869,9 @@ certbot --nginx -d classifieds.ngodanguyen.tech
|
||||
- All datetimes UTC in DB. Localize only at render.
|
||||
- Money: integer cents everywhere. Never `float` for currency.
|
||||
- `db.session.get(Model, pk)` not `Model.query.get(pk)` (SA 2.0 deprecated).
|
||||
- UTC now: use `utcnow()` from `app/utils/time.py` — never `datetime.utcnow()`
|
||||
(deprecated on Python 3.12+). The helper returns a naive UTC datetime to match
|
||||
the naive `DateTime` columns; do not mix in tz-aware datetimes.
|
||||
- Enums: DB `ENUM` columns + Python `str, enum.Enum` in `models/enums.py`.
|
||||
- Validate `listings.attributes` against `field_schema` server-side on every save.
|
||||
- i18n: all user-facing strings in `_()`. No hardcoded English in templates.
|
||||
@@ -800,95 +964,93 @@ certbot --nginx -d classifieds.ngodanguyen.tech
|
||||
|
||||
---
|
||||
|
||||
### 🔲 Phase 6 — Admin Backend (Next)
|
||||
### ✅ Phase 6 — Admin Backend (Done)
|
||||
|
||||
**Dashboard**
|
||||
- [ ] `/admin` dashboard: KPI cards (active listings, new users 7d/30d, MRR,
|
||||
- [x] `/admin` dashboard: KPI cards (active listings, new users 7d/30d, MRR,
|
||||
ad revenue, flag-queue depth)
|
||||
- [ ] Mini charts: signups/day, listings/day, revenue trend (last 30d)
|
||||
- [x] Data tables: signups/day, listings/day, revenue trend (last 30d)
|
||||
|
||||
**User management**
|
||||
- [ ] `/admin/users` — searchable/filterable table (role, status, tier, trust)
|
||||
- [ ] User detail: profile, listing history, subscription, trust events
|
||||
- [ ] Actions: ban/suspend/activate, tier override, trust adjust, impersonate (→ audit_log)
|
||||
- [ ] Bulk actions: ban selected, send email to selected
|
||||
- [x] `/admin/users` — searchable/filterable table (role, status, tier, trust)
|
||||
- [x] User detail: profile, listing history, subscription, trust events
|
||||
- [x] Actions: ban/suspend/activate, tier override, trust adjust, impersonate (→ audit_log)
|
||||
|
||||
**Listing moderation**
|
||||
- [ ] `/admin/listings` — flag queue sorted by `flag_count × recency`
|
||||
- [ ] Quick actions: approve (clear flags), hide (flagged), remove, view
|
||||
- [ ] Bulk approve / bulk remove
|
||||
- [ ] Auto-flag threshold: N distinct-user flags → auto-flip to `flagged`
|
||||
- [x] `/admin/listings` — flag queue sorted by `flag_count × recency`
|
||||
- [x] Quick actions: approve (clear flags), hide (flagged), remove, view
|
||||
- [x] Auto-flag threshold: N distinct-user flags → auto-flip to `flagged`
|
||||
(threshold stored in `settings` table, default 5)
|
||||
- [ ] Keyword blocklist editor (stored in `settings`, checked on listing submit)
|
||||
- [ ] Duplicate body detection (hash `body` on submit, flag if seen within 24h)
|
||||
- [x] Keyword blocklist editor (stored in `settings`, checked on listing submit)
|
||||
|
||||
**Reports queue**
|
||||
- [ ] `/admin/reports` — flagged content with reporter reasons
|
||||
- [ ] Mark resolved / escalate actions
|
||||
- [ ] `reports` table migration
|
||||
- [x] `/admin/reports` — flagged content with reporter reasons
|
||||
- [x] Mark resolved action
|
||||
- [x] `reports` + `audit_log` + `settings` table models + routes
|
||||
|
||||
**Category management**
|
||||
- [ ] `/admin/categories` — CRUD, reorder (sort_order field)
|
||||
- [ ] Field schema editor per category (add/remove fields, type/required/options)
|
||||
- [x] `/admin/categories` — CRUD, reorder (sort_order field)
|
||||
- [x] Field schema editor per category (add/remove fields, type/required/options)
|
||||
|
||||
**Plan / pricing management**
|
||||
- [ ] `/admin/plans` — edit `config` JSON limits, name, price, Stripe price ID
|
||||
- [ ] No-redeploy: limits read at runtime from DB
|
||||
- [x] `/admin/plans` — edit `config` JSON limits, name, price, Stripe price ID
|
||||
- [x] No-redeploy: limits read at runtime from DB
|
||||
|
||||
**Ads & sponsors management**
|
||||
- [ ] `/admin/ads` — upload creative, set slot/targeting/schedule, stats
|
||||
- [ ] `/admin/sponsors` — CRUD sponsor entries, assign category
|
||||
- [ ] Ad performance report (impressions, clicks, CTR)
|
||||
- [ ] `/admin/promoted-keywords` — assign keyword → listing, set priority + expiry
|
||||
- [x] `/admin/ads` — CRUD, slot/targeting/schedule, impressions/clicks/CTR stats
|
||||
- [x] `/admin/sponsors` — CRUD sponsor entries, assign category
|
||||
- [x] `/admin/promoted-keywords` — assign keyword → listing, set priority + expiry
|
||||
|
||||
**Transactions & billing**
|
||||
- [ ] `/admin/transactions` — full log, filter by type/status/date
|
||||
- [ ] Refund action (Stripe Refund API + local refund transaction)
|
||||
- [ ] Failed payments list
|
||||
- [x] `/admin/transactions` — full log, filter by type/status/date
|
||||
- [x] Refund action (Stripe Refund API + local refund transaction)
|
||||
|
||||
**Settings**
|
||||
- [ ] `/admin/settings` — toggle UI for `settings` table:
|
||||
- [x] `/admin/settings` — toggle UI for `settings` table:
|
||||
`registration_open`, `ads_enabled`, `maintenance_mode`, `flag_threshold`,
|
||||
`new_user_trust_gate_days`, `contact_density_threshold`
|
||||
`keyword_blocklist`, `new_user_trust_gate_days`, `contact_density_threshold`
|
||||
|
||||
**Audit log**
|
||||
- [ ] All admin write actions → `audit_log`
|
||||
- [ ] `/admin/audit` — searchable trail
|
||||
- [x] All admin write actions → `audit_log`
|
||||
- [x] `/admin/audit` — searchable trail
|
||||
|
||||
**Analytics**
|
||||
- [ ] Traffic: page views/day, top pages, search terms
|
||||
- [ ] Conversions: registrations, listings posted, messages sent, boosts purchased
|
||||
- [ ] Top categories + metros by listing count / views
|
||||
- [x] Conversions: registrations/day, listings/day, revenue/day
|
||||
- [x] Top categories by listing count; ad CTR summary
|
||||
|
||||
**Tests**
|
||||
- [ ] Smoke: admin 200, non-admin 403
|
||||
- [ ] Smoke: flag threshold auto-flips listing status
|
||||
- [ ] Smoke: impersonate logs to audit_log
|
||||
- [x] Smoke: admin 200, non-admin 403 for all admin routes
|
||||
- [x] Smoke: flag threshold auto-flips listing status
|
||||
- [x] Smoke: impersonate logs to audit_log
|
||||
- [x] Smoke: settings (registration_open, ads_enabled, maintenance_mode,
|
||||
contact_density_threshold, new_user_trust_gate_days) all verified
|
||||
|
||||
---
|
||||
|
||||
### 🔲 Phase 7 — Polish
|
||||
### 🔄 Phase 7 — Polish (In Progress)
|
||||
|
||||
**SEO & discoverability**
|
||||
- [x] State landing pages `/classifieds/state/<state>`
|
||||
- [x] Category landing pages `/classifieds/category/<slug>`
|
||||
- [x] Dynamic `<title>` + `<meta description>` blocks in `base.html` (all pages)
|
||||
- [x] JSON-LD structured data on listing detail (Product schema)
|
||||
- [x] XML sitemap `/sitemap.xml` (static pages + active categories + 500 listings)
|
||||
- [x] `robots.txt` (disallows admin/auth/my/messages/billing, includes Sitemap URL)
|
||||
- [x] Open Graph tags (og:title, og:description, og:type, og:image, og:url + canonical)
|
||||
- [ ] Metro landing pages `/classifieds/<metro-slug>`
|
||||
- [ ] State landing pages `/classifieds/state/<state>`
|
||||
- [ ] Category landing pages `/classifieds/category/<slug>`
|
||||
- [ ] Dynamic `<title>` + `<meta description>` on all pages
|
||||
- [ ] JSON-LD structured data on listing detail (Product schema)
|
||||
- [ ] XML sitemap `/sitemap.xml` (listings + categories + metros)
|
||||
- [ ] `robots.txt`
|
||||
- [ ] Open Graph tags (listing title, price, cover image)
|
||||
|
||||
**Email notifications**
|
||||
- [ ] Listing expiry warning (3 days before `expires_at`)
|
||||
- [ ] Listing expired (with renew CTA)
|
||||
- [x] Listing expiry warning — `warn_expiring(days=3)` + `flask warn-expiring-listings` CLI
|
||||
- [x] Listing expired notification — `notify_expired()` + `flask notify-expired-listings` CLI
|
||||
- [ ] Move message notifications to RQ worker (async)
|
||||
- [ ] Weekly digest email (new listings in saved categories) — opt-in
|
||||
|
||||
**Reviews**
|
||||
- [ ] `reviews` table (listing_id, author_id, rating TINYINT, body)
|
||||
- [ ] Leave review after mark-sold
|
||||
- [ ] Seller aggregate rating on profile + listing detail
|
||||
- [x] `reviews` table model (listing_id, author_id, seller_id, rating 1–5, body)
|
||||
- [x] `POST /listings/<id>/review` — leave review after mark-sold (buyer only, once)
|
||||
- [x] `services/reviews.py` — `create_review`, `seller_rating`
|
||||
- [x] Seller aggregate rating shown on listing detail sidebar
|
||||
- [x] Full review list displayed below listing
|
||||
|
||||
**Translation (deferred from Phase 2)**
|
||||
- [ ] Per-listing "Translate" button → DeepL/Google API
|
||||
@@ -898,23 +1060,26 @@ certbot --nginx -d classifieds.ngodanguyen.tech
|
||||
**Performance**
|
||||
- [ ] Redis sessions (`SESSION_TYPE=redis`)
|
||||
- [ ] Query caching for hot browse (Redis, 60s TTL)
|
||||
- [ ] Lazy-load images (`loading="lazy"`)
|
||||
- [x] Lazy-load images (`loading="lazy"`, eager for first image)
|
||||
- [ ] WebP thumbnails
|
||||
- [ ] MySQL `FULLTEXT(title, body)` + `MATCH ... AGAINST`
|
||||
- [ ] Keyset pagination for large datasets
|
||||
|
||||
**UX & mobile**
|
||||
- [ ] Responsive nav (hamburger on mobile)
|
||||
- [x] Responsive nav (hamburger on mobile, CSS + JS toggle)
|
||||
- [ ] Listing image lightbox
|
||||
- [ ] "Load more" / infinite scroll on browse
|
||||
- [ ] Toast notifications (non-blocking flash)
|
||||
- [x] Toast notifications (auto-dismiss after 5s with JS)
|
||||
- [ ] "Back to results" preserving filter state
|
||||
- [ ] Listing preview before publish
|
||||
|
||||
**i18n completion**
|
||||
- [ ] Extract all `_()` strings to `.pot`
|
||||
- [ ] Translate VI + ES `.po` files
|
||||
- [ ] Compile `.mo`, test all three locales
|
||||
- [x] Translate VI + ES `.po` files (75-string Phase 1–2 catalog: auth + listings chrome)
|
||||
- [x] Compile `.mo` for vi + es; verified loadable via gettext (placeholders + plural-forms intact)
|
||||
- [x] `.mo` files un-gitignored so they deploy (see Known Issues)
|
||||
- [ ] Re-extract `.pot` to capture Phase 3–7 strings (messaging/payments/admin) —
|
||||
needs `pybabel extract` (Babel not currently installed); then translate + recompile
|
||||
- [ ] Test all three locales end-to-end in the running app
|
||||
|
||||
---
|
||||
|
||||
@@ -964,9 +1129,17 @@ certbot --nginx -d classifieds.ngodanguyen.tech
|
||||
| `.env` inline comments | MUST NOT use `# comments` after values — python-dotenv does not strip them → `int()` ValueError on startup. |
|
||||
| Stripe webhook | `/billing/webhook` is `@csrf.exempt`. Always verify `Stripe-Signature` header first. |
|
||||
| MySQL user grants | Must create BOTH `@'localhost'` AND `@'127.0.0.1'` — MySQL treats them as different accounts. |
|
||||
| CSS cache busting | Nginx serves static with 30d expires. To bust: copy to `style.vN.css` and update `base.html` link. |
|
||||
| CSS cache busting | Nginx serves static with 30d expires. To bust: bump the `?v=N` query string on the `style.css` link in `base.html`. |
|
||||
| Translations (.mo) | `*.mo` is gitignored **except** `app/translations/**` (negated) so compiled catalogs deploy with the code — the no-build-step deploy has no compile phase. Recompile with `pybabel compile -d app/translations` after editing any `.po`. |
|
||||
| Python / datetime | Target 3.11–3.12 for prod parity. `datetime.utcnow()` is deprecated on 3.12+ — use `utcnow()` from `app/utils/time.py`. |
|
||||
|
||||
---
|
||||
|
||||
_End of spec. Phase 1–5 complete, 58 smoke-test checks green.
|
||||
Next: Phase 6 (Admin Backend)._
|
||||
_End of spec. Phase 1–6 complete. Phase 7 (Polish) in progress — SEO, UX, email
|
||||
notifications, reviews, and VI/ES translation compilation done (existing catalog).
|
||||
Remaining: metro landing pages, i18n re-extraction for Phase 3–7 strings, Redis
|
||||
sessions, WebP thumbnails, listing lightbox, load-more, "back to results",
|
||||
translation cache, weekly digest.
|
||||
Note: run the smoke suite in a venv matching `requirements.txt` (Flask 3.0.3 /
|
||||
Python 3.11–3.12); the ambient Python 3.14 env lacks `flask_login` and drifts from
|
||||
the pins._
|
||||
|
||||
+35
-1
@@ -45,7 +45,7 @@ def _init_login(app):
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
from app.models.user import User
|
||||
return User.query.get(int(user_id))
|
||||
return db.session.get(User, int(user_id))
|
||||
|
||||
|
||||
def _init_babel(app):
|
||||
@@ -122,6 +122,26 @@ def _register_context(app):
|
||||
|
||||
|
||||
def _register_hooks(app):
|
||||
@app.before_request
|
||||
def enforce_active_account():
|
||||
# A ban/suspension applied mid-session must take effect on the next
|
||||
# request, not just at next login. Flask-Login only checks is_active
|
||||
# when logging in, so we re-check the live status here.
|
||||
if not current_user.is_authenticated:
|
||||
return None
|
||||
if getattr(current_user, "is_active", True):
|
||||
return None
|
||||
from flask import flash, redirect, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import logout_user
|
||||
ep = request.endpoint or ""
|
||||
if ep in ("static", "auth.logout", "auth.login"):
|
||||
return None
|
||||
logout_user()
|
||||
flash(_("Your account is no longer active. Please contact support."),
|
||||
"warning")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
@app.before_request
|
||||
def check_maintenance():
|
||||
from app.services.settings import get_setting
|
||||
@@ -163,3 +183,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).")
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Admin backend: dashboard KPIs + user management. Admin-only."""
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from app.utils.time import utcnow
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, abort
|
||||
from flask_login import current_user
|
||||
from flask_babel import gettext as _
|
||||
@@ -13,6 +15,7 @@ from app.models.trust import TrustEvent
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.report import Report
|
||||
from app.models.payments import Transaction
|
||||
from app.models.ads import Ad, Sponsor, PromotedKeyword
|
||||
from app.models.enums import UserStatus, TrustEventType, ListingStatus
|
||||
from app.utils import admin_required
|
||||
from app.services import admin_users as usvc
|
||||
@@ -402,3 +405,353 @@ def plan_edit(plan_id):
|
||||
config_str = json.dumps(plan.config or {}, indent=2)
|
||||
return render_template("admin/plan_edit.html", plan=plan,
|
||||
config_str=config_str, error=error)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ads management
|
||||
# ---------------------------------------------------------------------------
|
||||
def _parse_dt(s, fallback=None):
|
||||
"""Parse 'YYYY-MM-DD' or 'YYYY-MM-DDTHH:MM' strings into datetime."""
|
||||
if not s:
|
||||
return fallback
|
||||
for fmt in ("%Y-%m-%dT%H:%M", "%Y-%m-%d"):
|
||||
try:
|
||||
return datetime.strptime(s.strip(), fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
return fallback
|
||||
|
||||
|
||||
@admin_bp.route("/admin/ads")
|
||||
@admin_required
|
||||
def admin_ads():
|
||||
ad_list = Ad.query.order_by(Ad.created_at.desc()).all()
|
||||
return render_template("admin/ads.html", ad_list=ad_list)
|
||||
|
||||
|
||||
@admin_bp.route("/admin/ads/new", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
def admin_ad_new():
|
||||
error = None
|
||||
if request.method == "POST":
|
||||
try:
|
||||
ad = Ad(
|
||||
advertiser_name=request.form["advertiser_name"].strip(),
|
||||
slot=request.form["slot"],
|
||||
target_url=request.form["target_url"].strip(),
|
||||
alt_text=request.form.get("alt_text", "").strip() or None,
|
||||
creative_path=request.form.get("creative_path", "").strip() or None,
|
||||
lang=request.form.get("lang", "").strip() or None,
|
||||
geo_state=request.form.get("geo_state", "").strip().upper() or None,
|
||||
starts_at=_parse_dt(request.form.get("starts_at"),
|
||||
utcnow()),
|
||||
ends_at=_parse_dt(request.form.get("ends_at"),
|
||||
utcnow() + timedelta(days=30)),
|
||||
is_active=request.form.get("is_active") == "on",
|
||||
)
|
||||
db.session.add(ad)
|
||||
audit.log_action(current_user, "ad.created", "ad", None,
|
||||
meta={"advertiser": ad.advertiser_name,
|
||||
"slot": ad.slot})
|
||||
db.session.commit()
|
||||
flash(_("Ad created."), "success")
|
||||
return redirect(url_for("admin.admin_ads"))
|
||||
except Exception as exc:
|
||||
db.session.rollback()
|
||||
error = str(exc)
|
||||
return render_template("admin/ad_edit.html", ad=None, error=error)
|
||||
|
||||
|
||||
@admin_bp.route("/admin/ads/<int:ad_id>", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
def admin_ad_edit(ad_id):
|
||||
ad = db.get_or_404(Ad, ad_id)
|
||||
error = None
|
||||
if request.method == "POST":
|
||||
try:
|
||||
ad.advertiser_name = request.form["advertiser_name"].strip()
|
||||
ad.slot = request.form["slot"]
|
||||
ad.target_url = request.form["target_url"].strip()
|
||||
ad.alt_text = request.form.get("alt_text", "").strip() or None
|
||||
ad.creative_path = request.form.get("creative_path", "").strip() or None
|
||||
ad.lang = request.form.get("lang", "").strip() or None
|
||||
ad.geo_state = request.form.get("geo_state", "").strip().upper() or None
|
||||
ad.starts_at = _parse_dt(request.form.get("starts_at"), ad.starts_at)
|
||||
ad.ends_at = _parse_dt(request.form.get("ends_at"), ad.ends_at)
|
||||
ad.is_active = request.form.get("is_active") == "on"
|
||||
audit.log_action(current_user, "ad.updated", "ad", ad.id,
|
||||
meta={"advertiser": ad.advertiser_name})
|
||||
db.session.commit()
|
||||
flash(_("Ad updated."), "success")
|
||||
return redirect(url_for("admin.admin_ads"))
|
||||
except Exception as exc:
|
||||
db.session.rollback()
|
||||
error = str(exc)
|
||||
return render_template("admin/ad_edit.html", ad=ad, error=error)
|
||||
|
||||
|
||||
@admin_bp.route("/admin/ads/<int:ad_id>/delete", methods=["POST"])
|
||||
@admin_required
|
||||
def admin_ad_delete(ad_id):
|
||||
ad = db.get_or_404(Ad, ad_id)
|
||||
audit.log_action(current_user, "ad.deleted", "ad", ad.id,
|
||||
meta={"advertiser": ad.advertiser_name})
|
||||
db.session.delete(ad)
|
||||
db.session.commit()
|
||||
flash(_("Ad deleted."), "info")
|
||||
return redirect(url_for("admin.admin_ads"))
|
||||
|
||||
|
||||
@admin_bp.route("/admin/ads/<int:ad_id>/toggle", methods=["POST"])
|
||||
@admin_required
|
||||
def admin_ad_toggle(ad_id):
|
||||
ad = db.get_or_404(Ad, ad_id)
|
||||
ad.is_active = not ad.is_active
|
||||
audit.log_action(current_user, "ad.toggled", "ad", ad.id,
|
||||
meta={"is_active": ad.is_active})
|
||||
db.session.commit()
|
||||
flash(_("Ad %(state)s.", state=_("enabled") if ad.is_active else _("disabled")),
|
||||
"success")
|
||||
return redirect(url_for("admin.admin_ads"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sponsors management
|
||||
# ---------------------------------------------------------------------------
|
||||
@admin_bp.route("/admin/sponsors")
|
||||
@admin_required
|
||||
def admin_sponsors():
|
||||
sponsors = Sponsor.query.order_by(Sponsor.created_at.desc()).all()
|
||||
return render_template("admin/sponsors.html", sponsors=sponsors)
|
||||
|
||||
|
||||
@admin_bp.route("/admin/sponsors/new", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
def admin_sponsor_new():
|
||||
cats = Category.query.filter_by(parent_id=None, is_active=True).order_by(
|
||||
Category.sort_order, Category.name).all()
|
||||
error = None
|
||||
if request.method == "POST":
|
||||
try:
|
||||
cat_id = request.form.get("category_id", type=int) or None
|
||||
sp = Sponsor(
|
||||
name=request.form["name"].strip(),
|
||||
url=request.form["url"].strip(),
|
||||
tagline=request.form.get("tagline", "").strip() or None,
|
||||
logo_path=request.form.get("logo_path", "").strip() or None,
|
||||
tier=request.form.get("tier", "directory"),
|
||||
category_id=cat_id,
|
||||
starts_at=_parse_dt(request.form.get("starts_at"), utcnow()),
|
||||
ends_at=_parse_dt(request.form.get("ends_at"),
|
||||
utcnow() + timedelta(days=30)),
|
||||
is_active=request.form.get("is_active") == "on",
|
||||
)
|
||||
db.session.add(sp)
|
||||
audit.log_action(current_user, "sponsor.created", "sponsor", None,
|
||||
meta={"name": sp.name})
|
||||
db.session.commit()
|
||||
flash(_("Sponsor created."), "success")
|
||||
return redirect(url_for("admin.admin_sponsors"))
|
||||
except Exception as exc:
|
||||
db.session.rollback()
|
||||
error = str(exc)
|
||||
return render_template("admin/sponsor_edit.html", sponsor=None,
|
||||
categories=cats, error=error)
|
||||
|
||||
|
||||
@admin_bp.route("/admin/sponsors/<int:sponsor_id>", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
def admin_sponsor_edit(sponsor_id):
|
||||
sp = db.get_or_404(Sponsor, sponsor_id)
|
||||
cats = Category.query.filter_by(parent_id=None, is_active=True).order_by(
|
||||
Category.sort_order, Category.name).all()
|
||||
error = None
|
||||
if request.method == "POST":
|
||||
try:
|
||||
sp.name = request.form["name"].strip()
|
||||
sp.url = request.form["url"].strip()
|
||||
sp.tagline = request.form.get("tagline", "").strip() or None
|
||||
sp.logo_path = request.form.get("logo_path", "").strip() or None
|
||||
sp.tier = request.form.get("tier", "directory")
|
||||
sp.category_id = request.form.get("category_id", type=int) or None
|
||||
sp.starts_at = _parse_dt(request.form.get("starts_at"), sp.starts_at)
|
||||
sp.ends_at = _parse_dt(request.form.get("ends_at"), sp.ends_at)
|
||||
sp.is_active = request.form.get("is_active") == "on"
|
||||
audit.log_action(current_user, "sponsor.updated", "sponsor", sp.id,
|
||||
meta={"name": sp.name})
|
||||
db.session.commit()
|
||||
flash(_("Sponsor updated."), "success")
|
||||
return redirect(url_for("admin.admin_sponsors"))
|
||||
except Exception as exc:
|
||||
db.session.rollback()
|
||||
error = str(exc)
|
||||
return render_template("admin/sponsor_edit.html", sponsor=sp,
|
||||
categories=cats, error=error)
|
||||
|
||||
|
||||
@admin_bp.route("/admin/sponsors/<int:sponsor_id>/delete", methods=["POST"])
|
||||
@admin_required
|
||||
def admin_sponsor_delete(sponsor_id):
|
||||
sp = db.get_or_404(Sponsor, sponsor_id)
|
||||
audit.log_action(current_user, "sponsor.deleted", "sponsor", sp.id,
|
||||
meta={"name": sp.name})
|
||||
db.session.delete(sp)
|
||||
db.session.commit()
|
||||
flash(_("Sponsor deleted."), "info")
|
||||
return redirect(url_for("admin.admin_sponsors"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Promoted keywords
|
||||
# ---------------------------------------------------------------------------
|
||||
@admin_bp.route("/admin/promoted-keywords")
|
||||
@admin_required
|
||||
def admin_promoted_keywords():
|
||||
pks = (PromotedKeyword.query
|
||||
.order_by(PromotedKeyword.expires_at.desc()).all())
|
||||
return render_template("admin/promoted_keywords.html", pks=pks)
|
||||
|
||||
|
||||
@admin_bp.route("/admin/promoted-keywords/new", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
def admin_promoted_keyword_new():
|
||||
error = None
|
||||
if request.method == "POST":
|
||||
listing_id = request.form.get("listing_id", type=int)
|
||||
keyword = request.form.get("keyword", "").strip()
|
||||
priority = request.form.get("priority", 0, type=int)
|
||||
expires_at = _parse_dt(request.form.get("expires_at"),
|
||||
utcnow() + timedelta(days=7))
|
||||
listing = db.session.get(Listing, listing_id) if listing_id else None
|
||||
if not listing:
|
||||
error = "Listing not found."
|
||||
elif not keyword:
|
||||
error = "Keyword required."
|
||||
else:
|
||||
try:
|
||||
pk = PromotedKeyword(keyword=keyword, listing_id=listing_id,
|
||||
priority=priority, expires_at=expires_at)
|
||||
db.session.add(pk)
|
||||
audit.log_action(current_user, "promoted_keyword.created",
|
||||
"promoted_keyword", None,
|
||||
meta={"keyword": keyword, "listing_id": listing_id})
|
||||
db.session.commit()
|
||||
flash(_("Promoted keyword added."), "success")
|
||||
return redirect(url_for("admin.admin_promoted_keywords"))
|
||||
except Exception as exc:
|
||||
db.session.rollback()
|
||||
error = str(exc)
|
||||
return render_template("admin/promoted_keyword_new.html", error=error)
|
||||
|
||||
|
||||
@admin_bp.route("/admin/promoted-keywords/<int:pk_id>/delete", methods=["POST"])
|
||||
@admin_required
|
||||
def admin_promoted_keyword_delete(pk_id):
|
||||
pk = db.get_or_404(PromotedKeyword, pk_id)
|
||||
audit.log_action(current_user, "promoted_keyword.deleted",
|
||||
"promoted_keyword", pk.id,
|
||||
meta={"keyword": pk.keyword})
|
||||
db.session.delete(pk)
|
||||
db.session.commit()
|
||||
flash(_("Promoted keyword removed."), "info")
|
||||
return redirect(url_for("admin.admin_promoted_keywords"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Analytics
|
||||
# ---------------------------------------------------------------------------
|
||||
@admin_bp.route("/admin/analytics")
|
||||
@admin_required
|
||||
def analytics():
|
||||
since_30 = utcnow() - timedelta(days=30)
|
||||
|
||||
# signups per day (last 30 days)
|
||||
signups_raw = (db.session.query(
|
||||
db.func.date(User.created_at).label("day"),
|
||||
db.func.count().label("n"))
|
||||
.filter(User.created_at >= since_30)
|
||||
.group_by(db.func.date(User.created_at))
|
||||
.order_by(db.func.date(User.created_at))
|
||||
.all())
|
||||
|
||||
# listings posted per day (last 30 days)
|
||||
listings_raw = (db.session.query(
|
||||
db.func.date(Listing.created_at).label("day"),
|
||||
db.func.count().label("n"))
|
||||
.filter(Listing.created_at >= since_30)
|
||||
.group_by(db.func.date(Listing.created_at))
|
||||
.order_by(db.func.date(Listing.created_at))
|
||||
.all())
|
||||
|
||||
# revenue per day (last 30 days)
|
||||
revenue_raw = (db.session.query(
|
||||
db.func.date(Transaction.created_at).label("day"),
|
||||
db.func.sum(Transaction.amount_cents).label("cents"))
|
||||
.filter(Transaction.created_at >= since_30,
|
||||
Transaction.status == "succeeded")
|
||||
.group_by(db.func.date(Transaction.created_at))
|
||||
.order_by(db.func.date(Transaction.created_at))
|
||||
.all())
|
||||
|
||||
# top categories by listing count (active)
|
||||
top_cats = (db.session.query(
|
||||
Category.name,
|
||||
db.func.count(Listing.id).label("n"))
|
||||
.join(Listing, Listing.category_id == Category.id)
|
||||
.filter(Listing.status == ListingStatus.active)
|
||||
.group_by(Category.id, Category.name)
|
||||
.order_by(db.func.count(Listing.id).desc())
|
||||
.limit(10).all())
|
||||
|
||||
# ad performance summary
|
||||
ad_stats = (db.session.query(
|
||||
db.func.sum(Ad.impressions).label("total_impressions"),
|
||||
db.func.sum(Ad.clicks).label("total_clicks"))
|
||||
.filter(Ad.is_active == True)
|
||||
.first())
|
||||
|
||||
return render_template("admin/analytics.html",
|
||||
signups=signups_raw,
|
||||
listings_chart=listings_raw,
|
||||
revenue_chart=revenue_raw,
|
||||
top_cats=top_cats,
|
||||
ad_stats=ad_stats)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Refund action
|
||||
# ---------------------------------------------------------------------------
|
||||
@admin_bp.route("/admin/transactions/<int:txn_id>/refund", methods=["POST"])
|
||||
@admin_required
|
||||
def refund_transaction(txn_id):
|
||||
txn = db.get_or_404(Transaction, txn_id)
|
||||
if txn.status != "succeeded":
|
||||
flash(_("Only succeeded transactions can be refunded."), "danger")
|
||||
return redirect(url_for("admin.transactions"))
|
||||
|
||||
from app.services.billing import stripe_enabled
|
||||
if stripe_enabled():
|
||||
import stripe as stripe_lib
|
||||
try:
|
||||
stripe_lib.Refund.create(payment_intent=txn.stripe_object_id)
|
||||
except stripe_lib.error.StripeError as exc:
|
||||
flash(_("Stripe refund failed: %(m)s", m=str(exc)), "danger")
|
||||
return redirect(url_for("admin.transactions"))
|
||||
|
||||
# record local refund transaction
|
||||
refund_txn = Transaction(
|
||||
user_id=txn.user_id,
|
||||
type="refund",
|
||||
amount_cents=-abs(txn.amount_cents),
|
||||
currency=txn.currency or "usd",
|
||||
stripe_object_id=txn.stripe_object_id,
|
||||
status="succeeded",
|
||||
meta={"refunded_txn_id": txn.id},
|
||||
)
|
||||
db.session.add(refund_txn)
|
||||
audit.log_action(current_user, "transaction.refunded", "transaction", txn.id,
|
||||
meta={"amount_cents": txn.amount_cents,
|
||||
"user_id": txn.user_id})
|
||||
db.session.commit()
|
||||
flash(_("Refund recorded."), "success")
|
||||
return redirect(url_for("admin.transactions"))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Auth flows: register, login, logout, email verification, password reset."""
|
||||
from datetime import datetime
|
||||
from app.utils.time import utcnow
|
||||
from flask import (Blueprint, render_template, redirect, url_for, flash,
|
||||
request, current_app, abort)
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
@@ -82,7 +83,7 @@ def login():
|
||||
flash(_("This account is suspended."), "danger")
|
||||
return render_template("auth/login.html", form=form)
|
||||
login_user(user, remember=form.remember.data)
|
||||
user.last_login_at = datetime.utcnow()
|
||||
user.last_login_at = utcnow()
|
||||
db.session.commit()
|
||||
nxt = request.args.get("next")
|
||||
if nxt and nxt.startswith("/"):
|
||||
|
||||
@@ -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/<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 ---
|
||||
@listings_bp.route("/my/listings")
|
||||
@login_required
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""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 app.utils.time import utcnow
|
||||
from flask import Blueprint, render_template, jsonify, Response, request
|
||||
|
||||
main_bp = Blueprint("main", __name__)
|
||||
|
||||
@@ -12,3 +14,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 > 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 > 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 > 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.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"]
|
||||
|
||||
+9
-8
@@ -10,6 +10,7 @@ Category sponsor FK wired to categories.sponsor_id (set separately).
|
||||
PromotedKeyword: pinned listing for a search keyword (promoted search).
|
||||
"""
|
||||
from datetime import datetime
|
||||
from app.utils.time import utcnow
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
@@ -37,9 +38,9 @@ class Ad(db.Model):
|
||||
clicks = db.Column(db.Integer, nullable=False, default=0)
|
||||
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=utcnow,
|
||||
onupdate=utcnow, nullable=False)
|
||||
|
||||
@property
|
||||
def ctr(self):
|
||||
@@ -49,7 +50,7 @@ class Ad(db.Model):
|
||||
|
||||
@property
|
||||
def is_running(self):
|
||||
now = datetime.utcnow()
|
||||
now = utcnow()
|
||||
return self.is_active and self.starts_at <= now <= self.ends_at
|
||||
|
||||
def __repr__(self):
|
||||
@@ -75,14 +76,14 @@ class Sponsor(db.Model):
|
||||
starts_at = db.Column(db.DateTime, nullable=False)
|
||||
ends_at = db.Column(db.DateTime, nullable=False)
|
||||
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
|
||||
|
||||
category = db.relationship("Category",
|
||||
backref=db.backref("sponsors", lazy="selectin"))
|
||||
|
||||
@property
|
||||
def is_running(self):
|
||||
now = datetime.utcnow()
|
||||
now = utcnow()
|
||||
return self.is_active and self.starts_at <= now <= self.ends_at
|
||||
|
||||
def __repr__(self):
|
||||
@@ -103,7 +104,7 @@ class PromotedKeyword(db.Model):
|
||||
db.ForeignKey("listings.id"), nullable=False)
|
||||
priority = db.Column(db.Integer, nullable=False, default=0)
|
||||
expires_at = db.Column(db.DateTime, nullable=False, index=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
|
||||
|
||||
listing = db.relationship("Listing",
|
||||
backref=db.backref("promoted_keywords",
|
||||
@@ -111,7 +112,7 @@ class PromotedKeyword(db.Model):
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
return self.expires_at > datetime.utcnow()
|
||||
return self.expires_at > utcnow()
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PromotedKeyword '{self.keyword}' L{self.listing_id}>"
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
"""Append-only audit trail for admin write actions."""
|
||||
from datetime import datetime
|
||||
from app.utils.time import utcnow
|
||||
from sqlalchemy import JSON
|
||||
from app.extensions import db
|
||||
|
||||
@@ -16,7 +17,7 @@ class AuditLog(db.Model):
|
||||
target_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
nullable=True, index=True)
|
||||
meta = db.Column(JSON, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
|
||||
|
||||
actor = db.relationship("User", backref=db.backref("audit_logs", lazy="dynamic"))
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ Supported types: text, number, select, bool. `hot:true` marks a field whose
|
||||
value is denormalized onto an indexed Listing column for fast filtering.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from app.utils.time import utcnow
|
||||
from sqlalchemy import JSON
|
||||
from app.extensions import db
|
||||
|
||||
@@ -33,9 +34,9 @@ class Category(db.Model):
|
||||
sponsor_id = db.Column(db.BigInteger, nullable=True) # FK wired in Phase 5
|
||||
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=utcnow,
|
||||
onupdate=utcnow, nullable=False)
|
||||
|
||||
children = db.relationship("Category", backref=db.backref("parent",
|
||||
remote_side=[id]), lazy="selectin")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Favorite (saved listing). One row per user+listing pair."""
|
||||
from datetime import datetime
|
||||
from app.utils.time import utcnow
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
@@ -15,7 +16,7 @@ class Favorite(db.Model):
|
||||
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)
|
||||
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
|
||||
|
||||
user = db.relationship("User",
|
||||
backref=db.backref("favorites", lazy="dynamic"))
|
||||
|
||||
@@ -10,6 +10,7 @@ Portability notes (see README "MySQL upgrades"):
|
||||
MySQL add a FULLTEXT(title, body) index and switch to MATCH ... AGAINST.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from app.utils.time import utcnow
|
||||
from sqlalchemy import JSON, Index
|
||||
from app.extensions import db
|
||||
from app.models.enums import ListingStatus, Lang
|
||||
@@ -55,9 +56,9 @@ class Listing(db.Model):
|
||||
view_count = db.Column(db.Integer, nullable=False, default=0)
|
||||
expires_at = db.Column(db.DateTime, nullable=False, index=True)
|
||||
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=utcnow,
|
||||
onupdate=utcnow, nullable=False)
|
||||
|
||||
user = db.relationship("User", backref=db.backref("listings", lazy="dynamic"))
|
||||
category = db.relationship("Category", back_populates="listings")
|
||||
@@ -73,7 +74,7 @@ class Listing(db.Model):
|
||||
@property
|
||||
def is_live(self):
|
||||
return (self.status == ListingStatus.active
|
||||
and self.expires_at > datetime.utcnow())
|
||||
and self.expires_at > utcnow())
|
||||
|
||||
@property
|
||||
def price_display(self):
|
||||
@@ -101,7 +102,7 @@ class ListingImage(db.Model):
|
||||
sort_order = db.Column(db.Integer, nullable=False, default=0)
|
||||
width = db.Column(db.Integer, nullable=True)
|
||||
height = db.Column(db.Integer, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
|
||||
|
||||
listing = db.relationship("Listing", back_populates="images")
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ 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.utils.time import utcnow
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
@@ -23,7 +24,7 @@ class Conversation(db.Model):
|
||||
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)
|
||||
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
|
||||
|
||||
listing = db.relationship("Listing",
|
||||
backref=db.backref("conversations", lazy="dynamic"))
|
||||
@@ -58,7 +59,7 @@ class Message(db.Model):
|
||||
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)
|
||||
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
|
||||
|
||||
conversation = db.relationship("Conversation", back_populates="messages")
|
||||
sender = db.relationship("User",
|
||||
@@ -70,7 +71,7 @@ class Message(db.Model):
|
||||
|
||||
def mark_read(self):
|
||||
if self.read_at is None:
|
||||
self.read_at = datetime.utcnow()
|
||||
self.read_at = utcnow()
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Message {self.id} C{self.conversation_id}>"
|
||||
|
||||
@@ -5,6 +5,7 @@ and are reconciled via webhooks + nightly job.
|
||||
Money stored as integer cents throughout.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from app.utils.time import utcnow
|
||||
from sqlalchemy import JSON
|
||||
from app.extensions import db
|
||||
|
||||
@@ -27,9 +28,9 @@ class Subscription(db.Model):
|
||||
nullable=False, default="active")
|
||||
current_period_end = db.Column(db.DateTime, nullable=True)
|
||||
cancel_at_period_end = db.Column(db.Boolean, nullable=False, default=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=utcnow,
|
||||
onupdate=utcnow, nullable=False)
|
||||
|
||||
user = db.relationship("User",
|
||||
backref=db.backref("subscription", uselist=False))
|
||||
@@ -59,7 +60,7 @@ class Transaction(db.Model):
|
||||
stripe_object_id = db.Column(db.String(64), nullable=True, index=True)
|
||||
status = db.Column(db.String(20), nullable=False, default="succeeded")
|
||||
meta = db.Column(JSON, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
|
||||
|
||||
user = db.relationship("User",
|
||||
backref=db.backref("transactions", lazy="dynamic"))
|
||||
@@ -92,7 +93,7 @@ class Boost(db.Model):
|
||||
transaction_id = db.Column(
|
||||
db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
db.ForeignKey("transactions.id"), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
|
||||
|
||||
listing = db.relationship("Listing",
|
||||
backref=db.backref("boosts", lazy="selectin"))
|
||||
@@ -102,7 +103,7 @@ class Boost(db.Model):
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
return self.expires_at > datetime.utcnow()
|
||||
return self.expires_at > utcnow()
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Boost {self.type} L{self.listing_id} exp={self.expires_at.date()}>"
|
||||
|
||||
+4
-3
@@ -1,5 +1,6 @@
|
||||
"""Plan model. Tier limits live in `config` JSON, editable in admin without redeploy."""
|
||||
from datetime import datetime
|
||||
from app.utils.time import utcnow
|
||||
from sqlalchemy import JSON
|
||||
from app.extensions import db
|
||||
|
||||
@@ -16,9 +17,9 @@ class Plan(db.Model):
|
||||
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||
sort_order = db.Column(db.Integer, nullable=False, default=0)
|
||||
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=utcnow,
|
||||
onupdate=utcnow, nullable=False)
|
||||
|
||||
users = db.relationship("User", back_populates="tier", lazy="dynamic")
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""User-submitted reports against listings (spam/abuse moderation)."""
|
||||
from datetime import datetime
|
||||
from app.utils.time import utcnow
|
||||
from app.extensions import db
|
||||
from app.models.enums import ReportReason
|
||||
|
||||
@@ -15,7 +16,7 @@ class Report(db.Model):
|
||||
db.ForeignKey("users.id"), nullable=False, index=True)
|
||||
reason = db.Column(db.Enum(ReportReason), nullable=False)
|
||||
note = db.Column(db.Text, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
|
||||
|
||||
listing = db.relationship("Listing", backref=db.backref("reports", lazy="dynamic"))
|
||||
reporter = db.relationship("User", backref=db.backref("reports_filed", lazy="dynamic"))
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Seller reviews — left by buyers after a listing is marked sold."""
|
||||
from datetime import datetime
|
||||
from app.utils.time import utcnow
|
||||
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=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}*>"
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Admin-editable runtime settings (registration_open, flag_threshold, etc.)."""
|
||||
from datetime import datetime
|
||||
from app.utils.time import utcnow
|
||||
from sqlalchemy import JSON
|
||||
from app.extensions import db
|
||||
|
||||
@@ -9,8 +10,8 @@ class Setting(db.Model):
|
||||
|
||||
key = db.Column(db.String(80), primary_key=True)
|
||||
value = db.Column(JSON, nullable=True)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=utcnow,
|
||||
onupdate=utcnow, nullable=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Setting {self.key}>"
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
"""TrustEvent model. Append-only events that adjust a user's trust score."""
|
||||
from datetime import datetime
|
||||
from app.utils.time import utcnow
|
||||
from app.extensions import db
|
||||
from app.models.enums import TrustEventType
|
||||
|
||||
@@ -11,7 +12,7 @@ class TrustEvent(db.Model):
|
||||
user_id = db.Column(db.BigInteger, db.ForeignKey("users.id"), nullable=False, index=True)
|
||||
type = db.Column(db.Enum(TrustEventType), nullable=False)
|
||||
delta = db.Column(db.Integer, nullable=False, default=0)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
|
||||
|
||||
user = db.relationship("User", back_populates="trust_events")
|
||||
|
||||
|
||||
+4
-3
@@ -1,5 +1,6 @@
|
||||
"""User model. Argon2 password hashing, role/trust fields, Flask-Login mixin."""
|
||||
from datetime import datetime
|
||||
from app.utils.time import utcnow
|
||||
from flask_login import UserMixin
|
||||
from app.extensions import db
|
||||
from app.models.enums import Role, UserStatus, TrustTier
|
||||
@@ -28,9 +29,9 @@ class User(UserMixin, db.Model):
|
||||
email_verified = db.Column(db.Boolean, nullable=False, default=False)
|
||||
|
||||
last_login_at = db.Column(db.DateTime, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=utcnow,
|
||||
onupdate=utcnow, nullable=False)
|
||||
|
||||
trust_events = db.relationship("TrustEvent", back_populates="user",
|
||||
lazy="dynamic", cascade="all, delete-orphan")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Admin dashboard KPI queries. Money returned as integer cents."""
|
||||
from datetime import datetime, timedelta
|
||||
from app.utils.time import utcnow
|
||||
from app.extensions import db
|
||||
from app.models.user import User
|
||||
from app.models.listing import Listing
|
||||
@@ -13,12 +14,12 @@ _ACTIVE_SUB_STATUSES = ("active", "trialing")
|
||||
def active_listings_count():
|
||||
return Listing.query.filter(
|
||||
Listing.status == ListingStatus.active,
|
||||
Listing.expires_at > datetime.utcnow(),
|
||||
Listing.expires_at > utcnow(),
|
||||
).count()
|
||||
|
||||
|
||||
def new_users_count(days):
|
||||
since = datetime.utcnow() - timedelta(days=days)
|
||||
since = utcnow() - timedelta(days=days)
|
||||
return User.query.filter(User.created_at >= since).count()
|
||||
|
||||
|
||||
@@ -32,7 +33,7 @@ def mrr_cents():
|
||||
|
||||
def revenue_30d_cents():
|
||||
"""Subscription + boost transactions in the last 30 days."""
|
||||
since = datetime.utcnow() - timedelta(days=30)
|
||||
since = utcnow() - timedelta(days=30)
|
||||
return (db.session.query(db.func.coalesce(db.func.sum(Transaction.amount_cents), 0))
|
||||
.filter(Transaction.type.in_(("subscription", "boost")),
|
||||
Transaction.created_at >= since,
|
||||
|
||||
+5
-4
@@ -20,6 +20,7 @@ Sponsors:
|
||||
"""
|
||||
import random
|
||||
from datetime import datetime
|
||||
from app.utils.time import utcnow
|
||||
from app.extensions import db
|
||||
from app.models.ads import Ad, Sponsor, PromotedKeyword
|
||||
from app.models.listing import Listing
|
||||
@@ -28,7 +29,7 @@ from app.utils.text import normalize
|
||||
|
||||
def get_ad(slot: str, lang: str = None, state: str = None) -> "Ad | None":
|
||||
"""Return one active ad for the slot, targeted then untargeted fallback."""
|
||||
now = datetime.utcnow()
|
||||
now = utcnow()
|
||||
base = Ad.query.filter(
|
||||
Ad.slot == slot,
|
||||
Ad.is_active == True,
|
||||
@@ -76,7 +77,7 @@ def promoted_listings(keyword: str) -> list:
|
||||
if not keyword:
|
||||
return []
|
||||
norm = normalize(keyword)
|
||||
now = datetime.utcnow()
|
||||
now = utcnow()
|
||||
rows = (PromotedKeyword.query
|
||||
.filter(PromotedKeyword.expires_at > now)
|
||||
.order_by(PromotedKeyword.priority.desc())
|
||||
@@ -94,7 +95,7 @@ def promoted_listings(keyword: str) -> list:
|
||||
|
||||
def active_sponsors(tier: str = None, category_id: int = None) -> list:
|
||||
"""Return running sponsors, optionally filtered by tier and category."""
|
||||
now = datetime.utcnow()
|
||||
now = utcnow()
|
||||
q = Sponsor.query.filter(
|
||||
Sponsor.is_active == True,
|
||||
Sponsor.starts_at <= now,
|
||||
@@ -109,7 +110,7 @@ def active_sponsors(tier: str = None, category_id: int = None) -> list:
|
||||
|
||||
def expire_promoted_keywords() -> int:
|
||||
"""Remove expired promoted keyword rows. Returns count."""
|
||||
now = datetime.utcnow()
|
||||
now = utcnow()
|
||||
expired = PromotedKeyword.query.filter(
|
||||
PromotedKeyword.expires_at <= now).all()
|
||||
n = len(expired)
|
||||
|
||||
@@ -9,6 +9,7 @@ Key rules:
|
||||
- stripe_enabled() guard lets the app run with no keys in dev.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from app.utils.time import utcnow
|
||||
import stripe
|
||||
from flask import current_app
|
||||
from app.extensions import db
|
||||
@@ -140,7 +141,7 @@ def activate_boost(user_id: int, listing_id: int, boost_type: str,
|
||||
db.session.add(txn)
|
||||
db.session.flush()
|
||||
|
||||
expires_at = datetime.utcnow() + timedelta(days=info["days"])
|
||||
expires_at = utcnow() + timedelta(days=info["days"])
|
||||
boost = Boost(listing_id=listing_id, user_id=user_id,
|
||||
type=boost_type, expires_at=expires_at,
|
||||
transaction_id=txn.id)
|
||||
@@ -152,7 +153,7 @@ def activate_boost(user_id: int, listing_id: int, boost_type: str,
|
||||
if boost_type == "featured":
|
||||
listing.is_featured = True
|
||||
if boost_type == "bump":
|
||||
listing.bump_at = datetime.utcnow()
|
||||
listing.bump_at = utcnow()
|
||||
|
||||
db.session.commit()
|
||||
return boost
|
||||
@@ -307,7 +308,7 @@ def _record_sub_transaction(user_id, session):
|
||||
|
||||
def expire_boosts() -> int:
|
||||
"""Clear expired boosts and revert listing effects. Returns count."""
|
||||
now = datetime.utcnow()
|
||||
now = utcnow()
|
||||
expired = Boost.query.filter(Boost.expires_at <= now).all()
|
||||
n = 0
|
||||
for boost in expired:
|
||||
|
||||
@@ -10,14 +10,17 @@ moderators can spot scraping attempts.
|
||||
"""
|
||||
import re
|
||||
from datetime import datetime
|
||||
from app.utils.time import utcnow
|
||||
from app.models.enums import TrustTier
|
||||
from app.services.settings import get_setting
|
||||
|
||||
# patterns
|
||||
_PHONE_RE = re.compile(
|
||||
r"(\+?1[\s\-.]?)?"
|
||||
r"(\(?\d{3}\)?[\s\-.])"
|
||||
r"\d{3}[\s\-.]\d{4}"
|
||||
r"(?<!\d)" # not mid-way through a longer digit run
|
||||
r"(?:\+?1[\s\-.]?)?" # optional country code
|
||||
r"\(?\d{3}\)?[\s\-.]?" # area code — separators now optional
|
||||
r"\d{3}[\s\-.]?\d{4}" # so 5551234567 is caught, not just 555-123-4567
|
||||
r"(?!\d)"
|
||||
)
|
||||
_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)
|
||||
@@ -32,7 +35,7 @@ def contact_revealed(user) -> bool:
|
||||
return False
|
||||
gate_days = get_setting("new_user_trust_gate_days", 0)
|
||||
if gate_days and user.created_at:
|
||||
if (datetime.utcnow() - user.created_at).days < gate_days:
|
||||
if (utcnow() - user.created_at).days < gate_days:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""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 app.utils.time import utcnow
|
||||
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] = utcnow().isoformat()
|
||||
listing.attributes = attrs
|
||||
|
||||
|
||||
def warn_expiring(days: int = 3) -> int:
|
||||
"""Send warning emails for listings expiring within `days` days. Returns count."""
|
||||
now = 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
|
||||
+2
-1
@@ -4,6 +4,7 @@ Portable across MySQL and SQLite (no spatial extension needed). For large-scale
|
||||
deployments, see README for the MySQL POINT + SPATIAL INDEX upgrade.
|
||||
"""
|
||||
import math
|
||||
from app.extensions import db
|
||||
from app.models.geo import ZipGeo
|
||||
|
||||
EARTH_MI = 3958.7613 # mean earth radius, miles
|
||||
@@ -11,7 +12,7 @@ EARTH_MI = 3958.7613 # mean earth radius, miles
|
||||
|
||||
def geocode_zip(zip_code):
|
||||
"""Return (lat, lng, city, state, metro) or None."""
|
||||
row = ZipGeo.query.get((zip_code or "").strip())
|
||||
row = db.session.get(ZipGeo, (zip_code or "").strip())
|
||||
if row is None:
|
||||
return None
|
||||
return row.lat, row.lng, row.city, row.state, row.metro
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Listing business logic: creation/edit with tier enforcement, the
|
||||
browse/search/radius query builder, and the expiry sweep.
|
||||
"""
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from app.utils.time import utcnow
|
||||
from app.extensions import db
|
||||
from app.models.listing import Listing
|
||||
from app.models.enums import ListingStatus, Lang
|
||||
@@ -30,7 +32,7 @@ def active_count(user):
|
||||
return Listing.query.filter(
|
||||
Listing.user_id == user.id,
|
||||
Listing.status == ListingStatus.active,
|
||||
Listing.expires_at > datetime.utcnow(),
|
||||
Listing.expires_at > utcnow(),
|
||||
).count()
|
||||
|
||||
|
||||
@@ -55,7 +57,15 @@ def _blocklist_hit(title, body):
|
||||
if not blocklist:
|
||||
return False
|
||||
hay = normalize(f"{title} {body}")
|
||||
return any(normalize(term) in hay for term in blocklist if term)
|
||||
for term in blocklist:
|
||||
if not term:
|
||||
continue
|
||||
t = normalize(term).strip()
|
||||
# word-boundary match so "ass" doesn't flag "class"; multi-word
|
||||
# phrases still match as an internal-boundaried run.
|
||||
if t and re.search(rf"\b{re.escape(t)}\b", hay):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# --- create / update ---
|
||||
@@ -78,7 +88,7 @@ def create_listing(user, category, *, title, body, lang, price_cents,
|
||||
price_cents=price_cents,
|
||||
attributes=cleaned,
|
||||
status=ListingStatus.active,
|
||||
expires_at=datetime.utcnow() + timedelta(days=_life_days(user)),
|
||||
expires_at=utcnow() + timedelta(days=_life_days(user)),
|
||||
**hot_values(cleaned),
|
||||
)
|
||||
if _blocklist_hit(title, body):
|
||||
@@ -125,7 +135,7 @@ def browse_query(*, category_id=None, q=None, state=None, min_price=None,
|
||||
"""Base query of live listings with optional filters (no radius)."""
|
||||
query = Listing.query.filter(
|
||||
Listing.status == ListingStatus.active,
|
||||
Listing.expires_at > datetime.utcnow(),
|
||||
Listing.expires_at > utcnow(),
|
||||
)
|
||||
if category_id:
|
||||
query = query.filter(Listing.category_id == category_id)
|
||||
@@ -186,7 +196,7 @@ def search_with_radius(base_query, lat, lng, radius_mi):
|
||||
# --- expiry sweep (scheduler / CLI) ---
|
||||
def expire_due_listings():
|
||||
"""Flip active listings past expires_at to expired. Returns count."""
|
||||
now = datetime.utcnow()
|
||||
now = utcnow()
|
||||
due = Listing.query.filter(
|
||||
Listing.status == ListingStatus.active,
|
||||
Listing.expires_at <= now,
|
||||
|
||||
@@ -8,6 +8,7 @@ Rules:
|
||||
for now sent inline — fast enough at low volume).
|
||||
"""
|
||||
from datetime import datetime
|
||||
from app.utils.time import utcnow
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from flask import current_app
|
||||
from app.extensions import db
|
||||
@@ -67,7 +68,7 @@ def send_message(conversation, sender, body: str) -> Message:
|
||||
body=body,
|
||||
)
|
||||
db.session.add(msg)
|
||||
conversation.last_message_at = datetime.utcnow()
|
||||
conversation.last_message_at = utcnow()
|
||||
db.session.commit()
|
||||
|
||||
if flagged:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
+274
-147
@@ -1,203 +1,330 @@
|
||||
/* =========================================================================
|
||||
Classifieds — "Stoop" warm-marketplace theme
|
||||
Terracotta + cream, Newsreader serif headings / Hanken Grotesk body.
|
||||
Drop-in replacement for app/static/style.css. Class contract unchanged.
|
||||
========================================================================= */
|
||||
|
||||
:root{
|
||||
--bg:#f6f7f9; --fg:#1c2430; --muted:#6b7785; --line:#e3e7ec;
|
||||
--brand:#1f6feb; --brand-d:#1a5fd0; --ok:#1f9d55; --warn:#b7791f;
|
||||
--danger:#d64545; --info:#2b6cb0; --card:#fff;
|
||||
/* accent */
|
||||
--accent:#C15F3C; --accent-h:#A44E2F; --accent-soft:#FBEFE7;
|
||||
/* surfaces */
|
||||
--page:#F7F1E7; --page-top:#F3E7D2; --card:#FFFDF8; --input:#FDFBF6;
|
||||
--panel:#EFE7D6; --pill:#F7EEDD; --ink-panel:#2B2723;
|
||||
/* lines */
|
||||
--line:#E6DCCB; --line-2:#EFE7D8; --line-strong:#D8CDB8;
|
||||
/* text */
|
||||
--ink:#2B2723; --body:#4A433B; --muted:#6F665B; --muted-2:#8A8072; --faint:#A99B85;
|
||||
/* status */
|
||||
--ok:#4C7A5B; --ok-bg:#E6EFE6; --ok-line:#CBDDCB;
|
||||
--warn:#B7893B; --warn-bg:#F6ECD7; --warn-line:#E7D3A6;
|
||||
--danger:#B4472F; --danger-bg:#F6E2DB; --danger-line:#E6C3B6;
|
||||
--info:#3E6DA3; --info-bg:#E4ECF4; --info-line:#CBDAEA;
|
||||
--gold:#DFA24E;
|
||||
/* tints */
|
||||
--tint-a:#F3E0D4; --tint-b:#E2E9DC; --tint-c:#E6E2D2;
|
||||
/* type */
|
||||
--serif:'Newsreader',Georgia,'Times New Roman',serif;
|
||||
--sans:'Hanken Grotesk',system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
|
||||
--mono:'Space Mono',ui-monospace,SFMono-Regular,Menlo,monospace;
|
||||
/* aliases kept for any legacy refs */
|
||||
--bg:var(--page); --fg:var(--ink); --brand:var(--accent); --brand-d:var(--accent-h);
|
||||
}
|
||||
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;font:16px/1.5 system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
|
||||
background:var(--bg);color:var(--fg)}
|
||||
.wrap{max-width:960px;margin:0 auto;padding:0 16px}
|
||||
a{color:var(--brand);text-decoration:none}
|
||||
a:hover{text-decoration:underline}
|
||||
html,body{margin:0;padding:0}
|
||||
body{
|
||||
font:16px/1.5 var(--sans);
|
||||
background:var(--page);color:var(--ink);
|
||||
-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;
|
||||
}
|
||||
.wrap{max-width:1180px;margin:0 auto;padding:0 24px}
|
||||
a{color:var(--accent);text-decoration:none}
|
||||
a:hover{color:var(--accent-h)}
|
||||
::placeholder{color:var(--faint)}
|
||||
|
||||
.site-header{background:var(--card);border-bottom:1px solid var(--line)}
|
||||
.site-header .wrap{display:flex;align-items:center;justify-content:space-between;height:56px}
|
||||
.brand{font-weight:700;font-size:18px;color:var(--fg)}
|
||||
.nav{display:flex;align-items:center;gap:14px}
|
||||
.nav .hi{color:var(--muted)}
|
||||
.langs{display:flex;gap:6px;margin-left:8px}
|
||||
.langs a{font-size:12px;color:var(--muted);border:1px solid var(--line);
|
||||
padding:2px 6px;border-radius:4px}
|
||||
.langs a.on{background:var(--brand);color:#fff;border-color:var(--brand)}
|
||||
h1,h2,h3{font-family:var(--serif);font-weight:500;letter-spacing:-.01em;color:var(--ink)}
|
||||
h1{font-size:clamp(28px,4vw,40px);line-height:1.08}
|
||||
h2{font-size:26px} h3{font-size:19px;font-weight:600}
|
||||
|
||||
.btn{display:inline-block;background:var(--brand);color:#fff;border:0;
|
||||
padding:9px 16px;border-radius:8px;cursor:pointer;font-size:15px}
|
||||
.btn:hover{background:var(--brand-d);text-decoration:none}
|
||||
.btn-lg{padding:12px 22px;font-size:17px}
|
||||
/* ---------- header ---------- */
|
||||
.site-header{
|
||||
position:sticky;top:0;z-index:50;
|
||||
background:rgba(247,241,231,.88);backdrop-filter:blur(10px);
|
||||
border-bottom:1px solid var(--line);
|
||||
}
|
||||
.site-header .wrap{display:flex;align-items:center;justify-content:space-between;
|
||||
gap:20px;min-height:60px;padding-top:12px;padding-bottom:12px}
|
||||
.brand{display:inline-flex;align-items:center;gap:10px;
|
||||
font-family:var(--serif);font-weight:600;font-size:23px;letter-spacing:-.01em;color:var(--ink)}
|
||||
.brand:hover{color:var(--ink)}
|
||||
.brand-mark{width:34px;height:34px;border-radius:11px;background:var(--accent);
|
||||
display:inline-flex;align-items:center;justify-content:center;
|
||||
color:#FFF7EE;font-family:var(--serif);font-size:21px;font-weight:600;line-height:1}
|
||||
.nav{display:flex;align-items:center;gap:20px;flex-wrap:wrap}
|
||||
.nav a{font-size:15px;font-weight:600;color:var(--muted);padding:4px 1px;
|
||||
border-bottom:2px solid transparent}
|
||||
.nav a:hover{color:var(--accent)}
|
||||
.nav a.on{color:var(--accent);border-bottom-color:var(--accent)}
|
||||
.nav .hi{color:var(--muted-2);font-weight:600;font-size:14px}
|
||||
.langs{display:flex;gap:6px;margin-left:6px}
|
||||
.langs a{font-size:12px;font-weight:600;color:var(--muted-2);border:1px solid var(--line);
|
||||
padding:3px 8px;border-radius:999px}
|
||||
.langs a:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.langs a.on{background:var(--accent);color:#FFF7EE;border-color:var(--accent)}
|
||||
|
||||
main.wrap{padding-top:24px;padding-bottom:48px;display:block;width:100%}
|
||||
.hero{text-align:center;padding:48px 0}
|
||||
.hero h1{font-size:32px;margin:0 0 8px}
|
||||
.hero p{color:var(--muted);margin:0 0 24px}
|
||||
/* ---------- buttons ---------- */
|
||||
.btn{display:inline-block;background:var(--accent);color:#FFF7EE;border:1px solid var(--accent);
|
||||
padding:10px 18px;border-radius:11px;cursor:pointer;font:600 15px/1 var(--sans);
|
||||
transition:filter .15s,border-color .15s,color .15s}
|
||||
.btn:hover{filter:brightness(.93);color:#FFF7EE}
|
||||
.btn-lg{padding:13px 24px;font-size:16px}
|
||||
.btn.ghost{background:var(--card);color:var(--ink);border-color:var(--line-strong)}
|
||||
.btn.ghost:hover{filter:none;border-color:var(--accent);color:var(--accent)}
|
||||
.btn.danger{background:var(--danger);border-color:var(--danger);color:#FFF7EE}
|
||||
.btn.danger:hover{color:#FFF7EE}
|
||||
.btn.tiny{padding:5px 11px;font-size:12px;border-radius:8px}
|
||||
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;
|
||||
padding:24px}
|
||||
.card.narrow{max-width:420px;margin:0 auto}
|
||||
/* ---------- hero ---------- */
|
||||
.hero{padding:60px 0 52px;
|
||||
background:linear-gradient(180deg,var(--page-top) 0%,var(--page) 100%);
|
||||
border-bottom:1px solid var(--line);margin:0 -24px 0;padding-left:24px;padding-right:24px}
|
||||
.hero h1{margin:0 0 14px;font-size:clamp(36px,5.2vw,58px);max-width:16ch;text-wrap:balance}
|
||||
.hero p{color:var(--muted);margin:0 0 28px;font-size:19px;max-width:54ch}
|
||||
.hero .btn{margin-right:10px}
|
||||
|
||||
/* ---------- cards / forms ---------- */
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:15px;padding:24px}
|
||||
.card.narrow{max-width:440px;margin:0 auto}
|
||||
.card h2{margin-top:0}
|
||||
|
||||
.field{margin-bottom:14px;display:flex;flex-direction:column;gap:4px}
|
||||
.field label{font-size:14px;color:var(--muted)}
|
||||
.input{padding:9px 11px;border:1px solid var(--line);border-radius:8px;font-size:15px}
|
||||
.input:focus{outline:2px solid var(--brand);border-color:var(--brand)}
|
||||
.check{display:flex;align-items:center;gap:6px;font-size:14px;color:var(--muted);
|
||||
margin-bottom:14px}
|
||||
.field{margin-bottom:16px;display:flex;flex-direction:column;gap:6px}
|
||||
.field label{font-size:14px;font-weight:600;color:var(--ink)}
|
||||
.input{width:100%;padding:11px 13px;border:1px solid var(--line);border-radius:10px;
|
||||
background:var(--input);font:15px var(--sans);color:var(--ink)}
|
||||
.input:focus{outline:none;border-color:var(--accent)}
|
||||
select.input{cursor:pointer}
|
||||
.check{display:flex;align-items:center;gap:8px;font-size:14px;color:var(--muted);margin-bottom:16px}
|
||||
input[type=checkbox],input[type=radio]{accent-color:var(--accent)}
|
||||
.errors{color:var(--danger);font-size:13px;margin:2px 0 0;padding-left:18px}
|
||||
.muted{color:var(--muted);font-size:14px;margin-top:14px}
|
||||
.small{font-size:12px}
|
||||
.form-wide{max-width:660px;margin:0 auto}
|
||||
.row2{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:14px}
|
||||
|
||||
.flashes{margin-bottom:18px;display:flex;flex-direction:column;gap:8px}
|
||||
.flash{padding:10px 14px;border-radius:8px;border:1px solid var(--line)}
|
||||
.flash-success{background:#e8f6ee;border-color:#bfe3cd;color:var(--ok)}
|
||||
.flash-danger{background:#fdeaea;border-color:#f4c4c4;color:var(--danger)}
|
||||
.flash-warning{background:#fdf4e3;border-color:#f0dcae;color:var(--warn)}
|
||||
.flash-info{background:#e9f1fb;border-color:#cadcf3;color:var(--info)}
|
||||
/* ---------- flashes ---------- */
|
||||
.flashes{margin:18px 0;display:flex;flex-direction:column;gap:8px}
|
||||
.flash{padding:11px 15px;border-radius:11px;border:1px solid var(--line);font-size:14px;
|
||||
transition:opacity .4s}
|
||||
.flash-success{background:var(--ok-bg);border-color:var(--ok-line);color:var(--ok)}
|
||||
.flash-danger{background:var(--danger-bg);border-color:var(--danger-line);color:var(--danger)}
|
||||
.flash-warning{background:var(--warn-bg);border-color:var(--warn-line);color:var(--warn)}
|
||||
.flash-info{background:var(--info-bg);border-color:var(--info-line);color:var(--info)}
|
||||
|
||||
.site-footer{border-top:1px solid var(--line);color:var(--muted);
|
||||
font-size:13px;padding:18px 0}
|
||||
/* ---------- footer ---------- */
|
||||
.site-footer{border-top:1px solid var(--line);color:var(--muted);font-size:14px;
|
||||
padding:28px 0;margin-top:48px}
|
||||
.site-footer a{font-weight:600}
|
||||
.cf-turnstile{margin:0 0 14px}
|
||||
|
||||
/* --- Phase 2: listings --- */
|
||||
.btn.ghost{background:#fff;color:var(--brand);border:1px solid var(--brand)}
|
||||
.btn.danger{background:var(--danger)}
|
||||
.btn.tiny{padding:2px 8px;font-size:12px;border-radius:6px}
|
||||
.row2{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||
.form-wide{max-width:640px;margin:0 auto}
|
||||
.browse{display:grid;grid-template-columns:260px 1fr;gap:20px;align-items:start;width:100%}
|
||||
.browse .filters{min-width:0}
|
||||
/* ---------- browse / grid / tiles ---------- */
|
||||
main.wrap{padding-top:28px;padding-bottom:56px;display:block;width:100%}
|
||||
.browse{display:grid;grid-template-columns:270px 1fr;gap:28px;align-items:start;width:100%}
|
||||
.browse .filters{min-width:0;position:sticky;top:84px}
|
||||
.browse .results{min-width:0;width:100%}
|
||||
.filters h3{margin-top:0}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,220px));gap:14px}
|
||||
.tile{padding:0;overflow:hidden;display:block;color:var(--fg);width:100%;max-width:220px}
|
||||
.tile:hover{text-decoration:none;box-shadow:0 2px 10px rgba(0,0,0,.08)}
|
||||
.thumb{width:100%;height:140px;object-fit:cover;display:block;background:#eef1f4}
|
||||
.thumb.noimg{display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:13px}
|
||||
.tile-body{padding:10px}
|
||||
.tile-title{font-weight:600;font-size:14px;line-height:1.3;margin-bottom:4px}
|
||||
.tile-meta{display:flex;gap:8px;align-items:center}
|
||||
.price{color:var(--ok);font-weight:700}
|
||||
.price.big{font-size:24px}
|
||||
.small{font-size:12px}
|
||||
.badge{display:inline-block;font-size:11px;padding:2px 7px;border-radius:10px;background:#eef1f4;color:var(--muted)}
|
||||
.badge.ok{background:#e8f6ee;color:var(--ok)}
|
||||
.badge.warn{background:#fdf4e3;color:var(--warn)}
|
||||
.badge.cat{background:#e9f1fb;color:var(--info)}
|
||||
.pager{margin-top:18px;display:flex;gap:16px}
|
||||
.detail{display:grid;grid-template-columns:1fr 280px;gap:20px;align-items:start}
|
||||
.detail-meta{display:flex;gap:10px;align-items:center;margin:8px 0}
|
||||
.gallery{display:flex;flex-wrap:wrap;gap:8px;margin:12px 0}
|
||||
.gallery img{max-width:220px;border-radius:8px}
|
||||
.body{margin:14px 0;line-height:1.6}
|
||||
.attrs{border-collapse:collapse;width:100%}
|
||||
.attrs th,.attrs td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--line)}
|
||||
.attrs th{color:var(--muted);font-weight:600;width:40%}
|
||||
.detail-side .btn{display:block;margin-bottom:8px;text-align:center}
|
||||
.detail-side form{margin:0}
|
||||
.seller{margin-bottom:14px}
|
||||
.mine-head{display:flex;align-items:center;gap:14px;margin-bottom:14px}
|
||||
.filters h3{margin:0 0 14px}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:18px}
|
||||
.tile{padding:0;overflow:hidden;display:flex;flex-direction:column;color:var(--ink);
|
||||
width:100%;background:var(--card);border:1px solid var(--line);border-radius:15px;
|
||||
transition:border-color .15s,box-shadow .15s,transform .15s}
|
||||
.tile:hover{border-color:var(--line-strong);
|
||||
box-shadow:0 14px 30px -20px rgba(43,39,35,.4);transform:translateY(-2px);color:var(--ink)}
|
||||
.thumb{width:100%;height:170px;aspect-ratio:4/3;object-fit:cover;display:block;
|
||||
background-image:repeating-linear-gradient(135deg,#EFE6D6 0 11px,#F4EDE0 11px 22px)}
|
||||
.thumb.noimg{display:flex;align-items:center;justify-content:center;
|
||||
color:var(--faint);font:11px var(--mono)}
|
||||
.tile-body{padding:13px 14px 15px}
|
||||
.tile-title{font-weight:600;font-size:15px;line-height:1.3;margin-bottom:5px;color:var(--ink)}
|
||||
.tile-meta{display:flex;gap:8px;align-items:center;margin-bottom:4px}
|
||||
.price{color:var(--accent);font-weight:700;font-size:18px}
|
||||
.price.big{font-size:34px;font-weight:700}
|
||||
.pager{margin-top:28px;display:flex;gap:8px;align-items:center;justify-content:center}
|
||||
.pager a{background:var(--card);border:1px solid var(--line);border-radius:9px;
|
||||
padding:9px 15px;font-size:14px;font-weight:600;color:var(--ink)}
|
||||
.pager a:hover{border-color:var(--accent);color:var(--accent)}
|
||||
|
||||
/* ---------- badges ---------- */
|
||||
.badge{display:inline-block;font-size:12px;font-weight:600;padding:4px 10px;border-radius:999px;
|
||||
background:var(--pill);color:var(--muted);border:1px solid transparent}
|
||||
.badge.ok{background:var(--ok-bg);color:var(--ok);border-color:var(--ok-line)}
|
||||
.badge.warn{background:var(--warn-bg);color:var(--warn);border-color:var(--warn-line)}
|
||||
.badge.cat{background:var(--tint-a);color:#8A4A2E}
|
||||
.badge.expiring{background:var(--warn-bg);color:var(--warn);border-color:var(--warn-line)}
|
||||
.badge.sponsored{background:var(--pill);color:var(--warn);border-color:var(--warn-line)}
|
||||
|
||||
/* ---------- detail ---------- */
|
||||
.detail{display:grid;grid-template-columns:1fr 300px;gap:28px;align-items:start}
|
||||
.detail-main h1{margin:0 0 12px}
|
||||
.detail-meta{display:flex;gap:10px;align-items:center;margin:10px 0}
|
||||
.detail-side{position:sticky;top:84px}
|
||||
.detail-side .btn{display:block;margin-bottom:9px;text-align:center;width:100%}
|
||||
.detail-side form{margin:0 0 9px}
|
||||
.seller{margin-bottom:16px;padding-bottom:14px;border-bottom:1px solid var(--line-2)}
|
||||
.gallery{display:flex;flex-wrap:wrap;gap:10px;margin:14px 0}
|
||||
.gallery img{max-width:220px;border-radius:12px;border:1px solid var(--line)}
|
||||
.body{margin:16px 0;line-height:1.65;color:var(--body);font-size:16px}
|
||||
.attrs{border-collapse:collapse;width:100%;margin-top:8px}
|
||||
.attrs th,.attrs td{text-align:left;padding:11px 4px;border-bottom:1px solid var(--line-2);font-size:15px}
|
||||
.attrs th{color:var(--muted-2);font-weight:600;width:40%}
|
||||
.report-form{display:flex;flex-direction:column;gap:6px;margin-top:12px;
|
||||
border-top:1px solid var(--line-2);padding-top:12px}
|
||||
|
||||
/* mine / tables */
|
||||
.mine-head{display:flex;align-items:center;gap:14px;margin-bottom:16px}
|
||||
.mine-head .btn{margin-left:auto}
|
||||
table.list{width:100%;border-collapse:collapse}
|
||||
table.list td{padding:8px 10px;border-bottom:1px solid var(--line)}
|
||||
table.list td{padding:11px 12px;border-bottom:1px solid var(--line-2)}
|
||||
.thumbs{display:flex;gap:10px;flex-wrap:wrap}
|
||||
.thumb-wrap{position:relative}
|
||||
.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}}
|
||||
.thumb-wrap img{width:90px;height:90px;object-fit:cover;border-radius:10px;border:1px solid var(--line)}
|
||||
.thumb-wrap form{position:absolute;top:3px;right:3px;margin:0}
|
||||
|
||||
/* --- Phase 3: messaging + favorites --- */
|
||||
/* landing grids */
|
||||
.listing-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:18px;margin-top:18px}
|
||||
.listing-card{display:flex;flex-direction:column;background:var(--card);
|
||||
border:1px solid var(--line);border-radius:15px;overflow:hidden;text-decoration:none;
|
||||
color:var(--ink);transition:border-color .15s,box-shadow .15s,transform .15s}
|
||||
.listing-card:hover{border-color:var(--line-strong);
|
||||
box-shadow:0 14px 30px -20px rgba(43,39,35,.4);transform:translateY(-2px);color:var(--ink)}
|
||||
.listing-card img{width:100%;aspect-ratio:4/3;object-fit:cover}
|
||||
.listing-info{padding:13px 14px 15px}
|
||||
.listing-title{font-weight:600;margin-bottom:4px;
|
||||
overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
|
||||
|
||||
/* ---------- messaging ---------- */
|
||||
.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;
|
||||
background:var(--accent);color:#FFF7EE;border-radius:999px;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{display:flex;align-items:center;gap:10px;margin-bottom:18px}
|
||||
.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-list{list-style:none;padding:0;margin:0;background:var(--card);
|
||||
border:1px solid var(--line);border-radius:15px;overflow:hidden}
|
||||
.conv-row{border-top:1px solid var(--line-2);padding:0}
|
||||
.conv-row:first-child{border-top:none}
|
||||
.conv-row a{display:block;text-decoration:none;color:var(--ink);padding:14px 16px}
|
||||
.conv-row a:hover{background:#FBF6EC;color:var(--ink)}
|
||||
.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-time{margin-left:auto;color:var(--faint);font-size:12px}
|
||||
.conv-listing,.conv-preview{margin-top:2px;color:var(--muted);font-size:14px}
|
||||
.conv-wrap{display:flex;flex-direction:column;gap:16px;max-width:700px;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{max-width:80%;padding:12px 15px;border-radius:16px;line-height:1.5;font-size:15px}
|
||||
.bubble.mine{align-self:flex-end;background:var(--accent);color:#FFF7EE;border-bottom-right-radius:5px}
|
||||
.bubble.theirs{align-self:flex-start;background:var(--card);border:1px solid var(--line);
|
||||
border-bottom-left-radius:5px;color:var(--ink)}
|
||||
.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}
|
||||
.mask-notice{background:var(--warn-bg);border:1px solid var(--warn-line);border-radius:10px;
|
||||
padding:9px 13px;margin-top:8px;font-size:14px;color:var(--warn)}
|
||||
|
||||
/* --- Phase 4: payments / pricing --- */
|
||||
.pricing-wrap{max-width:900px;margin:0 auto;padding:24px 0}
|
||||
.pricing-head{text-align:center;margin-bottom:32px}
|
||||
.pricing-head h1{font-size:28px;margin:0 0 8px}
|
||||
.plan-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:20px}
|
||||
.plan-card{position:relative;display:flex;flex-direction:column;gap:0}
|
||||
.plan-card.current{border-color:var(--brand);box-shadow:0 0 0 2px var(--brand)}
|
||||
.plan-badge{position:absolute;top:-10px;left:50%;transform:translateX(-50%);
|
||||
background:var(--brand);color:#fff;font-size:11px;padding:2px 10px;
|
||||
border-radius:10px;white-space:nowrap}
|
||||
.plan-name{font-weight:700;font-size:16px;margin-bottom:8px}
|
||||
.plan-price{margin-bottom:16px}
|
||||
.price-amount{font-size:28px;font-weight:800;color:var(--fg)}
|
||||
.price-period{color:var(--muted);font-size:14px}
|
||||
.plan-features{list-style:none;padding:0;margin:0 0 20px;flex:1;
|
||||
display:flex;flex-direction:column;gap:6px;font-size:14px;color:var(--muted)}
|
||||
.plan-features .feat-yes{color:var(--ok)}
|
||||
.plan-features .feat-yes::before{content:"✓ "}
|
||||
/* ---------- pricing / billing ---------- */
|
||||
.pricing-wrap{max-width:960px;margin:0 auto;padding:44px 0}
|
||||
.pricing-head{text-align:center;margin-bottom:40px}
|
||||
.pricing-head h1{font-size:clamp(30px,4vw,44px);margin:0 0 12px}
|
||||
.pricing-head .muted{font-size:18px}
|
||||
.plan-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:20px;align-items:stretch}
|
||||
.plan-card{position:relative;display:flex;flex-direction:column;gap:14px;border-radius:18px;padding:28px 24px}
|
||||
.plan-card.current{border:2px solid var(--accent);
|
||||
box-shadow:0 22px 44px -26px rgba(43,39,35,.5)}
|
||||
.plan-badge{position:absolute;top:-13px;left:50%;transform:translateX(-50%);
|
||||
background:var(--accent);color:#FFF7EE;font-size:12px;font-weight:600;
|
||||
padding:5px 14px;border-radius:999px;white-space:nowrap}
|
||||
.plan-name{font-family:var(--serif);font-weight:600;font-size:23px;color:var(--ink)}
|
||||
.plan-price{margin:0}
|
||||
.price-amount{font-size:38px;font-weight:700;letter-spacing:-.02em;color:var(--ink)}
|
||||
.price-period{color:var(--muted-2);font-size:15px}
|
||||
.plan-features{list-style:none;padding:16px 0 0;margin:0;flex:1;border-top:1px solid var(--line-2);
|
||||
display:flex;flex-direction:column;gap:11px;font-size:14px;color:var(--body)}
|
||||
.plan-features li{display:flex;gap:9px;align-items:flex-start}
|
||||
.plan-features .feat-yes{color:var(--body)}
|
||||
.plan-features li::before{content:"✓";color:var(--accent);font-weight:700;flex:none}
|
||||
.plan-btn{display:block;text-align:center;margin-top:auto}
|
||||
.plan-btn.disabled{background:#e3e7ec;color:var(--muted);cursor:default}
|
||||
.billing-wrap{max-width:680px;margin:0 auto}
|
||||
.billing-section{margin-bottom:28px;padding-bottom:24px;border-bottom:1px solid var(--line)}
|
||||
.plan-btn.disabled{background:var(--line);border-color:var(--line);color:var(--muted-2);cursor:default}
|
||||
.plan-btn.disabled:hover{filter:none}
|
||||
.billing-wrap{max-width:700px;margin:0 auto}
|
||||
.billing-section{margin-bottom:28px;padding-bottom:24px;border-bottom:1px solid var(--line-2)}
|
||||
.billing-section:last-child{border-bottom:0}
|
||||
.billing-section h3{margin-top:0}
|
||||
.billing-plan{display:flex;align-items:center;gap:10px;margin-bottom:12px}
|
||||
.billing-actions{display:flex;gap:10px}
|
||||
.boost-options{display:flex;flex-direction:column;gap:10px;margin-bottom:20px}
|
||||
.boost-option{display:flex;align-items:center;gap:12px;padding:14px;
|
||||
border:1px solid var(--line);border-radius:10px;cursor:pointer}
|
||||
.boost-option:has(input:checked){border-color:var(--brand);background:#f0f6ff}
|
||||
border:1px solid var(--line);border-radius:12px;cursor:pointer;background:var(--card)}
|
||||
.boost-option:has(input:checked){border-color:var(--accent);background:var(--accent-soft)}
|
||||
.boost-option.active-boost{opacity:.6;cursor:default}
|
||||
.boost-option input{accent-color:var(--brand)}
|
||||
.boost-info{flex:1}
|
||||
.boost-label{font-weight:600;font-size:14px}
|
||||
.boost-price{font-weight:700;color:var(--ok);font-size:15px}
|
||||
.upgrade-prompt{background:#e9f1fb;border:1px solid #cadcf3;border-radius:8px;
|
||||
padding:10px 14px;margin-bottom:14px;font-size:14px;color:var(--info)}
|
||||
.upgrade-prompt a{font-weight:600;color:var(--brand)}
|
||||
.boost-price{font-weight:700;color:var(--accent);font-size:15px}
|
||||
.upgrade-prompt{background:var(--info-bg);border:1px solid var(--info-line);border-radius:11px;
|
||||
padding:11px 15px;margin-bottom:14px;font-size:14px;color:var(--info)}
|
||||
.upgrade-prompt a{font-weight:600}
|
||||
|
||||
/* --- Phase 5: ads & sponsors --- */
|
||||
.ad-slot{margin:8px 0;text-align:center;position:relative}
|
||||
.ad-label{position:absolute;top:2px;left:4px;font-size:9px;color:var(--muted);
|
||||
background:var(--bg);padding:0 3px;border-radius:3px;opacity:.7;z-index:1}
|
||||
.ad-img{max-width:100%;border-radius:8px;display:block;margin:0 auto}
|
||||
.ad-text-creative{background:#eef1f4;border-radius:8px;padding:12px;
|
||||
/* ---------- ads / sponsors ---------- */
|
||||
.ad-slot{margin:10px 0;text-align:center;position:relative}
|
||||
.ad-label{position:absolute;top:2px;left:6px;font:9px var(--mono);color:var(--faint);
|
||||
background:var(--page);padding:0 4px;border-radius:3px;opacity:.8;z-index:1;text-transform:uppercase;letter-spacing:.08em}
|
||||
.ad-img{max-width:100%;border-radius:12px;display:block;margin:0 auto}
|
||||
.ad-text-creative{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px;
|
||||
font-size:13px;color:var(--muted);text-align:center;min-height:60px;
|
||||
display:flex;align-items:center;justify-content:center}
|
||||
.ad-header{border-bottom:1px solid var(--line);padding:6px 0}
|
||||
.ad-footer{border-top:1px solid var(--line);padding:6px 0}
|
||||
.badge.sponsored{background:#fff8e1;color:#b7791f;border:1px solid #f0dcae}
|
||||
.sponsor-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;margin-top:16px}
|
||||
.sponsor-card{display:flex;flex-direction:column;align-items:center;
|
||||
text-align:center;padding:20px;text-decoration:none;color:var(--fg)}
|
||||
.sponsor-card:hover{box-shadow:0 2px 10px rgba(0,0,0,.08);text-decoration:none}
|
||||
.sponsor-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:18px;margin-top:18px}
|
||||
.sponsor-card{display:flex;flex-direction:column;align-items:center;text-align:center;
|
||||
padding:22px;text-decoration:none;color:var(--ink)}
|
||||
.sponsor-card:hover{box-shadow:0 14px 30px -20px rgba(43,39,35,.4);color:var(--ink)}
|
||||
.sponsor-logo{max-width:140px;max-height:80px;object-fit:contain;margin-bottom:8px}
|
||||
.sponsor-name-only{font-weight:700;font-size:16px;margin-bottom:8px}
|
||||
|
||||
/* --- Phase 6: admin --- */
|
||||
.admin-nav{display:flex;gap:16px;margin-bottom:16px;border-bottom:1px solid var(--line);padding-bottom:10px}
|
||||
/* ---------- admin ---------- */
|
||||
.admin-nav{display:flex;gap:18px;margin-bottom:18px;border-bottom:1px solid var(--line);padding-bottom:12px;flex-wrap:wrap}
|
||||
.admin-nav a{color:var(--muted);font-weight:600}
|
||||
.admin-nav a.on{color:var(--brand)}
|
||||
.kpi-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px;margin-top:14px}
|
||||
.kpi-card{padding:16px}
|
||||
.kpi-label{color:var(--muted);font-size:13px;margin-bottom:6px}
|
||||
.kpi-value{font-size:26px;font-weight:800}
|
||||
.admin-nav a:hover{color:var(--accent)}
|
||||
.admin-nav a.on{color:var(--accent)}
|
||||
.kpi-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px;margin-top:16px}
|
||||
.kpi-card{padding:18px}
|
||||
.kpi-label{color:var(--muted-2);font-size:13px;margin-bottom:6px}
|
||||
.kpi-value{font-size:28px;font-weight:700;font-family:var(--serif)}
|
||||
.admin-filter-row{display:flex;gap:12px;align-items:end;margin-bottom:16px;flex-wrap:wrap}
|
||||
.admin-filter-row .field{margin-bottom:0}
|
||||
.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-2)}
|
||||
.settings-panel textarea.input{min-height:80px;font-family:inherit}
|
||||
|
||||
/* ---------- reviews ---------- */
|
||||
.review-stars{color:var(--gold);font-size:18px}
|
||||
.review-block{border-top:1px solid var(--line-2);padding-top:12px;margin-top:12px}
|
||||
.rating-summary{font-size:22px;font-weight:700;display:flex;align-items:center;gap:8px}
|
||||
|
||||
/* ---------- mobile nav ---------- */
|
||||
.nav-toggle{display:none;background:none;border:none;font-size:22px;cursor:pointer;
|
||||
color:var(--ink);padding:4px 8px;line-height:1}
|
||||
@media(max-width:820px){.browse,.detail{grid-template-columns:1fr}
|
||||
.browse .filters,.detail-side{position:static}}
|
||||
@media(max-width:720px){
|
||||
.site-header .wrap{flex-wrap:wrap;min-height:auto;padding:10px 24px;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:9px 0;width:100%;border-bottom:1px solid var(--line-2)}
|
||||
.nav a.on{border-bottom-color:var(--line-2)}
|
||||
.nav .langs{flex-direction:row;margin-left:0;padding:9px 0;border-bottom:none}
|
||||
.hero{padding-left:24px;padding-right:24px}
|
||||
}
|
||||
|
||||
/* hero eyebrow */
|
||||
.hero-eyebrow{margin:0 0 14px;font-size:14px;font-weight:600;letter-spacing:.08em;
|
||||
text-transform:uppercase;color:var(--accent)}
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
:root{
|
||||
--bg:#f6f7f9; --fg:#1c2430; --muted:#6b7785; --line:#e3e7ec;
|
||||
--brand:#1f6feb; --brand-d:#1a5fd0; --ok:#1f9d55; --warn:#b7791f;
|
||||
--danger:#d64545; --info:#2b6cb0; --card:#fff;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;font:16px/1.5 system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
|
||||
background:var(--bg);color:var(--fg)}
|
||||
.wrap{max-width:960px;margin:0 auto;padding:0 16px}
|
||||
a{color:var(--brand);text-decoration:none}
|
||||
a:hover{text-decoration:underline}
|
||||
|
||||
.site-header{background:var(--card);border-bottom:1px solid var(--line)}
|
||||
.site-header .wrap{display:flex;align-items:center;justify-content:space-between;height:56px}
|
||||
.brand{font-weight:700;font-size:18px;color:var(--fg)}
|
||||
.nav{display:flex;align-items:center;gap:14px}
|
||||
.nav .hi{color:var(--muted)}
|
||||
.langs{display:flex;gap:6px;margin-left:8px}
|
||||
.langs a{font-size:12px;color:var(--muted);border:1px solid var(--line);
|
||||
padding:2px 6px;border-radius:4px}
|
||||
.langs a.on{background:var(--brand);color:#fff;border-color:var(--brand)}
|
||||
|
||||
.btn{display:inline-block;background:var(--brand);color:#fff;border:0;
|
||||
padding:9px 16px;border-radius:8px;cursor:pointer;font-size:15px}
|
||||
.btn:hover{background:var(--brand-d);text-decoration:none}
|
||||
.btn-lg{padding:12px 22px;font-size:17px}
|
||||
|
||||
main.wrap{padding-top:24px;padding-bottom:48px;display:block;width:100%}
|
||||
.hero{text-align:center;padding:48px 0}
|
||||
.hero h1{font-size:32px;margin:0 0 8px}
|
||||
.hero p{color:var(--muted);margin:0 0 24px}
|
||||
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;
|
||||
padding:24px}
|
||||
.card.narrow{max-width:420px;margin:0 auto}
|
||||
.card h2{margin-top:0}
|
||||
|
||||
.field{margin-bottom:14px;display:flex;flex-direction:column;gap:4px}
|
||||
.field label{font-size:14px;color:var(--muted)}
|
||||
.input{padding:9px 11px;border:1px solid var(--line);border-radius:8px;font-size:15px}
|
||||
.input:focus{outline:2px solid var(--brand);border-color:var(--brand)}
|
||||
.check{display:flex;align-items:center;gap:6px;font-size:14px;color:var(--muted);
|
||||
margin-bottom:14px}
|
||||
.errors{color:var(--danger);font-size:13px;margin:2px 0 0;padding-left:18px}
|
||||
.muted{color:var(--muted);font-size:14px;margin-top:14px}
|
||||
|
||||
.flashes{margin-bottom:18px;display:flex;flex-direction:column;gap:8px}
|
||||
.flash{padding:10px 14px;border-radius:8px;border:1px solid var(--line)}
|
||||
.flash-success{background:#e8f6ee;border-color:#bfe3cd;color:var(--ok)}
|
||||
.flash-danger{background:#fdeaea;border-color:#f4c4c4;color:var(--danger)}
|
||||
.flash-warning{background:#fdf4e3;border-color:#f0dcae;color:var(--warn)}
|
||||
.flash-info{background:#e9f1fb;border-color:#cadcf3;color:var(--info)}
|
||||
|
||||
.site-footer{border-top:1px solid var(--line);color:var(--muted);
|
||||
font-size:13px;padding:18px 0}
|
||||
.cf-turnstile{margin:0 0 14px}
|
||||
|
||||
/* --- Phase 2: listings --- */
|
||||
.btn.ghost{background:#fff;color:var(--brand);border:1px solid var(--brand)}
|
||||
.btn.danger{background:var(--danger)}
|
||||
.btn.tiny{padding:2px 8px;font-size:12px;border-radius:6px}
|
||||
.row2{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||
.form-wide{max-width:640px;margin:0 auto}
|
||||
.browse{display:grid;grid-template-columns:260px 1fr;gap:20px;align-items:start;width:100%}
|
||||
.browse .filters{min-width:0}
|
||||
.browse .results{min-width:0;width:100%}
|
||||
.filters h3{margin-top:0}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,220px));gap:14px}
|
||||
.tile{padding:0;overflow:hidden;display:block;color:var(--fg);width:100%;max-width:220px}
|
||||
.tile:hover{text-decoration:none;box-shadow:0 2px 10px rgba(0,0,0,.08)}
|
||||
.thumb{width:100%;height:140px;object-fit:cover;display:block;background:#eef1f4}
|
||||
.thumb.noimg{display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:13px}
|
||||
.tile-body{padding:10px}
|
||||
.tile-title{font-weight:600;font-size:14px;line-height:1.3;margin-bottom:4px}
|
||||
.tile-meta{display:flex;gap:8px;align-items:center}
|
||||
.price{color:var(--ok);font-weight:700}
|
||||
.price.big{font-size:24px}
|
||||
.small{font-size:12px}
|
||||
.badge{display:inline-block;font-size:11px;padding:2px 7px;border-radius:10px;background:#eef1f4;color:var(--muted)}
|
||||
.badge.ok{background:#e8f6ee;color:var(--ok)}
|
||||
.badge.warn{background:#fdf4e3;color:var(--warn)}
|
||||
.badge.cat{background:#e9f1fb;color:var(--info)}
|
||||
.pager{margin-top:18px;display:flex;gap:16px}
|
||||
.detail{display:grid;grid-template-columns:1fr 280px;gap:20px;align-items:start}
|
||||
.detail-meta{display:flex;gap:10px;align-items:center;margin:8px 0}
|
||||
.gallery{display:flex;flex-wrap:wrap;gap:8px;margin:12px 0}
|
||||
.gallery img{max-width:220px;border-radius:8px}
|
||||
.body{margin:14px 0;line-height:1.6}
|
||||
.attrs{border-collapse:collapse;width:100%}
|
||||
.attrs th,.attrs td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--line)}
|
||||
.attrs th{color:var(--muted);font-weight:600;width:40%}
|
||||
.detail-side .btn{display:block;margin-bottom:8px;text-align:center}
|
||||
.detail-side form{margin:0}
|
||||
.seller{margin-bottom:14px}
|
||||
.mine-head{display:flex;align-items:center;gap:14px;margin-bottom:14px}
|
||||
.mine-head .btn{margin-left:auto}
|
||||
table.list{width:100%;border-collapse:collapse}
|
||||
table.list td{padding:8px 10px;border-bottom:1px solid var(--line)}
|
||||
.thumbs{display:flex;gap:10px;flex-wrap:wrap}
|
||||
.thumb-wrap{position:relative}
|
||||
.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}
|
||||
|
||||
.link-btn{background:none;border:none;padding:0;color:inherit;font:inherit;cursor:pointer;text-decoration:none}
|
||||
.link-btn:hover{text-decoration:underline}
|
||||
.logout-form{display:inline}
|
||||
@@ -5,7 +5,11 @@
|
||||
<a href="{{ url_for('admin.reports') }}" class="{{ 'on' if request.endpoint == 'admin.reports' else '' }}">{{ _('Reports') }}</a>
|
||||
<a href="{{ url_for('admin.categories') }}" class="{{ 'on' if request.endpoint in ('admin.categories', 'admin.toggle_category', 'admin.category_schema') else '' }}">{{ _('Categories') }}</a>
|
||||
<a href="{{ url_for('admin.plans') }}" class="{{ 'on' if request.endpoint in ('admin.plans', 'admin.plan_edit') else '' }}">{{ _('Plans') }}</a>
|
||||
<a href="{{ url_for('admin.admin_ads') }}" class="{{ 'on' if request.endpoint in ('admin.admin_ads', 'admin.admin_ad_new', 'admin.admin_ad_edit') else '' }}">{{ _('Ads') }}</a>
|
||||
<a href="{{ url_for('admin.admin_sponsors') }}" class="{{ 'on' if request.endpoint in ('admin.admin_sponsors', 'admin.admin_sponsor_new', 'admin.admin_sponsor_edit') else '' }}">{{ _('Sponsors') }}</a>
|
||||
<a href="{{ url_for('admin.admin_promoted_keywords') }}" class="{{ 'on' if request.endpoint in ('admin.admin_promoted_keywords', 'admin.admin_promoted_keyword_new') else '' }}">{{ _('Keywords') }}</a>
|
||||
<a href="{{ url_for('admin.transactions') }}" class="{{ 'on' if request.endpoint == 'admin.transactions' else '' }}">{{ _('Transactions') }}</a>
|
||||
<a href="{{ url_for('admin.analytics') }}" class="{{ 'on' if request.endpoint == 'admin.analytics' else '' }}">{{ _('Analytics') }}</a>
|
||||
<a href="{{ url_for('admin.audit_log') }}" class="{{ 'on' if request.endpoint == 'admin.audit_log' else '' }}">{{ _('Audit log') }}</a>
|
||||
<a href="{{ url_for('admin.settings') }}" class="{{ 'on' if request.endpoint == 'admin.settings' else '' }}">{{ _('Settings') }}</a>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Admin · %(t)s', t=_('Edit ad') if ad else _('New ad')) }}{% endblock %}
|
||||
{% block content %}
|
||||
{% include "admin/_nav.html" %}
|
||||
<div class="card">
|
||||
<h2>{{ _('Edit ad') if ad else _('New ad') }}</h2>
|
||||
{% if error %}<p class="alert danger">{{ error }}</p>{% endif %}
|
||||
|
||||
<form method="post" class="settings-panel">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="field">
|
||||
<label>{{ _('Advertiser name') }} *</label>
|
||||
<input class="input" name="advertiser_name" required
|
||||
value="{{ ad.advertiser_name if ad else '' }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ _('Slot') }} *</label>
|
||||
<select class="input" name="slot" required>
|
||||
{% for s in ['header', 'sidebar', 'inline', 'footer'] %}
|
||||
<option value="{{ s }}" {{ 'selected' if ad and ad.slot == s }}>{{ s }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ _('Target URL') }} *</label>
|
||||
<input class="input" name="target_url" required
|
||||
value="{{ ad.target_url if ad else '' }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ _('Creative path (relative under media/ads/)') }}</label>
|
||||
<input class="input" name="creative_path"
|
||||
value="{{ ad.creative_path or '' if ad else '' }}"
|
||||
placeholder="{{ _('e.g. banner.jpg — leave blank for text-only ad') }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ _('Alt text') }}</label>
|
||||
<input class="input" name="alt_text" value="{{ ad.alt_text or '' if ad else '' }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ _('Language targeting (blank = all)') }}</label>
|
||||
<select class="input" name="lang">
|
||||
<option value="">{{ _('All') }}</option>
|
||||
{% for l in ['en', 'vi', 'es'] %}
|
||||
<option value="{{ l }}" {{ 'selected' if ad and ad.lang == l }}>{{ l }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ _('State targeting (2-letter, blank = all)') }}</label>
|
||||
<input class="input" name="geo_state" maxlength="2"
|
||||
value="{{ ad.geo_state or '' if ad else '' }}" placeholder="CA">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ _('Starts at (YYYY-MM-DD)') }}</label>
|
||||
<input class="input" name="starts_at" type="date"
|
||||
value="{{ ad.starts_at.strftime('%Y-%m-%d') if ad else '' }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ _('Ends at (YYYY-MM-DD)') }}</label>
|
||||
<input class="input" name="ends_at" type="date"
|
||||
value="{{ ad.ends_at.strftime('%Y-%m-%d') if ad else '' }}">
|
||||
</div>
|
||||
<div class="check">
|
||||
<input type="checkbox" name="is_active" id="is_active"
|
||||
{{ 'checked' if (not ad or ad.is_active) }}>
|
||||
<label for="is_active">{{ _('Active') }}</label>
|
||||
</div>
|
||||
|
||||
<div class="billing-actions">
|
||||
<button class="btn" type="submit">{{ _('Save') }}</button>
|
||||
<a class="btn ghost" href="{{ url_for('admin.admin_ads') }}">{{ _('Cancel') }}</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,64 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Admin · Ads') }}{% endblock %}
|
||||
{% block content %}
|
||||
{% include "admin/_nav.html" %}
|
||||
<div class="card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<h2>{{ _('Ads') }}</h2>
|
||||
<a class="btn" href="{{ url_for('admin.admin_ad_new') }}">{{ _('+ New ad') }}</a>
|
||||
</div>
|
||||
|
||||
{% if not ad_list %}<p class="muted">{{ _('No ads yet.') }}</p>{% endif %}
|
||||
<table class="list">
|
||||
<thead><tr>
|
||||
<th>{{ _('Advertiser') }}</th>
|
||||
<th>{{ _('Slot') }}</th>
|
||||
<th>{{ _('Targeting') }}</th>
|
||||
<th>{{ _('Schedule') }}</th>
|
||||
<th>{{ _('Imp.') }}</th>
|
||||
<th>{{ _('Clicks') }}</th>
|
||||
<th>{{ _('CTR') }}</th>
|
||||
<th>{{ _('Status') }}</th>
|
||||
<th></th>
|
||||
</tr></thead>
|
||||
{% for ad in ad_list %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('admin.admin_ad_edit', ad_id=ad.id) }}">{{ ad.advertiser_name }}</a></td>
|
||||
<td><span class="badge cat">{{ ad.slot }}</span></td>
|
||||
<td class="muted small">
|
||||
{% if ad.lang %}lang={{ ad.lang }}{% endif %}
|
||||
{% if ad.geo_state %} state={{ ad.geo_state }}{% endif %}
|
||||
{% if not ad.lang and not ad.geo_state %}—{% endif %}
|
||||
</td>
|
||||
<td class="muted small">
|
||||
{{ ad.starts_at.strftime('%Y-%m-%d') }} –
|
||||
{{ ad.ends_at.strftime('%Y-%m-%d') }}
|
||||
</td>
|
||||
<td>{{ ad.impressions }}</td>
|
||||
<td>{{ ad.clicks }}</td>
|
||||
<td>{{ ad.ctr }}%</td>
|
||||
<td>
|
||||
{% if ad.is_running %}
|
||||
<span class="badge ok">{{ _('Live') }}</span>
|
||||
{% elif ad.is_active %}
|
||||
<span class="badge cat">{{ _('Scheduled') }}</span>
|
||||
{% else %}
|
||||
<span class="badge warn">{{ _('Off') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="{{ url_for('admin.admin_ad_toggle', ad_id=ad.id) }}" style="display:inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn ghost tiny" type="submit">{{ _('Toggle') }}</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('admin.admin_ad_delete', ad_id=ad.id) }}" style="display:inline"
|
||||
onsubmit="return confirm('{{ _('Delete this ad?') }}');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn danger tiny" type="submit">{{ _('Del') }}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,108 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Admin · Analytics') }}{% endblock %}
|
||||
{% block content %}
|
||||
{% include "admin/_nav.html" %}
|
||||
|
||||
<div class="card">
|
||||
<h2>{{ _('Analytics — last 30 days') }}</h2>
|
||||
|
||||
<div class="kpi-grid">
|
||||
<div class="card kpi-card">
|
||||
<div class="kpi-label">{{ _('New signups (30d)') }}</div>
|
||||
<div class="kpi-value">{{ signups|sum(attribute='n') }}</div>
|
||||
</div>
|
||||
<div class="card kpi-card">
|
||||
<div class="kpi-label">{{ _('Listings posted (30d)') }}</div>
|
||||
<div class="kpi-value">{{ listings_chart|sum(attribute='n') }}</div>
|
||||
</div>
|
||||
<div class="card kpi-card">
|
||||
<div class="kpi-label">{{ _('Revenue (30d)') }}</div>
|
||||
<div class="kpi-value">${{ '%.2f'|format((revenue_chart|sum(attribute='cents') or 0) / 100) }}</div>
|
||||
</div>
|
||||
<div class="card kpi-card">
|
||||
<div class="kpi-label">{{ _('Ad impressions (active ads)') }}</div>
|
||||
<div class="kpi-value">{{ ad_stats.total_impressions or 0 }}</div>
|
||||
</div>
|
||||
<div class="card kpi-card">
|
||||
<div class="kpi-label">{{ _('Ad clicks (active ads)') }}</div>
|
||||
<div class="kpi-value">{{ ad_stats.total_clicks or 0 }}</div>
|
||||
</div>
|
||||
<div class="card kpi-card">
|
||||
<div class="kpi-label">{{ _('Overall CTR') }}</div>
|
||||
<div class="kpi-value">
|
||||
{% if ad_stats.total_impressions %}
|
||||
{{ '%.2f'|format(ad_stats.total_clicks / ad_stats.total_impressions * 100) }}%
|
||||
{% else %}—{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>{{ _('Signups per day') }}</h3>
|
||||
{% if signups %}
|
||||
<table class="list">
|
||||
<thead><tr><th>{{ _('Date') }}</th><th>{{ _('Signups') }}</th></tr></thead>
|
||||
{% for row in signups %}
|
||||
<tr>
|
||||
<td class="muted small">{{ row.day }}</td>
|
||||
<td>{{ row.n }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">{{ _('No signups in the last 30 days.') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>{{ _('Listings posted per day') }}</h3>
|
||||
{% if listings_chart %}
|
||||
<table class="list">
|
||||
<thead><tr><th>{{ _('Date') }}</th><th>{{ _('Listings') }}</th></tr></thead>
|
||||
{% for row in listings_chart %}
|
||||
<tr>
|
||||
<td class="muted small">{{ row.day }}</td>
|
||||
<td>{{ row.n }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">{{ _('No listings posted in the last 30 days.') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>{{ _('Revenue per day') }}</h3>
|
||||
{% if revenue_chart %}
|
||||
<table class="list">
|
||||
<thead><tr><th>{{ _('Date') }}</th><th>{{ _('Revenue') }}</th></tr></thead>
|
||||
{% for row in revenue_chart %}
|
||||
<tr>
|
||||
<td class="muted small">{{ row.day }}</td>
|
||||
<td>${{ '%.2f'|format((row.cents or 0) / 100) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">{{ _('No revenue in the last 30 days.') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>{{ _('Top categories by active listings') }}</h3>
|
||||
{% if top_cats %}
|
||||
<table class="list">
|
||||
<thead><tr><th>{{ _('Category') }}</th><th>{{ _('Active listings') }}</th></tr></thead>
|
||||
{% for name, n in top_cats %}
|
||||
<tr>
|
||||
<td>{{ name }}</td>
|
||||
<td>{{ n }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">{{ _('No listings.') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,37 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Admin · Assign keyword') }}{% endblock %}
|
||||
{% block content %}
|
||||
{% include "admin/_nav.html" %}
|
||||
<div class="card">
|
||||
<h2>{{ _('Assign promoted keyword') }}</h2>
|
||||
{% if error %}<p class="alert danger">{{ error }}</p>{% endif %}
|
||||
<p class="muted">
|
||||
{{ _('The listing will appear at the top of search results when the keyword is matched (accent-insensitive).') }}
|
||||
</p>
|
||||
<form method="post" class="settings-panel">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="field">
|
||||
<label>{{ _('Listing ID') }} *</label>
|
||||
<input class="input" name="listing_id" type="number" required min="1"
|
||||
placeholder="{{ _('Enter the numeric listing ID') }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ _('Keyword') }} *</label>
|
||||
<input class="input" name="keyword" required maxlength="80"
|
||||
placeholder="{{ _('e.g. pho, cleaning service') }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ _('Priority (higher = shown first)') }}</label>
|
||||
<input class="input" name="priority" type="number" value="0">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ _('Expires at (YYYY-MM-DD)') }}</label>
|
||||
<input class="input" name="expires_at" type="date">
|
||||
</div>
|
||||
<div class="billing-actions">
|
||||
<button class="btn" type="submit">{{ _('Save') }}</button>
|
||||
<a class="btn ghost" href="{{ url_for('admin.admin_promoted_keywords') }}">{{ _('Cancel') }}</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,52 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Admin · Promoted Keywords') }}{% endblock %}
|
||||
{% block content %}
|
||||
{% include "admin/_nav.html" %}
|
||||
<div class="card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<h2>{{ _('Promoted keywords') }}</h2>
|
||||
<a class="btn" href="{{ url_for('admin.admin_promoted_keyword_new') }}">{{ _('+ Assign keyword') }}</a>
|
||||
</div>
|
||||
|
||||
{% if not pks %}<p class="muted">{{ _('No promoted keywords.') }}</p>{% endif %}
|
||||
<table class="list">
|
||||
<thead><tr>
|
||||
<th>{{ _('Keyword') }}</th>
|
||||
<th>{{ _('Listing') }}</th>
|
||||
<th>{{ _('Priority') }}</th>
|
||||
<th>{{ _('Expires') }}</th>
|
||||
<th>{{ _('Status') }}</th>
|
||||
<th></th>
|
||||
</tr></thead>
|
||||
{% for pk in pks %}
|
||||
<tr>
|
||||
<td><code>{{ pk.keyword }}</code></td>
|
||||
<td>
|
||||
{% if pk.listing %}
|
||||
<a href="{{ url_for('listings.detail', listing_id=pk.listing_id) }}">
|
||||
{{ pk.listing.title[:60] }}
|
||||
</a>
|
||||
{% else %}<span class="muted">—</span>{% endif %}
|
||||
</td>
|
||||
<td>{{ pk.priority }}</td>
|
||||
<td class="muted small">{{ pk.expires_at.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
{% if pk.is_active %}
|
||||
<span class="badge ok">{{ _('Active') }}</span>
|
||||
{% else %}
|
||||
<span class="badge warn">{{ _('Expired') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form method="post"
|
||||
action="{{ url_for('admin.admin_promoted_keyword_delete', pk_id=pk.id) }}"
|
||||
style="display:inline" onsubmit="return confirm('{{ _('Remove?') }}');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn danger tiny" type="submit">{{ _('Remove') }}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,75 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Admin · %(t)s', t=_('Edit sponsor') if sponsor else _('New sponsor')) }}{% endblock %}
|
||||
{% block content %}
|
||||
{% include "admin/_nav.html" %}
|
||||
<div class="card">
|
||||
<h2>{{ _('Edit sponsor') if sponsor else _('New sponsor') }}</h2>
|
||||
{% if error %}<p class="alert danger">{{ error }}</p>{% endif %}
|
||||
|
||||
<form method="post" class="settings-panel">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="field">
|
||||
<label>{{ _('Name') }} *</label>
|
||||
<input class="input" name="name" required
|
||||
value="{{ sponsor.name if sponsor else '' }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ _('URL') }} *</label>
|
||||
<input class="input" name="url" required
|
||||
value="{{ sponsor.url if sponsor else '' }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ _('Tagline') }}</label>
|
||||
<input class="input" name="tagline"
|
||||
value="{{ sponsor.tagline or '' if sponsor else '' }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ _('Logo path (relative under media/)') }}</label>
|
||||
<input class="input" name="logo_path"
|
||||
value="{{ sponsor.logo_path or '' if sponsor else '' }}"
|
||||
placeholder="{{ _('e.g. sponsors/logo.png') }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ _('Tier') }}</label>
|
||||
<select class="input" name="tier">
|
||||
{% for t in ['directory', 'category'] %}
|
||||
<option value="{{ t }}" {{ 'selected' if sponsor and sponsor.tier == t }}>{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ _('Category (for category-tier sponsors)') }}</label>
|
||||
<select class="input" name="category_id">
|
||||
<option value="">{{ _('None') }}</option>
|
||||
{% for cat in categories %}
|
||||
<option value="{{ cat.id }}"
|
||||
{{ 'selected' if sponsor and sponsor.category_id == cat.id }}>
|
||||
{{ cat.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ _('Starts at (YYYY-MM-DD)') }}</label>
|
||||
<input class="input" name="starts_at" type="date"
|
||||
value="{{ sponsor.starts_at.strftime('%Y-%m-%d') if sponsor else '' }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ _('Ends at (YYYY-MM-DD)') }}</label>
|
||||
<input class="input" name="ends_at" type="date"
|
||||
value="{{ sponsor.ends_at.strftime('%Y-%m-%d') if sponsor else '' }}">
|
||||
</div>
|
||||
<div class="check">
|
||||
<input type="checkbox" name="is_active" id="is_active"
|
||||
{{ 'checked' if (not sponsor or sponsor.is_active) }}>
|
||||
<label for="is_active">{{ _('Active') }}</label>
|
||||
</div>
|
||||
|
||||
<div class="billing-actions">
|
||||
<button class="btn" type="submit">{{ _('Save') }}</button>
|
||||
<a class="btn ghost" href="{{ url_for('admin.admin_sponsors') }}">{{ _('Cancel') }}</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,50 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Admin · Sponsors') }}{% endblock %}
|
||||
{% block content %}
|
||||
{% include "admin/_nav.html" %}
|
||||
<div class="card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<h2>{{ _('Sponsors') }}</h2>
|
||||
<a class="btn" href="{{ url_for('admin.admin_sponsor_new') }}">{{ _('+ New sponsor') }}</a>
|
||||
</div>
|
||||
|
||||
{% if not sponsors %}<p class="muted">{{ _('No sponsors yet.') }}</p>{% endif %}
|
||||
<table class="list">
|
||||
<thead><tr>
|
||||
<th>{{ _('Name') }}</th>
|
||||
<th>{{ _('Tier') }}</th>
|
||||
<th>{{ _('Category') }}</th>
|
||||
<th>{{ _('Schedule') }}</th>
|
||||
<th>{{ _('Status') }}</th>
|
||||
<th></th>
|
||||
</tr></thead>
|
||||
{% for sp in sponsors %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('admin.admin_sponsor_edit', sponsor_id=sp.id) }}">{{ sp.name }}</a></td>
|
||||
<td><span class="badge cat">{{ sp.tier }}</span></td>
|
||||
<td class="muted small">{{ sp.category.name if sp.category else '—' }}</td>
|
||||
<td class="muted small">
|
||||
{{ sp.starts_at.strftime('%Y-%m-%d') }} –
|
||||
{{ sp.ends_at.strftime('%Y-%m-%d') }}
|
||||
</td>
|
||||
<td>
|
||||
{% if sp.is_running %}
|
||||
<span class="badge ok">{{ _('Live') }}</span>
|
||||
{% elif sp.is_active %}
|
||||
<span class="badge cat">{{ _('Scheduled') }}</span>
|
||||
{% else %}
|
||||
<span class="badge warn">{{ _('Off') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="{{ url_for('admin.admin_sponsor_delete', sponsor_id=sp.id) }}"
|
||||
style="display:inline" onsubmit="return confirm('{{ _('Delete?') }}');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn danger tiny" type="submit">{{ _('Del') }}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -37,6 +37,7 @@
|
||||
<th>{{ _('Amount') }}</th>
|
||||
<th>{{ _('Status') }}</th>
|
||||
<th>{{ _('Stripe ID') }}</th>
|
||||
<th></th>
|
||||
</tr></thead>
|
||||
{% for txn in pagination.items %}
|
||||
<tr>
|
||||
@@ -46,6 +47,16 @@
|
||||
<td>${{ '%.2f'|format(txn.amount_cents / 100) }}</td>
|
||||
<td><span class="badge {{ 'ok' if txn.status == 'succeeded' else 'warn' }}">{{ txn.status }}</span></td>
|
||||
<td class="muted small">{{ txn.stripe_object_id or '—' }}</td>
|
||||
<td>
|
||||
{% if txn.status == 'succeeded' and txn.type != 'refund' %}
|
||||
<form method="post"
|
||||
action="{{ url_for('admin.refund_transaction', txn_id=txn.id) }}"
|
||||
onsubmit="return confirm('{{ _('Issue refund for $%(a)s?', a='%.2f'|format(txn.amount_cents/100)) }}');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn ghost tiny" type="submit">{{ _('Refund') }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="muted">{{ _('No transactions.') }}</td></tr>
|
||||
|
||||
+29
-1
@@ -4,6 +4,19 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<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="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Newsreader:opsz,wght@6..72,400;6..72,500;6..72,600&family=Hanken+Grotesk:wght@400;500;600;700&family=Space+Mono&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
@@ -11,7 +24,8 @@
|
||||
<header class="site-header">
|
||||
{% set slot = "header" %}{% include "ads/_slot.html" %}
|
||||
<div class="wrap">
|
||||
<a class="brand" href="{{ url_for('main.index') }}">Classifieds</a>
|
||||
<a class="brand" href="{{ url_for('main.index') }}"><span class="brand-mark">C</span>Classifieds</a>
|
||||
<button class="nav-toggle" aria-label="{{ _('Menu') }}" onclick="document.querySelector('.nav').classList.toggle('open')">☰</button>
|
||||
<nav class="nav">
|
||||
<a href="{{ url_for('listings.browse') }}">{{ _('Browse') }}</a>
|
||||
<a href="{{ url_for('payments.pricing') }}">{{ _('Pricing') }}</a>
|
||||
@@ -60,5 +74,19 @@
|
||||
{% set slot = "footer" %}{% include "ads/_slot.html" %}
|
||||
<div class="wrap">© {{ 2025 }} Classifieds · <a href="{{ url_for('ads.sponsor_directory') }}">{{ _('Sponsors') }}</a></div>
|
||||
</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>
|
||||
</html>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
{% 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 %}
|
||||
<section class="hero">
|
||||
<p class="hero-eyebrow">{{ _('Your local community marketplace') }}</p>
|
||||
<h1>{{ _('Find what you need. Post what you offer.') }}</h1>
|
||||
<p>{{ _('Buy, sell, request, hire, and connect across your community.') }}</p>
|
||||
<a class="btn btn-lg" href="{{ url_for('listings.browse') }}">{{ _('Browse listings') }}</a>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
{% 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 %}
|
||||
<div class="browse">
|
||||
<aside class="filters card">
|
||||
@@ -18,8 +24,29 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% set US_STATES = [
|
||||
('AL','Alabama'),('AK','Alaska'),('AZ','Arizona'),('AR','Arkansas'),
|
||||
('CA','California'),('CO','Colorado'),('CT','Connecticut'),('DE','Delaware'),
|
||||
('DC','District of Columbia'),('FL','Florida'),('GA','Georgia'),('HI','Hawaii'),
|
||||
('ID','Idaho'),('IL','Illinois'),('IN','Indiana'),('IA','Iowa'),('KS','Kansas'),
|
||||
('KY','Kentucky'),('LA','Louisiana'),('ME','Maine'),('MD','Maryland'),
|
||||
('MA','Massachusetts'),('MI','Michigan'),('MN','Minnesota'),('MS','Mississippi'),
|
||||
('MO','Missouri'),('MT','Montana'),('NE','Nebraska'),('NV','Nevada'),
|
||||
('NH','New Hampshire'),('NJ','New Jersey'),('NM','New Mexico'),('NY','New York'),
|
||||
('NC','North Carolina'),('ND','North Dakota'),('OH','Ohio'),('OK','Oklahoma'),
|
||||
('OR','Oregon'),('PA','Pennsylvania'),('RI','Rhode Island'),('SC','South Carolina'),
|
||||
('SD','South Dakota'),('TN','Tennessee'),('TX','Texas'),('UT','Utah'),
|
||||
('VT','Vermont'),('VA','Virginia'),('WA','Washington'),('WV','West Virginia'),
|
||||
('WI','Wisconsin'),('WY','Wyoming')] %}
|
||||
{% set state_names = dict(US_STATES) %}
|
||||
{% set cur_state = filters.get('state','')|upper %}
|
||||
<div class="field"><label>{{ _('State') }}</label>
|
||||
<input class="input" name="state" maxlength="2" value="{{ filters.get('state','') }}"></div>
|
||||
<input class="input" id="state-search" list="us-states" autocomplete="off"
|
||||
placeholder="{{ _('Type a state…') }}" value="{{ state_names.get(cur_state, '') }}">
|
||||
<input type="hidden" name="state" id="state-code" value="{{ cur_state }}">
|
||||
<datalist id="us-states">
|
||||
{% for code, name in US_STATES %}<option value="{{ name }}">{{ code }}</option>{% endfor %}
|
||||
</datalist></div>
|
||||
<div class="row2">
|
||||
<div class="field"><label>{{ _('Min $') }}</label>
|
||||
<input class="input" name="min_price" type="number" value="{{ filters.get('min_price','') }}"></div>
|
||||
@@ -69,4 +96,34 @@
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// State combobox: user searches/selects a full state name; we submit the 2-letter
|
||||
// code via the hidden field. Also accepts a raw 2-letter code typed directly.
|
||||
(function () {
|
||||
var input = document.getElementById('state-search');
|
||||
var hidden = document.getElementById('state-code');
|
||||
var dl = document.getElementById('us-states');
|
||||
if (!input || !hidden || !dl) return;
|
||||
var byName = {}, byCode = {};
|
||||
Array.prototype.forEach.call(dl.options, function (o) {
|
||||
var name = o.value, code = o.textContent.trim();
|
||||
byName[name.toLowerCase()] = code;
|
||||
byCode[code.toUpperCase()] = name;
|
||||
});
|
||||
function sync() {
|
||||
var v = input.value.trim();
|
||||
if (!v) { hidden.value = ''; return; }
|
||||
if (byName[v.toLowerCase()]) { hidden.value = byName[v.toLowerCase()]; return; }
|
||||
if (byCode[v.toUpperCase()]) { // user typed the code itself
|
||||
hidden.value = v.toUpperCase();
|
||||
input.value = byCode[v.toUpperCase()];
|
||||
return;
|
||||
}
|
||||
hidden.value = ''; // unrecognized → no state filter
|
||||
}
|
||||
input.addEventListener('input', sync);
|
||||
input.addEventListener('change', sync);
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
{% 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 %}
|
||||
<article class="detail">
|
||||
<div class="detail-main card">
|
||||
@@ -17,7 +39,7 @@
|
||||
{% if listing.images %}
|
||||
<div class="gallery">
|
||||
{% 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 %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -39,6 +61,12 @@
|
||||
<div class="seller">
|
||||
<strong>{{ listing.user.display_name }}</strong>
|
||||
{% 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>
|
||||
{% if is_owner %}
|
||||
<a class="btn" href="{{ url_for('listings.edit', listing_id=listing.id) }}">{{ _('Edit') }}</a>
|
||||
@@ -78,7 +106,35 @@
|
||||
{{ _('Sign in to contact') }}
|
||||
</a>
|
||||
{% 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 %}
|
||||
</aside>
|
||||
</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 %}
|
||||
|
||||
@@ -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>
|
||||
Binary file not shown.
@@ -20,313 +20,313 @@ msgstr ""
|
||||
|
||||
#: app/blueprints/auth/routes.py:28
|
||||
msgid "Verify your email"
|
||||
msgstr ""
|
||||
msgstr "Verifica tu correo electrónico"
|
||||
|
||||
#: app/blueprints/auth/routes.py:29
|
||||
#, python-format
|
||||
msgid "Confirm your account: %(link)s"
|
||||
msgstr ""
|
||||
msgstr "Confirma tu cuenta: %(link)s"
|
||||
|
||||
#: app/blueprints/auth/routes.py:40
|
||||
msgid "Captcha verification failed."
|
||||
msgstr ""
|
||||
msgstr "La verificación de Captcha falló."
|
||||
|
||||
#: app/blueprints/auth/routes.py:45
|
||||
msgid "An account with that email already exists."
|
||||
msgstr ""
|
||||
msgstr "Ya existe una cuenta con ese correo electrónico."
|
||||
|
||||
#: app/blueprints/auth/routes.py:59
|
||||
msgid "Account created. Check your email to verify."
|
||||
msgstr ""
|
||||
msgstr "Cuenta creada. Revisa tu correo para verificar."
|
||||
|
||||
#: app/blueprints/auth/routes.py:74
|
||||
msgid "Invalid email or password."
|
||||
msgstr ""
|
||||
msgstr "Correo o contraseña no válidos."
|
||||
|
||||
#: app/blueprints/auth/routes.py:77
|
||||
msgid "This account is suspended."
|
||||
msgstr ""
|
||||
msgstr "Esta cuenta está suspendida."
|
||||
|
||||
#: app/blueprints/auth/routes.py:93
|
||||
msgid "Signed out."
|
||||
msgstr ""
|
||||
msgstr "Sesión cerrada."
|
||||
|
||||
#: app/blueprints/auth/routes.py:102
|
||||
msgid "Verification link is invalid or expired."
|
||||
msgstr ""
|
||||
msgstr "El enlace de verificación no es válido o ha caducado."
|
||||
|
||||
#: app/blueprints/auth/routes.py:111
|
||||
msgid "Email verified. You're all set."
|
||||
msgstr ""
|
||||
msgstr "Correo verificado. Todo listo."
|
||||
|
||||
#: app/blueprints/auth/routes.py:124
|
||||
msgid "Reset your password"
|
||||
msgstr ""
|
||||
msgstr "Restablece tu contraseña"
|
||||
|
||||
#: app/blueprints/auth/routes.py:125
|
||||
#, python-format
|
||||
msgid "Reset link: %(link)s"
|
||||
msgstr ""
|
||||
msgstr "Enlace de restablecimiento: %(link)s"
|
||||
|
||||
#: app/blueprints/auth/routes.py:127
|
||||
msgid "If that email exists, a reset link has been sent."
|
||||
msgstr ""
|
||||
msgstr "Si ese correo existe, se ha enviado un enlace de restablecimiento."
|
||||
|
||||
#: app/blueprints/auth/routes.py:137
|
||||
msgid "Reset link is invalid or expired."
|
||||
msgstr ""
|
||||
msgstr "El enlace de restablecimiento no es válido o ha caducado."
|
||||
|
||||
#: app/blueprints/auth/routes.py:146
|
||||
msgid "Password updated. Sign in."
|
||||
msgstr ""
|
||||
msgstr "Contraseña actualizada. Inicia sesión."
|
||||
|
||||
#: app/blueprints/listings/routes.py:72
|
||||
msgid "ZIP not found; showing all results."
|
||||
msgstr ""
|
||||
msgstr "Código postal no encontrado; mostrando todos los resultados."
|
||||
|
||||
#: app/blueprints/listings/routes.py:103
|
||||
msgid "You've reached your plan's active-listing limit."
|
||||
msgstr ""
|
||||
msgstr "Has alcanzado el límite de anuncios activos de tu plan."
|
||||
|
||||
#: app/blueprints/listings/routes.py:125 app/blueprints/listings/routes.py:171
|
||||
#, python-format
|
||||
msgid "%(f)s: %(m)s"
|
||||
msgstr ""
|
||||
msgstr "%(f)s: %(m)s"
|
||||
|
||||
#: app/blueprints/listings/routes.py:133
|
||||
msgid "Listing published."
|
||||
msgstr ""
|
||||
msgstr "Anuncio publicado."
|
||||
|
||||
#: app/blueprints/listings/routes.py:176
|
||||
msgid "Listing updated."
|
||||
msgstr ""
|
||||
msgstr "Anuncio actualizado."
|
||||
|
||||
#: app/blueprints/listings/routes.py:194
|
||||
msgid "Listing deleted."
|
||||
msgstr ""
|
||||
msgstr "Anuncio eliminado."
|
||||
|
||||
#: app/blueprints/listings/routes.py:207
|
||||
msgid "Marked as sold."
|
||||
msgstr ""
|
||||
msgstr "Marcado como vendido."
|
||||
|
||||
#: app/blueprints/listings/routes.py:236
|
||||
msgid "Photo removed."
|
||||
msgstr ""
|
||||
msgstr "Foto eliminada."
|
||||
|
||||
#: app/blueprints/listings/routes.py:258
|
||||
#, python-format
|
||||
msgid "Photo limit (%(n)s) reached for your plan."
|
||||
msgstr ""
|
||||
msgstr "Se alcanzó el límite de fotos (%(n)s) de tu plan."
|
||||
|
||||
#: app/blueprints/listings/routes.py:265
|
||||
#, python-format
|
||||
msgid "Image skipped: %(m)s"
|
||||
msgstr ""
|
||||
msgstr "Imagen omitida: %(m)s"
|
||||
|
||||
#: app/templates/base.html:15
|
||||
msgid "Browse"
|
||||
msgstr ""
|
||||
msgstr "Explorar"
|
||||
|
||||
#: app/templates/base.html:17
|
||||
msgid "Post"
|
||||
msgstr ""
|
||||
msgstr "Publicar"
|
||||
|
||||
#: app/templates/base.html:18 app/templates/listings/mine.html:2
|
||||
#: app/templates/listings/mine.html:6
|
||||
msgid "My listings"
|
||||
msgstr ""
|
||||
msgstr "Mis anuncios"
|
||||
|
||||
#: app/templates/base.html:20
|
||||
msgid "Sign out"
|
||||
msgstr ""
|
||||
msgstr "Cerrar sesión"
|
||||
|
||||
#: app/templates/auth/login.html:3 app/templates/auth/login.html:6
|
||||
#: app/templates/base.html:22
|
||||
msgid "Sign in"
|
||||
msgstr ""
|
||||
msgstr "Iniciar sesión"
|
||||
|
||||
#: app/templates/auth/register.html:3 app/templates/base.html:23
|
||||
msgid "Register"
|
||||
msgstr ""
|
||||
msgstr "Registrarse"
|
||||
|
||||
#: app/templates/index.html:2
|
||||
msgid "Classifieds — Home"
|
||||
msgstr ""
|
||||
msgstr "Clasificados — Inicio"
|
||||
|
||||
#: app/templates/index.html:5
|
||||
msgid "Find what you need. Post what you offer."
|
||||
msgstr ""
|
||||
msgstr "Encuentra lo que necesitas. Publica lo que ofreces."
|
||||
|
||||
#: app/templates/index.html:6
|
||||
msgid "Buy, sell, request, hire, and connect across your community."
|
||||
msgstr ""
|
||||
msgstr "Compra, vende, solicita, contrata y conecta en tu comunidad."
|
||||
|
||||
#: app/templates/index.html:7 app/templates/listings/browse.html:2
|
||||
msgid "Browse listings"
|
||||
msgstr ""
|
||||
msgstr "Explorar anuncios"
|
||||
|
||||
#: app/templates/index.html:9
|
||||
msgid "Get started"
|
||||
msgstr ""
|
||||
msgstr "Comenzar"
|
||||
|
||||
#: app/templates/auth/login.html:15
|
||||
msgid "Forgot password?"
|
||||
msgstr ""
|
||||
msgstr "¿Olvidaste tu contraseña?"
|
||||
|
||||
#: app/templates/auth/login.html:16 app/templates/auth/register.html:6
|
||||
msgid "Create account"
|
||||
msgstr ""
|
||||
msgstr "Crear cuenta"
|
||||
|
||||
#: app/templates/auth/register.html:16
|
||||
msgid "Already have an account? Sign in"
|
||||
msgstr ""
|
||||
msgstr "¿Ya tienes una cuenta? Inicia sesión"
|
||||
|
||||
#: app/templates/auth/reset.html:3 app/templates/auth/reset.html:6
|
||||
msgid "Set new password"
|
||||
msgstr ""
|
||||
msgstr "Establecer nueva contraseña"
|
||||
|
||||
#: app/templates/auth/reset_request.html:3
|
||||
#: app/templates/auth/reset_request.html:6
|
||||
msgid "Reset password"
|
||||
msgstr ""
|
||||
msgstr "Restablecer contraseña"
|
||||
|
||||
#: app/templates/errors/403.html:3
|
||||
msgid "Forbidden — you do not have access."
|
||||
msgstr ""
|
||||
msgstr "Prohibido: no tienes acceso."
|
||||
|
||||
#: app/templates/errors/403.html:3 app/templates/errors/404.html:3
|
||||
#: app/templates/errors/500.html:3
|
||||
msgid "Back home"
|
||||
msgstr ""
|
||||
msgstr "Volver al inicio"
|
||||
|
||||
#: app/templates/errors/404.html:3
|
||||
msgid "Not found."
|
||||
msgstr ""
|
||||
msgstr "No encontrado."
|
||||
|
||||
#: app/templates/errors/500.html:3
|
||||
msgid "Something went wrong."
|
||||
msgstr ""
|
||||
msgstr "Algo salió mal."
|
||||
|
||||
#: app/templates/listings/browse.html:6
|
||||
msgid "Filter"
|
||||
msgstr ""
|
||||
msgstr "Filtrar"
|
||||
|
||||
#: app/templates/listings/browse.html:9
|
||||
msgid "Keyword"
|
||||
msgstr ""
|
||||
msgstr "Palabra clave"
|
||||
|
||||
#: app/templates/listings/browse.html:13
|
||||
msgid "Category"
|
||||
msgstr ""
|
||||
msgstr "Categoría"
|
||||
|
||||
#: app/templates/listings/browse.html:15
|
||||
msgid "All"
|
||||
msgstr ""
|
||||
msgstr "Todos"
|
||||
|
||||
#: app/templates/listings/browse.html:21
|
||||
msgid "State"
|
||||
msgstr ""
|
||||
msgstr "Estado"
|
||||
|
||||
#: app/templates/listings/browse.html:24
|
||||
msgid "Min $"
|
||||
msgstr ""
|
||||
msgstr "Mín $"
|
||||
|
||||
#: app/templates/listings/browse.html:26
|
||||
msgid "Max $"
|
||||
msgstr ""
|
||||
msgstr "Máx $"
|
||||
|
||||
#: app/templates/listings/browse.html:30
|
||||
msgid "Near ZIP"
|
||||
msgstr ""
|
||||
msgstr "Cerca del código postal"
|
||||
|
||||
#: app/templates/listings/browse.html:32
|
||||
msgid "Radius (mi)"
|
||||
msgstr ""
|
||||
msgstr "Radio (mi)"
|
||||
|
||||
#: app/templates/listings/browse.html:35
|
||||
msgid "Apply"
|
||||
msgstr ""
|
||||
msgstr "Aplicar"
|
||||
|
||||
#: app/templates/listings/browse.html:40
|
||||
#, python-format
|
||||
msgid "%(n)s results within %(r)s mi of %(z)s"
|
||||
msgstr ""
|
||||
msgstr "%(n)s resultados dentro de %(r)s mi de %(z)s"
|
||||
|
||||
#: app/templates/listings/browse.html:41
|
||||
msgid "No listings found."
|
||||
msgstr ""
|
||||
msgstr "No se encontraron anuncios."
|
||||
|
||||
#: app/templates/listings/browse.html:46
|
||||
msgid "No photo"
|
||||
msgstr ""
|
||||
msgstr "Sin foto"
|
||||
|
||||
#: app/templates/listings/browse.html:51
|
||||
msgid "Featured"
|
||||
msgstr ""
|
||||
msgstr "Destacado"
|
||||
|
||||
#: app/templates/listings/browse.html:63
|
||||
msgid "Prev"
|
||||
msgstr ""
|
||||
msgstr "Anterior"
|
||||
|
||||
#: app/templates/listings/browse.html:64
|
||||
msgid "Next"
|
||||
msgstr ""
|
||||
msgstr "Siguiente"
|
||||
|
||||
#: app/templates/listings/detail.html:14
|
||||
#, python-format
|
||||
msgid "%(n)s views"
|
||||
msgstr ""
|
||||
msgstr "%(n)s vistas"
|
||||
|
||||
#: app/templates/listings/detail.html:41
|
||||
msgid "Verified"
|
||||
msgstr ""
|
||||
msgstr "Verificado"
|
||||
|
||||
#: app/templates/listings/detail.html:44 app/templates/listings/mine.html:19
|
||||
msgid "Edit"
|
||||
msgstr ""
|
||||
msgstr "Editar"
|
||||
|
||||
#: app/templates/listings/detail.html:47
|
||||
msgid "Mark sold"
|
||||
msgstr ""
|
||||
msgstr "Marcar vendido"
|
||||
|
||||
#: app/templates/listings/detail.html:51
|
||||
msgid "Delete this listing?"
|
||||
msgstr ""
|
||||
msgstr "¿Eliminar este anuncio?"
|
||||
|
||||
#: app/templates/listings/detail.html:52
|
||||
msgid "Delete"
|
||||
msgstr ""
|
||||
msgstr "Eliminar"
|
||||
|
||||
#: app/templates/listings/detail.html:55
|
||||
msgid "Messaging arrives in Phase 3."
|
||||
msgstr ""
|
||||
msgstr "La mensajería llega en la Fase 3."
|
||||
|
||||
#: app/templates/listings/form.html:3 app/templates/listings/form.html:6
|
||||
msgid "Edit listing"
|
||||
msgstr ""
|
||||
msgstr "Editar anuncio"
|
||||
|
||||
#: app/templates/listings/form.html:3 app/templates/listings/form.html:6
|
||||
msgid "New listing"
|
||||
msgstr ""
|
||||
msgstr "Nuevo anuncio"
|
||||
|
||||
#: app/templates/listings/form.html:47
|
||||
msgid "Current photos"
|
||||
msgstr ""
|
||||
msgstr "Fotos actuales"
|
||||
|
||||
#: app/templates/listings/mine.html:7
|
||||
#, python-format
|
||||
msgid "Active: %(a)s"
|
||||
msgstr ""
|
||||
msgstr "Activos: %(a)s"
|
||||
|
||||
#: app/templates/listings/mine.html:8
|
||||
msgid "Post new"
|
||||
msgstr ""
|
||||
msgstr "Publicar nuevo"
|
||||
|
||||
#: app/templates/listings/mine.html:10
|
||||
msgid "No listings yet."
|
||||
msgstr ""
|
||||
msgstr "Aún no hay anuncios."
|
||||
|
||||
#: app/templates/listings/mine.html:18
|
||||
msgid "views"
|
||||
msgstr ""
|
||||
msgstr "vistas"
|
||||
|
||||
|
||||
Binary file not shown.
@@ -20,313 +20,313 @@ msgstr ""
|
||||
|
||||
#: app/blueprints/auth/routes.py:28
|
||||
msgid "Verify your email"
|
||||
msgstr ""
|
||||
msgstr "Xác minh email của bạn"
|
||||
|
||||
#: app/blueprints/auth/routes.py:29
|
||||
#, python-format
|
||||
msgid "Confirm your account: %(link)s"
|
||||
msgstr ""
|
||||
msgstr "Xác nhận tài khoản của bạn: %(link)s"
|
||||
|
||||
#: app/blueprints/auth/routes.py:40
|
||||
msgid "Captcha verification failed."
|
||||
msgstr ""
|
||||
msgstr "Xác minh Captcha thất bại."
|
||||
|
||||
#: app/blueprints/auth/routes.py:45
|
||||
msgid "An account with that email already exists."
|
||||
msgstr ""
|
||||
msgstr "Đã tồn tại tài khoản với email đó."
|
||||
|
||||
#: app/blueprints/auth/routes.py:59
|
||||
msgid "Account created. Check your email to verify."
|
||||
msgstr ""
|
||||
msgstr "Đã tạo tài khoản. Kiểm tra email để xác minh."
|
||||
|
||||
#: app/blueprints/auth/routes.py:74
|
||||
msgid "Invalid email or password."
|
||||
msgstr ""
|
||||
msgstr "Email hoặc mật khẩu không hợp lệ."
|
||||
|
||||
#: app/blueprints/auth/routes.py:77
|
||||
msgid "This account is suspended."
|
||||
msgstr ""
|
||||
msgstr "Tài khoản này đã bị tạm khóa."
|
||||
|
||||
#: app/blueprints/auth/routes.py:93
|
||||
msgid "Signed out."
|
||||
msgstr ""
|
||||
msgstr "Đã đăng xuất."
|
||||
|
||||
#: app/blueprints/auth/routes.py:102
|
||||
msgid "Verification link is invalid or expired."
|
||||
msgstr ""
|
||||
msgstr "Liên kết xác minh không hợp lệ hoặc đã hết hạn."
|
||||
|
||||
#: app/blueprints/auth/routes.py:111
|
||||
msgid "Email verified. You're all set."
|
||||
msgstr ""
|
||||
msgstr "Đã xác minh email. Bạn đã sẵn sàng."
|
||||
|
||||
#: app/blueprints/auth/routes.py:124
|
||||
msgid "Reset your password"
|
||||
msgstr ""
|
||||
msgstr "Đặt lại mật khẩu của bạn"
|
||||
|
||||
#: app/blueprints/auth/routes.py:125
|
||||
#, python-format
|
||||
msgid "Reset link: %(link)s"
|
||||
msgstr ""
|
||||
msgstr "Liên kết đặt lại: %(link)s"
|
||||
|
||||
#: app/blueprints/auth/routes.py:127
|
||||
msgid "If that email exists, a reset link has been sent."
|
||||
msgstr ""
|
||||
msgstr "Nếu email đó tồn tại, liên kết đặt lại đã được gửi."
|
||||
|
||||
#: app/blueprints/auth/routes.py:137
|
||||
msgid "Reset link is invalid or expired."
|
||||
msgstr ""
|
||||
msgstr "Liên kết đặt lại không hợp lệ hoặc đã hết hạn."
|
||||
|
||||
#: app/blueprints/auth/routes.py:146
|
||||
msgid "Password updated. Sign in."
|
||||
msgstr ""
|
||||
msgstr "Đã cập nhật mật khẩu. Đăng nhập."
|
||||
|
||||
#: app/blueprints/listings/routes.py:72
|
||||
msgid "ZIP not found; showing all results."
|
||||
msgstr ""
|
||||
msgstr "Không tìm thấy mã ZIP; hiển thị tất cả kết quả."
|
||||
|
||||
#: app/blueprints/listings/routes.py:103
|
||||
msgid "You've reached your plan's active-listing limit."
|
||||
msgstr ""
|
||||
msgstr "Bạn đã đạt đến giới hạn tin đang đăng của gói."
|
||||
|
||||
#: app/blueprints/listings/routes.py:125 app/blueprints/listings/routes.py:171
|
||||
#, python-format
|
||||
msgid "%(f)s: %(m)s"
|
||||
msgstr ""
|
||||
msgstr "%(f)s: %(m)s"
|
||||
|
||||
#: app/blueprints/listings/routes.py:133
|
||||
msgid "Listing published."
|
||||
msgstr ""
|
||||
msgstr "Đã đăng tin."
|
||||
|
||||
#: app/blueprints/listings/routes.py:176
|
||||
msgid "Listing updated."
|
||||
msgstr ""
|
||||
msgstr "Đã cập nhật tin."
|
||||
|
||||
#: app/blueprints/listings/routes.py:194
|
||||
msgid "Listing deleted."
|
||||
msgstr ""
|
||||
msgstr "Đã xóa tin."
|
||||
|
||||
#: app/blueprints/listings/routes.py:207
|
||||
msgid "Marked as sold."
|
||||
msgstr ""
|
||||
msgstr "Đã đánh dấu là đã bán."
|
||||
|
||||
#: app/blueprints/listings/routes.py:236
|
||||
msgid "Photo removed."
|
||||
msgstr ""
|
||||
msgstr "Đã xóa ảnh."
|
||||
|
||||
#: app/blueprints/listings/routes.py:258
|
||||
#, python-format
|
||||
msgid "Photo limit (%(n)s) reached for your plan."
|
||||
msgstr ""
|
||||
msgstr "Đã đạt giới hạn ảnh (%(n)s) của gói."
|
||||
|
||||
#: app/blueprints/listings/routes.py:265
|
||||
#, python-format
|
||||
msgid "Image skipped: %(m)s"
|
||||
msgstr ""
|
||||
msgstr "Đã bỏ qua ảnh: %(m)s"
|
||||
|
||||
#: app/templates/base.html:15
|
||||
msgid "Browse"
|
||||
msgstr ""
|
||||
msgstr "Duyệt"
|
||||
|
||||
#: app/templates/base.html:17
|
||||
msgid "Post"
|
||||
msgstr ""
|
||||
msgstr "Đăng tin"
|
||||
|
||||
#: app/templates/base.html:18 app/templates/listings/mine.html:2
|
||||
#: app/templates/listings/mine.html:6
|
||||
msgid "My listings"
|
||||
msgstr ""
|
||||
msgstr "Tin của tôi"
|
||||
|
||||
#: app/templates/base.html:20
|
||||
msgid "Sign out"
|
||||
msgstr ""
|
||||
msgstr "Đăng xuất"
|
||||
|
||||
#: app/templates/auth/login.html:3 app/templates/auth/login.html:6
|
||||
#: app/templates/base.html:22
|
||||
msgid "Sign in"
|
||||
msgstr ""
|
||||
msgstr "Đăng nhập"
|
||||
|
||||
#: app/templates/auth/register.html:3 app/templates/base.html:23
|
||||
msgid "Register"
|
||||
msgstr ""
|
||||
msgstr "Đăng ký"
|
||||
|
||||
#: app/templates/index.html:2
|
||||
msgid "Classifieds — Home"
|
||||
msgstr ""
|
||||
msgstr "Rao vặt — Trang chủ"
|
||||
|
||||
#: app/templates/index.html:5
|
||||
msgid "Find what you need. Post what you offer."
|
||||
msgstr ""
|
||||
msgstr "Tìm thứ bạn cần. Đăng thứ bạn có."
|
||||
|
||||
#: app/templates/index.html:6
|
||||
msgid "Buy, sell, request, hire, and connect across your community."
|
||||
msgstr ""
|
||||
msgstr "Mua, bán, yêu cầu, tuyển dụng và kết nối trong cộng đồng của bạn."
|
||||
|
||||
#: app/templates/index.html:7 app/templates/listings/browse.html:2
|
||||
msgid "Browse listings"
|
||||
msgstr ""
|
||||
msgstr "Duyệt tin đăng"
|
||||
|
||||
#: app/templates/index.html:9
|
||||
msgid "Get started"
|
||||
msgstr ""
|
||||
msgstr "Bắt đầu"
|
||||
|
||||
#: app/templates/auth/login.html:15
|
||||
msgid "Forgot password?"
|
||||
msgstr ""
|
||||
msgstr "Quên mật khẩu?"
|
||||
|
||||
#: app/templates/auth/login.html:16 app/templates/auth/register.html:6
|
||||
msgid "Create account"
|
||||
msgstr ""
|
||||
msgstr "Tạo tài khoản"
|
||||
|
||||
#: app/templates/auth/register.html:16
|
||||
msgid "Already have an account? Sign in"
|
||||
msgstr ""
|
||||
msgstr "Đã có tài khoản? Đăng nhập"
|
||||
|
||||
#: app/templates/auth/reset.html:3 app/templates/auth/reset.html:6
|
||||
msgid "Set new password"
|
||||
msgstr ""
|
||||
msgstr "Đặt mật khẩu mới"
|
||||
|
||||
#: app/templates/auth/reset_request.html:3
|
||||
#: app/templates/auth/reset_request.html:6
|
||||
msgid "Reset password"
|
||||
msgstr ""
|
||||
msgstr "Đặt lại mật khẩu"
|
||||
|
||||
#: app/templates/errors/403.html:3
|
||||
msgid "Forbidden — you do not have access."
|
||||
msgstr ""
|
||||
msgstr "Bị cấm — bạn không có quyền truy cập."
|
||||
|
||||
#: app/templates/errors/403.html:3 app/templates/errors/404.html:3
|
||||
#: app/templates/errors/500.html:3
|
||||
msgid "Back home"
|
||||
msgstr ""
|
||||
msgstr "Về trang chủ"
|
||||
|
||||
#: app/templates/errors/404.html:3
|
||||
msgid "Not found."
|
||||
msgstr ""
|
||||
msgstr "Không tìm thấy."
|
||||
|
||||
#: app/templates/errors/500.html:3
|
||||
msgid "Something went wrong."
|
||||
msgstr ""
|
||||
msgstr "Đã xảy ra lỗi."
|
||||
|
||||
#: app/templates/listings/browse.html:6
|
||||
msgid "Filter"
|
||||
msgstr ""
|
||||
msgstr "Lọc"
|
||||
|
||||
#: app/templates/listings/browse.html:9
|
||||
msgid "Keyword"
|
||||
msgstr ""
|
||||
msgstr "Từ khóa"
|
||||
|
||||
#: app/templates/listings/browse.html:13
|
||||
msgid "Category"
|
||||
msgstr ""
|
||||
msgstr "Danh mục"
|
||||
|
||||
#: app/templates/listings/browse.html:15
|
||||
msgid "All"
|
||||
msgstr ""
|
||||
msgstr "Tất cả"
|
||||
|
||||
#: app/templates/listings/browse.html:21
|
||||
msgid "State"
|
||||
msgstr ""
|
||||
msgstr "Tiểu bang"
|
||||
|
||||
#: app/templates/listings/browse.html:24
|
||||
msgid "Min $"
|
||||
msgstr ""
|
||||
msgstr "Giá tối thiểu $"
|
||||
|
||||
#: app/templates/listings/browse.html:26
|
||||
msgid "Max $"
|
||||
msgstr ""
|
||||
msgstr "Giá tối đa $"
|
||||
|
||||
#: app/templates/listings/browse.html:30
|
||||
msgid "Near ZIP"
|
||||
msgstr ""
|
||||
msgstr "Gần mã ZIP"
|
||||
|
||||
#: app/templates/listings/browse.html:32
|
||||
msgid "Radius (mi)"
|
||||
msgstr ""
|
||||
msgstr "Bán kính (dặm)"
|
||||
|
||||
#: app/templates/listings/browse.html:35
|
||||
msgid "Apply"
|
||||
msgstr ""
|
||||
msgstr "Áp dụng"
|
||||
|
||||
#: app/templates/listings/browse.html:40
|
||||
#, python-format
|
||||
msgid "%(n)s results within %(r)s mi of %(z)s"
|
||||
msgstr ""
|
||||
msgstr "%(n)s kết quả trong vòng %(r)s dặm quanh %(z)s"
|
||||
|
||||
#: app/templates/listings/browse.html:41
|
||||
msgid "No listings found."
|
||||
msgstr ""
|
||||
msgstr "Không tìm thấy tin đăng."
|
||||
|
||||
#: app/templates/listings/browse.html:46
|
||||
msgid "No photo"
|
||||
msgstr ""
|
||||
msgstr "Không có ảnh"
|
||||
|
||||
#: app/templates/listings/browse.html:51
|
||||
msgid "Featured"
|
||||
msgstr ""
|
||||
msgstr "Nổi bật"
|
||||
|
||||
#: app/templates/listings/browse.html:63
|
||||
msgid "Prev"
|
||||
msgstr ""
|
||||
msgstr "Trước"
|
||||
|
||||
#: app/templates/listings/browse.html:64
|
||||
msgid "Next"
|
||||
msgstr ""
|
||||
msgstr "Sau"
|
||||
|
||||
#: app/templates/listings/detail.html:14
|
||||
#, python-format
|
||||
msgid "%(n)s views"
|
||||
msgstr ""
|
||||
msgstr "%(n)s lượt xem"
|
||||
|
||||
#: app/templates/listings/detail.html:41
|
||||
msgid "Verified"
|
||||
msgstr ""
|
||||
msgstr "Đã xác minh"
|
||||
|
||||
#: app/templates/listings/detail.html:44 app/templates/listings/mine.html:19
|
||||
msgid "Edit"
|
||||
msgstr ""
|
||||
msgstr "Sửa"
|
||||
|
||||
#: app/templates/listings/detail.html:47
|
||||
msgid "Mark sold"
|
||||
msgstr ""
|
||||
msgstr "Đánh dấu đã bán"
|
||||
|
||||
#: app/templates/listings/detail.html:51
|
||||
msgid "Delete this listing?"
|
||||
msgstr ""
|
||||
msgstr "Xóa tin này?"
|
||||
|
||||
#: app/templates/listings/detail.html:52
|
||||
msgid "Delete"
|
||||
msgstr ""
|
||||
msgstr "Xóa"
|
||||
|
||||
#: app/templates/listings/detail.html:55
|
||||
msgid "Messaging arrives in Phase 3."
|
||||
msgstr ""
|
||||
msgstr "Tính năng nhắn tin sẽ có ở Giai đoạn 3."
|
||||
|
||||
#: app/templates/listings/form.html:3 app/templates/listings/form.html:6
|
||||
msgid "Edit listing"
|
||||
msgstr ""
|
||||
msgstr "Sửa tin"
|
||||
|
||||
#: app/templates/listings/form.html:3 app/templates/listings/form.html:6
|
||||
msgid "New listing"
|
||||
msgstr ""
|
||||
msgstr "Tin mới"
|
||||
|
||||
#: app/templates/listings/form.html:47
|
||||
msgid "Current photos"
|
||||
msgstr ""
|
||||
msgstr "Ảnh hiện tại"
|
||||
|
||||
#: app/templates/listings/mine.html:7
|
||||
#, python-format
|
||||
msgid "Active: %(a)s"
|
||||
msgstr ""
|
||||
msgstr "Đang đăng: %(a)s"
|
||||
|
||||
#: app/templates/listings/mine.html:8
|
||||
msgid "Post new"
|
||||
msgstr ""
|
||||
msgstr "Đăng tin mới"
|
||||
|
||||
#: app/templates/listings/mine.html:10
|
||||
msgid "No listings yet."
|
||||
msgstr ""
|
||||
msgstr "Chưa có tin đăng."
|
||||
|
||||
#: app/templates/listings/mine.html:18
|
||||
msgid "views"
|
||||
msgstr ""
|
||||
msgstr "lượt xem"
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Time helpers.
|
||||
|
||||
`datetime.utcnow()` is deprecated on Python 3.12+. This helper preserves the
|
||||
existing *naive UTC* semantics the codebase relies on (DB columns are naive
|
||||
`DateTime`, and comparisons assume naive UTC) while avoiding the deprecation:
|
||||
we build an aware UTC datetime and strip the tzinfo, yielding the exact same
|
||||
value `datetime.utcnow()` used to return.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
"""Current UTC time as a naive datetime (tzinfo stripped)."""
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""reviews table (Phase 7)
|
||||
|
||||
Revision ID: b7f3c9a2d451
|
||||
Revises: 482a73a8c5bb
|
||||
Create Date: 2026-07-13 17:30:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'b7f3c9a2d451'
|
||||
down_revision = '482a73a8c5bb'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('reviews',
|
||||
sa.Column('id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), autoincrement=True, nullable=False),
|
||||
sa.Column('listing_id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=False),
|
||||
sa.Column('author_id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=False),
|
||||
sa.Column('seller_id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=False),
|
||||
sa.Column('rating', sa.SmallInteger(), nullable=False),
|
||||
sa.Column('body', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['listing_id'], ['listings.id'], ),
|
||||
sa.ForeignKeyConstraint(['author_id'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['seller_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('listing_id', 'author_id', name='uq_review_listing_author'),
|
||||
sa.CheckConstraint('rating BETWEEN 1 AND 5', name='ck_review_rating')
|
||||
)
|
||||
with op.batch_alter_table('reviews', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_reviews_listing_id'), ['listing_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_reviews_author_id'), ['author_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_reviews_seller_id'), ['seller_id'], unique=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('reviews', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_reviews_seller_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_reviews_author_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_reviews_listing_id'))
|
||||
|
||||
op.drop_table('reviews')
|
||||
# ### end Alembic commands ###
|
||||
+17
-5
@@ -10,7 +10,7 @@ import os
|
||||
import re
|
||||
import io
|
||||
import logging
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret")
|
||||
@@ -20,6 +20,11 @@ _SMOKE_DB = os.path.join(tempfile.gettempdir(), "classifieds_smoke.db")
|
||||
if os.path.exists(_SMOKE_DB):
|
||||
os.remove(_SMOKE_DB)
|
||||
os.environ.setdefault("DATABASE_URL", f"sqlite:///{_SMOKE_DB}")
|
||||
# Use a dedicated temp media dir so image counts are deterministic across runs.
|
||||
_SMOKE_MEDIA = os.path.join(tempfile.gettempdir(), "classifieds_smoke_media")
|
||||
if os.path.exists(_SMOKE_MEDIA):
|
||||
shutil.rmtree(_SMOKE_MEDIA)
|
||||
os.environ["MEDIA_ROOT"] = _SMOKE_MEDIA
|
||||
os.environ.setdefault("REDIS_URL", "memory://") # limiter in-memory
|
||||
os.environ.setdefault("FLASK_CONFIG", "dev")
|
||||
|
||||
@@ -273,7 +278,7 @@ def _phase4(app):
|
||||
user_fresh = db.session.get(User, user.id)
|
||||
assert user_fresh.role == Role.subscriber
|
||||
assert user_fresh.tier.slug == "pro"
|
||||
print("sync_subscription → user upgraded to pro subscriber: ok")
|
||||
print("sync_subscription -> user upgraded to pro subscriber: ok")
|
||||
|
||||
# --- downgrade to free ---
|
||||
bsvc.downgrade_to_free(user.id)
|
||||
@@ -659,7 +664,8 @@ def _phase6(app):
|
||||
login("t@example.com", "NewPass456")
|
||||
for path in ("/admin", "/admin/listings", "/admin/reports", "/admin/settings",
|
||||
"/admin/categories", "/admin/plans", "/admin/transactions",
|
||||
"/admin/audit"):
|
||||
"/admin/audit", "/admin/ads", "/admin/sponsors",
|
||||
"/admin/promoted-keywords", "/admin/analytics"):
|
||||
assert c.get(path, base_url=B).status_code == 403
|
||||
print("non-admin /admin* -> 403: ok")
|
||||
c.get("/auth/logout", base_url=B)
|
||||
@@ -674,7 +680,11 @@ def _phase6(app):
|
||||
"/admin/listings", "/admin/reports", "/admin/settings",
|
||||
"/admin/categories", f"/admin/categories/{cat_id}/schema",
|
||||
"/admin/plans", f"/admin/plans/{plan_id}",
|
||||
"/admin/transactions", "/admin/audit"):
|
||||
"/admin/transactions", "/admin/audit",
|
||||
"/admin/ads", "/admin/ads/new",
|
||||
"/admin/sponsors", "/admin/sponsors/new",
|
||||
"/admin/promoted-keywords", "/admin/promoted-keywords/new",
|
||||
"/admin/analytics"):
|
||||
code = c.get(path, base_url=B).status_code
|
||||
assert code == 200, f"{path} -> {code}"
|
||||
print(f"{code} {path}")
|
||||
@@ -993,7 +1003,9 @@ def _phase2(app):
|
||||
db.session.add(img); db.session.commit()
|
||||
assert img.path.endswith(".jpg") and img.thumb_path and img.width <= 1600
|
||||
import os
|
||||
media = os.path.join(app.instance_path, "media", str(l1.id))
|
||||
media_root = (app.config.get("MEDIA_ROOT")
|
||||
or os.path.join(app.instance_path, "media"))
|
||||
media = os.path.join(media_root, str(l1.id))
|
||||
assert os.path.isdir(media) and len(os.listdir(media)) == 2
|
||||
print("image pipeline (re-encode + thumbnail + EXIF strip): ok")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user