Merge pull request 'refactor(payments): one canonical catalog order (storefront/offer/admin) + admin active/all toggle' (#261) from feature/wallet-catalog-order into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 21s
CI / ui (push) Has been skipped
CI / conformance (push) Successful in 11s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m55s
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 21s
CI / ui (push) Has been skipped
CI / conformance (push) Successful in 11s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m55s
This commit was merged in pull request #261.
This commit is contained in:
@@ -22,6 +22,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
<section class="panel"><h2>Products</h2>
|
<section class="panel"><h2>Products</h2>
|
||||||
|
<p class="note">Showing {{if .ShowAll}}<strong>all</strong> products (active + archived) — <a href="/_gm/catalog">active only</a>{{else}}<strong>active</strong> products — <a href="/_gm/catalog?all=1">show all (incl. archived)</a>{{end}}.</p>
|
||||||
<table class="list">
|
<table class="list">
|
||||||
<thead><tr><th>Title</th><th>Status</th><th>Atoms</th><th>Prices</th><th></th></tr></thead>
|
<thead><tr><th>Title</th><th>Status</th><th>Atoms</th><th>Prices</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -677,6 +677,9 @@ type FeedbackDetailView struct {
|
|||||||
// CatalogView is the product-catalog list page.
|
// CatalogView is the product-catalog list page.
|
||||||
type CatalogView struct {
|
type CatalogView struct {
|
||||||
Products []ProductRow
|
Products []ProductRow
|
||||||
|
// ShowAll reports whether archived products are listed alongside active ones (the active/all
|
||||||
|
// toggle); it drives the toggle link and the list heading.
|
||||||
|
ShowAll bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProductRow is one product in the catalog list: its composition, prices, the archived flag
|
// ProductRow is one product in the catalog list: its composition, prices, the archived flag
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
// productByTitle finds a catalog product by its (unique in the test) title.
|
// productByTitle finds a catalog product by its (unique in the test) title.
|
||||||
func productByTitle(t *testing.T, pay *payments.Service, title string) payments.AdminProduct {
|
func productByTitle(t *testing.T, pay *payments.Service, title string) payments.AdminProduct {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
all, err := pay.AdminCatalog(context.Background())
|
all, err := pay.AdminCatalog(context.Background(), true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("admin catalog: %v", err)
|
t.Fatalf("admin catalog: %v", err)
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ func TestConsoleCatalogEditor(t *testing.T) {
|
|||||||
if code, body := consoleDo(h, http.MethodPost, catalog, "title=cat-bad&chips=100&active=true", origin); code != http.StatusOK || !strings.Contains(body, "Invalid product") {
|
if code, body := consoleDo(h, http.MethodPost, catalog, "title=cat-bad&chips=100&active=true", origin); code != http.StatusOK || !strings.Contains(body, "Invalid product") {
|
||||||
t.Fatalf("bad pack = %d, has 'Invalid product' = %v", code, strings.Contains(body, "Invalid product"))
|
t.Fatalf("bad pack = %d, has 'Invalid product' = %v", code, strings.Contains(body, "Invalid product"))
|
||||||
}
|
}
|
||||||
if _, err := pay.AdminCatalog(ctx); err != nil {
|
if _, err := pay.AdminCatalog(ctx, true); err != nil {
|
||||||
t.Fatalf("catalog: %v", err)
|
t.Fatalf("catalog: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,3 +95,32 @@ func TestConsoleCatalogEditor(t *testing.T) {
|
|||||||
t.Fatalf("delete transacted = %d, has 'Cannot delete' = %v", code, strings.Contains(body, "Cannot delete"))
|
t.Fatalf("delete transacted = %d, has 'Cannot delete' = %v", code, strings.Contains(body, "Cannot delete"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestConsoleCatalogActiveToggle checks the catalog console lists only active products by default and
|
||||||
|
// includes the archived ones under ?all=1 (the active/all toggle).
|
||||||
|
func TestConsoleCatalogActiveToggle(t *testing.T) {
|
||||||
|
srv, _, _ := bannerServer(t)
|
||||||
|
h := srv.Handler()
|
||||||
|
const origin = "http://admin.test"
|
||||||
|
const catalog = "http://admin.test/_gm/catalog"
|
||||||
|
|
||||||
|
// An active value and an archived one (created without the active flag).
|
||||||
|
if code, _ := consoleDo(h, http.MethodPost, catalog, "title=toggle-active&hints=5&price_chip=50&active=true", origin); code != http.StatusOK {
|
||||||
|
t.Fatal("create active failed")
|
||||||
|
}
|
||||||
|
if code, _ := consoleDo(h, http.MethodPost, catalog, "title=toggle-archived&hints=9&price_chip=90", origin); code != http.StatusOK {
|
||||||
|
t.Fatal("create archived failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default view: active only.
|
||||||
|
if _, body := consoleDo(h, http.MethodGet, catalog, "", ""); !strings.Contains(body, "toggle-active") || strings.Contains(body, "toggle-archived") {
|
||||||
|
t.Errorf("default view: active listed=%v, archived listed=%v (want true/false)",
|
||||||
|
strings.Contains(body, "toggle-active"), strings.Contains(body, "toggle-archived"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ?all=1: active and archived.
|
||||||
|
if _, body := consoleDo(h, http.MethodGet, catalog+"?all=1", "", ""); !strings.Contains(body, "toggle-active") || !strings.Contains(body, "toggle-archived") {
|
||||||
|
t.Errorf("all view: active listed=%v, archived listed=%v (want true/true)",
|
||||||
|
strings.Contains(body, "toggle-active"), strings.Contains(body, "toggle-archived"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ const atomChips = "chips"
|
|||||||
// method are omitted (misconfigured or unavailable there). An untrusted context (empty Kind) has
|
// method are omitted (misconfigured or unavailable there). An untrusted context (empty Kind) has
|
||||||
// no method, so only values show; buying is gated server-side regardless.
|
// no method, so only values show; buying is gated server-side regardless.
|
||||||
func projectCatalog(entries []catalogEntry, cxt Context) CatalogView {
|
func projectCatalog(entries []catalogEntry, cxt Context) CatalogView {
|
||||||
|
// Present products in the canonical listing order shared with the offer and the admin console
|
||||||
|
// (packs first by rouble price, then values grouped hints → no-ads → combo and by chip price); the
|
||||||
|
// client renders them as received. Ordered in place — the caller passes a fresh loadCatalog result
|
||||||
|
// it does not reuse.
|
||||||
|
sortCatalogEntries(entries)
|
||||||
var out CatalogView
|
var out CatalogView
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
isPack := false
|
isPack := false
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package payments
|
package payments
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -144,10 +143,12 @@ func validateProduct(in ProductInput, sellable bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminCatalog lists every product (active and archived) with its composition, prices and whether it
|
// AdminCatalog lists products with their composition, prices and whether each has been transacted,
|
||||||
// has been transacted, for the admin editor. Read uncached, straight from the catalog tables.
|
// for the admin editor. includeInactive adds the archived products to the active ones (the console's
|
||||||
func (s *Service) AdminCatalog(ctx context.Context) ([]AdminProduct, error) {
|
// active/all toggle); with it false only active products are returned. Read uncached, straight from
|
||||||
return s.store.adminCatalog(ctx)
|
// the catalog tables.
|
||||||
|
func (s *Service) AdminCatalog(ctx context.Context, includeInactive bool) ([]AdminProduct, error) {
|
||||||
|
return s.store.adminCatalog(ctx, includeInactive)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SortAdminCatalog orders an admin catalog list in place the same way the public offer lists products
|
// SortAdminCatalog orders an admin catalog list in place the same way the public offer lists products
|
||||||
@@ -157,18 +158,7 @@ func (s *Service) AdminCatalog(ctx context.Context) ([]AdminProduct, error) {
|
|||||||
// products the same as active ones (the admin list shows both).
|
// products the same as active ones (the admin list shows both).
|
||||||
func SortAdminCatalog(products []AdminProduct) {
|
func SortAdminCatalog(products []AdminProduct) {
|
||||||
slices.SortStableFunc(products, func(a, b AdminProduct) int {
|
slices.SortStableFunc(products, func(a, b AdminProduct) int {
|
||||||
if pa, pb := adminIsPack(a), adminIsPack(b); pa != pb {
|
return compareCatalogRank(adminRank(a), adminRank(b))
|
||||||
if pa {
|
|
||||||
return -1 // packs (sales) before values (chip exchange)
|
|
||||||
}
|
|
||||||
return 1
|
|
||||||
} else if pa {
|
|
||||||
return cmp.Compare(adminPriceAmount(a, string(SourceDirect), CurrencyRUB), adminPriceAmount(b, string(SourceDirect), CurrencyRUB))
|
|
||||||
}
|
|
||||||
if d := cmp.Compare(adminValueGroup(a), adminValueGroup(b)); d != 0 {
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
return cmp.Compare(adminPriceAmount(a, "", CurrencyChip), adminPriceAmount(b, "", CurrencyChip))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
// catalogRank is the canonical listing key for a catalog product: whether it is a chip pack, its
|
||||||
|
// value subgroup (see [valueGroup]; unused for a pack) and the minor-unit amount its section sorts by
|
||||||
|
// (a pack's direct rouble price, a value's chip price; math.MaxInt64 when absent, so a misconfigured
|
||||||
|
// row sorts last rather than leading).
|
||||||
|
type catalogRank struct {
|
||||||
|
pack bool
|
||||||
|
group int
|
||||||
|
amount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// compareCatalogRank is the single canonical product order, shared by the storefront ([projectCatalog]),
|
||||||
|
// the public offer ([projectOfferPricing]) and the admin catalog ([SortAdminCatalog]): chip packs
|
||||||
|
// first, ascending by rouble price; then chip-priced values grouped hints → no-ads → combo →
|
||||||
|
// tournament and, within a group, ascending by chip price. A pack's group is unused (all packs share
|
||||||
|
// one section). Callers use it through [entryRank] / [adminRank], which build the key from each
|
||||||
|
// product shape, so the subgroup and ordering rules live in exactly one place.
|
||||||
|
func compareCatalogRank(a, b catalogRank) int {
|
||||||
|
if a.pack != b.pack {
|
||||||
|
if a.pack {
|
||||||
|
return -1 // packs (sales) before values (chip exchange)
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if !a.pack {
|
||||||
|
if d := cmp.Compare(a.group, b.group); d != 0 {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cmp.Compare(a.amount, b.amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// entryRank builds the canonical rank of a storefront / offer catalog entry.
|
||||||
|
func entryRank(e catalogEntry) catalogRank {
|
||||||
|
if isPackEntry(e) {
|
||||||
|
return catalogRank{pack: true, amount: offerSortAmount(e, string(SourceDirect), CurrencyRUB)}
|
||||||
|
}
|
||||||
|
return catalogRank{group: offerValueGroup(e), amount: offerSortAmount(e, "", CurrencyChip)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminRank builds the canonical rank of an admin catalog product.
|
||||||
|
func adminRank(p AdminProduct) catalogRank {
|
||||||
|
if adminIsPack(p) {
|
||||||
|
return catalogRank{pack: true, amount: adminPriceAmount(p, string(SourceDirect), CurrencyRUB)}
|
||||||
|
}
|
||||||
|
return catalogRank{group: adminValueGroup(p), amount: adminPriceAmount(p, "", CurrencyChip)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortCatalogEntries orders storefront / offer entries in place into the canonical listing order
|
||||||
|
// ([compareCatalogRank]). Stable, so equal-keyed products keep their incoming (creation) order.
|
||||||
|
func sortCatalogEntries(entries []catalogEntry) {
|
||||||
|
slices.SortStableFunc(entries, func(a, b catalogEntry) int {
|
||||||
|
return compareCatalogRank(entryRank(a), entryRank(b))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -135,6 +135,56 @@ func TestProjectCatalog_Empty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestProjectCatalog_CanonicalOrder checks the storefront lists products in the shared canonical order
|
||||||
|
// (compareCatalogRank): chip packs first ascending by rouble price, then chip-priced values grouped
|
||||||
|
// hints → no-ads → combo and ascending by chip price — the same order as the offer and the admin
|
||||||
|
// console, regardless of catalog creation order.
|
||||||
|
func TestProjectCatalog_CanonicalOrder(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: "direct", 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 shuffled on input; the projection must impose the canonical order.
|
||||||
|
entries := []catalogEntry{
|
||||||
|
pack("packDear", 30000),
|
||||||
|
value("bundle", 500, "hints", "noads_days"),
|
||||||
|
pack("packCheap", 10000),
|
||||||
|
value("adsOnly", 150, "noads_days"),
|
||||||
|
value("hintsBig", 200, "hints"),
|
||||||
|
value("hintsSmall", 50, "hints"),
|
||||||
|
}
|
||||||
|
got := projectCatalog(entries, Context{Kind: SourceDirect})
|
||||||
|
want := []string{"packCheap", "packDear", "hintsSmall", "hintsBig", "adsOnly", "bundle"}
|
||||||
|
if len(got.Products) != len(want) {
|
||||||
|
t.Fatalf("projected %d products, want %d", len(got.Products), len(want))
|
||||||
|
}
|
||||||
|
for i, p := range got.Products {
|
||||||
|
if p.Title != want[i] {
|
||||||
|
t.Fatalf("order[%d] = %q, want %q (full: %v)", i, p.Title, want[i], productTitles(got.Products))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// productTitles extracts the projected product titles for a failure message.
|
||||||
|
func productTitles(products []CatalogProduct) []string {
|
||||||
|
out := make([]string, len(products))
|
||||||
|
for i, p := range products {
|
||||||
|
out[i] = p.Title
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// TestSortAdminCatalog checks the admin list is ordered like the public offer: chip packs first,
|
// TestSortAdminCatalog checks the admin list is ordered like the public offer: chip packs first,
|
||||||
// ascending by rouble price; then values, grouped (hints only -> no-ads only -> no-ads + hints) and
|
// ascending by rouble price; then values, grouped (hints only -> no-ads only -> no-ads + hints) and
|
||||||
// ascending by chip price within a group. Stable and applied to active + archived alike.
|
// ascending by chip price within a group. Stable and applied to active + archived alike.
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package payments
|
package payments
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
@@ -60,8 +59,13 @@ func (s *Service) buildOfferPricing(ctx context.Context) (string, error) {
|
|||||||
// omitted. Amounts are rendered through [Money] so no floating point ever reaches the page (roubles
|
// 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.
|
// 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 {
|
||||||
|
// Order the whole catalog once by the shared canonical rank, then split it into the two offer
|
||||||
|
// tables (packs first, then the chip-exchange values); each keeps that order. Sort a copy so the
|
||||||
|
// caller's slice is left untouched.
|
||||||
|
sorted := slices.Clone(entries)
|
||||||
|
sortCatalogEntries(sorted)
|
||||||
var packs, values []catalogEntry
|
var packs, values []catalogEntry
|
||||||
for _, e := range entries {
|
for _, e := range sorted {
|
||||||
if isPackEntry(e) {
|
if isPackEntry(e) {
|
||||||
packs = append(packs, e)
|
packs = append(packs, e)
|
||||||
} else {
|
} else {
|
||||||
@@ -69,18 +73,6 @@ func projectOfferPricing(entries []catalogEntry) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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")
|
||||||
|
|||||||
@@ -19,11 +19,17 @@ import (
|
|||||||
// row references — it must be archived instead, to keep the append-only ledger resolvable.
|
// row references — it must be archived instead, to keep the append-only ledger resolvable.
|
||||||
var ErrProductTransacted = errors.New("payments: product has transactions; archive instead of delete")
|
var ErrProductTransacted = errors.New("payments: product has transactions; archive instead of delete")
|
||||||
|
|
||||||
// adminCatalog lists every product (active and archived) with its atoms, prices and transacted flag.
|
// adminCatalog lists products with their atoms, prices and transacted flag. includeInactive adds the
|
||||||
func (s *Store) adminCatalog(ctx context.Context) ([]AdminProduct, error) {
|
// archived products to the active ones; with it false only active products are returned.
|
||||||
|
func (s *Store) adminCatalog(ctx context.Context, includeInactive bool) ([]AdminProduct, error) {
|
||||||
|
where := table.Product.Active.IS_TRUE()
|
||||||
|
if includeInactive {
|
||||||
|
where = postgres.Bool(true)
|
||||||
|
}
|
||||||
var prods []model.Product
|
var prods []model.Product
|
||||||
if err := postgres.SELECT(table.Product.AllColumns).
|
if err := postgres.SELECT(table.Product.AllColumns).
|
||||||
FROM(table.Product).
|
FROM(table.Product).
|
||||||
|
WHERE(where).
|
||||||
ORDER_BY(table.Product.CreatedAt.ASC()).
|
ORDER_BY(table.Product.CreatedAt.ASC()).
|
||||||
QueryContext(ctx, s.db, &prods); err != nil {
|
QueryContext(ctx, s.db, &prods); err != nil {
|
||||||
return nil, fmt.Errorf("payments: admin list products: %w", err)
|
return nil, fmt.Errorf("payments: admin list products: %w", err)
|
||||||
|
|||||||
@@ -33,10 +33,12 @@ var priceFields = []struct {
|
|||||||
{"price_chip", "", payments.CurrencyChip},
|
{"price_chip", "", payments.CurrencyChip},
|
||||||
}
|
}
|
||||||
|
|
||||||
// consoleCatalog lists every product (active and archived) with its composition, prices, transacted
|
// consoleCatalog lists the catalog products with their composition, prices, transacted flag, and the
|
||||||
// flag, and the inline create form.
|
// inline create form. It shows active products by default; ?all=1 also lists the archived ones (the
|
||||||
|
// active/all toggle).
|
||||||
func (s *Server) consoleCatalog(c *gin.Context) {
|
func (s *Server) consoleCatalog(c *gin.Context) {
|
||||||
products, err := s.payments.AdminCatalog(c.Request.Context())
|
showAll := c.Query("all") == "1"
|
||||||
|
products, err := s.payments.AdminCatalog(c.Request.Context(), showAll)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.consoleError(c, err)
|
s.consoleError(c, err)
|
||||||
return
|
return
|
||||||
@@ -44,7 +46,7 @@ func (s *Server) consoleCatalog(c *gin.Context) {
|
|||||||
// Order the list like the public offer: sales (chip packs) first, then the chip-exchange values,
|
// Order the list like the public offer: sales (chip packs) first, then the chip-exchange values,
|
||||||
// grouped and price-sorted the same way, so the console mirrors what a buyer sees.
|
// grouped and price-sorted the same way, so the console mirrors what a buyer sees.
|
||||||
payments.SortAdminCatalog(products)
|
payments.SortAdminCatalog(products)
|
||||||
var view adminconsole.CatalogView
|
view := adminconsole.CatalogView{ShowAll: showAll}
|
||||||
for _, p := range products {
|
for _, p := range products {
|
||||||
view.Products = append(view.Products, catalogRow(p))
|
view.Products = append(view.Products, catalogRow(p))
|
||||||
}
|
}
|
||||||
@@ -69,7 +71,7 @@ func (s *Server) consoleCatalogDetail(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
products, err := s.payments.AdminCatalog(c.Request.Context())
|
products, err := s.payments.AdminCatalog(c.Request.Context(), true) // include archived: the detail form edits any product
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.consoleError(c, err)
|
s.consoleError(c, err)
|
||||||
return
|
return
|
||||||
@@ -242,7 +244,7 @@ func (s *Server) consoleGrantProduct(c *gin.Context) {
|
|||||||
// bundles, including archived ones — chips and tournament products are excluded).
|
// bundles, including archived ones — chips and tournament products are excluded).
|
||||||
func (s *Server) grantForm(ctx context.Context) adminconsole.GrantFormView {
|
func (s *Server) grantForm(ctx context.Context) adminconsole.GrantFormView {
|
||||||
fv := adminconsole.GrantFormView{Present: true, Origins: []string{"direct", "vk", "telegram"}}
|
fv := adminconsole.GrantFormView{Present: true, Origins: []string{"direct", "vk", "telegram"}}
|
||||||
products, err := s.payments.AdminCatalog(ctx)
|
products, err := s.payments.AdminCatalog(ctx, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fv
|
return fv
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user