diff --git a/.gitignore b/.gitignore index d1bd182..5f01af6 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,7 @@ config.toml *.swp .idea/ .vscode/ + +# Debug log output from `-debug` runs +veola-debug.log +*.log diff --git a/deploy/veola.service b/deploy/veola.service new file mode 100644 index 0000000..482c816 --- /dev/null +++ b/deploy/veola.service @@ -0,0 +1,52 @@ +[Unit] +Description=Veola price tracker +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple + +# --- Edit these for your host --------------------------------------------- +# User must be able to read config.toml and write WorkingDirectory (sqlite WAL). +User=veola +Group=veola +WorkingDirectory=/var/lib/veola +ExecStart=/usr/local/bin/veola-bin -config /etc/veola/config.toml +# -------------------------------------------------------------------------- + +Restart=on-failure +RestartSec=5s +# SIGINT triggers the graceful-shutdown path in main.go (matches Ctrl-C). +KillSignal=SIGINT +TimeoutStopSec=45s + +# Hardening. Veola only needs to read its config, write its sqlite db, and +# reach the network. Everything else can be locked down. +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectKernelLogs=true +ProtectControlGroups=true +ProtectClock=true +ProtectHostname=true +ProtectProc=invisible +RestrictNamespaces=true +RestrictRealtime=true +RestrictSUIDSGID=true +LockPersonality=true +MemoryDenyWriteExecute=true +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallFilter=~@privileged @resources +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +CapabilityBoundingSet= +AmbientCapabilities= +# Allow writes only to the sqlite db directory. +ReadWritePaths=/var/lib/veola +UMask=0027 + +[Install] +WantedBy=multi-user.target diff --git a/internal/apify/types.go b/internal/apify/types.go index 90a70c4..4c6876e 100644 --- a/internal/apify/types.go +++ b/internal/apify/types.go @@ -2,10 +2,70 @@ package apify import ( "encoding/json" + "log/slog" "strconv" "strings" + "time" ) +// jstLocation is the timezone ZenMarket reports Yahoo Auctions JP end times +// in when the value lacks an explicit offset. Falls back to UTC if the +// embedded tzdata lookup fails (main.go imports time/tzdata so in practice +// this always resolves). +var jstLocation = func() *time.Location { + loc, err := time.LoadLocation("Asia/Tokyo") + if err != nil { + return time.UTC + } + return loc +}() + +// yahooEndingDateLayouts is the ordered list of layouts attempted when +// parsing ZenMarket's `ending_date`. Some entries use Local-as-JST (no +// offset in the string) — see parseYahooEndingDate for the routing. +var yahooEndingDateLayouts = []struct { + layout string + jst bool // true: parse as JST; false: parse with offset/zone from string +}{ + {time.RFC3339, false}, + {"2006-01-02T15:04:05", true}, + {"2006-01-02 15:04:05", true}, + {"2006/01/02 15:04:05", true}, + {"2006/01/02 15:04", true}, + {"2006-01-02 15:04", true}, +} + +// parseYahooEndingDate is a permissive parser for ZenMarket's ending_date +// field. The actor's output format has shifted over time and isn't documented; +// rather than wedging on one layout, try the known shapes in order and treat +// anything without an explicit zone as JST (Yahoo Auctions runs in Japan). +// Returns nil + logs a warning when nothing parses, so operators see the raw +// value and can extend this list. +func parseYahooEndingDate(s string) *time.Time { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + for _, l := range yahooEndingDateLayouts { + var ( + t time.Time + err error + ) + if l.jst { + t, err = time.ParseInLocation(l.layout, s, jstLocation) + } else { + t, err = time.Parse(l.layout, s) + } + if err == nil { + slog.Debug("yahoo ending_date parsed", + "raw", s, "layout", l.layout, "jst_assumed", l.jst, "parsed", t.UTC().Format(time.RFC3339)) + return &t + } + } + slog.Warn("yahoo ending_date: no layout matched", "raw", s) + return nil +} + // ActiveListingInput is the input schema for `automation-lab/ebay-scraper`. // The actor accepts keyword searches and standard filters; it targets // ebay.com only (no per-marketplace routing in the actor itself), so @@ -145,6 +205,10 @@ type UnifiedResult struct { // MatchedQuery records which alias from the item's query list produced // this row. Empty for URL-only items or rows from non-search sources. MatchedQuery string + // EndsAt is the auction end time, if known. Auction-format eBay listings + // and Yahoo Auctions JP listings populate this; fixed-price listings + // and most Apify scraper outputs leave it nil. + EndsAt *time.Time } // Decode unmarshals a list of raw JSON items into UnifiedResult slices using @@ -197,8 +261,10 @@ func Decode(items []json.RawMessage, source string) ([]UnifiedResult, error) { } case SourceYahooJP: for _, raw := range items { + slog.Debug("yahoo raw item", "json", string(raw)) var r YahooAuctionsJPResult if err := json.Unmarshal(raw, &r); err != nil { + slog.Debug("yahoo decode failed", "err", err, "json", string(raw)) continue } img := "" @@ -213,6 +279,7 @@ func Decode(items []json.RawMessage, source string) ([]UnifiedResult, error) { Store: "yahoo-auctions-jp (via zenmarket)", ImageURL: img, Source: source, + EndsAt: parseYahooEndingDate(r.EndingDate), }) } case SourceMercariJP: diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 6c4112f..945bf9a 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -73,6 +73,19 @@ func CheckPassword(hash, plain string) bool { return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil } +// dummyHash is a valid bcrypt hash of a throwaway value. It exists only so a +// login attempt for a non-existent user can still run a real bcrypt +// comparison, equalizing response time and closing the user-enumeration +// timing oracle. +var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("veola-timing-equalizer"), BcryptCost) + +// EqualizeLoginTiming performs a bcrypt comparison against a throwaway hash so +// that a missing username costs roughly the same wall-clock time as a wrong +// password. Call it on the no-such-user branch of login. +func EqualizeLoginTiming() { + _ = bcrypt.CompareHashAndPassword(dummyHash, []byte("veola-timing-equalizer-x")) +} + // LogIn writes the user id into the session and rotates the token. func (m *Manager) LogIn(ctx context.Context, userID int64) error { if err := m.Sessions.RenewToken(ctx); err != nil { diff --git a/internal/db/db.go b/internal/db/db.go index ff82b71..23806b2 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -37,6 +37,22 @@ func Open(path string) (*sql.DB, error) { conn.Close() return nil, err } + if err := addColumnIfMissing(conn, "items", "condition", "TEXT"); err != nil { + conn.Close() + return nil, err + } + if err := addColumnIfMissing(conn, "items", "region", "TEXT"); err != nil { + conn.Close() + return nil, err + } + if err := addColumnIfMissing(conn, "items", "best_price_currency", "TEXT"); err != nil { + conn.Close() + return nil, err + } + if err := addColumnIfMissing(conn, "results", "ends_at", "DATETIME"); err != nil { + conn.Close() + return nil, err + } return conn, nil } diff --git a/internal/db/queries.go b/internal/db/queries.go index 94c9fb4..999902c 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -274,20 +274,20 @@ func (s *Store) CreateItem(ctx context.Context, it *models.Item) (int64, error) INSERT INTO items ( name, search_query, url, category, target_price, ntfy_topic, ntfy_priority, poll_interval_minutes, include_out_of_stock, min_price, exclude_keywords, - listing_type, + listing_type, condition, region, actor_active, actor_sold, actor_price_compare, use_price_comparison, - active, best_price, best_price_store, best_price_url, best_price_image_url, + active, best_price, best_price_currency, best_price_store, best_price_url, best_price_image_url, best_price_title, last_polled_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, it.Name, s.enc(it.SearchQuery), nullStr(it.URL), nullStr(it.Category), nullFloat(it.TargetPrice), s.enc(it.NtfyTopic), it.NtfyPriority, it.PollIntervalMinutes, boolToInt(it.IncludeOutOfStock), nullFloat(it.MinPrice), nullStr(s.enc(it.ExcludeKeywords)), - nullStr(it.ListingType), + nullStr(it.ListingType), nullStr(it.Condition), nullStr(it.Region), nullStr(it.ActorActive), nullStr(it.ActorSold), nullStr(it.ActorPriceCompare), boolToInt(it.UsePriceComparison), boolToInt(it.Active), - nullFloat(it.BestPrice), nullStr(it.BestPriceStore), + nullFloat(it.BestPrice), nullStr(it.BestPriceCurrency), nullStr(it.BestPriceStore), nullStr(s.enc(it.BestPriceURL)), nullStr(s.enc(it.BestPriceImageURL)), nullStr(s.enc(it.BestPriceTitle)), nullTime(it.LastPolledAt), ) @@ -310,7 +310,7 @@ func (s *Store) UpdateItem(ctx context.Context, it *models.Item) error { name = ?, search_query = ?, url = ?, category = ?, target_price = ?, ntfy_topic = ?, ntfy_priority = ?, poll_interval_minutes = ?, include_out_of_stock = ?, min_price = ?, exclude_keywords = ?, - listing_type = ?, + listing_type = ?, condition = ?, region = ?, actor_active = ?, actor_sold = ?, actor_price_compare = ?, use_price_comparison = ?, active = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? @@ -319,7 +319,7 @@ func (s *Store) UpdateItem(ctx context.Context, it *models.Item) error { nullFloat(it.TargetPrice), s.enc(it.NtfyTopic), it.NtfyPriority, it.PollIntervalMinutes, boolToInt(it.IncludeOutOfStock), nullFloat(it.MinPrice), nullStr(s.enc(it.ExcludeKeywords)), - nullStr(it.ListingType), + nullStr(it.ListingType), nullStr(it.Condition), nullStr(it.Region), nullStr(it.ActorActive), nullStr(it.ActorSold), nullStr(it.ActorPriceCompare), boolToInt(it.UsePriceComparison), boolToInt(it.Active), it.ID, @@ -491,11 +491,12 @@ func (s *Store) ListCategories(ctx context.Context) ([]string, error) { // UpdateItemPollResult writes best-price fields, last_polled_at, last_poll_error. func (s *Store) UpdateItemPollResult(ctx context.Context, id int64, best *models.Item, errMsg string) error { var ( - bestPrice sql.NullFloat64 - bestStore, bestURL, bestImage, bestTitle, errField sql.NullString + bestPrice sql.NullFloat64 + bestCurrency, bestStore, bestURL, bestImage, bestTitle, errField sql.NullString ) if best != nil { bestPrice = nullFloat(best.BestPrice) + bestCurrency = nullStr(best.BestPriceCurrency) bestStore = nullStr(best.BestPriceStore) bestURL = nullStr(s.enc(best.BestPriceURL)) bestImage = nullStr(s.enc(best.BestPriceImageURL)) @@ -506,20 +507,20 @@ func (s *Store) UpdateItemPollResult(ctx context.Context, id int64, best *models } _, err := s.DB.ExecContext(ctx, ` UPDATE items SET - best_price = ?, best_price_store = ?, best_price_url = ?, + best_price = ?, best_price_currency = ?, best_price_store = ?, best_price_url = ?, best_price_image_url = ?, best_price_title = ?, last_polled_at = CURRENT_TIMESTAMP, last_poll_error = ? WHERE id = ? - `, bestPrice, bestStore, bestURL, bestImage, bestTitle, errField, id) + `, bestPrice, bestCurrency, bestStore, bestURL, bestImage, bestTitle, errField, id) return err } const itemSelect = ` SELECT id, name, search_query, url, category, target_price, ntfy_topic, ntfy_priority, poll_interval_minutes, include_out_of_stock, min_price, exclude_keywords, - listing_type, + listing_type, condition, region, actor_active, actor_sold, actor_price_compare, use_price_comparison, - active, last_polled_at, last_poll_error, best_price, best_price_store, + active, last_polled_at, last_poll_error, best_price, best_price_currency, best_price_store, best_price_url, best_price_image_url, best_price_title, created_at, updated_at FROM items ` @@ -532,10 +533,10 @@ func scanItem(r rowScanner) (*models.Item, error) { var ( it models.Item searchQuery, urlS, category, listingType sql.NullString - excludeKw sql.NullString + excludeKw, condition, region sql.NullString actorA, actorS, actorP sql.NullString ntfyTopic, lastPollErr sql.NullString - bestStore, bestURL, bestImage, bestTitle sql.NullString + bestCurrency, bestStore, bestURL, bestImage, bestTitle sql.NullString targetPrice, minPrice, bestPrice sql.NullFloat64 includeOOS, usePC, active int lastPolledAt sql.NullTime @@ -543,9 +544,9 @@ func scanItem(r rowScanner) (*models.Item, error) { if err := r.Scan( &it.ID, &it.Name, &searchQuery, &urlS, &category, &targetPrice, &ntfyTopic, &it.NtfyPriority, &it.PollIntervalMinutes, &includeOOS, &minPrice, &excludeKw, - &listingType, + &listingType, &condition, ®ion, &actorA, &actorS, &actorP, &usePC, - &active, &lastPolledAt, &lastPollErr, &bestPrice, &bestStore, + &active, &lastPolledAt, &lastPollErr, &bestPrice, &bestCurrency, &bestStore, &bestURL, &bestImage, &bestTitle, &it.CreatedAt, &it.UpdatedAt, ); err != nil { return nil, err @@ -556,11 +557,14 @@ func scanItem(r rowScanner) (*models.Item, error) { it.URL = urlS.String it.Category = category.String it.ListingType = listingType.String + it.Condition = condition.String + it.Region = region.String it.ActorActive = actorA.String it.ActorSold = actorS.String it.ActorPriceCompare = actorP.String it.NtfyTopic = ntfyTopic.String it.LastPollError = lastPollErr.String + it.BestPriceCurrency = bestCurrency.String it.BestPriceStore = bestStore.String it.BestPriceURL = bestURL.String it.BestPriceImageURL = bestImage.String @@ -589,13 +593,14 @@ func (s *Store) decryptItem(it *models.Item) *models.Item { func (s *Store) InsertResult(ctx context.Context, r *models.Result) (int64, error) { res, err := s.DB.ExecContext(ctx, ` - INSERT INTO results (item_id, title, price, currency, url, source, image_url, matched_query, alerted, found_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + INSERT INTO results (item_id, title, price, currency, url, source, image_url, matched_query, alerted, found_at, ends_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?) `, r.ItemID, s.enc(r.Title), nullFloat(r.Price), r.Currency, nullStr(r.URL), nullStr(r.Source), s.enc(r.ImageURL), nullStr(s.enc(r.MatchedQuery)), boolToInt(r.Alerted), + nullTime(r.EndsAt), ) if err != nil { return 0, err @@ -624,11 +629,28 @@ func (s *Store) MarkResultAlerted(ctx context.Context, id int64) error { return err } +// BackfillResultEndsAt sets ends_at on an existing result row that's currently +// missing one. Used during polling: when a known auction listing reappears in +// a poll result, we still want its end time recorded even though the row +// itself isn't being re-inserted (URL dedup). +func (s *Store) BackfillResultEndsAt(ctx context.Context, itemID int64, urlStr string, endsAt time.Time) error { + if urlStr == "" { + return nil + } + _, err := s.DB.ExecContext(ctx, + `UPDATE results SET ends_at = ? WHERE item_id = ? AND url = ? AND ends_at IS NULL`, + endsAt, itemID, urlStr) + return err +} + type ResultsQuery struct { ItemID int64 // 0 = all items Limit int Offset int Order string // "price_asc", "price_desc", "found_desc" (default), "found_asc" + // ExcludeEnded drops rows whose ends_at is in the past. Fixed-price + // listings (ends_at IS NULL) are kept regardless: they don't expire. + ExcludeEnded bool } func (s *Store) ListResults(ctx context.Context, q ResultsQuery) ([]models.Result, error) { @@ -646,14 +668,22 @@ func (s *Store) ListResults(ctx context.Context, q ResultsQuery) ([]models.Resul limit = 20 } args := []any{} - where := "" + var conds []string if q.ItemID != 0 { - where = `WHERE item_id = ?` + conds = append(conds, `item_id = ?`) args = append(args, q.ItemID) } + if q.ExcludeEnded { + conds = append(conds, `(ends_at IS NULL OR ends_at > ?)`) + args = append(args, time.Now().UTC()) + } + where := "" + if len(conds) > 0 { + where = `WHERE ` + strings.Join(conds, ` AND `) + } args = append(args, limit, q.Offset) rows, err := s.DB.QueryContext(ctx, fmt.Sprintf(` - SELECT id, item_id, title, price, currency, url, source, image_url, matched_query, alerted, found_at + SELECT id, item_id, title, price, currency, url, source, image_url, matched_query, alerted, found_at, ends_at FROM results %s ORDER BY %s LIMIT ? OFFSET ? `, where, order), args...) if err != nil { @@ -667,8 +697,9 @@ func (s *Store) ListResults(ctx context.Context, q ResultsQuery) ([]models.Resul title, urlS, source, imageS, matchQ sql.NullString price sql.NullFloat64 alerted int + endsAt sql.NullTime ) - if err := rows.Scan(&r.ID, &r.ItemID, &title, &price, &r.Currency, &urlS, &source, &imageS, &matchQ, &alerted, &r.FoundAt); err != nil { + if err := rows.Scan(&r.ID, &r.ItemID, &title, &price, &r.Currency, &urlS, &source, &imageS, &matchQ, &alerted, &r.FoundAt, &endsAt); err != nil { return nil, err } r.Title = s.dec(title.String) @@ -678,19 +709,76 @@ func (s *Store) ListResults(ctx context.Context, q ResultsQuery) ([]models.Resul r.MatchedQuery = s.dec(matchQ.String) r.Price = ptrFloat(price) r.Alerted = alerted != 0 + r.EndsAt = ptrTime(endsAt) out = append(out, r) } return out, rows.Err() } -func (s *Store) CountResults(ctx context.Context, itemID int64) (int, error) { +// EndingSoon is a compact projection for the "ending soon" strip: the single +// nearest-to-end auction across the user's results, with enough context to +// render and link to it. +type EndingSoon struct { + ItemID int64 + ItemName string + Title string + URL string + EndsAt time.Time +} + +// NextEndingResult returns the soonest-ending result whose ends_at lies in the +// window (now, now+within]. If itemID is 0 the search spans all items; nil is +// returned when no auction falls inside the window. +func (s *Store) NextEndingResult(ctx context.Context, itemID int64, within time.Duration) (*EndingSoon, error) { + now := time.Now().UTC() + cutoff := now.Add(within) + q := `SELECT r.item_id, r.title, r.url, r.ends_at, i.name + FROM results r JOIN items i ON r.item_id = i.id + WHERE r.ends_at IS NOT NULL AND r.ends_at > ? AND r.ends_at <= ?` + args := []any{now, cutoff} + if itemID != 0 { + q += ` AND r.item_id = ?` + args = append(args, itemID) + } + q += ` ORDER BY r.ends_at ASC LIMIT 1` + row := s.DB.QueryRowContext(ctx, q, args...) + var ( + e EndingSoon + title sql.NullString + urlS sql.NullString + endsAt time.Time + ) + if err := row.Scan(&e.ItemID, &title, &urlS, &endsAt, &e.ItemName); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + e.Title = s.dec(title.String) + e.URL = urlS.String + e.EndsAt = endsAt + return &e, nil +} + +// CountResults returns the row count matching the same filters ListResults +// applies. Pagination relies on this matching the visible list, so it must +// honor ExcludeEnded too. +func (s *Store) CountResults(ctx context.Context, itemID int64, excludeEnded bool) (int, error) { var n int q := `SELECT COUNT(*) FROM results` args := []any{} + var conds []string if itemID != 0 { - q += ` WHERE item_id = ?` + conds = append(conds, `item_id = ?`) args = append(args, itemID) } + if excludeEnded { + conds = append(conds, `(ends_at IS NULL OR ends_at > ?)`) + args = append(args, time.Now().UTC()) + } + if len(conds) > 0 { + q += ` WHERE ` + strings.Join(conds, ` AND `) + } err := s.DB.QueryRowContext(ctx, q, args...).Scan(&n) return n, err } diff --git a/internal/db/schema.sql b/internal/db/schema.sql index fb9aac5..e8d2cf9 100644 --- a/internal/db/schema.sql +++ b/internal/db/schema.sql @@ -23,6 +23,8 @@ CREATE TABLE IF NOT EXISTS items ( min_price REAL, exclude_keywords TEXT, listing_type TEXT, + condition TEXT, + region TEXT, actor_active TEXT, actor_sold TEXT, actor_price_compare TEXT, @@ -31,6 +33,7 @@ CREATE TABLE IF NOT EXISTS items ( last_polled_at DATETIME, last_poll_error TEXT, best_price REAL, + best_price_currency TEXT, best_price_store TEXT, best_price_url TEXT, best_price_image_url TEXT, @@ -61,7 +64,8 @@ CREATE TABLE IF NOT EXISTS results ( image_url TEXT, matched_query TEXT, alerted INTEGER DEFAULT 0, - found_at DATETIME DEFAULT CURRENT_TIMESTAMP + found_at DATETIME DEFAULT CURRENT_TIMESTAMP, + ends_at DATETIME ); CREATE INDEX IF NOT EXISTS idx_results_item ON results(item_id, found_at DESC); diff --git a/internal/ebay/client.go b/internal/ebay/client.go index 8046f74..de90b03 100644 --- a/internal/ebay/client.go +++ b/internal/ebay/client.go @@ -149,6 +149,8 @@ type browseItemSummary struct { Seller struct { Username string `json:"username"` } `json:"seller"` + // itemEndDate is present only on auction-format listings. + ItemEndDate string `json:"itemEndDate"` } // Search runs one item_summary/search call and returns normalized listings. @@ -174,8 +176,20 @@ func (c *Client) Search(ctx context.Context, p SearchParams) ([]Listing, error) q := url.Values{} q.Set("q", query) q.Set("limit", strconv.Itoa(limit)) + // The Browse API "filter" parameter takes a comma-separated list of + // filter clauses; assemble whichever ones the caller requested. + var filters []string if f := buyingOptionsFilter(p.ListingType); f != "" { - q.Set("filter", f) + filters = append(filters, f) + } + if f := conditionIDsFilter(p.Condition); f != "" { + filters = append(filters, f) + } + if f := itemLocationFilter(p.Region); f != "" { + filters = append(filters, f) + } + if len(filters) > 0 { + q.Set("filter", strings.Join(filters, ",")) } reqURL := c.ends.browse + "/item_summary/search?" + q.Encode() @@ -214,6 +228,12 @@ func (c *Client) Search(ctx context.Context, p SearchParams) ([]Listing, error) if s.Seller.Username != "" { store = "ebay (" + s.Seller.Username + ")" } + var endsAt *time.Time + if s.ItemEndDate != "" { + if t, err := time.Parse(time.RFC3339, s.ItemEndDate); err == nil { + endsAt = &t + } + } out = append(out, Listing{ Title: s.Title, Price: price, @@ -221,6 +241,7 @@ func (c *Client) Search(ctx context.Context, p SearchParams) ([]Listing, error) URL: s.ItemWebURL, Store: store, ImageURL: img, + EndsAt: endsAt, }) } return out, nil diff --git a/internal/ebay/ebay_test.go b/internal/ebay/ebay_test.go index 9d7a7d0..a734132 100644 --- a/internal/ebay/ebay_test.go +++ b/internal/ebay/ebay_test.go @@ -43,3 +43,35 @@ func TestBuyingOptionsFilter(t *testing.T) { } } } + +func TestConditionIDsFilter(t *testing.T) { + cases := map[string]string{ + "": "", + "anything": "", + "new": "conditionIds:{1000|1500}", + "NEW": "conditionIds:{1000|1500}", + " used ": "conditionIds:{3000}", + "refurbished": "conditionIds:{2000|2010|2020|2030|2500}", + "parts": "conditionIds:{7000}", + } + for in, want := range cases { + if got := conditionIDsFilter(in); got != want { + t.Errorf("conditionIDsFilter(%q) = %q, want %q", in, got, want) + } + } +} + +func TestItemLocationFilter(t *testing.T) { + cases := map[string]string{ + "": "", + " ": "", + "us": "itemLocationCountry:US", + "GB": "itemLocationCountry:GB", + " jp ": "itemLocationCountry:JP", + } + for in, want := range cases { + if got := itemLocationFilter(in); got != want { + t.Errorf("itemLocationFilter(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/ebay/types.go b/internal/ebay/types.go index 07d18bf..44d624a 100644 --- a/internal/ebay/types.go +++ b/internal/ebay/types.go @@ -1,6 +1,9 @@ package ebay -import "strings" +import ( + "strings" + "time" +) // SearchParams is the input to a single Browse API item_summary/search call. // It is provider-specific and is carried as the opaque input payload of a @@ -14,6 +17,14 @@ type SearchParams struct { // ListingType is Veola's vocabulary ("all", "bin"/"buy_it_now", // "auction"); it is mapped to a buyingOptions filter. ListingType string + // Condition is Veola's condition vocabulary ("new", "used", + // "refurbished", "parts"); it is mapped to a conditionIds filter. Empty + // means no condition filter. + Condition string + // Region is an ISO 3166-1 alpha-2 country code constraining item + // location (mapped to the itemLocationCountry filter). Empty means no + // location filter. + Region string // Limit caps the number of results requested (Browse API max is 200). Limit int } @@ -28,6 +39,10 @@ type Listing struct { URL string Store string ImageURL string + // EndsAt is the auction end time as reported by the Browse API + // (itemEndDate). Nil for fixed-price ("Buy It Now") listings, which + // don't have one. + EndsAt *time.Time } // MarketplaceID maps a Veola marketplace string (e.g. "ebay.com", @@ -92,3 +107,34 @@ func buyingOptionsFilter(listingType string) string { return "" } } + +// conditionIDsFilter maps Veola's condition vocabulary to a Browse API +// "conditionIds" filter clause. Each Veola value expands to the set of eBay +// condition IDs that belong to it (e.g. "new" covers both brand-new and +// new-other). An empty or unknown value yields no filter. +func conditionIDsFilter(condition string) string { + var ids string + switch strings.ToLower(strings.TrimSpace(condition)) { + case "new": + ids = "1000|1500" + case "used": + ids = "3000" + case "refurbished": + ids = "2000|2010|2020|2030|2500" + case "parts": + ids = "7000" + default: + return "" + } + return "conditionIds:{" + ids + "}" +} + +// itemLocationFilter maps an ISO 3166-1 alpha-2 country code to a Browse API +// "itemLocationCountry" filter clause. An empty value yields no filter. +func itemLocationFilter(region string) string { + r := strings.ToUpper(strings.TrimSpace(region)) + if r == "" { + return "" + } + return "itemLocationCountry:" + r +} diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 9b97cb6..57004a7 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -27,6 +27,11 @@ func (a *App) PostLogin(w http.ResponseWriter, r *http.Request) { username := strings.TrimSpace(r.PostFormValue("username")) password := r.PostFormValue("password") u, err := a.Store.GetUserByUsername(r.Context(), username) + if err != nil || u == nil { + // Run a bcrypt comparison anyway so a missing username takes the + // same time as a wrong password (no user-enumeration oracle). + auth.EqualizeLoginTiming() + } if err != nil || u == nil || !auth.CheckPassword(u.PasswordHash, password) { render(w, r, templates.Login(templates.LoginData{ Page: a.page(r, "Sign in", ""), diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go index fcdd532..c396eb1 100644 --- a/internal/handlers/dashboard.go +++ b/internal/handlers/dashboard.go @@ -12,7 +12,7 @@ import ( func (a *App) GetDashboard(w http.ResponseWriter, r *http.Request) { d, err := a.dashboardData(r) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + a.serverError(w, r, err) return } render(w, r, templates.Dashboard(d)) @@ -21,7 +21,7 @@ func (a *App) GetDashboard(w http.ResponseWriter, r *http.Request) { func (a *App) GetDashboardRefresh(w http.ResponseWriter, r *http.Request) { d, err := a.dashboardData(r) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + a.serverError(w, r, err) return } // Render ONLY the inner body. The hx-swap="outerHTML" on DashboardBody's @@ -36,7 +36,7 @@ func (a *App) dashboardData(r *http.Request) (templates.DashboardData, error) { if err != nil { return templates.DashboardData{}, err } - results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{Limit: 20}) + results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{Limit: 20, ExcludeEnded: true}) if err != nil { return templates.DashboardData{}, err } diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 2852a15..c1b60f5 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -7,6 +7,7 @@ import ( "context" "log/slog" "net/http" + "os" "strconv" "time" @@ -52,9 +53,19 @@ func (a *App) Routes() http.Handler { r.Use(middleware.Recoverer) r.Use(securityHeaders) - fs := http.FileServer(http.Dir("./static")) + // noListFS denies directory requests, so http.FileServer can't render + // an index listing of static/ if an index.html is ever absent. + fs := http.FileServer(noListFS{http.Dir("./static")}) r.Handle("/static/*", http.StripPrefix("/static/", fs)) + // Health check for reverse-proxy/uptime probes. No session, no setup + // gate, no auth — just a 200 to confirm the process is serving. + r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + // All other routes pass through session loading + setup gate. r.Group(func(r chi.Router) { r.Use(a.Auth.Sessions.LoadAndSave) @@ -169,6 +180,34 @@ func (a *App) page(r *http.Request, title, active string) templates.Page { } } +// noListFS wraps an http.FileSystem and refuses to open directories, which +// stops http.FileServer from emitting an auto-generated directory listing. +type noListFS struct{ fs http.FileSystem } + +func (n noListFS) Open(name string) (http.File, error) { + f, err := n.fs.Open(name) + if err != nil { + return nil, err + } + info, err := f.Stat() + if err != nil { + f.Close() + return nil, err + } + if info.IsDir() { + f.Close() + return nil, os.ErrNotExist + } + return f, nil +} + +// serverError logs the underlying error and returns a generic 500 to the +// client, so internal details (DB errors, file paths) never reach the browser. +func (a *App) serverError(w http.ResponseWriter, r *http.Request, err error) { + slog.Error("handler error", "path", r.URL.Path, "err", err) + http.Error(w, "internal server error", http.StatusInternalServerError) +} + func render(w http.ResponseWriter, r *http.Request, c templ.Component) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := c.Render(r.Context(), w); err != nil { diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go new file mode 100644 index 0000000..a3f8e46 --- /dev/null +++ b/internal/handlers/handlers_test.go @@ -0,0 +1,115 @@ +package handlers + +import ( + "context" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "veola/internal/apify" + "veola/internal/auth" + "veola/internal/config" + "veola/internal/crypto" + "veola/internal/db" + "veola/internal/models" + "veola/internal/ntfy" + "veola/internal/scheduler" +) + +// newTestApp builds an App backed by a fresh sqlite db in t.TempDir(). The +// scheduler, apify, and ntfy clients are wired but unused by the routes we +// hit here. The returned http.Handler is App.Routes(). +func newTestApp(t *testing.T) (*App, http.Handler) { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "test.db") + sqlDB, err := db.Open(dbPath) + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { sqlDB.Close() }) + + key, err := crypto.DeriveKey([]byte("test-encryption-key-32-bytes-min-aaaaaa")) + if err != nil { + t.Fatalf("DeriveKey: %v", err) + } + store := db.NewStore(sqlDB, key) + + am, err := auth.NewManager(sqlDB, store, strings.Repeat("a", 32), false) + if err != nil { + t.Fatalf("auth.NewManager: %v", err) + } + + cfg := &config.Config{} + ap := apify.New("") + nt := ntfy.New("") + sc := scheduler.New(cfg, store, ap, nt) + + app := New(cfg, store, am, ap, nt, sc) + return app, app.Routes() +} + +func TestHealthz(t *testing.T) { + _, h := newTestApp(t) + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if got := rec.Body.String(); got != "ok" { + t.Fatalf("body = %q, want %q", got, "ok") + } +} + +func TestSetupGateRedirectsWhenNoUsers(t *testing.T) { + _, h := newTestApp(t) + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "/setup" { + t.Fatalf("Location = %q, want /setup", loc) + } +} + +func TestRequireAuthRedirectsToLogin(t *testing.T) { + app, h := newTestApp(t) + hash, err := auth.HashPassword("a-long-enough-password") + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + if _, err := app.Store.CreateUser(context.Background(), "admin", hash, models.RoleAdmin); err != nil { + t.Fatalf("CreateUser: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "/login" { + t.Fatalf("Location = %q, want /login", loc) + } +} + +func TestLoginPageRenders(t *testing.T) { + app, h := newTestApp(t) + hash, _ := auth.HashPassword("a-long-enough-password") + if _, err := app.Store.CreateUser(context.Background(), "admin", hash, models.RoleAdmin); err != nil { + t.Fatalf("CreateUser: %v", err) + } + req := httptest.NewRequest(http.MethodGet, "/login", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if !strings.Contains(rec.Body.String(), "") + } +} diff --git a/internal/handlers/items.go b/internal/handlers/items.go index 412b19a..dcfdbde 100644 --- a/internal/handlers/items.go +++ b/internal/handlers/items.go @@ -93,6 +93,8 @@ func parseItemForm(r *http.Request) (models.Item, []string) { } it.Marketplaces = collectMarketplaces(r.PostForm["marketplace"], r.PostFormValue("marketplace_custom")) it.ListingType = strings.TrimSpace(r.PostFormValue("listing_type")) + it.Condition = strings.TrimSpace(r.PostFormValue("condition")) + it.Region = strings.ToUpper(strings.TrimSpace(r.PostFormValue("region"))) it.ActorActive = strings.TrimSpace(r.PostFormValue("actor_active")) it.ActorSold = strings.TrimSpace(r.PostFormValue("actor_sold")) it.ActorPriceCompare = strings.TrimSpace(r.PostFormValue("actor_price_compare")) @@ -253,6 +255,8 @@ func (a *App) runPreview(ctx context.Context, it models.Item) ([]apify.UnifiedRe Marketplace: previewMarket, ListingType: it.ListingType, ActorIDs: strings.Join(actorIDs, ","), + Condition: it.Condition, + Region: it.Region, MaxResults: 30, } if cached, src, ok := a.Preview.Get(key); ok { @@ -304,6 +308,8 @@ func formValuesFromItem(it models.Item, r *http.Request) templates.FormValues { IncludeOutOfStock: it.IncludeOutOfStock, Marketplaces: it.Marketplaces, ListingType: it.ListingType, + Condition: it.Condition, + Region: it.Region, ActorActive: it.ActorActive, ActorSold: it.ActorSold, ActorPriceCompare: it.ActorPriceCompare, @@ -319,7 +325,7 @@ func (a *App) PostCreateItem(w http.ResponseWriter, r *http.Request) { } id, err := a.Store.CreateItem(r.Context(), &it) if err != nil { - http.Error(w, "could not save item: "+err.Error(), http.StatusInternalServerError) + a.serverError(w, r, err) return } it.ID = id @@ -361,7 +367,7 @@ func (a *App) PostUpdateItem(w http.ResponseWriter, r *http.Request) { updated.ID = id updated.Active = existing.Active if err := a.Store.UpdateItem(r.Context(), &updated); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + a.serverError(w, r, err) return } a.Scheduler.SyncItem(updated) @@ -377,7 +383,7 @@ func (a *App) PostToggleItem(w http.ResponseWriter, r *http.Request) { } it.Active = !it.Active if err := a.Store.SetItemActive(r.Context(), id, it.Active); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + a.serverError(w, r, err) return } a.Scheduler.SyncItem(*it) @@ -387,7 +393,7 @@ func (a *App) PostToggleItem(w http.ResponseWriter, r *http.Request) { func (a *App) PostDeleteItem(w http.ResponseWriter, r *http.Request) { id := intParam(r, "id") if err := a.Store.DeleteItem(r.Context(), id); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + a.serverError(w, r, err) return } a.Scheduler.RemoveItem(id) @@ -410,31 +416,22 @@ func (a *App) PostRunItem(w http.ResponseWriter, r *http.Request) { defer cancel() a.Scheduler.RunPoll(ctx, *it) - // RunPoll writes best price, last_polled_at, and last_poll_error; re-fetch - // so the rendered partial shows the post-poll state. - fresh, err := a.Store.GetItem(r.Context(), id) - if err != nil || fresh == nil { - http.Error(w, "could not reload item after run", http.StatusInternalServerError) + // A partial swap (single row or just the results table) leaves the rest + // of the page — best-price card, price chart, "last polled" time, badge — + // looking stale, so the run reads as a no-op. Tell htmx to do a full + // reload so every derived view picks up the post-poll state. + if r.Header.Get("HX-Request") != "" { + w.Header().Set("HX-Refresh", "true") + w.WriteHeader(http.StatusNoContent) return } - // The results page asks for a refreshed listing table; the items list - // asks for a refreshed row. Both POST to this same endpoint. + // Non-htmx fallback: redirect back to the originating page. + target := "/items" if r.PostFormValue("from") == "results" { - d, err := a.buildItemResultsData(r, fresh, 1, "found_desc") - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - if fresh.LastPollError != "" { - d.RunError = "Run finished with errors: " + fresh.LastPollError - } else { - d.RunMsg = fmt.Sprintf("Run complete. Showing %d listing(s).", len(d.Results)) - } - render(w, r, templates.ItemResultsTable(d)) - return + target = fmt.Sprintf("/items/%d/results", id) } - render(w, r, templates.ItemRow(*fresh, a.Auth.CSRFToken(r.Context()))) + http.Redirect(w, r, target, http.StatusSeeOther) } func (a *App) GetItemError(w http.ResponseWriter, r *http.Request) { diff --git a/internal/handlers/preview_cache.go b/internal/handlers/preview_cache.go index 38fbf9e..4be6056 100644 --- a/internal/handlers/preview_cache.go +++ b/internal/handlers/preview_cache.go @@ -10,8 +10,13 @@ import ( // previewKey caches the *raw* apify result set (post-decode, post-merge, // pre-filter). Filters like min_price and exclude_keywords are applied after // the cache lookup so the operator can iterate on them without burning credits. +// +// Condition and Region are part of the key, not post-filters: they are +// server-side eBay Browse API filters that change the result set the API +// returns, so a different condition/region must miss the cache. type previewKey struct { Queries, URL, Marketplace, ListingType, ActorIDs string + Condition, Region string MaxResults int } diff --git a/internal/handlers/results.go b/internal/handlers/results.go index d6dbc92..ceab01b 100644 --- a/internal/handlers/results.go +++ b/internal/handlers/results.go @@ -24,7 +24,7 @@ func (a *App) GetItemResults(w http.ResponseWriter, r *http.Request) { page, _ := strconv.Atoi(r.URL.Query().Get("page")) d, err := a.buildItemResultsData(r, it, page, r.URL.Query().Get("order")) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + a.serverError(w, r, err) return } render(w, r, templates.ItemResults(d)) @@ -41,7 +41,7 @@ func (a *App) buildItemResultsData(r *http.Request, it *models.Item, page int, o page = 1 } - total, err := a.Store.CountResults(r.Context(), it.ID) + total, err := a.Store.CountResults(r.Context(), it.ID, true) if err != nil { return templates.ItemResultsData{}, err } @@ -54,10 +54,11 @@ func (a *App) buildItemResultsData(r *http.Request, it *models.Item, page int, o } results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{ - ItemID: it.ID, - Limit: resultsPerPage, - Offset: (page - 1) * resultsPerPage, - Order: order, + ItemID: it.ID, + Limit: resultsPerPage, + Offset: (page - 1) * resultsPerPage, + Order: order, + ExcludeEnded: true, }) if err != nil { return templates.ItemResultsData{}, err @@ -68,6 +69,10 @@ func (a *App) buildItemResultsData(r *http.Request, it *models.Item, page int, o return templates.ItemResultsData{}, err } + // 24h surface for the "ending soon" strip — beyond that, a static + // "ends in 4 days" in the per-row cell carries enough signal on its own. + endingSoon, _ := a.Store.NextEndingResult(r.Context(), it.ID, 24*time.Hour) + return templates.ItemResultsData{ Page: a.page(r, it.Name, "items"), Item: *it, @@ -78,6 +83,7 @@ func (a *App) buildItemResultsData(r *http.Request, it *models.Item, page int, o TotalPages: totalPages, Order: order, HistoryChartJSON: buildChartJSON(history), + EndingSoon: endingSoon, }, nil } @@ -101,7 +107,7 @@ func (a *App) GetGlobalResults(w http.ResponseWriter, r *http.Request) { items, err := a.Store.ListItems(r.Context()) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + a.serverError(w, r, err) return } names := make(map[int64]string, len(items)) @@ -110,11 +116,12 @@ func (a *App) GetGlobalResults(w http.ResponseWriter, r *http.Request) { } results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{ - ItemID: itemID, - Limit: 200, + ItemID: itemID, + Limit: 200, + ExcludeEnded: true, }) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + a.serverError(w, r, err) return } @@ -138,12 +145,15 @@ func (a *App) GetGlobalResults(w http.ResponseWriter, r *http.Request) { }) } + endingSoon, _ := a.Store.NextEndingResult(r.Context(), itemID, 24*time.Hour) + render(w, r, templates.GlobalResults(templates.GlobalResultsData{ - Page: a.page(r, "Results", "results"), - Items: items, - Results: rows, - ItemID: itemID, - From: from, - To: to, + Page: a.page(r, "Results", "results"), + Items: items, + Results: rows, + ItemID: itemID, + From: from, + To: to, + EndingSoon: endingSoon, })) } diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go index 64b7168..1ec8acb 100644 --- a/internal/handlers/settings.go +++ b/internal/handlers/settings.go @@ -26,6 +26,40 @@ var settingsKeys = []string{ "match_confidence_threshold", } +// secretSettingsKeys are credential fields. Their values are never rendered +// back into the form, so a blank submission means "leave unchanged" rather +// than "clear" — see PostSettings. +var secretSettingsKeys = map[string]bool{ + "apify_api_key": true, + "ebay_client_id": true, + "ebay_client_secret": true, + "ntfy_token": true, +} + +// credentialStatus reports, per secret key, whether a value is saved in the +// settings table, inherited from config.toml, or absent — without exposing +// the secret itself. +func (a *App) credentialStatus(values map[string]string) map[string]string { + configVals := map[string]string{ + "apify_api_key": a.Cfg.Apify.APIKey, + "ebay_client_id": a.Cfg.Ebay.ClientID, + "ebay_client_secret": a.Cfg.Ebay.ClientSecret, + "ntfy_token": "", + } + status := make(map[string]string, len(secretSettingsKeys)) + for k := range secretSettingsKeys { + switch { + case strings.TrimSpace(values[k]) != "": + status[k] = "Saved in settings" + case strings.TrimSpace(configVals[k]) != "": + status[k] = "Set in config.toml" + default: + status[k] = "Not set" + } + } + return status +} + func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) { values, err := a.Store.GetAllSettings(r.Context()) if err != nil { @@ -38,19 +72,20 @@ func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) { cur := auth.CurrentUserFromRequest(r) ebayUsed, ebayLimit := a.Scheduler.EbayUsage(r.Context()) return templates.SettingsData{ - Page: a.page(r, "Settings", "settings"), - Values: values, - IsAdmin: cur != nil && cur.Role == models.RoleAdmin, - Users: users, - EbayUsedToday: ebayUsed, - EbayDailyLimit: ebayLimit, + Page: a.page(r, "Settings", "settings"), + Values: values, + CredentialStatus: a.credentialStatus(values), + IsAdmin: cur != nil && cur.Role == models.RoleAdmin, + Users: users, + EbayUsedToday: ebayUsed, + EbayDailyLimit: ebayLimit, }, nil } func (a *App) GetSettings(w http.ResponseWriter, r *http.Request) { d, err := a.settingsData(r) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + a.serverError(w, r, err) return } render(w, r, templates.Settings(d)) @@ -68,8 +103,14 @@ func (a *App) PostSettings(w http.ResponseWriter, r *http.Request) { } for _, k := range settingsKeys { v := strings.TrimSpace(r.PostFormValue(k)) + // Secret fields are never rendered back into the form, so a blank + // submission is the normal state and means "leave unchanged" — not + // "clear". (To clear a stored credential, edit the settings table.) + if v == "" && secretSettingsKeys[k] { + continue + } if err := a.Store.SetSetting(r.Context(), k, v); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + a.serverError(w, r, err) return } } @@ -92,7 +133,7 @@ func (a *App) PostPasswordChange(w http.ResponseWriter, r *http.Request) { d, err := a.settingsData(r) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + a.serverError(w, r, err) return } @@ -115,7 +156,7 @@ func (a *App) PostPasswordChange(w http.ResponseWriter, r *http.Request) { return } if err := a.Store.UpdateUserPassword(r.Context(), cur.ID, hash); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + a.serverError(w, r, err) return } d.PasswordMsg = "Password updated" @@ -130,7 +171,7 @@ func (a *App) PostTestNtfy(w http.ResponseWriter, r *http.Request) { } d, err := a.settingsData(r) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + a.serverError(w, r, err) return } baseURL := strings.TrimSpace(d.Values["ntfy_base_url"]) @@ -164,7 +205,7 @@ func (a *App) PostTestApify(w http.ResponseWriter, r *http.Request) { } d, err := a.settingsData(r) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + a.serverError(w, r, err) return } apiKey := strings.TrimSpace(d.Values["apify_api_key"]) @@ -210,7 +251,7 @@ func (a *App) PostTestEbay(w http.ResponseWriter, r *http.Request) { } d, err := a.settingsData(r) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + a.serverError(w, r, err) return } // Settings-table values win over config.toml. Both paths are trimmed: diff --git a/internal/handlers/users.go b/internal/handlers/users.go index 8e8d37e..c1e9606 100644 --- a/internal/handlers/users.go +++ b/internal/handlers/users.go @@ -13,7 +13,7 @@ import ( func (a *App) renderSettingsWithUserMsg(w http.ResponseWriter, r *http.Request, msg, errMsg string) { d, err := a.settingsData(r) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + a.serverError(w, r, err) return } d.UserMsg = msg diff --git a/internal/models/models.go b/internal/models/models.go index 02863c2..f347d9e 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -35,6 +35,12 @@ type Item struct { ExcludeKeywords string Marketplaces []string ListingType string + // Condition and Region are eBay-only search filters. Condition is + // Veola's vocabulary ("new", "used", "refurbished", "parts"); Region is + // an ISO 3166-1 alpha-2 country code constraining item location. Both + // empty means no filter, and both are ignored for non-eBay marketplaces. + Condition string + Region string ActorActive string ActorSold string ActorPriceCompare string @@ -43,6 +49,7 @@ type Item struct { LastPolledAt *time.Time LastPollError string BestPrice *float64 + BestPriceCurrency string BestPriceStore string BestPriceURL string BestPriceImageURL string @@ -63,6 +70,10 @@ type Result struct { MatchedQuery string Alerted bool FoundAt time.Time + // EndsAt is populated for auction-format listings (eBay auctions, + // Yahoo Auctions JP). Nil for fixed-price listings and for any source + // that doesn't report an end time. + EndsAt *time.Time } // SearchQueries returns the item's alias list. Splits on newline, comma, and diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index d205bf5..3d74957 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -220,6 +220,14 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) { continue } if exists { + // Row already stored — but if this poll surfaced an end time we + // didn't have before (or the row predates the ends_at column), + // backfill it so countdowns light up for known auctions. + if r.EndsAt != nil { + if err := s.store.BackfillResultEndsAt(ctx, it.ID, r.URL, *r.EndsAt); err != nil { + slog.Error("backfill ends_at failed", "err", err) + } + } continue } alerted := false @@ -242,6 +250,7 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) { ImageURL: r.ImageURL, MatchedQuery: r.MatchedQuery, Alerted: alerted, + EndsAt: r.EndsAt, }) if err != nil { slog.Error("insert result failed", "err", err) @@ -257,6 +266,7 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) { bp := best.Price _ = s.store.UpdateItemPollResult(ctx, it.ID, &models.Item{ BestPrice: &bp, + BestPriceCurrency: best.Currency, BestPriceStore: best.Store, BestPriceURL: best.URL, BestPriceImageURL: best.ImageURL, @@ -365,6 +375,7 @@ func (s *Scheduler) ExecutePlan(ctx context.Context, p actorPlan) ([]apify.Unifi Store: l.Store, ImageURL: l.ImageURL, Source: apify.SourceActiveEbay, + EndsAt: l.EndsAt, }) } default: @@ -549,6 +560,8 @@ func (s *Scheduler) buildInputsForQuery(it models.Item, query string, markets [] MarketplaceID: ebay.MarketplaceID(mk), Query: query, ListingType: it.ListingType, + Condition: it.Condition, + Region: it.Region, Limit: 30, }, }) diff --git a/main.go b/main.go index 3347e13..46f88d4 100644 --- a/main.go +++ b/main.go @@ -27,8 +27,19 @@ import ( func main() { configPath := flag.String("config", "config.toml", "path to config TOML file") + debug := flag.Bool("debug", false, "enable debug-level logging (verbose; raw external payloads logged)") flag.Parse() + level := slog.LevelInfo + if *debug { + level = slog.LevelDebug + } + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level}))) + + if *debug { + slog.Debug("debug logging enabled") + } + if err := run(*configPath); err != nil { slog.Error("fatal", "err", err) os.Exit(1) @@ -74,6 +85,9 @@ func run(configPath string) error { Addr: addr, Handler: app.Routes(), ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, } errCh := make(chan error, 1) diff --git a/static/css/app.css b/static/css/app.css index c3a2f21..235c548 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -26,16 +26,54 @@ a { color: var(--accent); } a:hover { text-decoration: underline; } .v-card { - background: var(--surface); + position: relative; + isolation: isolate; + background: + linear-gradient(180deg, rgba(36, 58, 147, 0.82), rgba(31, 51, 128, 0.82)); + backdrop-filter: blur(10px) saturate(140%); + -webkit-backdrop-filter: blur(10px) saturate(140%); border: 1px solid var(--border); - border-radius: 8px; + border-radius: 10px; box-shadow: var(--shadow); + transition: + transform 180ms ease, + box-shadow 180ms ease, + border-color 180ms ease; +} +.v-card::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + padding: 1px; + background: linear-gradient(135deg, + rgba(0, 164, 228, 0.65) 0%, + rgba(245, 196, 0, 0.30) 45%, + rgba(255, 255, 255, 0.04) 100%); + -webkit-mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + pointer-events: none; + z-index: -1; +} +.v-card:hover { + transform: translateY(-2px); + box-shadow: + 0 10px 28px rgba(0, 0, 80, 0.55), + 0 0 0 1px rgba(0, 164, 228, 0.30); } .v-card-flat { - background: var(--surface); + position: relative; + isolation: isolate; + background: + linear-gradient(180deg, rgba(36, 58, 147, 0.70), rgba(31, 51, 128, 0.70)); + backdrop-filter: blur(8px) saturate(130%); + -webkit-backdrop-filter: blur(8px) saturate(130%); border: 1px solid var(--border); - border-radius: 8px; + border-radius: 10px; } .v-divider { border-top: 1px solid var(--border); } @@ -173,7 +211,9 @@ table.v-table td { border-bottom: 1px solid var(--border); vertical-align: middle; } -table.v-table tr:hover td { background: rgba(255,255,255,0.03); } +table.v-table td { transition: background 140ms ease; } +table.v-table tr { transition: transform 140ms ease; } +table.v-table tbody tr:hover td { background: rgba(0, 164, 228, 0.08); } .v-error-text { color: var(--danger); font-size: 0.85rem; } .v-muted { color: var(--text-2); } @@ -210,3 +250,64 @@ table.v-table tr:hover td { background: rgba(255,255,255,0.03); } .htmx-indicator { display: none; } .htmx-request .htmx-indicator, .htmx-request.htmx-indicator { display: inline-flex; } + +/* flair.js adds .v-just-swapped to any htmx swap target for ~400ms, giving + refreshed regions a soft fade-in instead of an abrupt content jump. */ +@keyframes v-fade-in-up { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} +.v-just-swapped { animation: v-fade-in-up 240ms ease-out; } + +/* Ending-soon strip: one global "next auction to close" banner. flair.js + keeps the countdown live. Subtle by default; pulses red when inside the + last 5 minutes via .v-countdown-critical on the inner counter. */ +.v-ending-strip { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.7rem 1rem; + border-radius: 8px; + background: + linear-gradient(90deg, rgba(245, 196, 0, 0.12), rgba(0, 164, 228, 0.08)); + border: 1px solid rgba(245, 196, 0, 0.35); +} +.v-ending-label { + font-size: 0.7rem; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 700; + color: var(--yellow); + white-space: nowrap; +} +.v-ending-title { flex: 1; min-width: 0; } +.v-ending-countdown { + font-size: 1.15rem; + font-weight: 700; + color: var(--yellow); +} + +/* Per-row + strip countdown urgency states. Default reads as neutral muted + text so 6-day countdowns don't shout; urgency tints kick in only when + flair.js flips the class. */ +.v-countdown { color: var(--text-2); } +.v-countdown-urgent { color: var(--yellow); font-weight: 600; } +.v-countdown-critical { + color: var(--danger); + font-weight: 700; + animation: v-pulse 1.2s ease-in-out infinite; +} +.v-countdown-ended { color: var(--text-2); font-style: italic; } + +@keyframes v-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.55; } +} + +@media (prefers-reduced-motion: reduce) { + .v-card { transition: none; } + .v-card:hover { transform: none; } + table.v-table td, table.v-table tr { transition: none; } + .v-just-swapped { animation: none; } + .v-countdown-critical { animation: none; } +} diff --git a/static/css/tailwind.css b/static/css/tailwind.css index ddb383e..336aa96 100644 --- a/static/css/tailwind.css +++ b/static/css/tailwind.css @@ -1,2 +1,2 @@ *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } -/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}.my-4{margin-bottom:1rem;margin-top:1rem}.mb-0{margin-bottom:0}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-auto{margin-top:auto}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-40{height:10rem}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-40{width:10rem}.w-48{width:12rem}.w-full{width:100%}.max-w-3xl{max-width:48rem}.max-w-6xl{max-width:72rem}.max-w-\[140px\]{max-width:140px}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.cursor-pointer{cursor:pointer}.list-disc{list-style-type:disc}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(2rem*var(--tw-space-y-reverse));margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-white\/10{border-color:hsla(0,0%,100%,.1)}.bg-\[\#152560\]{--tw-bg-opacity:1;background-color:rgb(21 37 96/var(--tw-bg-opacity,1))}.bg-black\/30{background-color:rgba(0,0,0,.3)}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-4{padding-left:1rem;padding-right:1rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.pb-2{padding-bottom:.5rem}.pl-5{padding-left:1.25rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.tracking-wide{letter-spacing:.025em}@media (min-width:768px){.md\:flex{display:flex}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:grid-cols-\[160px_1fr\]{grid-template-columns:160px 1fr}.md\:flex-row{flex-direction:row}} \ No newline at end of file +/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}.my-4{margin-bottom:1rem;margin-top:1rem}.mb-0{margin-bottom:0}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-auto{margin-top:auto}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-40{height:10rem}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-40{width:10rem}.w-48{width:12rem}.w-full{width:100%}.max-w-3xl{max-width:48rem}.max-w-6xl{max-width:72rem}.max-w-\[140px\]{max-width:140px}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.cursor-pointer{cursor:pointer}.list-disc{list-style-type:disc}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(2rem*var(--tw-space-y-reverse));margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-white\/10{border-color:hsla(0,0%,100%,.1)}.bg-\[\#152560\]{--tw-bg-opacity:1;background-color:rgb(21 37 96/var(--tw-bg-opacity,1))}.bg-black\/30{background-color:rgba(0,0,0,.3)}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-4{padding-left:1rem;padding-right:1rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.pb-2{padding-bottom:.5rem}.pl-5{padding-left:1.25rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.tracking-wide{letter-spacing:.025em}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}@media (min-width:768px){.md\:flex{display:flex}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:grid-cols-\[160px_1fr\]{grid-template-columns:160px 1fr}.md\:flex-row{flex-direction:row}} \ No newline at end of file diff --git a/static/js/flair.js b/static/js/flair.js new file mode 100644 index 0000000..d361916 --- /dev/null +++ b/static/js/flair.js @@ -0,0 +1,130 @@ +// Lightweight visual flourishes: +// - count-up animation on [data-countup] elements at page load +// - fade-in on htmx swap targets via a transient .v-just-swapped class +// Respects prefers-reduced-motion by no-oping both effects. + +(function () { + const prefersReduced = + window.matchMedia && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + function easeOutCubic(t) { + return 1 - Math.pow(1 - t, 3); + } + + function animateCount(el) { + if (prefersReduced) return; + const raw = el.textContent.trim(); + const m = raw.match(/^(\$|£|€|¥)?(-?[\d,]+(?:\.\d+)?)$/); + if (!m) return; + const prefix = m[1] || ""; + const numeric = m[2].replace(/,/g, ""); + const target = parseFloat(numeric); + if (!isFinite(target)) return; + const decimals = numeric.includes(".") ? numeric.split(".")[1].length : 0; + const format = (v) => + prefix + (decimals > 0 ? v.toFixed(decimals) : Math.floor(v).toString()); + const duration = 650; + const start = performance.now(); + el.textContent = format(0); + function tick(now) { + const t = Math.min(1, (now - start) / duration); + const v = target * easeOutCubic(t); + el.textContent = format(v); + if (t < 1) { + requestAnimationFrame(tick); + } else { + el.textContent = format(target); + } + } + requestAnimationFrame(tick); + } + + function runCountUps() { + document.querySelectorAll("[data-countup]").forEach(animateCount); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", runCountUps); + } else { + runCountUps(); + } + + document.addEventListener("htmx:afterSwap", function (evt) { + if (prefersReduced) return; + const target = evt.detail && evt.detail.target; + if (!target || !target.classList) return; + target.classList.add("v-just-swapped"); + setTimeout(() => target.classList.remove("v-just-swapped"), 400); + }); + + // Auction countdowns. Each [data-countdown] reads its sibling/ancestor's + // data-ends-at ISO timestamp. Above 1h remaining we update once a minute + // (text barely changes); inside the last hour we tick every second and tint + // urgent / critical so the eye lands on it. Past the end we render "ended" + // and stop ticking that node. + function formatRemaining(ms) { + if (ms <= 0) return { text: "ended", urgent: false, critical: false }; + const s = Math.floor(ms / 1000); + const days = Math.floor(s / 86400); + const hours = Math.floor((s % 86400) / 3600); + const minutes = Math.floor((s % 3600) / 60); + const seconds = s % 60; + const urgent = ms < 60 * 60 * 1000; + const critical = ms < 5 * 60 * 1000; + let text; + if (days > 0) text = days + "d " + hours + "h"; + else if (hours > 0) text = hours + "h " + minutes + "m"; + else if (minutes > 0) text = minutes + "m " + String(seconds).padStart(2, "0") + "s"; + else text = seconds + "s"; + return { text, urgent, critical }; + } + + function resolveEndsAt(el) { + let cur = el; + while (cur && cur.dataset && !cur.dataset.endsAt) cur = cur.parentElement; + return cur && cur.dataset ? cur.dataset.endsAt : null; + } + + function tickCountdown(el, endsAtMs) { + const remaining = endsAtMs - Date.now(); + const f = formatRemaining(remaining); + el.textContent = f.text; + el.classList.toggle("v-countdown-urgent", f.urgent && !f.critical); + el.classList.toggle("v-countdown-critical", f.critical && remaining > 0); + el.classList.toggle("v-countdown-ended", remaining <= 0); + return remaining; + } + + function startCountdowns() { + document.querySelectorAll("[data-countdown]").forEach(function (el) { + const iso = resolveEndsAt(el); + if (!iso) return; + const endsAtMs = Date.parse(iso); + if (!isFinite(endsAtMs)) return; + + let timer = null; + function loop() { + const remaining = tickCountdown(el, endsAtMs); + if (remaining <= 0) { + if (timer) clearTimeout(timer); + return; + } + // Tick every second below an hour, every 30 seconds otherwise. The + // long-interval path keeps "3d 4h" from causing constant repaints. + const interval = remaining < 60 * 60 * 1000 ? 1000 : 30000; + timer = setTimeout(loop, interval); + } + loop(); + }); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", startCountdowns); + } else { + startCountdowns(); + } + + // After an htmx swap, freshly-inserted countdowns need to be started too. + document.addEventListener("htmx:afterSwap", startCountdowns); +})(); diff --git a/templates/dashboard.templ b/templates/dashboard.templ index 3d1d5a6..b1fe640 100644 --- a/templates/dashboard.templ +++ b/templates/dashboard.templ @@ -51,7 +51,7 @@ templ DashboardBody(d DashboardData) {
Potential Spend
-
{ fmt.Sprintf("$%.2f", d.Stats.PotentialSpend) }
+
{ fmt.Sprintf("$%.2f", d.Stats.PotentialSpend) }
across { fmt.Sprintf("%d", d.Stats.PricedItemCount) } items
if d.Stats.UnpricedCount > 0 {
{ fmt.Sprintf("%d items not yet priced.", d.Stats.UnpricedCount) }
@@ -59,7 +59,7 @@ templ DashboardBody(d DashboardData) {
Money Saved
-
{ fmt.Sprintf("$%.2f", d.Stats.MoneySaved) }
+
{ fmt.Sprintf("$%.2f", d.Stats.MoneySaved) }
across { fmt.Sprintf("%d", d.Stats.SavedItemCount) } items
@@ -108,7 +108,7 @@ templ DashboardBody(d DashboardData) { templ statCard(label, value, sub string) {
{ label }
-
{ value }
+
{ value }
if sub != "" {
{ sub }
} diff --git a/templates/dashboard_templ.go b/templates/dashboard_templ.go index a183642..6d0fe55 100644 --- a/templates/dashboard_templ.go +++ b/templates/dashboard_templ.go @@ -88,14 +88,14 @@ func DashboardBody(d DashboardData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
Potential Spend
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
Potential Spend
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.2f", d.Stats.PotentialSpend)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 54, Col: 87} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 54, Col: 100} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -137,14 +137,14 @@ func DashboardBody(d DashboardData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
Money Saved
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
Money Saved
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.2f", d.Stats.MoneySaved)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 62, Col: 96} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 62, Col: 109} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -346,14 +346,14 @@ func statCard(label, value, sub string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(value) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 111, Col: 46} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 111, Col: 59} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { diff --git a/templates/item_form.templ b/templates/item_form.templ index 5c7d1e1..c97a361 100644 --- a/templates/item_form.templ +++ b/templates/item_form.templ @@ -16,6 +16,43 @@ type ItemFormData struct { func itemSelected(have, want string) bool { return have == want } +type selectOpt struct { + Value string + Label string +} + +// conditionOptions are the eBay-only item-condition filters. Values match the +// vocabulary mapped to Browse API condition IDs in internal/ebay. +func conditionOptions() []selectOpt { + return []selectOpt{ + {"", "— any —"}, + {"new", "New"}, + {"used", "Used"}, + {"refurbished", "Refurbished"}, + {"parts", "For parts / not working"}, + } +} + +// regionOptions are the eBay-only item-location filters, keyed by ISO 3166-1 +// alpha-2 country code (the value the Browse API itemLocationCountry filter +// expects). +func regionOptions() []selectOpt { + return []selectOpt{ + {"", "— any —"}, + {"US", "United States"}, + {"GB", "United Kingdom"}, + {"DE", "Germany"}, + {"FR", "France"}, + {"IT", "Italy"}, + {"ES", "Spain"}, + {"CA", "Canada"}, + {"AU", "Australia"}, + {"JP", "Japan"}, + {"CN", "China"}, + {"HK", "Hong Kong"}, + } +} + type marketplaceOpt struct { Value string Label string @@ -226,6 +263,26 @@ templ itemFormInner(d ItemFormData) {
Drop results whose title contains any of these. Case-insensitive substring match.
+
+
+ + +
eBay marketplaces only. Ignored for Yahoo JP, Mercari, and custom actors.
+
+
+ + +
Restrict to items located in this country. eBay marketplaces only.
+
+
eBay marketplaces only. Ignored for Yahoo JP, Mercari, and custom actors.
Restrict to items located in this country. eBay marketplaces only.
Advanced
Leave blank to use the configured default for the selected marketplace.
Include out-of-stock results
Advanced
Leave blank to use the configured default for the selected marketplace.
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, " value=\"1\"> Use price comparison actor in addition to active listings
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if d.IsEdit { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " Cancel") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, " Cancel") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " Cancel Running preview… apify runs can take 30–60s.") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, " Cancel Running preview… apify runs can take 30–60s.") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if !d.IsEdit { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -744,9 +873,9 @@ func ItemForm(d ItemFormData) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var28 := templ.GetChildren(ctx) - if templ_7745c5c3_Var28 == nil { - templ_7745c5c3_Var28 = templ.NopComponent + templ_7745c5c3_Var32 := templ.GetChildren(ctx) + if templ_7745c5c3_Var32 == nil { + templ_7745c5c3_Var32 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = Layout(d.Page, itemFormBody(d)).Render(ctx, templ_7745c5c3_Buffer) diff --git a/templates/item_preview.templ b/templates/item_preview.templ index 14aa52b..870d243 100644 --- a/templates/item_preview.templ +++ b/templates/item_preview.templ @@ -35,6 +35,8 @@ type FormValues struct { IncludeOutOfStock bool Marketplaces []string ListingType string + Condition string + Region string ActorActive string ActorSold string ActorPriceCompare string @@ -136,6 +138,8 @@ templ confirmForm(d PreviewData) { @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) diff --git a/templates/item_preview_templ.go b/templates/item_preview_templ.go index 063066f..9795e5b 100644 --- a/templates/item_preview_templ.go +++ b/templates/item_preview_templ.go @@ -43,6 +43,8 @@ type FormValues struct { IncludeOutOfStock bool Marketplaces []string ListingType string + Condition string + Region string ActorActive string ActorSold string ActorPriceCompare string @@ -78,7 +80,7 @@ func ItemPreview(d PreviewData) templ.Component { var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(d.Error) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 48, Col: 17} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 50, Col: 17} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -101,7 +103,7 @@ func ItemPreview(d PreviewData) templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Found %d results for '%s'", len(d.Results), d.Form.SearchQuery)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 60, Col: 83} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 62, Col: 83} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -144,7 +146,7 @@ func ItemPreview(d PreviewData) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ImageURL) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 75, Col: 31} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 77, Col: 31} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) if templ_7745c5c3_Err != nil { @@ -162,7 +164,7 @@ func ItemPreview(d PreviewData) templ.Component { var templ_7745c5c3_Var5 templ.SafeURL templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.URL)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 78, Col: 40} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 80, Col: 40} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -175,7 +177,7 @@ func ItemPreview(d PreviewData) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 78, Col: 99} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 80, Col: 99} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -188,7 +190,7 @@ func ItemPreview(d PreviewData) templ.Component { var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(r.Store) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 79, Col: 48} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 81, Col: 48} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -201,7 +203,7 @@ func ItemPreview(d PreviewData) templ.Component { var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmtNumber(r.Price, r.Currency)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 81, Col: 64} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 83, Col: 64} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -225,7 +227,7 @@ func ItemPreview(d PreviewData) templ.Component { var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("and %d more", len(d.Results)-6)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 87, Col: 86} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 89, Col: 86} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -248,7 +250,7 @@ func ItemPreview(d PreviewData) templ.Component { var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Prices range from %s to %s across %d stores", fmtNumber(d.MinPrice, d.Currency), fmtNumber(d.MaxPrice, d.Currency), d.StoreCount)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 93, Col: 148} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 95, Col: 148} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -301,7 +303,7 @@ func previewBest(d PreviewData) templ.Component { var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Results[d.BestIndex].ImageURL) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 104, Col: 46} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 106, Col: 46} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12) if templ_7745c5c3_Err != nil { @@ -324,7 +326,7 @@ func previewBest(d PreviewData) templ.Component { var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmtNumber(d.Results[d.BestIndex].Price, d.Currency)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 110, Col: 94} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 112, Col: 94} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { @@ -337,7 +339,7 @@ func previewBest(d PreviewData) templ.Component { var templ_7745c5c3_Var14 templ.SafeURL templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(d.Results[d.BestIndex].URL)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 111, Col: 77} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 113, Col: 77} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -350,7 +352,7 @@ func previewBest(d PreviewData) templ.Component { var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(d.Results[d.BestIndex].Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 111, Col: 141} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 113, Col: 141} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { @@ -363,7 +365,7 @@ func previewBest(d PreviewData) templ.Component { var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(d.Results[d.BestIndex].Store) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 112, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 114, Col: 68} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { @@ -381,7 +383,7 @@ func previewBest(d PreviewData) templ.Component { var templ_7745c5c3_Var17 string templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(d.Results[d.BestIndex].MatchedQuery) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 114, Col: 81} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 116, Col: 81} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { @@ -429,7 +431,7 @@ func confirmForm(d PreviewData) templ.Component { var templ_7745c5c3_Var19 string templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 123, Col: 60} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 125, Col: 60} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19) if templ_7745c5c3_Err != nil { @@ -493,6 +495,14 @@ func confirmForm(d PreviewData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + templ_7745c5c3_Err = hidden("condition", d.Form.Condition).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = hidden("region", d.Form.Region).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } templ_7745c5c3_Err = hidden("actor_active", d.Form.ActorActive).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err @@ -555,7 +565,7 @@ func hidden(name, value string) templ.Component { var templ_7745c5c3_Var21 string templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 149, Col: 33} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 153, Col: 33} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21) if templ_7745c5c3_Err != nil { @@ -568,7 +578,7 @@ func hidden(name, value string) templ.Component { var templ_7745c5c3_Var22 string templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(value) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 149, Col: 49} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 153, Col: 49} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22) if templ_7745c5c3_Err != nil { @@ -611,7 +621,7 @@ func hiddenBool(name string, value bool) templ.Component { var templ_7745c5c3_Var24 string templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 154, Col: 34} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/item_preview.templ`, Line: 158, Col: 34} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24) if templ_7745c5c3_Err != nil { diff --git a/templates/items.templ b/templates/items.templ index 8569682..a31a005 100644 --- a/templates/items.templ +++ b/templates/items.templ @@ -89,7 +89,7 @@ templ itemRow(it models.Item, csrf string) { if it.BestPrice != nil { -
{ fmtPrice(it.BestPrice, "USD") }
+
{ fmtPrice(it.BestPrice, it.BestPriceCurrency) }
if it.BestPriceURL != "" { { it.BestPriceStore } } else if it.BestPriceStore != "" { diff --git a/templates/items_templ.go b/templates/items_templ.go index 159201c..46310cf 100644 --- a/templates/items_templ.go +++ b/templates/items_templ.go @@ -326,9 +326,9 @@ func itemRow(it models.Item, csrf string) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var16 string - templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(it.BestPrice, "USD")) + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(it.BestPrice, it.BestPriceCurrency)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 92, Col: 112} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/items.templ`, Line: 92, Col: 127} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { diff --git a/templates/layout.templ b/templates/layout.templ index df19664..c5475d3 100644 --- a/templates/layout.templ +++ b/templates/layout.templ @@ -24,6 +24,7 @@ templ head(title string) { + } diff --git a/templates/layout_templ.go b/templates/layout_templ.go index 60690ba..f244104 100644 --- a/templates/layout_templ.go +++ b/templates/layout_templ.go @@ -55,7 +55,7 @@ func head(title string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " · Veola") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " · Veola") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -236,7 +236,7 @@ func Layout(p Page, body templ.Component) templ.Component { var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(p.Flash) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 61, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 62, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { @@ -255,7 +255,7 @@ func Layout(p Page, body templ.Component) templ.Component { var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(p.FlashError) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 64, Col: 46} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 65, Col: 46} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -353,7 +353,7 @@ func CSRFInput(token string) templ.Component { var templ_7745c5c3_Var17 string templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(token) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 85, Col: 53} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 86, Col: 53} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) if templ_7745c5c3_Err != nil { diff --git a/templates/results.templ b/templates/results.templ index 0b92d1d..705128c 100644 --- a/templates/results.templ +++ b/templates/results.templ @@ -3,7 +3,9 @@ package templates import ( "encoding/json" "fmt" + "time" + "veola/internal/db" "veola/internal/models" ) @@ -21,6 +23,9 @@ type ItemResultsData struct { // normal page load; PostRunItem sets exactly one. RunMsg string RunError string + // EndingSoon, when non-nil, surfaces the soonest-ending auction across this + // item's results so users can act before close. + EndingSoon *db.EndingSoon } type BadgeData struct { @@ -35,6 +40,8 @@ type GlobalResultsData struct { ItemID int64 From string To string + // EndingSoon mirrors the per-item field but spans every watched item. + EndingSoon *db.EndingSoon } type ItemResultRow struct { @@ -42,8 +49,40 @@ type ItemResultRow struct { ItemName string } +// endingSoonStrip surfaces the next auction about to close. Hidden when nil +// (the handler decides cutoff: 24h by default). The data-ends-at attribute +// drives the live countdown in flair.js. +templ endingSoonStrip(e *db.EndingSoon) { + if e != nil { +
+
Ending soon
+
+ if e.URL != "" { + { e.Title } + } else { + { e.Title } + } + { e.ItemName } +
+
+
+ } +} + +// endsInCell renders the per-row countdown for auction listings only. Fixed- +// price rows (EndsAt == nil) get an em-dash placeholder so column widths stay +// consistent. +templ endsInCell(endsAt *time.Time) { + if endsAt != nil { + + } else { + + } +} + templ itemResultsBody(d ItemResultsData) {
+ @endingSoonStrip(d.EndingSoon)

