max / makenotwork
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( |