Skip to main content

max / makenotwork

Add usability audit remediations, page view tracking, buyer contacts, cross-project analytics UX easy wins: Docs/Fan+/Changelog links in header nav, contextual Edit links on public pages, project/item wizard hints, Members→Team rename, Copy ID tooltip, upload error messages now surface server-side detail. Terminology: subscription tiers→membership tiers across dashboard, wizards, public pages, and 7 docs files. Fan-facing "Subscribe" verbs kept. Page view tracking (M095): daily aggregate table with fire-and-forget UPSERT from item/project/user handlers, bot filtering, 2-year pruning. Buyer contacts: HTMX lazy-loaded section in Payments tab showing buyers who opted to share email, with CSV export. Uses existing get_seller_contacts(). Analytics: Views + Conversion stat cards on user and project analytics tabs. Cross-project comparison table with revenue bars, sales, views, conversion. New get_revenue_by_user_projects_in_range() for time-scoped comparison. Bump to 0.4.11. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-06 19:55 UTC
Commit: e0c1aec160cc26cc00d98e462ce3e9b5b10493fb
Parent: aa07511
45 files changed, +814 insertions, -83 deletions
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.4.10"
3 + version = "0.4.11"
4 4 edition = "2024"
5 5 license-file = "LICENSE"
6 6
@@ -3,7 +3,7 @@
3 3 ## Status
4 4 Done: All pre-beta phases, UX audit remediation, creator trust audit remediation. Active: Creator setup (Stripe), manual testing. Next: Soft launch.
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.
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.
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`.
@@ -71,11 +71,27 @@ Unify audio and video playback into one shared component. Video gets custom cont
71 71 - [ ] Add refund initiation from dashboard
72 72 - [ ] Add "Export as CSV" button on item sales tables
73 73
74 + ### Usability Audit (2026-05-06) — Easy Wins (batch) — DONE
75 + Completed: header nav (Docs, Fan+, Changelog links), contextual Edit links on project/user/item public pages, View Profile link on dashboard-user, Members→Team rename, project/item wizard hints, Copy ID tooltip. Pre-existing: empty states (Projects tab, Content tab), onboarding descriptions, insertion→clip rename, RSS link, View Live link, Tools checkboxes.
76 +
77 + All easy wins complete. Upload error fix: client-side JS now parses server JSON error responses instead of showing generic "Failed to get upload URL" / "Failed to confirm upload". Server already returns tier-specific messages ("File exceeds maximum size of X MB", "Basic tier is text-only", "You've used X of Y storage"). Fixed in: item-upload.js (audio + version), item/content.html (video + batch).
78 +
79 + ### Usability Audit (2026-05-06) — Medium — DONE
80 + Pre-existing (audit false negatives — HTMX tab content not visible to probes): promo code management (Promotions tab), custom links editor (Profile tab), collections management (Library tab), bulk item operations (Content tab checkboxes + publish/unpublish/delete), custom domain config (Profile tab), scheduled releases (Settings tab — datetime picker + cancel).
81 +
82 + Completed (2026-05-06):
83 + - Buyer Contacts: HTMX lazy-loaded in Payments tab, CSV export at `/api/export/contacts`. Uses existing `get_seller_contacts()` — no migration needed. Respects contact_revocations.
84 + - Page View Tracking: Migration 095 (`page_view_daily` table), fire-and-forget UPSERT from item/project/user page handlers, bot filtering, 2-year pruning in scheduler.
85 + - Cross-project analytics: Project Comparison table on user Analytics tab (revenue with inline bars, sales, views, conversion). Time-range aware.
86 + - Conversion analytics: Views + Conversion stat cards on both user and project Analytics tabs. Period-over-period % for views.
87 + - Subscription → Membership terminology: project dashboard tab, wizard, project page, 7 docs files.
88 +
74 89 ### UX — Deferred (post-beta table stakes)
75 90 - [ ] Reviews/ratings system for items
76 91 - [ ] Gift purchases at checkout
77 92 - [ ] HTML rich email for creator broadcasts (currently plain text only)
78 - - [ ] RSS feed link on all project pages (currently only shown if blog posts exist)
93 + - [ ] Creator-to-fan broadcast email composer (full system, not just contact export)
94 + - [ ] In-app notification center (beyond email-only notifications)
79 95
80 96 ---
81 97
@@ -0,0 +1,12 @@
1 + -- Daily aggregated page view counts per target.
2 + -- One row per (target_type, target_id, date). The tracking handler UPSERTs
3 + -- with ON CONFLICT ... DO UPDATE SET view_count = view_count + 1.
4 + CREATE TABLE page_view_daily (
5 + target_type TEXT NOT NULL,
6 + target_id UUID NOT NULL,
7 + view_date DATE NOT NULL DEFAULT CURRENT_DATE,
8 + view_count BIGINT NOT NULL DEFAULT 1,
9 + PRIMARY KEY (target_type, target_id, view_date)
10 + );
11 +
12 + CREATE INDEX idx_pvd_date ON page_view_daily (view_date DESC);
@@ -6,16 +6,16 @@ Set prices, manage purchases, and get paid.
6 6
7 7 Three models available per item: **free**, **fixed price**, and **pay-what-you-want** (with optional minimum). Set prices when creating or editing an item. See [Pricing](./pricing.md) for detailed guidance on choosing a model.
8 8
9 - ## Subscriptions
9 + ## Membership Tiers
10 10
11 - Offer recurring monthly subscriptions per project:
11 + Offer recurring monthly memberships per project:
12 12
13 13 - Create multiple tiers with different prices
14 14 - Toggle tiers active/inactive without deleting them
15 15 - Fans subscribe through the payment checkout
16 16 - The payment processor handles billing, renewals, and cancellations
17 17
18 - Subscription status changes (active, past due, canceled, renewed) sync via webhooks and update your dashboard automatically.
18 + Membership status changes (active, past due, canceled, renewed) sync via webhooks and update your dashboard automatically.
19 19
20 20 ## License Keys
21 21
@@ -101,7 +101,7 @@ Email notifications for sales and account events, each individually toggleable i
101 101
102 102 ## Data Export
103 103
104 - Export all your data at any time — projects, items, blog posts, sales, purchases, followers, subscribers, and content files. No limits on how often you export. You own your data.
104 + Export all your data at any time — projects, items, blog posts, sales, purchases, followers, members, and content files. No limits on how often you export. You own your data.
105 105
106 106 See [Data Export](./export.md) for full details on formats, what's included, and how to download.
107 107
@@ -9,7 +9,7 @@ You can export all of your data from Makenot.work at any time. No lock-in, no fr
9 9 | Projects & Items | JSON | All projects, items, tags, chapters, versions, license keys, promo codes, and blog posts |
10 10 | Sales | CSV | Date, item, amount, status, and buyer email for every sale |
11 11 | Purchases | CSV | Date, item, amount, and status for everything you have bought |
12 - | Followers & Subscribers | CSV | Usernames, display names, types, status, and subscription dates |
12 + | Followers & Members | CSV | Usernames, display names, types, status, and membership dates |
13 13 | Content Files | ZIP | All uploaded files (audio, video, covers, versions, dynamic clips) with a manifest |
14 14
15 15 ## How to Export
@@ -14,7 +14,7 @@ Your purchases are permanent and downloadable. No DRM, no streaming-only restric
14 14
15 15 ## How Payments Work
16 16
17 - 1. You pay for content or a creator's subscription tier
17 + 1. You pay for content or join a creator's membership tier
18 18 2. Payment goes directly to the creator's account
19 19 3. Creator receives the payment minus ~3% processing
20 20 4. We make money from creator tier fees ($10-60/month they pay to host), not from your purchases
@@ -38,14 +38,14 @@ Buy individual items or albums. Pay once, own forever.
38 38 - Access never expires, even if the creator leaves the platform
39 39 - No DRM
40 40
41 - ### Subscriptions
41 + ### Memberships
42 42
43 - Subscribe to a creator for ongoing access:
43 + Join a creator's membership tier for ongoing access:
44 44
45 45 - Monthly billing, cancel anytime
46 - - Access to subscriber-only content while subscribed
46 + - Access to member-only content while subscribed
47 47 - Keep access until your billing period ends
48 - - Some creators grant permanent access to items released while you were subscribed
48 + - Some creators grant permanent access to items released while you were a member
49 49
50 50 ### Pay-What-You-Want
51 51
@@ -95,13 +95,13 @@ Your library at `/library` is where everything you've bought, subscribed to, or
95 95
96 96 Each entry links to the item page where you can stream or download.
97 97
98 - ### Purchases vs. Subscriptions
98 + ### Purchases vs. Memberships
99 99
100 100 This distinction matters:
101 101
102 102 **Purchases** are permanent. If you buy an album, it's in your library forever. If the creator raises the price later, you still have it. If the creator leaves the platform, you still have it. If you delete your account and come back, your purchases are still tied to your email.
103 103
104 - **Subscriptions** give you access while you're subscribed. Cancel, and you lose access to subscriber-only content at the end of your billing period. Items you purchased individually (not through subscription access) remain yours regardless.
104 + **Memberships** give you access while you're a member. Cancel, and you lose access to member-only content at the end of your billing period. Items you purchased individually (not through membership access) remain yours regardless.
105 105
106 106 ## Downloads
107 107
@@ -12,7 +12,7 @@ Fan+ subscribers receive:
12 12
13 13 We may occasionally offer bonuses to Fan+ members, but Fan+ will never affect how fans engage with creators or gate access to any creator's content. It is a way to support the platform, not a paywall between fans and the people they follow.
14 14
15 - Fan+ is separate from creator subscription tiers. A fan can subscribe to individual creators *and* be a Fan+ member — they serve different purposes.
15 + Fan+ is separate from creator membership tiers. A fan can join a creator's membership *and* be a Fan+ member — they serve different purposes.
16 16
17 17 ## How It Works
18 18
@@ -39,4 +39,4 @@ Fan+ subscriptions are managed entirely by the platform. As a creator, you do no
39 39 ## See Also
40 40
41 41 - [Selling & Audience](./03-selling.md) — Creator monetization overview
42 - - [Pricing Models](./pricing.md) — Creator subscription tiers (separate from Fan+)
42 + - [Pricing Models](./pricing.md) — Creator membership tiers (separate from Fan+)
@@ -99,23 +99,25 @@ Consider: $0 minimum for samples/singles, paid minimum for albums/premium.
99 99
100 100 ---
101 101
102 - ## Subscriptions
102 + ## Membership Tiers
103 103
104 - Fans pay monthly for ongoing access to your content. You decide what subscribers get:
104 + Fans pay monthly for ongoing access to your content. You decide what members get:
105 105
106 106 - Everything you publish
107 - - Exclusive subscriber-only content
107 + - Exclusive member-only content
108 108 - Early access to releases
109 109 - Behind-the-scenes content
110 110
111 - ### Creating a Subscription Tier
111 + Membership tiers are separate from [Fan+](./fan-plus.md), which is a platform-wide membership. A fan can join your project *and* be a Fan+ member — they serve different purposes.
112 112
113 - 1. Go to Settings > Monetization > Subscriptions
114 - 2. Click "Add Tier"
113 + ### Creating a Membership Tier
114 +
115 + 1. Go to project dashboard > Membership Tiers tab
116 + 2. Click "New Tier"
115 117 3. Set:
116 118 - **Name**: e.g., "Supporter", "All Access"
117 119 - **Price**: Monthly amount
118 - - **Description**: What subscribers get
120 + - **Description**: What members get
119 121 - **Access rules**: Which content is included
120 122
121 123 ### Multiple Tiers
@@ -124,28 +126,28 @@ Offer different levels:
124 126
125 127 | Tier | Price | Access |
126 128 |------|-------|--------|
127 - | Supporter | $3 | Early access, subscriber feed |
129 + | Supporter | $3 | Early access, member feed |
128 130 | All Access | $10 | Everything + downloads |
129 131 | Superfan | $25 | Everything + exclusive content |
130 132
131 133 Fans can upgrade/downgrade anytime.
132 134
133 - ### Subscriber-Only Content
135 + ### Member-Only Content
134 136
135 137 When uploading or editing content:
136 138
137 - 1. Set visibility to "Subscribers"
139 + 1. Set visibility to "Members"
138 140 2. Choose which tier(s) can access
139 141 3. Optionally set a public release date
140 142
141 - Content can graduate from subscriber-only to public over time.
143 + Content can graduate from member-only to public over time.
142 144
143 - ### Managing Subscribers
145 + ### Managing Members
144 146
145 147 Your project dashboard shows:
146 148
147 - - Active subscriber count
148 - - Subscription tiers and pricing
149 + - Active member count
150 + - Membership tiers and pricing
149 151
150 152 Broadcast emails to followers are available from the project dashboard.
151 153
@@ -46,16 +46,18 @@ Each project has its own analytics tab showing:
46 46
47 47 Time range selector: 7 days, 30 days, 90 days, all-time.
48 48
49 - ## Subscription Tiers
49 + ## Membership Tiers
50 50
51 - Create monthly subscription tiers per project:
51 + Create monthly membership tiers per project:
52 52
53 - 1. Go to project settings > Monetization > Subscriptions
53 + 1. Go to project dashboard > Membership Tiers tab
54 54 2. Add tiers with name, price, and description
55 55 3. Toggle tiers active/inactive without deleting them
56 56
57 57 Fans subscribe through the payment checkout. Billing, renewals, and cancellations are handled by the payment processor.
58 58
59 + Membership tiers are separate from [Fan+](./fan-plus.md), which is a platform-wide membership. A fan can be a member of your project *and* a Fan+ member — they serve different purposes.
60 +
59 61 ## Promo Codes
60 62
61 63 Create promotional codes scoped to a project:
@@ -26,9 +26,9 @@ For writers, bloggers, journalists, newsletter creators, and anyone whose primar
26 26 - Unlimited posts and articles
27 27 - Rich text editor with markdown support
28 28 - Code syntax highlighting
29 - - Free, paid, and subscriber-only posts
29 + - Free, paid, and member-only posts
30 30 - Pay-what-you-want pricing
31 - - Subscription tiers
31 + - Membership tiers
32 32 - RSS feed generation
33 33 - Contact sharing (opt-in at purchase)
34 34 - [Analytics](./analytics.md) (revenue charts, period comparisons, per-project breakdowns)
@@ -72,7 +72,7 @@ For game developers, educators, course creators, and anyone producing large cont
72 72
73 73 - Video uploads (MP4, WebM, MOV) up to 20GB per file
74 74 - In-browser video player with access control
75 - - Subscriber-only and pay-per-view videos
75 + - Member-only and pay-per-view videos
76 76 - Video series and playlists
77 77 - Large binary downloads up to 20GB per file
78 78 - Per-file size increase available on request for files over 20GB
@@ -127,7 +127,7 @@ The "~3%" shorthand is accurate at $25+ but understates the cost on small transa
127 127
128 128 ### Basic ($10/month tier)
129 129
130 - A newsletter writer with 150 fan subscribers at $5/month:
130 + A newsletter writer with 150 fan members at $5/month:
131 131
132 132 | | Amount |
133 133 |---|---:|
@@ -143,7 +143,7 @@ On a percentage-cut platform at 10%, you'd pay $75.00 in platform fees instead o
143 143
144 144 ### Small Files ($20/month tier)
145 145
146 - A musician selling albums at $10, averaging 80 sales/month, plus 50 fan subscribers at $7/month:
146 + A musician selling albums at $10, averaging 80 sales/month, plus 50 fan members at $7/month:
147 147
148 148 | | Amount |
149 149 |---|---:|
@@ -157,7 +157,7 @@ A musician selling albums at $10, averaging 80 sales/month, plus 50 fan subscrib
157 157
158 158 On a percentage-cut platform at 15%, you'd pay $172.50 in platform fees. At $1,150/month gross, MNW saves you $152.50/month.
159 159
160 - **Break-even:** 3 album sales at $10 or 4 fan subscribers at $7.
160 + **Break-even:** 3 album sales at $10 or 4 fan members at $7.
161 161
162 162 ### Big Files ($30/month tier)
163 163
@@ -219,7 +219,7 @@ Upgrades take effect immediately and are prorated. If you're at 240GB on Small F
219 219
220 220 ## Upgrading and Downgrading
221 221
222 - - **Upgrade** to a higher tier anytime. All existing content, subscribers, and settings carry over. You pay the new rate starting immediately (prorated for the current billing period).
222 + - **Upgrade** to a higher tier anytime. All existing content, members, and settings carry over. You pay the new rate starting immediately (prorated for the current billing period).
223 223 - **Downgrade** to a lower tier anytime. Existing files stay. New uploads are subject to the lower tier's limits. If you're over the new tier's storage cap, you can't upload new files until you're under the limit.
224 224 - **Missed payment**: If a payment fails, Stripe retries automatically (typically 3 attempts over ~3 weeks). During this period your existing content stays published, but new uploads are disabled. If all retries fail, the subscription cancels and the cancellation grace period begins.
225 225 - **Cancellation**: 30-day grace period applies. Uploads are disabled, but existing items remain accessible to past buyers. After 30 days, items become hidden. Resubscribe to restore everything.
@@ -45,7 +45,7 @@ impl TimeRange {
45 45 /// SAFETY: These values are interpolated into SQL via format!. They MUST be
46 46 /// compile-time constants with no user input. The exhaustive match ensures
47 47 /// new variants require explicit SQL strings.
48 - fn interval_sql(&self) -> Option<&'static str> {
48 + pub(crate) fn interval_sql(&self) -> Option<&'static str> {
49 49 match self {
50 50 Self::Days7 => Some("7 days"),
51 51 Self::Days30 => Some("30 days"),
@@ -57,7 +57,7 @@ impl TimeRange {
57 57 /// SQL date_trunc bucket size: day for short ranges, week for 90d, month for All.
58 58 ///
59 59 /// SAFETY: Interpolated into SQL via format!. Must be compile-time constants.
60 - fn bucket_sql(&self) -> &'static str {
60 + pub(crate) fn bucket_sql(&self) -> &'static str {
61 61 match self {
62 62 Self::Days7 | Self::Days30 => "day",
63 63 Self::Days90 => "week",
@@ -104,7 +104,7 @@ impl PeriodComparison {
104 104 }
105 105
106 106 /// Compute percentage change text. Returns None when previous is zero.
107 - fn pct_change(current: i64, previous: i64) -> Option<(String, bool)> {
107 + pub(crate) fn pct_change(current: i64, previous: i64) -> Option<(String, bool)> {
108 108 if previous == 0 {
109 109 return None;
110 110 }
@@ -119,7 +119,7 @@ fn pct_change(current: i64, previous: i64) -> Option<(String, bool)> {
119 119 }
120 120
121 121 /// Format a bucket timestamp into a human-readable label.
122 - fn format_bucket_label(dt: &DateTime<Utc>, range: &TimeRange) -> String {
122 + pub(crate) fn format_bucket_label(dt: &DateTime<Utc>, range: &TimeRange) -> String {
123 123 match range {
124 124 TimeRange::Days7 | TimeRange::Days30 => dt.format("%b %-d").to_string(),
125 125 TimeRange::Days90 => format!("Week {}", dt.iso_week().week()),
@@ -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 page_views;
66 67
67 68 pub use id_types::*;
68 69 pub use validated_types::*;
@@ -0,0 +1,295 @@
1 + //! Page view tracking with daily aggregation.
2 + //!
3 + //! Each page view UPSERTs into `page_view_daily`, incrementing a counter per
4 + //! (target_type, target_id, date). No raw per-request rows — the table stays
5 + //! small (365 rows/item/year).
6 +
7 + use chrono::{DateTime, Utc};
8 + use sqlx::PgPool;
9 + use uuid::Uuid;
10 +
11 + use super::analytics::{format_bucket_label, TimeRange};
12 + use super::{ProjectId, UserId};
13 + use crate::error::Result;
14 +
15 + /// Record a single page view (UPSERT into daily aggregate).
16 + ///
17 + /// Called fire-and-forget from public content handlers via `tokio::spawn`.
18 + pub async fn record_view(pool: &PgPool, target_type: &str, target_id: Uuid) -> Result<()> {
19 + sqlx::query(
20 + r#"
21 + INSERT INTO page_view_daily (target_type, target_id, view_date, view_count)
22 + VALUES ($1, $2, CURRENT_DATE, 1)
23 + ON CONFLICT (target_type, target_id, view_date)
24 + DO UPDATE SET view_count = page_view_daily.view_count + 1
25 + "#,
26 + )
27 + .bind(target_type)
28 + .bind(target_id)
29 + .execute(pool)
30 + .await?;
31 + Ok(())
32 + }
33 +
34 + /// A single time bucket in a view timeseries.
35 + #[allow(dead_code)]
36 + pub struct ViewBucket {
37 + pub label: String,
38 + pub view_count: i64,
39 + }
40 +
41 + /// Fetch time-bucketed view counts for a seller (across all their items and projects).
42 + ///
43 + /// Optionally scoped to a single project. Uses the same bucketing as revenue charts.
44 + #[allow(dead_code)]
45 + pub async fn get_view_timeseries(
46 + pool: &PgPool,
47 + seller_id: UserId,
48 + project_id: Option<ProjectId>,
49 + range: &TimeRange,
50 + ) -> Result<Vec<ViewBucket>> {
51 + let bucket = range.bucket_sql();
52 + let time_filter = match range.interval_sql() {
53 + Some(interval) => format!(" AND pv.view_date >= (CURRENT_DATE - INTERVAL '{interval}')"),
54 + None => String::new(),
55 + };
56 +
57 + let project_filter = if project_id.is_some() {
58 + " AND i.project_id = $2"
59 + } else {
60 + ""
61 + };
62 +
63 + let sql = format!(
64 + r#"
65 + SELECT
66 + date_trunc('{bucket}', pv.view_date::TIMESTAMPTZ) AS bucket,
67 + COALESCE(SUM(pv.view_count), 0)::BIGINT
68 + FROM page_view_daily pv
69 + JOIN items i ON pv.target_type = 'item' AND pv.target_id = i.id
70 + JOIN projects p ON i.project_id = p.id
71 + WHERE p.user_id = $1{project_filter}{time_filter}
72 + GROUP BY bucket
73 + ORDER BY bucket
74 + LIMIT 500
75 + "#
76 + );
77 +
78 + let rows: Vec<(DateTime<Utc>, i64)> = if let Some(pid) = project_id {
79 + sqlx::query_as(&sql)
80 + .bind(seller_id)
81 + .bind(pid)
82 + .fetch_all(pool)
83 + .await?
84 + } else {
85 + sqlx::query_as(&sql)
86 + .bind(seller_id)
87 + .fetch_all(pool)
88 + .await?
89 + };
90 +
91 + let buckets = rows
92 + .into_iter()
93 + .map(|(dt, count)| ViewBucket {
94 + label: format_bucket_label(&dt, range),
95 + view_count: count,
96 + })
97 + .collect();
98 +
99 + Ok(buckets)
100 + }
101 +
102 + /// Period-over-period view comparison for stat cards.
103 + ///
104 + /// Returns `(current_views, previous_views)`.
105 + pub async fn get_view_period_comparison(
106 + pool: &PgPool,
107 + seller_id: UserId,
108 + project_id: Option<ProjectId>,
109 + range: &TimeRange,
110 + ) -> Result<(i64, i64)> {
111 + let Some(interval) = range.interval_sql() else {
112 + // All time: no comparison possible — return total views with 0 previous.
113 + let total = get_total_views(pool, seller_id, project_id, None).await?;
114 + return Ok((total, 0));
115 + };
116 +
117 + let project_filter = if project_id.is_some() {
118 + " AND i.project_id = $2"
119 + } else {
120 + ""
121 + };
122 +
123 + let sql = format!(
124 + r#"
125 + SELECT
126 + COALESCE(SUM(pv.view_count) FILTER (
127 + WHERE pv.view_date >= CURRENT_DATE - INTERVAL '{interval}'
128 + ), 0)::BIGINT,
129 + COALESCE(SUM(pv.view_count) FILTER (
130 + WHERE pv.view_date >= CURRENT_DATE - INTERVAL '{interval}' * 2
131 + AND pv.view_date < CURRENT_DATE - INTERVAL '{interval}'
132 + ), 0)::BIGINT
133 + FROM page_view_daily pv
134 + JOIN items i ON pv.target_type = 'item' AND pv.target_id = i.id
135 + JOIN projects p ON i.project_id = p.id
136 + WHERE p.user_id = $1{project_filter}
137 + AND pv.view_date >= CURRENT_DATE - INTERVAL '{interval}' * 2
138 + "#
139 + );
140 +
141 + let row: (i64, i64) = if let Some(pid) = project_id {
142 + sqlx::query_as(&sql)
143 + .bind(seller_id)
144 + .bind(pid)
145 + .fetch_one(pool)
146 + .await?
147 + } else {
148 + sqlx::query_as(&sql)
149 + .bind(seller_id)
150 + .fetch_one(pool)
151 + .await?
152 + };
153 +
154 + Ok(row)
155 + }
156 +
157 + /// Total views for a seller, optionally scoped to a project and time range.
158 + async fn get_total_views(
159 + pool: &PgPool,
160 + seller_id: UserId,
161 + project_id: Option<ProjectId>,
162 + since: Option<DateTime<Utc>>,
163 + ) -> Result<i64> {
164 + let time_filter = if since.is_some() {
165 + " AND pv.view_date >= $2::DATE"
166 + } else {
167 + ""
168 + };
169 + let project_filter = if project_id.is_some() {
170 + if since.is_some() {
171 + " AND i.project_id = $3"
172 + } else {
173 + " AND i.project_id = $2"
174 + }
175 + } else {
176 + ""
177 + };
178 +
179 + let sql = format!(
180 + r#"
181 + SELECT COALESCE(SUM(pv.view_count), 0)::BIGINT
182 + FROM page_view_daily pv
183 + JOIN items i ON pv.target_type = 'item' AND pv.target_id = i.id
184 + JOIN projects p ON i.project_id = p.id
185 + WHERE p.user_id = $1{time_filter}{project_filter}
186 + "#
187 + );
188 +
189 + let row: (i64,) = match (since, project_id) {
190 + (Some(s), Some(pid)) => {
191 + sqlx::query_as(&sql)
192 + .bind(seller_id)
193 + .bind(s)
194 + .bind(pid)
195 + .fetch_one(pool)
196 + .await?
197 + }
198 + (Some(s), None) => {
199 + sqlx::query_as(&sql)
200 + .bind(seller_id)
201 + .bind(s)
202 + .fetch_one(pool)
203 + .await?
204 + }
205 + (None, Some(pid)) => {
206 + sqlx::query_as(&sql)
207 + .bind(seller_id)
208 + .bind(pid)
209 + .fetch_one(pool)
210 + .await?
211 + }
212 + (None, None) => {
213 + sqlx::query_as(&sql)
214 + .bind(seller_id)
215 + .fetch_one(pool)
216 + .await?
217 + }
218 + };
219 +
220 + Ok(row.0)
221 + }
222 +
223 + /// Per-project view totals for a seller. Used for the cross-project comparison table.
224 + pub async fn get_views_by_seller_projects(
225 + pool: &PgPool,
226 + seller_id: UserId,
227 + range: &TimeRange,
228 + ) -> Result<Vec<(ProjectId, i64)>> {
229 + let time_filter = match range.interval_sql() {
230 + Some(interval) => format!(" AND pv.view_date >= (CURRENT_DATE - INTERVAL '{interval}')"),
231 + None => String::new(),
232 + };
233 +
234 + let sql = format!(
235 + r#"
236 + SELECT p.id, COALESCE(SUM(pv.view_count), 0)::BIGINT
237 + FROM projects p
238 + LEFT JOIN items i ON i.project_id = p.id
239 + LEFT JOIN page_view_daily pv
240 + ON pv.target_type = 'item' AND pv.target_id = i.id{time_filter}
241 + WHERE p.user_id = $1
242 + GROUP BY p.id
243 + "#
244 + );
245 +
246 + let rows: Vec<(ProjectId, i64)> = sqlx::query_as(&sql)
247 + .bind(seller_id)
248 + .fetch_all(pool)
249 + .await?;
250 +
251 + Ok(rows)
252 + }
253 +
254 + /// Per-item view totals for a project. Used for the project analytics "top items" list.
255 + #[allow(dead_code)]
256 + pub async fn get_views_by_project_items(
257 + pool: &PgPool,
258 + project_id: ProjectId,
259 + range: &TimeRange,
260 + ) -> Result<Vec<(super::ItemId, i64)>> {
261 + let time_filter = match range.interval_sql() {
262 + Some(interval) => format!(" AND pv.view_date >= (CURRENT_DATE - INTERVAL '{interval}')"),
263 + None => String::new(),
264 + };
265 +
266 + let sql = format!(
267 + r#"
268 + SELECT i.id, COALESCE(SUM(pv.view_count), 0)::BIGINT
269 + FROM items i
270 + LEFT JOIN page_view_daily pv
271 + ON pv.target_type = 'item' AND pv.target_id = i.id{time_filter}
272 + WHERE i.project_id = $1
273 + GROUP BY i.id
274 + "#
275 + );
276 +
277 + let rows: Vec<(super::ItemId, i64)> = sqlx::query_as(&sql)
278 + .bind(project_id)
279 + .fetch_all(pool)
280 + .await?;
281 +
282 + Ok(rows)
283 + }
284 +
285 + /// Delete page view rows older than `retain_days`. Called by the daily scheduler.
286 + pub async fn prune_old_views(pool: &PgPool, retain_days: i64) -> Result<u64> {
287 + let result = sqlx::query(
288 + "DELETE FROM page_view_daily WHERE view_date < CURRENT_DATE - $1 * INTERVAL '1 day'",
289 + )
290 + .bind(retain_days)
291 + .execute(pool)
292 + .await?;
293 +
294 + Ok(result.rows_affected())
295 + }
@@ -516,6 +516,44 @@ pub async fn get_revenue_by_user_projects(
516 516 Ok(rows)
517 517 }
518 518
519 + /// Revenue and sales per project for a seller within a time range.
520 + ///
521 + /// Used for the cross-project comparison table on the user analytics tab.
522 + #[tracing::instrument(skip_all)]
523 + pub async fn get_revenue_by_user_projects_in_range(
524 + pool: &PgPool,
525 + user_id: UserId,
526 + range: &super::analytics::TimeRange,
527 + ) -> Result<Vec<(ProjectId, String, i64, i64)>> {
528 + let time_filter = match range.interval_sql() {
529 + Some(interval) => format!(
530 + " AND t.completed_at >= NOW() - INTERVAL '{interval}'"
531 + ),
532 + None => String::new(),
533 + };
534 +
535 + let sql = format!(
536 + r#"
537 + SELECT p.id, p.title,
538 + COALESCE(SUM(t.amount_cents), 0)::BIGINT,
539 + COUNT(t.id)::BIGINT
540 + FROM projects p
541 + LEFT JOIN items i ON i.project_id = p.id
542 + LEFT JOIN transactions t ON t.item_id = i.id AND t.status = 'completed'{time_filter}
543 + WHERE p.user_id = $1
544 + GROUP BY p.id, p.title
545 + ORDER BY COALESCE(SUM(t.amount_cents), 0) DESC
546 + "#
547 + );
548 +
549 + let rows: Vec<(ProjectId, String, i64, i64)> = sqlx::query_as(&sql)
550 + .bind(user_id)
551 + .fetch_all(pool)
552 + .await?;
553 +
554 + Ok(rows)
555 + }
556 +
519 557 /// Remove a free item from library (deletes the claim transaction)
520 558 #[tracing::instrument(skip_all)]
521 559 pub async fn remove_free_item_from_library(
@@ -727,6 +727,42 @@ fn extension_from_key(key: &str) -> &str {
727 727 key.rsplit('.').next().unwrap_or("bin")
728 728 }
729 729
730 + /// Export buyer contacts (who opted to share their email) as CSV.
731 + #[tracing::instrument(skip_all, name = "exports::export_contacts")]
732 + pub(super) async fn export_contacts(
733 + State(state): State<AppState>,
734 + headers: HeaderMap,
735 + AuthUser(user): AuthUser,
736 + ) -> Result<Response> {
737 + let is_htmx = is_htmx_request(&headers);
738 + let contacts = db::transactions::get_seller_contacts(&state.db, user.id).await?;
739 +
740 + let mut csv_content = String::from("Username,Email,Purchases,Total Spent,Last Purchase\n");
741 + for c in &contacts {
742 + csv_content.push_str(&format!(
743 + "{},{},{},{:.2},{}\n",
744 + sanitize_csv_cell(&c.username),
745 + sanitize_csv_cell(&c.email),
746 + c.total_purchases,
747 + c.total_spent_cents as f64 / 100.0,
748 + c.last_purchase_at.format("%Y-%m-%d"),
749 + ));
750 + }
751 +
752 + if is_htmx {
753 + let data_uri = format!(
754 + "data:text/csv;charset=utf-8,{}",
755 + urlencoding::encode(&csv_content)
756 + );
757 + return Ok(ExportDownloadTemplate {
758 + data_uri,
759 + filename: "makenot-work-contacts.csv".to_string(),
760 + }.into_response());
761 + }
762 +
763 + download_response(csv_content.into_bytes(), "makenot-work-contacts.csv", "text/csv")
764 + }
765 +
730 766 /// Sanitize a title for use as a filename in the ZIP archive.
731 767 fn sanitize_filename(name: &str) -> String {
732 768 name.chars()
@@ -352,6 +352,7 @@ pub fn api_routes() -> Router<AppState> {
352 352 .route("/api/export/followers", post(exports::export_followers))
353 353 .route("/api/export/subscriptions", post(exports::export_subscriptions))
354 354 .route("/api/export/content", post(exports::export_content))
355 + .route("/api/export/contacts", post(exports::export_contacts))
355 356 .route_layer(GovernorLayer {
356 357 config: export_rate_limit,
357 358 });
@@ -58,6 +58,7 @@ pub fn dashboard_routes() -> Router<AppState> {
58 58 .route("/dashboard/tabs/media", get(tabs::dashboard_tab_media))
59 59 .route("/dashboard/tabs/ssh-keys", get(tabs::dashboard_tab_ssh_keys))
60 60 .route("/dashboard/tabs/support", get(tabs::dashboard_tab_support))
61 + .route("/dashboard/tabs/contacts", get(tabs::dashboard_tab_contacts))
61 62 .route("/dashboard/transactions", get(tabs::dashboard_transactions))
62 63 .route("/dashboard/project/{slug}/tabs/overview", get(project_tabs::project_tab_overview))
63 64 .route("/dashboard/project/{slug}/tabs/content", get(project_tabs::project_tab_content))
@@ -211,7 +211,23 @@ pub(super) async fn project_tab_analytics(
211 211 comparison.current_revenue_cents % 100
212 212 );
213 213
214 - let stats = vec![
214 + // Page view stats for this project
215 + let (current_views, prev_views) = db::page_views::get_view_period_comparison(
216 + &state.db,
217 + session_user.id,
218 + Some(db_project.id),
219 + &range,
220 + )
221 + .await?;
222 + let view_change = db::analytics::pct_change(current_views, prev_views);
223 +
224 + let mut stats = vec![
225 + StatCard {
226 + label: "Views".to_string(),
227 + value: current_views.to_string(),
228 + change: view_change.as_ref().map(|(t, _)| t.clone()),
229 + is_positive: view_change.map(|(_, p)| p).unwrap_or(true),
230 + },
215 231 StatCard {
216 232 label: "Revenue".to_string(),
217 233 value: revenue_str,
@@ -232,6 +248,19 @@ pub(super) async fn project_tab_analytics(
232 248 },
233 249 ];
234 250
251 + if current_views > 0 {
252 + let conversion = format!(
253 + "{:.1}%",
254 + comparison.current_sales as f64 / current_views as f64 * 100.0
255 + );
256 + stats.push(StatCard {
257 + label: "Conversion".to_string(),
258 + value: conversion,
259 + change: None,
260 + is_positive: true,
261 + });
262 + }
263 +
235 264 let db_items = db::items::get_items_by_project(&state.db, db_project.id).await?;
236 265 let items: Vec<ContentItem> = db_items
237 266 .iter()
@@ -8,9 +8,9 @@ pub(super) use item::{
8 8 item_tab_sales, item_tab_settings,
9 9 };
10 10 pub(super) use user::{
11 - dashboard_tab_account, dashboard_tab_analytics, dashboard_tab_creator,
12 - dashboard_tab_details, dashboard_tab_forums, dashboard_tab_media,
13 - dashboard_tab_payments, dashboard_tab_profile, dashboard_tab_projects,
14 - dashboard_tab_ssh_keys, dashboard_tab_support, dashboard_tab_synckit,
15 - dashboard_transactions,
11 + dashboard_tab_account, dashboard_tab_analytics, dashboard_tab_contacts,
12 + dashboard_tab_creator, dashboard_tab_details, dashboard_tab_forums,
13 + dashboard_tab_media, dashboard_tab_payments, dashboard_tab_profile,
14 + dashboard_tab_projects, dashboard_tab_ssh_keys, dashboard_tab_support,
15 + dashboard_tab_synckit, dashboard_transactions,
16 16 };
@@ -431,7 +431,25 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_analytics(
431 431 comparison.current_revenue_cents % 100
432 432 );
433 433
434 - let stats = vec![
434 + // Page view stats
435 + let (current_views, prev_views) =
436 + db::page_views::get_view_period_comparison(&state.db, session_user.id, None, &range)
437 + .await?;
438 + let view_change = db::analytics::pct_change(current_views, prev_views);
439 +
440 + let conversion_rate = if current_views > 0 {
441 + format!("{:.1}%", comparison.current_sales as f64 / current_views as f64 * 100.0)
442 + } else {
443 + "-".to_string()
444 + };
445 +
446 + let mut stats = vec![
447 + StatCard {
448 + label: "Views".to_string(),
449 + value: current_views.to_string(),
450 + change: view_change.as_ref().map(|(t, _)| t.clone()),
451 + is_positive: view_change.map(|(_, p)| p).unwrap_or(true),
452 + },
435 453 StatCard {
436 454 label: "Revenue".to_string(),
437 455 value: revenue_str,
@@ -458,7 +476,49 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_analytics(
458 476 },
459 477 ];
460 478
461 - // Build per-project revenue breakdown (single query, no N+1)
479 + if current_views > 0 {
480 + stats.push(StatCard {
481 + label: "Conversion".to_string(),
482 + value: conversion_rate,
483 + change: None,
484 + is_positive: true,
485 + });
486 + }
487 +
488 + // Build per-project revenue + views breakdown for comparison table
489 + let project_data =
490 + db::transactions::get_revenue_by_user_projects_in_range(&state.db, session_user.id, &range)
491 + .await?;
492 + let project_views =
493 + db::page_views::get_views_by_seller_projects(&state.db, session_user.id, &range).await?;
494 +
495 + // Merge revenue + views by project ID
496 + let max_rev = project_data.iter().map(|(_, _, r, _)| *r).max().unwrap_or(1).max(1);
497 + let project_comparisons: Vec<ProjectComparison> = project_data
498 + .iter()
499 + .map(|(pid, title, rev_cents, sales)| {
500 + let views = project_views
501 + .iter()
502 + .find(|(id, _)| id == pid)
503 + .map(|(_, v)| *v)
504 + .unwrap_or(0);
505 + let conv = if views > 0 {
506 + format!("{:.1}%", *sales as f64 / views as f64 * 100.0)
507 + } else {
508 + "-".to_string()
509 + };
510 + ProjectComparison {
511 + title: title.clone(),
512 + revenue: format!("${}.{:02}", rev_cents / 100, rev_cents.unsigned_abs() % 100),
513 + revenue_pct: *rev_cents as f64 / max_rev as f64 * 100.0,
514 + sales: *sales,
515 + views,
516 + conversion: conv,
517 + }
518 + })
519 + .collect();
520 +
521 + // Top projects list (all-time, for the simple list below the chart)
462 522 let project_revenues =
463 523 db::transactions::get_revenue_by_user_projects(&state.db, session_user.id).await?;
464 524 let top_projects: Vec<ProjectRevenue> = project_revenues
@@ -474,9 +534,30 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_analytics(
474 534 bars,
475 535 top_projects,
476 536 active_range: range.to_string(),
537 + project_comparisons,
477 538 })
478 539 }
479 540
541 + /// Render the HTMX partial for the buyer contacts section (lazy-loaded in Payments tab).
542 + #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_contacts")]
543 + pub(in crate::routes::pages::dashboard) async fn dashboard_tab_contacts(
544 + State(state): State<AppState>,
545 + AuthUser(session_user): AuthUser,
546 + ) -> Result<impl IntoResponse> {
547 + let contacts = db::transactions::get_seller_contacts(&state.db, session_user.id).await?;
548 + let contact_views: Vec<BuyerContact> = contacts
549 + .into_iter()
550 + .map(|c| BuyerContact {
551 + username: c.username,
552 + email: c.email,
553 + total_purchases: c.total_purchases,
554 + total_spent: format!("${}.{:02}", c.total_spent_cents / 100, c.total_spent_cents.unsigned_abs() % 100),
555 + last_purchase: c.last_purchase_at.format("%b %-d, %Y").to_string(),
556 + })
557 + .collect();
558 + Ok(BuyerContactsPartialTemplate { contacts: contact_views })
559 + }
560 +
480 561 /// Render the HTMX partial for the filtered transactions table.
481 562 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_transactions")]
482 563 pub(in crate::routes::pages::dashboard) async fn dashboard_transactions(