Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

render.gno

6.28 Kb Β· 192 lines
  1package patron
  2
  3import (
  4	"strconv"
  5	"strings"
  6
  7	"chain/runtime"
  8
  9	"gno.land/p/moul/kit/num/v0"
 10	"gno.land/p/moul/kit/store/v0"
 11	"gno.land/p/moul/kit/ui/v0"
 12	"gno.land/p/moul/md/v0"
 13	"gno.land/p/moul/realmpath/v0"
 14	"gno.land/p/moul/txlink/v0"
 15	pt "gno.land/p/moul/x/social/patron/v0"
 16)
 17
 18// pageSize is how many plans the index lists at once.
 19const pageSize = 20
 20
 21// titleWidth is how much of a plan's title a listing shows.
 22const titleWidth = 40
 23
 24// Render routes two views: the index and one plan.
 25func Render(path string) string {
 26	req := realmpath.Parse(path)
 27
 28	switch {
 29	case strings.HasPrefix(req.Path, "plan/"):
 30		return renderPlan(strings.TrimPrefix(req.Path, "plan/"))
 31	case req.Path == "":
 32		return renderIndex(req)
 33	default:
 34		return md.H1("Not found") + "No such view: " + ui.Inline(req.Path)
 35	}
 36}
 37
 38func renderIndex(req *realmpath.Request) string {
 39	now := runtime.ChainHeight()
 40
 41	out := md.H1("Patron")
 42	out += "Recurring support for a builder. A creator opens a plan with a price per " +
 43		"period and a period measured in blocks, and a supporter buys whole periods of " +
 44		"it with " + md.InlineCode("ugnot") + ".\n\n"
 45	out += md.Blockquote("There is no cron on chain, so a renewal is a pull and not a " +
 46		"push: nothing here can charge anybody. A supporter renews by paying again, and " +
 47		"renewing early never loses time already paid for.") + "\n"
 48
 49	stats := ui.NewTable("plans", "owed to creators and supporters", "block")
 50	stats.Row(
 51		strconv.Itoa(plans.Count()),
 52		num.GNOTf(plans.TotalOwed()),
 53		strconv.FormatInt(now, 10),
 54	)
 55	out += stats.String()
 56
 57	out += md.H2("Plans")
 58	out += planTable(plans.List(pageNum(req), pageSize), now)
 59
 60	out += "\n" + ui.Action("βž• Open a plan", "Open",
 61		"title", "", "description", "", "pricePerPeriod", "", "periodBlocks", "") +
 62		" Β· " + ui.Action("πŸ’° Withdraw what I am owed", "Withdraw") + "\n"
 63
 64	out += md.H2("What it does not do")
 65	out += md.BulletList([]string{
 66		"**It does not charge anybody.** A renewal is a payment the supporter signs.",
 67		"**It does not stream.** A creator is credited the whole price at the moment " +
 68			"it is paid, so a supporter who stops being active is not refunded and no " +
 69			"part of a paid period comes back. Escrowed streaming needs a claim " +
 70			"schedule and a refund path, which is the v1.",
 71		"**It does not issue a token.** Minting a creator coin per period paid is " +
 72			"easy; the sink is not, because what it would be redeemed for is a promise " +
 73			"made off chain.",
 74	})
 75
 76	return out
 77}
 78
 79func renderPlan(raw string) string {
 80	id, ok := store.ParseID(raw)
 81	if !ok {
 82		return md.H1("Patron") + "Not a plan id: " + ui.Inline(raw)
 83	}
 84	p, found := plans.Get(id)
 85	if !found {
 86		return md.H1("Patron") + "No plan #" + id.String()
 87	}
 88	now := runtime.ChainHeight()
 89
 90	out := md.H1("Plan #" + id.String() + ": " + ui.Inline(p.Title))
 91	if p.Description != "" {
 92		out += md.Blockquote(ui.Inline(p.Description)) + "\n"
 93	}
 94
 95	status := "open"
 96	if !p.Open {
 97		status = "closed to new subscriptions"
 98	}
 99	facts := ui.NewTable("creator", "price per period", "period", "status")
100	facts.Row(
101		ui.Addr(p.Creator),
102		num.GNOTf(p.PricePerPeriod),
103		strconv.FormatInt(p.PeriodBlocks, 10)+" blocks",
104		status,
105	)
106	out += facts.String()
107
108	if p.Open {
109		// The link carries the send amount, so the price is not something a
110		// supporter has to copy out of the table and retype.
111		out += md.Link(
112			"πŸ’œ Support for one period ("+num.GNOTf(p.PricePerPeriod)+")",
113			txlink.Realm(realmPath).NewLink("Subscribe").
114				AddArgs("planID", id.String()).
115				SetSend(strconv.FormatInt(p.PricePerPeriod, 10)+denom).
116				URL(),
117		) + "\n\n"
118		out += "Paying " + md.InlineCode("n") + " times the price buys " +
119			md.InlineCode("n") + " periods at once. Anything left over under one " +
120			"period is credited back to you, not kept: take it with " +
121			md.InlineCode("Withdraw") + ".\n"
122	} else {
123		out += ui.Empty("This plan takes no new subscriptions. Anything already paid " +
124			"for runs to its own block.")
125	}
126
127	out += md.H2("Supporters")
128	out += supporterTable(p, now)
129
130	out += md.H2("Creator")
131	out += "Earned across every plan: " + md.Bold(num.GNOTf(plans.EarnedBy(p.Creator))) +
132		" Β· withdrawable now: " + md.Bold(num.GNOTf(plans.CreditOf(p.Creator))) + "\n\n"
133	out += ui.Action("πŸšͺ Close", "Close", "planID", id.String()) +
134		" Β· " + ui.Action("πŸ”“ Reopen", "Reopen", "planID", id.String()) +
135		" Β· " + ui.Action("πŸ’° Withdraw", "Withdraw") + "\n\n"
136	out += md.Link("← every plan", pt.RealmURL(realmPath)) + "\n"
137
138	return out
139}
140
141// planTable lists plans.
142//
143// The link text is the plan NUMBER and not its title, which is the one thing
144// to copy from here. md.Link escapes its text with the inline escaper, and the
145// inline escaper does not touch a pipe, because a pipe means nothing in a
146// sentence: a user title carried inside a link inside a table cell therefore
147// still opens a column. So the title gets its own cell and goes through
148// ui.Cell, which is the escaper that knows it is in a table. It is truncated
149// first with ui.ShortN, which does not escape, so the cut can never strand a
150// backslash that the escaper had just inserted.
151func planTable(items []pt.Listing, now int64) string {
152	t := ui.NewTable("#", "plan", "creator", "per period", "period", "supporters", "")
153	for _, it := range items {
154		mark := "🟒"
155		if !it.Plan.Open {
156			mark = "πŸ”’"
157		}
158		t.Row(
159			md.Link(it.ID.String(), pt.PlanURL(realmPath, it.ID)),
160			ui.Cell(ui.ShortN(it.Plan.Title, titleWidth, 0)),
161			ui.Addr(it.Plan.Creator),
162			num.GNOTf(it.Plan.PricePerPeriod),
163			strconv.FormatInt(it.Plan.PeriodBlocks, 10)+" blocks",
164			strconv.Itoa(it.Plan.ActiveCount(now))+" of "+strconv.Itoa(it.Plan.SupporterCount()),
165			mark,
166		)
167	}
168	return t.OrEmpty("No plans yet.")
169}
170
171func supporterTable(p *pt.Plan, now int64) string {
172	t := ui.NewTable("supporter", "paid through block", "active")
173	for _, who := range p.Supporters() {
174		active := "no"
175		if p.IsActive(who, now) {
176			active = "yes"
177		}
178		t.Row(ui.Addr(who), strconv.FormatInt(p.PaidThrough(who), 10), active)
179	}
180	return t.OrEmpty("Nobody supports this plan yet.")
181}
182
183// pageNum reads ?page= and falls back to the first page for anything it
184// cannot read, which is the only answer a Render can give: there is nobody to
185// report an error to.
186func pageNum(req *realmpath.Request) int {
187	n, err := strconv.Atoi(req.Query.Get("page"))
188	if err != nil || n < 1 {
189		return 1
190	}
191	return n
192}