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}