Files
member-console/internal/server/operator_product_forms.go
T
cgalo5758 782ca8f326 Derive Stripe mode from API key and refine disabled controls
Derive Stripe test/live mode from the API key prefix at boot, failing on
unrecognized prefixes, and drop the separate `stripe-mode` config key.

Refine disabled controls to render through the shared `disabledControl`
part with the not-allowed cursor, and add a lint rule refusing
hand-rolled disabled buttons.

Adjust plan cards to offer no purchase control on free rungs, fix bound
checkbox Bool handling, and rename "Public/Private" to "Listed/Unlisted"
with enhanced readiness verdicts.
2026-09-13 16:59:12 -05:00

374 lines
14 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
import (
"html/template"
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
)
// The product form (spec product-management "Operator product creation",
// "Operator product editing"; spec form-library; design D2, D5, D8, D14).
// One declaration serves the create page at /operator/products/new and the
// edit form on the product's record page, rendered in two modes, so the
// two cannot expose different field sets: the eleven differences the forms
// audit found between six create and edit forms (findings FA-1 to FA-5,
// FA-12) become one list with three declared sides. None of the three
// renders a notice: a field simply absent from the other side needs no
// sentence saying so (maintainer, 2026-09-03).
//
// This file is the form's home; the registry is only the index (design
// D1). It sits beside operator_products.go, which parses through the
// declaration and never through r.FormValue.
// productFormName is the declaration's name, its data-form id, and the id
// prefix of every one of its fields.
const productFormName = "operator.product"
// productSetOptionNew is the create side's first and default
// entitlement-set option: choosing it creates the set with the product, in
// one transaction, so a deployment with no set yet can still create its
// first product (design D14). It is not a set identifier and never reaches
// the database as one.
const productSetOptionNew = "new"
// productForm is the declaration, registered as this package is
// initialised rather than from the handler's constructor, so the registry
// is complete for the invariants test, the route test and the
// registered-forms table whether or not a handler has been built.
var productForm = forms.Register(forms.FormSpec{
Name: productFormName,
Kind: forms.KindCreate,
Family: forms.Stacked,
Method: "POST",
Path: "/partials/operator/products",
// The record side is a different route: the create page posts to the
// collection, the edit form puts to the record.
EditMethod: "PUT",
EditPath: "/partials/operator/products/{productID}",
// Both sides re-render the operator body, because saving a product
// changes its readiness panel and its prices view as well as the
// form, and a refusal must swap into the same target the success
// does (design D9, lesson L§17).
Target: "#operator-body",
Swap: "innerHTML",
Fields: []forms.Field{
productNameField(),
{
Name: "display_category",
Label: "Display category",
Control: forms.Text,
Optional: true,
MaxLen: 100,
Placeholder: "e.g. addon",
// A free-form grouping label with no behavioural weight, and
// no fixed domain: any string, blank included, is accepted
// (Doc 41 Decision 136). The explanation the detail page
// carries is the explanation the create page carries.
Help: forms.Help("Display category", "Grouping for the storefront and operator lists; never drives behavior. Blank is fine."),
Autocomplete: "off",
},
{
Name: "entitlement_set_id",
Label: "Entitlement set",
Control: forms.Select,
// The options are the deployment's active sets plus the
// side's own first option, so they are supplied per request
// and the same list is given to ParseWith.
RuntimeOptions: true,
Hint: "The set is what the product delivers; a product with none delivers no capabilities.",
},
{
Name: "visibility",
Label: "Listed",
Control: forms.Checkbox,
Value: "public",
// One checkbox, not a pair of radios and not two switches:
// the model records only is_public, and an unlisted product is
// the one way to grant a set (Doc 41; walk run 2, ACC-12).
// The field name and its value stay "visibility"/"public";
// only the operator-facing words change (design D4).
Help: forms.Help("Listed", "Shown in the member catalog."),
},
// The three edit-only fields: the create page publishes the
// product in one step, so its write-up, its feature list and its
// Active state all follow creation. None declares a Notice,
// because a field simply absent from the create page needs no
// sentence saying so (maintainer, 2026-09-03).
forms.RecordDescription().On(forms.EditOnly),
productFeaturesField(),
productActiveField(),
productExcludeField(),
},
Commit: "Create product",
CommitEdit: "Save changes",
WayOut: forms.LinkOut("Cancel", "/operator/products"),
})
// productNameField is the record name plus the duplicate-name warning's
// live region (spec product-management "Duplicate product name warns
// without blocking"). core.products.name carries no uniqueness
// constraint, so same-named products are legitimate; the form warns and
// never blocks. The scoped request swaps into the field's declared slot,
// whose id is escaped for a CSS selector because the form's name carries a
// dot. hx-include sends the form's own fields, which is how the edit side
// tells the check to exclude the product being edited.
func productNameField() forms.Field {
f := forms.RecordName()
f.Slot = "warning"
f.Attrs = map[string]string{
"hx-get": "/partials/operator/products/name-check",
"hx-trigger": "input changed delay:300ms",
"hx-target": `#form-operator\.product-name-warning`,
"hx-swap": "innerHTML",
"hx-include": "closest form",
}
return f
}
// productFeaturesField is the free-text feature list the member Products
// page shows, one per line. Edit-only: the product is created and
// published in one step, and its write-up follows (findings FA-2, FA-3).
// One line under the control, its hint; the side's reason is declared and
// not rendered.
func productFeaturesField() forms.Field {
f := forms.Field{
Name: "features",
Label: "Additional features",
Control: forms.Textarea,
Optional: true,
Rows: 3,
MaxLen: 2000,
Hint: "Shown on the Products page, one per line; supplements entitlement-derived limits.",
}
return f.On(forms.EditOnly)
}
// productActiveField is the product's Active state. Edit-only, because a
// product is created active; the help icon after the label explains what
// inactive means, so the operator meets the concept where it can be acted
// on (finding FA-3).
func productActiveField() forms.Field {
f := forms.Field{
Name: "is_active",
Label: "Active",
Control: forms.Checkbox,
Value: "true",
Help: forms.Help("Active", "An inactive product is not offered when new grants are created. Grants that already reference it keep working."),
}
return f.On(forms.EditOnly)
}
// productExcludeField carries the product being edited to the
// duplicate-name check, through the name field's hx-include. It writes
// nothing and renders nothing.
func productExcludeField() forms.Field {
f := forms.Field{
Name: productExcludeName,
Label: "Excluded product",
Control: forms.Hidden,
Optional: true,
}
return f.On(forms.EditOnly)
}
// productExcludeName is the edit side's hidden carrier for the
// duplicate-name check: the product being edited must not warn about its
// own name. It writes nothing; it rides the name field's hx-include so the
// check can leave this product out, and UpdateProduct ignores it, because
// the route and not the field is the authority on which product is being
// edited (finding FA-10).
const productExcludeName = "exclude"
// productSetOptions builds the entitlement-set select's options for one
// side. The create side opens on "New set named after this product"; the
// edit side opens on "None", whose consequence the field's hint states.
// The active sets follow, plus, on the edit side, the product's own set
// when it has been deactivated since (so Save round-trips the existing
// assignment instead of silently rewriting it).
func productSetOptions(side forms.Side, sets []EntitlementSetOption) []forms.Option {
var out []forms.Option
if side == forms.EditOnly {
out = append(out, forms.NoneOption())
} else {
out = append(out, forms.Option{Value: productSetOptionNew, Label: "New set named after this product"})
}
for _, s := range sets {
label := s.Name
if s.Inactive {
label += " (current; inactive)"
}
out = append(out, forms.Option{Value: s.SetID, Label: label})
}
return out
}
// productFormOptions is the option map both the render and the parse read,
// so what the control offered and what the handler accepts are one list.
func productFormOptions(side forms.Side, sets []EntitlementSetOption) map[string][]forms.Option {
return map[string][]forms.Option{"entitlement_set_id": productSetOptions(side, sets)}
}
// productCreateForm renders the create page's form: unbound on a first
// visit, submission-bound on a refusal.
func productCreateForm(sets []EntitlementSetOption, values forms.Values, errs *forms.Errors) forms.FormView {
mode := forms.ModeUnbound
if errs.Any() {
mode = forms.ModeSubmission
}
return forms.Render(productForm, forms.Binding{
Mode: mode,
Side: forms.CreateOnly,
Values: values,
Errors: errs,
Options: productFormOptions(forms.CreateOnly, sets),
})
}
// productEditForm renders the record page's form, bound to the product or
// to a refused submission of it.
func productEditForm(productID string, sets []EntitlementSetOption, values forms.Values, errs *forms.Errors, slots map[string]template.HTML) forms.FormView {
mode := forms.ModeRecord
if errs.Any() {
mode = forms.ModeSubmission
}
action, err := web.RouteURL(productForm.EditPath, productID)
if err != nil {
action = productForm.Path
}
return forms.Render(productForm, forms.Binding{
Mode: mode,
Side: forms.EditOnly,
Action: action,
Values: values,
Errors: errs,
Options: productFormOptions(forms.EditOnly, sets),
Slots: slots,
})
}
// productEditSlots builds the edit form's one live region: the
// duplicate-name warning under the name field, rendered at load so the
// no-JavaScript path and the "someone else created it since" case are both
// covered. It is the only slot the product form declares; the
// cross-reference to the assigned set's rules that once sat under the
// entitlement-set select is gone (maintainer, 2026-09-03: "It seems
// superfluous. Remove.").
func productEditSlots(t *SafeTemplates, data OperatorProductEditData) map[string]template.HTML {
if len(data.NameWarning.Matches) == 0 {
return nil
}
return map[string]template.HTML{
"name": t.Fragment("operator_product_name_warning.html", data.NameWarning),
}
}
// productEditValues binds a product record to the declaration's fields.
func productEditValues(p OperatorProductViewModel) forms.Values {
values := forms.NewValues()
values.Set("name", p.Name)
values.Set("display_category", p.DisplayCategory)
values.Set("entitlement_set_id", p.EntitlementSetID)
values.Set("description", p.Description)
values.Set("features", p.Features)
values.Set(productExcludeName, p.ProductID)
if f, ok := productForm.Field("visibility"); ok {
values.SetBool(f, p.IsPublic)
}
if f, ok := productForm.Field("is_active"); ok {
values.SetBool(f, p.IsActive)
}
return values
}
// The price-add form (spec product-management "Prices are added from a
// closed panel"; spec form-library; design D10). A dense sub-record panel
// beside the product's own declaration (lane B).
const productPriceFormName = "operator.product.price.add"
// productPriceAddPanelID is the collapse element the price form's Cancel
// closes (operator_product_prices.html's own panel wrapper).
const productPriceAddPanelID = "createPricePanel"
var productPriceForm = forms.Register(forms.FormSpec{
Name: productPriceFormName,
Kind: forms.KindSubRecord,
Family: forms.Dense,
Method: "POST",
Path: "/partials/operator/products/{productID}/prices",
// The whole composite re-renders: adding a price changes the prices
// table, so a refusal must swap into the same target the success does
// (design D9, lesson L§17). Single instance per page, so no
// Binding.Target override is needed.
Target: "#operator-body",
Swap: "innerHTML",
Fields: []forms.Field{
// A currency amount, not an integer count: Text with a decimal
// inputmode reads better than the Number control, whose Parse
// path assumes whole numbers (strconv.Atoi) and whose native
// spinner and locale handling are known rough edges for currency
// input. The handler still parses and range-checks the raw
// string itself (dollars to integer cents, Stripe's ceiling).
{
Name: "amount",
Label: "Amount",
Control: forms.Text,
Inputmode: "decimal",
// A money amount in a text control: shorter than the third a
// text field usually needs.
Width: forms.WidthNarrower,
},
{
Name: "currency",
Label: "Currency",
Control: forms.Select,
Options: []forms.Option{{Value: "usd", Label: "USD"}},
// A code, not a sentence: narrower than a select's third.
Width: forms.WidthNarrower,
},
// recurring_interval's three options are validated by Parse's own
// membership check, closing FA-11 (the control was `required` in
// markup with nothing behind it): "one_time" is the form's own
// value for a price with no recurrence, translated to a NULL
// column by the handler.
{
Name: "recurring_interval",
Label: "Interval",
Control: forms.Select,
Options: []forms.Option{
{Value: "month", Label: "Monthly"},
{Value: "year", Label: "Yearly"},
{Value: "one_time", Label: "One-time"},
},
// One word: narrower than a select's third.
Width: forms.WidthNarrower,
},
// Trial days is intentionally undeclared: trial_period_days is
// stored but not yet honored anywhere in the billing chain
// (checkout sets no trial), so collecting it would promise a
// trial members never get (finding #1, FA-6). CreatePrice no
// longer reads it at all; the column stays for when the payments
// model builds trials.
},
Commit: "Add price",
WayOut: forms.ClosePanel("Cancel", productPriceAddPanelID),
})
// productPriceFormView renders the price-add panel, bound to whatever was
// submitted (fresh, or a refusal).
func productPriceFormView(productID string, values forms.Values, errs *forms.Errors) forms.FormView {
mode := forms.ModeRecord
if errs.Any() {
mode = forms.ModeSubmission
}
action, err := web.RouteURL(productPriceForm.Path, productID)
if err != nil {
action = productPriceForm.Path
}
return forms.Render(productPriceForm, forms.Binding{Mode: mode, Action: action, Values: values, Errors: errs})
}