Files
veola/templates/item_form.templ
2026-05-13 19:42:49 -07:00

303 lines
9.3 KiB
Plaintext
Raw 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 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>
<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))
}