Skip to main content

max / makenotwork

6.4 KB · 178 lines History Blame Raw
1 //! The tip control, described.
2 //!
3 //! "Support", and behind it an amount, an optional message and a submit that
4 //! hands the reader to Stripe. It replaces `templates/partials/tip_button.html`,
5 //! which both public creator pages included.
6 //!
7 //! # The disclosure is the vocabulary's now
8 //!
9 //! The partial drew a button carrying `data-action="toggleTipForm"` and a form
10 //! carrying `hidden`, and a function in `actions-pages.js` swapped the class.
11 //! A region that shows at most one child is what that is
12 //! ([`Slot::showing_at_most_one`]), and `quasi-webview` writes the control and
13 //! the program that moves it. Nothing is asked of the server to open it, which
14 //! is what it always meant.
15 //!
16 //! # The token is not a field any more
17 //!
18 //! The form carried a hidden `_csrf`. `create_tip_checkout` reads the token
19 //! header-first and falls back to the field, and a described form is an htmx
20 //! post, so `frontend/src/core/htmx-glue.ts` attaches the header from the
21 //! document's `csrf-token` meta. The hidden input was a second copy of a token
22 //! that rotates mid-session. Same removal the reset link's token got, for the
23 //! same reason.
24 //!
25 //! # And the answer is a redirect
26 //!
27 //! `create_tip_checkout` ends at Stripe, so it answers a 303. An htmx request
28 //! follows that in the browser and swaps whatever came back into the control
29 //! that sent it, which is the failure `c7b0d3c1` hit on the header's Log Out.
30 //! The handler answers `HX-Redirect` to an htmx caller for that reason.
31
32 use quasi_declare::declare;
33 use quasi_router::Action;
34
35 /// The region the offer sits in, and the disclosure that opens it.
36 pub const REGION: &str = "tip-offer";
37
38 /// The region holding the form itself, which is what the disclosure opens.
39 const FORM_REGION: &str = "tip-form";
40
41 /// What a page needs to know to offer a tip.
42 pub struct Offer<'a> {
43 /// Who is being tipped.
44 pub creator_id: &'a str,
45 /// Which project the tip is attributed to, on a project page.
46 pub project_id: Option<&'a str>,
47 /// Whether there is a session. Without one the offer is a link to sign in,
48 /// which is what the partial drew: a tip is a charge and a charge needs an
49 /// account.
50 pub signed_in: bool,
51 }
52
53 declare! {
54 /// The offer, as the page carries it.
55 ///
56 /// `None` from the caller when the creator does not take tips; this returns the
57 /// node for when they do.
58 #[must_use]
59 pub shape control(offer: &Offer<'_>) -> Node;
60
61 given offer.signed_in {
62 false -> region REGION as Group {
63 act "Support" to get "/login" navigating;
64 }
65 true -> region REGION as Group {
66 showing_at_most_one None;
67 region FORM_REGION as Group {
68 label "Support";
69 form doing checkout(offer) {
70 submit "Send tip";
71 field Number "amount_dollars" "Tip amount" {
72 required;
73 value "1";
74 unit "$";
75 within "1" "9999";
76 }
77 field Textarea "message" "Message (optional)" {
78 placeholder "Add a message (optional)";
79 limited_to 280;
80 }
81 }
82 }
83 }
84 }
85 }
86
87 /// Where a tip is taken, and what it is attributed to.
88 ///
89 /// A supplier rather than a spelled-out action, because the project is carried
90 /// only on a project page and the form has no way to say "and this value too,
91 /// sometimes". Amendment 6's `doing` is the remedy the form names for exactly
92 /// this.
93 fn checkout(offer: &Offer<'_>) -> Action {
94 let action = Action::post(format!("/stripe/checkout/tip/{}", offer.creator_id));
95 match offer.project_id {
96 Some(project) => action.with("project_id", project),
97 None => action,
98 }
99 }
100
101 #[cfg(test)]
102 mod tests {
103 use super::*;
104 use quasi_router::Node;
105 use quasi_webview::Webview;
106
107 fn html(node: &Node) -> String {
108 use quasi_axum::Serves as _;
109 Webview::new().fragment(node)
110 }
111
112 /// A reader with no session is offered the way to get one, which is what
113 /// the partial drew: a tip is a charge, and a charge needs an account.
114 #[test]
115 fn a_signed_out_reader_is_sent_to_sign_in() {
116 let rendered = html(&control(&Offer {
117 creator_id: "c1",
118 project_id: None,
119 signed_in: false,
120 }));
121 assert!(rendered.contains("/login"), "{rendered}");
122 assert!(!rendered.contains("amount_dollars"), "{rendered}");
123 }
124
125 /// The amount, the message and the cap the partial wrote on each.
126 #[test]
127 fn the_form_keeps_the_limits_the_partial_wrote() {
128 let rendered = html(&control(&Offer {
129 creator_id: "c1",
130 project_id: None,
131 signed_in: true,
132 }));
133 assert!(rendered.contains("name=\"amount_dollars\""), "{rendered}");
134 assert!(rendered.contains("min=\"1\""), "{rendered}");
135 assert!(rendered.contains("max=\"9999\""), "{rendered}");
136 assert!(rendered.contains("maxlength=\"280\""), "{rendered}");
137 }
138
139 /// A tip from a project page is attributed to it, which is what the
140 /// partial's second hidden field did.
141 #[test]
142 fn a_tip_from_a_project_page_carries_the_project() {
143 let rendered = html(&control(&Offer {
144 creator_id: "c1",
145 project_id: Some("p9"),
146 signed_in: true,
147 }));
148 assert!(rendered.contains("project_id"), "{rendered}");
149 assert!(rendered.contains("p9"), "{rendered}");
150 }
151
152 /// The token is the header's now. A hidden input is a second copy of
153 /// something that rotates mid-session.
154 #[test]
155 fn the_form_carries_no_hidden_token() {
156 let rendered = html(&control(&Offer {
157 creator_id: "c1",
158 project_id: None,
159 signed_in: true,
160 }));
161 assert!(!rendered.contains("_csrf"), "{rendered}");
162 assert!(!rendered.contains("type=\"hidden\""), "{rendered}");
163 }
164
165 /// The disclosure is the region's, so nothing on this page calls a
166 /// function in its own JavaScript to open it.
167 #[test]
168 fn the_offer_opens_without_a_script_of_its_own() {
169 let rendered = html(&control(&Offer {
170 creator_id: "c1",
171 project_id: None,
172 signed_in: true,
173 }));
174 assert!(!rendered.contains("toggleTipForm"), "{rendered}");
175 assert!(rendered.contains("data-shows"), "{rendered}");
176 }
177 }
178