Add Authentik SSO, budget visibility, Resend email, 12h item default

Opens Veola up to more users (Parodia's Authentik) and makes Apify spend
visible to everyone.

- Auth: Traefik forward-auth (trust X-Authentik-* headers only from
  trusted_proxies CIDRs, keyed by email, role synced from admin_group),
  keeping local password login as break-glass. New [auth] config,
  CaptureDirectIP + ForwardAuth middleware, deploy/authentik-forward-auth.md.
- Budget: count every Apify run (apify_api_usage table) and show
  calls + estimated cost to all users on the dashboard, with an optional
  monthly-budget bar. New [budget] config + settings.
- Email: Resend client for opt-in deal alerts and a weekly digest
  (Mon 09:00). Per-user email + toggles on Settings. New [resend] config.
- Defaults: new items default to a 12-hour poll interval to cut spend.

users table gains email/auth_source/email-pref columns (migrated in place).
go build/vet/test green; boots and migrates cleanly.
This commit is contained in:
prosolis
2026-06-20 11:12:11 -07:00
parent b6fadf6504
commit 7c95e4fd4e
23 changed files with 1883 additions and 261 deletions

View File

@@ -13,6 +13,25 @@ secure_cookies = true
session_secret = "change-this-to-a-random-32-byte-string-aaaa" session_secret = "change-this-to-a-random-32-byte-string-aaaa"
encryption_key = "change-this-to-a-different-random-32-byte-string-bb" encryption_key = "change-this-to-a-different-random-32-byte-string-bb"
# Identity. mode = "local" (default) uses Veola's own password login. mode =
# "forward" additionally trusts identity headers set by a reverse proxy doing
# forward-auth (Traefik in front of Authentik); local password login stays
# available as a break-glass fallback. Users are matched/created by EMAIL.
#
# SECURITY: forward-auth headers are honored ONLY when the direct connection
# comes from a CIDR in trusted_proxies. Without this, anyone who can reach the
# port could spoof the headers. Set it to your proxy's address(es). Veola
# refuses to start in forward mode with an empty trusted_proxies list.
[auth]
mode = "local"
# trusted_proxies = ["127.0.0.1/32"] # required when mode = "forward"
# admin_group = "veola-admins" # IdP group that maps to the admin role
# Header names default to Authentik's; override only if your proxy differs.
# forward_header_user = "X-Authentik-Username"
# forward_header_email = "X-Authentik-Email"
# forward_header_name = "X-Authentik-Name"
# forward_header_groups = "X-Authentik-Groups"
[apify] [apify]
api_key = "" api_key = ""
@@ -55,6 +74,26 @@ daily_call_limit = 5000
base_url = "https://ntfy.yourdomain.com" base_url = "https://ntfy.yourdomain.com"
default_topic = "veola" default_topic = "veola"
# Resend transactional email (https://resend.com) for opt-in deal alerts and
# the weekly digest. api_key and from can also be set via /settings. The from
# address domain must be verified in your Resend account. Each user enables
# their own email + opt-ins on the Settings page.
[resend]
api_key = ""
from = "Veola <veola@yourdomain.com>"
# Budget visibility. Apify bills per actor run/result, which Veola cannot read
# back exactly, so apify_cost_per_call is a USD estimate multiplied by the
# recorded run count. monthly_budget_usd > 0 draws a budget bar on the
# dashboard (visible to every signed-in user). Both are overridable via
# /settings.
[budget]
apify_cost_per_call = 0.0
monthly_budget_usd = 0.0
[scheduler] [scheduler]
# Per-item poll cadence is set on the add-item form (new items default to a
# 12-hour refresh to keep Apify spend down). This global value is the fallback
# for items left at 0.
global_poll_interval_minutes = 60 global_poll_interval_minutes = 60
match_confidence_threshold = 0.6 match_confidence_threshold = 0.6

View File

@@ -0,0 +1,69 @@
# Authentik forward-auth (via Traefik)
Veola can delegate identity to Authentik using Traefik's `forwardAuth`
middleware. Veola itself does not speak OIDC; it trusts identity headers that
Authentik's proxy outpost sets, and matches/creates a local user by **email**.
Local password login stays available as a break-glass fallback.
## 1. Veola config
```toml
[auth]
mode = "forward"
trusted_proxies = ["172.18.0.0/16"] # CIDR(s) of the Traefik container/host
admin_group = "veola-admins" # Authentik group that grants admin
```
`trusted_proxies` is mandatory in forward mode: forward-auth headers are only
honored when the **direct** connection (before `X-Forwarded-For` rewriting)
comes from one of these CIDRs. Without it the headers are spoofable. Use the
address Traefik connects from (its Docker network range, or `127.0.0.1/32` if
co-located).
Header names default to Authentik's (`X-Authentik-Username`,
`X-Authentik-Email`, `X-Authentik-Name`, `X-Authentik-Groups`); override under
`[auth]` only if your outpost differs.
## 2. Authentik
- Create a **Proxy Provider** in *forward auth (single application)* mode with
the external host (e.g. `https://veola.example.com`).
- Bind it to an Application, and bind the embedded/standalone outpost.
- Create a group (default name `veola-admins`) and add operators who should be
Veola admins. Everyone else lands as a regular user. Role is re-synced from
the group on every request.
## 3. Traefik
```yaml
http:
middlewares:
authentik:
forwardAuth:
address: "http://authentik-server:9000/outpost.goauthentik.io/auth/traefik"
trustForwardHeader: true
authResponseHeaders:
- X-authentik-username
- X-authentik-email
- X-authentik-name
- X-authentik-groups
routers:
veola:
rule: "Host(`veola.example.com`)"
service: veola
middlewares:
- authentik
tls:
certResolver: letsencrypt
```
Traefik must strip any client-supplied `X-Authentik-*` and `X-Forwarded-*`
headers on ingress so only the outpost can set them.
## Break-glass
`/login` and `/setup` still work for local password accounts. If Authentik is
down, reach Veola directly (bypassing the proxy) from a trusted network and log
in with a local admin account. Forward-auth is a no-op for connections that do
not originate from `trusted_proxies`.

View File

