// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package server import ( "net/http" "git.coopcloud.tech/wiki-cafe/member-console/internal/forms" ) // Record creation gets its own page (design D20, round 4; maintainer, // 2026-09-03: "agree!"). Products, entitlement sets, and plan ladders each // have a create page under their list, reached from the list section // header's one filled "New ..." link: // // GET /operator/products/new // GET /operator/entitlement-sets/new // GET /operator/plan-ladders/new // // The POST endpoints these forms submit to are unchanged // (/partials/operator/{products,entitlement-sets,plan-ladders}): success is // still a full navigation to the new record with ?flash=created, and a // validation failure now re-renders THIS page with the field errors and the // values the operator typed, at 422, instead of the list page. // // Sub-record creation (a price, a tier, a rule, Issue grant, Extend) stays // inline behind a tertiary opener on its section header: those records have // no identity apart from the record they hang off, so a page of their own // would leave the parent behind for one field. // ProductNewData is the body data for GET /operator/products/new. The // page is the product declaration rendered unbound (form-library): no // values, no errors, and no gate, because the entitlement-set select's // first option creates the set with the product, so a deployment with no // set yet is no longer a blocked state (design D14). type ProductNewData struct { Form forms.FormView } // Header titles the page and roots its trail at the products list // ("Operator / Products / New product"; design D18). func (ProductNewData) Header() PageHeader { return PageHeader{Title: "New product", Crumbs: []Link{{Label: "Products", URL: "/operator/products"}}} } // EntitlementSetNewData is the body data for GET // /operator/entitlement-sets/new. The page is the set declaration rendered // unbound (form-library): no values, no errors, and no Active control, // because Active is the declaration's edit-only field (design D21). type EntitlementSetNewData struct { Form forms.FormView } // Header titles the page below the sets list ("Operator / Products / // Entitlement sets / New entitlement set"), the trail the sets list itself // carries plus this page. func (EntitlementSetNewData) Header() PageHeader { return PageHeader{Title: "New entitlement set", Crumbs: []Link{ {Label: "Products", URL: "/operator/products"}, {Label: "Entitlement sets", URL: "/operator/entitlement-sets"}, }} } // PlanLadderNewData is the body data for GET /operator/plan-ladders/new. // The page is the ladder declaration rendered unbound (form-library). type PlanLadderNewData struct { Form forms.FormView } // Header titles the page below the ladders list ("Operator / Products / // Plan ladders / New plan ladder"). func (PlanLadderNewData) Header() PageHeader { return PageHeader{Title: "New plan ladder", Crumbs: []Link{ {Label: "Products", URL: "/operator/products"}, {Label: "Plan ladders", URL: "/operator/plan-ladders"}, }} } // GetProductNewPage handles GET /operator/products/new. func (h *OperatorPartialsHandler) GetProductNewPage(w http.ResponseWriter, r *http.Request) { page := h.buildOperatorPageData(r) page.IAPosition = "catalog:products:new" page.ActiveCapability = "products" page.BodyTemplate = "operator_product_new.html" page.BodyData = h.loadProductNewData(r, forms.NewValues(), nil) h.Templates.Render(w, "operator.html", page) } // GetEntitlementSetNewPage handles GET /operator/entitlement-sets/new. func (h *OperatorPartialsHandler) GetEntitlementSetNewPage(w http.ResponseWriter, r *http.Request) { page := h.buildOperatorPageData(r) page.IAPosition = "catalog:entitlement-sets:new" page.ActiveCapability = "entitlement-sets" page.BodyTemplate = "operator_entitlement_set_new.html" page.BodyData = EntitlementSetNewData{Form: entitlementSetCreateForm(forms.NewValues(), nil)} h.Templates.Render(w, "operator.html", page) } // GetPlanLadderNewPage handles GET /operator/plan-ladders/new. func (h *OperatorPartialsHandler) GetPlanLadderNewPage(w http.ResponseWriter, r *http.Request) { page := h.buildOperatorPageData(r) page.IAPosition = "catalog:plan-ladders:new" page.ActiveCapability = "plan-ladders" page.BodyTemplate = "operator_plan_ladder_new.html" page.BodyData = PlanLadderNewData{Form: planLadderCreateForm(forms.NewValues(), nil)} h.Templates.Render(w, "operator.html", page) } // loadProductNewData hydrates the product create page: the declaration // rendered on its create side, with the picker's active entitlement sets // behind the side's own first option. func (h *OperatorPartialsHandler) loadProductNewData(r *http.Request, values forms.Values, errs *forms.Errors) ProductNewData { sets := h.loadEntitlementSetOptions(r) return ProductNewData{Form: productCreateForm(sets, values, errs)} } // renderProductNewRefusal re-renders the product create page at 422 with // the declaration in submission mode: every field's error under its // control, the form-level error in the slot the part always renders, and // every value that was typed still there (design D9). One function for // both kinds of refusal, because a refusal that belongs to no field lands // in the form's own slot rather than in a page banner (finding FA-26). The // form posts with htmx targeting #operator-body, so the body partial alone // is the response and the address bar stays on /operator/products/new. func (h *OperatorPartialsHandler) renderProductNewRefusal(w http.ResponseWriter, r *http.Request, values forms.Values, errs *forms.Errors) { w.WriteHeader(http.StatusUnprocessableEntity) h.Templates.Render(w, "operator_product_new.html", h.loadProductNewData(r, values, errs)) } // renderEntitlementSetNewRefusal re-renders the set create page at 422 // with the declaration in submission mode (design D9). func (h *OperatorPartialsHandler) renderEntitlementSetNewRefusal(w http.ResponseWriter, r *http.Request, values forms.Values, errs *forms.Errors) { w.WriteHeader(http.StatusUnprocessableEntity) h.Templates.Render(w, "operator_entitlement_set_new.html", EntitlementSetNewData{Form: entitlementSetCreateForm(values, errs)}) } // renderPlanLadderNewRefusal re-renders the ladder create page at 422 // with the declaration in submission mode (design D9). func (h *OperatorPartialsHandler) renderPlanLadderNewRefusal(w http.ResponseWriter, r *http.Request, values forms.Values, errs *forms.Errors) { w.WriteHeader(http.StatusUnprocessableEntity) h.Templates.Render(w, "operator_plan_ladder_new.html", PlanLadderNewData{Form: planLadderCreateForm(values, errs)}) }