Files
veola/templates/item_preview.templ
prosolis 90a74967e0 Refine item form and preview
- Item form: subtitle + fields grouped into hairline-separated labeled
  sections (Basics / Alerts / Schedule and marketplaces / Filters /
  Advanced) instead of one flat card; refined Advanced summary.
- Preview: refined headings; best-price hero keeps a 160px product image;
  Other results becomes a thumbnailed feed via the shared components.
- New .v-form-section-title helper.
2026-06-20 12:52:25 -07:00

166 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">Nothing listed right now</div>
<div>Track it anyway. Veola will alert you when it appears. Leave the target price blank to be notified on any new listing.</div>
</div>
} else {
<div class="v-card p-5">
<div class="flex items-center justify-between mb-4">
<h2 class="v-section-title">
{ 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-6">
<div class="v-form-section-title mb-3">Other results</div>
<ul class="v-feed">
for i, r := range d.Results {
if i != d.BestIndex && i < d.BestIndex+6 {
<li class="v-feed-row">
@thumbSm(r.ImageURL)
<div class="min-w-0 flex-1">
<a href={ templ.SafeURL(r.URL) } target="_blank" rel="noopener" class="v-feed-name truncate">{ r.Title }</a>
<div class="v-feed-meta truncate">{ r.Store }</div>
</div>
<div class="font-mono v-feed-price">{ 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="" loading="lazy" referrerpolicy="no-referrer" class="w-40 h-40 object-cover rounded-lg border border-white/10"/>
} else {
<div class="w-40 h-40 rounded-lg border border-white/10 bg-black/20"></div>
}
<div>
<div class="v-metric-label v-price-deal 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.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)
}