Skip to main content

max / makenotwork

Describe the buyer contacts section, export included
Author: Max Johnson <me@maxj.phd> · 2026-08-11 02:28 UTC
Signed with PGP, not checked
Commit: 3c29f6fcf3e614605909ae59172965b23fbe7b23
Parent: cfa6887
4 files changed, +313 insertions, -4 deletions
@@ -31,6 +31,7 @@
31 31 use crate::AppState;
32 32 use crate::auth::SessionUser;
33 33
34 + pub mod buyer_contacts;
34 35 pub mod forum_memberships;
35 36 pub mod library_contacts;
36 37 pub mod ssh_keys;
@@ -173,6 +174,12 @@
173 174 ),
174 175 ));
175 176 }
177 + if described(app, buyer_contacts::SCREEN) {
178 + mounted.push((
179 + buyer_contacts::PATH,
180 + mount(app, buyer_contacts::screen, buyer_contacts::renderer),
181 + ));
182 + }
176 183 if described(app, user_analytics::SCREEN) {
177 184 mounted.push((
178 185 user_analytics::PATH,
@@ -3,6 +3,7 @@
3 3 // the plain (non-HTMX) form-submit loading + bfcache reset (431).
4 4
5 5 import { resolveHtmxLoadingButton } from './loading.ts';
6 + import { csrfHeaders } from './net.ts';
6 7 import { showToast } from './toast.ts';
7 8
8 9 interface HxDetail {
@@ -121,6 +122,55 @@
121 122 }, 1200);
122 123 });
123 124
125 + // data-saves: the answer is a file the reader keeps, not a view.
126 + //
127 + // Emitted by the description layer from `Action::saving(name)`. It replaces
128 + // window.exportCsvButton, which said the same thing as a class name plus two
129 + // positional arguments and had to be repeated per button. A described screen
130 + // says what it means and this performs it once for all of them.
131 + //
132 + // htmx cannot do this itself: it swaps a response into the DOM, and a CSV is
133 + // not markup. So the request is cancelled here and reissued as a fetch whose
134 + // body becomes a download. A read never reaches this at all, because a link
135 + // carries a `download` attribute instead and the browser does the whole job.
136 + body.addEventListener('htmx:beforeRequest', (e) => {
137 + const evt = e as CustomEvent<HxDetail & { xhr: XMLHttpRequest }>;
138 + const elt = evt.detail.elt;
139 + const saveAs = elt?.dataset?.saves;
140 + if (!saveAs) return;
141 + evt.preventDefault();
142 +
143 + const url = elt.getAttribute('hx-post') ?? elt.getAttribute('hx-put') ?? '';
144 + if (!url) return;
145 + const restore = elt.textContent ?? '';
146 + elt.textContent = 'Exporting...';
147 + if (elt instanceof HTMLButtonElement) elt.disabled = true;
148 + const done = (): void => {
149 + elt.textContent = restore;
150 + if (elt instanceof HTMLButtonElement) elt.disabled = false;
151 + };
152 +
153 + void fetch(url, { method: 'POST', headers: csrfHeaders() })
154 + .then((r) => {
155 + if (!r.ok) throw new Error(String(r.status));
156 + return r.blob();
157 + })
158 + .then((blob) => {
159 + const href = URL.createObjectURL(blob);
160 + const link = document.createElement('a');
161 + link.href = href;
162 + link.download = saveAs;
163 + link.click();
164 + // Or the blob is held for the life of the document.
165 + URL.revokeObjectURL(href);
166 + done();
167 + })
168 + .catch(() => {
169 + done();
170 + showToast('Export failed', 'error');
171 + });
172 + });
173 +
124 174 // Plain (non-HTMX) form submit: swap the label while the browser round-trips
125 175 // (e.g. Stripe checkout). Opt in via data-loading-text on the submit button.
126 176 body.addEventListener(
@@ -82,10 +82,6 @@
82 82 // The SSH-keys tab is registered below rather than here: when its
83 83 // screen is switched on, `crate::quasi` serves this address instead and
84 84 // axum panics on two routes claiming one path.
85 - .route_get(
86 - "/dashboard/tabs/contacts",
87 - get(tabs::dashboard_tab_contacts),
88 - )
89 85 .route_get(
90 86 "/dashboard/tabs/payout-summary",
91 87 get(tabs::dashboard_tab_payout_summary),
@@ -167,6 +163,14 @@
167 163 get(tabs::dashboard_tab_ssh_keys),
168 164 )
169 165 };
166 + let tab_routes = if screens.enabled(crate::quasi::buyer_contacts::SCREEN) {
167 + tab_routes
168 + } else {
169 + tab_routes.route_get(
170 + crate::quasi::buyer_contacts::PATH,
171 + get(tabs::dashboard_tab_contacts),
172 + )
173 + };
170 174 let tab_routes = if screens.enabled(crate::quasi::user_analytics::SCREEN) {
171 175 tab_routes
172 176 } else {
@@ -1,0 +1,248 @@
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::{Shell, Webview};
42 +
43 + use super::Viewer;
44 + use crate::db;
45 +
46 + /// The conversion switch's name for this screen. `QUASI_SCREENS=buyer_contacts`.
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 + const REGION: &str = "contacts-section";
57 +
58 + /// One buyer who chose to share their email, as the screen needs it.
59 + pub struct BuyerView {
60 + username: String,
61 + email: String,
62 + purchases: String,
63 + spent: String,
64 + last_purchase: String,
65 + }
66 +
67 + /// The section.
68 + pub fn screen(viewer: &Viewer, _request: Request) -> Result<Response, RouteError> {
69 + let contacts = viewer
70 + .block_on(db::transactions::get_seller_contacts(
71 + &viewer.app.db,
72 + viewer.user.id,
73 + ))
74 + .map_err(|_| RouteError::internal("your contacts could not be read"))?;
75 +
76 + let buyers: Vec<BuyerView> = contacts
77 + .into_iter()
78 + .map(|contact| BuyerView {
79 + username: contact.username,
80 + email: contact.email,
81 + purchases: contact.total_purchases.to_string(),
82 + spent: crate::formatting::format_revenue(
83 + contact.total_spent_cents,
84 + viewer.user.settlement_currency,
85 + ),
86 + last_purchase: contact.last_purchase_at.format("%b %-d, %Y").to_string(),
87 + })
88 + .collect();
89 +
90 + Ok(Response::fragment(REGION, pane(&buyers)))
91 + }
92 +
93 + /// Everything inside the section.
94 + fn pane(buyers: &[BuyerView]) -> Node {
95 + let slot = Slot::new(REGION, RegionKind::Pane)
96 + .with(Node::section(format!("Shared Contacts ({})", buyers.len())))
97 + .with(Node::text(
98 + "Buyers who opted to share their email at checkout. \
99 + They can revoke sharing from their library.",
100 + ));
101 +
102 + if buyers.is_empty() {
103 + return Node::Region(slot.with(Node::empty(
104 + "No shared contacts yet. When buyers opt to share their email at checkout, \
105 + they will appear here.",
106 + )));
107 + }
108 +
109 + Node::Region(slot.with(export()).with(table(buyers)))
110 + }
111 +
112 + /// The Export CSV control.
113 + ///
114 + /// `Action::saving` is the whole of what used to be
115 + /// `data-action="exportCsvButton" data-arg="/api/export/contacts"
116 + /// data-arg2="contacts.csv"`: a class naming a behaviour, plus the two things
117 + /// the behaviour needed, positionally. Said here it is one sentence, the host
118 + /// performs it from one attribute, and a terminal renderer can write the file to
119 + /// disk without being told which button this is.
120 + fn export() -> Node {
121 + Node::act(
122 + "Export CSV",
123 + Action::post("/api/export/contacts").saving("contacts.csv"),
124 + )
125 + }
126 +
127 + /// The buyers who shared an email.
128 + fn table(buyers: &[BuyerView]) -> Node {
129 + Node::Table {
130 + columns: vec![
131 + Column::new("Username")
132 + .width(layout::Width::Content)
133 + .priority(layout::Priority::Essential),
134 + Column::new("Email")
135 + .width(layout::Width::Fill)
136 + .priority(layout::Priority::Essential),
137 + Column::new("Purchases").width(layout::Width::Content),
138 + Column::new("Total Spent").width(layout::Width::Content),
139 + Column::new("Last Purchase")
140 + .width(layout::Width::Content)
141 + .priority(layout::Priority::Optional),
142 + ],
143 + rows: buyers
144 + .iter()
145 + .map(|buyer| {
146 + Cells::new([
147 + Cell::new(buyer.username.clone())
148 + .activate(Action::get(format!("/u/{}", buyer.username))),
149 + // Plain text, unlike `library_contacts`, and the templates
150 + // differ the same way: a creator's own list does not link
151 + // the address it is showing. Kept rather than harmonised,
152 + // because which of the two is right is a design question
153 + // and this batch is a conversion.
154 + Cell::new(buyer.email.clone()),
155 + Cell::new(buyer.purchases.clone()),
156 + Cell::new(buyer.spent.clone()),
157 + Cell::new(buyer.last_purchase.clone()),
158 + ])
159 + })
160 + .collect(),
161 + }
162 + }
163 +
164 + /// The renderer this screen is drawn with.
165 + pub fn renderer(_viewer: &Viewer) -> Webview {
166 + Webview::new().with_shell(Shell::under("/static").layered(["base", "components", "responsive"]))
167 + }
168 +
169 + #[cfg(test)]
170 + mod tests {
171 + use super::*;
172 + use quasi_axum::Render;
173 +
174 + fn buyer(username: &str) -> BuyerView {
175 + BuyerView {
176 + username: username.into(),
177 + email: format!("{username}@example.com"),
178 + purchases: "3".into(),
179 + spent: "$42.00".into(),
180 + last_purchase: "Aug 10, 2026".into(),
181 + }
182 + }
183 +
184 + fn render(node: &Node) -> String {
185 + Webview::new().fragment(node)
186 + }
187 +
188 + #[test]
189 + fn the_region_is_the_one_the_payments_tab_leaves_empty() {
190 + // The Payments tab fills this on `revealed`. If the id ever disagrees
191 + // the section loads into nothing, and nothing else would notice.
192 + let payments = include_str!("../../templates/partials/tabs/user_payments.html");
193 + assert!(payments.contains(&format!("id=\"{REGION}\"")), "{REGION}");
194 + assert!(payments.contains(&format!("hx-get=\"{PATH}\"")));
195 + }
196 +
197 + #[test]
198 + fn the_export_says_what_it_produces_rather_than_naming_a_behaviour() {
199 + let html = render(&export());
200 +
201 + assert!(html.contains("data-saves=\"contacts.csv\""), "{html}");
202 + assert!(html.contains("hx-post=\"/api/export/contacts\""), "{html}");
203 + // The thing this replaced. A described screen naming a JS function by
204 + // string would be the vocabulary gap papered over rather than closed.
205 + assert!(!html.contains("data-action"), "{html}");
206 + assert!(!html.contains("exportCsvButton"), "{html}");
207 + }
208 +
209 + #[test]
210 + fn the_export_address_is_one_the_api_answers() {
211 + // The S3 failure class: a control addressing a route registered nowhere
212 + // renders fine and answers 404 when pressed.
213 + let api = include_str!("../routes/api/mod.rs");
214 + assert!(api.contains("/api/export/contacts"), "registered route");
215 + }
216 +
217 + #[test]
218 + fn an_empty_list_offers_no_export_of_nothing() {
219 + // The template hides the button and the table together, which is worth
220 + // keeping: an export of an empty set is a file nobody wants.
221 + let html = render(&pane(&[]));
222 +
223 + assert!(html.contains("Shared Contacts (0)"), "{html}");
224 + assert!(html.contains("No shared contacts yet."), "{html}");
225 + assert!(!html.contains("data-saves"), "{html}");
226 + assert!(!html.contains("role=\"table\""), "{html}");
227 + }
228 +
229 + #[test]
230 + fn a_buyers_name_goes_to_their_profile() {
231 + let html = render(&table(&[buyer("ada")]));
232 +
233 + assert!(html.contains("href=\"/u/ada\""), "{html}");
234 + assert!(
235 + html.contains("Shared Contacts") || html.contains("ada@example.com"),
236 + "{html}"
237 + );
238 + // The address is shown and not linked here, unlike the library's view of
239 + // the same data. Both templates say so; see `table`.
240 + assert!(!html.contains("mailto:"), "{html}");
241 + }
242 +
243 + #[test]
244 + fn a_username_cannot_smuggle_markup() {
245 + let html = render(&table(&[buyer("<script>x()</script>")]));
246 + assert!(!html.contains("<script>x()"), "{html}");
247 + }
248 + }