Skip to main content

max / makenotwork

v0.6.2: fix paywall template, add monetization settings UI, paywall logging - project_paywall.html: include site header, replace mismatched CSS class names with inline rules matching the template, fix /u/{user} link path. - project_settings.html: add Monetization form-section so creators can change pricing_model/price/pwyw_min post-creation; previously only settable during project create wizard. - api/projects.rs: extend UpdateProjectRequest with pricing_model, price_dollars, pwyw_min_dollars; validates and persists via existing db::projects::update_project_pricing. - public/content/project.rs: tracing::warn! at paywall gate logging viewer/creator IDs and access context, to diagnose unexpected gating.
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-17 17:43 UTC
Commit: c67a44ef85386fba38573b1e2eca1954a1f255c3
Parent: 4e4896c
8 files changed, +261 insertions, -59 deletions
@@ -3551,7 +3551,7 @@ dependencies = [
3551 3551
3552 3552 [[package]]
3553 3553 name = "makenotwork"
3554 - version = "0.6.1"
3554 + version = "0.6.2"
3555 3555 dependencies = [
3556 3556 "anyhow",
3557 3557 "argon2",
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.6.1"
3 + version = "0.6.2"
4 4 edition = "2024"
5 5 license-file = "LICENSE"
6 6
@@ -200,6 +200,12 @@ pub struct UpdateProjectRequest {
200 200 pub features: Option<Vec<String>>,
201 201 pub is_public: Option<bool>,
202 202 pub category: Option<String>,
203 + /// Pricing model as kebab string: "free" | "buy_once" | "pwyw" | "subscription".
204 + pub pricing_model: Option<String>,
205 + /// Buy-once price in dollars. Required when pricing_model="buy_once".
206 + pub price_dollars: Option<f64>,
207 + /// PWYW minimum in dollars. Optional when pricing_model="pwyw".
208 + pub pwyw_min_dollars: Option<f64>,
203 209 }
204 210
205 211 /// Update an existing project owned by the authenticated user.
@@ -252,6 +258,48 @@ pub(super) async fn update_project(
252 258 )
253 259 .await?;
254 260
261 + if let Some(ref model_str) = req.pricing_model {
262 + let kind: db::PricingKind = model_str
263 + .parse()
264 + .map_err(|_| AppError::Validation(format!("Invalid pricing_model: {model_str}")))?;
265 +
266 + let price_cents = if kind == db::PricingKind::BuyOnce {
267 + let dollars = req.price_dollars.ok_or_else(|| {
268 + AppError::Validation("price_dollars required for buy_once".into())
269 + })?;
270 + if dollars < 0.50 {
271 + return Err(AppError::Validation(
272 + "price_dollars must be at least 0.50".into(),
273 + ));
274 + }
275 + (dollars * 100.0).round() as i32
276 + } else {
277 + 0
278 + };
279 +
280 + let pwyw_min_cents = if kind == db::PricingKind::Pwyw {
281 + let dollars = req.pwyw_min_dollars.unwrap_or(0.0);
282 + if dollars < 0.0 {
283 + return Err(AppError::Validation(
284 + "pwyw_min_dollars must be non-negative".into(),
285 + ));
286 + }
287 + Some((dollars * 100.0).round() as i32)
288 + } else {
289 + None
290 + };
291 +
292 + db::projects::update_project_pricing(
293 + &state.db,
294 + id,
295 + user.id,
296 + kind,
297 + price_cents,
298 + pwyw_min_cents,
299 + )
300 + .await?;
301 + }
302 +
255 303 db::projects::bump_cache_generation(&state.db, id).await?;
256 304
257 305 Ok(Json(ProjectResponse {
@@ -336,7 +336,21 @@ pub(super) async fn project_tab_settings(
336 336 let project_features = db::ProjectFeature::all();
337 337 let sections = db::project_sections::list_by_project(&state.db, db_project.id).await?;
338 338
339 - Ok(helpers::with_etag(generation, ProjectSettingsTabTemplate { project, category_name, project_id, features, project_features, sections }))
339 + let pricing_model = db_project.pricing_model.to_string();
340 + let price_dollars = if db_project.price_cents > 0 {
341 + format!("{:.2}", db_project.price_cents as f64 / 100.0)
342 + } else {
343 + String::new()
344 + };
345 + let pwyw_min_dollars = match db_project.pwyw_min_cents {
346 + Some(c) if c > 0 => format!("{:.2}", c as f64 / 100.0),
347 + _ => String::new(),
348 + };
349 +
350 + Ok(helpers::with_etag(generation, ProjectSettingsTabTemplate {
351 + project, category_name, project_id, features, project_features, sections,
352 + pricing_model, price_dollars, pwyw_min_dollars,
353 + }))
340 354 }
341 355
342 356 /// Render the HTMX partial for the project subscriptions tab (tier management).
@@ -63,6 +63,17 @@ pub(crate) async fn render_project_page(
63 63 )
64 64 .await?;
65 65 if !project_pricing.can_access(&project_ctx) {
66 + tracing::warn!(
67 + project_id = %db_project.id,
68 + project_slug = %db_project.slug,
69 + creator_user_id = %db_project.user_id,
70 + viewer_user_id = ?maybe_user.as_ref().map(|u| u.id),
71 + is_creator = project_ctx.is_creator,
72 + has_purchased = project_ctx.has_purchased,
73 + has_active_subscription = project_ctx.has_active_subscription,
74 + pricing_kind = ?project_pricing.kind(),
75 + "project paywall gate: showing paywall"
76 + );
66 77 let db_tiers =
67 78 db::subscriptions::get_active_tiers_by_project(&state.db, db_project.id).await?;
68 79 let subscription_tiers: Vec<SubscriptionTier> =
@@ -383,6 +383,12 @@ pub struct ProjectSettingsTabTemplate {
383 383 pub project_features: &'static [(&'static str, &'static str, &'static str)],
384 384 /// Tabbed markdown sections (privacy policy, terms, FAQ, etc).
385 385 pub sections: Vec<crate::db::DbProjectSection>,
386 + /// Current pricing model as kebab string ("free", "buy_once", "pwyw", "subscription").
387 + pub pricing_model: String,
388 + /// Current buy-once price in dollars (formatted), empty if not set.
389 + pub price_dollars: String,
390 + /// Current PWYW minimum in dollars (formatted), empty if not set.
391 + pub pwyw_min_dollars: String,
386 392 }
387 393
388 394 /// Dashboard code tab partial (git repos management).
@@ -8,75 +8,99 @@
8 8 <meta property="og:description" content="{{ project.description }}">
9 9 <meta property="og:type" content="website">
10 10 <meta property="og:url" content="{{ host_url }}/p/{{ project.slug }}">
11 + <link rel="canonical" href="{{ host_url }}/p/{{ project.slug }}">
12 + {% if let Some(img) = project.cover_image_url %}
13 + <meta property="og:image" content="{{ img }}">
14 + <meta name="twitter:card" content="summary_large_image">
15 + <meta name="twitter:image" content="{{ img }}">
16 + {% else %}
17 + <meta name="twitter:card" content="summary">
18 + {% endif %}
19 + <style>
20 + .paywall-cover { margin-bottom: 1.5rem; }
21 + .paywall-cover img { width: 120px; height: 120px; border-radius: 8px; object-fit: cover; }
22 + .paywall-title { font-family: var(--font-heading); font-size: 3rem; margin-bottom: 0.5rem; }
23 + .paywall-creator { font-size: 0.95rem; opacity: 0.7; margin-bottom: 1.5rem; }
24 + .paywall-creator a { color: var(--detail); }
25 + .paywall-description { font-size: 1.1rem; max-width: 800px; margin: 0 auto 2rem; text-align: left; }
26 + .paywall-tiers { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.5rem; margin-top: 1.5rem; text-align: left; }
27 + .paywall-tiers .tier-card { background: var(--light-background); padding: 2rem; display: flex; flex-direction: column; }
28 + .paywall-tiers .tier-card h3 { font-family: var(--font-heading); font-weight: bold; font-size: 1.3rem; margin-bottom: 0.5rem; }
29 + .paywall-tiers .tier-price { font-size: 1.5rem; margin-bottom: 1rem; }
30 + .paywall-tiers .tier-card form, .paywall-tiers .tier-card .button { margin-top: auto; }
31 + .paywall-tiers .tier-card button, .paywall-tiers .tier-card .button { width: 100%; }
32 + </style>
11 33 {% endblock %}
12 34
13 35 {% block content %}
14 - <div class="content-container narrow">
15 - <div class="project-paywall">
16 - {% if let Some(cover) = project.cover_image_url %}
17 - <div class="paywall-cover">
18 - <img src="{{ cover }}" alt="{{ project.title }}">
19 - </div>
20 - {% endif %}
36 + {% include "partials/site_header.html" %}
21 37
22 - <h1>{{ project.title }}</h1>
23 - <p class="paywall-creator">by <a href="/{{ creator_username }}">{{ creator_username }}</a></p>
24 -
25 - {% if !project.description.is_empty() %}
26 - <p class="paywall-description">{{ project.description }}</p>
27 - {% endif %}
38 + <div class="container">
39 + <section class="paywall-section">
40 + <div class="paywall-box">
41 + {% if let Some(cover) = project.cover_image_url %}
42 + <div class="paywall-cover">
43 + <img src="{{ cover }}" alt="{{ project.title }}">
44 + </div>
45 + {% endif %}
28 46
29 - <div class="paywall-pricing">
30 - <span class="paywall-price">{{ price_display }}</span>
31 - </div>
47 + <h1 class="paywall-title">{{ project.title }}<span class="dot">.</span></h1>
48 + <p class="paywall-creator">by <a href="/u/{{ creator_username }}">{{ creator_username }}</a></p>
32 49
33 - {% match checkout_type %}
34 - {% when crate::pricing::CheckoutType::OneTime %}
35 - {% if session_user.is_some() %}
36 - <form method="POST" action="/stripe/checkout/project/{{ project.id }}">
37 - <button type="submit" class="primary">Purchase Access</button>
38 - </form>
39 - {% else %}
40 - <a href="/login" class="button primary">Log in to Purchase</a>
41 - {% endif %}
50 + {% if !project.description.is_empty() %}
51 + <p class="paywall-description">{{ project.description }}</p>
52 + {% endif %}
42 53
43 - {% when crate::pricing::CheckoutType::PayWhatYouWant %}
44 - {% if session_user.is_some() %}
45 - <form method="POST" action="/stripe/checkout/project/{{ project.id }}">
46 - <div class="form-group">
47 - <label for="amount">Your price ($)</label>
48 - <input type="number" name="amount_cents" id="amount" min="0" step="1" placeholder="0">
49 - </div>
50 - <button type="submit" class="primary">Purchase Access</button>
51 - </form>
52 - {% else %}
53 - <a href="/login" class="button primary">Log in to Purchase</a>
54 - {% endif %}
54 + <div class="paywall-price">{{ price_display }}</div>
55 55
56 - {% when crate::pricing::CheckoutType::Subscription %}
57 - {% if !subscription_tiers.is_empty() %}
58 - <div class="paywall-tiers">
59 - {% for tier in subscription_tiers %}
60 - <div class="tier-card">
61 - <h3>{{ tier.name }}</h3>
62 - <span class="tier-price">{{ tier.price }}/mo</span>
63 - {% if !tier.description.is_empty() %}
64 - <p>{{ tier.description }}</p>
56 + {% match checkout_type %}
57 + {% when crate::pricing::CheckoutType::OneTime %}
58 + {% if session_user.is_some() %}
59 + <form method="POST" action="/stripe/checkout/project/{{ project.id }}">
60 + <button type="submit" class="primary paywall-btn">Purchase Access</button>
61 + </form>
62 + {% else %}
63 + <a href="/login" class="primary paywall-btn">Log in to Purchase</a>
65 64 {% endif %}
65 +
66 + {% when crate::pricing::CheckoutType::PayWhatYouWant %}
66 67 {% if session_user.is_some() %}
67 - <form method="POST" action="/stripe/subscribe/{{ tier.id }}">
68 - <button type="submit" class="primary">Subscribe</button>
68 + <form method="POST" action="/stripe/checkout/project/{{ project.id }}">
69 + <div class="form-group" style="max-width: 240px; margin: 1rem auto;">
70 + <label for="amount">Your price ($)</label>
71 + <input type="number" name="amount_cents" id="amount" min="0" step="1" placeholder="0">
72 + </div>
73 + <button type="submit" class="primary paywall-btn">Purchase Access</button>
69 74 </form>
70 75 {% else %}
71 - <a href="/login" class="button primary">Log in to Subscribe</a>
76 + <a href="/login" class="primary paywall-btn">Log in to Purchase</a>
72 77 {% endif %}
73 - </div>
74 - {% endfor %}
75 - </div>
76 - {% endif %}
77 78
78 - {% when crate::pricing::CheckoutType::None %}
79 - {% endmatch %}
79 + {% when crate::pricing::CheckoutType::Subscription %}
80 + {% if !subscription_tiers.is_empty() %}
81 + <div class="paywall-tiers">
82 + {% for tier in subscription_tiers %}
83 + <div class="tier-card">
84 + <h3>{{ tier.name }}</h3>
85 + <div class="tier-price">{{ tier.price }}/mo</div>
86 + {% if !tier.description.is_empty() %}
87 + <p class="paywall-desc">{{ tier.description }}</p>
88 + {% endif %}
89 + {% if session_user.is_some() %}
90 + <form method="POST" action="/stripe/subscribe/{{ tier.id }}">
91 + <button type="submit" class="primary">Subscribe</button>
92 + </form>
93 + {% else %}
94 + <a href="/login" class="primary button">Log in to Subscribe</a>
95 + {% endif %}
96 + </div>
97 + {% endfor %}
98 + </div>
99 + {% endif %}
100 +
101 + {% when crate::pricing::CheckoutType::None %}
102 + {% endmatch %}
103 + </div>
104 + </section>
80 105 </div>
81 - </div>
82 106 {% endblock %}
@@ -98,6 +98,51 @@
98 98 </div>
99 99
100 100 <div class="form-section">
101 + <h2>Monetization</h2>
102 + <p style="margin-bottom: 1rem; opacity: 0.7; text-align: left;">
103 + How visitors access this project. 0% platform fee — only ~3% payment processing.
104 + Changing models is safe but does not refund or cancel existing purchases or subscriptions.
105 + </p>
106 +
107 + <form id="project-pricing-form" onsubmit="return saveProjectPricing(event, '{{ project.id }}')">
108 + <div class="form-group">
109 + <label for="settings-pricing-model">Pricing model</label>
110 + <select id="settings-pricing-model" name="pricing_model" onchange="updateSettingsPricingUI()">
111 + <option value="free"{% if pricing_model == "free" %} selected{% endif %}>Free — anyone can access</option>
112 + <option value="buy_once"{% if pricing_model == "buy_once" %} selected{% endif %}>One-time purchase</option>
113 + <option value="pwyw"{% if pricing_model == "pwyw" %} selected{% endif %}>Pay what you want</option>
114 + <option value="subscription"{% if pricing_model == "subscription" %} selected{% endif %}>Membership — recurring monthly</option>
115 + </select>
116 + </div>
117 +
118 + <div id="settings-buy-once-fields" style="display: none;">
119 + <div class="form-group">
120 + <label for="settings-price-dollars">Price ($)</label>
121 + <input type="number" id="settings-price-dollars" name="price_dollars" min="0.50" step="0.01"
122 + placeholder="9.99" value="{{ price_dollars }}">
123 + </div>
124 + </div>
125 +
126 + <div id="settings-pwyw-fields" style="display: none;">
127 + <div class="form-group">
128 + <label for="settings-pwyw-min-dollars">Minimum price ($, 0 for no minimum)</label>
129 + <input type="number" id="settings-pwyw-min-dollars" name="pwyw_min_dollars" min="0" step="0.01"
130 + placeholder="0.00" value="{{ pwyw_min_dollars }}">
131 + </div>
132 + </div>
133 +
134 + <div id="settings-subscription-note" style="display: none;">
135 + <p style="opacity: 0.7; font-size: 0.9rem;">
136 + Manage tiers in the <strong>Subscriptions</strong> tab.
137 + </p>
138 + </div>
139 +
140 + <button class="primary" type="submit">Save Monetization</button>
141 + <span id="project-pricing-status" style="margin-left: 0.5rem; font-size: 0.85rem;"></span>
142 + </form>
143 + </div>
144 +
145 + <div class="form-section">
101 146 <h2>Features</h2>
102 147 <p style="margin-bottom: 1rem; opacity: 0.7; text-align: left;">Platform tools enabled for this project. Changes take effect immediately.</p>
103 148
@@ -284,6 +329,60 @@ function saveProjectInfo(e, projectId) {
284 329 return false;
285 330 }
286 331
332 + // Monetization save
333 + function updateSettingsPricingUI() {
334 + var model = document.getElementById('settings-pricing-model').value;
335 + var sections = [
336 + { id: 'settings-buy-once-fields', active: model === 'buy_once' },
337 + { id: 'settings-pwyw-fields', active: model === 'pwyw' },
338 + { id: 'settings-subscription-note', active: model === 'subscription' }
339 + ];
340 + sections.forEach(function(s) {
341 + var el = document.getElementById(s.id);
342 + if (el) el.style.display = s.active ? '' : 'none';
343 + });
344 + }
345 + updateSettingsPricingUI();
346 +
347 + function saveProjectPricing(e, projectId) {
348 + e.preventDefault();
349 + var status = document.getElementById('project-pricing-status');
350 + var model = document.getElementById('settings-pricing-model').value;
351 + var data = { pricing_model: model };
352 +
353 + if (model === 'buy_once') {
354 + var p = parseFloat(document.getElementById('settings-price-dollars').value);
355 + if (!(p >= 0.50)) {
356 + if (status) { status.textContent = 'Price must be at least $0.50'; status.style.color = 'var(--error-color)'; }
357 + return false;
358 + }
359 + data.price_dollars = p;
360 + } else if (model === 'pwyw') {
361 + var v = document.getElementById('settings-pwyw-min-dollars').value;
362 + data.pwyw_min_dollars = v === '' ? 0 : parseFloat(v);
363 + }
364 +
365 + fetch('/api/projects/' + projectId, {
366 + method: 'PUT',
367 + headers: {'Content-Type': 'application/json', ...csrfHeaders()},
368 + body: JSON.stringify(data)
369 + }).then(function(r) {
370 + if (r.ok) {
371 + if (status) { status.textContent = 'Saved'; status.style.color = 'var(--success-color)'; }
372 + setTimeout(function() { if (status) status.textContent = ''; }, 2000);
373 + } else {
374 + r.text().then(function(body) {
375 + var msg = 'Save failed';
376 + try { msg = JSON.parse(body).error || msg; } catch(_) {}
377 + if (status) { status.textContent = msg; status.style.color = 'var(--error-color)'; }
378 + });
379 + }
380 + }).catch(function() {
381 + if (status) { status.textContent = 'Network error'; status.style.color = 'var(--error-color)'; }
382 + });
383 + return false;
384 + }
385 +
287 386 // Features update
288 387 function updateFeatures(projectId) {
289 388 var checkboxes = document.querySelectorAll('#features-grid input[type="checkbox"]');