Skip to main content

max / makenotwork

6.3 KB · 174 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 makeover_layout as layout;
33 use quasi_router::screen::Field;
34 use quasi_router::{Action, Node, RegionKind, Slot};
35
36 /// The region the offer sits in, and the disclosure that opens it.
37 pub const REGION: &str = "tip-offer";
38
39 /// The region holding the form itself, which is what the disclosure opens.
40 const FORM_REGION: &str = "tip-form";
41
42 /// What a page needs to know to offer a tip.
43 pub struct Offer<'a> {
44 /// Who is being tipped.
45 pub creator_id: &'a str,
46 /// Which project the tip is attributed to, on a project page.
47 pub project_id: Option<&'a str>,
48 /// Whether there is a session. Without one the offer is a link to sign in,
49 /// which is what the partial drew: a tip is a charge and a charge needs an
50 /// account.
51 pub signed_in: bool,
52 }
53
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 fn control(offer: &Offer<'_>) -> Node {
60 if !offer.signed_in {
61 return Node::Region(
62 Slot::new(REGION, RegionKind::Group)
63 .with(Node::act("Support", Action::get("/login").navigating())),
64 );
65 }
66
67 let mut amount = Field::new(layout::FieldKind::Number, "amount_dollars", "Tip amount")
68 .required()
69 .value("1")
70 .unit("$");
71 amount.min = Some("1".to_owned());
72 amount.max = Some("9999".to_owned());
73
74 let mut message = Field::new(layout::FieldKind::Textarea, "message", "Message (optional)");
75 message.placeholder = Some("Add a message (optional)".to_owned());
76 message.max_length = Some(280);
77
78 let mut action = Action::post(format!("/stripe/checkout/tip/{}", offer.creator_id));
79 if let Some(project) = offer.project_id {
80 action = action.with("project_id", project);
81 }
82
83 Node::Region(
84 Slot::new(REGION, RegionKind::Group)
85 .showing_at_most_one(None)
86 .with(Node::Region(
87 Slot::new(FORM_REGION, RegionKind::Group)
88 .label("Support")
89 .with(Node::Form {
90 action,
91 submit: "Send tip".to_owned(),
92 fields: vec![amount, message],
93 }),
94 )),
95 )
96 }
97
98 #[cfg(test)]
99 mod tests {
100 use super::*;
101 use quasi_webview::Webview;
102
103 fn html(node: &Node) -> String {
104 use quasi_axum::Serves as _;
105 Webview::new().fragment(node)
106 }
107
108 /// A reader with no session is offered the way to get one, which is what
109 /// the partial drew: a tip is a charge, and a charge needs an account.
110 #[test]
111 fn a_signed_out_reader_is_sent_to_sign_in() {
112 let rendered = html(&control(&Offer {
113 creator_id: "c1",
114 project_id: None,
115 signed_in: false,
116 }));
117 assert!(rendered.contains("/login"), "{rendered}");
118 assert!(!rendered.contains("amount_dollars"), "{rendered}");
119 }
120
121 /// The amount, the message and the cap the partial wrote on each.
122 #[test]
123 fn the_form_keeps_the_limits_the_partial_wrote() {
124 let rendered = html(&control(&Offer {
125 creator_id: "c1",
126 project_id: None,
127 signed_in: true,
128 }));
129 assert!(rendered.contains("name=\"amount_dollars\""), "{rendered}");
130 assert!(rendered.contains("min=\"1\""), "{rendered}");
131 assert!(rendered.contains("max=\"9999\""), "{rendered}");
132 assert!(rendered.contains("maxlength=\"280\""), "{rendered}");
133 }
134
135 /// A tip from a project page is attributed to it, which is what the
136 /// partial's second hidden field did.
137 #[test]
138 fn a_tip_from_a_project_page_carries_the_project() {
139 let rendered = html(&control(&Offer {
140 creator_id: "c1",
141 project_id: Some("p9"),
142 signed_in: true,
143 }));
144 assert!(rendered.contains("project_id"), "{rendered}");
145 assert!(rendered.contains("p9"), "{rendered}");
146 }
147
148 /// The token is the header's now. A hidden input is a second copy of
149 /// something that rotates mid-session.
150 #[test]
151 fn the_form_carries_no_hidden_token() {
152 let rendered = html(&control(&Offer {
153 creator_id: "c1",
154 project_id: None,
155 signed_in: true,
156 }));
157 assert!(!rendered.contains("_csrf"), "{rendered}");
158 assert!(!rendered.contains("type=\"hidden\""), "{rendered}");
159 }
160
161 /// The disclosure is the region's, so nothing on this page calls a
162 /// function in its own JavaScript to open it.
163 #[test]
164 fn the_offer_opens_without_a_script_of_its_own() {
165 let rendered = html(&control(&Offer {
166 creator_id: "c1",
167 project_id: None,
168 signed_in: true,
169 }));
170 assert!(!rendered.contains("toggleTipForm"), "{rendered}");
171 assert!(rendered.contains("data-shows"), "{rendered}");
172 }
173 }
174