//! Item embed handlers: buy button, product card, audio player. use axum::{ extract::{Path, Query, State}, http::{header, HeaderValue}, response::{IntoResponse, Response}, }; use serde::Deserialize; use crate::{ db::{self, ItemId}, error::{AppError, Result}, AppState, }; /// Shared context fetched for all item embeds. struct ItemEmbedContext { title: String, price_display: String, button_text: String, purchase_url: String, cover_image_url: Option, creator_username: String, creator_display_name: String, description_excerpt: String, #[allow(dead_code)] item_type_label: String, /// Whether the item has audio (for player embed eligibility). has_audio: bool, } async fn fetch_item_embed_context(state: &AppState, item_id: ItemId) -> Result { let item = db::items::get_item_by_id(&state.db, item_id) .await? .ok_or(AppError::NotFound)?; if !item.is_public { return Err(AppError::NotFound); } let project = db::projects::get_project_by_id(&state.db, item.project_id) .await? .ok_or(AppError::NotFound)?; let user = db::users::get_user_by_id(&state.db, project.user_id) .await? .ok_or(AppError::NotFound)?; if user.is_suspended() || user.is_deactivated() { return Err(AppError::NotFound); } let (price_display, button_text) = if item.price_cents == 0 { ("Free".to_string(), "Get".to_string()) } else if item.pwyw_enabled { (format!("${:.2}+", item.price_cents as f64 / 100.0), "Buy".to_string()) } else { (format!("${:.2}", item.price_cents as f64 / 100.0), "Buy".to_string()) }; let purchase_url = format!("{}/buy/{}", state.config.host_url, item_id); let description_excerpt = item.description.as_deref() .unwrap_or("") .chars() .take(150) .collect::(); Ok(ItemEmbedContext { title: item.title.clone(), price_display, button_text, purchase_url, cover_image_url: item.cover_image_url.clone(), creator_username: user.username.to_string(), creator_display_name: user.display_name.unwrap_or_else(|| user.username.to_string()), description_excerpt, item_type_label: item.item_type.label().to_string(), has_audio: item.audio_s3_key.is_some(), }) } /// Set embed-specific caching. Framing + CSP for `/embed/*` are owned by the /// global `security_headers_middleware` (which runs on the way out and /// overwrites any X-Frame-Options/CSP set here), so this only sets Cache-Control /// — the one header the middleware does not touch. pub(super) fn set_embed_headers(response: &mut Response) { let headers = response.headers_mut(); headers.insert( header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=300"), ); } // ─── Buy Button ───────────────────────────────────────────────────────────── #[tracing::instrument(skip_all, name = "embed::item_button")] /// GET /embed/i/{item_id}/button pub(super) async fn item_button( State(state): State, Path(item_id): Path, ) -> Result { let ctx = fetch_item_embed_context(&state, item_id).await?; let mut response = crate::templates::EmbedItemButtonTemplate { title: ctx.title, price_display: ctx.price_display, purchase_url: ctx.purchase_url, button_text: ctx.button_text, cover_image_url: ctx.cover_image_url, } .into_response(); set_embed_headers(&mut response); Ok(response) } // ─── Product Card ─────────────────────────────────────────────────────────── #[derive(Debug, Deserialize)] pub(super) struct CardQuery { pub layout: Option, } #[tracing::instrument(skip_all, name = "embed::item_card")] /// GET /embed/i/{item_id}/card pub(super) async fn item_card( State(state): State, Path(item_id): Path, Query(query): Query, ) -> Result { let ctx = fetch_item_embed_context(&state, item_id).await?; let layout = query.layout.as_deref().unwrap_or("vertical"); let is_horizontal = layout == "horizontal"; let profile_url = format!("{}/u/{}", state.config.host_url, ctx.creator_username); let mut response = crate::templates::EmbedItemCardTemplate { title: ctx.title, price_display: ctx.price_display, purchase_url: ctx.purchase_url, button_text: ctx.button_text, cover_image_url: ctx.cover_image_url, creator_display_name: ctx.creator_display_name, profile_url, description_excerpt: ctx.description_excerpt, is_horizontal, } .into_response(); set_embed_headers(&mut response); Ok(response) } // ─── Audio Player ─────────────────────────────────────────────────────────── #[tracing::instrument(skip_all, name = "embed::item_player")] /// GET /embed/i/{item_id}/player /// /// Audio preview player embed. Returns 404 for non-audio items. #[tracing::instrument(skip_all, name = "embed::item_player")] pub(super) async fn item_player( State(state): State, Path(item_id): Path, ) -> Result { let ctx = fetch_item_embed_context(&state, item_id).await?; if !ctx.has_audio { return Err(AppError::NotFound); } // Preview URL would be /embed/i/{item_id}/preview.mp3 once ffmpeg generation is built. // For now, link to the item page — the player embed is a stub until preview generation ships. let preview_url = format!("{}/api/stream/{}", state.config.host_url, item_id); let mut response = crate::templates::EmbedItemPlayerTemplate { title: ctx.title, price_display: ctx.price_display, purchase_url: ctx.purchase_url, button_text: ctx.button_text, creator_display_name: ctx.creator_display_name, cover_image_url: ctx.cover_image_url, preview_url, } .into_response(); set_embed_headers(&mut response); Ok(response) } #[cfg(test)] mod tests { #[test] fn price_display_free() { let (price, btn) = if 0 == 0 { ("Free".to_string(), "Get".to_string()) } else { unreachable!() }; assert_eq!(price, "Free"); assert_eq!(btn, "Get"); } #[test] fn price_display_fixed() { let cents = 1999; let price = format!("${:.2}", cents as f64 / 100.0); assert_eq!(price, "$19.99"); } #[test] fn price_display_pwyw() { let cents = 500; let price = format!("${:.2}+", cents as f64 / 100.0); assert_eq!(price, "$5.00+"); } #[test] fn description_excerpt_truncation() { let long = "a".repeat(200); let excerpt: String = long.chars().take(150).collect(); assert_eq!(excerpt.len(), 150); } #[test] fn description_excerpt_short() { let short = "Hello"; let excerpt: String = short.chars().take(150).collect(); assert_eq!(excerpt, "Hello"); } }