max / makenotwork
62 files changed,
+2053 insertions,
-126 deletions
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "makenotwork" | |
| 3 | - | version = "0.4.12" | |
| 3 | + | version = "0.5.0" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license-file = "LICENSE" | |
| 6 | 6 |
| @@ -1,9 +1,9 @@ | |||
| 1 | 1 | # Makenotwork TODO | |
| 2 | 2 | ||
| 3 | 3 | ## Status | |
| 4 | - | Done: All pre-beta phases, UX audit remediation, creator trust audit remediation. Active: Creator setup (Stripe), manual testing. Next: Soft launch. | |
| 4 | + | Done: All pre-beta phases, UX audit remediation, creator trust audit remediation, shopping cart + wishlist checkout. Active: Final pre-launch testing. Next: Soft launch (target 2026-05-09). | |
| 5 | 5 | ||
| 6 | - | v0.4.10 deployed 2026-05-04. Audit grade A (Run 20, 2026-05-04). ~83K LOC, 1,214+ test annotations, 0 cargo warnings, 2 cold spots. CI on astra operational. Mutation kill rate 99.4%. Property-based testing active (proptest). `cargo test --features fast-tests` for fast runs. Doc fuzz (2026-05-06): deleted stale database_schema.md, fixed MT README, updated SyncKit version in docs. Usability audit (2026-05-06): grade B-, 18 easy wins + 9 medium items + 3 deferred added. | |
| 6 | + | v0.5.0 deployed 2026-05-06. Audit grade A (Run 20, 2026-05-04). ~85K LOC, 1,912 tests (1220 unit + 664 integration + 28 load), 0 cargo warnings, 2 flaky sandbox tests. CI on astra operational. Mutation kill rate 99.4%. Property-based testing active (proptest). UX audit (2026-05-06): Learnability A-, Complexity B+, Discoverability B+, Feature Completeness B. Shopping cart implemented: multi-item Stripe checkout sessions, wishlist-to-cart flow, per-seller grouping, savings nudges. Migration 096. | |
| 7 | 7 | ||
| 8 | 8 | Human tasks (manual testing, outreach, legal, infrastructure) moved to `human_todo.md`. | |
| 9 | 9 | Completed items moved to `todo_done.md`. | |
| @@ -86,12 +86,28 @@ Completed (2026-05-06): | |||
| 86 | 86 | - Conversion analytics: Views + Conversion stat cards on both user and project Analytics tabs. Period-over-period % for views. | |
| 87 | 87 | - Subscription → Membership terminology: project dashboard tab, wizard, project page, 7 docs files. | |
| 88 | 88 | ||
| 89 | - | ### UX — Deferred (post-beta table stakes) | |
| 89 | + | ### UX Audit Remediation (2026-05-06) — DONE (4 rounds) | |
| 90 | + | ||
| 91 | + | **Learnability (B+ → A-):** Join preamble, TOTP/passkey labels, SyncKit jargon fix, empty state CTAs (subscriptions, collections), project onboarding checklist, tab hover hints (all dashboards), passkey login label. | |
| 92 | + | ||
| 93 | + | **Complexity (B- → B+):** Pitch step simplified (removed trial/tier fields), item wizard sections step removed (6→5 steps), item Settings merged into Details, item Embed folded into Overview (7→5 tabs), item tab hints. | |
| 94 | + | ||
| 95 | + | **Upload flow:** Client-side file size pre-validation with tier limit from presign response, tier-aware error messages (HTTP 413), retry preserves file selection, upload speed + ETA display. | |
| 96 | + | ||
| 97 | + | **Discoverability (B- → B+):** Wishlists tab in Library (new endpoint + template), data export link in dashboard header, changelog link de-muted, RSS/copy-link moved to profile header, library purchases search filter, quick-edit links confirmed present. | |
| 98 | + | ||
| 99 | + | **Shopping cart + wishlist checkout (Feature Completeness B → B+):** Migration 096 (cart_items table), cart DB module, cart API (toggle/remove/count), multi-line-item Stripe Checkout Sessions per seller (Direct Charges), cart checkout route, cart webhook handler (batch transaction completion), cart page grouped by seller with savings nudge, wishlist-to-cart flow, "Add to Cart" on item pages + wishlists + purchase page, cart link in nav, scheduler cleanup. | |
| 100 | + | ||
| 101 | + | ### UX — Deferred (post-launch) | |
| 102 | + | - [ ] Storefront customization (creator themes/colors — 24 TOML themes exist in shared/themes/) | |
| 90 | 103 | - [ ] Reviews/ratings system for items | |
| 104 | + | - [ ] Wishlist price-drop alerts | |
| 91 | 105 | - [ ] Gift purchases at checkout | |
| 92 | 106 | - [ ] HTML rich email for creator broadcasts (currently plain text only) | |
| 93 | - | - [ ] Creator-to-fan broadcast email composer (full system, not just contact export) | |
| 94 | 107 | - [ ] In-app notification center (beyond email-only notifications) | |
| 108 | + | - [ ] Cross-seller cart checkout (currently one Stripe session per seller) | |
| 109 | + | - [ ] Promo codes in cart checkout (currently single-item only) | |
| 110 | + | - [ ] PWYW custom amounts in cart (currently uses minimum price) | |
| 95 | 111 | ||
| 96 | 112 | --- | |
| 97 | 113 | ||
| @@ -274,7 +290,7 @@ MNW/server/src/ | |||
| 274 | 290 | import/ (CSV converter, pipeline, intermediate format) | |
| 275 | 291 | MNW/server/tests/ | |
| 276 | 292 | integration.rs, harness/, workflows/*.rs | |
| 277 | - | MNW/server/migrations/ (001-090) | |
| 293 | + | MNW/server/migrations/ (001-096) | |
| 278 | 294 | MNW/server/templates/ | |
| 279 | 295 | MNW/server/deploy/ | |
| 280 | 296 | MNW/server/site-docs/public/, MNW/server/site-docs/unpublished/ |
| @@ -0,0 +1,10 @@ | |||
| 1 | + | -- Shopping cart: persistent across sessions/devices, one row per item per user. | |
| 2 | + | -- Mirrors the wishlists table structure (composite PK, CASCADE deletes). | |
| 3 | + | CREATE TABLE IF NOT EXISTS cart_items ( | |
| 4 | + | user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 5 | + | item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE, | |
| 6 | + | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | |
| 7 | + | PRIMARY KEY (user_id, item_id) | |
| 8 | + | ); | |
| 9 | + | ||
| 10 | + | CREATE INDEX IF NOT EXISTS idx_cart_items_item ON cart_items(item_id); |
| @@ -0,0 +1,3 @@ | |||
| 1 | + | -- Allow buyers to store a custom PWYW amount per cart item. | |
| 2 | + | -- NULL means "use item minimum price" (backward compatible). | |
| 3 | + | ALTER TABLE cart_items ADD COLUMN IF NOT EXISTS amount_cents INT; |
| @@ -0,0 +1,245 @@ | |||
| 1 | + | //! Shopping cart queries: fans batch items for combined checkout. | |
| 2 | + | ||
| 3 | + | use chrono::{DateTime, Duration, Utc}; | |
| 4 | + | use sqlx::PgPool; | |
| 5 | + | ||
| 6 | + | use super::{ItemId, UserId}; | |
| 7 | + | use crate::error::Result; | |
| 8 | + | ||
| 9 | + | /// Check if an item is in the user's cart. | |
| 10 | + | #[tracing::instrument(skip_all)] | |
| 11 | + | pub async fn is_in_cart(pool: &PgPool, user_id: UserId, item_id: ItemId) -> Result<bool> { | |
| 12 | + | let exists: bool = sqlx::query_scalar( | |
| 13 | + | "SELECT EXISTS(SELECT 1 FROM cart_items WHERE user_id = $1 AND item_id = $2)", | |
| 14 | + | ) | |
| 15 | + | .bind(user_id) | |
| 16 | + | .bind(item_id) | |
| 17 | + | .fetch_one(pool) | |
| 18 | + | .await?; | |
| 19 | + | ||
| 20 | + | Ok(exists) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | /// Add an item to the user's cart (idempotent). | |
| 24 | + | #[tracing::instrument(skip_all)] | |
| 25 | + | pub async fn add_to_cart(pool: &PgPool, user_id: UserId, item_id: ItemId) -> Result<()> { | |
| 26 | + | sqlx::query( | |
| 27 | + | "INSERT INTO cart_items (user_id, item_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", | |
| 28 | + | ) | |
| 29 | + | .bind(user_id) | |
| 30 | + | .bind(item_id) | |
| 31 | + | .execute(pool) | |
| 32 | + | .await?; | |
| 33 | + | ||
| 34 | + | Ok(()) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /// Remove an item from the user's cart. | |
| 38 | + | #[tracing::instrument(skip_all)] | |
| 39 | + | pub async fn remove_from_cart(pool: &PgPool, user_id: UserId, item_id: ItemId) -> Result<()> { | |
| 40 | + | sqlx::query("DELETE FROM cart_items WHERE user_id = $1 AND item_id = $2") | |
| 41 | + | .bind(user_id) | |
| 42 | + | .bind(item_id) | |
| 43 | + | .execute(pool) | |
| 44 | + | .await?; | |
| 45 | + | ||
| 46 | + | Ok(()) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /// Update the PWYW amount for a cart item. | |
| 50 | + | #[tracing::instrument(skip_all)] | |
| 51 | + | pub async fn update_cart_amount( | |
| 52 | + | pool: &PgPool, | |
| 53 | + | user_id: UserId, | |
| 54 | + | item_id: ItemId, | |
| 55 | + | amount_cents: Option<i32>, | |
| 56 | + | ) -> Result<bool> { | |
| 57 | + | let result = sqlx::query( | |
| 58 | + | "UPDATE cart_items SET amount_cents = $3 WHERE user_id = $1 AND item_id = $2", | |
| 59 | + | ) | |
| 60 | + | .bind(user_id) | |
| 61 | + | .bind(item_id) | |
| 62 | + | .bind(amount_cents) | |
| 63 | + | .execute(pool) | |
| 64 | + | .await?; | |
| 65 | + | ||
| 66 | + | Ok(result.rows_affected() > 0) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | /// Get the number of items in the user's cart. | |
| 70 | + | #[tracing::instrument(skip_all)] | |
| 71 | + | pub async fn get_cart_count(pool: &PgPool, user_id: UserId) -> Result<i64> { | |
| 72 | + | let count: i64 = sqlx::query_scalar( | |
| 73 | + | "SELECT COUNT(*) FROM cart_items WHERE user_id = $1", | |
| 74 | + | ) | |
| 75 | + | .bind(user_id) | |
| 76 | + | .fetch_one(pool) | |
| 77 | + | .await?; | |
| 78 | + | ||
| 79 | + | Ok(count) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /// A cart item with joined display and checkout data. | |
| 83 | + | #[derive(Debug, Clone, sqlx::FromRow)] | |
| 84 | + | pub struct CartItem { | |
| 85 | + | pub item_id: ItemId, | |
| 86 | + | pub title: String, | |
| 87 | + | pub item_type: String, | |
| 88 | + | pub price_cents: i32, | |
| 89 | + | pub pwyw_enabled: bool, | |
| 90 | + | pub pwyw_min_cents: Option<i32>, | |
| 91 | + | /// Buyer's chosen PWYW amount (None = use item minimum). | |
| 92 | + | pub amount_cents: Option<i32>, | |
| 93 | + | pub creator_username: String, | |
| 94 | + | pub seller_id: UserId, | |
| 95 | + | pub seller_stripe_account_id: Option<String>, | |
| 96 | + | pub seller_charges_enabled: bool, | |
| 97 | + | pub project_slug: String, | |
| 98 | + | pub added_at: DateTime<Utc>, | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | impl CartItem { | |
| 102 | + | /// Effective price for cart checkout. For PWYW: buyer's chosen amount if set, | |
| 103 | + | /// otherwise the item minimum. For fixed: the item price. | |
| 104 | + | pub fn effective_price_cents(&self) -> i32 { | |
| 105 | + | if self.pwyw_enabled { | |
| 106 | + | self.amount_cents | |
| 107 | + | .unwrap_or_else(|| self.pwyw_min_cents.unwrap_or(0)) | |
| 108 | + | .max(0) | |
| 109 | + | } else { | |
| 110 | + | self.price_cents | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | /// Whether this item is free at its effective cart price. | |
| 115 | + | pub fn is_free(&self) -> bool { | |
| 116 | + | self.effective_price_cents() == 0 | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | /// Minimum price in dollars for PWYW display. | |
| 120 | + | pub fn pwyw_min_dollars(&self) -> String { | |
| 121 | + | let min = self.pwyw_min_cents.unwrap_or(0).max(0); | |
| 122 | + | format!("{}.{:02}", min / 100, min % 100) | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | /// Get all cart items for a user with joined item, project, and seller data. | |
| 127 | + | /// Only returns items that are still public and not deleted. | |
| 128 | + | #[tracing::instrument(skip_all)] | |
| 129 | + | pub async fn get_cart_items(pool: &PgPool, user_id: UserId) -> Result<Vec<CartItem>> { | |
| 130 | + | let items = sqlx::query_as::<_, CartItem>( | |
| 131 | + | r#" | |
| 132 | + | SELECT c.item_id, i.title, i.item_type::TEXT as item_type, | |
| 133 | + | i.price_cents, i.pwyw_enabled, i.pwyw_min_cents, | |
| 134 | + | c.amount_cents, | |
| 135 | + | u.username AS creator_username, p.user_id AS seller_id, | |
| 136 | + | u.stripe_account_id AS seller_stripe_account_id, | |
| 137 | + | u.stripe_charges_enabled AS seller_charges_enabled, | |
| 138 | + | p.slug AS project_slug, | |
| 139 | + | c.created_at AS added_at | |
| 140 | + | FROM cart_items c | |
| 141 | + | JOIN items i ON i.id = c.item_id | |
| 142 | + | JOIN projects p ON p.id = i.project_id | |
| 143 | + | JOIN users u ON u.id = p.user_id | |
| 144 | + | WHERE c.user_id = $1 | |
| 145 | + | AND i.is_public = true | |
| 146 | + | AND i.deleted_at IS NULL | |
| 147 | + | ORDER BY u.username, c.created_at DESC | |
| 148 | + | "#, | |
| 149 | + | ) | |
| 150 | + | .bind(user_id) | |
| 151 | + | .fetch_all(pool) | |
| 152 | + | .await?; | |
| 153 | + | ||
| 154 | + | Ok(items) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | /// Get cart items for a user filtered to a specific seller. | |
| 158 | + | #[tracing::instrument(skip_all)] | |
| 159 | + | pub async fn get_cart_items_for_seller( | |
| 160 | + | pool: &PgPool, | |
| 161 | + | user_id: UserId, | |
| 162 | + | seller_id: UserId, | |
| 163 | + | ) -> Result<Vec<CartItem>> { | |
| 164 | + | let items = sqlx::query_as::<_, CartItem>( | |
| 165 | + | r#" | |
| 166 | + | SELECT c.item_id, i.title, i.item_type::TEXT as item_type, | |
| 167 | + | i.price_cents, i.pwyw_enabled, i.pwyw_min_cents, | |
| 168 | + | c.amount_cents, | |
| 169 | + | u.username AS creator_username, p.user_id AS seller_id, | |
| 170 | + | u.stripe_account_id AS seller_stripe_account_id, | |
| 171 | + | u.stripe_charges_enabled AS seller_charges_enabled, | |
| 172 | + | p.slug AS project_slug, | |
| 173 | + | c.created_at AS added_at | |
| 174 | + | FROM cart_items c | |
| 175 | + | JOIN items i ON i.id = c.item_id | |
| 176 | + | JOIN projects p ON p.id = i.project_id | |
| 177 | + | JOIN users u ON u.id = p.user_id | |
| 178 | + | WHERE c.user_id = $1 | |
| 179 | + | AND p.user_id = $2 | |
| 180 | + | AND i.is_public = true | |
| 181 | + | AND i.deleted_at IS NULL | |
| 182 | + | ORDER BY c.created_at DESC | |
| 183 | + | "#, | |
| 184 | + | ) | |
| 185 | + | .bind(user_id) | |
| 186 | + | .bind(seller_id) | |
| 187 | + | .fetch_all(pool) | |
| 188 | + | .await?; | |
| 189 | + | ||
| 190 | + | Ok(items) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | /// Remove all cart items belonging to a specific seller (after checkout). | |
| 194 | + | #[tracing::instrument(skip_all)] | |
| 195 | + | pub async fn remove_seller_items_from_cart( | |
| 196 | + | pool: &PgPool, | |
| 197 | + | user_id: UserId, | |
| 198 | + | seller_id: UserId, | |
| 199 | + | ) -> Result<u64> { | |
| 200 | + | let result = sqlx::query( | |
| 201 | + | r#" | |
| 202 | + | DELETE FROM cart_items c | |
| 203 | + | USING items i | |
| 204 | + | JOIN projects p ON p.id = i.project_id | |
| 205 | + | WHERE c.user_id = $1 | |
| 206 | + | AND c.item_id = i.id | |
| 207 | + | AND p.user_id = $2 | |
| 208 | + | "#, | |
| 209 | + | ) | |
| 210 | + | .bind(user_id) | |
| 211 | + | .bind(seller_id) | |
| 212 | + | .execute(pool) | |
| 213 | + | .await?; | |
| 214 | + | ||
| 215 | + | Ok(result.rows_affected()) | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | /// Remove stale cart items older than the given duration. | |
| 219 | + | #[tracing::instrument(skip_all)] | |
| 220 | + | pub async fn cleanup_stale_cart_items(pool: &PgPool, older_than: Duration) -> Result<u64> { | |
| 221 | + | let cutoff = Utc::now() - older_than; | |
| 222 | + | let result = sqlx::query("DELETE FROM cart_items WHERE created_at < $1") | |
| 223 | + | .bind(cutoff) | |
| 224 | + | .execute(pool) | |
| 225 | + | .await?; | |
| 226 | + | ||
| 227 | + | Ok(result.rows_affected()) | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | /// Remove cart items for items that are no longer available (unpublished, deleted). | |
| 231 | + | #[tracing::instrument(skip_all)] | |
| 232 | + | pub async fn cleanup_unavailable_cart_items(pool: &PgPool) -> Result<u64> { | |
| 233 | + | let result = sqlx::query( | |
| 234 | + | r#" | |
| 235 | + | DELETE FROM cart_items c | |
| 236 | + | USING items i | |
| 237 | + | WHERE c.item_id = i.id | |
| 238 | + | AND (i.is_public = false OR i.deleted_at IS NOT NULL) | |
| 239 | + | "#, | |
| 240 | + | ) | |
| 241 | + | .execute(pool) | |
| 242 | + | .await?; | |
| 243 | + | ||
| 244 | + | Ok(result.rows_affected()) | |
| 245 | + | } |
| @@ -625,6 +625,42 @@ pub async fn check_presign_allowed( | |||
| 625 | 625 | Ok(()) | |
| 626 | 626 | } | |
| 627 | 627 | ||
| 628 | + | /// Return the effective per-file size limit in bytes for this user, accounting | |
| 629 | + | /// for their active tier and any admin override. Returns `None` for file types | |
| 630 | + | /// that bypass tier checks (covers, media images). | |
| 631 | + | #[tracing::instrument(skip_all)] | |
| 632 | + | pub async fn get_effective_max_file_bytes( | |
| 633 | + | pool: &PgPool, | |
| 634 | + | user_id: UserId, | |
| 635 | + | file_type: FileType, | |
| 636 | + | ) -> Result<Option<u64>> { | |
| 637 | + | if file_type == FileType::Cover || file_type == FileType::MediaImage { | |
| 638 | + | return Ok(None); | |
| 639 | + | } | |
| 640 | + | ||
| 641 | + | let active_tier = get_active_creator_tier(pool, user_id).await?; | |
| 642 | + | let grandfathered = get_grandfathered_until(pool, user_id).await?; | |
| 643 | + | ||
| 644 | + | let effective_tier = match active_tier { | |
| 645 | + | Some(tier) => tier, | |
| 646 | + | None => { | |
| 647 | + | if let Some(until) = grandfathered { | |
| 648 | + | if Utc::now() < until { | |
| 649 | + | CreatorTier::SmallFiles | |
| 650 | + | } else { | |
| 651 | + | return Ok(Some(file_type.max_size())); | |
| 652 | + | } | |
| 653 | + | } else { | |
| 654 | + | return Ok(Some(file_type.max_size())); | |
| 655 | + | } | |
| 656 | + | } | |
| 657 | + | }; | |
| 658 | + | ||
| 659 | + | let max_override = get_max_file_override(pool, user_id).await?; | |
| 660 | + | let tier_limit = max_override.unwrap_or(effective_tier.max_file_bytes()) as u64; | |
| 661 | + | Ok(Some(std::cmp::min(tier_limit, file_type.max_size()))) | |
| 662 | + | } | |
| 663 | + | ||
| 628 | 664 | /// Get total known file sizes for a user (versions + content insertions). | |
| 629 | 665 | /// Used by the account deletion form to show how much data will be removed. | |
| 630 | 666 | #[tracing::instrument(skip_all)] |
| @@ -928,6 +928,7 @@ pub enum CheckoutType { | |||
| 928 | 928 | Tip, | |
| 929 | 929 | FanPlus, | |
| 930 | 930 | CreatorTier, | |
| 931 | + | Cart, | |
| 931 | 932 | } | |
| 932 | 933 | ||
| 933 | 934 | impl_str_enum!(CheckoutType { | |
| @@ -936,6 +937,7 @@ impl_str_enum!(CheckoutType { | |||
| 936 | 937 | Tip => "tip", | |
| 937 | 938 | FanPlus => "fan_plus", | |
| 938 | 939 | CreatorTier => "creator_tier", | |
| 940 | + | Cart => "cart", | |
| 939 | 941 | }); | |
| 940 | 942 | ||
| 941 | 943 | impl ModerationActionType { |
| @@ -63,6 +63,7 @@ pub(crate) mod webhook_events; | |||
| 63 | 63 | pub(crate) mod scheduler_jobs; | |
| 64 | 64 | pub(crate) mod moderation; | |
| 65 | 65 | pub(crate) mod wishlists; | |
| 66 | + | pub(crate) mod cart; | |
| 66 | 67 | pub(crate) mod page_views; | |
| 67 | 68 | ||
| 68 | 69 | pub use id_types::*; |
| @@ -210,6 +210,33 @@ pub async fn complete_transaction<'e>( | |||
| 210 | 210 | Ok(tx) | |
| 211 | 211 | } | |
| 212 | 212 | ||
| 213 | + | /// Complete ALL pending transactions for a cart checkout session. | |
| 214 | + | /// Returns the list of completed transactions (empty if already processed). | |
| 215 | + | #[tracing::instrument(skip_all)] | |
| 216 | + | pub async fn complete_cart_transactions<'e>( | |
| 217 | + | executor: impl sqlx::PgExecutor<'e>, | |
| 218 | + | stripe_checkout_session_id: &str, | |
| 219 | + | stripe_payment_intent_id: &str, | |
| 220 | + | ) -> Result<Vec<DbTransaction>> { | |
| 221 | + | let txs = sqlx::query_as::<_, DbTransaction>( | |
| 222 | + | r#" | |
| 223 | + | UPDATE transactions | |
| 224 | + | SET status = 'completed', | |
| 225 | + | stripe_payment_intent_id = $2, | |
| 226 | + | completed_at = NOW() | |
| 227 | + | WHERE stripe_checkout_session_id = $1 | |
| 228 | + | AND status = 'pending' | |
| 229 | + | RETURNING * | |
| 230 | + | "#, | |
| 231 | + | ) | |
| 232 | + | .bind(stripe_checkout_session_id) | |
| 233 | + | .bind(stripe_payment_intent_id) | |
| 234 | + | .fetch_all(executor) | |
| 235 | + | .await?; | |
| 236 | + | ||
| 237 | + | Ok(txs) | |
| 238 | + | } | |
| 239 | + | ||
| 213 | 240 | /// List transactions where the user is the buyer, newest first. | |
| 214 | 241 | /// | |
| 215 | 242 | /// Pass `limit: None` for all rows (exports), or `Some(n)` for dashboard display. |
| @@ -48,7 +48,6 @@ pub async fn remove_from_wishlist(pool: &PgPool, user_id: UserId, item_id: ItemI | |||
| 48 | 48 | ||
| 49 | 49 | /// A wishlisted item with joined display data. | |
| 50 | 50 | #[derive(Debug, Clone, sqlx::FromRow)] | |
| 51 | - | #[allow(dead_code)] | |
| 52 | 51 | pub struct WishlistItem { | |
| 53 | 52 | pub item_id: ItemId, | |
| 54 | 53 | pub title: String, | |
| @@ -59,7 +58,6 @@ pub struct WishlistItem { | |||
| 59 | 58 | } | |
| 60 | 59 | ||
| 61 | 60 | /// Get the user's wishlist with item details. | |
| 62 | - | #[allow(dead_code)] | |
| 63 | 61 | #[tracing::instrument(skip_all)] | |
| 64 | 62 | pub async fn get_wishlist(pool: &PgPool, user_id: UserId) -> Result<Vec<WishlistItem>> { | |
| 65 | 63 | let items = sqlx::query_as::<_, WishlistItem>( |
| @@ -24,6 +24,23 @@ pub struct CheckoutParams<'a> { | |||
| 24 | 24 | pub enable_stripe_tax: bool, | |
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | + | /// A single line item in a cart checkout. | |
| 28 | + | pub struct CartLineItem<'a> { | |
| 29 | + | pub title: &'a str, | |
| 30 | + | pub amount_cents: i32, | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | /// Parameters for creating a multi-item cart Checkout Session. | |
| 34 | + | pub struct CartCheckoutParams<'a> { | |
| 35 | + | pub connected_account_id: &'a str, | |
| 36 | + | pub line_items: &'a [CartLineItem<'a>], | |
| 37 | + | pub buyer_id: UserId, | |
| 38 | + | pub seller_id: UserId, | |
| 39 | + | pub success_url: &'a str, | |
| 40 | + | pub cancel_url: &'a str, | |
| 41 | + | pub enable_stripe_tax: bool, | |
| 42 | + | } | |
| 43 | + | ||
| 27 | 44 | /// Parameters for creating a subscription Checkout Session. | |
| 28 | 45 | pub struct SubscriptionCheckoutParams<'a> { | |
| 29 | 46 | pub connected_account_id: &'a str, | |
| @@ -200,6 +217,69 @@ impl StripeClient { | |||
| 200 | 217 | Ok(session) | |
| 201 | 218 | } | |
| 202 | 219 | ||
| 220 | + | /// Create a multi-line-item Checkout Session for a cart purchase. | |
| 221 | + | /// | |
| 222 | + | /// Groups multiple items from the same seller into one session, saving | |
| 223 | + | /// the $0.30 flat Stripe fee per additional item. Uses Direct Charges. | |
| 224 | + | #[tracing::instrument(skip_all, name = "payments::create_cart_checkout_session")] | |
| 225 | + | pub async fn create_cart_checkout_session( | |
| 226 | + | &self, | |
| 227 | + | cart: &CartCheckoutParams<'_>, | |
| 228 | + | ) -> Result<CheckoutSession> { | |
| 229 | + | let mut params = CreateCheckoutSession::new(); | |
| 230 | + | params.mode = Some(CheckoutSessionMode::Payment); | |
| 231 | + | params.success_url = Some(cart.success_url); | |
| 232 | + | params.cancel_url = Some(cart.cancel_url); | |
| 233 | + | ||
| 234 | + | let line_items: Vec<CreateCheckoutSessionLineItems> = cart | |
| 235 | + | .line_items | |
| 236 | + | .iter() | |
| 237 | + | .map(|li| CreateCheckoutSessionLineItems { | |
| 238 | + | price_data: Some(CreateCheckoutSessionLineItemsPriceData { | |
| 239 | + | currency: Currency::USD, | |
| 240 | + | product_data: Some(CreateCheckoutSessionLineItemsPriceDataProductData { | |
| 241 | + | name: li.title.to_string(), | |
| 242 | + | ..Default::default() | |
| 243 | + | }), | |
| 244 | + | unit_amount: Some(li.amount_cents as i64), | |
| 245 | + | ..Default::default() | |
| 246 | + | }), | |
| 247 | + | quantity: Some(1), | |
| 248 | + | ..Default::default() | |
| 249 | + | }) | |
| 250 | + | .collect(); | |
| 251 | + | params.line_items = Some(line_items); | |
| 252 | + | ||
| 253 | + | let mut metadata = std::collections::HashMap::new(); | |
| 254 | + | metadata.insert("checkout_type".to_string(), CheckoutType::Cart.to_string()); | |
| 255 | + | metadata.insert("buyer_id".to_string(), cart.buyer_id.to_string()); | |
| 256 | + | metadata.insert("seller_id".to_string(), cart.seller_id.to_string()); | |
| 257 | + | params.metadata = Some(metadata); | |
| 258 | + | ||
| 259 | + | if cart.enable_stripe_tax { | |
| 260 | + | params.automatic_tax = Some(stripe::CreateCheckoutSessionAutomaticTax { | |
| 261 | + | enabled: true, | |
| 262 | + | liability: None, | |
| 263 | + | }); | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | let session = CheckoutSession::create( | |
| 267 | + | &self.client.clone().with_stripe_account( | |
| 268 | + | cart.connected_account_id.parse().map_err(|_| { | |
| 269 | + | AppError::BadRequest("Invalid Stripe account ID format".to_string()) | |
| 270 | + | })?, | |
| 271 | + | ), | |
| 272 | + | params, | |
| 273 | + | ) | |
| 274 | + | .await | |
| 275 | + | .map_err(|e| { | |
| 276 | + | tracing::error!(error = ?e, "failed to create cart checkout session"); | |
| 277 | + | AppError::BadRequest("Failed to create checkout session".to_string()) | |
| 278 | + | })?; | |
| 279 | + | ||
| 280 | + | Ok(session) | |
| 281 | + | } | |
| 282 | + | ||
| 203 | 283 | /// Create a Checkout Session in subscription mode on a connected account. | |
| 204 | 284 | /// | |
| 205 | 285 | /// Uses Direct Charges pattern consistent with one-time purchases. | |
| @@ -625,6 +705,40 @@ pub fn is_guest_checkout(session: &CheckoutSession) -> bool { | |||
| 625 | 705 | get_checkout_type(session) == Some(CheckoutType::Guest) | |
| 626 | 706 | } | |
| 627 | 707 | ||
| 708 | + | /// Check if a checkout session is a cart (multi-item) checkout. | |
| 709 | + | pub fn is_cart_checkout(session: &CheckoutSession) -> bool { | |
| 710 | + | get_checkout_type(session) == Some(CheckoutType::Cart) | |
| 711 | + | } | |
| 712 | + | ||
| 713 | + | /// Parsed metadata from a cart checkout session. | |
| 714 | + | #[derive(Debug)] | |
| 715 | + | pub struct CartCheckoutMetadata { | |
| 716 | + | pub buyer_id: UserId, | |
| 717 | + | pub seller_id: UserId, | |
| 718 | + | } | |
| 719 | + | ||
| 720 | + | impl CartCheckoutMetadata { | |
| 721 | + | pub fn from_session(session: &CheckoutSession) -> Result<Self> { | |
| 722 | + | let meta = session.metadata.as_ref().ok_or(AppError::BadRequest( | |
| 723 | + | "Missing checkout metadata".to_string(), | |
| 724 | + | ))?; | |
| 725 | + | ||
| 726 | + | let buyer_id: UserId = meta | |
| 727 | + | .get("buyer_id") | |
| 728 | + | .ok_or_else(|| AppError::BadRequest("Missing buyer_id in metadata".to_string()))? | |
| 729 | + | .parse() | |
| 730 | + | .map_err(|_| AppError::BadRequest("Invalid buyer_id".to_string()))?; | |
| 731 | + | ||
| 732 | + | let seller_id: UserId = meta | |
| 733 | + | .get("seller_id") | |
| 734 | + | .ok_or_else(|| AppError::BadRequest("Missing seller_id in metadata".to_string()))? | |
| 735 | + | .parse() | |
| 736 | + | .map_err(|_| AppError::BadRequest("Invalid seller_id".to_string()))?; | |
| 737 | + | ||
| 738 | + | Ok(Self { buyer_id, seller_id }) | |
| 739 | + | } | |
| 740 | + | } | |
| 741 | + | ||
| 628 | 742 | /// Parsed metadata from a guest checkout session. | |
| 629 | 743 | #[derive(Debug)] | |
| 630 | 744 | pub struct GuestCheckoutMetadata { |
| @@ -64,6 +64,7 @@ pub trait PaymentProvider: Send + Sync { | |||
| 64 | 64 | async fn create_tip_checkout_session(&self, params: &TipCheckoutParams<'_>) -> crate::error::Result<CheckoutResult>; | |
| 65 | 65 | async fn create_fan_plus_checkout_session(&self, price_id: &str, user_id: crate::db::UserId, success_url: &str, cancel_url: &str) -> crate::error::Result<CheckoutResult>; | |
| 66 | 66 | async fn create_creator_tier_checkout_session(&self, price_id: &str, user_id: crate::db::UserId, tier: &str, success_url: &str, cancel_url: &str) -> crate::error::Result<CheckoutResult>; | |
| 67 | + | async fn create_cart_checkout_session(&self, params: &CartCheckoutParams<'_>) -> crate::error::Result<CheckoutResult>; | |
| 67 | 68 | ||
| 68 | 69 | // Connect | |
| 69 | 70 | async fn create_connect_account(&self, email: &str) -> crate::error::Result<String>; | |
| @@ -121,6 +122,11 @@ impl PaymentProvider for StripeClient { | |||
| 121 | 122 | Ok(CheckoutResult { id: session.id.to_string(), url: session.url }) | |
| 122 | 123 | } | |
| 123 | 124 | ||
| 125 | + | async fn create_cart_checkout_session(&self, params: &CartCheckoutParams<'_>) -> crate::error::Result<CheckoutResult> { | |
| 126 | + | let session = StripeClient::create_cart_checkout_session(self, params).await?; | |
| 127 | + | Ok(CheckoutResult { id: session.id.to_string(), url: session.url }) | |
| 128 | + | } | |
| 129 | + | ||
| 124 | 130 | async fn create_connect_account(&self, email: &str) -> crate::error::Result<String> { | |
| 125 | 131 | StripeClient::create_connect_account(self, email).await | |
| 126 | 132 | } |
| @@ -0,0 +1,132 @@ | |||
| 1 | + | //! Cart API: add/remove items, get count. | |
| 2 | + | ||
| 3 | + | use axum::extract::{Path, State}; | |
| 4 | + | use axum::response::IntoResponse; | |
| 5 | + | use axum::Json; | |
| 6 | + | ||
| 7 | + | use crate::{ | |
| 8 | + | auth::AuthUser, | |
| 9 | + | db::{self, ItemId}, | |
| 10 | + | error::{AppError, Result}, | |
| 11 | + | AppState, | |
| 12 | + | }; | |
| 13 | + | ||
| 14 | + | /// Toggle an item's cart status. Returns the new state. | |
| 15 | + | #[tracing::instrument(skip_all, name = "cart::toggle")] | |
| 16 | + | pub(super) async fn toggle_cart( | |
| 17 | + | State(state): State<AppState>, | |
| 18 | + | AuthUser(user): AuthUser, | |
| 19 | + | Path(item_id): Path<ItemId>, | |
| 20 | + | ) -> Result<impl IntoResponse> { | |
| 21 | + | // Verify item exists and is public | |
| 22 | + | let item = db::items::get_item_by_id(&state.db, item_id) | |
| 23 | + | .await? | |
| 24 | + | .ok_or(AppError::NotFound)?; | |
| 25 | + | if !item.is_public { | |
| 26 | + | return Err(AppError::NotFound); | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // Don't let sellers cart their own items | |
| 30 | + | let owner = db::items::get_item_owner(&state.db, item_id) | |
| 31 | + | .await? | |
| 32 | + | .ok_or(AppError::NotFound)?; | |
| 33 | + | if owner == user.id { | |
| 34 | + | return Err(AppError::BadRequest( | |
| 35 | + | "You can't add your own items to your cart.".to_string(), | |
| 36 | + | )); | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // Don't add items already purchased | |
| 40 | + | if db::transactions::has_purchased_item(&state.db, user.id, item_id).await? { | |
| 41 | + | return Err(AppError::BadRequest( | |
| 42 | + | "You already own this item.".to_string(), | |
| 43 | + | )); | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | let currently_in_cart = db::cart::is_in_cart(&state.db, user.id, item_id).await?; | |
| 47 | + | ||
| 48 | + | if currently_in_cart { | |
| 49 | + | db::cart::remove_from_cart(&state.db, user.id, item_id).await?; | |
| 50 | + | } else { | |
| 51 | + | db::cart::add_to_cart(&state.db, user.id, item_id).await?; | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | Ok(Json(serde_json::json!({ "in_cart": !currently_in_cart }))) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /// Remove an item from the cart explicitly. Returns 204. | |
| 58 | + | #[tracing::instrument(skip_all, name = "cart::remove")] | |
| 59 | + | pub(super) async fn remove_from_cart( | |
| 60 | + | State(state): State<AppState>, | |
| 61 | + | AuthUser(user): AuthUser, | |
| 62 | + | Path(item_id): Path<ItemId>, | |
| 63 | + | ) -> Result<impl IntoResponse> { | |
| 64 | + | db::cart::remove_from_cart(&state.db, user.id, item_id).await?; | |
| 65 | + | Ok(axum::http::StatusCode::NO_CONTENT) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | /// Update the PWYW amount for a cart item. | |
| 69 | + | #[tracing::instrument(skip_all, name = "cart::update_amount")] | |
| 70 | + | pub(super) async fn update_cart_amount( | |
| 71 | + | State(state): State<AppState>, | |
| 72 | + | AuthUser(user): AuthUser, | |
| 73 | + | Path(item_id): Path<ItemId>, | |
| 74 | + | Json(body): Json<UpdateCartAmountRequest>, | |
| 75 | + | ) -> Result<impl IntoResponse> { | |
| 76 | + | // Verify item exists and is PWYW | |
| 77 | + | let item = db::items::get_item_by_id(&state.db, item_id) | |
| 78 | + | .await? | |
| 79 | + | .ok_or(AppError::NotFound)?; | |
| 80 | + | ||
| 81 | + | if !item.pwyw_enabled { | |
| 82 | + | return Err(AppError::BadRequest( | |
| 83 | + | "This item does not use pay-what-you-want pricing.".to_string(), | |
| 84 | + | )); | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // Validate amount against minimum | |
| 88 | + | let min = item.pwyw_min_cents.unwrap_or(0); | |
| 89 | + | if body.amount_cents < min { | |
| 90 | + | return Err(AppError::BadRequest(format!( | |
| 91 | + | "Amount must be at least ${}.{:02}.", | |
| 92 | + | min / 100, | |
| 93 | + | min % 100 | |
| 94 | + | ))); | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // Cap at $10,000 | |
| 98 | + | if body.amount_cents > 1_000_000 { | |
| 99 | + | return Err(AppError::BadRequest( | |
| 100 | + | "Amount cannot exceed $10,000.".to_string(), | |
| 101 | + | )); | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | let updated = db::cart::update_cart_amount( | |
| 105 | + | &state.db, | |
| 106 | + | user.id, | |
| 107 | + | item_id, | |
| 108 | + | Some(body.amount_cents), | |
| 109 | + | ) | |
| 110 | + | .await?; | |
| 111 | + | ||
| 112 | + | if !updated { | |
| 113 | + | return Err(AppError::NotFound); | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | Ok(Json(serde_json::json!({ "amount_cents": body.amount_cents }))) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | #[derive(Debug, serde::Deserialize)] | |
| 120 | + | pub(super) struct UpdateCartAmountRequest { | |
| 121 | + | pub amount_cents: i32, | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | /// Get the number of items in the cart (for nav badge). | |
| 125 | + | #[tracing::instrument(skip_all, name = "cart::count")] | |
| 126 | + | pub(super) async fn cart_count( | |
| 127 | + | State(state): State<AppState>, | |
| 128 | + | AuthUser(user): AuthUser, | |
| 129 | + | ) -> Result<impl IntoResponse> { | |
| 130 | + | let count = db::cart::get_cart_count(&state.db, user.id).await?; | |
| 131 | + | Ok(Json(serde_json::json!({ "count": count }))) | |
| 132 | + | } |
| @@ -36,6 +36,7 @@ mod reports; | |||
| 36 | 36 | mod collections; | |
| 37 | 37 | mod validate; | |
| 38 | 38 | mod wishlists; | |
| 39 | + | mod cart; | |
| 39 | 40 | mod domains; | |
| 40 | 41 | mod guest_checkout; | |
| 41 | 42 | mod imports; | |
| @@ -331,6 +332,10 @@ pub fn api_routes() -> Router<AppState> { | |||
| 331 | 332 | .route("/api/collections/{id}/items/reorder", put(collections::reorder_items)) | |
| 332 | 333 | // Wishlists | |
| 333 | 334 | .route("/api/wishlists/{item_id}", post(wishlists::toggle_wishlist)) | |
| 335 | + | // Cart | |
| 336 | + | .route("/api/cart/{item_id}", post(cart::toggle_cart)) | |
| 337 | + | .route("/api/cart/{item_id}", put(cart::update_cart_amount)) | |
| 338 | + | .route("/api/cart/{item_id}", delete(cart::remove_from_cart)) | |
| 334 | 339 | // Custom domains | |
| 335 | 340 | .route("/api/domains", post(domains::add_domain)) | |
| 336 | 341 | .route("/api/domains/verify", post(domains::verify_domain)) | |
| @@ -403,6 +408,8 @@ pub fn api_routes() -> Router<AppState> { | |||
| 403 | 408 | .route("/api/domains", get(domains::get_domain)) | |
| 404 | 409 | .route("/api/domains/caddy-ask", get(domains::caddy_ask)) | |
| 405 | 410 | .route("/api/restart-status", get(internal::restart_status)) | |
| 411 | + | // Cart (read) | |
| 412 | + | .route("/api/cart/count", get(cart::cart_count)) | |
| 406 | 413 | // Import system (read) | |
| 407 | 414 | .route("/api/users/me/import/{id}", get(imports::get_import_status)) | |
| 408 | 415 | .route("/api/users/me/imports", get(imports::list_imports)) |
| @@ -134,10 +134,15 @@ pub(super) async fn project_tab_overview( | |||
| 134 | 134 | .await? | |
| 135 | 135 | .ok_or(AppError::NotFound)?; | |
| 136 | 136 | ||
| 137 | + | let has_items = !db_items.is_empty(); | |
| 138 | + | let has_published_item = db_items.iter().any(|i| i.is_public); | |
| 139 | + | ||
| 137 | 140 | Ok(helpers::with_etag(generation, ProjectOverviewTabTemplate { | |
| 138 | 141 | stats, | |
| 139 | 142 | project_slug: db_project.slug.to_string(), | |
| 140 | 143 | stripe_connected: db_user.stripe_account_id.is_some(), | |
| 144 | + | has_items, | |
| 145 | + | has_published_item, | |
| 141 | 146 | })) | |
| 142 | 147 | } | |
| 143 | 148 |
| @@ -41,7 +41,6 @@ pub const ITEM_STEPS: &[&str] = &[ | |||
| 41 | 41 | "type", | |
| 42 | 42 | "basics", | |
| 43 | 43 | "content", | |
| 44 | - | "sections", | |
| 45 | 44 | "pricing", | |
| 46 | 45 | "preview", | |
| 47 | 46 | ]; | |
| @@ -51,7 +50,6 @@ pub(super) const ITEM_LABELS: &[&str] = &[ | |||
| 51 | 50 | "Type", | |
| 52 | 51 | "Basics", | |
| 53 | 52 | "Content", | |
| 54 | - | "Sections", | |
| 55 | 53 | "Pricing", | |
| 56 | 54 | "Preview", | |
| 57 | 55 | ]; |
| @@ -345,6 +345,12 @@ pub(crate) async fn render_item_page( | |||
| 345 | 345 | false | |
| 346 | 346 | }; | |
| 347 | 347 | ||
| 348 | + | let in_cart = if let Some(ref user) = maybe_user { | |
| 349 | + | db::cart::is_in_cart(&state.db, user.id, db_item.id).await.unwrap_or(false) | |
| 350 | + | } else { | |
| 351 | + | false | |
| 352 | + | }; | |
| 353 | + | ||
| 348 | 354 | Ok(ItemTemplate { | |
| 349 | 355 | csrf_token, | |
| 350 | 356 | session_user: maybe_user, | |
| @@ -362,6 +368,7 @@ pub(crate) async fn render_item_page( | |||
| 362 | 368 | sections, | |
| 363 | 369 | is_owner, | |
| 364 | 370 | is_wishlisted, | |
| 371 | + | in_cart, | |
| 365 | 372 | } | |
| 366 | 373 | .into_response()) | |
| 367 | 374 | } |
| @@ -69,6 +69,71 @@ pub(super) async fn library( | |||
| 69 | 69 | }) | |
| 70 | 70 | } | |
| 71 | 71 | ||
| 72 | + | /// Render the shopping cart page with items grouped by seller. | |
| 73 | + | #[tracing::instrument(skip_all, name = "landing::cart_page")] | |
| 74 | + | pub(super) async fn cart_page( | |
| 75 | + | State(state): State<AppState>, | |
| 76 | + | session: Session, | |
| 77 | + | AuthUser(user): AuthUser, | |
| 78 | + | ) -> Result<impl IntoResponse> { | |
| 79 | + | use std::collections::BTreeMap; | |
| 80 | + | use crate::templates::CartSellerGroup; | |
| 81 | + | ||
| 82 | + | let cart_items = db::cart::get_cart_items(&state.db, user.id).await?; | |
| 83 | + | ||
| 84 | + | // Group by seller | |
| 85 | + | let mut groups: BTreeMap<String, Vec<db::cart::CartItem>> = BTreeMap::new(); | |
| 86 | + | for item in cart_items.iter() { | |
| 87 | + | groups | |
| 88 | + | .entry(item.seller_id.to_string()) | |
| 89 | + | .or_default() | |
| 90 | + | .push(item.clone()); | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | let seller_groups: Vec<CartSellerGroup> = groups | |
| 94 | + | .into_iter() | |
| 95 | + | .map(|(seller_id_str, items)| { | |
| 96 | + | let subtotal_cents: i32 = items.iter().map(|i| i.effective_price_cents()).sum(); | |
| 97 | + | let item_count = items.len(); | |
| 98 | + | // Savings: buying N items in one session saves (N-1) * $0.30 | |
| 99 | + | let savings_cents = if item_count > 1 { (item_count as i32 - 1) * 30 } else { 0 }; | |
| 100 | + | let seller_username = items.first().map(|i| i.creator_username.clone()).unwrap_or_default(); | |
| 101 | + | let stripe_ready = items.first().map(|i| { | |
| 102 | + | i.seller_stripe_account_id.is_some() && i.seller_charges_enabled | |
| 103 | + | }).unwrap_or(false); | |
| 104 | + | ||
| 105 | + | CartSellerGroup { | |
| 106 | + | seller_username, | |
| 107 | + | seller_id: seller_id_str, | |
| 108 | + | stripe_ready, | |
| 109 | + | items, | |
| 110 | + | subtotal_cents, | |
| 111 | + | item_count, | |
| 112 | + | savings_cents, | |
| 113 | + | } | |
| 114 | + | }) | |
| 115 | + | .collect(); | |
| 116 | + | ||
| 117 | + | let total_items: usize = seller_groups.iter().map(|g| g.item_count).sum(); | |
| 118 | + | ||
| 119 | + | // Wishlist suggestions: items in wishlist but not in cart | |
| 120 | + | let wishlist = db::wishlists::get_wishlist(&state.db, user.id).await?; | |
| 121 | + | let cart_item_ids: std::collections::HashSet<_> = cart_items.iter().map(|i| i.item_id).collect(); | |
| 122 | + | let wishlist_suggestions: Vec<_> = wishlist | |
| 123 | + | .into_iter() | |
| 124 | + | .filter(|w| !cart_item_ids.contains(&w.item_id)) | |
| 125 | + | .take(10) | |
| 126 | + | .collect(); | |
| 127 | + | ||
| 128 | + | Ok(CartTemplate { | |
| 129 | + | csrf_token: get_csrf_token(&session).await, | |
| 130 | + | session_user: Some(user), | |
| 131 | + | seller_groups, | |
| 132 | + | wishlist_suggestions, | |
| 133 | + | total_items, | |
| 134 | + | }) | |
| 135 | + | } | |
| 136 | + | ||
| 72 | 137 | /// HTMX partial: library purchases tab. | |
| 73 | 138 | #[tracing::instrument(skip_all, name = "landing::library_tab_purchases")] | |
| 74 | 139 | pub(super) async fn library_tab_purchases( | |
| @@ -136,6 +201,16 @@ pub(super) async fn library_tab_contacts( | |||
| 136 | 201 | Ok(LibraryContactsTabTemplate { shared_creators, buyer_contacts, total_buyer_contacts }) | |
| 137 | 202 | } | |
| 138 | 203 | ||
| 204 | + | /// HTMX partial: library wishlists tab. | |
| 205 | + | #[tracing::instrument(skip_all, name = "landing::library_tab_wishlists")] | |
| 206 | + | pub(super) async fn library_tab_wishlists( | |
| 207 | + | State(state): State<AppState>, | |
| 208 | + | AuthUser(user): AuthUser, | |
| 209 | + | ) -> Result<impl IntoResponse> { | |
| 210 | + | let wishlists = db::wishlists::get_wishlist(&state.db, user.id).await?; | |
| 211 | + | Ok(LibraryWishlistsTabTemplate { wishlists }) | |
| 212 | + | } | |
| 213 | + | ||
| 139 | 214 | /// HTMX partial: library communities tab (Multithreaded forum memberships). | |
| 140 | 215 | #[tracing::instrument(skip_all, name = "landing::library_tab_communities")] | |
| 141 | 216 | pub(super) async fn library_tab_communities( |
| @@ -38,9 +38,11 @@ pub fn public_routes() -> Router<AppState> { | |||
| 38 | 38 | Router::new() | |
| 39 | 39 | .route("/", get(landing::index)) | |
| 40 | 40 | .route("/library", get(landing::library)) | |
| 41 | + | .route("/cart", get(landing::cart_page)) | |
| 41 | 42 | .route("/library/tabs/purchases", get(landing::library_tab_purchases)) | |
| 42 | 43 | .route("/library/tabs/subscriptions", get(landing::library_tab_subscriptions)) | |
| 43 | 44 | .route("/library/tabs/collections", get(landing::library_tab_collections)) | |
| 45 | + | .route("/library/tabs/wishlists", get(landing::library_tab_wishlists)) | |
| 44 | 46 | .route("/library/tabs/contacts", get(landing::library_tab_contacts)) | |
| 45 | 47 | .route("/library/tabs/communities", get(landing::library_tab_communities)) | |
| 46 | 48 | .route("/health", get(health::health)) |
| @@ -95,6 +95,7 @@ pub(super) async fn project_image_presign( | |||
| 95 | 95 | s3_key, | |
| 96 | 96 | expires_in, | |
| 97 | 97 | cache_control: Some(CACHE_CONTROL_IMMUTABLE.to_string()), | |
| 98 | + | max_file_bytes: None, | |
| 98 | 99 | })) | |
| 99 | 100 | } | |
| 100 | 101 | ||
| @@ -248,6 +249,7 @@ pub(super) async fn item_image_presign( | |||
| 248 | 249 | s3_key, | |
| 249 | 250 | expires_in, | |
| 250 | 251 | cache_control: Some(CACHE_CONTROL_IMMUTABLE.to_string()), | |
| 252 | + | max_file_bytes: None, | |
| 251 | 253 | })) | |
| 252 | 254 | } | |
| 253 | 255 |