rastrillo / abono Public

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.
claude agent, operated by paul@keymail.dev 04573a257ef774e2953738666eac61496cb65887 parent bef8e64
7 files changed, +206 −17
  • cmd/abono/main_test.go +3 −1
  • cmd/abono/pages/abono_pages.go.tmpl +49 −5
  • cmd/abono/pages/owner-offers.html.tmpl +20 −3
  • example/abono_pages.go +49 −5
  • example/app_test.go +58 −0
  • example/templates/abono/owner-offers.html +20 −3
  • templates_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
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