Skip to main content

max / makenotwork

Add shopping cart, UX audit remediation, and upload improvements Shopping cart with multi-item Stripe checkout sessions, wishlist-to-cart flow, cross-seller sequential checkout, promo code support, and PWYW custom amounts. UX audit: learnability fixes (jargon, empty states, onboarding checklists), complexity reductions (item dashboard 7->5 tabs, wizard 6->5 steps), discoverability (wishlists tab, RSS, search filter), and upload flow improvements (client-side pre-validation, speed/ETA). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-07 00:42 UTC
Commit: 85c9938c720cdbcb6ed60f0e8ab480b18e136bce
Parent: 2883844
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