Skip to main content

max / makenotwork

10.5 KB · 265 lines History Blame Raw
1 //! The creator's buyer-contacts section, described.
2 //!
3 //! S4's fourth batch, and the first taken from the set that a `data-action`
4 //! used to disqualify. Its only client behaviour was the Export CSV button, and
5 //! that turned out to be one idea on nine sites rather than a per-screen
6 //! bespoke: `Action::saving(name)` says the answer is a file the reader keeps,
7 //! and `htmx-glue.ts` performs it once for every screen that says so. See
8 //! [`export`].
9 //!
10 //! Compare `routes::pages::dashboard::tabs::user::dashboard_tab_contacts`,
11 //! which answers the same address from Askama when the screen is switched off.
12 //!
13 //! # This is the third copy of one table
14 //!
15 //! The same five columns over the same buyers already exist in
16 //! [`super::library_contacts`], which describes the reader's own view of who
17 //! shared an email with them. This is the creator's view of the same set, and
18 //! `templates/partials/tabs/buyer_contacts.html` was a third hand-written copy
19 //! of the markup. The forum-memberships batch found a pair; this makes it a
20 //! triple, and it is the same finding: a table written per template drifts per
21 //! template.
22 //!
23 //! Not folded into one function with `library_contacts` even so. The two screens
24 //! answer different questions of different people, their columns agree today by
25 //! coincidence rather than by contract, and a shared helper would make the next
26 //! divergence a merge conflict instead of an edit. The duplication worth
27 //! removing was the markup, and describing both removes it.
28 //!
29 //! # What it gives up
30 //!
31 //! The Askama version wraps the section in `<details open>`. Nothing names a
32 //! disclosure yet: 51 `<details>` sites were counted for it and it is filed on
33 //! quasicoherent, but it needs makeover-layout to name one first, so this is a
34 //! heading and its content. Since the template's disclosure is `open`, the loss
35 //! is the ability to collapse a section that starts expanded, and no reader
36 //! loses anything they can currently see.
37
38 use makeover_layout as layout;
39 use quasi_router::screen::{Cell, Cells, Column};
40 use quasi_router::{Action, Node, RegionKind, Request, Response, RouteError, Slot};
41 use quasi_webview::Webview;
42
43 use super::Viewer;
44 use crate::db;
45
46 /// This screen's name. Was the `QUASI_SCREENS` switch name until `64b33b26`
47 /// deleted the flag; it survives as the marker the tab strips read.
48 pub const SCREEN: &str = "buyer_contacts";
49
50 /// The address this screen answers, and the one the Askama route gives up.
51 pub const PATH: &str = "/dashboard/tabs/contacts";
52
53 /// The region the answer replaces.
54 ///
55 /// The Payments tab leaves an empty div here and fills it on `revealed`, so
56 /// this region is the whole of what the section is, not a pane it shares.
57 const REGION: &str = "contacts-section";
58
59 /// One buyer who chose to share their email, as the screen needs it.
60 pub struct BuyerView {
61 username: String,
62 email: String,
63 purchases: String,
64 spent: String,
65 last_purchase: String,
66 }
67
68 /// The section.
69 pub fn screen(viewer: &Viewer, _request: Request) -> Result<Response, RouteError> {
70 let contacts = viewer
71 .block_on(db::transactions::get_seller_contacts(
72 &viewer.app.db,
73 viewer.user.id,
74 ))
75 .map_err(|_| RouteError::internal("your contacts could not be read"))?;
76
77 let buyers: Vec<BuyerView> = contacts
78 .into_iter()
79 .map(|contact| BuyerView {
80 username: contact.username,
81 email: contact.email,
82 purchases: contact.total_purchases.to_string(),
83 spent: crate::formatting::format_revenue(
84 contact.total_spent_cents,
85 viewer.user.settlement_currency,
86 ),
87 last_purchase: contact.last_purchase_at.format("%b %-d, %Y").to_string(),
88 })
89 .collect();
90
91 Ok(Response::fragment(REGION, pane(&buyers)))
92 }
93
94 /// Everything inside the section.
95 fn pane(buyers: &[BuyerView]) -> Node {
96 let slot = Slot::new(REGION, RegionKind::Pane)
97 .with(Node::section(format!("Shared Contacts ({})", buyers.len())))
98 .with(Node::text(
99 "Buyers who opted to share their email at checkout. \
100 They can revoke sharing from their library.",
101 ));
102
103 if buyers.is_empty() {
104 return Node::Region(slot.with(Node::empty(
105 "No shared contacts yet. When buyers opt to share their email at checkout, \
106 they will appear here.",
107 )));
108 }
109
110 Node::Region(slot.with(export()).with(table(buyers)))
111 }
112
113 /// The Export CSV control.
114 ///
115 /// `Action::saving` is the whole of what used to be
116 /// `data-action="exportCsvButton" data-arg="/api/export/contacts"
117 /// data-arg2="contacts.csv"`: a class naming a behaviour, plus the two things
118 /// the behaviour needed, positionally. Said here it is one sentence, the host
119 /// performs it from one attribute, and a terminal renderer can write the file to
120 /// disk without being told which button this is.
121 ///
122 /// `awaiting` because the server assembles the file before any of it comes
123 /// back, which is the report case `Action::awaiting`'s own docs name. Nothing
124 /// countable to say about it: the row count is known here but the bytes are
125 /// not, and `layout::Awaiting` takes a measurement rather than a stand-in for
126 /// one.
127 fn export() -> Node {
128 // Through `export_act` rather than spelled again here. This screen had the
129 // only described copy when it was written; there are five call sites now
130 // (`27d5e5b8`, the glue-module ruling), and one of them is the Askama
131 // fallback for this very tab. Two spellings of one control is what the
132 // conversion is for removing.
133 super::export_act::act("/api/export/contacts", "contacts.csv")
134 }
135
136 /// The buyers who shared an email.
137 fn table(buyers: &[BuyerView]) -> Node {
138 Node::Table {
139 columns: vec![
140 Column::new("Username")
141 .width(layout::Width::Content)
142 .priority(layout::Priority::Essential),
143 Column::new("Email")
144 .width(layout::Width::Fill)
145 .priority(layout::Priority::Essential),
146 Column::new("Purchases").width(layout::Width::Content),
147 Column::new("Total Spent").width(layout::Width::Content),
148 Column::new("Last Purchase")
149 .width(layout::Width::Content)
150 .priority(layout::Priority::Optional),
151 ],
152 rows: buyers
153 .iter()
154 .map(|buyer| {
155 Cells::new([
156 Cell::new(buyer.username.clone())
157 .activate(Action::get(format!("/u/{}", buyer.username)).navigating()),
158 // Plain text, unlike `library_contacts`, and the templates
159 // differ the same way: a creator's own list does not link
160 // the address it is showing. Kept rather than harmonised,
161 // because which of the two is right is a design question
162 // and this batch is a conversion.
163 Cell::new(buyer.email.clone()),
164 Cell::new(buyer.purchases.clone()),
165 Cell::new(buyer.spent.clone()),
166 Cell::new(buyer.last_purchase.clone()),
167 ])
168 })
169 .collect(),
170 // No paging described here: every one of these tables is a
171 // whole set the handler already counted.
172 more: None,
173 }
174 }
175
176 /// The renderer this screen is drawn with.
177 pub fn renderer(viewer: &Viewer) -> Webview {
178 Webview::new().with_shell(viewer.shell())
179 }
180
181 #[cfg(test)]
182 mod tests {
183 use super::*;
184 use quasi_axum::Serves;
185
186 fn buyer(username: &str) -> BuyerView {
187 BuyerView {
188 username: username.into(),
189 email: format!("{username}@example.com"),
190 purchases: "3".into(),
191 spent: "$42.00".into(),
192 last_purchase: "Aug 10, 2026".into(),
193 }
194 }
195
196 fn render(node: &Node) -> String {
197 Webview::new().fragment(node)
198 }
199
200 #[test]
201 fn the_region_is_the_one_the_payments_tab_leaves_empty() {
202 // The Payments tab fills this on `revealed`. If the id ever disagrees
203 // the section loads into nothing, and nothing else would notice.
204 let payments = include_str!("../../templates/partials/tabs/user_payments.html");
205 assert!(payments.contains(&format!("id=\"{REGION}\"")), "{REGION}");
206 assert!(payments.contains(&format!("hx-get=\"{PATH}\"")));
207 }
208
209 #[test]
210 fn the_export_says_what_it_produces_rather_than_naming_a_behaviour() {
211 let html = render(&export());
212
213 assert!(html.contains("data-saves=\"contacts.csv\""), "{html}");
214 assert!(html.contains("hx-post=\"/api/export/contacts\""), "{html}");
215 // The thing this replaced. A described screen naming a JS function by
216 // string would be the vocabulary gap papered over rather than closed.
217 assert!(!html.contains("data-action"), "{html}");
218 assert!(!html.contains("exportCsvButton"), "{html}");
219 }
220
221 #[test]
222 fn the_export_address_is_one_the_api_answers() {
223 // The S3 failure class: a control addressing a route registered nowhere
224 // renders fine and answers 404 when pressed.
225 let api = include_str!("../routes/api/mod.rs");
226 assert!(api.contains("/api/export/contacts"), "registered route");
227 }
228
229 #[test]
230 fn an_empty_list_offers_no_export_of_nothing() {
231 // The template hides the button and the table together, which is worth
232 // keeping: an export of an empty set is a file nobody wants.
233 let html = render(&pane(&[]));
234
235 assert!(html.contains("Shared Contacts (0)"), "{html}");
236 assert!(html.contains("No shared contacts yet."), "{html}");
237 assert!(!html.contains("data-saves"), "{html}");
238 assert!(!html.contains("role=\"table\""), "{html}");
239 }
240
241 #[test]
242 fn a_buyers_name_goes_to_their_profile() {
243 let html = render(&table(&[buyer("ada")]));
244
245 assert!(html.contains("href=\"/u/ada\""), "{html}");
246 assert!(
247 !html.contains("hx-get=\"/u/ada\""),
248 "a navigation carries no verb: {html}"
249 );
250 assert!(
251 html.contains("Shared Contacts") || html.contains("ada@example.com"),
252 "{html}"
253 );
254 // The address is shown and not linked here, unlike the library's view of
255 // the same data. Both templates say so; see `table`.
256 assert!(!html.contains("mailto:"), "{html}");
257 }
258
259 #[test]
260 fn a_username_cannot_smuggle_markup() {
261 let html = render(&table(&[buyer("<script>x()</script>")]));
262 assert!(!html.contains("<script>x()"), "{html}");
263 }
264 }
265