//! Item embed handlers: buy button, product card, audio player. use crate::extractors::ValidatedQuery; use axum::{ extract::{Path, State}, http::{HeaderValue, header}, response::{IntoResponse, Response}, }; use serde::Deserialize; use crate::{ config::Config, db::{self, ItemId}, error::{AppError, Result}, }; use sqlx::PgPool; /// 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( db: &PgPool, config: &Config, item_id: ItemId, ) -> Result { let item = db::items::get_item_by_id(db, item_id) .await? .ok_or(AppError::NotFound)?; if !item.is_public { return Err(AppError::NotFound); } let project = db::projects::get_project_by_id(db, item.project_id) .await? .ok_or(AppError::NotFound)?; let user = db::users::get_user_by_id(db, project.user_id) .await? .ok_or(AppError::NotFound)?; if user.is_suspended() || user.is_deactivated() { return Err(AppError::NotFound); } // Use the canonical formatter so the embed card matches every other surface // (thousands separators, whole-dollar shortening) instead of an f64-divided // "$12345.00" (Run #2 UX SERIOUS, price fragmentation). let (price_display, button_text) = if item.price_cents == 0 { ("Free".to_string(), "Get".to_string()) } else if item.pwyw_enabled { ( format!( "{}+", crate::formatting::format_price(item.price_cents, user.settlement_currency) ), "Buy".to_string(), ) } else { ( crate::formatting::format_price(item.price_cents, user.settlement_currency), "Buy".to_string(), ) }; let purchase_url = format!("{}/buy/{}", 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(db): State, State(config): State, Path(item_id): Path, ) -> Result { let ctx = fetch_item_embed_context(&db, &config, 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, theme_css: crate::templates::embed_theme_css(), geometry_css: crate::templates::EMBED_GEOMETRY_CSS, } .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(db): State, State(config): State, Path(item_id): Path, ValidatedQuery(query): ValidatedQuery, ) -> Result { let ctx = fetch_item_embed_context(&db, &config, item_id).await?; let layout = query.layout.as_deref().unwrap_or("vertical"); let is_horizontal = layout == "horizontal"; let profile_url = format!("{}/u/{}", 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, theme_css: crate::templates::embed_theme_css(), geometry_css: crate::templates::EMBED_GEOMETRY_CSS, } .into_response(); set_embed_headers(&mut response); Ok(response) } // ─── Audio 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(db): State, State(config): State, Path(item_id): Path, ) -> Result { let ctx = fetch_item_embed_context(&db, &config, 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/{}", 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, theme_css: crate::templates::embed_theme_css(), geometry_css: crate::templates::EMBED_GEOMETRY_CSS, } .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 price = crate::formatting::format_revenue(1999, crate::currency::SettlementCurrency::Usd); assert_eq!(price, "$19.99"); } #[test] fn price_display_pwyw() { // Embed PWYW prices append "+" to the sealed revenue string. let price = format!( "{}+", crate::formatting::format_revenue(500, crate::currency::SettlementCurrency::Usd) ); 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"); } }