| 1 |
|
| 2 |
|
| 3 |
use askama::Template as _; |
| 4 |
|
| 5 |
use crate::extractors::ValidatedQuery; |
| 6 |
use axum::{ |
| 7 |
extract::{Path, State}, |
| 8 |
response::IntoResponse, |
| 9 |
}; |
| 10 |
use tower_sessions::Session; |
| 11 |
|
| 12 |
use crate::{ |
| 13 |
auth::AuthUser, |
| 14 |
config::Config, |
| 15 |
db::{self, ItemId, Slug, analytics::TimeRange}, |
| 16 |
error::{AppError, Result, ResultExt}, |
| 17 |
helpers::get_csrf_token, |
| 18 |
quasi, |
| 19 |
templates::{ |
| 20 |
DashboardItemTemplate, DashboardProjectTemplate, DashboardUserTemplate, |
| 21 |
ItemAnalyticsPartialTemplate, ItemOverviewTabTemplate, ItemVersionUploadTemplate, |
| 22 |
OnboardingChecklistPartialTemplate, |
| 23 |
}, |
| 24 |
types::{ |
| 25 |
Item, OnboardingChecklist, OnboardingStep, Project, ProjectCard, StatCard, User, Version, |
| 26 |
}, |
| 27 |
}; |
| 28 |
use sqlx::PgPool; |
| 29 |
|
| 30 |
use super::{AnalyticsQuery, ItemTabQuery, UserTabQuery, project_tabs, tabs}; |
| 31 |
|
| 32 |
const ONBOARDING_DISMISSED_KEY: &str = "onboarding_dismissed"; |
| 33 |
|
| 34 |
|
| 35 |
struct OnboardingProgress { |
| 36 |
profile_done: bool, |
| 37 |
stripe_done: bool, |
| 38 |
projects_done: bool, |
| 39 |
publish_done: bool, |
| 40 |
} |
| 41 |
|
| 42 |
|
| 43 |
fn build_onboarding_checklist(progress: &OnboardingProgress) -> OnboardingChecklist { |
| 44 |
let OnboardingProgress { |
| 45 |
profile_done, |
| 46 |
stripe_done, |
| 47 |
projects_done, |
| 48 |
publish_done, |
| 49 |
} = *progress; |
| 50 |
let steps = vec![ |
| 51 |
OnboardingStep { |
| 52 |
label: "Set up your profile: name, bio, and links", |
| 53 |
done: profile_done, |
| 54 |
link_href: "/dashboard?tab=settings", |
| 55 |
link_label: "Go to Profile", |
| 56 |
}, |
| 57 |
OnboardingStep { |
| 58 |
label: "Connect Stripe: required to receive payments, 3% processing only", |
| 59 |
done: stripe_done, |
| 60 |
link_href: "/dashboard?tab=payments", |
| 61 |
link_label: "Go to Payments", |
| 62 |
}, |
| 63 |
OnboardingStep { |
| 64 |
label: "Create your first project: blog, podcast, course, etc.", |
| 65 |
done: projects_done, |
| 66 |
link_href: "/dashboard?tab=projects", |
| 67 |
link_label: "Go to Projects", |
| 68 |
}, |
| 69 |
OnboardingStep { |
| 70 |
label: "Publish your first item: upload files, set pricing, go live", |
| 71 |
done: publish_done, |
| 72 |
link_href: "/dashboard?tab=projects", |
| 73 |
link_label: "Go to Projects", |
| 74 |
}, |
| 75 |
]; |
| 76 |
let completed = steps.iter().filter(|s| s.done).count(); |
| 77 |
let total = steps.len(); |
| 78 |
|
| 79 |
|
| 80 |
|
| 81 |
let progress_pct = (completed * 100).checked_div(total).unwrap_or(0) as u32; |
| 82 |
OnboardingChecklist { |
| 83 |
steps, |
| 84 |
completed, |
| 85 |
total, |
| 86 |
progress_pct, |
| 87 |
} |
| 88 |
} |
| 89 |
|
| 90 |
|
| 91 |
#[tracing::instrument(skip_all, name = "dashboard::dashboard")] |
| 92 |
pub(super) async fn dashboard( |
| 93 |
State(db): State<PgPool>, |
| 94 |
State(config): State<Config>, |
| 95 |
State(payments): State<crate::Billing>, |
| 96 |
session: Session, |
| 97 |
AuthUser(session_user): AuthUser, |
| 98 |
ValidatedQuery(query): ValidatedQuery<UserTabQuery>, |
| 99 |
) -> Result<impl IntoResponse> { |
| 100 |
let csrf_token = get_csrf_token(&session).await; |
| 101 |
|
| 102 |
|
| 103 |
|
| 104 |
|
| 105 |
|
| 106 |
|
| 107 |
|
| 108 |
|
| 109 |
let (db_user, db_projects) = tokio::try_join!( |
| 110 |
db::users::get_user_by_id(&db, session_user.id), |
| 111 |
db::projects::get_projects_by_user(&db, session_user.id), |
| 112 |
)?; |
| 113 |
let db_user = db_user.ok_or(AppError::NotFound)?; |
| 114 |
|
| 115 |
let user = User::from(&db_user); |
| 116 |
|
| 117 |
let projects: Vec<ProjectCard> = db_projects.iter().map(ProjectCard::from_db).collect(); |
| 118 |
|
| 119 |
|
| 120 |
let onboarding_dismissed = session |
| 121 |
.get::<bool>(ONBOARDING_DISMISSED_KEY) |
| 122 |
.await |
| 123 |
.ok() |
| 124 |
.flatten() |
| 125 |
.unwrap_or(false); |
| 126 |
let (onboarding, show_checklist_recovery) = if session_user.can_create_projects { |
| 127 |
let profile_done = db_user.display_name.as_ref().is_some_and(|n| !n.is_empty()); |
| 128 |
let stripe_done = user.stripe_connected; |
| 129 |
let projects_done = !db_projects.is_empty(); |
| 130 |
let publish_done = if projects_done { |
| 131 |
db::items::has_public_item_by_user(&db, session_user.id).await? |
| 132 |
} else { |
| 133 |
false |
| 134 |
}; |
| 135 |
|
| 136 |
let all_done = profile_done && stripe_done && projects_done && publish_done; |
| 137 |
if all_done { |
| 138 |
(None, false) |
| 139 |
} else if onboarding_dismissed { |
| 140 |
(None, true) |
| 141 |
} else { |
| 142 |
( |
| 143 |
Some(build_onboarding_checklist(&OnboardingProgress { |
| 144 |
profile_done, |
| 145 |
stripe_done, |
| 146 |
projects_done, |
| 147 |
publish_done, |
| 148 |
})), |
| 149 |
false, |
| 150 |
) |
| 151 |
} |
| 152 |
} else { |
| 153 |
(None, false) |
| 154 |
}; |
| 155 |
|
| 156 |
let suspended = db_user.is_suspended(); |
| 157 |
let suspension_reason = db_user.suspension_reason.clone(); |
| 158 |
let has_pending_appeal = |
| 159 |
db_user.appeal_submitted_at.is_some() && db_user.appeal_decided_at.is_none(); |
| 160 |
let appeal_decision = db_user.appeal_decision.clone(); |
| 161 |
let appeal_response = db_user.appeal_response.clone(); |
| 162 |
|
| 163 |
|
| 164 |
let password_warning = session |
| 165 |
.get::<String>("password_warning") |
| 166 |
.await |
| 167 |
.ok() |
| 168 |
.flatten(); |
| 169 |
if password_warning.is_some() { |
| 170 |
session.remove::<String>("password_warning").await.ok(); |
| 171 |
} |
| 172 |
|
| 173 |
|
| 174 |
|
| 175 |
|
| 176 |
|
| 177 |
let deactivated = db_user.is_deactivated(); |
| 178 |
let shown = quasi::user_tabs::shown_at( |
| 179 |
query.tab.as_deref(), |
| 180 |
deactivated, |
| 181 |
session_user.can_create_projects, |
| 182 |
); |
| 183 |
|
| 184 |
|
| 185 |
let panel = |
| 186 |
match quasi::user_tabs::route_at(shown, deactivated, session_user.can_create_projects) { |
| 187 |
"payments" => tabs::build_payments(&db, csrf_token.clone(), &session_user) |
| 188 |
.await? |
| 189 |
.render(), |
| 190 |
"settings" => tabs::build_settings( |
| 191 |
&db, |
| 192 |
&config, |
| 193 |
&payments, |
| 194 |
csrf_token.clone(), |
| 195 |
&session_user, |
| 196 |
query.section.as_deref(), |
| 197 |
) |
| 198 |
.await? |
| 199 |
.render(), |
| 200 |
|
| 201 |
"support" => Ok(quasi::user_support::fill(&session_user.email)), |
| 202 |
|
| 203 |
|
| 204 |
_ => Ok(quasi::user_projects::fill( |
| 205 |
&projects, |
| 206 |
session_user.can_create_projects, |
| 207 |
)), |
| 208 |
} |
| 209 |
.map_err(|error| AppError::Internal(anyhow::anyhow!(error)))?; |
| 210 |
|
| 211 |
Ok(DashboardUserTemplate { |
| 212 |
tabs: quasi::user_tabs::html(shown, &panel, deactivated, session_user.can_create_projects), |
| 213 |
csrf_token, |
| 214 |
session_user: Some(session_user), |
| 215 |
user, |
| 216 |
onboarding, |
| 217 |
show_checklist_recovery, |
| 218 |
suspended, |
| 219 |
suspension_reason, |
| 220 |
has_pending_appeal, |
| 221 |
appeal_decision, |
| 222 |
appeal_response, |
| 223 |
password_warning, |
| 224 |
deactivated, |
| 225 |
creator_paused: db_user.is_creator_paused(), |
| 226 |
}) |
| 227 |
} |
| 228 |
|
| 229 |
|
| 230 |
#[tracing::instrument(skip_all, name = "dashboard::dashboard_project")] |
| 231 |
pub(super) async fn dashboard_project( |
| 232 |
State(db): State<PgPool>, |
| 233 |
State(config): State<Config>, |
| 234 |
session: Session, |
| 235 |
AuthUser(session_user): AuthUser, |
| 236 |
Path(slug): Path<String>, |
| 237 |
ValidatedQuery(query): ValidatedQuery<ItemTabQuery>, |
| 238 |
) -> Result<impl IntoResponse> { |
| 239 |
let csrf_token = get_csrf_token(&session).await; |
| 240 |
let slug = Slug::new(&slug).map_err(|_| AppError::NotFound)?; |
| 241 |
|
| 242 |
let db_project = db::projects::get_project_by_user_and_slug(&db, session_user.id, &slug) |
| 243 |
.await? |
| 244 |
.ok_or(AppError::NotFound)?; |
| 245 |
|
| 246 |
let db_items = db::items::get_items_by_project(&db, db_project.id).await?; |
| 247 |
let project = Project::from_db(&db_project, db_items.len() as u32); |
| 248 |
|
| 249 |
let git_enabled = config.build.git_repos_path.is_some(); |
| 250 |
let synckit_enabled = db_project.features.iter().any(|f| f == "cloud_sync"); |
| 251 |
|
| 252 |
|
| 253 |
|
| 254 |
|
| 255 |
|
| 256 |
let shown = quasi::project_tabs::shown_at(query.tab.as_deref(), git_enabled, synckit_enabled); |
| 257 |
let panel = match quasi::project_tabs::route_at(shown, git_enabled, synckit_enabled) { |
| 258 |
|
| 259 |
|
| 260 |
|
| 261 |
"content" => { |
| 262 |
let content = project_tabs::build_content(&db, &session_user, &db_project).await?; |
| 263 |
Ok(quasi::project_content::fill( |
| 264 |
db_project.slug.as_ref(), |
| 265 |
&content.items, |
| 266 |
&content.deleted_items, |
| 267 |
&content.posts, |
| 268 |
&quasi::project_content::View::default(), |
| 269 |
)) |
| 270 |
} |
| 271 |
"synckit" => project_tabs::build_synckit(&db, &session_user, &db_project) |
| 272 |
.await? |
| 273 |
.render(), |
| 274 |
|
| 275 |
|
| 276 |
|
| 277 |
_ => { |
| 278 |
let overview = project_tabs::build_overview(&db, &session_user, &db_project).await?; |
| 279 |
Ok(quasi::project_overview::fill( |
| 280 |
&overview.project_slug, |
| 281 |
&overview.stats, |
| 282 |
overview.stripe_connected, |
| 283 |
overview.has_items, |
| 284 |
overview.has_published_item, |
| 285 |
)) |
| 286 |
} |
| 287 |
} |
| 288 |
.map_err(|error| AppError::Internal(anyhow::anyhow!(error)))?; |
| 289 |
|
| 290 |
Ok(DashboardProjectTemplate { |
| 291 |
csrf_token, |
| 292 |
session_user: Some(session_user), |
| 293 |
project, |
| 294 |
tabs: quasi::project_tabs::html( |
| 295 |
db_project.slug.as_ref(), |
| 296 |
shown, |
| 297 |
&panel, |
| 298 |
git_enabled, |
| 299 |
synckit_enabled, |
| 300 |
), |
| 301 |
}) |
| 302 |
} |
| 303 |
|
| 304 |
|
| 305 |
#[tracing::instrument(skip_all, name = "dashboard::dashboard_item")] |
| 306 |
pub(super) async fn dashboard_item( |
| 307 |
State(db): State<PgPool>, |
| 308 |
session: Session, |
| 309 |
AuthUser(session_user): AuthUser, |
| 310 |
Path(id): Path<String>, |
| 311 |
ValidatedQuery(query): ValidatedQuery<ItemTabQuery>, |
| 312 |
) -> Result<impl IntoResponse> { |
| 313 |
let csrf_token = get_csrf_token(&session).await; |
| 314 |
|
| 315 |
let item_id: ItemId = id.parse().map_err(|_| AppError::NotFound)?; |
| 316 |
|
| 317 |
let db_item = db::items::get_item_by_id(&db, item_id) |
| 318 |
.await? |
| 319 |
.ok_or(AppError::NotFound)?; |
| 320 |
|
| 321 |
let db_project = db::projects::get_project_by_id(&db, db_item.project_id) |
| 322 |
.await? |
| 323 |
.ok_or(AppError::NotFound)?; |
| 324 |
|
| 325 |
|
| 326 |
if db_project.user_id != session_user.id { |
| 327 |
return Err(AppError::Forbidden); |
| 328 |
} |
| 329 |
|
| 330 |
let is_free = db_item.price_cents == 0; |
| 331 |
let item_tags = db::tags::get_tags_for_item(&db, item_id).await?; |
| 332 |
let item = Item::from_db_detail( |
| 333 |
&db_item, |
| 334 |
&item_tags, |
| 335 |
None, |
| 336 |
None, |
| 337 |
is_free, |
| 338 |
true, |
| 339 |
session_user.settlement_currency, |
| 340 |
); |
| 341 |
|
| 342 |
|
| 343 |
|
| 344 |
|
| 345 |
|
| 346 |
|
| 347 |
let is_bundle = item.item_type == "bundle"; |
| 348 |
let shown = quasi::item_tabs::shown_at(query.tab.as_deref(), is_bundle); |
| 349 |
|
| 350 |
|
| 351 |
|
| 352 |
|
| 353 |
let panel = match quasi::item_tabs::route_at(shown, is_bundle) { |
| 354 |
"files" => { |
| 355 |
let db_versions = db::versions::get_versions_by_item(&db, item_id).await?; |
| 356 |
let versions: Vec<Version> = db_versions.iter().map(Version::from_db).collect(); |
| 357 |
|
| 358 |
|
| 359 |
let uploader = ItemVersionUploadTemplate { |
| 360 |
item: item.clone(), |
| 361 |
versions: versions.clone(), |
| 362 |
} |
| 363 |
.render() |
| 364 |
.map_err(|error| AppError::Internal(anyhow::anyhow!(error)))?; |
| 365 |
quasi::item_files::fragment(&item.id, &versions, &uploader) |
| 366 |
} |
| 367 |
_ => ItemOverviewTabTemplate { item: item.clone() } |
| 368 |
.render() |
| 369 |
.map_err(|error| AppError::Internal(anyhow::anyhow!(error)))?, |
| 370 |
}; |
| 371 |
let tabs = quasi::item_tabs::html(&item.id, shown, &panel, is_bundle); |
| 372 |
|
| 373 |
Ok(DashboardItemTemplate { |
| 374 |
csrf_token, |
| 375 |
session_user: Some(session_user), |
| 376 |
item, |
| 377 |
project_title: db_project.title, |
| 378 |
project_slug: db_project.slug.to_string(), |
| 379 |
tabs, |
| 380 |
}) |
| 381 |
} |
| 382 |
|
| 383 |
|
| 384 |
#[tracing::instrument(skip_all, name = "dashboard::dashboard_item_analytics")] |
| 385 |
pub(super) async fn dashboard_item_analytics( |
| 386 |
State(db): State<PgPool>, |
| 387 |
AuthUser(session_user): AuthUser, |
| 388 |
Path(id): Path<String>, |
| 389 |
ValidatedQuery(query): ValidatedQuery<AnalyticsQuery>, |
| 390 |
) -> Result<impl IntoResponse> { |
| 391 |
let item_id: ItemId = id.parse().map_err(|_| AppError::NotFound)?; |
| 392 |
|
| 393 |
let db_item = db::items::get_item_by_id(&db, item_id) |
| 394 |
.await? |
| 395 |
.ok_or(AppError::NotFound)?; |
| 396 |
|
| 397 |
let db_project = db::projects::get_project_by_id(&db, db_item.project_id) |
| 398 |
.await? |
| 399 |
.ok_or(AppError::NotFound)?; |
| 400 |
|
| 401 |
if db_project.user_id != session_user.id { |
| 402 |
return Err(AppError::Forbidden); |
| 403 |
} |
| 404 |
|
| 405 |
let range = query |
| 406 |
.range |
| 407 |
.as_deref() |
| 408 |
.and_then(|s| s.parse::<TimeRange>().ok()) |
| 409 |
.unwrap_or(TimeRange::Days30); |
| 410 |
|
| 411 |
let buckets = |
| 412 |
db::analytics::get_revenue_timeseries(&db, session_user.id, None, Some(item_id), &range) |
| 413 |
.await?; |
| 414 |
|
| 415 |
let comparison = |
| 416 |
db::analytics::get_period_comparison(&db, session_user.id, None, Some(item_id), &range) |
| 417 |
.await?; |
| 418 |
|
| 419 |
let bars = super::build_chart_bars(&buckets, session_user.settlement_currency); |
| 420 |
|
| 421 |
let currency = db::users::get_user_by_id(&db, session_user.id) |
| 422 |
.await? |
| 423 |
.map(|u| u.settlement_currency) |
| 424 |
.unwrap_or_default(); |
| 425 |
let revenue_str = comparison.current_revenue_cents.format_revenue(currency); |
| 426 |
|
| 427 |
let db_versions = db::versions::get_versions_by_item(&db, item_id).await?; |
| 428 |
let total_downloads: i32 = db_versions.iter().map(|v| v.download_count).sum(); |
| 429 |
|
| 430 |
let stats = vec![ |
| 431 |
StatCard { |
| 432 |
label: "Revenue".to_string(), |
| 433 |
value: revenue_str, |
| 434 |
change: comparison.revenue_change().map(|(t, _)| t), |
| 435 |
is_positive: comparison.revenue_change().is_none_or(|(_, p)| p), |
| 436 |
}, |
| 437 |
StatCard { |
| 438 |
label: "Sales".to_string(), |
| 439 |
value: comparison.current_sales.to_string(), |
| 440 |
change: comparison.sales_change().map(|(t, _)| t), |
| 441 |
is_positive: comparison.sales_change().is_none_or(|(_, p)| p), |
| 442 |
}, |
| 443 |
StatCard { |
| 444 |
label: "Downloads".to_string(), |
| 445 |
value: total_downloads.to_string(), |
| 446 |
change: None, |
| 447 |
is_positive: true, |
| 448 |
}, |
| 449 |
]; |
| 450 |
|
| 451 |
Ok(ItemAnalyticsPartialTemplate { |
| 452 |
stats, |
| 453 |
bars, |
| 454 |
item_id: item_id.to_string(), |
| 455 |
active_range: range.to_string(), |
| 456 |
}) |
| 457 |
} |
| 458 |
|
| 459 |
|
| 460 |
|
| 461 |
#[tracing::instrument(skip_all, name = "dashboard::dismiss_onboarding")] |
| 462 |
pub(super) async fn dismiss_onboarding( |
| 463 |
session: Session, |
| 464 |
AuthUser(_session_user): AuthUser, |
| 465 |
) -> Result<impl IntoResponse> { |
| 466 |
session |
| 467 |
.insert(ONBOARDING_DISMISSED_KEY, true) |
| 468 |
.await |
| 469 |
.context("session insert")?; |
| 470 |
Ok(axum::response::Html( |
| 471 |
"<div style=\"padding: 0.75rem 0; text-align: right;\">\ |
| 472 |
<a href=\"#\" hx-post=\"/dashboard/onboarding/restore\" hx-target=\"#onboarding-area\" hx-swap=\"innerHTML\" \ |
| 473 |
style=\"font-size: 0.85rem; opacity: 0.7;\">Show setup checklist</a></div>", |
| 474 |
)) |
| 475 |
} |
| 476 |
|
| 477 |
|
| 478 |
#[tracing::instrument(skip_all, name = "dashboard::restore_onboarding")] |
| 479 |
pub(super) async fn restore_onboarding( |
| 480 |
State(db): State<PgPool>, |
| 481 |
session: Session, |
| 482 |
AuthUser(session_user): AuthUser, |
| 483 |
) -> Result<impl IntoResponse> { |
| 484 |
session.remove::<bool>(ONBOARDING_DISMISSED_KEY).await.ok(); |
| 485 |
|
| 486 |
let db_user = db::users::get_user_by_id(&db, session_user.id) |
| 487 |
.await? |
| 488 |
.ok_or(AppError::NotFound)?; |
| 489 |
let db_projects = db::projects::get_projects_by_user(&db, session_user.id).await?; |
| 490 |
|
| 491 |
let profile_done = db_user.display_name.as_ref().is_some_and(|n| !n.is_empty()); |
| 492 |
let stripe_done = db_user.stripe_account_id.is_some(); |
| 493 |
let projects_done = !db_projects.is_empty(); |
| 494 |
let publish_done = if projects_done { |
| 495 |
db::items::has_public_item_by_user(&db, session_user.id).await? |
| 496 |
} else { |
| 497 |
false |
| 498 |
}; |
| 499 |
|
| 500 |
let checklist = build_onboarding_checklist(&OnboardingProgress { |
| 501 |
profile_done, |
| 502 |
stripe_done, |
| 503 |
projects_done, |
| 504 |
publish_done, |
| 505 |
}); |
| 506 |
|
| 507 |
Ok(OnboardingChecklistPartialTemplate { checklist }) |
| 508 |
} |
| 509 |
|