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>
168 lines
5.4 KiB
Plaintext
168 lines
5.4 KiB
Plaintext
package templates
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"veola/internal/apify"
|
|
)
|
|
|
|
type PreviewData struct {
|
|
CSRFToken string
|
|
Form FormValues
|
|
Results []apify.UnifiedResult
|
|
BestIndex int
|
|
MinPrice float64
|
|
MaxPrice float64
|
|
StoreCount int
|
|
Error string
|
|
Empty bool
|
|
Cached bool
|
|
Currency string
|
|
}
|
|
|
|
// FormValues mirrors the Step 1 form so the confirm POST has every field.
|
|
type FormValues struct {
|
|
Name string
|
|
SearchQuery string
|
|
URL string
|
|
Category string
|
|
TargetPrice string
|
|
MinPrice string
|
|
ExcludeKeywords string
|
|
NtfyTopic string
|
|
NtfyPriority string
|
|
PollIntervalMinutes string
|
|
IncludeOutOfStock bool
|
|
Marketplaces []string
|
|
ListingType string
|
|
Condition string
|
|
Region string
|
|
ActorActive string
|
|
ActorSold string
|
|
ActorPriceCompare string
|
|
UsePriceComparison bool
|
|
}
|
|
|
|
templ ItemPreview(d PreviewData) {
|
|
if d.Error != "" {
|
|
<div class="v-flash-error">
|
|
<div class="font-semibold mb-1">Could not run preview</div>
|
|
<div>{ d.Error }</div>
|
|
<button class="v-btn-ghost mt-2" hx-get="/items/new" hx-target="#preview-target" hx-swap="innerHTML">Back</button>
|
|
</div>
|
|
} else if d.Empty {
|
|
<div class="v-flash">
|
|
<div class="font-semibold mb-1">No results found</div>
|
|
<div>Try a broader search query or a different marketplace.</div>
|
|
</div>
|
|
} else {
|
|
<div class="v-card p-5">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h2 class="font-semibold">
|
|
{ fmt.Sprintf("Found %d results for '%s'", len(d.Results), d.Form.SearchQuery) }
|
|
if d.Cached {
|
|
<span class="v-pill v-pill-paused ml-2">cached</span>
|
|
}
|
|
</h2>
|
|
</div>
|
|
@previewBest(d)
|
|
if len(d.Results) > 1 {
|
|
<div class="mt-5">
|
|
<div class="v-muted text-xs uppercase tracking-wide mb-2">Other results</div>
|
|
<ul class="space-y-2">
|
|
for i, r := range d.Results {
|
|
if i != d.BestIndex && i < d.BestIndex+6 {
|
|
<li class="flex items-center gap-3">
|
|
if r.ImageURL != "" {
|
|
<img src={ r.ImageURL } alt="" class="w-10 h-10 object-cover rounded"/>
|
|
}
|
|
<div class="flex-1 truncate">
|
|
<a href={ templ.SafeURL(r.URL) } target="_blank" rel="noopener" class="text-sm">{ r.Title }</a>
|
|
<div class="v-muted text-xs">{ r.Store }</div>
|
|
</div>
|
|
<div class="font-mono">{ fmtNumber(r.Price, r.Currency) }</div>
|
|
</li>
|
|
}
|
|
}
|
|
</ul>
|
|
if len(d.Results) > 6 {
|
|
<div class="v-muted text-xs mt-2">{ fmt.Sprintf("and %d more", len(d.Results)-6) }</div>
|
|
}
|
|
</div>
|
|
}
|
|
<div class="v-divider my-4"></div>
|
|
<div class="text-sm v-muted">
|
|
{ fmt.Sprintf("Prices range from %s to %s across %d stores", fmtNumber(d.MinPrice, d.Currency), fmtNumber(d.MaxPrice, d.Currency), d.StoreCount) }
|
|
</div>
|
|
</div>
|
|
}
|
|
@confirmForm(d)
|
|
}
|
|
|
|
templ previewBest(d PreviewData) {
|
|
if d.BestIndex >= 0 && d.BestIndex < len(d.Results) {
|
|
<div class="grid md:grid-cols-[160px_1fr] gap-4 items-start">
|
|
if d.Results[d.BestIndex].ImageURL != "" {
|
|
<img src={ d.Results[d.BestIndex].ImageURL } alt="" class="rounded w-40 h-40 object-cover"/>
|
|
} else {
|
|
<div class="rounded w-40 h-40 bg-black/30"></div>
|
|
}
|
|
<div>
|
|
<div class="text-xs v-price-deal uppercase tracking-wide mb-1">Best Price</div>
|
|
<div class="font-mono text-3xl mb-2">{ fmtNumber(d.Results[d.BestIndex].Price, d.Currency) }</div>
|
|
<a class="font-semibold" href={ templ.SafeURL(d.Results[d.BestIndex].URL) } target="_blank" rel="noopener">{ d.Results[d.BestIndex].Title }</a>
|
|
<div class="v-muted text-sm mt-1">{ d.Results[d.BestIndex].Store }</div>
|
|
if d.Results[d.BestIndex].MatchedQuery != "" {
|
|
<div class="v-muted text-xs mt-1">via "{ d.Results[d.BestIndex].MatchedQuery }"</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
}
|
|
|
|
templ confirmForm(d PreviewData) {
|
|
<form method="post" action="/items" class="mt-5 flex items-center gap-3">
|
|
<input type="hidden" name="csrf_token" value={ d.CSRFToken }/>
|
|
@hidden("name", d.Form.Name)
|
|
@hidden("search_query", d.Form.SearchQuery)
|
|
@hidden("url", d.Form.URL)
|
|
@hidden("category", d.Form.Category)
|
|
@hidden("target_price", d.Form.TargetPrice)
|
|
@hidden("min_price", d.Form.MinPrice)
|
|
@hidden("exclude_keywords", d.Form.ExcludeKeywords)
|
|
@hidden("ntfy_topic", d.Form.NtfyTopic)
|
|
@hidden("ntfy_priority", d.Form.NtfyPriority)
|
|
@hidden("poll_interval_minutes", d.Form.PollIntervalMinutes)
|
|
@hiddenBool("include_out_of_stock", d.Form.IncludeOutOfStock)
|
|
for _, m := range d.Form.Marketplaces {
|
|
@hidden("marketplace", m)
|
|
}
|
|
@hidden("listing_type", d.Form.ListingType)
|
|
@hidden("condition", d.Form.Condition)
|
|
@hidden("region", d.Form.Region)
|
|
@hidden("actor_active", d.Form.ActorActive)
|
|
@hidden("actor_sold", d.Form.ActorSold)
|
|
@hidden("actor_price_compare", d.Form.ActorPriceCompare)
|
|
@hiddenBool("use_price_comparison", d.Form.UsePriceComparison)
|
|
<button type="submit" class="v-btn" disabled?={ d.Empty || d.Error != "" }>Confirm and Track</button>
|
|
<a class="v-btn-ghost" href="/items/new">Back</a>
|
|
</form>
|
|
}
|
|
|
|
templ hidden(name, value string) {
|
|
<input type="hidden" name={ name } value={ value }/>
|
|
}
|
|
|
|
templ hiddenBool(name string, value bool) {
|
|
if value {
|
|
<input type="hidden" name={ name } value="1"/>
|
|
}
|
|
}
|
|
|
|
func fmtNumber(p float64, currency string) string {
|
|
if p == 0 {
|
|
return "—"
|
|
}
|
|
return fmt.Sprintf("%s%.2f", currencySymbol(currency), p)
|
|
}
|