{ d.Item.Name }

@@ -53,7 +92,7 @@ templ itemResultsBody(d ItemResultsData) {
if d.Item.BestPrice != nil { -
{ fmtPrice(d.Item.BestPrice, "USD") }
+
{ fmtPrice(d.Item.BestPrice, d.Item.BestPriceCurrency) }
if d.Item.BestPriceURL != "" { { d.Item.BestPriceStore } } @@ -120,6 +159,7 @@ templ ItemResultsTable(d ItemResultsData) { Price Store + Ends Found @@ -146,6 +186,7 @@ templ ItemResultsTable(d ItemResultsData) { { fmtPrice(r.Price, r.Currency) } { r.Source } + @endsInCell(r.EndsAt) { humanTime(r.FoundAt) } if r.Alerted { @@ -197,6 +238,7 @@ templ ItemResults(d ItemResultsData) { templ globalResultsBody(d GlobalResultsData) {

All Results

+ @endingSoonStrip(d.EndingSoon)
@@ -220,7 +262,7 @@ templ globalResultsBody(d GlobalResultsData) {
- + for _, r := range d.Results { @@ -238,6 +280,7 @@ templ globalResultsBody(d GlobalResultsData) { +
ItemTitlePriceStoreFoundAlert
ItemTitlePriceStoreEndsFoundAlert
{ fmtPrice(r.Price, r.Currency) } { r.Source }@endsInCell(r.EndsAt) { humanTime(r.FoundAt) } if r.Alerted { diff --git a/templates/results_templ.go b/templates/results_templ.go index 3efcd4c..e7eb538 100644 --- a/templates/results_templ.go +++ b/templates/results_templ.go @@ -11,7 +11,9 @@ import templruntime "github.com/a-h/templ/runtime" import ( "encoding/json" "fmt" + "time" + "veola/internal/db" "veola/internal/models" ) @@ -29,6 +31,9 @@ type ItemResultsData struct { // normal page load; PostRunItem sets exactly one. RunMsg string RunError string + // EndingSoon, when non-nil, surfaces the soonest-ending auction across this + // item's results so users can act before close. + EndingSoon *db.EndingSoon } type BadgeData struct { @@ -43,6 +48,8 @@ type GlobalResultsData struct { ItemID int64 From string To string + // EndingSoon mirrors the per-item field but spans every watched item. + EndingSoon *db.EndingSoon } type ItemResultRow struct { @@ -50,7 +57,10 @@ type ItemResultRow struct { ItemName string } -func itemResultsBody(d ItemResultsData) templ.Component { +// endingSoonStrip surfaces the next auction about to close. Hidden when nil +// (the handler decides cutoff: 24h by default). The data-ends-at attribute +// drives the live countdown in flair.js. +func endingSoonStrip(e *db.EndingSoon) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -71,198 +81,365 @@ func itemResultsBody(d ItemResultsData) templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var2 string - templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.Name) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 49, Col: 52} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if d.Item.Category != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + if e != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\">
Ending soon
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if d.Item.BestPrice != nil { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var4 string - templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(d.Item.BestPrice, "USD")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 56, Col: 72} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if d.Item.BestPriceURL != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(e.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 61, Col: 78} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(e.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 63, Col: 14} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" target=\"_blank\" rel=\"noopener\">") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var6 string - templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.BestPriceStore) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 58, Col: 123} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
no price yet
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(e.ItemName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 65, Col: 51} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - if d.Badge.Label != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var7 = []any{"v-badge", d.Badge.Class} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var7...) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var9 string - templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(d.Badge.Label) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 65, Col: 62} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " Running...

Price History

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if len(d.History) < 2 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
Not enough history yet.
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" data-countdown>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") + return nil + }) +} + +func itemResultsBody(d ItemResultsData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var9 := templ.GetChildren(ctx) + if templ_7745c5c3_Var9 == nil { + templ_7745c5c3_Var9 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = endingSoonStrip(d.EndingSoon).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 88, Col: 52} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.Item.Category != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.Category) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 90, Col: 43} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.Item.BestPrice != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(d.Item.BestPrice, d.Item.BestPriceCurrency)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 95, Col: 91} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.Item.BestPriceURL != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(d.Item.BestPriceStore) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 97, Col: 123} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
no price yet
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if d.Badge.Label != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var15 = []any{"v-badge", d.Badge.Class} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var15...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(d.Badge.Label) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 104, Col: 62} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
Running...

