//! Main dashboard pages: user dashboard, project dashboard, item dashboard. use axum::{ extract::{Path, Query, State}, response::IntoResponse, }; use tower_sessions::Session; use crate::{ auth::AuthUser, constants::DASHBOARD_TRANSACTION_LIMIT, db::{self, analytics::TimeRange, ItemId, Slug}, error::{AppError, Result, ResultExt}, helpers::{self, get_csrf_token}, templates::*, types::*, AppState, }; use super::AnalyticsQuery; const ONBOARDING_DISMISSED_KEY: &str = "onboarding_dismissed"; /// Build the onboarding checklist from pre-computed step flags. fn build_onboarding_checklist( profile_done: bool, stripe_done: bool, projects_done: bool, publish_done: bool, ) -> OnboardingChecklist { let steps = vec![ OnboardingStep { label: "Set up your profile — name, bio, and links", done: profile_done, link_tab: "tab-profile", link_label: "Go to Profile", }, OnboardingStep { label: "Connect Stripe — required to receive payments, 3% processing only", done: stripe_done, link_tab: "tab-payments", link_label: "Go to Payments", }, OnboardingStep { label: "Create your first project — blog, podcast, course, etc.", done: projects_done, link_tab: "tab-projects", link_label: "Go to Projects", }, OnboardingStep { label: "Publish your first item — upload files, set pricing, go live", done: publish_done, link_tab: "tab-projects", link_label: "Go to Projects", }, ]; let completed = steps.iter().filter(|s| s.done).count(); let total = steps.len(); OnboardingChecklist { steps, completed, total } } /// Render the main user dashboard with projects and transactions. #[tracing::instrument(skip_all, name = "dashboard::dashboard")] pub(super) async fn dashboard( State(state): State, session: Session, AuthUser(session_user): AuthUser, ) -> Result { let csrf_token = get_csrf_token(&session).await; let db_user = db::users::get_user_by_id(&state.db, session_user.id) .await? .ok_or(AppError::NotFound)?; let db_projects = db::projects::get_projects_by_user(&state.db, session_user.id).await?; // Get transactions where user is buyer or seller let incoming_txs = db::transactions::get_transactions_by_seller(&state.db, session_user.id, Some(DASHBOARD_TRANSACTION_LIMIT)).await?; let outgoing_txs = db::transactions::get_transactions_by_buyer(&state.db, session_user.id, Some(DASHBOARD_TRANSACTION_LIMIT)).await?; let user = User::from(&db_user); let transactions = super::collect_transactions(incoming_txs, outgoing_txs); let projects: Vec = db_projects.iter().map(ProjectCard::from_db).collect(); // Build onboarding checklist for creators who haven't completed all steps let onboarding_dismissed = session .get::(ONBOARDING_DISMISSED_KEY) .await .ok() .flatten() .unwrap_or(false); let (onboarding, show_checklist_recovery) = if session_user.can_create_projects { let profile_done = db_user.display_name.as_ref().is_some_and(|n| !n.is_empty()); let stripe_done = user.stripe_connected; let projects_done = !db_projects.is_empty(); let publish_done = if projects_done { db::items::has_public_item_by_user(&state.db, session_user.id).await? } else { false }; let all_done = profile_done && stripe_done && projects_done && publish_done; if all_done { (None, false) } else if onboarding_dismissed { (None, true) } else { (Some(build_onboarding_checklist(profile_done, stripe_done, projects_done, publish_done)), false) } } else { (None, false) }; // Check if user has MT forum memberships (for Forums tab visibility) let has_mt_memberships = if let Some(ref mt_url) = state.config.mt_base_url { let url = format!("{}/api/user/{}/summary", mt_url, session_user.id); match reqwest::Client::new() .get(&url) .timeout(std::time::Duration::from_secs(3)) .send() .await { Ok(resp) if resp.status().is_success() => resp .json::() .await .ok() .and_then(|j| j["memberships"].as_array().map(|a| !a.is_empty())) .unwrap_or(false), _ => false, } } else { false }; let suspended = db_user.is_suspended(); let suspension_reason = db_user.suspension_reason.clone(); let has_pending_appeal = db_user.appeal_submitted_at.is_some() && db_user.appeal_decided_at.is_none(); let appeal_decision = db_user.appeal_decision.clone(); let appeal_response = db_user.appeal_response.clone(); // Check for one-time password breach warning (set during signup/password change) let password_warning = session .get::("password_warning") .await .ok() .flatten(); if password_warning.is_some() { session.remove::("password_warning").await.ok(); } Ok(DashboardUserTemplate { csrf_token, session_user: Some(session_user), user, transactions, projects, onboarding, show_checklist_recovery, has_mt_memberships, suspended, suspension_reason, has_pending_appeal, appeal_decision, appeal_response, password_warning, deactivated: db_user.is_deactivated(), creator_paused: db_user.is_creator_paused(), git_enabled: state.config.git_repos_path.is_some(), }) } /// Render the dashboard view for a single owned project. #[tracing::instrument(skip_all, name = "dashboard::dashboard_project")] pub(super) async fn dashboard_project( State(state): State, session: Session, AuthUser(session_user): AuthUser, Path(slug): Path, ) -> Result { let csrf_token = get_csrf_token(&session).await; let slug = Slug::new(&slug).map_err(|_| AppError::NotFound)?; let db_project = db::projects::get_project_by_user_and_slug(&state.db, session_user.id, &slug) .await? .ok_or(AppError::NotFound)?; let db_items = db::items::get_items_by_project(&state.db, db_project.id).await?; let (revenue_cents, sales_count) = db::transactions::get_revenue_by_project(&state.db, db_project.id).await?; let project = Project::from_db(&db_project, db_items.len() as u32); let stats = vec![ StatCard { label: "Total Revenue".to_string(), value: helpers::format_revenue(revenue_cents), change: None, is_positive: true, }, StatCard { label: "Total Sales".to_string(), value: sales_count.to_string(), change: None, is_positive: true, }, StatCard { label: "Items".to_string(), value: db_items.len().to_string(), change: None, is_positive: true, }, ]; let items: Vec = db_items .iter() .enumerate() .map(|(i, item)| ContentItem::from_db(item, (i + 1) as u32)) .collect(); let db_user = db::users::get_user_by_id(&state.db, session_user.id) .await? .ok_or(AppError::NotFound)?; let has_blog = db_project.features.iter().any(|f| f == "blog"); let synckit_enabled = db_project.features.iter().any(|f| f == "cloud_sync"); let git_enabled = state.config.git_repos_path.is_some(); Ok(DashboardProjectTemplate { csrf_token, session_user: Some(session_user.clone()), project, creator_username: session_user.username.to_string(), stats, items, stripe_connected: db_user.stripe_account_id.is_some(), has_blog, git_enabled, synckit_enabled, }) } /// Render the dashboard shell for a single owned item (tabs loaded via HTMX). #[tracing::instrument(skip_all, name = "dashboard::dashboard_item")] pub(super) async fn dashboard_item( State(state): State, session: Session, AuthUser(session_user): AuthUser, Path(id): Path, ) -> Result { let csrf_token = get_csrf_token(&session).await; let item_id: ItemId = id.parse().map_err(|_| AppError::NotFound)?; let db_item = db::items::get_item_by_id(&state.db, item_id) .await? .ok_or(AppError::NotFound)?; let db_project = db::projects::get_project_by_id(&state.db, db_item.project_id) .await? .ok_or(AppError::NotFound)?; // Verify ownership if db_project.user_id != session_user.id { return Err(AppError::Forbidden); } let is_free = db_item.price_cents == 0; let item_tags = db::tags::get_tags_for_item(&state.db, item_id).await?; let item = Item::from_db_detail(&db_item, &item_tags, None, None, is_free, true); Ok(DashboardItemTemplate { csrf_token, session_user: Some(session_user), item, project_title: db_project.title, project_slug: db_project.slug.to_string(), }) } /// Render the HTMX partial for item analytics (stats + revenue chart). #[tracing::instrument(skip_all, name = "dashboard::dashboard_item_analytics")] pub(super) async fn dashboard_item_analytics( State(state): State, AuthUser(session_user): AuthUser, Path(id): Path, Query(query): Query, ) -> Result { let item_id: ItemId = id.parse().map_err(|_| AppError::NotFound)?; let db_item = db::items::get_item_by_id(&state.db, item_id) .await? .ok_or(AppError::NotFound)?; let db_project = db::projects::get_project_by_id(&state.db, db_item.project_id) .await? .ok_or(AppError::NotFound)?; if db_project.user_id != session_user.id { return Err(AppError::Forbidden); } let range = query .range .as_deref() .and_then(|s| s.parse::().ok()) .unwrap_or(TimeRange::Days30); let buckets = db::analytics::get_revenue_timeseries( &state.db, session_user.id, None, Some(item_id), &range, ) .await?; let comparison = db::analytics::get_period_comparison( &state.db, session_user.id, None, Some(item_id), &range, ) .await?; let bars = super::build_chart_bars(&buckets); let revenue_str = comparison.current_revenue_cents.format_revenue(); let db_versions = db::versions::get_versions_by_item(&state.db, item_id).await?; let total_downloads: i32 = db_versions.iter().map(|v| v.download_count).sum(); let stats = vec![ StatCard { label: "Revenue".to_string(), value: revenue_str, change: comparison.revenue_change().map(|(t, _)| t), is_positive: comparison.revenue_change().map(|(_, p)| p).unwrap_or(true), }, StatCard { label: "Sales".to_string(), value: comparison.current_sales.to_string(), change: comparison.sales_change().map(|(t, _)| t), is_positive: comparison.sales_change().map(|(_, p)| p).unwrap_or(true), }, StatCard { label: "Downloads".to_string(), value: total_downloads.to_string(), change: None, is_positive: true, }, ]; Ok(ItemAnalyticsPartialTemplate { stats, bars, item_id: item_id.to_string(), active_range: range.to_string(), }) } /// Dismiss the onboarding checklist for the current session. /// Returns a recovery link so the user can bring it back. #[tracing::instrument(skip_all, name = "dashboard::dismiss_onboarding")] pub(super) async fn dismiss_onboarding( session: Session, AuthUser(_session_user): AuthUser, ) -> Result { session.insert(ONBOARDING_DISMISSED_KEY, true).await .context("session insert")?; Ok(axum::response::Html( "" )) } /// Restore the onboarding checklist after it was dismissed. #[tracing::instrument(skip_all, name = "dashboard::restore_onboarding")] pub(super) async fn restore_onboarding( State(state): State, session: Session, AuthUser(session_user): AuthUser, ) -> Result { session.remove::(ONBOARDING_DISMISSED_KEY).await.ok(); let db_user = db::users::get_user_by_id(&state.db, session_user.id) .await? .ok_or(AppError::NotFound)?; let db_projects = db::projects::get_projects_by_user(&state.db, session_user.id).await?; let profile_done = db_user.display_name.as_ref().is_some_and(|n| !n.is_empty()); let stripe_done = db_user.stripe_account_id.is_some(); let projects_done = !db_projects.is_empty(); let publish_done = if projects_done { db::items::has_public_item_by_user(&state.db, session_user.id).await? } else { false }; let checklist = build_onboarding_checklist(profile_done, stripe_done, projects_done, publish_done); Ok(OnboardingChecklistPartialTemplate { checklist }) }