abono pages: the owner adds an existing Stripe price to a plan, and puts a legacy price back on sale
Summary
The generated offers page gains, with Stripe or the sandbox on, a form that adds an existing Stripe price to a plan, on sale or as a legacy price (POST /admin/offers/prices, which calls AddPrice), and on every supported legacy price a button that puts it back on sale through the same route. A price put on sale replaces the plan's sold price that bills the same way (ruling R38). Failures re-render the page with the form as posted.
Agent notes
Final review I10, ruling R41. New keys, rendered as keys and in pendingReview and newKeys: abono.owner.add_price_title, abono.owner.add_price_id, abono.button.add_price (the ruling's three), plus two the flow needed that the brief did not list: abono.button.make_sold (the put-on-sale button) and abono.error.add_price_failed (Stripe lacks the price, it is another plan's, or none was typed). The plan field reuses abono.owner.adopt_plan ('Plan'), the on-sale switch abono.owner.price_sold ('On sale'), and an unknown plan abono.error.unknown_plan. Example pages regenerated. Regression: example TestOwnerAddsAndPromotesPrices.
7 files changed,
+206
−17
cmd/abono/main_test.go+3 −1cmd/abono/pages/abono_pages.go.tmpl+49 −5cmd/abono/pages/owner-offers.html.tmpl+20 −3example/abono_pages.go+49 −5example/app_test.go+58 −0example/templates/abono/owner-offers.html+20 −3templates_test.go+7 −0
CI passed — run details
CI log
=== vet === go vet ./... === fmt === === test === go test ./... -count=1 ok amadan.net/rastrillo/abono 2.693s ok amadan.net/rastrillo/abono/abonotest 0.057s ok amadan.net/rastrillo/abono/cmd/abono 0.707s ok amadan.net/rastrillo/abono/internal/fakestripe 0.020s ok amadan.net/rastrillo/abono/internal/stripeapi 0.008s ok example 0.264s === race === CGO_ENABLED=1 go test ./... -race -count=1 ok amadan.net/rastrillo/abono 9.433s ok amadan.net/rastrillo/abono/abonotest 1.712s ok amadan.net/rastrillo/abono/cmd/abono 2.454s ok amadan.net/rastrillo/abono/internal/fakestripe 1.110s ok amadan.net/rastrillo/abono/internal/stripeapi 1.034s ok example 2.380s === browser === ok example 0.905s
Intent
diff --git a/cmd/abono/main_test.go b/cmd/abono/main_test.go| index 37e58ca..bec0f11 100644 |
| --- a/cmd/abono/main_test.go |
| +++ b/cmd/abono/main_test.go |
| @@ -284,7 +284,9 @@ var partialKeys = []string{"abono.price.per_month", "abono.price.per_year"} |
| // newKeys are keys the pages need that the draft lacks (ruling R28), |
| // going through their own copy review. The pages must use each. |
| -var newKeys = []string{"abono.owner.free_no_member", "abono.pages.done_processing", "abono.owner.price_unsupported"} |
| +var newKeys = []string{"abono.owner.free_no_member", "abono.pages.done_processing", "abono.owner.price_unsupported", |
| + "abono.owner.add_price_title", "abono.owner.add_price_id", "abono.button.add_price", "abono.button.make_sold", |
| + "abono.error.add_price_failed"} |
| var keyLiteral = regexp.MustCompile(`"(abono\.[a-z][a-z_.]*[a-z])"`) |
diff --git a/cmd/abono/pages/abono_pages.go.tmpl b/cmd/abono/pages/abono_pages.go.tmpl| index beddd43..be108d5 100644 |
| --- a/cmd/abono/pages/abono_pages.go.tmpl |
| +++ b/cmd/abono/pages/abono_pages.go.tmpl |
| @@ -105,6 +105,7 @@ func (p *AbonoPages) Mount(r chi.Router) { |
| r.Post(abonoMembersPath+"/comps/{id}/end", p.endComp) |
| r.Get(abonoOffersPath, p.offers) |
| r.Post(abonoOffersPath+"/adopt", p.adopt) |
| + r.Post(abonoOffersPath+"/prices", p.addPrice) |
| r.Get(abonoStripePath, p.stripe) |
| }) |
| } |
| @@ -632,13 +633,20 @@ func (p *AbonoPages) endComp(w http.ResponseWriter, r *http.Request) { |
| } |
| } |
| -// GET /admin/offers: plans and their prices, and paying subscriptions |
| -// no plan recognises, each with a way onto a plan. |
| +// abonoPriceForm is the add-a-price form as posted. |
| +type abonoPriceForm struct { |
| + Plan, Price string |
| + Sold bool |
| +} |
| + |
| +// GET /admin/offers: plans and their prices, a way to add an existing |
| +// Stripe price to a plan or put a legacy one back on sale, and paying |
| +// subscriptions no plan recognises, each with a way onto a plan. |
| func (p *AbonoPages) offers(w http.ResponseWriter, r *http.Request) { |
| - p.renderOffers(w, r, http.StatusOK, "") |
| + p.renderOffers(w, r, http.StatusOK, abonoPriceForm{}, "") |
| } |
| -func (p *AbonoPages) renderOffers(w http.ResponseWriter, r *http.Request, status int, errKey string) { |
| +func (p *AbonoPages) renderOffers(w http.ResponseWriter, r *http.Request, status int, form abonoPriceForm, errKey string) { |
| ctx := r.Context() |
| offers, err := p.Service.Offers(ctx) |
| if err != nil { |
| @@ -659,12 +667,48 @@ func (p *AbonoPages) renderOffers(w http.ResponseWriter, r *http.Request, status |
| "Unrecognised": unknown, |
| "PlanOptions": abonoPlanOptions(offers, ""), |
| "AdoptAction": abonoOffersPath + "/adopt", |
| + "PriceOptions": abonoPlanOptions(offers, form.Plan), |
| + "PriceForm": form, |
| + "PriceAction": abonoOffersPath + "/prices", |
| "StripeURL": abonoStripePath, |
| "Done": abonoReadNotice(r, offers), |
| "Error": errKey, |
| }) |
| } |
| +// POST /admin/offers/prices: add an existing Stripe price to the posted |
| +// plan, on sale (sold) or as a legacy price whose subscribers keep the |
| +// plan; posting a price the plan already has changes whether it is on |
| +// sale, which is how a legacy price goes back on sale. A price put on |
| +// sale replaces the plan's sold price that bills the same way. |
| +func (p *AbonoPages) addPrice(w http.ResponseWriter, r *http.Request) { |
| + ctx := r.Context() |
| + form := abonoPriceForm{ |
| + Plan: r.PostFormValue("plan"), |
| + Price: strings.TrimSpace(r.PostFormValue("price")), |
| + Sold: r.PostFormValue("sold") != "", |
| + } |
| + offers, err := p.Service.Offers(ctx) |
| + if err != nil { |
| + p.fail(w, r, "offers", err) |
| + return |
| + } |
| + switch { |
| + case !abonoIsPlan(offers, form.Plan): |
| + p.renderOffers(w, r, http.StatusUnprocessableEntity, form, "abono.error.unknown_plan") |
| + return |
| + case form.Price == "": |
| + p.renderOffers(w, r, http.StatusUnprocessableEntity, form, "abono.error.add_price_failed") |
| + return |
| + } |
| + if err := p.Service.AddPrice(ctx, form.Plan, form.Price, form.Sold); err != nil { |
| + slog.ErrorContext(ctx, "abono pages: add price", "plan", form.Plan, "price", form.Price, "err", err) |
| + p.renderOffers(w, r, http.StatusUnprocessableEntity, form, "abono.error.add_price_failed") |
| + return |
| + } |
| + http.Redirect(w, r, abonoOffersPath, http.StatusSeeOther) |
| +} |
| + |
| // POST /admin/offers/adopt: put an unrecognised subscription, and every |
| // other on its price, onto the posted plan. Its holder is found as a sync |
| // finds one. The form is on the members page and the offers page; a |
| @@ -673,7 +717,7 @@ func (p *AbonoPages) renderOffers(w http.ResponseWriter, r *http.Request, status |
| func (p *AbonoPages) adopt(w http.ResponseWriter, r *http.Request) { |
| ctx := r.Context() |
| sub, plan := r.PostFormValue("subscription"), r.PostFormValue("plan") |
| - back, again := abonoOffersPath, func(status int, errKey string) { p.renderOffers(w, r, status, errKey) } |
| + back, again := abonoOffersPath, func(status int, errKey string) { p.renderOffers(w, r, status, abonoPriceForm{}, errKey) } |
| if r.PostFormValue("from") == "members" { |
| back, again = abonoMembersPath, func(status int, errKey string) { p.renderMembers(w, r, status, abonoCompForm{}, errKey) } |
| } |
diff --git a/cmd/abono/pages/owner-offers.html.tmpl b/cmd/abono/pages/owner-offers.html.tmpl| index e88c160..b92d86d 100644 |
| --- a/cmd/abono/pages/owner-offers.html.tmpl |
| +++ b/cmd/abono/pages/owner-offers.html.tmpl |
| @@ -3,7 +3,9 @@ |
| billed other than every month or every year is marked unsupported, |
| since it is never sold), and the |
| paying subscriptions on a price no plan has, each with a form that |
| - puts it, and everyone else on its price, onto a plan. |
| + puts it, and everyone else on its price, onto a plan. With Stripe |
| + on, a legacy price has a button that puts it back on sale, and a form |
| + adds an existing Stripe price to a plan, on sale or legacy. |
| Data: |
| Title string, the page title's message key |
| @@ -13,6 +15,10 @@ |
| Subject, Label, ...}; empty while Stripe is off |
| PlanOptions []abonoOption, the plans, as field-select options |
| AdoptAction string, where an adopt form posts |
| + PriceOptions []abonoOption, the plans, for the add-a-price form |
| + PriceForm abonoPriceForm, {Plan, Price, Sold}: that form as posted |
| + PriceAction string, where the add-a-price form and the |
| + put-on-sale buttons post |
| StripeURL string, the Stripe page |
| Done *abonoNotice, {Kind, N, Plan}: what the last action did |
| Error string, a message key, or "" |
| @@ -23,14 +29,25 @@ |
| {{with .Error}}{{template "callout" dict "L" $L "Tone" "negative" "Focus" true "Body" (T $L .)}}{{end}} |
| {{if not .On}}<p>{{T $L "abono.owner.nothing_for_sale"}} <a href="{{.StripeURL}}">{{T $L "abono.owner.stripe_title"}}</a></p> |
| {{end}} |
| -{{range .Offers}}<section rst-box> |
| +{{range .Offers}}{{$plan := .Plan}}<section rst-box> |
| <h2>{{.Name}}</h2> |
| <ul> |
| -{{range .Prices}}<li><code>{{.ID}}</code>{{if .Currency}}{{$m := money $L .Amount .Currency}} {{if .Unsupported}}{{$m}}{{else if eq .Interval "month"}}{{Tf $L "abono.price.per_month" "amount" $m}}{{else if eq .Interval "year"}}{{Tf $L "abono.price.per_year" "amount" $m}}{{else}}{{$m}}{{end}}{{end}} {{if .Sold}}{{template "badge" dict "L" $L "Label" (T $L "abono.owner.price_sold") "Tone" "positive"}}{{else}}{{template "badge" dict "L" $L "Label" (T $L "abono.owner.price_legacy")}}{{end}}{{if .Unsupported}} {{template "badge" dict "L" $L "Label" (T $L "abono.owner.price_unsupported") "Tone" "warning"}}{{end}}</li> |
| +{{range .Prices}}<li><code>{{.ID}}</code>{{if .Currency}}{{$m := money $L .Amount .Currency}} {{if .Unsupported}}{{$m}}{{else if eq .Interval "month"}}{{Tf $L "abono.price.per_month" "amount" $m}}{{else if eq .Interval "year"}}{{Tf $L "abono.price.per_year" "amount" $m}}{{else}}{{$m}}{{end}}{{end}} {{if .Sold}}{{template "badge" dict "L" $L "Label" (T $L "abono.owner.price_sold") "Tone" "positive"}}{{else}}{{template "badge" dict "L" $L "Label" (T $L "abono.owner.price_legacy")}}{{end}}{{if .Unsupported}} {{template "badge" dict "L" $L "Label" (T $L "abono.owner.price_unsupported") "Tone" "warning"}}{{end}}{{if and $.On (not .Sold) (not .Unsupported)}} |
| +<form rst-form-actions method="post" action="{{$.PriceAction}}" data-abono-make-sold="{{.ID}}"><input type="hidden" name="plan" value="{{$plan}}"><input type="hidden" name="price" value="{{.ID}}"><input type="hidden" name="sold" value="1"><button type="submit" rst-btn>{{T $L "abono.button.make_sold"}}</button></form>{{end}}</li> |
| {{end}}</ul> |
| </section> |
| {{else}}{{template "empty-state" dict "L" $L "Body" (T $L "abono.owner.offers_empty")}} |
| {{end}} |
| +{{if and .On .Offers}}<section rst-box aria-labelledby="abono-add-price-title"> |
| +<h2 id="abono-add-price-title">{{T $L "abono.owner.add_price_title"}}</h2> |
| +<form rst-form method="post" action="{{.PriceAction}}"> |
| +{{template "field-select" dict "L" $L "ID" "abono-add-price-plan" "Name" "plan" "Label" (T $L "abono.owner.adopt_plan") "Options" .PriceOptions "Required" true}} |
| +{{template "field-text" dict "L" $L "Name" "price" "Label" (T $L "abono.owner.add_price_id") "Value" .PriceForm.Price "Required" true "Autocomplete" "off"}} |
| +{{template "field-check" dict "Name" "sold" "Value" "1" "Label" (T $L "abono.owner.price_sold") "Checked" .PriceForm.Sold}} |
| +{{template "form-foot" dict "L" $L "Submit" (T $L "abono.button.add_price")}} |
| +</form> |
| +</section> |
| +{{end}} |
| {{if .On}}<section rst-box aria-labelledby="abono-unrecognised-title"> |
| <h2 id="abono-unrecognised-title">{{T $L "abono.owner.unrecognised_title"}}</h2> |
| {{if .Unrecognised}}<p>{{T $L "abono.owner.unrecognised_body"}}</p> |
diff --git a/example/abono_pages.go b/example/abono_pages.go| index 4ffe650..acb9ce4 100644 |
| --- a/example/abono_pages.go |
| +++ b/example/abono_pages.go |
| @@ -105,6 +105,7 @@ func (p *AbonoPages) Mount(r chi.Router) { |
| r.Post(abonoMembersPath+"/comps/{id}/end", p.endComp) |
| r.Get(abonoOffersPath, p.offers) |
| r.Post(abonoOffersPath+"/adopt", p.adopt) |
| + r.Post(abonoOffersPath+"/prices", p.addPrice) |
| r.Get(abonoStripePath, p.stripe) |
| }) |
| } |
| @@ -632,13 +633,20 @@ func (p *AbonoPages) endComp(w http.ResponseWriter, r *http.Request) { |
| } |
| } |
| -// GET /admin/offers: plans and their prices, and paying subscriptions |
| -// no plan recognises, each with a way onto a plan. |
| +// abonoPriceForm is the add-a-price form as posted. |
| +type abonoPriceForm struct { |
| + Plan, Price string |
| + Sold bool |
| +} |
| + |
| +// GET /admin/offers: plans and their prices, a way to add an existing |
| +// Stripe price to a plan or put a legacy one back on sale, and paying |
| +// subscriptions no plan recognises, each with a way onto a plan. |
| func (p *AbonoPages) offers(w http.ResponseWriter, r *http.Request) { |
| - p.renderOffers(w, r, http.StatusOK, "") |
| + p.renderOffers(w, r, http.StatusOK, abonoPriceForm{}, "") |
| } |
| -func (p *AbonoPages) renderOffers(w http.ResponseWriter, r *http.Request, status int, errKey string) { |
| +func (p *AbonoPages) renderOffers(w http.ResponseWriter, r *http.Request, status int, form abonoPriceForm, errKey string) { |
| ctx := r.Context() |
| offers, err := p.Service.Offers(ctx) |
| if err != nil { |
| @@ -659,12 +667,48 @@ func (p *AbonoPages) renderOffers(w http.ResponseWriter, r *http.Request, status |
| "Unrecognised": unknown, |
| "PlanOptions": abonoPlanOptions(offers, ""), |
| "AdoptAction": abonoOffersPath + "/adopt", |
| + "PriceOptions": abonoPlanOptions(offers, form.Plan), |
| + "PriceForm": form, |
| + "PriceAction": abonoOffersPath + "/prices", |
| "StripeURL": abonoStripePath, |
| "Done": abonoReadNotice(r, offers), |
| "Error": errKey, |
| }) |
| } |
| +// POST /admin/offers/prices: add an existing Stripe price to the posted |
| +// plan, on sale (sold) or as a legacy price whose subscribers keep the |
| +// plan; posting a price the plan already has changes whether it is on |
| +// sale, which is how a legacy price goes back on sale. A price put on |
| +// sale replaces the plan's sold price that bills the same way. |
| +func (p *AbonoPages) addPrice(w http.ResponseWriter, r *http.Request) { |
| + ctx := r.Context() |
| + form := abonoPriceForm{ |
| + Plan: r.PostFormValue("plan"), |
| + Price: strings.TrimSpace(r.PostFormValue("price")), |
| + Sold: r.PostFormValue("sold") != "", |
| + } |
| + offers, err := p.Service.Offers(ctx) |
| + if err != nil { |
| + p.fail(w, r, "offers", err) |
| + return |
| + } |
| + switch { |
| + case !abonoIsPlan(offers, form.Plan): |
| + p.renderOffers(w, r, http.StatusUnprocessableEntity, form, "abono.error.unknown_plan") |
| + return |
| + case form.Price == "": |
| + p.renderOffers(w, r, http.StatusUnprocessableEntity, form, "abono.error.add_price_failed") |
| + return |
| + } |
| + if err := p.Service.AddPrice(ctx, form.Plan, form.Price, form.Sold); err != nil { |
| + slog.ErrorContext(ctx, "abono pages: add price", "plan", form.Plan, "price", form.Price, "err", err) |
| + p.renderOffers(w, r, http.StatusUnprocessableEntity, form, "abono.error.add_price_failed") |
| + return |
| + } |
| + http.Redirect(w, r, abonoOffersPath, http.StatusSeeOther) |
| +} |
| + |
| // POST /admin/offers/adopt: put an unrecognised subscription, and every |
| // other on its price, onto the posted plan. Its holder is found as a sync |
| // finds one. The form is on the members page and the offers page; a |
| @@ -673,7 +717,7 @@ func (p *AbonoPages) renderOffers(w http.ResponseWriter, r *http.Request, status |
| func (p *AbonoPages) adopt(w http.ResponseWriter, r *http.Request) { |
| ctx := r.Context() |
| sub, plan := r.PostFormValue("subscription"), r.PostFormValue("plan") |
| - back, again := abonoOffersPath, func(status int, errKey string) { p.renderOffers(w, r, status, errKey) } |
| + back, again := abonoOffersPath, func(status int, errKey string) { p.renderOffers(w, r, status, abonoPriceForm{}, errKey) } |
| if r.PostFormValue("from") == "members" { |
| back, again = abonoMembersPath, func(status int, errKey string) { p.renderMembers(w, r, status, abonoCompForm{}, errKey) } |
| } |
diff --git a/example/app_test.go b/example/app_test.go| index f1f53b9..50dc79b 100644 |
| --- a/example/app_test.go |
| +++ b/example/app_test.go |
| @@ -706,3 +706,61 @@ func TestStripePageShowsTheWebhookAddress(t *testing.T) { |
| "<code>"+app.URL+abono.WebhookPath+"</code>", |
| html.EscapeString(approved["abono.owner.stripe_webhook_help"])) |
| } |
| + |
| +// TestOwnerAddsAndPromotesPrices is final review I10 (ruling R41): from |
| +// the offers page the owner adds an existing Stripe price to a plan, sold |
| +// or legacy, and makes a legacy price the sold one again. A new sold price |
| +// replaces the plan's sold price at its interval (ruling R38). |
| +func TestOwnerAddsAndPromotesPrices(t *testing.T) { |
| + app := newApp(t) |
| + boss := newClient(t, app) |
| + boss.signin(owner) |
| + reader := newClient(t, app) |
| + reader.signin("reader@example.com") |
| + |
| + status, body := boss.get("/admin/offers") |
| + want(t, "the offers page", status, http.StatusOK, body, |
| + say("abono.owner.add_price_title"), `action="/admin/offers/prices"`, say("abono.owner.add_price_id"), |
| + `name="price"`, `name="sold"`, say("abono.owner.price_sold"), say("abono.button.add_price")) |
| + if st, _, _ := reader.post("/admin/offers/prices", url.Values{"plan": {"monthly"}, "price": {"price_monthly_new"}, "sold": {"1"}}); st != http.StatusForbidden { |
| + t.Errorf("a reader adding a price: %d, want 403", st) |
| + } |
| + |
| + // A new monthly price on sale: the old one becomes legacy. |
| + st, loc, body := boss.post("/admin/offers/prices", url.Values{"plan": {"monthly"}, "price": {"price_monthly_new"}, "sold": {"1"}}) |
| + if st != http.StatusSeeOther || loc != "/admin/offers" { |
| + t.Fatalf("add a sold price: %d to %q\n%s", st, loc, body) |
| + } |
| + status, body = boss.get("/admin/offers") |
| + want(t, "after adding", status, http.StatusOK, body, "price_monthly_new", `data-abono-make-sold="price_monthly"`) |
| + if strings.Contains(body, `data-abono-make-sold="price_monthly_new"`) { |
| + t.Error("the price on sale offers to be put on sale") |
| + } |
| + if st, body := reader.get("/subscribe"); st != http.StatusOK || !strings.Contains(body, `action="/subscribe/monthly"`) { |
| + t.Errorf("monthly is not for sale after the replacement: %d\n%s", st, body) |
| + } |
| + |
| + // The old one back on sale, from its own button. |
| + st, loc, _ = boss.post("/admin/offers/prices", url.Values{"plan": {"monthly"}, "price": {"price_monthly"}, "sold": {"1"}}) |
| + if st != http.StatusSeeOther || loc != "/admin/offers" { |
| + t.Fatalf("make price_monthly the sold one: %d to %q", st, loc) |
| + } |
| + status, body = boss.get("/admin/offers") |
| + want(t, "after promoting", status, http.StatusOK, body, `data-abono-make-sold="price_monthly_new"`, say("abono.button.make_sold")) |
| + |
| + // A legacy price: listed, never sold. |
| + st, loc, _ = boss.post("/admin/offers/prices", url.Values{"plan": {"annual"}, "price": {"price_annual_2019"}}) |
| + if st != http.StatusSeeOther || loc != "/admin/offers" { |
| + t.Fatalf("add a legacy price: %d to %q", st, loc) |
| + } |
| + status, body = boss.get("/admin/offers") |
| + want(t, "after a legacy price", status, http.StatusOK, body, "price_annual_2019", `data-abono-make-sold="price_annual_2019"`) |
| + |
| + // What cannot be done says so, on the page. |
| + st, _, body = boss.post("/admin/offers/prices", url.Values{"plan": {"gold"}, "price": {"price_x"}, "sold": {"1"}}) |
| + want(t, "a price for an unknown plan", st, http.StatusUnprocessableEntity, body, say("abono.error.unknown_plan"), `value="price_x"`) |
| + st, _, body = boss.post("/admin/offers/prices", url.Values{"plan": {"monthly"}, "price": {"price_annual"}, "sold": {"1"}}) |
| + want(t, "another plan's price", st, http.StatusUnprocessableEntity, body, say("abono.error.add_price_failed")) |
| + st, _, body = boss.post("/admin/offers/prices", url.Values{"plan": {"monthly"}, "price": {" "}}) |
| + want(t, "no price", st, http.StatusUnprocessableEntity, body, say("abono.error.add_price_failed")) |
| +} |
diff --git a/example/templates/abono/owner-offers.html b/example/templates/abono/owner-offers.html| index e88c160..b92d86d 100644 |
| --- a/example/templates/abono/owner-offers.html |
| +++ b/example/templates/abono/owner-offers.html |
| @@ -3,7 +3,9 @@ |
| billed other than every month or every year is marked unsupported, |
| since it is never sold), and the |
| paying subscriptions on a price no plan has, each with a form that |
| - puts it, and everyone else on its price, onto a plan. |
| + puts it, and everyone else on its price, onto a plan. With Stripe |
| + on, a legacy price has a button that puts it back on sale, and a form |
| + adds an existing Stripe price to a plan, on sale or legacy. |
| Data: |
| Title string, the page title's message key |
| @@ -13,6 +15,10 @@ |
| Subject, Label, ...}; empty while Stripe is off |
| PlanOptions []abonoOption, the plans, as field-select options |
| AdoptAction string, where an adopt form posts |
| + PriceOptions []abonoOption, the plans, for the add-a-price form |
| + PriceForm abonoPriceForm, {Plan, Price, Sold}: that form as posted |
| + PriceAction string, where the add-a-price form and the |
| + put-on-sale buttons post |
| StripeURL string, the Stripe page |
| Done *abonoNotice, {Kind, N, Plan}: what the last action did |
| Error string, a message key, or "" |
| @@ -23,14 +29,25 @@ |
| {{with .Error}}{{template "callout" dict "L" $L "Tone" "negative" "Focus" true "Body" (T $L .)}}{{end}} |
| {{if not .On}}<p>{{T $L "abono.owner.nothing_for_sale"}} <a href="{{.StripeURL}}">{{T $L "abono.owner.stripe_title"}}</a></p> |
| {{end}} |
| -{{range .Offers}}<section rst-box> |
| +{{range .Offers}}{{$plan := .Plan}}<section rst-box> |
| <h2>{{.Name}}</h2> |
| <ul> |
| -{{range .Prices}}<li><code>{{.ID}}</code>{{if .Currency}}{{$m := money $L .Amount .Currency}} {{if .Unsupported}}{{$m}}{{else if eq .Interval "month"}}{{Tf $L "abono.price.per_month" "amount" $m}}{{else if eq .Interval "year"}}{{Tf $L "abono.price.per_year" "amount" $m}}{{else}}{{$m}}{{end}}{{end}} {{if .Sold}}{{template "badge" dict "L" $L "Label" (T $L "abono.owner.price_sold") "Tone" "positive"}}{{else}}{{template "badge" dict "L" $L "Label" (T $L "abono.owner.price_legacy")}}{{end}}{{if .Unsupported}} {{template "badge" dict "L" $L "Label" (T $L "abono.owner.price_unsupported") "Tone" "warning"}}{{end}}</li> |
| +{{range .Prices}}<li><code>{{.ID}}</code>{{if .Currency}}{{$m := money $L .Amount .Currency}} {{if .Unsupported}}{{$m}}{{else if eq .Interval "month"}}{{Tf $L "abono.price.per_month" "amount" $m}}{{else if eq .Interval "year"}}{{Tf $L "abono.price.per_year" "amount" $m}}{{else}}{{$m}}{{end}}{{end}} {{if .Sold}}{{template "badge" dict "L" $L "Label" (T $L "abono.owner.price_sold") "Tone" "positive"}}{{else}}{{template "badge" dict "L" $L "Label" (T $L "abono.owner.price_legacy")}}{{end}}{{if .Unsupported}} {{template "badge" dict "L" $L "Label" (T $L "abono.owner.price_unsupported") "Tone" "warning"}}{{end}}{{if and $.On (not .Sold) (not .Unsupported)}} |
| +<form rst-form-actions method="post" action="{{$.PriceAction}}" data-abono-make-sold="{{.ID}}"><input type="hidden" name="plan" value="{{$plan}}"><input type="hidden" name="price" value="{{.ID}}"><input type="hidden" name="sold" value="1"><button type="submit" rst-btn>{{T $L "abono.button.make_sold"}}</button></form>{{end}}</li> |
| {{end}}</ul> |
| </section> |
| {{else}}{{template "empty-state" dict "L" $L "Body" (T $L "abono.owner.offers_empty")}} |
| {{end}} |
| +{{if and .On .Offers}}<section rst-box aria-labelledby="abono-add-price-title"> |
| +<h2 id="abono-add-price-title">{{T $L "abono.owner.add_price_title"}}</h2> |
| +<form rst-form method="post" action="{{.PriceAction}}"> |
| +{{template "field-select" dict "L" $L "ID" "abono-add-price-plan" "Name" "plan" "Label" (T $L "abono.owner.adopt_plan") "Options" .PriceOptions "Required" true}} |
| +{{template "field-text" dict "L" $L "Name" "price" "Label" (T $L "abono.owner.add_price_id") "Value" .PriceForm.Price "Required" true "Autocomplete" "off"}} |
| +{{template "field-check" dict "Name" "sold" "Value" "1" "Label" (T $L "abono.owner.price_sold") "Checked" .PriceForm.Sold}} |
| +{{template "form-foot" dict "L" $L "Submit" (T $L "abono.button.add_price")}} |
| +</form> |
| +</section> |
| +{{end}} |
| {{if .On}}<section rst-box aria-labelledby="abono-unrecognised-title"> |
| <h2 id="abono-unrecognised-title">{{T $L "abono.owner.unrecognised_title"}}</h2> |
| {{if .Unrecognised}}<p>{{T $L "abono.owner.unrecognised_body"}}</p> |
diff --git a/templates_test.go b/templates_test.go| index c0cec61..7d3006b 100644 |
| --- a/templates_test.go |
| +++ b/templates_test.go |
| @@ -711,6 +711,13 @@ var pendingReview = []string{ |
| // The final-review fix wave's keys, for the third mini copy review. |
| "abono.pages.done_processing", |
| "abono.owner.price_unsupported", |
| + "abono.owner.add_price_title", |
| + "abono.owner.add_price_id", |
| + "abono.button.add_price", |
| + // Beyond the brief's list: the put-on-sale button and the add-price |
| + // form's failure needed words of their own. |
| + "abono.button.make_sold", |
| + "abono.error.add_price_failed", |
| } |
| // Every key a consumer uses is in en.toml (bar the pendingReview ones), and |