Initial commit
This commit is contained in:
302
templates/item_form.templ
Normal file
302
templates/item_form.templ
Normal file
@@ -0,0 +1,302 @@
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user