Skip to main content

max / makenotwork

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