Price History

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(d.History) < 2 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
Not enough history yet.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -270,7 +447,7 @@ func itemResultsBody(d ItemResultsData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -298,316 +475,324 @@ func ItemResultsTable(d ItemResultsData) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var13 := templ.GetChildren(ctx) - if templ_7745c5c3_Var13 == nil { - templ_7745c5c3_Var13 = templ.NopComponent + templ_7745c5c3_Var21 := templ.GetChildren(ctx) + if templ_7745c5c3_Var21 == nil { + templ_7745c5c3_Var21 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if d.RunError != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var14 string - templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(d.RunError) + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(d.RunError) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 109, Col: 42} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 148, Col: 42} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else if d.RunMsg != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var15 string - templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(d.RunMsg) + var templ_7745c5c3_Var23 string + templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(d.RunMsg) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 111, Col: 34} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 150, Col: 34} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "
Title") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\">Found") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, r := range d.Results { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var23 = []any{"font-mono", priceClass(r.Price, d.Item.TargetPrice)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var23...) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "
TitlePriceStorePriceStoreEndsFoundAlert
Alert
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if r.ImageURL != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\"\"") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" alt=\"\" class=\"w-10 h-10 object-cover rounded\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if r.URL != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var20 string - templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 139, Col: 82} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - var templ_7745c5c3_Var21 string - templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 141, Col: 18} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if r.MatchedQuery != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "
via \"") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var22 string - templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(r.MatchedQuery) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 144, Col: 59} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\"
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var25 string - templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(r.Price, r.Currency)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 147, Col: 105} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var26 string - templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(r.Source) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 148, Col: 37} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var27 string - templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(r.FoundAt)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 149, Col: 57} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if r.Alerted { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "sent") + if r.URL != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if d.TotalPages > 1 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - for i := 1; i <= d.TotalPages; i++ { - var templ_7745c5c3_Var28 = []any{pageClass(i, d.Page_)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var28...) + var templ_7745c5c3_Var27 templ.SafeURL + templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.URL)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 179, Col: 39} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + var templ_7745c5c3_Var28 string + templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 179, Col: 82} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { var templ_7745c5c3_Var29 string - templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var28).String()) + templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 1, Col: 0} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 181, Col: 18} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" href=\"") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var30 templ.SafeURL - templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results?page=%d&order=%s", d.Item.ID, i, d.Order))) + } + if r.MatchedQuery != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
via \"") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 163, Col: 134} + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var30 string + templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(r.MatchedQuery) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 184, Col: 59} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\">") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var31 string - templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", i)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 163, Col: 159} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\"
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var31 = []any{"font-mono", priceClass(r.Price, d.Item.TargetPrice)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var31...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var33 string + templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(r.Price, r.Currency)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 187, Col: 105} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var34 string + templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(r.Source) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 188, Col: 37} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = endsInCell(r.EndsAt).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var35 string + templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(r.FoundAt)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 190, Col: 57} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if r.Alerted { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "sent") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.TotalPages > 1 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for i := 1; i <= d.TotalPages; i++ { + var templ_7745c5c3_Var36 = []any{pageClass(i, d.Page_)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var36...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var39 string + templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", i)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 204, Col: 159} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -654,9 +839,9 @@ func ItemResults(d ItemResultsData) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var32 := templ.GetChildren(ctx) - if templ_7745c5c3_Var32 == nil { - templ_7745c5c3_Var32 = templ.NopComponent + templ_7745c5c3_Var40 := templ.GetChildren(ctx) + if templ_7745c5c3_Var40 == nil { + templ_7745c5c3_Var40 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = Layout(d.Page, itemResultsBody(d)).Render(ctx, templ_7745c5c3_Buffer) @@ -683,238 +868,254 @@ func globalResultsBody(d GlobalResultsData) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var33 := templ.GetChildren(ctx) - if templ_7745c5c3_Var33 == nil { - templ_7745c5c3_Var33 = templ.NopComponent + templ_7745c5c3_Var41 := templ.GetChildren(ctx) + if templ_7745c5c3_Var41 == nil { + templ_7745c5c3_Var41 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "

All Results

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, it := range d.Items { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\">
ItemTitlePriceStoreFoundAlert
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, r := range d.Results { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "") + if r.MatchedQuery != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "
via \"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var51 string + templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(r.MatchedQuery) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 278, Col: 59} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\"
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "
ItemTitlePriceStoreEndsFoundAlert
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var39 string - templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(r.ItemName) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 228, Col: 93} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if r.URL != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var41 string - templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 231, Col: 82} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - var templ_7745c5c3_Var42 string - templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 233, Col: 18} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if r.MatchedQuery != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "
via \"") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var43 string - templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(r.MatchedQuery) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 236, Col: 59} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\"
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var44 string - templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(r.Price, r.Currency)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 239, Col: 60} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var45 string - templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(r.Source) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 240, Col: 37} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var46 string - templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(r.FoundAt)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 241, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 270, Col: 78} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if r.Alerted { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "sent") + var templ_7745c5c3_Var47 string + templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(r.ItemName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 270, Col: 93} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if r.URL != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var49 string + templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 273, Col: 82} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + var templ_7745c5c3_Var50 string + templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 275, Col: 18} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var52 string + templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(r.Price, r.Currency)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 281, Col: 60} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var53 string + templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(r.Source) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 282, Col: 37} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = endsInCell(r.EndsAt).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var54 string + templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(r.FoundAt)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/results.templ`, Line: 284, Col: 57} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if r.Alerted { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "sent") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -938,9 +1139,9 @@ func GlobalResults(d GlobalResultsData) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var47 := templ.GetChildren(ctx) - if templ_7745c5c3_Var47 == nil { - templ_7745c5c3_Var47 = templ.NopComponent + templ_7745c5c3_Var55 := templ.GetChildren(ctx) + if templ_7745c5c3_Var55 == nil { + templ_7745c5c3_Var55 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = Layout(d.Page, globalResultsBody(d)).Render(ctx, templ_7745c5c3_Buffer) diff --git a/templates/settings.templ b/templates/settings.templ index 85321c3..58cb7e5 100644 --- a/templates/settings.templ +++ b/templates/settings.templ @@ -8,8 +8,12 @@ import ( type SettingsData struct { Page - Values map[string]string - IsAdmin bool + Values map[string]string + // CredentialStatus maps each secret settings key to a human-readable + // status ("Saved in settings", "Set in config.toml", "Not set"). Secret + // values are never rendered into the form itself. + CredentialStatus map[string]string + IsAdmin bool Users []models.User TestNtfyOK string TestApifyOK string @@ -28,6 +32,16 @@ func (d SettingsData) EbayLimitReached() bool { return d.EbayDailyLimit > 0 && d.EbayUsedToday >= d.EbayDailyLimit } +// credStatus renders the "Saved in settings / Set in config.toml / Not set" +// indicator for a secret field without ever printing the secret itself. +templ credStatus(d SettingsData, key string) { + if s := d.CredentialStatus[key]; s != "" { +
+ Status: { s } +
+ } +} + templ settingsBody(d SettingsData) {

Settings

@@ -38,15 +52,18 @@ templ settingsBody(d SettingsData) { @CSRFInput(d.CSRFToken)
- + + @credStatus(d, "apify_api_key")
- + + @credStatus(d, "ebay_client_id")
- + + @credStatus(d, "ebay_client_secret")
@@ -73,7 +90,8 @@ templ settingsBody(d SettingsData) {
- + + @credStatus(d, "ntfy_token")
diff --git a/templates/settings_templ.go b/templates/settings_templ.go index b451ba8..b9dbdd6 100644 --- a/templates/settings_templ.go +++ b/templates/settings_templ.go @@ -16,18 +16,22 @@ import ( type SettingsData struct { Page - Values map[string]string - IsAdmin bool - Users []models.User - TestNtfyOK string - TestApifyOK string - TestEbayOK string - EbayUsedToday int - EbayDailyLimit int - PasswordMsg string - PasswordError string - UserMsg string - UserError string + Values map[string]string + // CredentialStatus maps each secret settings key to a human-readable + // status ("Saved in settings", "Set in config.toml", "Not set"). Secret + // values are never rendered into the form itself. + CredentialStatus map[string]string + IsAdmin bool + Users []models.User + TestNtfyOK string + TestApifyOK string + TestEbayOK string + EbayUsedToday int + EbayDailyLimit int + PasswordMsg string + PasswordError string + UserMsg string + UserError string } // EbayLimitReached reports whether eBay polling is currently halted because @@ -36,7 +40,9 @@ func (d SettingsData) EbayLimitReached() bool { return d.EbayDailyLimit > 0 && d.EbayUsedToday >= d.EbayDailyLimit } -func settingsBody(d SettingsData) templ.Component { +// credStatus renders the "Saved in settings / Set in config.toml / Not set" +// indicator for a secret field without ever printing the secret itself. +func credStatus(d SettingsData, key string) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -57,7 +63,51 @@ func settingsBody(d SettingsData) templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Settings

Apify, eBay and Ntfy

") + if s := d.CredentialStatus[key]; s != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
Status: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(s) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 40, Col: 14} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +func settingsBody(d SettingsData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var3 := templ.GetChildren(ctx) + if templ_7745c5c3_Var3 == nil { + templ_7745c5c3_Var3 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

Settings

Apify, eBay and Ntfy

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -65,91 +115,58 @@ func settingsBody(d SettingsData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var2 string - templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["apify_api_key"]) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 41, Col: 108} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) + templ_7745c5c3_Err = credStatus(d, "apify_api_key").Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var3 string - templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ebay_client_id"]) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 45, Col: 110} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) + templ_7745c5c3_Err = credStatus(d, "ebay_client_id").Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" placeholder=\"used for eBay marketplaces instead of Apify\">
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = credStatus(d, "ebay_client_secret").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
eBay API calls today: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" placeholder=\"5000 (blank uses config default)\">
eBay API calls today: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if d.EbayDailyLimit > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var6 string - templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d / %d", d.EbayUsedToday, d.EbayDailyLimit)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 58, Col: 89} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var7 string - templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d (uncapped)", d.EbayUsedToday)) + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d / %d", d.EbayUsedToday, d.EbayDailyLimit)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 60, Col: 77} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 75, Col: 89} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -157,127 +174,121 @@ func settingsBody(d SettingsData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - } - if d.EbayLimitReached() { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "Limit reached. eBay polling halted until the next reset (midnight US Pacific).") + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d (uncapped)", d.EbayUsedToday)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 77, Col: 77} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
Limit reached. eBay polling halted until the next reset (midnight US Pacific).") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = credStatus(d, "ntfy_token").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if !d.IsAdmin { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
Read-only for non-admin users.
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
Read-only for non-admin users.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if d.TestNtfyOK != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var13 string - templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestNtfyOK) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 98, Col: 44} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if d.TestApifyOK != "" { templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var14 string - templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestApifyOK) + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestNtfyOK) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 101, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 116, Col: 44} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -286,17 +297,17 @@ func settingsBody(d SettingsData) templ.Component { return templ_7745c5c3_Err } } - if d.TestEbayOK != "" { + if d.TestApifyOK != "" { templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var15 string - templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestEbayOK) + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestApifyOK) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 104, Col: 44} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 119, Col: 45} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -305,40 +316,40 @@ func settingsBody(d SettingsData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "

Change Password

") + if d.TestEbayOK != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestEbayOK) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 122, Col: 44} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "

Change Password

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if d.PasswordError != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var16 string - templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordError) + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordError) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 111, Col: 48} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 129, Col: 48} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if d.PasswordMsg != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var17 string - templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordMsg) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 114, Col: 40} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -347,7 +358,26 @@ func settingsBody(d SettingsData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") + if d.PasswordMsg != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordMsg) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 132, Col: 40} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -355,45 +385,26 @@ func settingsBody(d SettingsData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if d.IsAdmin { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "

Users

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "

Users

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if d.UserError != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var18 string - templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserError) + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserError) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 138, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 156, Col: 45} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if d.UserMsg != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var19 string - templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserMsg) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 141, Col: 37} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -402,126 +413,145 @@ func settingsBody(d SettingsData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "") + if d.UserMsg != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserMsg) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 159, Col: 37} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
UsernameRoleCreated
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, u := range d.Users { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "
UsernameRoleCreated
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 166, Col: 24} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(string(u.Role)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 167, Col: 44} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var20 string - templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username) + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(u.CreatedAt.Format("2006-01-02")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 148, Col: 24} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 168, Col: 70} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\">
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -545,9 +575,9 @@ func Settings(d SettingsData) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var28 := templ.GetChildren(ctx) - if templ_7745c5c3_Var28 == nil { - templ_7745c5c3_Var28 = templ.NopComponent + templ_7745c5c3_Var26 := templ.GetChildren(ctx) + if templ_7745c5c3_Var26 == nil { + templ_7745c5c3_Var26 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = Layout(d.Page, settingsBody(d)).Render(ctx, templ_7745c5c3_Buffer)