|
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 |
+ |
}
|