Skip to main content

max / makenotwork

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