feat(offer): sort and align the §4.4 price tables, style them for both themes
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m47s

- 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.
This commit is contained in:
Ilia Denisov
2026-07-11 11:28:56 +02:00
parent b54371845f
commit 40acbcccdd
4 changed files with 171 additions and 25 deletions
+90 -22
View File
@@ -1,8 +1,11 @@
package payments package payments
import ( import (
"cmp"
"context" "context"
"fmt" "fmt"
"math"
"slices"
"strings" "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 // 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 // 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 // price. Packs are ordered by ascending rouble price; values are grouped by what they grant (hints
// through [Money] so no floating point ever reaches the page (roubles show kopecks as "200.00", // only, then no-ads only, then no-ads + hints, then tournament — see [offerValueGroup]) and, within
// whole-unit rails as integers); a missing rail price shows an em dash. // 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 { func projectOfferPricing(entries []catalogEntry) string {
var packs, values []string var packs, values []catalogEntry
for _, e := range entries { for _, e := range entries {
if isPackEntry(e) { if isPackEntry(e) {
packs = append(packs, fmt.Sprintf("| %s | %s | %s | %s |", packs = append(packs, e)
offerCell(e.title),
offerPrice(e, string(SourceDirect), CurrencyRUB),
offerPrice(e, string(SourceVK), CurrencyVote),
offerPrice(e, string(SourceTelegram), CurrencyStar),
))
} else { } else {
values = append(values, fmt.Sprintf("| %s | %s |", values = append(values, e)
offerCell(e.title), offerPrice(e, "", CurrencyChip)))
} }
} }
// 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 var b strings.Builder
if len(packs) > 0 { if len(packs) > 0 {
b.WriteString("Приобретение внутриигровой валюты «Фишка»:\n\n") b.WriteString("Приобретение внутриигровой валюты «Фишка»:\n\n")
b.WriteString("| Наименование | Рубли | Голоса в VK | Stars в Telegram |\n") b.WriteString("| Наименование | Рубли | Голоса в VK | Stars в Telegram |\n")
b.WriteString("| --- | --- | --- | --- |\n") b.WriteString("| --- | ---: | ---: | ---: |\n")
b.WriteString(strings.Join(packs, "\n")) for _, e := range packs {
b.WriteString("\n") 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(values) > 0 {
if len(packs) > 0 { if len(packs) > 0 {
@@ -84,13 +101,54 @@ func projectOfferPricing(entries []catalogEntry) string {
} }
b.WriteString("Использование внутриигровой валюты «Фишка»:\n\n") b.WriteString("Использование внутриигровой валюты «Фишка»:\n\n")
b.WriteString("| Наименование | «Фишки» |\n") b.WriteString("| Наименование | «Фишки» |\n")
b.WriteString("| --- | --- |\n") b.WriteString("| --- | ---: |\n")
b.WriteString(strings.Join(values, "\n")) for _, e := range values {
b.WriteString("\n") fmt.Fprintf(&b, "| %s | %s |\n", offerCell(e.title), offerPrice(e, "", CurrencyChip))
}
} }
return strings.TrimRight(b.String(), "\n") 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 // 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. // chips with money) rather than being a chip-priced value.
func isPackEntry(e catalogEntry) bool { 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 // 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. // string, or an em dash when the entry carries no such price.
func offerPrice(e catalogEntry, method string, cur Currency) string { func offerPrice(e catalogEntry, method string, cur Currency) string {
for _, pr := range e.prices { amt, ok := offerAmount(e, method, cur)
if pr.method == method && pr.currency == cur { if !ok {
m, err := MoneyFromMinor(pr.amount, pr.currency) return "—"
}
m, err := MoneyFromMinor(amt, cur)
if err != nil { if err != nil {
return "—" return "—"
} }
return m.Major() 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 {
return pr.amount, true
} }
return "—" }
return 0, false
} }
// offerCellReplacer neutralises every metacharacter of an admin-entered title so it renders as // offerCellReplacer neutralises every metacharacter of an admin-entered title so it renders as
+44
View File
@@ -31,8 +31,10 @@ func TestProjectOfferPricing(t *testing.T) {
md := projectOfferPricing(entries) md := projectOfferPricing(entries)
for _, want := range []string{ for _, want := range []string{
"| Наименование | Рубли | Голоса в VK | Stars в Telegram |", "| Наименование | Рубли | Голоса в VK | Stars в Telegram |",
"| --- | ---: | ---: | ---: |", // price columns right-aligned
"| 50 «Фишек» | 200.00 | 30 | 100 |", "| 50 «Фишек» | 200.00 | 30 | 100 |",
"| Наименование | «Фишки» |", "| Наименование | «Фишки» |",
"| --- | ---: |",
"| 200 подсказок | 50 |", "| 200 подсказок | 50 |",
} { } {
if !strings.Contains(md, want) { 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 // TestProjectOfferPricingMissingRailAndEscaping checks a pack missing a rail shows an em dash and a
// title carrying a pipe is escaped so the table layout survives. // title carrying a pipe is escaped so the table layout survives.
func TestProjectOfferPricingMissingRailAndEscaping(t *testing.T) { func TestProjectOfferPricingMissingRailAndEscaping(t *testing.T) {
+6 -1
View File
@@ -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 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 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 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 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 — exchanges alphabet indices, but the persisted journal (and everything derived from it —
+29
View File
@@ -31,6 +31,7 @@ export function renderOfferHtml(markdown: string): string {
--text: #1a1c20; --text: #1a1c20;
--accent: #2563eb; --accent: #2563eb;
--rule: #e5e7eb; --rule: #e5e7eb;
--cell-border: #d1d5db;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
@@ -38,6 +39,9 @@ export function renderOfferHtml(markdown: string): string {
--text: #e7e9ee; --text: #e7e9ee;
--accent: #6ea8fe; --accent: #6ea8fe;
--rule: #242832; --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 { li {
margin: 0.25em 0; 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;
}
</style> </style>
</head> </head>
<body> <body>