Skip to main content

max / makenotwork

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