303 lines
9.3 KiB
Plaintext
303 lines
9.3 KiB
Plaintext
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="yahoo.co.jp"]:checked, #marketplace-grid input[value="mercari.jp"]: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 30–60s.
|
||
</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))
|
||
}
|