Skip to main content

max / makenotwork

v0.6.4: split item page into store (/i/) and library (/l/) views /i/{id} is now always the store page (marketing, price, buy CTA) for every viewer; /l/{id} is the consumption surface (player, downloads, full article body) gated on access. Per-type library templates: library_text, library_audio, library_video, library_downloads, plus library_locked for 403s. Store templates carry only excerpt/cover + a CTA that flips to "View in library" when the viewer has access. View tracking moved to /l/. Stripe single-item success URL embeds item_id so buyers land on /l/{id}?purchase=success; cart purchases still land on /library?purchase=success. Library index rows, receipt "View item", and project "View Content" point at /l/.
Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-17 19:45 UTC
Commit: ccf23453f8ed15a5f592869a45c68fb661f1e61b
Parent: aedfc5a
26 files changed, +1584 insertions, -408 deletions
@@ -3551,7 +3551,7 @@
3551 3551
3552 3552 [[package]]
3553 3553 name = "makenotwork"
3554 - version = "0.6.2"
3554 + version = "0.6.4"
3555 3555 dependencies = [
3556 3556 "anyhow",
3557 3557 "argon2",
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.6.3"
3 + version = "0.6.4"
4 4 edition = "2024"
5 5 license-file = "LICENSE"
6 6
@@ -4,6 +4,26 @@
4 4
5 5 ---
6 6
7 + ## 🚨 LAUNCH BLOCKER — Stripe webhooks not delivering (discovered 2026-05-16)
8 +
9 + Symptom: testaccount123 completed a $5 PWYW checkout for "Audiofiles Desktop App" at 21:27 UTC. Stripe redirected to `/stripe/success` (session `cs_live_a1o3Ky7bRCXbnKNUYrGFS1JSmBFYLCxQ8zEk6gtmYfCPsyGAiJJfLNFwGm`). Transaction row is stuck `status=pending`. No item appeared in their library.
10 +
11 + Root cause: Stripe is not delivering webhook events to `POST /stripe/webhook`.
12 + - `webhook_events` table: 0 rows
13 + - `processed_webhook_events`: 3 rows, **all from 2026-05-12** (webhooks worked previously)
14 + - No `/stripe/webhook` hits in prod logs for past 7 days
15 + - `STRIPE_WEBHOOK_SECRET` is set in `/opt/makenotwork/.env` — value not verified against dashboard
16 +
17 + Action items (Stripe Dashboard at https://dashboard.stripe.com/webhooks):
18 + - [ ] Confirm an endpoint exists for `https://makenot.work/stripe/webhook`
19 + - [ ] Confirm the endpoint is enabled (not disabled/paused)
20 + - [ ] Confirm live-mode toggle is on
21 + - [ ] Check "Webhook attempts" — recent attempts and their response codes (or zero attempts = endpoint missing/wrong)
22 + - [ ] Confirm signing secret matches prod `STRIPE_WEBHOOK_SECRET`
23 + - [ ] Subscribe the endpoint to at least: `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed`, `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.payment_succeeded`, `invoice.payment_failed`, `payment_intent.succeeded`, `payment_intent.payment_failed`, `account.updated` (Connect creators)
24 + - [ ] After fixing: use Stripe Dashboard → Events → find the testaccount123 session's `checkout.session.completed` event → "Resend" to deliver retroactively, OR manually mark the transaction completed in DB if Stripe shows the charge succeeded
25 + - [ ] Verify by triggering one more test purchase end-to-end; expect a row in `webhook_events` and the transaction to flip from `pending` to `completed`
26 +
7 27 ## External Blockers
8 28
9 29 ### Business Formation (Make Creative, LLC)
@@ -209,9 +229,11 @@
209 229 ### 8. Monetization & Payments
210 230
211 231 - [ ] **One-time item purchase** — fixed or PWYW price via Stripe, 0% platform fee
212 - - [ ] Purchase flow (AF): fixed price item checkout completes
213 - - [ ] PWYW flow (BB): pay-what-you-want with minimum
214 - - [ ] Free download flow (GO): free item claimed
232 + - [ ] Purchase flow (AF): fixed price item checkout completes; **buyer lands on `/l/{item_id}?purchase=success`** (not `/library`)
233 + - [ ] PWYW flow (BB): pay-what-you-want with minimum; buyer lands on `/l/{item_id}?purchase=success`
234 + - [ ] Free download flow (GO): free item claimed; `/l/{item_id}` becomes accessible
235 + - [ ] After purchase, revisiting `/i/{item_id}` shows "View in library →" CTA (not Buy)
236 + - [ ] Cart purchase (multi-item) still lands on `/library?purchase=success`
215 237 - [ ] **Guest checkout** — purchase without account
216 238 - [ ] Email receipt with download link arrives
217 239 - [ ] CORS works for embedded checkouts
@@ -532,9 +554,13 @@
532 554
533 555 - [ ] Phase 22E: MediaMTX deployment on alpha-west-1 (install binary, systemd unit, Caddy config, Cloudflare DNS, firewall rules)
534 556 - [ ] Add `ffprobe` to production server (Phase 14E-1)
535 - - [ ] Generate Tauri signing key: `cargo tauri signer generate -w ~/.tauri/mnw.key` — save private key, note public key
536 - - [ ] Deploy signing key to each build host: set `TAURI_SIGNING_PRIVATE_KEY` env var (or `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` if password-protected) in the build user's environment
537 - - [ ] Add public key to each app's `tauri.conf.json` under `plugins.updater.pubkey`
557 + - [x] Generate Tauri signing keys — per-app keys at `~/.tauri/goingson.key{,.pub}` and `~/.tauri/balanced-breakfast.key{,.pub}` (AF is not a Tauri app). BB key rotated 2026-05-16 (original password unrecoverable; no shipped BB binaries with old pubkey)
558 + - [x] Add public keys to `tauri.conf.json` — GO and BB both wired with `plugins.updater.pubkey` + endpoint `https://makenot.work/api/v1/sync/ota/{app}/{{target}}/{{arch}}/{{current_version}}`
559 + - [x] Enable `bundle.createUpdaterArtifacts: true` in both apps' `tauri.conf.json` — Tauri 2 requires opt-in; without it builds skip the updater bundles + `.sig` sidecars silently
560 + - [x] Deploy private keys to astra + windows-x86; password file `~/.tauri/passwords.env` (Linux) / `passwords.ps1` (Windows). Master copy at `_private/tauri-passwords.env`
561 + - [ ] Deploy private keys to pop-os (offline 2026-05-14, last seen 2d ago — sync when next online)
562 + - [x] Verify signed build produces `.sig` sidecars — confirmed on astra for BB 0.3.3 (AppImage, deb, rpm all signed)
563 + - [ ] Full end-to-end OTA flow: build signed GO release, upload artifact via MNW release management UI, install on clean machine, confirm `/api/v1/sync/ota/...` returns 200 and Tauri updater accepts the signature
538 564 - [ ] Set S3 lifecycle rule on the upload prefix: delete incomplete/unconfirmed objects after 36 hours (safety net for presigned URL cleanup)
539 565
540 566 ---
@@ -137,6 +137,25 @@
137 137 }
138 138 ```
139 139
140 + ## Library View Split smoke checklist
141 +
142 + `/i/{id}` is the **store** page (marketing, price, buy CTA); `/l/{id}` is the **library** (consumption: player, downloads, full article body). For each item type, walk these flows:
143 +
144 + - **Anonymous viewer hits `/l/{id}`** → 403 with link back to `/i/{id}` and a Log-in button. For unlisted bundle children, lists the containing bundles.
145 + - **Anonymous viewer hits `/i/{id}`** → 200 store layout. Text shows excerpt (not full body). Audio/video show cover only (no player). Downloads/bundle show purchase box + bundle preview (no `/l/` link on children).
146 + - **Owner / buyer hits `/i/{id}`** → primary CTA is "View in library →" linking to `/l/{id}`. Buy button is not shown.
147 + - **Owner / buyer hits `/l/{id}`** →
148 + - Text: full article body + reading time + discussion.
149 + - Audio: full player + chapters + source-file downloads (if any) + discussion.
150 + - Video: full player + chapters + source-file downloads (if any) + discussion.
151 + - Downloads / bundle: downloads hero (versions list) + download notice + bundle children with `/l/{child_id}` links + collapsible Description + tabbed Sections + License + discussion.
152 + - **Buy flow (single item):** Stripe success URL embeds `item_id` → buyer lands on `/l/{id}?purchase=success`. Cart purchases still land on `/library?purchase=success`.
153 + - **Free claim:** "Add to Library" on a free item completes a transaction → `/l/{id}` becomes accessible.
154 + - **Bundle child access:** Buying bundle B grants `/l/{C}` for every C in B via `bundles::has_access_via_bundle`.
155 + - **View tracking:** `track_view("item", id)` fires from `/l/{id}` only — `/i/{id}` no longer records views.
156 + - **`noindex` on `/l/{id}`:** library views ship `<meta robots="noindex">` and no OG tags.
157 + - **Already-purchased redirect:** POST `/stripe/checkout/{item_id}` with an existing completed purchase redirects to `/l/{id}`.
158 +
140 159 ## Key Paths
141 160
142 161 - `tests/harness/` — TestHarness, TestClient, helper methods
@@ -100,6 +100,79 @@
100 100
101 101 ---
102 102
103 + ## Library View Split (`/i/{id}` store + `/l/{id}` library)
104 +
105 + **Goal:** `/i/{id}` is always the *store* page (marketing, description, price, buy CTA) — same layout for everyone, only the CTA changes ("Buy Once" → "View in library" when the viewer has access). `/l/{id}` is the *library* page (downloads / player / reader / consumption) — only accessible to users with access (purchased, subscribed, creator, bundle-granted, or free-claimed).
106 +
107 + **Why:** today `/i/{id}` silently morphs based on `has_access` — buyer can't revisit the store page once they own the item, shareable URLs render differently for owners vs. visitors, and the downloads list teases unpurchased viewers (filenames + sizes visible, click 401s). Splitting clarifies intent and matches the prior art (Gumroad `/l/`, itch, Bandcamp).
108 +
109 + **Decisions locked (resolved before Phase 0):**
110 + - **View tracking swap:** `/l/{id}` is the only path that calls `track_view("item", id)`. `/i/{id}` stops tracking views. Consumption is the meaningful retention signal; store-traffic analytics deferred until requested.
111 + - **403 vs. 404 on `/l/{id}`:** item missing → 404. Item exists but unpublished/deleted and viewer is not owner → 404 (don't leak draft existence). Item public but viewer lacks access → 403 with link back to `/i/{id}`. Bundle-only (unlisted) items in the 403 case list **all** containing bundles, matching current store behavior.
112 + - **OG / Twitter cards on `/l/{id}`:** deferred to its own task — ship Phase 0 with `noindex` and no OG tags. Revisit if buyers want to share library URLs.
113 + - **Owner "preview as visitor" toggle:** not building. The split makes `/i/{id}` identically rendered for all viewers (CTA label is the only diff). Private window covers the rare case.
114 + - **Bundle-child "via bundle" provenance badge on `/l/{child_id}`:** not building in v1. Being on `/l/{id}` already implies access; provenance belongs in Library index / receipts.
115 +
116 + **Phase 0 — Shared scaffolding** (lands first; subsequent phases plug item-type templates into this)
117 + - [x] Route `GET /l/{item_id}` in `routes/pages/public/mod.rs` → new `content::library_page` handler.
118 + - [x] `library_page` handler: parse `ItemId`, load `db_item`/`db_project`/`db_user`, compute the same `AccessContext` as `item_page`, branch:
119 + - [x] Item missing / sandbox seller → 404
120 + - [x] Item unpublished or soft-deleted and viewer is not owner → 404
121 + - [x] Item public but `!has_access` → 403 (render `library_locked.html` with link to `/i/{id}`; for unlisted items, list all containing bundles)
122 + - [x] Otherwise → render library template; `track_view("item", id)` here
123 + - [x] `item_page` (`/i/{id}`): **remove** the `track_view` call (moved to `/l/{id}`).
124 + - [x] Pick template by `item_type` (mirrors `render_item_page`): text → `library_text.html`, audio → `library_audio.html`, video → `library_video.html`, bundle/other → `library_downloads.html`. Phases 1–4 implement these. Phase 0 ships a placeholder `library_downloads.html` so the route works end-to-end.
125 + - [x] `library_locked.html` template (the 403 page): minimal — title, cover, "you don't have access to this yet", buttons to `/i/{id}` (store page) and `/login` (if not logged in). For unlisted items, render the existing "Available in: Bundle X, Bundle Y" block.
126 + - [x] `noindex` meta on `library_*.html`. No OG/Twitter cards (deferred).
127 + - [x] Update `item.html` / `text_reader.html` / `audio_player.html` / `video_player.html` to **always render the store layout**, swapping the primary CTA: when `has_access` is true, replace the Buy/PWYW/Subscribe button with a "View in library →" link to `/l/{item_id}`. Remove the body/player/downloads gating from the store templates entirely (those move to `/l/{id}`).
128 + - [x] Redirect targets that should now go to `/l/{id}`:
129 + - [x] `routes/stripe/checkout/item.rs::create_checkout` already-purchased redirect (currently `/i/{id}`)
130 + - [x] Stripe checkout success for single-item purchases (currently `/library?purchase=success` — make it `/l/{id}?purchase=success` so the buyer lands on the downloads, not a list)
131 + - [x] `routes/stripe/webhook` confirmation emails — "View your purchase" link
132 + - [x] Cart success path (multi-item) stays at `/library?purchase=success` since there's no single item
133 + - [x] Library index row "View" links: `/i/{id}` → `/l/{id}`
134 + - [x] Receipt page "View item" link
135 + - [x] Custom domain fallback (`routes/custom_domain.rs::render_item_page`) — keep at `/i/{id}` semantics; library URL is platform-only.
136 + - [x] Bundle children: if user has access to bundle B and child C is unlisted, `/l/{C}` must still grant access via `bundles::has_access_via_bundle`. Already covered by existing access logic — verify in handler.
137 + - [x] Free items: claiming a free item must enable `/l/{id}` access. `library/add` creates a completed transaction, so `has_purchased_item` returns true → already works. Verify.
138 + - [x] Sandbox / unpublished items: creator viewing their own unpublished item — `is_owner` already grants access; library view should work as a preview.
139 + - [x] Audit all template references to `/i/{` to spot anything that should change to `/l/{` (cart cards, dashboard items, transaction history, search results). Most stay on `/i/{` — that's the *store* page now, still the right link for browsing. The migration is targeted: post-purchase flows + library index.
140 +
141 + **Phase 1 — `/l/{id}` for downloads / bundle / other items** (item.html path; audiofiles, GO/BB if they ship as downloads)
142 + - [x] New `templates/pages/library_downloads.html`: hero = Downloads section (versions list with filename, size, version label, download button). Below: download notice ("Provided by third-party creators, scan before running"). Below: bundle contents (if bundle) with `/l/{child_id}` links. Collapsible Description (`<details>` closed by default) and tabbed Sections. License section. Discussion.
143 + - [x] Strip downloads + download-notice sections out of `item.html`. Leave purchase box, description, sections, license, bundle contents (preview only, no /l/ link if user lacks access).
144 + - [x] `item.html` CTA swap: when `has_access` → replace Buy/PWYW button with "View in library →" linking to `/l/{id}`. Keep "Add to cart" / "Wishlist" / "Save to collection" buttons visible for owners (they may want to gift, etc. — TBD).
145 + - [x] Smoke: buy audiofiles as testaccount123 → success → land on `/l/{audiofiles_item_id}` → download files works → revisit `/i/{audiofiles_item_id}` → store page shows with "View in library" CTA.
146 +
147 + **Phase 2 — `/l/{id}` for audio items** (audio_player.html path; podcast/music)
148 + - [x] New `templates/pages/library_audio.html`: full player as hero, episode/track info, downloads section if the item has version files (some audio items offer source files alongside the stream), description, discussion.
149 + - [x] `audio_player.html` → store layout only: cover, title, price, description. **Decide on preview**: 30s sample (requires server-side trimmed asset, not in scope here) vs. cover-only. Default: cover-only on store; can layer preview later as a separate task.
150 + - [x] Update `/api/stream/{item_id}` access check — already gates on `can_access`; no code change, just confirm.
151 + - [x] Smoke with a paid audio item from GO seed content.
152 +
153 + **Phase 3 — `/l/{id}` for video items** (video_player.html path)
154 + - [x] New `templates/pages/library_video.html`: full player as hero (same `/api/stream/{id}` endpoint), description, downloads (if any), discussion.
155 + - [x] `video_player.html` → store layout: cover/poster image, title, price, description, buy CTA. No video element on store (cover only — videos are expensive to ship as previews).
156 + - [x] Smoke with a video item.
157 +
158 + **Phase 4 — `/l/{id}` for text items** (text_reader.html path)
159 + - [x] New `templates/pages/library_text.html`: full article body as hero, reading-time, TOC (if implemented later), discussion.
160 + - [x] `text_reader.html` → store layout: title, byline, reading-time, **excerpt** (first paragraph or first 200 chars of plain-text body — preview value is real here, unlike audio/video). Add `excerpt` field to `Item` view-model or compute in handler. Buy CTA.
161 + - [x] Smoke with a text item from a project.
162 +
163 + **Phase 5 — cleanup pass after all 4 land**
164 + - [x] Delete now-unused branches (any leftover `{% if has_access %}...player...{% endif %}` in store templates).
165 + - [x] Update `docs/test_plan.md` smoke checklist with `/l/{id}` flow.
166 + - [x] Cross-link audit pass: re-grep templates for `/i/{` and confirm each remaining link is intentional (store page link, not consumption).
167 + - [x] Update `human_todo.md` purchase-flow test cases to assert landing on `/l/{id}`.
168 + - [x] Consider: 301 redirect `/i/{id}/download` (legacy?) → `/l/{id}`. Search for any such legacy paths first.
169 +
170 + **Open questions to resolve during Phase 0:**
171 + - Should owners see a "Preview as visitor" toggle on `/i/{id}` like the current dashboard preview? Probably out of scope — they can log out or use a private window.
172 + - Bundle children that are themselves owned individually: how does `/l/{child}` decide whether to show "in your library" badge for both the child and via-bundle access? Just show; access is access.
173 +
174 + ---
175 +
103 176 ## DIY Tier (Post-Launch, exploratory)
104 177
105 178 Ko-fi-style cheap tier: **$12/yr**, embeds + Stripe Connect only. No file hosting, no profile, no discovery, no mobile, no themes. Positioned as creator-acquisition wedge; modeled at break-even (~$0.10/yr infra, ~$11.24/yr support+margin budget). Tier viability requires ≥95% of DIY creators never contact support — every item below is in service of that constraint.
@@ -340,6 +413,13 @@
340 413 - [x] Compact Fan+ pane in dashboard account tab: status + period end + Cancel/Resume/Manage billing buttons (intentionally not pushy — non-subscribers see a one-line "Learn about Fan+" link, no upsell)
341 414
342 415 ### Global UX
416 + - [ ] **Misleading webhook error message** — discovered 2026-05-16 during launch-blocker triage. `payments::verify_webhook` logs `"webhook signature verification failed"` and returns response body `"Invalid webhook signature"` for ALL failure modes, including JSON parse errors (`BadParse(missing field X)`) caused by Stripe API version mismatch. This made diagnosis take an extra step (the signature was fine; the schema was wrong). Fix: distinguish signature errors from parse errors in both the log message and the HTTP response body. Two log lines / two response codes (still 400 in both cases is OK, but body should say what actually failed).
417 + - [ ] **Checkout error messages not surfaced to user** — reported 2026-05-16. Backend `AppError::BadRequest` returns useful messages (e.g. "You cannot purchase your own items", "Creator's payment account is not ready", "This promo code has expired") at `routes/stripe/checkout/item.rs`, but the frontend checkout flow shows a generic "Error" instead of the body. Fix: surface `response.text()` from non-2xx HTMX/fetch responses in the checkout UI (and audit other 4xx paths for the same pattern). Especially load-bearing for promo-code rejection and creator-not-ready cases, which buyers will hit during normal use.
418 + - [ ] **Library page scroll-in-scroll + row dropdown** — reported 2026-05-16 during human testing. Buyer library page has nested scroll (page scroll + an inner scroll region) which is awkward, and the per-row `...` action dropdown clips or fights with the inner scroll. Redesign: flatten to a single scroll context and either make the row actions an inline action bar or use a portaled menu that escapes the scroll container.
419 + - [ ] **Stuck "Verbing..." buttons** — reported 2026-05-16 during human testing. After failed or non-swapping requests, many action buttons stay in their loading state (Saving... / Deleting... / Uploading... / ...) forever. Two root causes:
420 + - `static/mnw.js:290-304` `htmx:afterRequest` handler only restores buttons inside a `<form>`. Buttons with `hx-post`/`hx-delete` directly on them (no parent form) never get reset.
421 + - Per-file `fetch()` loading-state code in `static/item-details.js`, `static/blog-editor.js`, `static/media-picker.js`, `static/collections.js` each manually toggle textContent and don't consistently restore on error/non-JSON responses.
422 + - Fix: add a shared `withLoadingState(btn, verb, asyncFn)` helper with `try/finally`, migrate the per-file patterns to use it, and widen the htmx handler so non-form buttons (whose own `evt.detail.elt` is the button) also get reset.
343 423 - [ ] Find a better place for the keyboard shortcuts help button (removed from header nav)
344 424 - [ ] Add toast stacking for multiple notifications
345 425 - [ ] Add "New" badge/dot indicator on recently launched features
@@ -64,6 +64,11 @@
64 64 ProjectTemplate,
65 65 ProjectPaywallTemplate,
66 66 ItemTemplate,
67 + LibraryAudioTemplate,
68 + LibraryDownloadsTemplate,
69 + LibraryLockedTemplate,
70 + LibraryTextTemplate,
71 + LibraryVideoTemplate,
67 72 TextReaderTemplate,
68 73 AudioPlayerTemplate,
69 74 VideoPlayerTemplate,
@@ -84,79 +84,38 @@
84 84 {% endmatch %}
85 85 </div>
86 86
87 - {% if has_access %}
88 - <div class="media-player">
89 - <audio id="media-a" preload="metadata">
90 - {% if let Some(audio_url) = audio_url %}
91 - <source src="{{ audio_url }}" type="audio/mpeg">
92 - {% endif %}
93 - </audio>
94 - <audio id="media-b" preload="metadata"></audio>
95 -
96 - <div class="player-controls">
97 - <button class="play-button" id="play-btn" aria-label="Play">
98 - <svg id="play-icon" width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
99 - <path d="M8 5v14l11-7z"/>
100 - </svg>
101 - <svg id="pause-icon" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" style="display: none;">
102 - <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
103 - </svg>
104 - </button>
105 - <div class="progress-container">
106 - <div class="progress-bar" id="progress-bar">
107 - <div class="progress-fill" id="progress-fill"></div>
108 - </div>
109 - <div class="time-display">
110 - <span id="current-time">0:00</span>
111 - <span id="duration">0:00</span>
112 - </div>
113 - </div>
114 - </div>
115 -
116 - <div class="insertion-label" id="insertion-label"></div>
117 - <button class="skip-insertion" id="skip-btn" type="button">Skip &#8250;</button>
118 -
119 - <div class="player-secondary">
120 - <div class="speed-control">
121 - <span>Speed:</span>
122 - <button class="speed-button" data-speed="0.5">0.5x</button>
123 - <button class="speed-button active" data-speed="1">1x</button>
124 - <button class="speed-button" data-speed="1.5">1.5x</button>
125 - <button class="speed-button" data-speed="2">2x</button>
126 - </div>
127 - <div class="volume-control">
128 - <span class="volume-icon">
129 - <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
130 - <path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
131 - </svg>
132 - </span>
133 - <input type="range" class="volume-slider" id="volume-slider" min="0" max="100" value="75" />
134 - </div>
135 - </div>
136 - </div>
137 -
138 87 {% if !item.description.is_empty() %}
139 88 <div class="media-description">
140 89 <p>{{ item.description }}</p>
141 90 </div>
142 91 {% endif %}
143 - {% else %}
144 - {% include "partials/paywall.html" %}
145 - {% endif %}
146 92
147 - {% if !chapters.is_empty() %}
148 - <div class="chapters">
149 - <h3>Chapters</h3>
150 - <ul class="chapter-list">
151 - {% for chapter in chapters %}
152 - <li class="chapter-item"{% if has_access %} data-time="{{ chapter.start_seconds }}"{% endif %}>
153 - <span class="chapter-title">{{ chapter.title }}</span>
154 - <span class="chapter-time">{{ chapter.timestamp }}</span>
155 - </li>
156 - {% endfor %}
157 - </ul>
93 + <div class="store-cta" style="margin: 2rem 0; padding: 1.5rem; background: var(--surface-muted); text-align: center;">
94 + {% if has_access %}
95 + <p style="font-size: 1.1rem; margin-bottom: 1rem;">You have access to this item.</p>
96 + <a href="/l/{{ item.id }}"><button class="primary">View in library &rarr;</button></a>
97 + {% else if item.is_free %}
98 + {% if let Some(_user) = session_user %}
99 + {% if in_library %}
100 + <span class="library-status" style="font-family: var(--font-mono); color: var(--text-muted);">In your library &middot; <a href="/l/{{ item.id }}">Listen now &rarr;</a></span>
101 + {% else %}
102 + <button class="add-to-library-btn primary"
103 + hx-post="/api/library/add/{{ item.id }}"
104 + hx-swap="outerHTML">Add to Library - Free</button>
105 + {% endif %}
106 + {% else %}
107 + <a href="/login?redirect=/l/{{ item.id }}"><button class="primary">Log in to listen - Free</button></a>
108 + {% endif %}
109 + {% else %}
110 + <p style="font-size: 1.5rem; margin-bottom: 1rem;">{{ item.price }}</p>
111 + {% if let Some(_user) = session_user %}
112 + <a href="/purchase/{{ item.id }}"><button class="primary">{% if item.pwyw_enabled %}Pay What You Want{% else %}Buy Once{% endif %} - {{ item.price }}</button></a>
113 + {% else %}
114 + <a href="/login?redirect=/i/{{ item.id }}"><button class="primary">Log in to purchase</button></a>
115 + {% endif %}
116 + <p style="font-size: 0.85rem; opacity: 0.7; margin-top: 0.75rem;">Support {{ creator_username }} directly &mdash; 0% platform fee.</p>
117 + {% endif %}
158 118 </div>
159 - {% endif %}
160 119
161 120 {% if !item.tags.is_empty() %}
162 121 <div class="media-tags">
@@ -166,24 +125,6 @@
166 125 </div>
167 126 {% endif %}
168 127
169 - {% if is_free %}
170 - <div class="library-action" style="margin-top: 2rem; padding-top: 2rem; border-top: 1px solid var(--border);">
171 - {% if let Some(_user) = session_user %}
172 - {% if in_library %}
173 - <span class="library-status" style="font-family: var(--font-mono); color: var(--text-muted);">In your library</span>
174 - {% else %}
175 - <button class="add-to-library-btn"
176 - hx-post="/api/library/add/{{ item.id }}"
177 - hx-swap="outerHTML"
178 - style="background: var(--primary-dark); color: var(--primary-light); border: none; padding: 0.75rem 1.5rem; font-family: var(--font-mono); cursor: pointer;">
179 - Add to Library
180 - </button>
181 - {% endif %}
182 - {% else %}
183 - <a href="/login" style="font-family: var(--font-mono); color: var(--highlight);">Log in to add to library</a>
184 - {% endif %}
185 - </div>
186 - {% endif %}
187 128 </article>
188 129
189 130 {% include "partials/discussion_section.html" %}
@@ -194,15 +135,4 @@
194 135 </footer>
195 136 {% endblock %}
196 137
197 - {% block scripts %}
198 - {% if has_access %}
199 - <script id="media-player-data" type="application/json">
200 - {
201 - "segments": {{ segments_json|safe }},
202 - "mediaType": "audio",
203 - "itemId": "{{ item.id }}"
204 - }
205 - </script>
206 - <script src="/static/media-player.js"></script>
207 - {% endif %}
208 - {% endblock %}
138 + {% block scripts %}{% endblock %}