//! The tip control, described. //! //! "Support", and behind it an amount, an optional message and a submit that //! hands the reader to Stripe. It replaces `templates/partials/tip_button.html`, //! which both public creator pages included. //! //! # The disclosure is the vocabulary's now //! //! The partial drew a button carrying `data-action="toggleTipForm"` and a form //! carrying `hidden`, and a function in `actions-pages.js` swapped the class. //! A region that shows at most one child is what that is //! ([`Slot::showing_at_most_one`]), and `quasi-webview` writes the control and //! the program that moves it. Nothing is asked of the server to open it, which //! is what it always meant. //! //! # The token is not a field any more //! //! The form carried a hidden `_csrf`. `create_tip_checkout` reads the token //! header-first and falls back to the field, and a described form is an htmx //! post, so `frontend/src/core/htmx-glue.ts` attaches the header from the //! document's `csrf-token` meta. The hidden input was a second copy of a token //! that rotates mid-session. Same removal the reset link's token got, for the //! same reason. //! //! # And the answer is a redirect //! //! `create_tip_checkout` ends at Stripe, so it answers a 303. An htmx request //! follows that in the browser and swaps whatever came back into the control //! that sent it, which is the failure `c7b0d3c1` hit on the header's Log Out. //! The handler answers `HX-Redirect` to an htmx caller for that reason. use quasi_declare::declare; use quasi_router::Action; /// The region the offer sits in, and the disclosure that opens it. pub const REGION: &str = "tip-offer"; /// The region holding the form itself, which is what the disclosure opens. const FORM_REGION: &str = "tip-form"; /// What a page needs to know to offer a tip. pub struct Offer<'a> { /// Who is being tipped. pub creator_id: &'a str, /// Which project the tip is attributed to, on a project page. pub project_id: Option<&'a str>, /// Whether there is a session. Without one the offer is a link to sign in, /// which is what the partial drew: a tip is a charge and a charge needs an /// account. pub signed_in: bool, } declare! { /// The offer, as the page carries it. /// /// `None` from the caller when the creator does not take tips; this returns the /// node for when they do. #[must_use] pub shape control(offer: &Offer<'_>) -> Node; given offer.signed_in { false -> region REGION as Group { act "Support" to get "/login" navigating; } true -> region REGION as Group { showing_at_most_one None; region FORM_REGION as Group { label "Support"; form doing checkout(offer) { submit "Send tip"; field Number "amount_dollars" "Tip amount" { required; value "1"; unit "$"; within "1" "9999"; } field Textarea "message" "Message (optional)" { placeholder "Add a message (optional)"; limited_to 280; } } } } } } /// Where a tip is taken, and what it is attributed to. /// /// A supplier rather than a spelled-out action, because the project is carried /// only on a project page and the form has no way to say "and this value too, /// sometimes". Amendment 6's `doing` is the remedy the form names for exactly /// this. fn checkout(offer: &Offer<'_>) -> Action { let action = Action::post(format!("/stripe/checkout/tip/{}", offer.creator_id)); match offer.project_id { Some(project) => action.with("project_id", project), None => action, } } #[cfg(test)] mod tests { use super::*; use quasi_router::Node; use quasi_webview::Webview; fn html(node: &Node) -> String { use quasi_axum::Serves as _; Webview::new().fragment(node) } /// A reader with no session is offered the way to get one, which is what /// the partial drew: a tip is a charge, and a charge needs an account. #[test] fn a_signed_out_reader_is_sent_to_sign_in() { let rendered = html(&control(&Offer { creator_id: "c1", project_id: None, signed_in: false, })); assert!(rendered.contains("/login"), "{rendered}"); assert!(!rendered.contains("amount_dollars"), "{rendered}"); } /// The amount, the message and the cap the partial wrote on each. #[test] fn the_form_keeps_the_limits_the_partial_wrote() { let rendered = html(&control(&Offer { creator_id: "c1", project_id: None, signed_in: true, })); assert!(rendered.contains("name=\"amount_dollars\""), "{rendered}"); assert!(rendered.contains("min=\"1\""), "{rendered}"); assert!(rendered.contains("max=\"9999\""), "{rendered}"); assert!(rendered.contains("maxlength=\"280\""), "{rendered}"); } /// A tip from a project page is attributed to it, which is what the /// partial's second hidden field did. #[test] fn a_tip_from_a_project_page_carries_the_project() { let rendered = html(&control(&Offer { creator_id: "c1", project_id: Some("p9"), signed_in: true, })); assert!(rendered.contains("project_id"), "{rendered}"); assert!(rendered.contains("p9"), "{rendered}"); } /// The token is the header's now. A hidden input is a second copy of /// something that rotates mid-session. #[test] fn the_form_carries_no_hidden_token() { let rendered = html(&control(&Offer { creator_id: "c1", project_id: None, signed_in: true, })); assert!(!rendered.contains("_csrf"), "{rendered}"); assert!(!rendered.contains("type=\"hidden\""), "{rendered}"); } /// The disclosure is the region's, so nothing on this page calls a /// function in its own JavaScript to open it. #[test] fn the_offer_opens_without_a_script_of_its_own() { let rendered = html(&control(&Offer { creator_id: "c1", project_id: None, signed_in: true, })); assert!(!rendered.contains("toggleTipForm"), "{rendered}"); assert!(rendered.contains("data-shows"), "{rendered}"); } }