From 40acbcccdd9d43a20e29ff2dd792cba985a3749e Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 11 Jul 2026 11:28:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(offer):=20sort=20and=20align=20the=20?= =?UTF-8?q?=C2=A74.4=20price=20tables,=20style=20them=20for=20both=20theme?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - packs sorted by ascending rouble price; - values grouped hints-only -> no-ads-only -> no-ads+hints -> tournament (the tournament group is empty until such products become sellable), ascending chip price within each group; - price columns right-aligned (GFM "---:" separators); - tables span the full content width; the name column shrinks to its content and never wraps; - muted-but-visible cell borders on the dark theme, where the section rule colour blends into the background. --- backend/internal/payments/offer.go | 116 +++++++++++++++++++----- backend/internal/payments/offer_test.go | 44 +++++++++ docs/ARCHITECTURE.md | 7 +- ui/src/lib/offer.ts | 29 ++++++ 4 files changed, 171 insertions(+), 25 deletions(-) diff --git a/backend/internal/payments/offer.go b/backend/internal/payments/offer.go index ee48c50..0fc4cc0 100644 --- a/backend/internal/payments/offer.go +++ b/backend/internal/payments/offer.go @@ -1,8 +1,11 @@ package payments import ( + "cmp" "context" "fmt" + "math" + "slices" "strings" ) @@ -51,32 +54,46 @@ func (s *Service) buildOfferPricing(ctx context.Context) (string, error) { // projectOfferPricing renders the active catalog into the two offer tables. A chip pack (it carries // the chips atom) lists its per-rail money price; a value (no chips atom) lists its uniform chip -// price. Rows keep the catalog's creation order. An empty section is omitted. Amounts are rendered -// through [Money] so no floating point ever reaches the page (roubles show kopecks as "200.00", -// whole-unit rails as integers); a missing rail price shows an em dash. +// price. Packs are ordered by ascending rouble price; values are grouped by what they grant (hints +// only, then no-ads only, then no-ads + hints, then tournament — see [offerValueGroup]) and, within +// each group, ordered by ascending chip price. Price columns are right-aligned. An empty section is +// omitted. Amounts are rendered through [Money] so no floating point ever reaches the page (roubles +// show kopecks as "200.00", whole-unit rails as integers); a missing rail price shows an em dash. func projectOfferPricing(entries []catalogEntry) string { - var packs, values []string + var packs, values []catalogEntry for _, e := range entries { if isPackEntry(e) { - packs = append(packs, fmt.Sprintf("| %s | %s | %s | %s |", - offerCell(e.title), - offerPrice(e, string(SourceDirect), CurrencyRUB), - offerPrice(e, string(SourceVK), CurrencyVote), - offerPrice(e, string(SourceTelegram), CurrencyStar), - )) + packs = append(packs, e) } else { - values = append(values, fmt.Sprintf("| %s | %s |", - offerCell(e.title), offerPrice(e, "", CurrencyChip))) + values = append(values, e) } } + // Packs: ascending by the rouble price (the offer's base currency); a pack with no rouble price + // sorts last. Values: by group, then ascending chip price. Stable, so the catalog order breaks ties. + slices.SortStableFunc(packs, func(a, b catalogEntry) int { + return cmp.Compare(offerSortAmount(a, string(SourceDirect), CurrencyRUB), offerSortAmount(b, string(SourceDirect), CurrencyRUB)) + }) + slices.SortStableFunc(values, func(a, b catalogEntry) int { + if d := cmp.Compare(offerValueGroup(a), offerValueGroup(b)); d != 0 { + return d + } + return cmp.Compare(offerSortAmount(a, "", CurrencyChip), offerSortAmount(b, "", CurrencyChip)) + }) + var b strings.Builder if len(packs) > 0 { b.WriteString("Приобретение внутриигровой валюты «Фишка»:\n\n") b.WriteString("| Наименование | Рубли | Голоса в VK | Stars в Telegram |\n") - b.WriteString("| --- | --- | --- | --- |\n") - b.WriteString(strings.Join(packs, "\n")) - b.WriteString("\n") + b.WriteString("| --- | ---: | ---: | ---: |\n") + for _, e := range packs { + fmt.Fprintf(&b, "| %s | %s | %s | %s |\n", + offerCell(e.title), + offerPrice(e, string(SourceDirect), CurrencyRUB), + offerPrice(e, string(SourceVK), CurrencyVote), + offerPrice(e, string(SourceTelegram), CurrencyStar), + ) + } } if len(values) > 0 { if len(packs) > 0 { @@ -84,13 +101,54 @@ func projectOfferPricing(entries []catalogEntry) string { } b.WriteString("Использование внутриигровой валюты «Фишка»:\n\n") b.WriteString("| Наименование | «Фишки» |\n") - b.WriteString("| --- | --- |\n") - b.WriteString(strings.Join(values, "\n")) - b.WriteString("\n") + b.WriteString("| --- | ---: |\n") + for _, e := range values { + fmt.Fprintf(&b, "| %s | %s |\n", offerCell(e.title), offerPrice(e, "", CurrencyChip)) + } } return strings.TrimRight(b.String(), "\n") } +// offerValueGroup ranks a chip-priced value into the offer's usage groups, in listing order: hints +// only (0), no-ads only (1), no-ads + hints (2), then anything carrying the tournament atom (3). +// Tournament products are not sellable yet (validateProduct forbids an active one), so group 3 is +// empty today; the rank reserves their place for when the tournament economy lands. A value with no +// recognised benefit atom sorts after the known groups (defensive — the catalog shape forbids it). +func offerValueGroup(e catalogEntry) int { + hasHints, hasNoAds, hasTournament := false, false, false + for _, a := range e.atoms { + switch a.atomType { + case "hints": + hasHints = true + case "noads_days": + hasNoAds = true + case "tournament": + hasTournament = true + } + } + switch { + case hasTournament: + return 3 + case hasHints && hasNoAds: + return 2 + case hasNoAds: + return 1 + case hasHints: + return 0 + default: + return 4 + } +} + +// offerSortAmount returns the entry's price in the given method and currency for ordering, or +// math.MaxInt64 when it carries no such price, so a misconfigured row sorts last rather than leading. +func offerSortAmount(e catalogEntry, method string, cur Currency) int64 { + if amt, ok := offerAmount(e, method, cur); ok { + return amt + } + return math.MaxInt64 +} + // isPackEntry reports whether the catalog entry is a chip pack — it carries the chips atom (funds // chips with money) rather than being a chip-priced value. func isPackEntry(e catalogEntry) bool { @@ -105,16 +163,26 @@ func isPackEntry(e catalogEntry) bool { // offerPrice formats the entry's price for the given payment method and currency as a major-unit // string, or an em dash when the entry carries no such price. func offerPrice(e catalogEntry, method string, cur Currency) string { + amt, ok := offerAmount(e, method, cur) + if !ok { + return "—" + } + m, err := MoneyFromMinor(amt, cur) + if err != nil { + return "—" + } + return m.Major() +} + +// offerAmount returns the raw minor-unit amount of the entry's price for the payment method and +// currency, and whether such a price exists. +func offerAmount(e catalogEntry, method string, cur Currency) (int64, bool) { for _, pr := range e.prices { if pr.method == method && pr.currency == cur { - m, err := MoneyFromMinor(pr.amount, pr.currency) - if err != nil { - return "—" - } - return m.Major() + return pr.amount, true } } - return "—" + return 0, false } // offerCellReplacer neutralises every metacharacter of an admin-entered title so it renders as diff --git a/backend/internal/payments/offer_test.go b/backend/internal/payments/offer_test.go index 0173723..614fc65 100644 --- a/backend/internal/payments/offer_test.go +++ b/backend/internal/payments/offer_test.go @@ -31,8 +31,10 @@ func TestProjectOfferPricing(t *testing.T) { md := projectOfferPricing(entries) for _, want := range []string{ "| Наименование | Рубли | Голоса в VK | Stars в Telegram |", + "| --- | ---: | ---: | ---: |", // price columns right-aligned "| 50 «Фишек» | 200.00 | 30 | 100 |", "| Наименование | «Фишки» |", + "| --- | ---: |", "| 200 подсказок | 50 |", } { if !strings.Contains(md, want) { @@ -44,6 +46,48 @@ func TestProjectOfferPricing(t *testing.T) { } } +// TestProjectOfferPricingOrdering checks packs sort by ascending rouble price, and values sort by +// group (hints only → no-ads only → no-ads + hints) then ascending chip price within a group. +func TestProjectOfferPricingOrdering(t *testing.T) { + pack := func(title string, rub int64) catalogEntry { + return catalogEntry{ + id: uuid.New(), + title: title, + atoms: []atomQty{{atomType: atomChips, quantity: 1}}, + prices: []priceRow{{method: string(SourceDirect), currency: CurrencyRUB, amount: rub}}, + } + } + value := func(title string, chips int64, atoms ...string) catalogEntry { + e := catalogEntry{id: uuid.New(), title: title, prices: []priceRow{{method: "", currency: CurrencyChip, amount: chips}}} + for _, a := range atoms { + e.atoms = append(e.atoms, atomQty{atomType: a, quantity: 1}) + } + return e + } + // Deliberately out of order on input. + entries := []catalogEntry{ + pack("packDear", 30000), + pack("packCheap", 10000), + value("bundle", 500, "hints", "noads_days"), + value("adsOnly", 150, "noads_days"), + value("hintsBig", 200, "hints"), + value("hintsSmall", 50, "hints"), + } + md := projectOfferPricing(entries) + order := []string{"packCheap", "packDear", "hintsSmall", "hintsBig", "adsOnly", "bundle"} + last := -1 + for _, title := range order { + i := strings.Index(md, "| "+title+" |") + if i < 0 { + t.Fatalf("row %q missing:\n%s", title, md) + } + if i < last { + t.Errorf("row %q out of order (want sequence %v):\n%s", title, order, md) + } + last = i + } +} + // TestProjectOfferPricingMissingRailAndEscaping checks a pack missing a rail shows an em dash and a // title carrying a pipe is escaped so the table layout survives. func TestProjectOfferPricingMissingRailAndEscaping(t *testing.T) { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 948df6a..fbd26d4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -918,7 +918,12 @@ fetches the two catalog tables as markdown from the backend's internal backend projects the tables from the active catalog through `payments.Money` (no float reaches the page) and caches them in memory — built at boot, reprojected on any catalog edit — so a served render issues no query in the steady state and the page always reflects the current catalog without -a redeploy. A backend outage degrades `/offer/` to a 502 rather than a stale price list. +a redeploy. A backend outage degrades `/offer/` to a 502 rather than a stale price list. Packs are +ordered by ascending rouble price; values are grouped by what they grant — hints only, then no-ads +only, then no-ads + hints, then (reserved, empty today) products carrying the **tournament** atom, +which becomes a fourth group once the tournament economy makes them sellable — and ordered by +ascending chip price within each group. Product titles are admin input, so the projection escapes +them (HTML entities + markdown metacharacters) before they reach the un-sanitised renderer. The alphabet-on-the-wire transport does **not** touch this invariant: the live edge exchanges alphabet indices, but the persisted journal (and everything derived from it — diff --git a/ui/src/lib/offer.ts b/ui/src/lib/offer.ts index 4d0d969..396e4d3 100644 --- a/ui/src/lib/offer.ts +++ b/ui/src/lib/offer.ts @@ -31,6 +31,7 @@ export function renderOfferHtml(markdown: string): string { --text: #1a1c20; --accent: #2563eb; --rule: #e5e7eb; + --cell-border: #d1d5db; } @media (prefers-color-scheme: dark) { :root { @@ -38,6 +39,9 @@ export function renderOfferHtml(markdown: string): string { --text: #e7e9ee; --accent: #6ea8fe; --rule: #242832; + /* A muted grey — the section rules (--rule) vanish on the dark background, so table cells + get a slightly firmer border to stay legible without being loud. */ + --cell-border: #3a4250; } } * { @@ -77,6 +81,31 @@ export function renderOfferHtml(markdown: string): string { li { margin: 0.25em 0; } + /* The price tables (§4.4) span the full content width. */ + table { + width: 100%; + border-collapse: collapse; + margin: 0.75em 0 1.25em; + font-size: 0.95rem; + } + th, + td { + border: 1px solid var(--cell-border); + padding: 6px 10px; + } + /* The product-name column shrinks to its content and never wraps; the price columns share the + rest of the width (width:1% is the shrink-to-fit idiom paired with the table's width:100%). */ + th:first-child, + td:first-child { + width: 1%; + white-space: nowrap; + } + /* Right-aligned price columns. marked emits the alignment from the "---:" separator; this rule + keeps it applied whether that surfaces as an align attribute or an inline style. */ + th[align='right'], + td[align='right'] { + text-align: right; + }