@@ -36,6 +36,9 @@ type Manager struct {
Sessions *scs.SessionManager Sessions *scs.SessionManager
Store *db.Store Store *db.Store
hmacKey []byte hmacKey []byte
// forward, when non-nil, enables trust of reverse-proxy identity headers.
// See forward.go.
forward *ForwardAuthConfig
} }
func NewManager(sqlDB *sql.DB, store *db.Store, sessionSecret string, secureCookies bool) (*Manager, error) { func NewManager(sqlDB *sql.DB, store *db.Store, sessionSecret string, secureCookies bool) (*Manager, error) {

156
internal/auth/forward.go Normal file
View File

@@ -0,0 +1,156 @@
package auth
import (
"context"
"log/slog"
"net"
"net/http"
"strings"
"veola/internal/models"
)
// ForwardAuthConfig configures trust of reverse-proxy identity headers
// (Traefik forwardAuth in front of Authentik). It is nil unless forward-auth
// is enabled in config.
type ForwardAuthConfig struct {
HeaderUser string
HeaderEmail string
HeaderName string
HeaderGroups string
AdminGroup string
trustedNets []*net.IPNet
}
// NewForwardAuthConfig parses the trusted-proxy CIDRs and returns a ready
// config. An entry that is a bare IP (no "/") is treated as a /32 or /128.
func NewForwardAuthConfig(headerUser, headerEmail, headerName, headerGroups, adminGroup string, trustedProxies []string) (*ForwardAuthConfig, error) {
fa := &ForwardAuthConfig{
HeaderUser: headerUser,
HeaderEmail: headerEmail,
HeaderName: headerName,
HeaderGroups: headerGroups,
AdminGroup: adminGroup,
}
for _, raw := range trustedProxies {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
if !strings.Contains(raw, "/") {
if strings.Contains(raw, ":") {
raw += "/128"
} else {
raw += "/32"
}
}
_, n, err := net.ParseCIDR(raw)
if err != nil {
return nil, err
}
fa.trustedNets = append(fa.trustedNets, n)
}
return fa, nil
}
// trusts reports whether a direct-peer IP string is within a trusted CIDR.
func (fa *ForwardAuthConfig) trusts(ipStr string) bool {
ip := net.ParseIP(ipStr)
if ip == nil {
return false
}
for _, n := range fa.trustedNets {
if n.Contains(ip) {
return true
}
}
return false
}
// SetForwardAuth enables forward-auth header trust on the manager.
func (m *Manager) SetForwardAuth(fa *ForwardAuthConfig) { m.forward = fa }
type directIPCtxKey struct{}
// CaptureDirectIP records the direct connection's remote IP into the request
// context BEFORE middleware.RealIP rewrites RemoteAddr from X-Forwarded-For.
// Forward-auth trust is checked against this captured value, not the
// proxy-supplied one, so a client cannot claim to be the proxy.
func CaptureDirectIP(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
ctx := context.WithValue(r.Context(), directIPCtxKey{}, host)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func directIP(ctx context.Context) string {
s, _ := ctx.Value(directIPCtxKey{}).(string)
return s
}
// ForwardAuth, when enabled, trusts identity headers from a configured proxy.
// It runs after LoadUser: if the direct peer is a trusted proxy and an email
// header is present, it find-or-creates the local user (keyed by email),
// re-syncs role from the IdP group, writes the user into the session + context,
// and so supersedes any stale local session. When forward-auth is disabled, or
// the peer is untrusted, or no headers are present, it is a no-op and local
// session auth applies (break-glass login still works).
func (m *Manager) ForwardAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fa := m.forward
if fa == nil {
next.ServeHTTP(w, r)
return
}
if !fa.trusts(directIP(r.Context())) {
next.ServeHTTP(w, r)
return
}
email := strings.TrimSpace(r.Header.Get(fa.HeaderEmail))
if email == "" {
next.ServeHTTP(w, r)
return
}
username := strings.TrimSpace(r.Header.Get(fa.HeaderUser))
if username == "" {
username = strings.TrimSpace(r.Header.Get(fa.HeaderName))
}
role := models.RoleUser
if fa.hasAdminGroup(r.Header.Get(fa.HeaderGroups)) {
role = models.RoleAdmin
}
u, err := m.Store.UpsertForwardUser(r.Context(), email, username, role)
if err != nil || u == nil {
slog.Error("forward-auth upsert failed", "email", email, "err", err)
http.Error(w, "forward-auth provisioning failed", http.StatusInternalServerError)
return
}
// Keep the session in sync so UserID()/CSRFToken() and a later
// non-proxied request behave; rotate only when the bound user changes.
if m.UserID(r.Context()) != u.ID {
if err := m.LogIn(r.Context(), u.ID); err != nil {
slog.Error("forward-auth session login failed", "err", err)
}
}
ctx := context.WithValue(r.Context(), ctxKeyUser, u)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// hasAdminGroup reports whether the IdP groups header contains the admin group.
// Authentik joins groups with "|"; commas are also accepted.
func (fa *ForwardAuthConfig) hasAdminGroup(header string) bool {
if fa.AdminGroup == "" || header == "" {
return false
}
for _, g := range strings.FieldsFunc(header, func(r rune) bool { return r == '|' || r == ',' }) {
if strings.EqualFold(strings.TrimSpace(g), fa.AdminGroup) {
return true
}
}
return false
}

View File

@@ -0,0 +1,53 @@
package auth
import "testing"
func TestForwardAuthTrusts(t *testing.T) {
fa, err := NewForwardAuthConfig("u", "e", "n", "g", "admins", []string{"127.0.0.1/32", "10.0.0.0/8", "::1"})
if err != nil {
t.Fatalf("config: %v", err)
}
cases := map[string]bool{
"127.0.0.1": true,
"10.5.6.7": true,
"::1": true,
"192.168.1.1": false,
"8.8.8.8": false,
"": false,
"not-an-ip": false,
}
for ip, want := range cases {
if got := fa.trusts(ip); got != want {
t.Errorf("trusts(%q) = %v, want %v", ip, got, want)
}
}
}
func TestForwardAuthBadCIDR(t *testing.T) {
if _, err := NewForwardAuthConfig("u", "e", "n", "g", "a", []string{"not-a-cidr/99"}); err == nil {
t.Fatal("expected error for malformed CIDR")
}
}
func TestHasAdminGroup(t *testing.T) {
fa := &ForwardAuthConfig{AdminGroup: "veola-admins"}
cases := map[string]bool{
"veola-admins": true,
"users|veola-admins|staff": true, // Authentik pipe-delimited
"users,veola-admins": true, // comma-delimited
"VEOLA-ADMINS": true, // case-insensitive
"users|staff": false,
"": false,
"veola-admins-extra": false,
}
for header, want := range cases {
if got := fa.hasAdminGroup(header); got != want {
t.Errorf("hasAdminGroup(%q) = %v, want %v", header, got, want)
}
}
// Empty admin group never matches.
empty := &ForwardAuthConfig{AdminGroup: ""}
if empty.hasAdminGroup("anything") {
t.Error("empty admin group should not match")
}
}

View File

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"github.com/BurntSushi/toml" "github.com/BurntSushi/toml"
) )
@@ -12,12 +13,53 @@ import (
type Config struct { type Config struct {
Server ServerConfig `toml:"server"` Server ServerConfig `toml:"server"`
Security SecurityConfig `toml:"security"` Security SecurityConfig `toml:"security"`
Auth AuthConfig `toml:"auth"`
Apify ApifyConfig `toml:"apify"` Apify ApifyConfig `toml:"apify"`
Ebay EbayConfig `toml:"ebay"` Ebay EbayConfig `toml:"ebay"`
Ntfy NtfyConfig `toml:"ntfy"` Ntfy NtfyConfig `toml:"ntfy"`
Resend ResendConfig `toml:"resend"`
Budget BudgetConfig `toml:"budget"`
Scheduler SchedulerConfig `toml:"scheduler"` Scheduler SchedulerConfig `toml:"scheduler"`
} }
// AuthConfig controls how identities are established. Mode "local" (default)
// uses Veola's own password login. Mode "forward" trusts identity headers set
// by a reverse proxy doing forward-auth (Traefik in front of Authentik); local
// password login stays available as a break-glass fallback. The forward-auth
// headers are honored ONLY when the direct connection comes from a CIDR in
// TrustedProxies — without that gate anyone reaching the port could spoof them.
type AuthConfig struct {
Mode string `toml:"mode"`
ForwardHeaderUser string `toml:"forward_header_user"`
ForwardHeaderEmail string `toml:"forward_header_email"`
ForwardHeaderName string `toml:"forward_header_name"`
ForwardHeaderGroups string `toml:"forward_header_groups"`
AdminGroup string `toml:"admin_group"`
TrustedProxies []string `toml:"trusted_proxies"`
}
// ForwardAuthEnabled reports whether forward-auth header trust is active.
func (c AuthConfig) ForwardAuthEnabled() bool {
return strings.EqualFold(strings.TrimSpace(c.Mode), "forward")
}
// ResendConfig holds Resend transactional-email credentials. APIKey and From
// can both be overridden at runtime via /settings (resend_api_key,
// resend_from). From is an RFC-5322 address, e.g. "Veola <veola@example.com>".
type ResendConfig struct {
APIKey string `toml:"api_key"`
From string `toml:"from"`
}
// BudgetConfig drives the cost estimate shown to every user. CostPerApifyCall
// is a USD estimate multiplied by the recorded Apify run count (Apify does not
// report exact spend back to Veola). MonthlyBudgetUSD, when > 0, draws a
// budget-consumed bar on the dashboard. Both are overridable via /settings.
type BudgetConfig struct {
CostPerApifyCall float64 `toml:"apify_cost_per_call"`
MonthlyBudgetUSD float64 `toml:"monthly_budget_usd"`
}
// EbayConfig holds credentials for eBay's official Buy > Browse API. When set, // EbayConfig holds credentials for eBay's official Buy > Browse API. When set,
// eBay marketplaces are polled through the Browse API instead of an Apify // eBay marketplaces are polled through the Browse API instead of an Apify
// scraper actor. ClientID is the App ID and ClientSecret is the Cert ID from // scraper actor. ClientID is the App ID and ClientSecret is the Cert ID from
@@ -144,6 +186,26 @@ func (c *Config) validate() error {
if c.Ebay.DailyCallLimit == 0 { if c.Ebay.DailyCallLimit == 0 {
c.Ebay.DailyCallLimit = 5000 c.Ebay.DailyCallLimit = 5000
} }
// Default the forward-auth header names to Authentik's defaults so a
// minimal [auth] block (just mode + trusted_proxies) works out of the box.
if c.Auth.ForwardHeaderUser == "" {
c.Auth.ForwardHeaderUser = "X-Authentik-Username"
}
if c.Auth.ForwardHeaderEmail == "" {
c.Auth.ForwardHeaderEmail = "X-Authentik-Email"
}
if c.Auth.ForwardHeaderName == "" {
c.Auth.ForwardHeaderName = "X-Authentik-Name"
}
if c.Auth.ForwardHeaderGroups == "" {
c.Auth.ForwardHeaderGroups = "X-Authentik-Groups"
}
if c.Auth.AdminGroup == "" {
c.Auth.AdminGroup = "veola-admins"
}
if c.Auth.ForwardAuthEnabled() && len(c.Auth.TrustedProxies) == 0 {
return errors.New("auth.mode is \"forward\" but auth.trusted_proxies is empty: forward-auth headers would be spoofable. Set the CIDR(s) of your reverse proxy (e.g. [\"127.0.0.1/32\"])")
}
return nil return nil
} }

View File

@@ -53,6 +53,27 @@ func Open(path string) (*sql.DB, error) {
conn.Close() conn.Close()
return nil, err return nil, err
} }
// Multi-user / email columns (Authentik forward-auth + Resend mail).
for _, c := range []struct{ col, typ string }{
{"email", "TEXT"},
{"auth_source", "TEXT NOT NULL DEFAULT 'local'"},
{"email_deal_alerts", "INTEGER NOT NULL DEFAULT 0"},
{"email_weekly_digest", "INTEGER NOT NULL DEFAULT 0"},
} {
if err := addColumnIfMissing(conn, "users", c.col, c.typ); err != nil {
conn.Close()
return nil, err
}
}
// Email is the identity key for forward-auth (Authentik) and the Resend
// destination. Partial unique index: many local users may have no email
// (NULL/'') while any populated address stays unique. Created here, after
// the email column exists, so it works on both fresh and migrated DBs.
if _, err := conn.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email
ON users(email) WHERE email IS NOT NULL AND email != ''`); err != nil {
conn.Close()
return nil, fmt.Errorf("create users email index: %w", err)
}
return conn, nil return conn, nil
} }

View File

@@ -94,6 +94,28 @@ func (s *Store) UserCount(ctx context.Context) (int, error) {
return n, err return n, err
} }
const userSelect = `SELECT id, username, password_hash, role, email, auth_source,
email_deal_alerts, email_weekly_digest, created_at FROM users`
func scanUser(r rowScanner) (*models.User, error) {
var (
u models.User
role, authSource string
email sql.NullString
dealAlerts, digest int
)
if err := r.Scan(&u.ID, &u.Username, &u.PasswordHash, &role, &email, &authSource,
&dealAlerts, &digest, &u.CreatedAt); err != nil {
return nil, err
}
u.Role = models.Role(role)
u.Email = email.String
u.AuthSource = authSource
u.EmailDealAlerts = dealAlerts != 0
u.EmailWeeklyDigest = digest != 0
return &u, nil
}
func (s *Store) CreateUser(ctx context.Context, username, hash string, role models.Role) (int64, error) { func (s *Store) CreateUser(ctx context.Context, username, hash string, role models.Role) (int64, error) {
res, err := s.DB.ExecContext(ctx, res, err := s.DB.ExecContext(ctx,
`INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)`, `INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)`,
@@ -105,61 +127,136 @@ func (s *Store) CreateUser(ctx context.Context, username, hash string, role mode
} }
func (s *Store) GetUserByUsername(ctx context.Context, username string) (*models.User, error) { func (s *Store) GetUserByUsername(ctx context.Context, username string) (*models.User, error) {
row := s.DB.QueryRowContext(ctx, u, err := scanUser(s.DB.QueryRowContext(ctx, userSelect+` WHERE username = ?`, username))
`SELECT id, username, password_hash, role, created_at FROM users WHERE username = ?`, if errors.Is(err, sql.ErrNoRows) {
username) return nil, nil
var u models.User
var role string
if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &role, &u.CreatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
} }
u.Role = models.Role(role) return u, err
return &u, nil
} }
func (s *Store) GetUserByID(ctx context.Context, id int64) (*models.User, error) { func (s *Store) GetUserByID(ctx context.Context, id int64) (*models.User, error) {
row := s.DB.QueryRowContext(ctx, u, err := scanUser(s.DB.QueryRowContext(ctx, userSelect+` WHERE id = ?`, id))
`SELECT id, username, password_hash, role, created_at FROM users WHERE id = ?`, id) if errors.Is(err, sql.ErrNoRows) {
var u models.User return nil, nil
var role string
if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &role, &u.CreatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
} }
u.Role = models.Role(role) return u, err
return &u, nil }
func (s *Store) GetUserByEmail(ctx context.Context, email string) (*models.User, error) {
if email == "" {
return nil, nil
}
u, err := scanUser(s.DB.QueryRowContext(ctx, userSelect+` WHERE email = ?`, email))
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return u, err
} }
func (s *Store) ListUsers(ctx context.Context) ([]models.User, error) { func (s *Store) ListUsers(ctx context.Context) ([]models.User, error) {
rows, err := s.DB.QueryContext(ctx, rows, err := s.DB.QueryContext(ctx, userSelect+` ORDER BY id`)
`SELECT id, username, password_hash, role, created_at FROM users ORDER BY id`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var out []models.User var out []models.User
for rows.Next() { for rows.Next() {
var u models.User u, err := scanUser(rows)
var role string if err != nil {
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &role, &u.CreatedAt); err != nil {
return nil, err return nil, err
} }
u.Role = models.Role(role) out = append(out, *u)
out = append(out, u)
} }
return out, rows.Err() return out, rows.Err()
} }
// UpsertForwardUser find-or-creates a user keyed by email for forward-auth
// (Authentik). On an existing row it re-syncs role + username so the IdP stays
// the source of truth. Returns the resolved user. New rows are marked
// auth_source='forward' and carry an unusable password hash (forward-auth
// users never log in with a local password).
func (s *Store) UpsertForwardUser(ctx context.Context, email, username string, role models.Role) (*models.User, error) {
if email == "" {
return nil, errors.New("forward-auth requires an email")
}
existing, err := s.GetUserByEmail(ctx, email)
if err != nil {
return nil, err
}
if existing != nil {
if existing.Role != role || (username != "" && existing.Username != username) {
newName := existing.Username
if username != "" {
newName = username
}
if _, err := s.DB.ExecContext(ctx,
`UPDATE users SET role = ?, username = ? WHERE id = ?`,
string(role), newName, existing.ID); err != nil {
return nil, err
}
existing.Role = role
existing.Username = newName
}
return existing, nil
}
if username == "" {
username = email
}
res, err := s.DB.ExecContext(ctx,
`INSERT INTO users (username, password_hash, role, email, auth_source) VALUES (?, ?, ?, ?, 'forward')`,
username, "!forward-auth", string(role), email)
if err != nil {
return nil, err
}
id, err := res.LastInsertId()
if err != nil {
return nil, err
}
return s.GetUserByID(ctx, id)
}
func (s *Store) UpdateUserPassword(ctx context.Context, id int64, hash string) error { func (s *Store) UpdateUserPassword(ctx context.Context, id int64, hash string) error {
_, err := s.DB.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, hash, id) _, err := s.DB.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, hash, id)
return err return err
} }
// UpdateUserEmailPrefs sets a user's notification email and per-channel email
// opt-ins. Email "" clears the address (and silences email for that user).
func (s *Store) UpdateUserEmailPrefs(ctx context.Context, id int64, email string, dealAlerts, weeklyDigest bool) error {
_, err := s.DB.ExecContext(ctx,
`UPDATE users SET email = ?, email_deal_alerts = ?, email_weekly_digest = ? WHERE id = ?`,
nullStr(email), boolToInt(dealAlerts), boolToInt(weeklyDigest), id)
return err
}
// UsersWithDealAlerts returns users who opted into deal-alert email and have a
// destination address. UsersWithWeeklyDigest is the digest equivalent.
func (s *Store) UsersWithDealAlerts(ctx context.Context) ([]models.User, error) {
return s.usersWhereEmailPref(ctx, "email_deal_alerts")
}
func (s *Store) UsersWithWeeklyDigest(ctx context.Context) ([]models.User, error) {
return s.usersWhereEmailPref(ctx, "email_weekly_digest")
}
func (s *Store) usersWhereEmailPref(ctx context.Context, col string) ([]models.User, error) {
rows, err := s.DB.QueryContext(ctx,
userSelect+` WHERE `+col+` = 1 AND email IS NOT NULL AND email != '' ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.User
for rows.Next() {
u, err := scanUser(rows)
if err != nil {
return nil, err
}
out = append(out, *u)
}
return out, rows.Err()
}
func (s *Store) DeleteUser(ctx context.Context, id int64) error { func (s *Store) DeleteUser(ctx context.Context, id int64) error {
_, err := s.DB.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id) _, err := s.DB.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id)
return err return err
@@ -267,6 +364,47 @@ func (s *Store) IncrementEbayUsage(ctx context.Context) (int, error) {
return n, nil return n, nil
} }
// ============ apify api usage ============
// apifyUsageDay buckets Apify runs by UTC calendar day. Unlike eBay (which has
// its own Pacific-time quota reset) Apify billing is a soft estimate here, so a
// plain UTC day keeps the month math simple.
func apifyUsageDay() string {
return time.Now().UTC().Format("2006-01-02")
}
// IncrementApifyUsage records one Apify actor run against the current UTC day.
func (s *Store) IncrementApifyUsage(ctx context.Context) error {
_, err := s.DB.ExecContext(ctx, `
INSERT INTO apify_api_usage (usage_date, call_count) VALUES (?, 1)
ON CONFLICT(usage_date) DO UPDATE SET call_count = call_count + 1
`, apifyUsageDay())
return err
}
// ApifyUsageToday returns Apify runs recorded for the current UTC day.
func (s *Store) ApifyUsageToday(ctx context.Context) (int, error) {
var n int
err := s.DB.QueryRowContext(ctx,
`SELECT call_count FROM apify_api_usage WHERE usage_date = ?`, apifyUsageDay()).Scan(&n)
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
return n, err
}
// ApifyUsageMonth returns total Apify runs in the current UTC calendar month.
func (s *Store) ApifyUsageMonth(ctx context.Context) (int, error) {
prefix := time.Now().UTC().Format("2006-01") + "-%"
var n sql.NullInt64
err := s.DB.QueryRowContext(ctx,
`SELECT SUM(call_count) FROM apify_api_usage WHERE usage_date LIKE ?`, prefix).Scan(&n)
if err != nil {
return 0, err
}
return int(n.Int64), nil
}
// ============ items ============ // ============ items ============
func (s *Store) CreateItem(ctx context.Context, it *models.Item) (int64, error) { func (s *Store) CreateItem(ctx context.Context, it *models.Item) (int64, error) {

View File

@@ -6,8 +6,18 @@ CREATE TABLE IF NOT EXISTS users (
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user', role TEXT NOT NULL DEFAULT 'user',
email TEXT,
-- auth_source records how the row was provisioned: 'local' (password
-- login / setup) or 'forward' (Traefik forward-auth from Authentik). For
-- forward rows the role is re-synced from the IdP group on every request.
auth_source TEXT NOT NULL DEFAULT 'local',
email_deal_alerts INTEGER NOT NULL DEFAULT 0,
email_weekly_digest INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
-- The email column and its unique index are created in db.go AFTER the
-- addColumnIfMissing migrations, so existing databases (whose users table
-- predates the email column) gain the column before the index references it.
CREATE TABLE IF NOT EXISTS items ( CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -92,7 +102,20 @@ INSERT OR IGNORE INTO settings (key, value) VALUES
('ntfy_base_url', ''), ('ntfy_base_url', ''),
('ntfy_default_topic', 'veola'), ('ntfy_default_topic', 'veola'),
('global_poll_interval_minutes', '60'), ('global_poll_interval_minutes', '60'),
('match_confidence_threshold', '0.6'); ('match_confidence_threshold', '0.6'),
('apify_cost_per_call', '0.00'),
('monthly_budget_usd', '0.00'),
('resend_api_key', ''),
('resend_from', '');
-- apify_api_usage tracks Apify actor runs per UTC day so the operator (and
-- every signed-in user) can see consumption and an estimated spend. Apify
-- bills per actor run / per result, which Veola cannot read back exactly, so
-- this is a call counter multiplied by a configurable per-call cost estimate.
CREATE TABLE IF NOT EXISTS apify_api_usage (
usage_date TEXT PRIMARY KEY,
call_count INTEGER NOT NULL DEFAULT 0
);
-- ebay_api_usage tracks Browse API calls per day so Veola can surface -- ebay_api_usage tracks Browse API calls per day so Veola can surface
-- consumption and halt polling before the developer keyset's daily call -- consumption and halt polling before the developer keyset's daily call

97
internal/email/client.go Normal file
View File

@@ -0,0 +1,97 @@
// Package email sends transactional mail through Resend (https://resend.com).
// Veola uses it for opt-in deal alerts and the weekly digest. Credentials are
// resolved at call time (settings table overriding config.toml), mirroring how
// the ntfy and Apify clients are built per-send.
package email
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const apiURL = "https://api.resend.com/emails"
// Client is a thin wrapper around the Resend send endpoint.
type Client struct {
APIKey string
From string
HTTP *http.Client
}
// New returns a Resend client. apiKey and from are typically resolved from
// settings (falling back to config.toml) by the caller.
func New(apiKey, from string) *Client {
return &Client{
APIKey: strings.TrimSpace(apiKey),
From: strings.TrimSpace(from),
HTTP: &http.Client{Timeout: 15 * time.Second},
}
}
// Configured reports whether the client has enough to attempt a send.
func (c *Client) Configured() bool {
return c.APIKey != "" && c.From != ""
}
// Message is a single outbound email. HTML is the rendered body; Text is an
// optional plain-text alternative.
type Message struct {
To string
Subject string
HTML string
Text string
}
type sendRequest struct {
From string `json:"from"`
To []string `json:"to"`
Subject string `json:"subject"`
HTML string `json:"html,omitempty"`
Text string `json:"text,omitempty"`
}
// Send delivers one message. Returns an error if the client is unconfigured or
// Resend rejects the request.
func (c *Client) Send(ctx context.Context, m Message) error {
if c.APIKey == "" {
return fmt.Errorf("resend api key not configured")
}
if c.From == "" {
return fmt.Errorf("resend from address not configured")
}
if strings.TrimSpace(m.To) == "" {
return fmt.Errorf("recipient address required")
}
body, err := json.Marshal(sendRequest{
From: c.From,
To: []string{m.To},
Subject: m.Subject,
HTML: m.HTML,
Text: m.Text,
})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.APIKey)
resp, err := c.HTTP.Do(req)
if err != nil {
return fmt.Errorf("resend POST: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("resend returned %d: %s", resp.StatusCode, string(b))
}
return nil
}

View File

@@ -63,11 +63,19 @@ func (a *App) dashboardData(r *http.Request) (templates.DashboardData, error) {
if err != nil { if err != nil {
return templates.DashboardData{}, err return templates.DashboardData{}, err
} }
apifyToday, apifyMonth, costPerCall, monthlyBudget := a.Scheduler.ApifyUsage(r.Context())
ebayUsed, ebayLimit := a.Scheduler.EbayUsage(r.Context())
return templates.DashboardData{ return templates.DashboardData{
Page: a.page(r, "Dashboard", "dashboard"), Page: a.page(r, "Dashboard", "dashboard"),
Stats: stats, Stats: stats,
RecentResults: rrs, RecentResults: rrs,
RecentAlerts: alerts, RecentAlerts: alerts,
ApifyToday: apifyToday,
ApifyMonth: apifyMonth,
ApifyCostPerCall: costPerCall,
MonthlyBudget: monthlyBudget,
EbayToday: ebayUsed,
EbayLimit: ebayLimit,
}, nil }, nil
} }

View File

@@ -49,6 +49,10 @@ func (a *App) Routes() http.Handler {
// RealIP rewrites RemoteAddr from X-Forwarded-For — safe only because // RealIP rewrites RemoteAddr from X-Forwarded-For — safe only because
// Veola is expected to sit behind a trusted proxy (Traefik) that sets // Veola is expected to sit behind a trusted proxy (Traefik) that sets
// it; Traefik must be configured to strip client-supplied values. // it; Traefik must be configured to strip client-supplied values.
// CaptureDirectIP must run before RealIP: forward-auth trusts identity
// headers only from the real connecting peer, which RealIP would otherwise
// overwrite with the proxy-supplied X-Forwarded-For.
r.Use(auth.CaptureDirectIP)
r.Use(middleware.RealIP) r.Use(middleware.RealIP)
r.Use(middleware.Recoverer) r.Use(middleware.Recoverer)
r.Use(securityHeaders) r.Use(securityHeaders)
@@ -70,6 +74,10 @@ func (a *App) Routes() http.Handler {
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(a.Auth.Sessions.LoadAndSave) r.Use(a.Auth.Sessions.LoadAndSave)
r.Use(a.Auth.LoadUser) r.Use(a.Auth.LoadUser)
// ForwardAuth runs after LoadUser so a trusted proxy's identity headers
// supersede any stale local session. No-op when forward-auth is off or
// the peer is untrusted.
r.Use(a.Auth.ForwardAuth)
r.Use(a.setupGate) r.Use(a.setupGate)
// Public auth pages. // Public auth pages.
@@ -106,6 +114,8 @@ func (a *App) Routes() http.Handler {
r.With(a.Auth.CSRFProtect).Post("/settings/test-ntfy", a.PostTestNtfy) r.With(a.Auth.CSRFProtect).Post("/settings/test-ntfy", a.PostTestNtfy)
r.With(a.Auth.CSRFProtect).Post("/settings/test-apify", a.PostTestApify) r.With(a.Auth.CSRFProtect).Post("/settings/test-apify", a.PostTestApify)
r.With(a.Auth.CSRFProtect).Post("/settings/test-ebay", a.PostTestEbay) r.With(a.Auth.CSRFProtect).Post("/settings/test-ebay", a.PostTestEbay)
r.With(a.Auth.CSRFProtect).Post("/settings/test-resend", a.PostTestResend)
r.With(a.Auth.CSRFProtect).Post("/settings/email", a.PostEmailPrefs)
r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users", a.PostCreateUser) r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users", a.PostCreateUser)
r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users/{id}/delete", a.PostDeleteUser) r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users/{id}/delete", a.PostDeleteUser)
r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users/{id}/reset-password", a.PostResetPassword) r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users/{id}/reset-password", a.PostResetPassword)

View File

@@ -16,6 +16,10 @@ import (
"veola/templates" "veola/templates"
) )
// defaultPollIntervalMinutes is the poll cadence pre-selected on the add-item
// form: 12 hours, chosen to keep Apify spend low by default.
const defaultPollIntervalMinutes = 720
func (a *App) GetItems(w http.ResponseWriter, r *http.Request) { func (a *App) GetItems(w http.ResponseWriter, r *http.Request) {
cat := r.URL.Query().Get("category") cat := r.URL.Query().Get("category")
all, err := a.Store.ListItems(r.Context()) all, err := a.Store.ListItems(r.Context())
@@ -56,8 +60,11 @@ func (a *App) GetNewItem(w http.ResponseWriter, r *http.Request) {
IsEdit: false, IsEdit: false,
Categories: cats, Categories: cats,
Item: models.Item{ Item: models.Item{
NtfyPriority: "default", NtfyPriority: "default",
PollIntervalMinutes: a.Cfg.Scheduler.GlobalPollIntervalMinutes, // Default new items to a 12-hour refresh. Apify runs cost credits
// and most watched items do not move hour-to-hour, so a slow
// default keeps spend down; the operator can dial it tighter.
PollIntervalMinutes: defaultPollIntervalMinutes,
Marketplaces: []string{"ebay.com"}, Marketplaces: []string{"ebay.com"},
ListingType: "all", ListingType: "all",
}, },

View File

@@ -9,6 +9,7 @@ import (
"veola/internal/apify" "veola/internal/apify"
"veola/internal/auth" "veola/internal/auth"
"veola/internal/ebay" "veola/internal/ebay"
"veola/internal/email"
"veola/internal/models" "veola/internal/models"
"veola/internal/ntfy" "veola/internal/ntfy"
"veola/templates" "veola/templates"
@@ -24,6 +25,10 @@ var settingsKeys = []string{
"ntfy_token", "ntfy_token",
"global_poll_interval_minutes", "global_poll_interval_minutes",
"match_confidence_threshold", "match_confidence_threshold",
"apify_cost_per_call",
"monthly_budget_usd",
"resend_api_key",
"resend_from",
} }
// secretSettingsKeys are credential fields. Their values are never rendered // secretSettingsKeys are credential fields. Their values are never rendered
@@ -34,6 +39,7 @@ var secretSettingsKeys = map[string]bool{
"ebay_client_id": true, "ebay_client_id": true,
"ebay_client_secret": true, "ebay_client_secret": true,
"ntfy_token": true, "ntfy_token": true,
"resend_api_key": true,
} }
// credentialStatus reports, per secret key, whether a value is saved in the // credentialStatus reports, per secret key, whether a value is saved in the
@@ -45,6 +51,7 @@ func (a *App) credentialStatus(values map[string]string) map[string]string {
"ebay_client_id": a.Cfg.Ebay.ClientID, "ebay_client_id": a.Cfg.Ebay.ClientID,
"ebay_client_secret": a.Cfg.Ebay.ClientSecret, "ebay_client_secret": a.Cfg.Ebay.ClientSecret,
"ntfy_token": "", "ntfy_token": "",
"resend_api_key": a.Cfg.Resend.APIKey,
} }
status := make(map[string]string, len(secretSettingsKeys)) status := make(map[string]string, len(secretSettingsKeys))
for k := range secretSettingsKeys { for k := range secretSettingsKeys {
@@ -71,6 +78,7 @@ func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) {
users, _ := a.Store.ListUsers(r.Context()) users, _ := a.Store.ListUsers(r.Context())
cur := auth.CurrentUserFromRequest(r) cur := auth.CurrentUserFromRequest(r)
ebayUsed, ebayLimit := a.Scheduler.EbayUsage(r.Context()) ebayUsed, ebayLimit := a.Scheduler.EbayUsage(r.Context())
apifyToday, apifyMonth, costPerCall, monthlyBudget := a.Scheduler.ApifyUsage(r.Context())
return templates.SettingsData{ return templates.SettingsData{
Page: a.page(r, "Settings", "settings"), Page: a.page(r, "Settings", "settings"),
Values: values, Values: values,
@@ -79,9 +87,103 @@ func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) {
Users: users, Users: users,
EbayUsedToday: ebayUsed, EbayUsedToday: ebayUsed,
EbayDailyLimit: ebayLimit, EbayDailyLimit: ebayLimit,
ApifyToday: apifyToday,
ApifyMonth: apifyMonth,
ApifyCostPerCall: costPerCall,
MonthlyBudget: monthlyBudget,
}, nil }, nil
} }
// PostEmailPrefs lets the signed-in user set their own notification email and
// opt into deal-alert / weekly-digest email. Available to every user, not just
// admins (each user owns their own delivery prefs).
func (a *App) PostEmailPrefs(w http.ResponseWriter, r *http.Request) {
cur := auth.CurrentUserFromRequest(r)
if cur == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
email := strings.TrimSpace(r.PostFormValue("email"))
dealAlerts := r.PostFormValue("email_deal_alerts") == "1"
weeklyDigest := r.PostFormValue("email_weekly_digest") == "1"
d, err := a.settingsData(r)
if err != nil {
a.serverError(w, r, err)
return
}
if email == "" && (dealAlerts || weeklyDigest) {
d.EmailError = "Set an email address to receive email notifications."
render(w, r, templates.Settings(d))
return
}
if err := a.Store.UpdateUserEmailPrefs(r.Context(), cur.ID, email, dealAlerts, weeklyDigest); err != nil {
a.serverError(w, r, err)
return
}
// Reflect the saved values immediately (CurrentUser in d predates the write).
d, err = a.settingsData(r)
if err != nil {
a.serverError(w, r, err)
return
}
if d.Page.CurrentUser != nil {
d.Page.CurrentUser.Email = email
d.Page.CurrentUser.EmailDealAlerts = dealAlerts
d.Page.CurrentUser.EmailWeeklyDigest = weeklyDigest
}
d.EmailMsg = "Email preferences saved."
render(w, r, templates.Settings(d))
}
// PostTestResend sends a test email to the admin's own address to verify the
// Resend configuration.
func (a *App) PostTestResend(w http.ResponseWriter, r *http.Request) {
cur := auth.CurrentUserFromRequest(r)
if cur == nil || cur.Role != models.RoleAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
d, err := a.settingsData(r)
if err != nil {
a.serverError(w, r, err)
return
}
apiKey := strings.TrimSpace(d.Values["resend_api_key"])
if apiKey == "" {
apiKey = a.Cfg.Resend.APIKey
}
from := strings.TrimSpace(d.Values["resend_from"])
if from == "" {
from = a.Cfg.Resend.From
}
to := ""
if cur != nil {
to = cur.Email
}
if to == "" {
d.TestResendOK = "Set your notification email below first, then test."
render(w, r, templates.Settings(d))
return
}
client := email.New(apiKey, from)
if err := client.Send(r.Context(), email.Message{
To: to,
Subject: "Veola test email",
HTML: "<p>Test email from Veola settings. Resend is configured correctly.</p>",
Text: "Test email from Veola settings. Resend is configured correctly.",
}); err != nil {
d.TestResendOK = "Resend test failed: " + err.Error()
} else {
d.TestResendOK = "Sent a test email to " + to + "."
}
render(w, r, templates.Settings(d))
}
func (a *App) GetSettings(w http.ResponseWriter, r *http.Request) { func (a *App) GetSettings(w http.ResponseWriter, r *http.Request) {
d, err := a.settingsData(r) d, err := a.settingsData(r)
if err != nil { if err != nil {

View File

@@ -17,7 +17,15 @@ type User struct {
Username string Username string
PasswordHash string PasswordHash string
Role Role Role Role
CreatedAt time.Time // Email is the address Resend mail is sent to and the identity key used
// to match a forward-auth (Authentik) identity to a local row. Optional
// for password-login users.
Email string
// AuthSource is "local" (password) or "forward" (Traefik forward-auth).
AuthSource string
EmailDealAlerts bool
EmailWeeklyDigest bool
CreatedAt time.Time
} }
type Item struct { type Item struct {

171
internal/scheduler/email.go Normal file
View File

@@ -0,0 +1,171 @@
package scheduler
import (
"context"
"fmt"
"html"
"log/slog"
"strings"
"veola/internal/apify"
"veola/internal/db"
"veola/internal/email"
"veola/internal/models"
)
// emailClient builds a Resend client with credentials resolved from settings,
// falling back to config.toml. Returns a client even when unconfigured; callers
// check Configured() to skip silently.
func (s *Scheduler) emailClient(ctx context.Context) *email.Client {
apiKey := s.cfg.Resend.APIKey
if v, _ := s.store.GetSetting(ctx, "resend_api_key"); v != "" {
apiKey = v
}
from := s.cfg.Resend.From
if v, _ := s.store.GetSetting(ctx, "resend_from"); v != "" {
from = v
}
return email.New(apiKey, from)
}
// sendDealEmails mails a deal alert to every user who opted into deal-alert
// email. Entirely best-effort: an unconfigured Resend account or a per-user
// send failure is logged and swallowed.
func (s *Scheduler) sendDealEmails(ctx context.Context, it models.Item, r apify.UnifiedResult) {
client := s.emailClient(ctx)
if !client.Configured() {
return
}
recipients, err := s.store.UsersWithDealAlerts(ctx)
if err != nil {
slog.Error("load deal-alert recipients failed", "err", err)
return
}
if len(recipients) == 0 {
return
}
subject := fmt.Sprintf("Veola deal: %s", it.Name)
price := fmt.Sprintf("%s%.2f", currencyPrefix(r.Currency), r.Price)
target := ""
if it.TargetPrice != nil {
target = fmt.Sprintf("%s%.2f", currencyPrefix(r.Currency), *it.TargetPrice)
}
body := dealEmailHTML(it.Name, r.Title, r.Store, price, target, r.URL)
text := dealEmailText(it.Name, r.Title, r.Store, price, target, r.URL)
for _, u := range recipients {
if err := client.Send(ctx, email.Message{
To: u.Email, Subject: subject, HTML: body, Text: text,
}); err != nil {
slog.Error("deal email send failed", "to", u.Email, "err", err)
}
}
}
// RunWeeklyDigest builds and mails the weekly digest to every opted-in user.
// Public so it can be triggered manually as well as on the cron schedule.
func (s *Scheduler) RunWeeklyDigest(ctx context.Context) {
client := s.emailClient(ctx)
if !client.Configured() {
return
}
recipients, err := s.store.UsersWithWeeklyDigest(ctx)
if err != nil {
slog.Error("load digest recipients failed", "err", err)
return
}
if len(recipients) == 0 {
return
}
items, err := s.store.ListActiveItems(ctx)
if err != nil {
slog.Error("digest item load failed", "err", err)
return
}
stats, err := s.store.GetDashboardStats(ctx)
if err != nil {
slog.Error("digest stats load failed", "err", err)
return
}
subject := fmt.Sprintf("Veola weekly digest — %d items watched", len(items))
body := digestHTML(items, stats)
text := digestText(items, stats)
for _, u := range recipients {
if err := client.Send(ctx, email.Message{
To: u.Email, Subject: subject, HTML: body, Text: text,
}); err != nil {
slog.Error("digest email send failed", "to", u.Email, "err", err)
}
}
slog.Info("weekly digest sent", "recipients", len(recipients), "items", len(items))
}
func dealEmailHTML(itemName, title, store, price, target, url string) string {
var b strings.Builder
fmt.Fprintf(&b, `<div style="font-family:sans-serif">`)
fmt.Fprintf(&b, `<h2>Veola deal: %s</h2>`, html.EscapeString(itemName))
fmt.Fprintf(&b, `<p style="font-size:20px"><strong>%s</strong> at %s</p>`, html.EscapeString(price), html.EscapeString(store))
if target != "" {
fmt.Fprintf(&b, `<p>Your target: %s</p>`, html.EscapeString(target))
}
if title != "" {
fmt.Fprintf(&b, `<p>%s</p>`, html.EscapeString(title))
}
if url != "" {
fmt.Fprintf(&b, `<p><a href="%s">View listing</a></p>`, html.EscapeString(url))
}
b.WriteString(`</div>`)
return b.String()
}
func dealEmailText(itemName, title, store, price, target, url string) string {
var b strings.Builder
fmt.Fprintf(&b, "Veola deal: %s\n\n%s at %s\n", itemName, price, store)
if target != "" {
fmt.Fprintf(&b, "Your target: %s\n", target)
}
if title != "" {
fmt.Fprintf(&b, "%s\n", title)
}
if url != "" {
fmt.Fprintf(&b, "%s\n", url)
}
return b.String()
}
func digestHTML(items []models.Item, stats *db.DashboardStats) string {
var b strings.Builder
b.WriteString(`<div style="font-family:sans-serif">`)
b.WriteString(`<h2>Veola weekly digest</h2>`)
if stats != nil {
fmt.Fprintf(&b, `<p>%d active items. Estimated money saved so far: $%.2f.</p>`,
stats.ActiveItems, stats.MoneySaved)
}
b.WriteString(`<table style="border-collapse:collapse" cellpadding="6">`)
b.WriteString(`<tr><th align="left">Item</th><th align="left">Best price</th><th align="left">Where</th></tr>`)
for _, it := range items {
best := "—"
if it.BestPrice != nil {
best = fmt.Sprintf("%s%.2f", currencyPrefix(it.BestPriceCurrency), *it.BestPrice)
}
fmt.Fprintf(&b, `<tr><td>%s</td><td>%s</td><td>%s</td></tr>`,
html.EscapeString(it.Name), html.EscapeString(best), html.EscapeString(it.BestPriceStore))
}
b.WriteString(`</table></div>`)
return b.String()
}
func digestText(items []models.Item, stats *db.DashboardStats) string {
var b strings.Builder
b.WriteString("Veola weekly digest\n\n")
if stats != nil {
fmt.Fprintf(&b, "%d active items. Estimated money saved so far: $%.2f.\n\n", stats.ActiveItems, stats.MoneySaved)
}
for _, it := range items {
best := "no price yet"
if it.BestPrice != nil {
best = fmt.Sprintf("%s%.2f", currencyPrefix(it.BestPriceCurrency), *it.BestPrice)
}
fmt.Fprintf(&b, "- %s: %s %s\n", it.Name, best, it.BestPriceStore)
}
return b.String()
}

View File

@@ -2,6 +2,7 @@ package scheduler
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"strconv" "strconv"
@@ -64,6 +65,15 @@ func (s *Scheduler) Start(ctx context.Context) error {
for _, it := range items { for _, it := range items {
s.register(it) s.register(it)
} }
// Weekly digest: Monday 09:00 server-local. Best-effort inside
// RunWeeklyDigest (no-op when Resend is unconfigured or nobody opted in).
if _, err := s.cron.AddFunc("0 9 * * 1", func() {
ctx, cancel := context.WithTimeout(s.rootCtx, 5*time.Minute)
defer cancel()
s.RunWeeklyDigest(ctx)
}); err != nil {
slog.Error("weekly digest schedule failed", "err", err)
}
s.cron.Start() s.cron.Start()
slog.Info("scheduler started", "items", len(items)) slog.Info("scheduler started", "items", len(items))
return nil return nil
@@ -142,7 +152,6 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
s.recordError(ctx, it.ID, "no marketplaces configured for this item") s.recordError(ctx, it.ID, "no marketplaces configured for this item")
return return
} }
apifyClient := s.apifyClient(ctx)
var results []apify.UnifiedResult var results []apify.UnifiedResult
var errs []string var errs []string
successes := 0 successes := 0
@@ -176,7 +185,7 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
pcQueries = []string{""} pcQueries = []string{""}
} }
for _, q := range pcQueries { for _, q := range pcQueries {
pcRaw, err := apifyClient.Run(ctx, pcID, apify.PriceComparisonInput{ pcRaw, err := s.runApifyActor(ctx, pcID, apify.PriceComparisonInput{
Query: q, URL: it.URL, Query: q, URL: it.URL,
ProxyConfiguration: s.proxyConfig(), ProxyConfiguration: s.proxyConfig(),
}) })
@@ -238,6 +247,9 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
alerted = true alerted = true
alertsSent++ alertsSent++
} }
// Email opted-in users in addition to ntfy. Best-effort: a mail
// failure must not block result storage or the next alert.
s.sendDealEmails(ctx, it, r)
} }
price := r.Price price := r.Price
_, err = s.store.InsertResult(ctx, &models.Result{ _, err = s.store.InsertResult(ctx, &models.Result{
@@ -307,6 +319,37 @@ func (s *Scheduler) apifyClient(ctx context.Context) *apify.Client {
return apify.New(key) return apify.New(key)
} }
// runApifyActor runs an Apify actor and records the call against the daily
// usage counter that feeds the budget view. Every Apify-billed run in Veola
// goes through here so the count stays honest.
func (s *Scheduler) runApifyActor(ctx context.Context, actorID string, input any) ([]json.RawMessage, error) {
if err := s.store.IncrementApifyUsage(ctx); err != nil {
slog.Error("apify usage increment failed", "err", err)
}
return s.apifyClient(ctx).Run(ctx, actorID, input)
}
// ApifyUsage reports Apify run counts and the cost model used by the budget
// view. costPerCall and monthlyBudget come from settings, falling back to
// config.toml.
func (s *Scheduler) ApifyUsage(ctx context.Context) (today, month int, costPerCall, monthlyBudget float64) {
today, _ = s.store.ApifyUsageToday(ctx)
month, _ = s.store.ApifyUsageMonth(ctx)
costPerCall = s.cfg.Budget.CostPerApifyCall
if v, _ := s.store.GetSetting(ctx, "apify_cost_per_call"); v != "" {
if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
costPerCall = f
}
}
monthlyBudget = s.cfg.Budget.MonthlyBudgetUSD
if v, _ := s.store.GetSetting(ctx, "monthly_budget_usd"); v != "" {
if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
monthlyBudget = f
}
}
return today, month, costPerCall, monthlyBudget
}
// ebayClient returns the shared eBay client with credentials refreshed from // ebayClient returns the shared eBay client with credentials refreshed from
// settings (falling back to config.toml). The client caches its OAuth token // settings (falling back to config.toml). The client caches its OAuth token
// in memory, so the same instance is reused across polls; credentials are // in memory, so the same instance is reused across polls; credentials are
@@ -382,7 +425,7 @@ func (s *Scheduler) ExecutePlan(ctx context.Context, p actorPlan) ([]apify.Unifi
if p.actorID == "" { if p.actorID == "" {
return nil, fmt.Errorf("no actor configured for %s", p.marketplace) return nil, fmt.Errorf("no actor configured for %s", p.marketplace)
} }
raw, err := s.apifyClient(ctx).Run(ctx, p.actorID, p.input) raw, err := s.runApifyActor(ctx, p.actorID, p.input)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -677,7 +720,7 @@ func (s *Scheduler) seedSoldHistoryFor(ctx context.Context, it models.Item, quer
if actorID == "" { if actorID == "" {
return return
} }
raw, err := s.apifyClient(ctx).Run(ctx, actorID, apify.SoldListingInput{ raw, err := s.runApifyActor(ctx, actorID, apify.SoldListingInput{
Query: query, Marketplace: marketplace, MaxResults: 50, DaysBack: 30, Query: query, Marketplace: marketplace, MaxResults: 50, DaysBack: 30,
ProxyConfiguration: s.proxyConfig(), ProxyConfiguration: s.proxyConfig(),
}) })

12
main.go
View File

@@ -68,6 +68,18 @@ func run(configPath string) error {
if err != nil { if err != nil {
return fmt.Errorf("auth manager: %w", err) return fmt.Errorf("auth manager: %w", err)
} }
if cfg.Auth.ForwardAuthEnabled() {
fa, err := auth.NewForwardAuthConfig(
cfg.Auth.ForwardHeaderUser, cfg.Auth.ForwardHeaderEmail,
cfg.Auth.ForwardHeaderName, cfg.Auth.ForwardHeaderGroups,
cfg.Auth.AdminGroup, cfg.Auth.TrustedProxies,
)
if err != nil {
return fmt.Errorf("forward-auth config: %w", err)
}
authMgr.SetForwardAuth(fa)
slog.Info("forward-auth enabled", "trusted_proxies", cfg.Auth.TrustedProxies, "admin_group", cfg.Auth.AdminGroup)
}
apifyClient := apify.New(cfg.Apify.APIKey) apifyClient := apify.New(cfg.Apify.APIKey)
ntfyClient := ntfy.New(cfg.Ntfy.BaseURL) ntfyClient := ntfy.New(cfg.Ntfy.BaseURL)

View File

@@ -474,3 +474,18 @@ table.v-table tbody tr:hover td { background: rgba(0, 164, 228, 0.08); }
.v-just-swapped { animation: none; } .v-just-swapped { animation: none; }
.v-countdown-critical { animation: none; } .v-countdown-critical { animation: none; }
} }
/* Budget progress bar (dashboard). Styled cross-browser without inline styles
so it stays within the strict CSP (no style-src 'unsafe-inline'). */
.v-progress {
height: 0.6rem;
border-radius: 9999px;
overflow: hidden;
-webkit-appearance: none;
appearance: none;
border: 1px solid var(--border);
background: var(--surface-2);
}
.v-progress::-webkit-progress-bar { background: var(--surface-2); }
.v-progress::-webkit-progress-value { background: var(--accent); }
.v-progress::-moz-progress-bar { background: var(--accent); }

View File

@@ -13,6 +13,39 @@ type DashboardData struct {
Stats *db.DashboardStats Stats *db.DashboardStats
RecentResults []ResultRow RecentResults []ResultRow
RecentAlerts []AlertRow RecentAlerts []AlertRow
// Budget surface, shown to every signed-in user (Apify calls cost money).
ApifyToday int
ApifyMonth int
ApifyCostPerCall float64
MonthlyBudget float64
EbayToday int
EbayLimit int
}
// ApifyMonthCost is the estimated month-to-date Apify spend in USD.
func (d DashboardData) ApifyMonthCost() float64 {
return float64(d.ApifyMonth) * d.ApifyCostPerCall
}
// BudgetPercent is month-to-date spend as a percent of the monthly budget,
// clamped to [0,100]. Zero when no budget is set.
func (d DashboardData) BudgetPercent() int {
if d.MonthlyBudget <= 0 {
return 0
}
p := int(d.ApifyMonthCost() / d.MonthlyBudget * 100)
if p < 0 {
return 0
}
if p > 100 {
return 100
}
return p
}
// BudgetOver reports whether estimated spend has exceeded the monthly budget.
func (d DashboardData) BudgetOver() bool {
return d.MonthlyBudget > 0 && d.ApifyMonthCost() > d.MonthlyBudget
} }
type ResultRow struct { type ResultRow struct {
@@ -63,6 +96,50 @@ templ DashboardBody(d DashboardData) {
<div class="v-muted text-sm mt-1">across { fmt.Sprintf("%d", d.Stats.SavedItemCount) } items</div> <div class="v-muted text-sm mt-1">across { fmt.Sprintf("%d", d.Stats.SavedItemCount) } items</div>
</div> </div>
</div> </div>
<div class="v-card p-5 mb-6">
<div class="flex items-center justify-between mb-3">
<h2 class="font-semibold">API Usage and Budget</h2>
<span class="v-muted text-xs">Apify runs cost credits. Visible to everyone.</span>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<div class="v-muted text-xs uppercase tracking-wide">Apify (month)</div>
<div class="font-mono text-2xl mt-1">{ fmt.Sprintf("%d", d.ApifyMonth) }</div>
if d.ApifyCostPerCall > 0 {
<div class="v-muted text-xs mt-1">{ fmt.Sprintf("~$%.2f est.", d.ApifyMonthCost()) }</div>
}
</div>
<div>
<div class="v-muted text-xs uppercase tracking-wide">Apify (today)</div>
<div class="font-mono text-2xl mt-1">{ fmt.Sprintf("%d", d.ApifyToday) }</div>
</div>
<div>
<div class="v-muted text-xs uppercase tracking-wide">eBay (today)</div>
if d.EbayLimit > 0 {
<div class="font-mono text-2xl mt-1">{ fmt.Sprintf("%d / %d", d.EbayToday, d.EbayLimit) }</div>
} else {
<div class="font-mono text-2xl mt-1">{ fmt.Sprintf("%d", d.EbayToday) }</div>
}
</div>
if d.MonthlyBudget > 0 {
<div>
<div class="v-muted text-xs uppercase tracking-wide">Monthly Budget</div>
<div class="font-mono text-2xl mt-1">{ fmt.Sprintf("$%.0f", d.MonthlyBudget) }</div>
</div>
}
</div>
if d.MonthlyBudget > 0 {
<div class="mt-4">
<progress class="v-progress w-full" value={ fmt.Sprintf("%d", d.BudgetPercent()) } max="100"></progress>
<div class="flex justify-between text-xs mt-1">
<span class="v-muted">{ fmt.Sprintf("%d%% of budget used", d.BudgetPercent()) }</span>
if d.BudgetOver() {
<span class="v-price-target">Over budget</span>
}
</div>
</div>
}
</div>
<div class="grid md:grid-cols-2 gap-6"> <div class="grid md:grid-cols-2 gap-6">
<div class="v-card p-5"> <div class="v-card p-5">
<h2 class="font-semibold mb-3">Recent Results</h2> <h2 class="font-semibold mb-3">Recent Results</h2>

View File

@@ -21,6 +21,39 @@ type DashboardData struct {
Stats *db.DashboardStats Stats *db.DashboardStats
RecentResults []ResultRow RecentResults []ResultRow
RecentAlerts []AlertRow RecentAlerts []AlertRow
// Budget surface, shown to every signed-in user (Apify calls cost money).
ApifyToday int
ApifyMonth int
ApifyCostPerCall float64
MonthlyBudget float64
EbayToday int
EbayLimit int
}
// ApifyMonthCost is the estimated month-to-date Apify spend in USD.
func (d DashboardData) ApifyMonthCost() float64 {
return float64(d.ApifyMonth) * d.ApifyCostPerCall
}
// BudgetPercent is month-to-date spend as a percent of the monthly budget,
// clamped to [0,100]. Zero when no budget is set.
func (d DashboardData) BudgetPercent() int {
if d.MonthlyBudget <= 0 {
return 0
}
p := int(d.ApifyMonthCost() / d.MonthlyBudget * 100)
if p < 0 {
return 0
}
if p > 100 {
return 100
}
return p
}
// BudgetOver reports whether estimated spend has exceeded the monthly budget.
func (d DashboardData) BudgetOver() bool {
return d.MonthlyBudget > 0 && d.ApifyMonthCost() > d.MonthlyBudget
} }
type ResultRow struct { type ResultRow struct {
@@ -95,7 +128,7 @@ func DashboardBody(d DashboardData) templ.Component {
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.2f", d.Stats.PotentialSpend)) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.2f", d.Stats.PotentialSpend))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 54, Col: 100} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 87, Col: 100}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -108,7 +141,7 @@ func DashboardBody(d DashboardData) templ.Component {
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.Stats.PricedItemCount)) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.Stats.PricedItemCount))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 55, Col: 89} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 88, Col: 89}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -126,7 +159,7 @@ func DashboardBody(d DashboardData) templ.Component {
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d items not yet priced.", d.Stats.UnpricedCount)) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d items not yet priced.", d.Stats.UnpricedCount))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 57, Col: 103} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 90, Col: 103}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -144,7 +177,7 @@ func DashboardBody(d DashboardData) templ.Component {
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.2f", d.Stats.MoneySaved)) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.2f", d.Stats.MoneySaved))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 62, Col: 109} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 95, Col: 109}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -157,154 +190,313 @@ func DashboardBody(d DashboardData) templ.Component {
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.Stats.SavedItemCount)) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.Stats.SavedItemCount))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 63, Col: 88} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 96, Col: 88}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " items</div></div></div><div class=\"grid md:grid-cols-2 gap-6\"><div class=\"v-card p-5\"><h2 class=\"font-semibold mb-3\">Recent Results</h2>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " items</div></div></div><div class=\"v-card p-5 mb-6\"><div class=\"flex items-center justify-between mb-3\"><h2 class=\"font-semibold\">API Usage and Budget</h2><span class=\"v-muted text-xs\">Apify runs cost credits. Visible to everyone.</span></div><div class=\"grid grid-cols-2 md:grid-cols-4 gap-4\"><div><div class=\"v-muted text-xs uppercase tracking-wide\">Apify (month)</div><div class=\"font-mono text-2xl mt-1\">")
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", d.ApifyMonth))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 107, Col: 75}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.ApifyCostPerCall > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"v-muted text-xs mt-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("~$%.2f est.", d.ApifyMonthCost()))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 109, Col: 88}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div><div><div class=\"v-muted text-xs uppercase tracking-wide\">Apify (today)</div><div class=\"font-mono text-2xl mt-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.ApifyToday))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 114, Col: 75}
}
_, 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, 14, "</div></div><div><div class=\"v-muted text-xs uppercase tracking-wide\">eBay (today)</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.EbayLimit > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<div class=\"font-mono text-2xl mt-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d / %d", d.EbayToday, d.EbayLimit))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 119, Col: 93}
}
_, 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, 16, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"font-mono text-2xl mt-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.EbayToday))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 121, Col: 75}
}
_, 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, 18, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.MonthlyBudget > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div><div class=\"v-muted text-xs uppercase tracking-wide\">Monthly Budget</div><div class=\"font-mono text-2xl mt-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.0f", d.MonthlyBudget))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 127, Col: 82}
}
_, 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, 21, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.MonthlyBudget > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"mt-4\"><progress class=\"v-progress w-full\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", d.BudgetPercent()))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 133, Col: 85}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" max=\"100\"></progress><div class=\"flex justify-between text-xs mt-1\"><span class=\"v-muted\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d%% of budget used", d.BudgetPercent()))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 135, Col: 83}
}
_, 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, 25, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.BudgetOver() {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<span class=\"v-price-target\">Over budget</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div><div class=\"grid md:grid-cols-2 gap-6\"><div class=\"v-card p-5\"><h2 class=\"font-semibold mb-3\">Recent Results</h2>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if len(d.RecentResults) == 0 { if len(d.RecentResults) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"v-muted text-sm\">No results yet.</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div class=\"v-muted text-sm\">No results yet.</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<table class=\"v-table\"><thead><tr><th>Item</th><th>Price</th><th>Source</th><th>Found</th></tr></thead> <tbody>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<table class=\"v-table\"><thead><tr><th>Item</th><th>Price</th><th>Source</th><th>Found</th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, r := range d.RecentResults { for _, r := range d.RecentResults {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<tr><td><a href=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<tr><td><a href=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var7 templ.SafeURL var templ_7745c5c3_Var15 templ.SafeURL
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results", r.ItemID))) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results", r.ItemID)))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 79, Col: 80} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 156, Col: 80}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var16 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(r.ItemName) templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(r.ItemName)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 79, Col: 95} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 156, Col: 95}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</a></td><td class=\"font-mono\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</a></td><td class=\"font-mono\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var17 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(r.Price, r.Currency)) templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(r.Price, r.Currency))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 80, Col: 62} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 157, Col: 62}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</td><td>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</td><td>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var18 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(r.Source) templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(r.Source)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 81, Col: 23} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 158, Col: 23}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</td><td class=\"v-muted text-sm\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</td><td class=\"v-muted text-sm\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var19 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(r.FoundAt)) templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(r.FoundAt))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 82, Col: 59} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 159, Col: 59}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</td></tr>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</td></tr>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</tbody></table>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</tbody></table>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div><div class=\"v-card p-5\"><h2 class=\"font-semibold mb-3\">Recent Alerts</h2>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</div><div class=\"v-card p-5\"><h2 class=\"font-semibold mb-3\">Recent Alerts</h2>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if len(d.RecentAlerts) == 0 { if len(d.RecentAlerts) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div class=\"v-muted text-sm\">No alerts sent yet.</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<div class=\"v-muted text-sm\">No alerts sent yet.</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<ul class=\"space-y-2\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<ul class=\"space-y-2\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, a := range d.RecentAlerts { for _, a := range d.RecentAlerts {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<li class=\"flex justify-between items-center border-b border-white/10 pb-2\"><span>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<li class=\"flex justify-between items-center border-b border-white/10 pb-2\"><span>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var20 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(a.ItemName) templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(a.ItemName)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 97, Col: 26} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 174, Col: 26}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</span> <span class=\"font-mono v-price-target\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</span> <span class=\"font-mono v-price-target\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var21 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(a.Price, a.Currency)) templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(a.Price, a.Currency))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 98, Col: 78} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 175, Col: 78}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</span></li>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</span></li>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</ul>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</ul>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</div></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -328,61 +520,61 @@ func statCard(label, value, sub string) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var14 := templ.GetChildren(ctx) templ_7745c5c3_Var22 := templ.GetChildren(ctx)
if templ_7745c5c3_Var14 == nil { if templ_7745c5c3_Var22 == nil {
templ_7745c5c3_Var14 = templ.NopComponent templ_7745c5c3_Var22 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<div class=\"v-card p-4\"><div class=\"v-muted text-xs uppercase tracking-wide\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<div class=\"v-card p-4\"><div class=\"v-muted text-xs uppercase tracking-wide\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var23 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(label) templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(label)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 110, Col: 62} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 187, Col: 62}
} }
_, 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div><div class=\"font-mono text-3xl mt-1\" data-countup>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</div><div class=\"font-mono text-3xl mt-1\" data-countup>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var24 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(value) templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(value)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 111, Col: 59} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 188, Col: 59}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if sub != "" { if sub != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<div class=\"v-muted text-xs mt-1\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<div class=\"v-muted text-xs mt-1\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var17 string var templ_7745c5c3_Var25 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(sub) templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(sub)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 113, Col: 42} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 190, Col: 42}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -406,9 +598,9 @@ func Dashboard(d DashboardData) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var18 := templ.GetChildren(ctx) templ_7745c5c3_Var26 := templ.GetChildren(ctx)
if templ_7745c5c3_Var18 == nil { if templ_7745c5c3_Var26 == nil {
templ_7745c5c3_Var18 = templ.NopComponent templ_7745c5c3_Var26 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = Layout(d.Page, DashboardBody(d)).Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Layout(d.Page, DashboardBody(d)).Render(ctx, templ_7745c5c3_Buffer)

View File

@@ -18,12 +18,24 @@ type SettingsData struct {
TestNtfyOK string TestNtfyOK string
TestApifyOK string TestApifyOK string
TestEbayOK string TestEbayOK string
TestResendOK string
EbayUsedToday int EbayUsedToday int
EbayDailyLimit int EbayDailyLimit int
ApifyToday int
ApifyMonth int
ApifyCostPerCall float64
MonthlyBudget float64
PasswordMsg string PasswordMsg string
PasswordError string PasswordError string
UserMsg string UserMsg string
UserError string UserError string
EmailMsg string
EmailError string
}
// ApifyMonthCost is the estimated month-to-date Apify spend.
func (d SettingsData) ApifyMonthCost() float64 {
return float64(d.ApifyMonth) * d.ApifyCostPerCall
} }
// EbayLimitReached reports whether eBay polling is currently halted because // EbayLimitReached reports whether eBay polling is currently halted because
@@ -101,6 +113,26 @@ templ settingsBody(d SettingsData) {
<label class="v-label">Match Confidence Threshold</label> <label class="v-label">Match Confidence Threshold</label>
<input class="v-input font-mono" name="match_confidence_threshold" type="number" min="0" max="1" step="0.05" value={ d.Values["match_confidence_threshold"] }/> <input class="v-input font-mono" name="match_confidence_threshold" type="number" min="0" max="1" step="0.05" value={ d.Values["match_confidence_threshold"] }/>
</div> </div>
<div class="border-t border-white/10 pt-4">
<label class="v-label">Resend API Key</label>
<input class="v-input font-mono" type="password" name="resend_api_key" autocomplete="off" placeholder="re_... (leave blank to keep current value)"/>
@credStatus(d, "resend_api_key")
</div>
<div>
<label class="v-label">Resend From Address</label>
<input class="v-input" name="resend_from" value={ d.Values["resend_from"] } placeholder="Veola &lt;veola@yourdomain.com&gt;"/>
<div class="v-muted text-xs mt-1">Used for deal-alert and weekly-digest email. Domain must be verified in Resend.</div>
</div>
<div class="border-t border-white/10 pt-4">
<label class="v-label">Estimated Apify Cost per Call (USD)</label>
<input class="v-input font-mono" name="apify_cost_per_call" type="number" min="0" step="0.001" value={ d.Values["apify_cost_per_call"] }/>
<div class="v-muted text-xs mt-1">Apify does not report exact spend; this estimate drives the budget figures shown to everyone.</div>
</div>
<div>
<label class="v-label">Monthly Budget (USD)</label>
<input class="v-input font-mono" name="monthly_budget_usd" type="number" min="0" step="1" value={ d.Values["monthly_budget_usd"] }/>
<div class="v-muted text-xs mt-1">0 hides the budget bar. When set, the dashboard shows month-to-date spend against it.</div>
</div>
if !d.IsAdmin { if !d.IsAdmin {
<div class="v-muted text-sm">Read-only for non-admin users.</div> <div class="v-muted text-sm">Read-only for non-admin users.</div>
} else { } else {
@@ -109,6 +141,7 @@ templ settingsBody(d SettingsData) {
<button class="v-btn-ghost" type="submit" formaction="/settings/test-ntfy">Test Ntfy</button> <button class="v-btn-ghost" type="submit" formaction="/settings/test-ntfy">Test Ntfy</button>
<button class="v-btn-ghost" type="submit" formaction="/settings/test-apify">Test Apify</button> <button class="v-btn-ghost" type="submit" formaction="/settings/test-apify">Test Apify</button>
<button class="v-btn-ghost" type="submit" formaction="/settings/test-ebay">Test eBay</button> <button class="v-btn-ghost" type="submit" formaction="/settings/test-ebay">Test eBay</button>
<button class="v-btn-ghost" type="submit" formaction="/settings/test-resend">Test Resend</button>
</div> </div>
} }
</form> </form>
@@ -121,6 +154,18 @@ templ settingsBody(d SettingsData) {
if d.TestEbayOK != "" { if d.TestEbayOK != "" {
<div class="v-flash mt-3">{ d.TestEbayOK }</div> <div class="v-flash mt-3">{ d.TestEbayOK }</div>
} }
if d.TestResendOK != "" {
<div class="v-flash mt-3">{ d.TestResendOK }</div>
}
<div class="text-sm mt-4 border-t border-white/10 pt-4">
<span class="v-muted">Apify calls this month:</span>
<span class="font-mono">{ fmt.Sprintf("%d", d.ApifyMonth) }</span>
if d.ApifyCostPerCall > 0 {
<span class="v-muted ml-1">{ fmt.Sprintf("(~$%.2f est.)", d.ApifyMonthCost()) }</span>
}
<span class="v-muted ml-3">today:</span>
<span class="font-mono">{ fmt.Sprintf("%d", d.ApifyToday) }</span>
</div>
</section> </section>
<section class="v-card p-6"> <section class="v-card p-6">
@@ -149,6 +194,33 @@ templ settingsBody(d SettingsData) {
</form> </form>
</section> </section>
<section class="v-card p-6">
<h2 class="font-semibold mb-4">Email Notifications</h2>
if d.EmailError != "" {
<div class="v-flash-error">{ d.EmailError }</div>
}
if d.EmailMsg != "" {
<div class="v-flash">{ d.EmailMsg }</div>
}
<form method="post" action="/settings/email" class="space-y-4">
@CSRFInput(d.CSRFToken)
<div>
<label class="v-label">Notification Email</label>
<input class="v-input" type="email" name="email" value={ currentEmail(d) } placeholder="you@example.com"/>
<div class="v-muted text-xs mt-1">Where deal alerts and the weekly digest are sent. Requires Resend to be configured by an admin.</div>
</div>
<label class="flex items-center gap-2">
<input type="checkbox" name="email_deal_alerts" value="1" checked?={ currentDealAlerts(d) }/>
<span>Email me deal alerts</span>
</label>
<label class="flex items-center gap-2">
<input type="checkbox" name="email_weekly_digest" value="1" checked?={ currentWeeklyDigest(d) }/>
<span>Email me the weekly digest (Mondays)</span>
</label>
<button class="v-btn" type="submit">Save Preferences</button>
</form>
</section>
if d.IsAdmin { if d.IsAdmin {
<section class="v-card p-6"> <section class="v-card p-6">
<h2 class="font-semibold mb-4">Users</h2> <h2 class="font-semibold mb-4">Users</h2>
@@ -205,6 +277,23 @@ templ settingsBody(d SettingsData) {
</div> </div>
} }
// currentEmail and the two opt-in accessors read the signed-in user's saved
// email preferences off Page.CurrentUser, guarding the nil case.
func currentEmail(d SettingsData) string {
if d.CurrentUser == nil {
return ""
}
return d.CurrentUser.Email
}
func currentDealAlerts(d SettingsData) bool {
return d.CurrentUser != nil && d.CurrentUser.EmailDealAlerts
}
func currentWeeklyDigest(d SettingsData) bool {
return d.CurrentUser != nil && d.CurrentUser.EmailWeeklyDigest
}
templ Settings(d SettingsData) { templ Settings(d SettingsData) {
@Layout(d.Page, settingsBody(d)) @Layout(d.Page, settingsBody(d))
} }

View File

@@ -26,12 +26,24 @@ type SettingsData struct {
TestNtfyOK string TestNtfyOK string
TestApifyOK string TestApifyOK string
TestEbayOK string TestEbayOK string
TestResendOK string
EbayUsedToday int EbayUsedToday int
EbayDailyLimit int EbayDailyLimit int
ApifyToday int
ApifyMonth int
ApifyCostPerCall float64
MonthlyBudget float64
PasswordMsg string PasswordMsg string
PasswordError string PasswordError string
UserMsg string UserMsg string
UserError string UserError string
EmailMsg string
EmailError string
}
// ApifyMonthCost is the estimated month-to-date Apify spend.
func (d SettingsData) ApifyMonthCost() float64 {
return float64(d.ApifyMonth) * d.ApifyCostPerCall
} }
// EbayLimitReached reports whether eBay polling is currently halted because // EbayLimitReached reports whether eBay polling is currently halted because
@@ -71,7 +83,7 @@ func credStatus(d SettingsData, key string) templ.Component {
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(s) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(s)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 40, Col: 14} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 52, Col: 14}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -146,7 +158,7 @@ func settingsBody(d SettingsData) templ.Component {
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ebay_daily_call_limit"]) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ebay_daily_call_limit"])
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 70, Col: 122} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 82, Col: 122}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -164,7 +176,7 @@ func settingsBody(d SettingsData) templ.Component {
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d / %d", d.EbayUsedToday, d.EbayDailyLimit)) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d / %d", d.EbayUsedToday, d.EbayDailyLimit))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 75, Col: 89} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 87, Col: 89}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -182,7 +194,7 @@ func settingsBody(d SettingsData) templ.Component {
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d (uncapped)", d.EbayUsedToday)) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d (uncapped)", d.EbayUsedToday))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 77, Col: 77} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 89, Col: 77}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -206,7 +218,7 @@ func settingsBody(d SettingsData) templ.Component {
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ntfy_base_url"]) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ntfy_base_url"])
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 85, Col: 82} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 97, Col: 82}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -219,7 +231,7 @@ func settingsBody(d SettingsData) templ.Component {
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ntfy_default_topic"]) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ntfy_default_topic"])
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 89, Col: 92} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 101, Col: 92}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -240,7 +252,7 @@ func settingsBody(d SettingsData) templ.Component {
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["global_poll_interval_minutes"]) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["global_poll_interval_minutes"])
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 98, Col: 136} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 110, Col: 136}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -253,80 +265,89 @@ func settingsBody(d SettingsData) templ.Component {
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["match_confidence_threshold"]) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["match_confidence_threshold"])
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 102, Col: 160} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 114, Col: 160}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\"></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\"></div><div class=\"border-t border-white/10 pt-4\"><label class=\"v-label\">Resend API Key</label> <input class=\"v-input font-mono\" type=\"password\" name=\"resend_api_key\" autocomplete=\"off\" placeholder=\"re_... (leave blank to keep current value)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = credStatus(d, "resend_api_key").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div><div><label class=\"v-label\">Resend From Address</label> <input class=\"v-input\" name=\"resend_from\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["resend_from"])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 123, Col: 78}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" placeholder=\"Veola &lt;veola@yourdomain.com&gt;\"><div class=\"v-muted text-xs mt-1\">Used for deal-alert and weekly-digest email. Domain must be verified in Resend.</div></div><div class=\"border-t border-white/10 pt-4\"><label class=\"v-label\">Estimated Apify Cost per Call (USD)</label> <input class=\"v-input font-mono\" name=\"apify_cost_per_call\" type=\"number\" min=\"0\" step=\"0.001\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["apify_cost_per_call"])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 128, Col: 139}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\"><div class=\"v-muted text-xs mt-1\">Apify does not report exact spend; this estimate drives the budget figures shown to everyone.</div></div><div><label class=\"v-label\">Monthly Budget (USD)</label> <input class=\"v-input font-mono\" name=\"monthly_budget_usd\" type=\"number\" min=\"0\" step=\"1\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["monthly_budget_usd"])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 133, Col: 133}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\"><div class=\"v-muted text-xs mt-1\">0 hides the budget bar. When set, the dashboard shows month-to-date spend against it.</div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if !d.IsAdmin { if !d.IsAdmin {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div class=\"v-muted text-sm\">Read-only for non-admin users.</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div class=\"v-muted text-sm\">Read-only for non-admin users.</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<div class=\"flex items-center gap-3 pt-1\"><button class=\"v-btn\" type=\"submit\">Save</button> <button class=\"v-btn-ghost\" type=\"submit\" formaction=\"/settings/test-ntfy\">Test Ntfy</button> <button class=\"v-btn-ghost\" type=\"submit\" formaction=\"/settings/test-apify\">Test Apify</button> <button class=\"v-btn-ghost\" type=\"submit\" formaction=\"/settings/test-ebay\">Test eBay</button></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"flex items-center gap-3 pt-1\"><button class=\"v-btn\" type=\"submit\">Save</button> <button class=\"v-btn-ghost\" type=\"submit\" formaction=\"/settings/test-ntfy\">Test Ntfy</button> <button class=\"v-btn-ghost\" type=\"submit\" formaction=\"/settings/test-apify\">Test Apify</button> <button class=\"v-btn-ghost\" type=\"submit\" formaction=\"/settings/test-ebay\">Test eBay</button> <button class=\"v-btn-ghost\" type=\"submit\" formaction=\"/settings/test-resend\">Test Resend</button></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</form>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</form>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if d.TestNtfyOK != "" { if d.TestNtfyOK != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"v-flash mt-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
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: 116, Col: 44}
}
_, 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, 24, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if d.TestApifyOK != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"v-flash mt-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
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: 119, Col: 45}
}
_, 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, 26, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if d.TestEbayOK != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<div class=\"v-flash mt-3\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<div class=\"v-flash mt-3\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestEbayOK) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestNtfyOK)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 122, Col: 44} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 149, Col: 44}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -335,49 +356,155 @@ func settingsBody(d SettingsData) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</section><section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Change Password</h2>") if d.TestApifyOK != "" {
if templ_7745c5c3_Err != nil { templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div class=\"v-flash mt-3\">")
return templ_7745c5c3_Err
}
if d.PasswordError != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<div class=\"v-flash-error\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
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: 129, Col: 48}
}
_, 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, 31, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if d.PasswordMsg != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<div class=\"v-flash\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordMsg) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestApifyOK)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 132, Col: 40} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 152, 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_Var15))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<form method=\"post\" action=\"/settings/password\" class=\"space-y-4\">") if d.TestEbayOK != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<div class=\"v-flash mt-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestEbayOK)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 155, Col: 44}
}
_, 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, 32, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if d.TestResendOK != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<div class=\"v-flash mt-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestResendOK)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 158, Col: 46}
}
_, 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, 34, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<div class=\"text-sm mt-4 border-t border-white/10 pt-4\"><span class=\"v-muted\">Apify calls this month:</span> <span class=\"font-mono\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.ApifyMonth))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 162, Col: 61}
}
_, 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, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.ApifyCostPerCall > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<span class=\"v-muted ml-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("(~$%.2f est.)", d.ApifyMonthCost()))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 164, Col: 82}
}
_, 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, 38, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<span class=\"v-muted ml-3\">today:</span> <span class=\"font-mono\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.ApifyToday))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 167, Col: 61}
}
_, 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, 40, "</span></div></section><section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Change Password</h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.PasswordError != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<div class=\"v-flash-error\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordError)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 174, Col: 48}
}
_, 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, 42, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if d.PasswordMsg != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<div class=\"v-flash\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordMsg)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 177, Col: 40}
}
_, 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, 44, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<form method=\"post\" action=\"/settings/password\" class=\"space-y-4\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -385,173 +512,256 @@ func settingsBody(d SettingsData) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<div><label class=\"v-label\">Current Password</label> <input class=\"v-input\" type=\"password\" name=\"current_password\"></div><div><label class=\"v-label\">New Password</label> <input class=\"v-input\" type=\"password\" name=\"new_password\"></div><div><label class=\"v-label\">Confirm New Password</label> <input class=\"v-input\" type=\"password\" name=\"new_password_confirm\"></div><button class=\"v-btn\" type=\"submit\">Update Password</button></form></section>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<div><label class=\"v-label\">Current Password</label> <input class=\"v-input\" type=\"password\" name=\"current_password\"></div><div><label class=\"v-label\">New Password</label> <input class=\"v-input\" type=\"password\" name=\"new_password\"></div><div><label class=\"v-label\">Confirm New Password</label> <input class=\"v-input\" type=\"password\" name=\"new_password_confirm\"></div><button class=\"v-btn\" type=\"submit\">Update Password</button></form></section><section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Email Notifications</h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.EmailError != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<div class=\"v-flash-error\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(d.EmailError)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 200, Col: 45}
}
_, 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, 48, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if d.EmailMsg != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<div class=\"v-flash\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(d.EmailMsg)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 203, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<form method=\"post\" action=\"/settings/email\" class=\"space-y-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = CSRFInput(d.CSRFToken).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<div><label class=\"v-label\">Notification Email</label> <input class=\"v-input\" type=\"email\" name=\"email\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentEmail(d))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 209, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" placeholder=\"you@example.com\"><div class=\"v-muted text-xs mt-1\">Where deal alerts and the weekly digest are sent. Requires Resend to be configured by an admin.</div></div><label class=\"flex items-center gap-2\"><input type=\"checkbox\" name=\"email_deal_alerts\" value=\"1\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if currentDealAlerts(d) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " checked")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "> <span>Email me deal alerts</span></label> <label class=\"flex items-center gap-2\"><input type=\"checkbox\" name=\"email_weekly_digest\" value=\"1\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if currentWeeklyDigest(d) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " checked")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "> <span>Email me the weekly digest (Mondays)</span></label> <button class=\"v-btn\" type=\"submit\">Save Preferences</button></form></section>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if d.IsAdmin { if d.IsAdmin {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Users</h2>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Users</h2>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if d.UserError != "" { if d.UserError != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<div class=\"v-flash-error\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<div class=\"v-flash-error\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var26 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserError) templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserError)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 156, Col: 45} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 228, Col: 45}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
if d.UserMsg != "" { if d.UserMsg != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<div class=\"v-flash\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<div class=\"v-flash\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var17 string var templ_7745c5c3_Var27 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserMsg) templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserMsg)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 159, Col: 37} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 231, Col: 37}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<table class=\"v-table mb-4\"><thead><tr><th>Username</th><th>Role</th><th>Created</th><th></th></tr></thead> <tbody>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<table class=\"v-table mb-4\"><thead><tr><th>Username</th><th>Role</th><th>Created</th><th></th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, u := range d.Users { for _, u := range d.Users {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<tr><td>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<tr><td>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var18 string var templ_7745c5c3_Var28 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username) templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 166, Col: 24} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 238, Col: 24}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</td><td class=\"v-muted\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</td><td class=\"v-muted\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var19 string var templ_7745c5c3_Var29 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(string(u.Role)) templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(string(u.Role))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 167, Col: 44} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 239, Col: 44}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</td><td class=\"v-muted text-sm\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</td><td class=\"v-muted text-sm\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var20 string var templ_7745c5c3_Var30 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(u.CreatedAt.Format("2006-01-02")) templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(u.CreatedAt.Format("2006-01-02"))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 168, Col: 70} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 240, Col: 70}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</td><td class=\"text-right\"><form class=\"inline\" method=\"post\" action=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</td><td class=\"text-right\"><form class=\"inline\" method=\"post\" action=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var21 templ.SafeURL var templ_7745c5c3_Var31 templ.SafeURL
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/users/%d/reset-password", u.ID))) templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/users/%d/reset-password", u.ID)))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 170, Col: 113} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 242, Col: 113}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\"><input type=\"hidden\" name=\"csrf_token\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var22 string var templ_7745c5c3_Var32 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken) templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 171, Col: 68} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 243, Col: 68}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\"> <input type=\"password\" class=\"v-input inline-block max-w-[140px]\" name=\"new_password\" placeholder=\"new password\"> <button class=\"v-btn-ghost\" type=\"submit\">Reset</button></form><form class=\"inline\" method=\"post\" action=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\"> <input type=\"password\" class=\"v-input inline-block max-w-[140px]\" name=\"new_password\" placeholder=\"new password\"> <button class=\"v-btn-ghost\" type=\"submit\">Reset</button></form><form class=\"inline\" method=\"post\" action=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var23 templ.SafeURL var templ_7745c5c3_Var33 templ.SafeURL
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/users/%d/delete", u.ID))) templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/users/%d/delete", u.ID)))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 175, Col: 105} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 247, Col: 105}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" onsubmit=\"return confirm('Remove user?')\"><input type=\"hidden\" name=\"csrf_token\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\" onsubmit=\"return confirm('Remove user?')\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var24 string var templ_7745c5c3_Var34 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken) templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 176, Col: 68} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 248, Col: 68}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\"> <button class=\"v-btn-ghost\" type=\"submit\">Remove</button></form></td></tr>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\"> <button class=\"v-btn-ghost\" type=\"submit\">Remove</button></form></td></tr>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</tbody></table><form method=\"post\" action=\"/users\" class=\"grid md:grid-cols-4 gap-3 items-end\"><input type=\"hidden\" name=\"csrf_token\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</tbody></table><form method=\"post\" action=\"/users\" class=\"grid md:grid-cols-4 gap-3 items-end\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var25 string var templ_7745c5c3_Var35 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken) templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 185, Col: 63} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 257, Col: 63}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\"><div><label class=\"v-label\">Username</label> <input class=\"v-input\" name=\"username\"></div><div><label class=\"v-label\">Role</label> <select class=\"v-select\" name=\"role\"><option value=\"user\">user</option> <option value=\"admin\">admin</option></select></div><div><label class=\"v-label\">Initial Password</label> <input class=\"v-input\" type=\"password\" name=\"password\"></div><button class=\"v-btn\" type=\"submit\">Add User</button></form></section>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\"><div><label class=\"v-label\">Username</label> <input class=\"v-input\" name=\"username\"></div><div><label class=\"v-label\">Role</label> <select class=\"v-select\" name=\"role\"><option value=\"user\">user</option> <option value=\"admin\">admin</option></select></div><div><label class=\"v-label\">Initial Password</label> <input class=\"v-input\" type=\"password\" name=\"password\"></div><button class=\"v-btn\" type=\"submit\">Add User</button></form></section>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -559,6 +769,23 @@ func settingsBody(d SettingsData) templ.Component {
}) })
} }
// currentEmail and the two opt-in accessors read the signed-in user's saved
// email preferences off Page.CurrentUser, guarding the nil case.
func currentEmail(d SettingsData) string {
if d.CurrentUser == nil {
return ""
}
return d.CurrentUser.Email
}
func currentDealAlerts(d SettingsData) bool {
return d.CurrentUser != nil && d.CurrentUser.EmailDealAlerts
}
func currentWeeklyDigest(d SettingsData) bool {
return d.CurrentUser != nil && d.CurrentUser.EmailWeeklyDigest
}
func Settings(d SettingsData) templ.Component { func Settings(d SettingsData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 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 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
@@ -575,9 +802,9 @@ func Settings(d SettingsData) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var26 := templ.GetChildren(ctx) templ_7745c5c3_Var36 := templ.GetChildren(ctx)
if templ_7745c5c3_Var26 == nil { if templ_7745c5c3_Var36 == nil {
templ_7745c5c3_Var26 = templ.NopComponent templ_7745c5c3_Var36 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = Layout(d.Page, settingsBody(d)).Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Layout(d.Page, settingsBody(d)).Render(ctx, templ_7745c5c3_Buffer)