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