Auction end times, visual flair, and pre-launch cleanup

Auction handling:
- Capture itemEndDate from eBay Browse API and ending_date from ZenMarket
  (Yahoo JP); plumb through results.ends_at column. Permissive ZenMarket
  parser (multiple layouts, JST when offset missing).
- Per-row "Ends" countdown column + "Ending soon" banner on results pages,
  live-ticked by flair.js with urgent/critical tinting under 1h/5m.
- Backfill ends_at for known auctions when their URL reappears in a poll
  (dedup hit no longer drops the new end time).
- Hide ended auctions from result listings by default via
  ResultsQuery.ExcludeEnded; rows stay in the DB.

Visual flair:
- Glassy backdrop-blur v-cards with gradient-mask borders and hover-lift.
- htmx swap fade-in via transient .v-just-swapped class.
- Count-up animation on dashboard stats. All animations gated behind
  prefers-reduced-motion.

eBay condition + region filters (auctions-style scoping):
- items.condition and items.region columns; threaded through item form,
  CreateItem/UpdateItem, scheduler eBay plan input, and previewKey so
  cache invalidates when these change.
- ebay.SearchParams gains conditionIds and itemLocationCountry filters.

Run Now reload + countdown engine:
- Run Now now sets HX-Refresh: true (non-htmx fallback: 303 redirect) so
  the entire results view — best price, chart, badge, last polled —
  reflects the new poll, instead of swapping just one partial.

Pre-launch hardening (P1 set):
- auth.EqualizeLoginTiming on no-such-user branch.
- (*App).serverError centralizes 500s; replaces err.Error() leaks across
  results/settings/items/users/dashboard handlers.
- main.go server: ReadTimeout 30s / WriteTimeout 60s / IdleTimeout 120s
  alongside the existing ReadHeaderTimeout.
- noListFS wrapper blocks static directory listings.
- Credential fields in settings no longer render value=; blank submission
  preserves the saved value, with per-field "Saved in settings / Set in
  config.toml / Not set" status indicator.

Misc:
- -debug flag wires slog to LevelDebug; raw ZenMarket items logged for
  format diagnosis.
- /healthz public endpoint for reverse-proxy probes.
- deploy/veola.service systemd unit template (hardening flags, single
  ReadWritePaths=/var/lib/veola).
- handlers_test.go covers /healthz, setup-gate redirect, auth gate, and
  /login render with httptest + in-memory sqlite.
- best_price_currency on items; templates pick the right symbol per row.
- .gitignore now excludes *.log / veola-debug.log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-15 17:47:09 -07:00
parent d87536c879
commit edb732ee1f
39 changed files with 2264 additions and 947 deletions

View File

@@ -16,6 +16,43 @@ type ItemFormData struct {
func itemSelected(have, want string) bool { return have == want }
type selectOpt struct {
Value string
Label string
}
// conditionOptions are the eBay-only item-condition filters. Values match the
// vocabulary mapped to Browse API condition IDs in internal/ebay.
func conditionOptions() []selectOpt {
return []selectOpt{
{"", "— any —"},
{"new", "New"},
{"used", "Used"},
{"refurbished", "Refurbished"},
{"parts", "For parts / not working"},
}
}
// regionOptions are the eBay-only item-location filters, keyed by ISO 3166-1
// alpha-2 country code (the value the Browse API itemLocationCountry filter
// expects).
func regionOptions() []selectOpt {
return []selectOpt{
{"", "— any —"},
{"US", "United States"},
{"GB", "United Kingdom"},
{"DE", "Germany"},
{"FR", "France"},
{"IT", "Italy"},
{"ES", "Spain"},
{"CA", "Canada"},
{"AU", "Australia"},
{"JP", "Japan"},
{"CN", "China"},
{"HK", "Hong Kong"},
}
}
type marketplaceOpt struct {
Value string
Label string
@@ -226,6 +263,26 @@ templ itemFormInner(d ItemFormData) {
<div class="v-muted text-xs mt-1">Drop results whose title contains any of these. Case-insensitive substring match.</div>
</div>
</div>
<div class="grid md:grid-cols-2 gap-4">
<div>
<label class="v-label">Condition</label>
<select class="v-select" name="condition">
for _, o := range conditionOptions() {
<option value={ o.Value } selected?={ itemSelected(d.Item.Condition, o.Value) }>{ o.Label }</option>
}
</select>
<div class="v-muted text-xs mt-1">eBay marketplaces only. Ignored for Yahoo JP, Mercari, and custom actors.</div>
</div>
<div>
<label class="v-label">Item Location</label>
<select class="v-select" name="region">
for _, o := range regionOptions() {
<option value={ o.Value } selected?={ itemSelected(d.Item.Region, o.Value) }>{ o.Label }</option>
}
</select>
<div class="v-muted text-xs mt-1">Restrict to items located in this country. eBay marketplaces only.</div>
</div>
</div>
<label class="flex items-center gap-2">
<input type="checkbox" name="include_out_of_stock" checked?={ d.Item.IncludeOutOfStock } value="1"/>
<span>Include out-of-stock results</span>