Skip to main content

max / makenotwork

11.7 KB · 311 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 quasi_declare::declare;
39 use quasi_router::{Request, Response, RouteError};
40 use quasi_webview::Webview;
41
42 use super::Viewer;
43 use crate::db;
44
45 /// This screen's name. Was the `QUASI_SCREENS` switch name until `64b33b26`
46 /// deleted the flag; it survives as the marker the tab strips read.
47 pub const SCREEN: &str = "buyer_contacts";
48
49 /// The address this screen answers, and the one the Askama route gives up.
50 pub const PATH: &str = "/dashboard/tabs/contacts";
51
52 /// The region the answer replaces.
53 ///
54 /// The Payments tab leaves an empty div here and fills it on `revealed`, so
55 /// this region is the whole of what the section is, not a pane it shares.
56 /// `pub` so the seam's mount can name it: a panel served from a residual
57 /// answers no `Response`, so the region it retargets is stated at the mount
58 /// rather than by the router. `library_contacts::REGION` is `pub` already.
59 pub const REGION: &str = "contacts-section";
60
61 /// One buyer who chose to share their email, as the screen needs it.
62 pub(crate) struct BuyerView {
63 username: String,
64 email: String,
65 purchases: String,
66 spent: String,
67 last_purchase: String,
68 }
69
70 /// The one read this section makes, for the mount that serves it from a
71 /// residual.
72 pub(crate) fn reading(viewer: &Viewer) -> Result<Vec<BuyerView>, RouteError> {
73 let contacts = viewer
74 .block_on(db::transactions::get_seller_contacts(
75 &viewer.app.db,
76 viewer.reader()?.id,
77 ))
78 .map_err(|_| RouteError::internal("your contacts could not be read"))?;
79
80 let currency = viewer.reader()?.settlement_currency;
81 Ok(contacts
82 .into_iter()
83 .map(|contact| BuyerView {
84 username: contact.username,
85 email: contact.email,
86 purchases: contact.total_purchases.to_string(),
87 spent: crate::formatting::format_revenue(contact.total_spent_cents, currency),
88 last_purchase: contact.last_purchase_at.format("%b %-d, %Y").to_string(),
89 })
90 .collect())
91 }
92
93 /// The section.
94 pub fn screen(viewer: &Viewer, _request: Request) -> Result<Response, RouteError> {
95 Ok(Response::fragment(REGION, pane(&reading(viewer)?)))
96 }
97
98 declare! {
99 /// Everything inside the section.
100 #[staged]
101 pub(crate) shape pane(buyers: &[BuyerView]) -> Node;
102
103 region REGION as Pane {
104 section "Shared Contacts ({buyers.len()})";
105 text "Buyers who opted to share their email at checkout. \
106 They can revoke sharing from their library.";
107
108 empty "No shared contacts yet. When buyers opt to share their email at checkout, \
109 they will appear here." when buyers.is_empty();
110
111 // The template hides the button and the table together: an export of an
112 // empty set is a file nobody wants.
113 include export() unless buyers.is_empty();
114 include table(buyers) unless buyers.is_empty();
115 }
116 }
117
118 declare! {
119 /// The Export CSV control.
120 ///
121 /// `Action::saving` is the whole of what used to be
122 /// `data-action="exportCsvButton" data-arg="/api/export/contacts"
123 /// data-arg2="contacts.csv"`: a class naming a behaviour, plus the two
124 /// things the behaviour needed, positionally. Said here it is one sentence,
125 /// the host performs it from one attribute, and a terminal renderer can
126 /// write the file to disk without being told which button this is.
127 ///
128 /// `awaiting` because the server assembles the file before any of it comes
129 /// back, which is the report case `Action::awaiting`'s own docs name.
130 /// Nothing countable to say about it: the row count is known here but the
131 /// bytes are not, and `layout::Awaiting` takes a measurement rather than a
132 /// stand-in for one.
133 #[must_use]
134 #[constant]
135 shape export() -> Node;
136
137 // Through `export_act` rather than spelled again here. This screen had the
138 // only described copy when it was written; there are five call sites now
139 // (`27d5e5b8`, the glue-module ruling), and one of them is the Askama
140 // fallback for this very tab. Two spellings of one control is what the
141 // conversion is for removing.
142 include super::export_act::act("/api/export/contacts", "contacts.csv");
143 }
144
145 declare! {
146 /// The buyers who shared an email.
147 ///
148 /// Cells by position rather than by name, which is the safe half of the
149 /// choice `Table` offers: the columns and the rows are the one block below,
150 /// and every buyer contributes the same five cells, so there is no seam for
151 /// a heading and a cell to drift across. Nothing is paged either, so no
152 /// `more`: this is a whole set the handler already counted.
153 #[staged]
154 shape table(buyers: &[BuyerView]) -> Node;
155
156 table {
157 column "Username" {
158 width Content;
159 priority Essential;
160 }
161 column "Email" {
162 width Fill;
163 priority Essential;
164 }
165 column "Purchases" {
166 width Content;
167 }
168 column "Total Spent" {
169 width Content;
170 }
171 column "Last Purchase" {
172 width Content;
173 priority Optional;
174 }
175
176 for buyer in buyers {
177 cells {
178 cell buyer.username.clone() {
179 activate to get "/u/{buyer.username}" navigating;
180 }
181 // Plain text, unlike `library_contacts`, and the templates
182 // differ the same way: a creator's own list does not link the
183 // address it is showing. Kept rather than harmonised, because
184 // which of the two is right is a design question and this batch
185 // is a conversion.
186 cell buyer.email.clone();
187 cell buyer.purchases.clone();
188 cell buyer.spent.clone();
189 cell buyer.last_purchase.clone();
190 }
191 }
192 }
193 }
194
195 /// The renderer this screen is drawn with.
196 pub fn renderer(viewer: &Viewer) -> Webview {
197 Webview::new().with_shell(viewer.shell())
198 }
199
200 /// One buyer as the tests draw it.
201 ///
202 /// Module-level rather than inside `mod tests` because `quasi::residuals` needs
203 /// one too, and `BuyerView` is this module's own type. Test-only.
204 #[cfg(test)]
205 pub(crate) fn sample(username: &str) -> BuyerView {
206 BuyerView {
207 username: username.into(),
208 email: format!("{username}@example.com"),
209 purchases: "3".into(),
210 spent: "$42.00".into(),
211 last_purchase: "Aug 10, 2026".into(),
212 }
213 }
214
215 #[cfg(test)]
216 mod tests {
217 use quasi_axum::Serves;
218 use quasi_router::Node;
219
220 use super::*;
221
222 use super::sample as buyer;
223
224 fn render(node: &Node) -> String {
225 Webview::new().fragment(node)
226 }
227
228 #[test]
229 fn the_region_is_the_one_the_payments_tab_leaves_empty() {
230 // The Payments tab fills this on `revealed`. If the id ever disagrees
231 // the section loads into nothing, and nothing else would notice.
232 let payments = include_str!("../../templates/partials/tabs/user_payments.html");
233 assert!(payments.contains(&format!("id=\"{REGION}\"")), "{REGION}");
234 assert!(payments.contains(&format!("hx-get=\"{PATH}\"")));
235 }
236
237 #[test]
238 fn the_export_says_what_it_produces_rather_than_naming_a_behaviour() {
239 let html = render(&export());
240
241 assert!(html.contains("data-saves=\"contacts.csv\""), "{html}");
242 assert!(html.contains("hx-post=\"/api/export/contacts\""), "{html}");
243 // The thing this replaced. A described screen naming a JS function by
244 // string would be the vocabulary gap papered over rather than closed.
245 assert!(!html.contains("data-action"), "{html}");
246 assert!(!html.contains("exportCsvButton"), "{html}");
247 }
248
249 #[test]
250 fn the_export_address_is_one_the_api_answers() {
251 // The S3 failure class: a control addressing a route registered nowhere
252 // renders fine and answers 404 when pressed.
253 let api = include_str!("../routes/api/mod.rs");
254 assert!(api.contains("/api/export/contacts"), "registered route");
255 }
256
257 #[test]
258 fn an_empty_list_offers_no_export_of_nothing() {
259 // The template hides the button and the table together, which is worth
260 // keeping: an export of an empty set is a file nobody wants.
261 let html = render(&pane(&[]));
262
263 assert!(html.contains("Shared Contacts (0)"), "{html}");
264 assert!(html.contains("No shared contacts yet."), "{html}");
265 assert!(!html.contains("data-saves"), "{html}");
266 assert!(!html.contains("role=\"table\""), "{html}");
267 }
268
269 #[test]
270 fn a_buyers_name_goes_to_their_profile() {
271 let html = render(&table(&[buyer("ada")]));
272
273 assert!(html.contains("href=\"/u/ada\""), "{html}");
274 assert!(
275 !html.contains("hx-get=\"/u/ada\""),
276 "a navigation carries no verb: {html}"
277 );
278 assert!(
279 html.contains("Shared Contacts") || html.contains("ada@example.com"),
280 "{html}"
281 );
282 // The address is shown and not linked here, unlike the library's view of
283 // the same data. Both templates say so; see `table`.
284 assert!(!html.contains("mailto:"), "{html}");
285 }
286
287 /// The five headings, which moved from a hand-written `Table::new` list into
288 /// the declaration. A column dropped on the way is silent: the cells still
289 /// render and land under the wrong name.
290 #[test]
291 fn every_column_the_table_had_is_still_named() {
292 let html = render(&table(&[buyer("ada")]));
293
294 for heading in [
295 "Username",
296 "Email",
297 "Purchases",
298 "Total Spent",
299 "Last Purchase",
300 ] {
301 assert!(html.contains(heading), "{heading} is gone from {html}");
302 }
303 }
304
305 #[test]
306 fn a_username_cannot_smuggle_markup() {
307 let html = render(&table(&[buyer("<script>x()</script>")]));
308 assert!(!html.contains("<script>x()"), "{html}");
309 }
310 }
311