//! The creator's buyer-contacts section, described. //! //! S4's fourth batch, and the first taken from the set that a `data-action` //! used to disqualify. Its only client behaviour was the Export CSV button, and //! that turned out to be one idea on nine sites rather than a per-screen //! bespoke: `Action::saving(name)` says the answer is a file the reader keeps, //! and `htmx-glue.ts` performs it once for every screen that says so. See //! [`export`]. //! //! Compare `routes::pages::dashboard::tabs::user::dashboard_tab_contacts`, //! which answers the same address from Askama when the screen is switched off. //! //! # This is the third copy of one table //! //! The same five columns over the same buyers already exist in //! [`super::library_contacts`], which describes the reader's own view of who //! shared an email with them. This is the creator's view of the same set, and //! `templates/partials/tabs/buyer_contacts.html` was a third hand-written copy //! of the markup. The forum-memberships batch found a pair; this makes it a //! triple, and it is the same finding: a table written per template drifts per //! template. //! //! Not folded into one function with `library_contacts` even so. The two screens //! answer different questions of different people, their columns agree today by //! coincidence rather than by contract, and a shared helper would make the next //! divergence a merge conflict instead of an edit. The duplication worth //! removing was the markup, and describing both removes it. //! //! # What it gives up //! //! The Askama version wraps the section in `
`. Nothing names a //! disclosure yet: 51 `
` sites were counted for it and it is filed on //! quasicoherent, but it needs makeover-layout to name one first, so this is a //! heading and its content. Since the template's disclosure is `open`, the loss //! is the ability to collapse a section that starts expanded, and no reader //! loses anything they can currently see. use quasi_declare::declare; use quasi_router::{Request, Response, RouteError}; use quasi_webview::Webview; use super::Viewer; use crate::db; /// This screen's name. Was the `QUASI_SCREENS` switch name until `64b33b26` /// deleted the flag; it survives as the marker the tab strips read. pub const SCREEN: &str = "buyer_contacts"; /// The address this screen answers, and the one the Askama route gives up. pub const PATH: &str = "/dashboard/tabs/contacts"; /// The region the answer replaces. /// /// The Payments tab leaves an empty div here and fills it on `revealed`, so /// this region is the whole of what the section is, not a pane it shares. /// `pub` so the seam's mount can name it: a panel served from a residual /// answers no `Response`, so the region it retargets is stated at the mount /// rather than by the router. `library_contacts::REGION` is `pub` already. pub const REGION: &str = "contacts-section"; /// One buyer who chose to share their email, as the screen needs it. pub(crate) struct BuyerView { username: String, email: String, purchases: String, spent: String, last_purchase: String, } /// The one read this section makes, for the mount that serves it from a /// residual. pub(crate) fn reading(viewer: &Viewer) -> Result, RouteError> { let contacts = viewer .block_on(db::transactions::get_seller_contacts( &viewer.app.db, viewer.reader()?.id, )) .map_err(|_| RouteError::internal("your contacts could not be read"))?; let currency = viewer.reader()?.settlement_currency; Ok(contacts .into_iter() .map(|contact| BuyerView { username: contact.username, email: contact.email, purchases: contact.total_purchases.to_string(), spent: crate::formatting::format_revenue(contact.total_spent_cents, currency), last_purchase: contact.last_purchase_at.format("%b %-d, %Y").to_string(), }) .collect()) } /// The section. pub fn screen(viewer: &Viewer, _request: Request) -> Result { Ok(Response::fragment(REGION, pane(&reading(viewer)?))) } declare! { /// Everything inside the section. #[staged] pub(crate) shape pane(buyers: &[BuyerView]) -> Node; region REGION as Pane { section "Shared Contacts ({buyers.len()})"; text "Buyers who opted to share their email at checkout. \ They can revoke sharing from their library."; empty "No shared contacts yet. When buyers opt to share their email at checkout, \ they will appear here." when buyers.is_empty(); // The template hides the button and the table together: an export of an // empty set is a file nobody wants. include export() unless buyers.is_empty(); include table(buyers) unless buyers.is_empty(); } } declare! { /// The Export CSV control. /// /// `Action::saving` is the whole of what used to be /// `data-action="exportCsvButton" data-arg="/api/export/contacts" /// data-arg2="contacts.csv"`: a class naming a behaviour, plus the two /// things the behaviour needed, positionally. Said here it is one sentence, /// the host performs it from one attribute, and a terminal renderer can /// write the file to disk without being told which button this is. /// /// `awaiting` because the server assembles the file before any of it comes /// back, which is the report case `Action::awaiting`'s own docs name. /// Nothing countable to say about it: the row count is known here but the /// bytes are not, and `layout::Awaiting` takes a measurement rather than a /// stand-in for one. #[must_use] #[constant] shape export() -> Node; // Through `export_act` rather than spelled again here. This screen had the // only described copy when it was written; there are five call sites now // (`27d5e5b8`, the glue-module ruling), and one of them is the Askama // fallback for this very tab. Two spellings of one control is what the // conversion is for removing. include super::export_act::act("/api/export/contacts", "contacts.csv"); } declare! { /// The buyers who shared an email. /// /// Cells by position rather than by name, which is the safe half of the /// choice `Table` offers: the columns and the rows are the one block below, /// and every buyer contributes the same five cells, so there is no seam for /// a heading and a cell to drift across. Nothing is paged either, so no /// `more`: this is a whole set the handler already counted. #[staged] shape table(buyers: &[BuyerView]) -> Node; table { column "Username" { width Content; priority Essential; } column "Email" { width Fill; priority Essential; } column "Purchases" { width Content; } column "Total Spent" { width Content; } column "Last Purchase" { width Content; priority Optional; } for buyer in buyers { cells { cell buyer.username.clone() { activate to get "/u/{buyer.username}" navigating; } // Plain text, unlike `library_contacts`, and the templates // differ the same way: a creator's own list does not link the // address it is showing. Kept rather than harmonised, because // which of the two is right is a design question and this batch // is a conversion. cell buyer.email.clone(); cell buyer.purchases.clone(); cell buyer.spent.clone(); cell buyer.last_purchase.clone(); } } } } /// The renderer this screen is drawn with. pub fn renderer(viewer: &Viewer) -> Webview { Webview::new().with_shell(viewer.shell()) } /// One buyer as the tests draw it. /// /// Module-level rather than inside `mod tests` because `quasi::residuals` needs /// one too, and `BuyerView` is this module's own type. Test-only. #[cfg(test)] pub(crate) fn sample(username: &str) -> BuyerView { BuyerView { username: username.into(), email: format!("{username}@example.com"), purchases: "3".into(), spent: "$42.00".into(), last_purchase: "Aug 10, 2026".into(), } } #[cfg(test)] mod tests { use quasi_axum::Serves; use quasi_router::Node; use super::*; use super::sample as buyer; fn render(node: &Node) -> String { Webview::new().fragment(node) } #[test] fn the_region_is_the_one_the_payments_tab_leaves_empty() { // The Payments tab fills this on `revealed`. If the id ever disagrees // the section loads into nothing, and nothing else would notice. let payments = include_str!("../../templates/partials/tabs/user_payments.html"); assert!(payments.contains(&format!("id=\"{REGION}\"")), "{REGION}"); assert!(payments.contains(&format!("hx-get=\"{PATH}\""))); } #[test] fn the_export_says_what_it_produces_rather_than_naming_a_behaviour() { let html = render(&export()); assert!(html.contains("data-saves=\"contacts.csv\""), "{html}"); assert!(html.contains("hx-post=\"/api/export/contacts\""), "{html}"); // The thing this replaced. A described screen naming a JS function by // string would be the vocabulary gap papered over rather than closed. assert!(!html.contains("data-action"), "{html}"); assert!(!html.contains("exportCsvButton"), "{html}"); } #[test] fn the_export_address_is_one_the_api_answers() { // The S3 failure class: a control addressing a route registered nowhere // renders fine and answers 404 when pressed. let api = include_str!("../routes/api/mod.rs"); assert!(api.contains("/api/export/contacts"), "registered route"); } #[test] fn an_empty_list_offers_no_export_of_nothing() { // The template hides the button and the table together, which is worth // keeping: an export of an empty set is a file nobody wants. let html = render(&pane(&[])); assert!(html.contains("Shared Contacts (0)"), "{html}"); assert!(html.contains("No shared contacts yet."), "{html}"); assert!(!html.contains("data-saves"), "{html}"); assert!(!html.contains("role=\"table\""), "{html}"); } #[test] fn a_buyers_name_goes_to_their_profile() { let html = render(&table(&[buyer("ada")])); assert!(html.contains("href=\"/u/ada\""), "{html}"); assert!( !html.contains("hx-get=\"/u/ada\""), "a navigation carries no verb: {html}" ); assert!( html.contains("Shared Contacts") || html.contains("ada@example.com"), "{html}" ); // The address is shown and not linked here, unlike the library's view of // the same data. Both templates say so; see `table`. assert!(!html.contains("mailto:"), "{html}"); } /// The five headings, which moved from a hand-written `Table::new` list into /// the declaration. A column dropped on the way is silent: the cells still /// render and land under the wrong name. #[test] fn every_column_the_table_had_is_still_named() { let html = render(&table(&[buyer("ada")])); for heading in [ "Username", "Email", "Purchases", "Total Spent", "Last Purchase", ] { assert!(html.contains(heading), "{heading} is gone from {html}"); } } #[test] fn a_username_cannot_smuggle_markup() { let html = render(&table(&[buyer("")])); assert!(!html.contains("