// 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}) }