Apply the dashboard's refined direction to the rest of the app: - Items: page-title header, product thumbnail (best-price image) beside each item name via the shared thumbSm component. - Results: refined headers; per-item and global tables get standardized thumbnails (global table gains a thumbnail column). - Settings: page-title + subtitle, section headings restyled. - Item form: page-title header. - Shared: thumbSm component + compact thumbnail CSS variant.
296 lines
8.5 KiB
Plaintext
296 lines
8.5 KiB
Plaintext
package templates
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"veola/internal/db"
|
|
"veola/internal/models"
|
|
)
|
|
|
|
type DashboardData struct {
|
|
Page
|
|
Stats *db.DashboardStats
|
|
RecentResults []ResultRow
|
|
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 {
|
|
ItemID int64
|
|
ItemName string
|
|
Title string
|
|
Price *float64
|
|
Currency string
|
|
Source string
|
|
URL string
|
|
ImageURL string
|
|
FoundAt time.Time
|
|
Alerted bool
|
|
}
|
|
|
|
type AlertRow struct {
|
|
ItemName string
|
|
Title string
|
|
Price *float64
|
|
Currency string
|
|
ImageURL string
|
|
FoundAt time.Time
|
|
}
|
|
|
|
// DashboardBody is the self-refreshing inner block. It is both the initial
|
|
// render target (inside Layout) and the response to /dashboard/refresh, so the
|
|
// hx-swap="outerHTML" on its root div replaces it with a fresh copy of itself.
|
|
// GetDashboardRefresh must render THIS, not Dashboard — rendering the full
|
|
// Layout would inject a nested page (and a duplicate sidebar) into the div.
|
|
templ DashboardBody(d DashboardData) {
|
|
<div hx-get="/dashboard/refresh" hx-trigger="every 60s" hx-swap="outerHTML">
|
|
<header class="mb-10">
|
|
<h1 class="v-page-title">Dashboard</h1>
|
|
<p class="v-page-sub">{ fmt.Sprintf("Watching %d items · %d active", d.Stats.TotalItems, d.Stats.ActiveItems) }</p>
|
|
</header>
|
|
<div class="grid md:grid-cols-2 gap-8 mb-8">
|
|
<div>
|
|
<div class="v-metric-label">Potential spend</div>
|
|
<div class="v-metric-value" data-countup>{ fmt.Sprintf("$%.2f", d.Stats.PotentialSpend) }</div>
|
|
<div class="v-metric-sub">
|
|
{ fmt.Sprintf("across %d priced items", d.Stats.PricedItemCount) }
|
|
if d.Stats.UnpricedCount > 0 {
|
|
{ fmt.Sprintf(" · %d not yet priced", d.Stats.UnpricedCount) }
|
|
}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="v-metric-label">Money saved</div>
|
|
<div class="v-metric-value v-price-deal" data-countup>{ fmt.Sprintf("$%.2f", d.Stats.MoneySaved) }</div>
|
|
<div class="v-metric-sub">{ fmt.Sprintf("across %d items, vs 30-day average", d.Stats.SavedItemCount) }</div>
|
|
</div>
|
|
</div>
|
|
<hr class="v-rule mb-8"/>
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-6 mb-8">
|
|
@statBlock("Total items", fmt.Sprintf("%d", d.Stats.TotalItems))
|
|
@statBlock("Active", fmt.Sprintf("%d", d.Stats.ActiveItems))
|
|
@statBlock("Results today", fmt.Sprintf("%d", d.Stats.ResultsToday))
|
|
@statBlock("Alerts today", fmt.Sprintf("%d", d.Stats.AlertsToday))
|
|
</div>
|
|
<hr class="v-rule mb-8"/>
|
|
<section class="mb-10">
|
|
<div class="flex items-baseline justify-between mb-5">
|
|
<h2 class="v-section-title">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-6">
|
|
<div>
|
|
<div class="v-stat-label">Apify (month)</div>
|
|
<div class="v-stat-value">{ fmt.Sprintf("%d", d.ApifyMonth) }</div>
|
|
if d.ApifyCostPerCall > 0 {
|
|
<div class="v-metric-sub">{ fmt.Sprintf("~$%.2f estimated", d.ApifyMonthCost()) }</div>
|
|
}
|
|
</div>
|
|
<div>
|
|
<div class="v-stat-label">Apify (today)</div>
|
|
<div class="v-stat-value">{ fmt.Sprintf("%d", d.ApifyToday) }</div>
|
|
</div>
|
|
<div>
|
|
<div class="v-stat-label">eBay (today)</div>
|
|
if d.EbayLimit > 0 {
|
|
<div class="v-stat-value">{ fmt.Sprintf("%d / %d", d.EbayToday, d.EbayLimit) }</div>
|
|
} else {
|
|
<div class="v-stat-value">{ fmt.Sprintf("%d", d.EbayToday) }</div>
|
|
}
|
|
</div>
|
|
if d.MonthlyBudget > 0 {
|
|
<div>
|
|
<div class="v-stat-label">Monthly budget</div>
|
|
<div class="v-stat-value">{ fmt.Sprintf("$%.0f", d.MonthlyBudget) }</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
if d.MonthlyBudget > 0 {
|
|
<div class="mt-6 max-w-md">
|
|
<progress class="v-progress w-full" value={ fmt.Sprintf("%d", d.BudgetPercent()) } max="100"></progress>
|
|
<div class="flex justify-between text-xs mt-2">
|
|
<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>
|
|
}
|
|
</section>
|
|
<hr class="v-rule mb-8"/>
|
|
<div class="grid md:grid-cols-2 gap-10">
|
|
<section>
|
|
<h2 class="v-section-title mb-4">Recent results</h2>
|
|
if len(d.RecentResults) == 0 {
|
|
<div class="v-empty">Nothing found yet. Veola is watching.</div>
|
|
} else {
|
|
<ul class="v-feed">
|
|
for _, r := range d.RecentResults {
|
|
<li class="v-feed-row">
|
|
<a href={ templ.SafeURL(fmt.Sprintf("/items/%d/results", r.ItemID)) }>
|
|
@thumb(r.ImageURL, r.Source)
|
|
</a>
|
|
<div class="min-w-0 flex-1">
|
|
<a class="v-feed-name" href={ templ.SafeURL(fmt.Sprintf("/items/%d/results", r.ItemID)) }>{ r.ItemName }</a>
|
|
<div class="v-feed-meta truncate">{ resultMeta(r) }</div>
|
|
</div>
|
|
<div class="font-mono v-feed-price">{ fmtPrice(r.Price, r.Currency) }</div>
|
|
</li>
|
|
}
|
|
</ul>
|
|
}
|
|
</section>
|
|
<section>
|
|
<h2 class="v-section-title mb-4">Recent alerts</h2>
|
|
if len(d.RecentAlerts) == 0 {
|
|
<div class="v-empty">No alerts sent. Nothing has hit target.</div>
|
|
} else {
|
|
<ul class="v-feed">
|
|
for _, a := range d.RecentAlerts {
|
|
<li class="v-feed-row">
|
|
@thumb(a.ImageURL, "")
|
|
<div class="min-w-0 flex-1">
|
|
<div class="v-feed-name truncate">{ a.ItemName }</div>
|
|
if a.Title != "" {
|
|
<div class="v-feed-meta truncate">{ a.Title }</div>
|
|
}
|
|
</div>
|
|
<div class="font-mono v-feed-price v-price-target">{ fmtPrice(a.Price, a.Currency) }</div>
|
|
</li>
|
|
}
|
|
</ul>
|
|
}
|
|
</section>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
// thumb renders a product thumbnail, falling back to a quiet placeholder
|
|
// (the source initial) when a listing has no image. Marketplace images come
|
|
// from an open set of CDNs, which the CSP's img-src allows.
|
|
templ thumb(url, source string) {
|
|
if url != "" {
|
|
<img class="v-thumb" src={ url } loading="lazy" alt="" referrerpolicy="no-referrer"/>
|
|
} else {
|
|
<div class="v-thumb v-thumb-empty">{ sourceInitial(source) }</div>
|
|
}
|
|
}
|
|
|
|
// thumbSm is the compact table-row variant of thumb (no source placeholder
|
|
// glyph — a plain quiet square when an item has no best-price image yet).
|
|
templ thumbSm(url string) {
|
|
if url != "" {
|
|
<img class="v-thumb v-thumb-sm" src={ url } loading="lazy" alt="" referrerpolicy="no-referrer"/>
|
|
} else {
|
|
<div class="v-thumb v-thumb-sm v-thumb-empty"></div>
|
|
}
|
|
}
|
|
|
|
func resultMeta(r ResultRow) string {
|
|
if r.Title != "" {
|
|
return r.Title
|
|
}
|
|
return r.Source + " · " + humanTime(r.FoundAt)
|
|
}
|
|
|
|
func sourceInitial(source string) string {
|
|
if source == "" {
|
|
return ""
|
|
}
|
|
return string([]rune(source)[:1])
|
|
}
|
|
|
|
templ statBlock(label, value string) {
|
|
<div>
|
|
<div class="v-stat-label">{ label }</div>
|
|
<div class="v-stat-value" data-countup>{ value }</div>
|
|
</div>
|
|
}
|
|
|
|
templ Dashboard(d DashboardData) {
|
|
@Layout(d.Page, DashboardBody(d))
|
|
}
|
|
|
|
// Helpers used by multiple templates.
|
|
|
|
func fmtPrice(p *float64, currency string) string {
|
|
if p == nil {
|
|
return "—"
|
|
}
|
|
sym := currencySymbol(currency)
|
|
return fmt.Sprintf("%s%.2f", sym, *p)
|
|
}
|
|
|
|
func currencySymbol(c string) string {
|
|
switch c {
|
|
case "USD", "":
|
|
return "$"
|
|
case "GBP":
|
|
return "£"
|
|
case "EUR":
|
|
return "€"
|
|
case "JPY":
|
|
return "¥"
|
|
default:
|
|
return c + " "
|
|
}
|
|
}
|
|
|
|
func humanTime(t time.Time) string {
|
|
if t.IsZero() {
|
|
return "—"
|
|
}
|
|
d := time.Since(t)
|
|
switch {
|
|
case d < time.Minute:
|
|
return "just now"
|
|
case d < time.Hour:
|
|
m := int(d.Minutes())
|
|
return fmt.Sprintf("%d minutes ago", m)
|
|
case d < 24*time.Hour:
|
|
h := int(d.Hours())
|
|
return fmt.Sprintf("%d hours ago", h)
|
|
case d < 30*24*time.Hour:
|
|
d2 := int(d.Hours() / 24)
|
|
return fmt.Sprintf("%d days ago", d2)
|
|
default:
|
|
return t.Format("2006-01-02")
|
|
}
|
|
}
|
|
|
|
// Used by item rendering.
|
|
var _ = models.Item{}
|