Files
veola/templates/item_form.templ
prosolis edb732ee1f 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>
2026-05-15 17:47:09 -07:00

360 lines
11 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package templates
import (
"fmt"
"veola/internal/models"
)
type ItemFormData struct {
Page
IsEdit bool
Item models.Item
Categories []string
Errors []string
}
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
}
func marketplaceOptions() []marketplaceOpt {
return []marketplaceOpt{
{"ebay.com", "eBay (US)"},
{"ebay.co.uk", "eBay (UK)"},
{"ebay.de", "eBay (DE)"},
{"ebay.fr", "eBay (FR)"},
{"ebay.com.au", "eBay (AU)"},
{"ebay.ca", "eBay (CA)"},
{"yahoo.co.jp", "Yahoo Auctions JP"},
{"mercari.jp", "Mercari JP"},
}
}
func isKnownMarketplace(v string) bool {
for _, o := range marketplaceOptions() {
if o.Value == v {
return true
}
}
return false
}
func itemHasMarketplace(it models.Item, v string) bool {
for _, m := range it.Marketplaces {
if m == v {
return true
}
}
return false
}
// customMarketplacesCSV returns the comma-separated list of marketplaces on
// the item that are NOT in the curated list, so the user can keep editing
// unusual values without losing them.
func customMarketplacesCSV(it models.Item) string {
var custom []string
for _, m := range it.Marketplaces {
if !isKnownMarketplace(m) {
custom = append(custom, m)
}
}
return joinCSV(custom)
}
func joinCSV(vs []string) string {
out := ""
for i, v := range vs {
if i > 0 {
out += ", "
}
out += v
}
return out
}
func itemHasJapanMarketplace(it models.Item) bool {
for _, m := range it.Marketplaces {
if m == "yahoo.co.jp" || m == "mercari.jp" {
return true
}
}
return false
}
func newCategory(v string, known []string) string {
if v == "" {
return ""
}
for _, c := range known {
if c == v {
return ""
}
}
return v
}
templ itemFormBody(d ItemFormData) {
<div>
<h1 class="text-3xl font-semibold mb-6">
if d.IsEdit {
Edit { d.Item.Name }
} else {
Add Item
}
</h1>
if len(d.Errors) > 0 {
<div class="v-flash-error">
<ul class="list-disc pl-5">
for _, e := range d.Errors {
<li>{ e }</li>
}
</ul>
</div>
}
@itemFormInner(d)
</div>
}
templ itemFormInner(d ItemFormData) {
<form
method="post"
action={ formAction(d) }
if !d.IsEdit {
hx-post="/items/preview"
hx-target="#preview-target"
hx-swap="innerHTML"
hx-indicator="#preview-loading"
hx-disabled-elt="find button[type='submit']"
}
class="v-card p-6 space-y-4 max-w-3xl"
>
@CSRFInput(d.CSRFToken)
<div class="grid md:grid-cols-2 gap-4">
<div>
<label class="v-label">Name</label>
<input class="v-input" name="name" value={ d.Item.Name } required/>
</div>
<div>
<label class="v-label">Category</label>
<select class="v-select" name="category">
<option value="" selected?={ d.Item.Category == "" }>— none —</option>
for _, c := range d.Categories {
<option value={ c } selected?={ d.Item.Category == c }>{ c }</option>
}
</select>
<input class="v-input mt-2 text-sm" name="category_new" value={ newCategory(d.Item.Category, d.Categories) } placeholder="New category (overrides above)"/>
</div>
</div>
<div>
<label class="v-label">Search Queries</label>
<textarea class="v-textarea" name="search_query" rows="4" lang="ja" placeholder="One per line, or comma / semicolon separated">{ d.Item.SearchQuery }</textarea>
<div class="v-muted text-xs mt-1">Each is searched independently; the lowest price across all wins. Cost scales with the number of queries.</div>
</div>
<div>
<label class="v-label">Product URL</label>
<input class="v-input" name="url" value={ d.Item.URL }/>
<div class="v-muted text-xs mt-1">At least one of search queries or URL is required.</div>
</div>
<div class="grid md:grid-cols-3 gap-4">
<div>
<label class="v-label">Target Price</label>
<input class="v-input font-mono" name="target_price" type="number" step="0.01" value={ optFloat(d.Item.TargetPrice) }/>
<div class="v-muted text-xs mt-1">Blank = alert on every new result.</div>
</div>
<div>
<label class="v-label">Ntfy Topic</label>
<input class="v-input" name="ntfy_topic" value={ d.Item.NtfyTopic } required/>
</div>
<div>
<label class="v-label">Ntfy Priority</label>
<select class="v-select" name="ntfy_priority">
for _, p := range []string{"min","low","default","high","urgent"} {
<option value={ p } selected?={ itemSelected(d.Item.NtfyPriority, p) }>{ p }</option>
}
</select>
</div>
</div>
<div class="grid md:grid-cols-3 gap-4">
<div>
<label class="v-label">Poll Interval</label>
<select class="v-select" name="poll_interval_minutes">
for _, opt := range pollOptions() {
<option value={ fmt.Sprintf("%d", opt.Minutes) } selected?={ d.Item.PollIntervalMinutes == opt.Minutes }>{ opt.Label }</option>
}
</select>
</div>
<div>
<label class="v-label">Marketplaces</label>
<div id="marketplace-grid" class="grid grid-cols-2 gap-1" onchange="document.getElementById('marketplace-jp-note').hidden = !document.querySelector('#marketplace-grid input[value=&quot;yahoo.co.jp&quot;]:checked, #marketplace-grid input[value=&quot;mercari.jp&quot;]:checked')">
for _, m := range marketplaceOptions() {
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" name="marketplace" value={ m.Value } checked?={ itemHasMarketplace(d.Item, m.Value) }/>
<span>{ m.Label }</span>
</label>
}
</div>
<input class="v-input mt-2 text-sm" name="marketplace_custom" value={ customMarketplacesCSV(d.Item) } placeholder="Custom (comma-separated, added to above)"/>
<div
id="marketplace-jp-note"
class="v-flash mt-2 text-sm"
hidden?={ !itemHasJapanMarketplace(d.Item) }
>Yahoo Auctions JP and Mercari JP search in Japanese. English queries return few or no results.</div>
<div class="v-muted text-xs mt-1">Pick one or more. Each is polled per cycle; one failure does not stop the others.</div>
</div>
<div>
<label class="v-label">Listing Type</label>
<select class="v-select" name="listing_type">
for _, lt := range []string{"all","BIN","auction"} {
<option value={ lt } selected?={ itemSelected(d.Item.ListingType, lt) }>{ lt }</option>
}
</select>
</div>
</div>
<div class="grid md:grid-cols-2 gap-4">
<div>
<label class="v-label">Minimum Price</label>
<input class="v-input font-mono" name="min_price" type="number" step="0.01" value={ optFloat(d.Item.MinPrice) }/>
<div class="v-muted text-xs mt-1">Drop results below this price. Filters out accessories and obvious junk.</div>
</div>
<div>
<label class="v-label">Exclude Keywords</label>
<textarea class="v-textarea" name="exclude_keywords" rows="3" placeholder="One per line, or comma separated">{ d.Item.ExcludeKeywords }</textarea>
<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>
</label>
<details class="v-card-flat p-4">
<summary class="cursor-pointer font-semibold">Advanced</summary>
<div class="v-muted text-sm mt-2">Leave blank to use the configured default for the selected marketplace.</div>
<div class="grid md:grid-cols-2 gap-4 mt-3">
<div>
<label class="v-label">Active Listings Actor</label>
<input class="v-input" name="actor_active" value={ d.Item.ActorActive } placeholder="from config"/>
</div>
<div>
<label class="v-label">Sold Listings Actor</label>
<input class="v-input" name="actor_sold" value={ d.Item.ActorSold } placeholder="from config"/>
</div>
<div>
<label class="v-label">Price Comparison Actor</label>
<input class="v-input" name="actor_price_compare" value={ d.Item.ActorPriceCompare } placeholder="from config"/>
</div>
<label class="flex items-center gap-2 mt-6">
<input type="checkbox" name="use_price_comparison" checked?={ d.Item.UsePriceComparison } value="1"/>
<span>Use price comparison actor in addition to active listings</span>
</label>
</div>
</details>
<div class="flex items-center gap-3 pt-2">
if d.IsEdit {
<button class="v-btn" type="submit">Save</button>
<a class="v-btn-ghost" href="/items">Cancel</a>
} else {
<button class="v-btn" type="submit">Preview</button>
<a class="v-btn-ghost" href="/items">Cancel</a>
<span id="preview-loading" class="htmx-indicator v-muted text-sm flex items-center gap-2">
<span class="v-spinner"></span>
Running preview… apify runs can take 3060s.
</span>
}
</div>
</form>
if !d.IsEdit {
<div id="preview-target" class="mt-6"></div>
}
}
func formAction(d ItemFormData) templ.SafeURL {
if d.IsEdit {
return templ.SafeURL(fmt.Sprintf("/items/%d", d.Item.ID))
}
return templ.SafeURL("/items/preview")
}
type pollOpt struct {
Minutes int
Label string
}
func pollOptions() []pollOpt {
return []pollOpt{
{15, "15 min"}, {30, "30 min"}, {60, "1 hr"}, {120, "2 hr"},
{360, "6 hr"}, {720, "12 hr"}, {1440, "24 hr"},
}
}
func optFloat(p *float64) string {
if p == nil {
return ""
}
return fmt.Sprintf("%.2f", *p)
}
templ ItemForm(d ItemFormData) {
@Layout(d.Page, itemFormBody(d))
}