Skip to main content

max / makenotwork

7.8 KB · 256 lines History Blame Raw
1 //! Item embed handlers: buy button, product card, audio player.
2
3 use crate::extractors::ValidatedQuery;
4 use axum::{
5 extract::{Path, State},
6 http::{HeaderValue, header},
7 response::{Html, IntoResponse, Response},
8 };
9 use serde::Deserialize;
10
11 use crate::quasi::embeds;
12 use crate::{
13 config::Config,
14 db::{self, ItemId},
15 error::{AppError, Result},
16 };
17 use quasi_router::Document;
18 use sqlx::PgPool;
19
20 /// Shared context fetched for all item embeds.
21 struct ItemEmbedContext {
22 title: String,
23 price_display: String,
24 button_text: String,
25 purchase_url: String,
26 cover_image_url: Option<String>,
27 creator_username: String,
28 creator_display_name: String,
29 description_excerpt: String,
30 #[allow(dead_code)]
31 item_type_label: String,
32 /// Whether the item has audio (for player embed eligibility).
33 has_audio: bool,
34 }
35
36 impl ItemEmbedContext {
37 /// What the described screens are drawn from.
38 ///
39 /// The profile URL is built here rather than fetched, because it is the one
40 /// address that is derived from a value this context already carries.
41 fn view(self, config: &Config) -> embeds::ItemView {
42 embeds::ItemView {
43 profile_url: format!("{}/u/{}", config.host_url, self.creator_username),
44 title: self.title,
45 price: self.price_display,
46 button_text: self.button_text,
47 purchase_url: self.purchase_url,
48 cover_image_url: self.cover_image_url,
49 creator_display_name: self.creator_display_name,
50 description_excerpt: self.description_excerpt,
51 }
52 }
53 }
54
55 async fn fetch_item_embed_context(
56 db: &PgPool,
57 config: &Config,
58 item_id: ItemId,
59 ) -> Result<ItemEmbedContext> {
60 let item = db::items::get_item_by_id(db, item_id)
61 .await?
62 .ok_or(AppError::NotFound)?;
63
64 if !item.is_public {
65 return Err(AppError::NotFound);
66 }
67
68 let project = db::projects::get_project_by_id(db, item.project_id)
69 .await?
70 .ok_or(AppError::NotFound)?;
71
72 let user = db::users::get_user_by_id(db, project.user_id)
73 .await?
74 .ok_or(AppError::NotFound)?;
75
76 if user.is_suspended() || user.is_deactivated() {
77 return Err(AppError::NotFound);
78 }
79
80 // Use the canonical formatter so the embed card matches every other surface
81 // (thousands separators, whole-dollar shortening) instead of an f64-divided
82 // "$12345.00" (Run #2 UX SERIOUS, price fragmentation).
83 let (price_display, button_text) = if item.price_cents == 0 {
84 ("Free".to_string(), "Get".to_string())
85 } else if item.pwyw_enabled {
86 (
87 format!(
88 "{}+",
89 crate::formatting::format_price(item.price_cents, user.settlement_currency)
90 ),
91 "Buy".to_string(),
92 )
93 } else {
94 (
95 crate::formatting::format_price(item.price_cents, user.settlement_currency),
96 "Buy".to_string(),
97 )
98 };
99
100 let purchase_url = format!("{}/buy/{}", config.host_url, item_id);
101
102 let description_excerpt = item
103 .description
104 .as_deref()
105 .unwrap_or("")
106 .chars()
107 .take(150)
108 .collect::<String>();
109
110 Ok(ItemEmbedContext {
111 title: item.title.clone(),
112 price_display,
113 button_text,
114 purchase_url,
115 cover_image_url: item.cover_image_url.clone(),
116 creator_username: user.username.to_string(),
117 creator_display_name: user
118 .display_name
119 .unwrap_or_else(|| user.username.to_string()),
120 description_excerpt,
121 item_type_label: item.item_type.label().to_string(),
122 has_audio: item.audio_s3_key.is_some(),
123 })
124 }
125
126 /// Set embed-specific caching. Framing + CSP for `/embed/*` are owned by the
127 /// global `security_headers_middleware` (which runs on the way out and
128 /// overwrites any X-Frame-Options/CSP set here), so this only sets Cache-Control
129 ///, the one header the middleware does not touch.
130 pub(super) fn set_embed_headers(response: &mut Response) {
131 let headers = response.headers_mut();
132 headers.insert(
133 header::CACHE_CONTROL,
134 HeaderValue::from_static("public, max-age=300"),
135 );
136 }
137
138 // --- Buy Button ---
139
140 #[tracing::instrument(skip_all, name = "embed::item_button")]
141 /// GET /embed/i/{item_id}/button
142 pub(super) async fn item_button(
143 State(db): State<PgPool>,
144 State(config): State<Config>,
145 Path(item_id): Path<ItemId>,
146 ) -> Result<Response> {
147 let ctx = fetch_item_embed_context(&db, &config, item_id).await?;
148
149 let view = ctx.view(&config);
150 let mut response = Html(embeds::document(
151 &embeds::item_button(&view).documented(Document::default().classed("embed-button")),
152 ))
153 .into_response();
154 set_embed_headers(&mut response);
155 Ok(response)
156 }
157
158 // --- Product Card ---
159
160 #[derive(Debug, Deserialize)]
161 pub(super) struct CardQuery {
162 pub layout: Option<String>,
163 }
164
165 #[tracing::instrument(skip_all, name = "embed::item_card")]
166 /// GET /embed/i/{item_id}/card
167 pub(super) async fn item_card(
168 State(db): State<PgPool>,
169 State(config): State<Config>,
170 Path(item_id): Path<ItemId>,
171 ValidatedQuery(query): ValidatedQuery<CardQuery>,
172 ) -> Result<Response> {
173 let ctx = fetch_item_embed_context(&db, &config, item_id).await?;
174 let layout = query.layout.as_deref().unwrap_or("vertical");
175 let is_horizontal = layout == "horizontal";
176
177 let view = ctx.view(&config);
178 // The two layouts are one description in two documents, which is what
179 // `Screen::document` is: the row is the same row, and how it is laid out in
180 // this frame is a fact about this host's furniture. Decided here rather
181 // than inside `item_card`, because which layout was asked for is a fact
182 // about the request.
183 let body_class = if is_horizontal {
184 "embed-card embed-card-horizontal"
185 } else {
186 "embed-card"
187 };
188 let mut response = Html(embeds::document(
189 &embeds::item_card(&view).documented(Document::default().classed(body_class)),
190 ))
191 .into_response();
192 set_embed_headers(&mut response);
193 Ok(response)
194 }
195
196 // --- Audio Player ---
197
198 /// GET /embed/i/{item_id}/player
199 ///
200 /// Audio preview player embed. Returns 404 for non-audio items.
201 #[tracing::instrument(skip_all, name = "embed::item_player")]
202 pub(super) async fn item_player(
203 State(db): State<PgPool>,
204 State(config): State<Config>,
205 Path(item_id): Path<ItemId>,
206 ) -> Result<Response> {
207 let ctx = fetch_item_embed_context(&db, &config, item_id).await?;
208
209 if !ctx.has_audio {
210 return Err(AppError::NotFound);
211 }
212
213 // Preview URL would be /embed/i/{item_id}/preview.mp3 once ffmpeg generation is built.
214 // For now, link to the item page, the player embed is a stub until preview generation ships.
215 let preview_url = format!("{}/api/stream/{}", config.host_url, item_id);
216
217 let view = ctx.view(&config);
218 let mut response = Html(embeds::player_document(&view, &preview_url)).into_response();
219 set_embed_headers(&mut response);
220 Ok(response)
221 }
222
223 #[cfg(test)]
224 mod tests {
225 #[test]
226 fn price_display_fixed() {
227 let price =
228 crate::formatting::format_revenue(1999, crate::currency::SettlementCurrency::Usd);
229 assert_eq!(price, "$19.99");
230 }
231
232 #[test]
233 fn price_display_pwyw() {
234 // Embed PWYW prices append "+" to the sealed revenue string.
235 let price = format!(
236 "{}+",
237 crate::formatting::format_revenue(500, crate::currency::SettlementCurrency::Usd)
238 );
239 assert_eq!(price, "$5.00+");
240 }
241
242 #[test]
243 fn description_excerpt_truncation() {
244 let long = "a".repeat(200);
245 let excerpt: String = long.chars().take(150).collect();
246 assert_eq!(excerpt.len(), 150);
247 }
248
249 #[test]
250 fn description_excerpt_short() {
251 let short = "Hello";
252 let excerpt: String = short.chars().take(150).collect();
253 assert_eq!(excerpt, "Hello");
254 }
255 }
256