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
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user