max / makenotwork
60 files changed,
+3602 insertions,
-48 deletions
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "makenotwork" | |
| 3 | - | version = "0.4.2" | |
| 3 | + | version = "0.4.3" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license-file = "LICENSE" | |
| 6 | 6 |
| @@ -0,0 +1,351 @@ | |||
| 1 | + | # Plan: Guest Checkout | |
| 2 | + | ||
| 3 | + | Allow fans to purchase items without creating an MNW account. This is a core platform feature — not limited to embeds. Guest checkout is available everywhere: item pages, project pages, embeds, direct links. | |
| 4 | + | ||
| 5 | + | ## Why | |
| 6 | + | ||
| 7 | + | Every extra step between "I want this" and "I have this" costs conversions. The current flow requires: create account → verify email → log in → find item → buy. Guest checkout reduces this to: click buy → enter payment → done. This is how Gumroad and Bandcamp work, and it's what creators expect from a platform that claims to stay out of the way. | |
| 8 | + | ||
| 9 | + | ## Scope | |
| 10 | + | ||
| 11 | + | Guest checkout is a **first-class purchase path**, not an embed-only feature: | |
| 12 | + | - Item pages show guest checkout as the primary option for logged-out visitors | |
| 13 | + | - Embeds use it for zero-friction purchases | |
| 14 | + | - Direct links (shared by creators) support it | |
| 15 | + | - The logged-in purchase flow remains unchanged for users who prefer it | |
| 16 | + | ||
| 17 | + | ## How It Works (Fan Perspective) | |
| 18 | + | ||
| 19 | + | 1. Fan clicks "Buy" (on embed, on item page, anywhere) | |
| 20 | + | 2. Stripe Checkout opens (popup for embeds, redirect for on-site) | |
| 21 | + | 3. Fan enters email + card details on Stripe's hosted checkout | |
| 22 | + | 4. Payment completes | |
| 23 | + | 5. Fan receives email with: | |
| 24 | + | - Download link (signed, long-lived) | |
| 25 | + | - Claim link to attach purchase to an MNW account (optional) | |
| 26 | + | 6. Fan can download immediately — no account needed | |
| 27 | + | ||
| 28 | + | If the fan's email matches an existing MNW account, the purchase is automatically attached to that account (visible in their library next time they log in). | |
| 29 | + | ||
| 30 | + | ## Data Model Changes | |
| 31 | + | ||
| 32 | + | ### Migration 078: Guest purchases | |
| 33 | + | ||
| 34 | + | ```sql | |
| 35 | + | -- Allow transactions without a registered buyer account. | |
| 36 | + | -- guest_email stores the buyer's email from Stripe for guest checkouts. | |
| 37 | + | -- claim_token allows the buyer to later attach the purchase to an account. | |
| 38 | + | ALTER TABLE transactions ADD COLUMN guest_email VARCHAR(255); | |
| 39 | + | ALTER TABLE transactions ADD COLUMN claim_token UUID; | |
| 40 | + | ALTER TABLE transactions ADD COLUMN claimed_by UUID REFERENCES users(id) ON DELETE SET NULL; | |
| 41 | + | ALTER TABLE transactions ADD COLUMN download_token UUID DEFAULT gen_random_uuid(); | |
| 42 | + | ||
| 43 | + | -- Make buyer_id nullable for guest purchases. | |
| 44 | + | ALTER TABLE transactions ALTER COLUMN buyer_id DROP NOT NULL; | |
| 45 | + | ||
| 46 | + | -- Index for claim token lookup. | |
| 47 | + | CREATE INDEX idx_transactions_claim_token ON transactions(claim_token) WHERE claim_token IS NOT NULL; | |
| 48 | + | ||
| 49 | + | -- Index for guest email lookup (to auto-attach when they create an account). | |
| 50 | + | CREATE INDEX idx_transactions_guest_email ON transactions(guest_email) WHERE guest_email IS NOT NULL; | |
| 51 | + | ||
| 52 | + | -- Index for download token (signed download links). | |
| 53 | + | CREATE UNIQUE INDEX idx_transactions_download_token ON transactions(download_token) WHERE download_token IS NOT NULL; | |
| 54 | + | ``` | |
| 55 | + | ||
| 56 | + | ### Fields explained | |
| 57 | + | ||
| 58 | + | | Column | Purpose | | |
| 59 | + | |--------|---------| | |
| 60 | + | | `guest_email` | Buyer's email from Stripe (NULL for logged-in purchases) | | |
| 61 | + | | `claim_token` | UUID sent in post-purchase email; fan uses it to attach purchase to account | | |
| 62 | + | | `claimed_by` | User ID after claim (NULL until claimed) | | |
| 63 | + | | `download_token` | Unique token for signed download links (no auth required) | | |
| 64 | + | ||
| 65 | + | ### Constraint change | |
| 66 | + | ||
| 67 | + | `buyer_id` becomes nullable. For guest purchases, `buyer_id = NULL` and `guest_email` is set. For logged-in purchases, `buyer_id` is set and `guest_email` is NULL. | |
| 68 | + | ||
| 69 | + | Update the partial unique index to handle both cases: | |
| 70 | + | ```sql | |
| 71 | + | -- Prevent duplicate pending checkouts: logged-in buyers | |
| 72 | + | CREATE UNIQUE INDEX idx_transactions_pending_buyer_item | |
| 73 | + | ON transactions(buyer_id, item_id) | |
| 74 | + | WHERE status = 'pending' AND buyer_id IS NOT NULL; | |
| 75 | + | ||
| 76 | + | -- Prevent duplicate pending checkouts: guest buyers | |
| 77 | + | CREATE UNIQUE INDEX idx_transactions_pending_guest_item | |
| 78 | + | ON transactions(guest_email, item_id) | |
| 79 | + | WHERE status = 'pending' AND guest_email IS NOT NULL; | |
| 80 | + | ``` | |
| 81 | + | ||
| 82 | + | (Drop the old partial unique index if one exists on `(buyer_id, item_id) WHERE status = 'pending'`.) | |
| 83 | + | ||
| 84 | + | ## New Endpoints | |
| 85 | + | ||
| 86 | + | ### 1. Guest checkout session creation | |
| 87 | + | ||
| 88 | + | ``` | |
| 89 | + | POST /api/checkout/guest/{item_id} | |
| 90 | + | Content-Type: application/json (or form) | |
| 91 | + | ||
| 92 | + | No authentication required. | |
| 93 | + | CORS: Allow-Origin * (for embed usage) | |
| 94 | + | ``` | |
| 95 | + | ||
| 96 | + | Request body (optional): | |
| 97 | + | ```json | |
| 98 | + | { | |
| 99 | + | "amount_cents": 1500, // Only for PWYW items | |
| 100 | + | "promo_code": "LAUNCH20" // Optional | |
| 101 | + | } | |
| 102 | + | ``` | |
| 103 | + | ||
| 104 | + | Response: | |
| 105 | + | ```json | |
| 106 | + | { | |
| 107 | + | "checkout_url": "https://checkout.stripe.com/c/pay/cs_live_..." | |
| 108 | + | } | |
| 109 | + | ``` | |
| 110 | + | ||
| 111 | + | Handler logic: | |
| 112 | + | 1. Fetch item (404 if not found, not public, or not purchasable) | |
| 113 | + | 2. Validate price (PWYW minimum check, fixed price override) | |
| 114 | + | 3. Apply promo code if provided (reserve use_count) | |
| 115 | + | 4. Create Stripe Checkout Session: | |
| 116 | + | - `mode: "payment"` | |
| 117 | + | - `customer_creation: "always"` (Stripe creates a customer record with email) | |
| 118 | + | - `payment_intent_data.transfer_data.destination: seller_stripe_account_id` | |
| 119 | + | - `metadata: { item_id, seller_id, checkout_type: "guest", promo_code_id }` | |
| 120 | + | - No `customer` field (guest — Stripe collects email) | |
| 121 | + | - `success_url`: `/purchase/success?session_id={CHECKOUT_SESSION_ID}` | |
| 122 | + | - `cancel_url`: `/i/{item_id}` | |
| 123 | + | 5. Create pending transaction with `buyer_id = NULL`, `guest_email = NULL` (populated on webhook) | |
| 124 | + | 6. Return checkout URL | |
| 125 | + | ||
| 126 | + | ### 2. Guest download endpoint | |
| 127 | + | ||
| 128 | + | ``` | |
| 129 | + | GET /download/{download_token} | |
| 130 | + | ||
| 131 | + | No authentication required. | |
| 132 | + | ``` | |
| 133 | + | ||
| 134 | + | Handler logic: | |
| 135 | + | 1. Look up transaction by `download_token` | |
| 136 | + | 2. Verify status = 'completed' | |
| 137 | + | 3. Fetch item (if still exists) | |
| 138 | + | 4. Generate presigned S3 URL for the content | |
| 139 | + | 5. Redirect to presigned URL (or return JSON with URL for API consumers) | |
| 140 | + | ||
| 141 | + | This endpoint allows email download links to work without login. | |
| 142 | + | ||
| 143 | + | ### 3. Claim endpoint | |
| 144 | + | ||
| 145 | + | ``` | |
| 146 | + | POST /api/purchases/claim | |
| 147 | + | Authentication: Required (logged-in user) | |
| 148 | + | ||
| 149 | + | Body: { "claim_token": "uuid-here" } | |
| 150 | + | ``` | |
| 151 | + | ||
| 152 | + | Handler logic: | |
| 153 | + | 1. Find transaction by claim_token | |
| 154 | + | 2. Verify not already claimed | |
| 155 | + | 3. Set `buyer_id = current_user.id`, `claimed_by = current_user.id` | |
| 156 | + | 4. Clear claim_token (one-time use) | |
| 157 | + | 5. Purchase now appears in user's library | |
| 158 | + | ||
| 159 | + | ## Webhook Changes | |
| 160 | + | ||
| 161 | + | ### Modified: `handle_purchase_checkout_completed()` | |
| 162 | + | ||
| 163 | + | Current: Expects `buyer_id` in metadata, updates existing pending transaction. | |
| 164 | + | ||
| 165 | + | New logic branch for `checkout_type == "guest"`: | |
| 166 | + | 1. Extract `customer_details.email` from Stripe session | |
| 167 | + | 2. Look up pending transaction by `stripe_checkout_session_id` | |
| 168 | + | 3. Set `guest_email = email` on transaction | |
| 169 | + | 4. Generate `claim_token` and `download_token` | |
| 170 | + | 5. Check if email matches existing MNW user: | |
| 171 | + | - **Yes**: Set `buyer_id` to that user (auto-claim). Skip claim email, purchase appears in library. | |
| 172 | + | - **No**: Leave `buyer_id = NULL`. Send guest purchase email with download + claim links. | |
| 173 | + | 6. Complete transaction (set status = 'completed', payment_intent_id) | |
| 174 | + | 7. Normal secondary effects: increment sales_count, generate license keys, revenue splits | |
| 175 | + | ||
| 176 | + | ### Email: Guest purchase confirmation | |
| 177 | + | ||
| 178 | + | New email template with: | |
| 179 | + | - "Your purchase of {item_title}" subject | |
| 180 | + | - Download link: `https://makenot.work/download/{download_token}` | |
| 181 | + | - Claim link: `https://makenot.work/claim?token={claim_token}` | |
| 182 | + | - "Want to keep all your purchases in one place? Create an account to claim this purchase." | |
| 183 | + | - Receipt details (amount, date, seller) | |
| 184 | + | ||
| 185 | + | ## Embed Integration | |
| 186 | + | ||
| 187 | + | The embed buy button changes from: | |
| 188 | + | ```html | |
| 189 | + | <a href="/i/{item_id}" target="_blank">Buy</a> | |
| 190 | + | ``` | |
| 191 | + | to triggering a popup checkout: | |
| 192 | + | ```html | |
| 193 | + | <a href="javascript:void(0)" onclick="mnwBuy('{item_id}')">Buy</a> | |
| 194 | + | <script> | |
| 195 | + | function mnwBuy(itemId) { | |
| 196 | + | fetch('https://makenot.work/api/checkout/guest/' + itemId, { method: 'POST' }) | |
| 197 | + | .then(r => r.json()) | |
| 198 | + | .then(data => { | |
| 199 | + | // Open Stripe Checkout in popup | |
| 200 | + | const popup = window.open(data.checkout_url, 'mnw-checkout', | |
| 201 | + | 'width=500,height=700,scrollbars=yes'); | |
| 202 | + | if (!popup) window.location.href = data.checkout_url; // fallback | |
| 203 | + | }); | |
| 204 | + | } | |
| 205 | + | </script> | |
| 206 | + | ``` | |
| 207 | + | ||
| 208 | + | For the simpler iframe-only embeds (v1), the buy button can still link to `/i/{item_id}` in a new tab where the item page offers both logged-in and guest checkout. The popup variant is an enhancement for v2/overlay embeds. | |
| 209 | + | ||
| 210 | + | ## CORS Configuration | |
| 211 | + | ||
| 212 | + | New middleware (or layer) for specific routes: | |
| 213 | + | ||
| 214 | + | ```rust | |
| 215 | + | // Routes that need CORS for embed cross-origin requests | |
| 216 | + | let cors_routes = ["/api/checkout/guest/"]; | |
| 217 | + | ||
| 218 | + | // In middleware: | |
| 219 | + | if path matches cors_routes { | |
| 220 | + | response.headers_mut().insert("Access-Control-Allow-Origin", "*"); | |
| 221 | + | response.headers_mut().insert("Access-Control-Allow-Methods", "POST, OPTIONS"); | |
| 222 | + | response.headers_mut().insert("Access-Control-Allow-Headers", "Content-Type"); | |
| 223 | + | } | |
| 224 | + | ``` | |
| 225 | + | ||
| 226 | + | Also handle `OPTIONS` preflight requests on these routes. | |
| 227 | + | ||
| 228 | + | ## On-Site Guest Checkout (Primary Purchase Path) | |
| 229 | + | ||
| 230 | + | Guest checkout is the default purchase experience for logged-out visitors everywhere on the platform — not a secondary option behind "log in first." | |
| 231 | + | ||
| 232 | + | **Item page** (`/i/{item_id}`): | |
| 233 | + | - Logged out: "Buy" button triggers guest checkout (Stripe). Small "or log in" link below. | |
| 234 | + | - Logged in: Normal purchase flow (no change). | |
| 235 | + | ||
| 236 | + | **Project page** (`/p/{slug}`): | |
| 237 | + | - Same pattern for project-level purchases and subscriptions. | |
| 238 | + | ||
| 239 | + | **Direct purchase links** (shared by creators): | |
| 240 | + | - `/buy/{item_id}` — lightweight page with item summary + immediate guest checkout. No navigation chrome. Optimized for link-in-bio, social media, newsletters. | |
| 241 | + | ||
| 242 | + | This benefits all creators immediately. The embed integration is just one surface that uses the same underlying guest checkout API. | |
| 243 | + | ||
| 244 | + | ## Account Creation After Guest Purchase | |
| 245 | + | ||
| 246 | + | When a guest later creates an MNW account with the same email: | |
| 247 | + | ||
| 248 | + | 1. During signup (after email verification), check for unclaimed transactions matching the email | |
| 249 | + | 2. Auto-attach: set `buyer_id` on all matching guest transactions | |
| 250 | + | 3. Show "We found N purchases with this email — they're now in your library" message | |
| 251 | + | ||
| 252 | + | This means fans who buy first and sign up later lose nothing. | |
| 253 | + | ||
| 254 | + | ## Library Changes | |
| 255 | + | ||
| 256 | + | The library query currently filters `WHERE buyer_id = $1`. Update to: | |
| 257 | + | ```sql | |
| 258 | + | WHERE buyer_id = $1 OR claimed_by = $1 | |
| 259 | + | ``` | |
| 260 | + | ||
| 261 | + | ## Download Links (Future: One-Time Downloads) | |
| 262 | + | ||
| 263 | + | v1 download tokens are permanent and unlimited-use (like a personal download link). In a future iteration, creators can generate **one-time-download links** for gifting, press copies, or limited distribution: | |
| 264 | + | ||
| 265 | + | - Add `downloads_remaining INTEGER` column to transactions (NULL = unlimited) | |
| 266 | + | - Each download decrements the counter | |
| 267 | + | - When counter hits 0, link returns 410 Gone | |
| 268 | + | - Creator dashboard: "Generate download link" with configurable download count (1, 3, 5, unlimited) | |
| 269 | + | - These work alongside claim tokens — a recipient can claim the purchase to their account AND has a finite number of downloads available | |
| 270 | + | ||
| 271 | + | This is deferred to keep v1 simple but the token infrastructure supports it directly. | |
| 272 | + | ||
| 273 | + | ## Security Considerations | |
| 274 | + | ||
| 275 | + | - **Download tokens are long-lived** (no expiry). The token is unguessable (UUID v4 = 122 bits entropy). Acceptable risk for digital content. | |
| 276 | + | - **Claim tokens are one-time use.** Cleared after claim. | |
| 277 | + | - **Rate limit guest checkout endpoint** to prevent abuse (e.g., 10 req/min per IP). | |
| 278 | + | - **Promo code abuse**: Guest checkout doesn't tie to a user, so someone could use a single-use promo code from multiple emails. Mitigation: rate limit by IP, flag suspicious patterns. | |
| 279 | + | - **Email validation**: Stripe validates the email (requires real payment method). No fake emails getting free content. | |
| 280 | + | - **No contact sharing for guests**: `share_contact` requires an account. Guests can claim later if they want to share contact info. | |
| 281 | + | ||
| 282 | + | ## Migration Path | |
| 283 | + | ||
| 284 | + | This is backwards-compatible: | |
| 285 | + | - Existing transactions: `guest_email = NULL`, `buyer_id` set → logged-in purchase (unchanged) | |
| 286 | + | - New guest transactions: `guest_email` set, `buyer_id = NULL` → guest purchase | |
| 287 | + | - After auto-attach or claim: both `guest_email` and `buyer_id` set | |
| 288 | + | ||
| 289 | + | No data migration needed. Only new transactions use the new columns. | |
| 290 | + | ||
| 291 | + | ## Implementation Order | |
| 292 | + | ||
| 293 | + | 1. Migration 078 (schema changes) | |
| 294 | + | 2. `CreateTransactionParams` update — make `buyer_id` optional | |
| 295 | + | 3. Guest checkout endpoint (`/api/checkout/guest/{item_id}`) | |
| 296 | + | 4. Webhook handler branch for `checkout_type: "guest"` | |
| 297 | + | 5. Download token endpoint (`/download/{download_token}`) | |
| 298 | + | 6. Guest purchase email template | |
| 299 | + | 7. Claim endpoint (`/api/purchases/claim`) | |
| 300 | + | 8. Auto-attach on signup | |
| 301 | + | 9. Library query update | |
| 302 | + | 10. Item page UI (guest checkout option for logged-out visitors) | |
| 303 | + | 11. CORS middleware for guest checkout endpoint | |
| 304 | + | 12. Embed integration (update buy button to use guest checkout) | |
| 305 | + | 13. Tests | |
| 306 | + | ||
| 307 | + | ## Tests | |
| 308 | + | ||
| 309 | + | - `guest_checkout_creates_session` — returns valid Stripe checkout URL | |
| 310 | + | - `guest_checkout_private_item_404` | |
| 311 | + | - `guest_checkout_free_item` — works (amount_cents = 0, Stripe setup mode) | |
| 312 | + | - `guest_checkout_pwyw_validates_minimum` | |
| 313 | + | - `guest_checkout_promo_code_applies` | |
| 314 | + | - `guest_webhook_completes_transaction` — sets guest_email, generates tokens | |
| 315 | + | - `guest_webhook_auto_attaches_existing_user` — email match sets buyer_id | |
| 316 | + | - `guest_download_token_works` — returns presigned URL | |
| 317 | + | - `guest_download_invalid_token_404` | |
| 318 | + | - `guest_claim_attaches_to_account` | |
| 319 | + | - `guest_claim_already_claimed_error` | |
| 320 | + | - `signup_auto_attaches_guest_purchases` | |
| 321 | + | - `guest_checkout_cors_headers` — preflight and response headers correct | |
| 322 | + | - `guest_checkout_rate_limited` | |
| 323 | + | ||
| 324 | + | ## Estimated Scope | |
| 325 | + | ||
| 326 | + | - Migration: ~20 lines SQL | |
| 327 | + | - Checkout endpoint: ~120 lines | |
| 328 | + | - Webhook changes: ~80 lines | |
| 329 | + | - Download endpoint: ~50 lines | |
| 330 | + | - Claim endpoint: ~40 lines | |
| 331 | + | - Auto-attach on signup: ~30 lines | |
| 332 | + | - Email template: ~50 lines | |
| 333 | + | - CORS middleware: ~30 lines | |
| 334 | + | - Item page UI changes: ~40 lines | |
| 335 | + | - Library query update: ~10 lines | |
| 336 | + | - Tests: ~250 lines | |
| 337 | + | - **Total: ~720 lines** | |
| 338 | + | ||
| 339 | + | ## Free Items | |
| 340 | + | ||
| 341 | + | For free items (price = 0), Stripe Checkout doesn't work (can't charge $0). Two options: | |
| 342 | + | ||
| 343 | + | **Option A**: Use Stripe's `setup` mode to collect email without charging. Overkill. | |
| 344 | + | ||
| 345 | + | **Option B**: Skip Stripe entirely for free items. The guest checkout endpoint for $0 items: | |
| 346 | + | 1. Collect email via a simple form (in the embed or on-site) | |
| 347 | + | 2. Create completed transaction immediately (no Stripe involved) | |
| 348 | + | 3. Send download + claim email | |
| 349 | + | 4. Rate limit aggressively (prevent scraping all free content) | |
| 350 | + | ||
| 351 | + | Option B is simpler and faster. Implement as a branch in the guest checkout handler. |
| @@ -0,0 +1,128 @@ | |||
| 1 | + | # Plan: Buy Button Embed | |
| 2 | + | ||
| 3 | + | The simplest embed — a horizontal strip showing item title, price, and a buy button. Proves the routing, CORS, and iframe pattern that all other embeds build on. | |
| 4 | + | ||
| 5 | + | ## Output | |
| 6 | + | ||
| 7 | + | `/embed/i/{item_id}/button` — a self-contained HTML page (~300x60px) rendered inside an iframe on any external site. Clicking "Buy" opens the MNW purchase page in a new tab. | |
| 8 | + | ||
| 9 | + | ## Steps | |
| 10 | + | ||
| 11 | + | ### 1. Route module (`src/routes/embed/mod.rs`) | |
| 12 | + | ||
| 13 | + | Create new route module with: | |
| 14 | + | - `pub fn router() -> Router<AppState>` registering all embed routes | |
| 15 | + | - Mount at `/embed` in `src/routes/mod.rs` | |
| 16 | + | ||
| 17 | + | ### 2. Security headers override | |
| 18 | + | ||
| 19 | + | In `src/lib.rs` security headers middleware, add a path check: | |
| 20 | + | - If request path starts with `/embed/`, set: | |
| 21 | + | - `X-Frame-Options: ALLOWALL` | |
| 22 | + | - `Content-Security-Policy: frame-ancestors *` | |
| 23 | + | - `Cache-Control: public, max-age=300` | |
| 24 | + | - Otherwise, keep existing `X-Frame-Options: DENY` and current CSP. | |
| 25 | + | ||
| 26 | + | ### 3. Buy button handler (`src/routes/embed/item.rs`) | |
| 27 | + | ||
| 28 | + | ```rust | |
| 29 | + | /// GET /embed/i/{item_id}/button | |
| 30 | + | pub async fn item_button( | |
| 31 | + | State(state): State<AppState>, | |
| 32 | + | Path(item_id): Path<ItemId>, | |
| 33 | + | ) -> Result<Response> | |
| 34 | + | ``` | |
| 35 | + | ||
| 36 | + | Logic: | |
| 37 | + | - Fetch item by ID (return 404 if not found or not public) | |
| 38 | + | - Fetch parent project (for the purchase URL) | |
| 39 | + | - Render `templates/embeds/button.html` with: title, price_display, purchase_url, cover_image_url (optional, for small icon) | |
| 40 | + | ||
| 41 | + | ### 4. Template (`templates/embeds/button.html`) | |
| 42 | + | ||
| 43 | + | Full standalone HTML page. No `{% extends %}`. Structure: | |
| 44 | + | ||
| 45 | + | ```html | |
| 46 | + | <!doctype html> | |
| 47 | + | <html lang="en"> | |
| 48 | + | <head> | |
| 49 | + | <meta charset="UTF-8"> | |
| 50 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 51 | + | <title>{{ title }} — Makenot.work</title> | |
| 52 | + | <style> | |
| 53 | + | /* Inline styles only — MNW brand colors */ | |
| 54 | + | * { margin: 0; padding: 0; box-sizing: border-box; } | |
| 55 | + | body { | |
| 56 | + | font-family: Lato, -apple-system, sans-serif; | |
| 57 | + | background: #ede8e1; | |
| 58 | + | color: #3d3530; | |
| 59 | + | display: flex; | |
| 60 | + | align-items: center; | |
| 61 | + | height: 100vh; | |
| 62 | + | padding: 8px 12px; | |
| 63 | + | } | |
| 64 | + | .embed-button { | |
| 65 | + | display: flex; | |
| 66 | + | align-items: center; | |
| 67 | + | gap: 10px; | |
| 68 | + | width: 100%; | |
| 69 | + | } | |
| 70 | + | .cover { width: 40px; height: 40px; border-radius: 4px; object-fit: cover; } | |
| 71 | + | .info { flex: 1; min-width: 0; } | |
| 72 | + | .title { | |
| 73 | + | font-size: 13px; font-weight: 600; | |
| 74 | + | white-space: nowrap; overflow: hidden; text-overflow: ellipsis; | |
| 75 | + | } | |
| 76 | + | .price { font-size: 12px; opacity: 0.7; font-family: 'IBM Plex Mono', monospace; } | |
| 77 | + | .buy-btn { | |
| 78 | + | background: #6c5ce7; color: #fff; | |
| 79 | + | border: none; border-radius: 4px; | |
| 80 | + | padding: 8px 14px; font-size: 12px; font-weight: 600; | |
| 81 | + | cursor: pointer; text-decoration: none; | |
| 82 | + | white-space: nowrap; | |
| 83 | + | } | |
| 84 | + | .buy-btn:hover { background: #5a4bd6; } | |
| 85 | + | </style> | |
| 86 | + | </head> | |
| 87 | + | <body> | |
| 88 | + | <div class="embed-button"> | |
| 89 | + | {% if let Some(url) = cover_image_url %} | |
| 90 | + | <img class="cover" src="{{ url }}" alt=""> | |
| 91 | + | {% endif %} | |
| 92 | + | <div class="info"> | |
| 93 | + | <div class="title">{{ title }}</div> | |
| 94 | + | <div class="price">{{ price_display }}</div> | |
| 95 | + | </div> | |
| 96 | + | <a class="buy-btn" href="{{ purchase_url }}" target="_blank" rel="noopener">Buy</a> | |
| 97 | + | </div> | |
| 98 | + | </body> | |
| 99 | + | </html> | |
| 100 | + | ``` | |
| 101 | + | ||
| 102 | + | ### 5. Price display logic | |
| 103 | + | ||
| 104 | + | - Free items: "Free" (button text becomes "Get") | |
| 105 | + | - Fixed price: "$X.XX" | |
| 106 | + | - PWYW with $0 min: "Name your price" (button: "Get") | |
| 107 | + | - PWYW with min > 0: "$X.XX+" | |
| 108 | + | ||
| 109 | + | ### 6. Register route | |
| 110 | + | ||
| 111 | + | In `src/routes/mod.rs`, add `.nest("/embed", embed::router())`. | |
| 112 | + | ||
| 113 | + | ### 7. Tests (`tests/embeds/button.rs`) | |
| 114 | + | ||
| 115 | + | - `embed_button_returns_html` — valid item returns 200 with text/html | |
| 116 | + | - `embed_button_private_item_404` — non-public items return 404 | |
| 117 | + | - `embed_button_nonexistent_404` — bad ID returns 404 | |
| 118 | + | - `embed_button_frame_headers` — response has `X-Frame-Options: ALLOWALL` and correct CSP | |
| 119 | + | - `embed_button_cache_headers` — response has `Cache-Control: public, max-age=300` | |
| 120 | + | - `other_routes_still_deny_frames` — normal pages still have `X-Frame-Options: DENY` | |
| 121 | + | ||
| 122 | + | ## Dependencies | |
| 123 | + | ||
| 124 | + | None — this is the foundation embed. No ffmpeg, no JS, no new DB columns. | |
| 125 | + | ||
| 126 | + | ## Estimated scope | |
| 127 | + | ||
| 128 | + | ~200 lines of Rust (route module + handler + registration), ~60 lines HTML template, ~80 lines tests. |
| @@ -0,0 +1,106 @@ | |||
| 1 | + | # Plan: Product Card Embed | |
| 2 | + | ||
| 3 | + | A richer embed showing cover art, title, creator name, price, description excerpt, and buy button. Supports vertical (~350x400px) and horizontal (~600x200px) layouts. | |
| 4 | + | ||
| 5 | + | ## Output | |
| 6 | + | ||
| 7 | + | `/embed/i/{item_id}/card?layout=vertical|horizontal` — a self-contained HTML page rendered inside an iframe. Clicking through opens MNW purchase page. | |
| 8 | + | ||
| 9 | + | ## Prerequisites | |
| 10 | + | ||
| 11 | + | - Buy button embed shipped (route module, CORS setup, frame header override all exist) | |
| 12 | + | ||
| 13 | + | ## Steps | |
| 14 | + | ||
| 15 | + | ### 1. Handler (`src/routes/embed/item.rs`) | |
| 16 | + | ||
| 17 | + | ```rust | |
| 18 | + | /// GET /embed/i/{item_id}/card | |
| 19 | + | pub async fn item_card( | |
| 20 | + | State(state): State<AppState>, | |
| 21 | + | Path(item_id): Path<ItemId>, | |
| 22 | + | Query(params): Query<CardParams>, | |
| 23 | + | ) -> Result<Response> | |
| 24 | + | ``` | |
| 25 | + | ||
| 26 | + | `CardParams`: `layout: Option<String>` (default "vertical", accepts "horizontal") | |
| 27 | + | ||
| 28 | + | Logic: | |
| 29 | + | - Fetch item (404 if not found/not public) | |
| 30 | + | - Fetch parent project + creator user (for creator display name, username) | |
| 31 | + | - Truncate description to ~120 chars for vertical, ~200 for horizontal | |
| 32 | + | - Render `templates/embeds/card.html` | |
| 33 | + | ||
| 34 | + | ### 2. Template (`templates/embeds/card.html`) | |
| 35 | + | ||
| 36 | + | Two layout variants in one template, switched with a CSS class on body. | |
| 37 | + | ||
| 38 | + | **Vertical layout** (~350x400px): | |
| 39 | + | ``` | |
| 40 | + | ┌─────────────────────────┐ | |
| 41 | + | │ Cover Image │ | |
| 42 | + | │ (200px tall) │ | |
| 43 | + | ├─────────────────────────┤ | |
| 44 | + | │ Title │ | |
| 45 | + | │ by @username │ | |
| 46 | + | │ Description excerpt... │ | |
| 47 | + | │ │ | |
| 48 | + | │ $12.00 [Buy] │ | |
| 49 | + | └─────────────────────────┘ | |
| 50 | + | ``` | |
| 51 | + | ||
| 52 | + | **Horizontal layout** (~600x200px): | |
| 53 | + | ``` | |
| 54 | + | ┌────────┬────────────────────────────────────┐ | |
| 55 | + | │ │ Title │ | |
| 56 | + | │ Cover │ by @username │ | |
| 57 | + | │ (150px)│ Description excerpt... │ | |
| 58 | + | │ │ │ | |
| 59 | + | │ │ $12.00 [Buy] │ | |
| 60 | + | └────────┴────────────────────────────────────┘ | |
| 61 | + | ``` | |
| 62 | + | ||
| 63 | + | Styling: | |
| 64 | + | - Cover image with `object-fit: cover`, rounded corners | |
| 65 | + | - Title in Lato semibold, 15px | |
| 66 | + | - Creator name in IBM Plex Mono, 12px, linked to profile (target=_blank) | |
| 67 | + | - Description in Lato regular, 13px, 2-3 lines max with ellipsis | |
| 68 | + | - Price in IBM Plex Mono | |
| 69 | + | - Buy button matches button embed style (violet #6c5ce7) | |
| 70 | + | - Subtle border: `1px solid rgba(61,53,48,0.1)` | |
| 71 | + | - Items with no cover: show a placeholder gradient or just collapse the image area | |
| 72 | + | ||
| 73 | + | ### 3. Context struct | |
| 74 | + | ||
| 75 | + | ```rust | |
| 76 | + | struct CardContext { | |
| 77 | + | title: String, | |
| 78 | + | creator_display_name: String, | |
| 79 | + | creator_username: String, | |
| 80 | + | profile_url: String, | |
| 81 | + | cover_image_url: Option<String>, | |
| 82 | + | price_display: String, | |
| 83 | + | description_excerpt: String, | |
| 84 | + | purchase_url: String, | |
| 85 | + | button_text: String, // "Buy" / "Get" / "Subscribe" | |
| 86 | + | layout: String, // "vertical" / "horizontal" | |
| 87 | + | item_type_label: String, // "Audio" / "Video" / "Text" / etc. | |
| 88 | + | } | |
| 89 | + | ``` | |
| 90 | + | ||
| 91 | + | ### 4. Register route | |
| 92 | + | ||
| 93 | + | Add `GET /embed/i/{item_id}/card` to the embed router. | |
| 94 | + | ||
| 95 | + | ### 5. Tests | |
| 96 | + | ||
| 97 | + | - `embed_card_vertical_default` — no layout param returns vertical | |
| 98 | + | - `embed_card_horizontal` — `?layout=horizontal` returns different dimensions hint | |
| 99 | + | - `embed_card_no_cover` — items without cover still render cleanly | |
| 100 | + | - `embed_card_long_description_truncated` — description doesn't overflow | |
| 101 | + | - `embed_card_free_item` — shows "Free" and "Get" button | |
| 102 | + | - `embed_card_private_404` | |
| 103 | + | ||
| 104 | + | ## Estimated scope | |
| 105 | + | ||
| 106 | + | ~80 lines handler, ~120 lines template (both layouts), ~60 lines tests. |
| @@ -0,0 +1,169 @@ | |||
| 1 | + | # Plan: Audio Preview Player Embed | |
| 2 | + | ||
| 3 | + | The highest-value embed for musician creators. Shows cover art, title, a 30-second preview with play/pause and progress bar, and a buy button. Audio plays directly in the embed without redirect. | |
| 4 | + | ||
| 5 | + | ## Output | |
| 6 | + | ||
| 7 | + | - `/embed/i/{item_id}/player` — HTML page (~350x120px) with inline audio player | |
| 8 | + | - `/embed/i/{item_id}/preview.mp3` — 30-second MP3 clip (128kbps) | |
| 9 | + | ||
| 10 | + | ## Prerequisites | |
| 11 | + | ||
| 12 | + | - Buy button embed shipped (route module, CORS, headers exist) | |
| 13 | + | - ffmpeg available on server (already installed for other media processing) | |
| 14 | + | ||
| 15 | + | ## Steps | |
| 16 | + | ||
| 17 | + | ### 1. Preview audio generation | |
| 18 | + | ||
| 19 | + | **On first request to `/embed/i/{item_id}/preview.mp3`:** | |
| 20 | + | ||
| 21 | + | Handler logic: | |
| 22 | + | 1. Check if item exists, is public, and is an audio item (404 otherwise) | |
| 23 | + | 2. Check S3 for cached preview: `{audio_s3_key}.preview.mp3` | |
| 24 | + | 3. If cached, redirect to presigned S3 URL (or serve via CDN) | |
| 25 | + | 4. If not cached: | |
| 26 | + | a. Download original audio from S3 to temp file | |
| 27 | + | b. Run ffmpeg: `ffmpeg -i input -ss {start} -t 30 -ab 128k -f mp3 output.mp3` | |
| 28 | + | c. Upload preview to S3 at `{audio_s3_key}.preview.mp3` | |
| 29 | + | d. Redirect to presigned URL of the new preview | |
| 30 | + | 5. Set `Cache-Control: public, max-age=86400` | |
| 31 | + | ||
| 32 | + | **Start offset**: Default 0 seconds. Future enhancement: `preview_start_seconds` column on items table lets creators choose. For v1, always start at 0. | |
| 33 | + | ||
| 34 | + | **Edge cases**: | |
| 35 | + | - Audio shorter than 30 seconds: use full duration | |
| 36 | + | - Item has no audio_s3_key: return 404 | |
| 37 | + | - ffmpeg fails: return 500, log error, don't cache | |
| 38 | + | ||
| 39 | + | ### 2. Player embed handler (`src/routes/embed/item.rs`) | |
| 40 | + | ||
| 41 | + | ```rust | |
| 42 | + | /// GET /embed/i/{item_id}/player | |
| 43 | + | pub async fn item_player( | |
| 44 | + | State(state): State<AppState>, | |
| 45 | + | Path(item_id): Path<ItemId>, | |
| 46 | + | ) -> Result<Response> | |
| 47 | + | ``` | |
| 48 | + | ||
| 49 | + | Logic: | |
| 50 | + | - Fetch item (404 if not public or not audio type) | |
| 51 | + | - Render `templates/embeds/player.html` with: title, creator name, cover_image_url, preview_url, purchase_url, price_display, duration_display (of full track) | |
| 52 | + | ||
| 53 | + | ### 3. Template (`templates/embeds/player.html`) | |
| 54 | + | ||
| 55 | + | Layout (~350x120px): | |
| 56 | + | ``` | |
| 57 | + | ┌──────┬──────────────────────────────────────┐ | |
| 58 | + | │ │ Title $9.99 │ | |
| 59 | + | │ Cover│ by @artist │ | |
| 60 | + | │ 80px │ [▶] ━━━━━━━━━━━━━━━━ 0:00/0:30 │ | |
| 61 | + | │ │ 30-sec preview [Buy] │ | |
| 62 | + | └──────┴──────────────────────────────────────┘ | |
| 63 | + | ``` | |
| 64 | + | ||
| 65 | + | **JavaScript (minimal, inline)**: | |
| 66 | + | ```javascript | |
| 67 | + | const audio = new Audio(); | |
| 68 | + | const playBtn = document.getElementById('play'); | |
| 69 | + | const progress = document.getElementById('progress'); | |
| 70 | + | const time = document.getElementById('time'); | |
| 71 | + | ||
| 72 | + | // Lazy-load audio on first play | |
| 73 | + | let loaded = false; | |
| 74 | + | playBtn.onclick = () => { | |
| 75 | + | if (!loaded) { | |
| 76 | + | audio.src = '{{ preview_url }}'; | |
| 77 | + | loaded = true; | |
| 78 | + | } | |
| 79 | + | if (audio.paused) { audio.play(); playBtn.textContent = '⏸'; } | |
| 80 | + | else { audio.pause(); playBtn.textContent = '▶'; } | |
| 81 | + | }; | |
| 82 | + | ||
| 83 | + | audio.ontimeupdate = () => { | |
| 84 | + | const pct = (audio.currentTime / audio.duration) * 100; | |
| 85 | + | progress.style.width = pct + '%'; | |
| 86 | + | time.textContent = fmt(audio.currentTime) + '/' + fmt(audio.duration); | |
| 87 | + | }; | |
| 88 | + | ||
| 89 | + | audio.onended = () => { playBtn.textContent = '▶'; }; | |
| 90 | + | ||
| 91 | + | // Click-to-seek on progress bar | |
| 92 | + | document.getElementById('progress-bar').onclick = (e) => { | |
| 93 | + | const rect = e.currentTarget.getBoundingClientRect(); | |
| 94 | + | audio.currentTime = ((e.clientX - rect.left) / rect.width) * audio.duration; | |
| 95 | + | }; | |
| 96 | + | ||
| 97 | + | function fmt(s) { | |
| 98 | + | const m = Math.floor(s/60), sec = Math.floor(s%60); | |
| 99 | + | return m + ':' + (sec < 10 ? '0' : '') + sec; | |
| 100 | + | } | |
| 101 | + | ``` | |
| 102 | + | ||
| 103 | + | **Styling**: | |
| 104 | + | - Play button: circular, violet background, white icon | |
| 105 | + | - Progress bar: thin horizontal bar, beige track, violet fill | |
| 106 | + | - Cover: 80x80px rounded | |
| 107 | + | - "30-sec preview" label in 10px muted text below progress bar | |
| 108 | + | - No volume control (keep it simple — 30 seconds, they can adjust system volume) | |
| 109 | + | ||
| 110 | + | ### 4. Preview audio handler (`src/routes/embed/item.rs`) | |
| 111 | + | ||
| 112 | + | ```rust | |
| 113 | + | /// GET /embed/i/{item_id}/preview.mp3 | |
| 114 | + | pub async fn item_preview_audio( | |
| 115 | + | State(state): State<AppState>, | |
| 116 | + | Path(item_id): Path<ItemId>, | |
| 117 | + | ) -> Result<Response> | |
| 118 | + | ``` | |
| 119 | + | ||
| 120 | + | Implementation: | |
| 121 | + | - Needs `tokio::process::Command` to shell out to ffmpeg | |
| 122 | + | - Temp file in `/tmp/mnw-previews/` (cleaned up after upload) | |
| 123 | + | - S3 operations for check/upload/presign | |
| 124 | + | ||
| 125 | + | ### 5. ffmpeg utility (`src/media/preview.rs` or `src/routes/embed/preview.rs`) | |
| 126 | + | ||
| 127 | + | ```rust | |
| 128 | + | pub async fn generate_preview( | |
| 129 | + | s3: &S3Client, | |
| 130 | + | audio_s3_key: &str, | |
| 131 | + | start_seconds: u32, | |
| 132 | + | duration_seconds: u32, // 30 | |
| 133 | + | ) -> Result<String> // returns S3 key of preview | |
| 134 | + | ``` | |
| 135 | + | ||
| 136 | + | Steps: | |
| 137 | + | 1. `s3.download_to_file(audio_s3_key, &tmp_input)` | |
| 138 | + | 2. Run ffmpeg command | |
| 139 | + | 3. `s3.upload_file(&tmp_output, &preview_key, "audio/mpeg")` | |
| 140 | + | 4. Clean up temp files | |
| 141 | + | 5. Return preview_key | |
| 142 | + | ||
| 143 | + | ### 6. Register routes | |
| 144 | + | ||
| 145 | + | ```rust | |
| 146 | + | .route("/embed/i/:item_id/player", get(item_player)) | |
| 147 | + | .route("/embed/i/:item_id/preview.mp3", get(item_preview_audio)) | |
| 148 | + | ``` | |
| 149 | + | ||
| 150 | + | ### 7. Tests | |
| 151 | + | ||
| 152 | + | - `embed_player_audio_item_200` — audio item returns player HTML | |
| 153 | + | - `embed_player_non_audio_404` — text/video items return 404 | |
| 154 | + | - `embed_player_private_404` | |
| 155 | + | - `embed_preview_generates_mp3` — mock ffmpeg, verify S3 upload | |
| 156 | + | - `embed_preview_serves_cached` — second request hits S3 cache, no ffmpeg | |
| 157 | + | - `embed_preview_short_audio` — audio < 30s uses full duration | |
| 158 | + | - `embed_preview_headers` — correct CORS, cache-control, content-type | |
| 159 | + | ||
| 160 | + | ## Performance considerations | |
| 161 | + | ||
| 162 | + | - Preview generation is expensive (~2-5s). First request for each item is slow. | |
| 163 | + | - After first request, preview is cached in S3 indefinitely (until source changes). | |
| 164 | + | - Could add a pre-generation step: when an audio item is published, queue preview generation in background. For v1, lazy generation on first request is simpler. | |
| 165 | + | - Rate-limit the preview endpoint to prevent abuse (generating previews for every item at once). | |
| 166 | + | ||
| 167 | + | ## Estimated scope | |
| 168 | + | ||
| 169 | + | ~150 lines handler code, ~100 lines ffmpeg utility, ~130 lines template (HTML+CSS+JS), ~100 lines tests. Most complex embed due to audio generation. |
| @@ -0,0 +1,62 @@ | |||
| 1 | + | # Plan: Tip Button Embed | |
| 2 | + | ||
| 3 | + | A minimal "Support this creator" button for embedding on personal websites, blogs, and portfolios. Shows creator avatar, display name, and a tip button. | |
| 4 | + | ||
| 5 | + | ## Output | |
| 6 | + | ||
| 7 | + | `/embed/u/{username}/tip` — a self-contained HTML page (~250x50px) with creator info and a support button linking to their tip page on MNW. | |
| 8 | + | ||
| 9 | + | ## Prerequisites | |
| 10 | + | ||
| 11 | + | - Buy button embed shipped (route module, CORS, headers exist) | |
| 12 | + | - Tips feature implemented (already live — `tips_enabled` on users) | |
| 13 | + | ||
| 14 | + | ## Steps | |
| 15 | + | ||
| 16 | + | ### 1. Handler (`src/routes/embed/user.rs`) | |
| 17 | + | ||
| 18 | + | ```rust | |
| 19 | + | /// GET /embed/u/{username}/tip | |
| 20 | + | pub async fn tip_button( | |
| 21 | + | State(state): State<AppState>, | |
| 22 | + | Path(username): Path<String>, | |
| 23 | + | ) -> Result<Response> | |
| 24 | + | ``` | |
| 25 | + | ||
| 26 | + | Logic: | |
| 27 | + | - Fetch user by username (404 if not found, deactivated, or suspended) | |
| 28 | + | - Check `tips_enabled` (404 if tips disabled — don't expose a broken widget) | |
| 29 | + | - Render `templates/embeds/tip_button.html` with: display_name, username, avatar_url, tip_url | |
| 30 | + | ||
| 31 | + | ### 2. Template (`templates/embeds/tip_button.html`) | |
| 32 | + | ||
| 33 | + | Layout (~250x50px): | |
| 34 | + | ``` | |
| 35 | + | ┌────┬─────────────────────┬─────────────┐ | |
| 36 | + | │ AV │ Support @username │ [Support] │ | |
| 37 | + | └────┴─────────────────────┴─────────────┘ | |
| 38 | + | ``` | |
| 39 | + | ||
| 40 | + | - Avatar: 32x32px circle | |
| 41 | + | - "Support @username" in Lato, 13px | |
| 42 | + | - Button: violet, "Support" text | |
| 43 | + | - Clicking button opens `/u/{username}/tip` in new tab | |
| 44 | + | - Clicking name opens `/u/{username}` in new tab | |
| 45 | + | ||
| 46 | + | ### 3. Register route | |
| 47 | + | ||
| 48 | + | ```rust | |
| 49 | + | .route("/embed/u/:username/tip", get(tip_button)) | |
| 50 | + | ``` | |
| 51 | + | ||
| 52 | + | ### 4. Tests | |
| 53 | + | ||
| 54 | + | - `embed_tip_button_200` — creator with tips enabled returns HTML | |
| 55 | + | - `embed_tip_button_tips_disabled_404` — creator without tips returns 404 | |
| 56 | + | - `embed_tip_button_nonexistent_user_404` | |
| 57 | + | - `embed_tip_button_suspended_404` | |
| 58 | + | - `embed_tip_button_frame_headers` | |
| 59 | + | ||
| 60 | + | ## Estimated scope | |
| 61 | + | ||
| 62 | + | ~50 lines handler, ~50 lines template, ~40 lines tests. Simplest embed after buy button. |
| @@ -0,0 +1,87 @@ | |||
| 1 | + | # Plan: Project Card Embed | |
| 2 | + | ||
| 3 | + | Shows a project's cover art, title, creator, item count, description excerpt, and a "View" button linking to the project page. | |
| 4 | + | ||
| 5 | + | ## Output | |
| 6 | + | ||
| 7 | + | `/embed/p/{project_slug}/card` — a self-contained HTML page (~350x300px vertical card) rendered in an iframe. | |
| 8 | + | ||
| 9 | + | ## Prerequisites | |
| 10 | + | ||
| 11 | + | - Product card embed shipped (template pattern and card styling established) | |
| 12 | + | ||
| 13 | + | ## Steps | |
| 14 | + | ||
| 15 | + | ### 1. Handler (`src/routes/embed/project.rs`) | |
| 16 | + | ||
| 17 | + | ```rust | |
| 18 | + | /// GET /embed/p/{project_slug}/card | |
| 19 | + | pub async fn project_card( | |
| 20 | + | State(state): State<AppState>, | |
| 21 | + | Path(project_slug): Path<String>, | |
| 22 | + | ) -> Result<Response> | |
| 23 | + | ``` | |
| 24 | + | ||
| 25 | + | Logic: | |
| 26 | + | - Fetch project by slug (404 if not found or not public) | |
| 27 | + | - Fetch creator user | |
| 28 | + | - Count public items in the project | |
| 29 | + | - Truncate description to ~150 chars | |
| 30 | + | - Render `templates/embeds/project_card.html` | |
| 31 | + | ||
| 32 | + | ### 2. Template (`templates/embeds/project_card.html`) | |
| 33 | + | ||
| 34 | + | Layout (~350x300px): | |
| 35 | + | ``` | |
| 36 | + | ┌─────────────────────────┐ | |
| 37 | + | │ Cover Image │ | |
| 38 | + | │ (160px tall) │ | |
| 39 | + | ├─────────────────────────┤ | |
| 40 | + | │ Project Title │ | |
| 41 | + | │ by @username │ | |
| 42 | + | │ 12 items · Audio │ | |
| 43 | + | │ Description excerpt... │ | |
| 44 | + | │ │ | |
| 45 | + | │ [View] │ | |
| 46 | + | └─────────────────────────┘ | |
| 47 | + | ``` | |
| 48 | + | ||
| 49 | + | Styling: | |
| 50 | + | - Matches product card visual language (same border, radius, fonts) | |
| 51 | + | - Item count + category shown as metadata line | |
| 52 | + | - "View" button instead of "Buy" (links to `/p/{slug}`, target=_blank) | |
| 53 | + | - No cover: collapse image area, card becomes shorter | |
| 54 | + | ||
| 55 | + | ### 3. Context struct | |
| 56 | + | ||
| 57 | + | ```rust | |
| 58 | + | struct ProjectCardContext { | |
| 59 | + | title: String, | |
| 60 | + | creator_display_name: String, | |
| 61 | + | creator_username: String, | |
| 62 | + | profile_url: String, | |
| 63 | + | cover_image_url: Option<String>, | |
| 64 | + | description_excerpt: String, | |
| 65 | + | project_url: String, | |
| 66 | + | item_count: i64, | |
| 67 | + | category_label: String, | |
| 68 | + | } | |
| 69 | + | ``` | |
| 70 | + | ||
| 71 | + | ### 4. Register route | |
| 72 | + | ||
| 73 | + | ```rust | |
| 74 | + | .route("/embed/p/:project_slug/card", get(project_card)) | |
| 75 | + | ``` | |
| 76 | + | ||
| 77 | + | ### 5. Tests | |
| 78 | + | ||
| 79 | + | - `embed_project_card_200` — public project renders card | |
| 80 | + | - `embed_project_card_private_404` | |
| 81 | + | - `embed_project_card_no_cover` — renders without image section | |
| 82 | + | - `embed_project_card_item_count` — shows correct count of public items only | |
| 83 | + | - `embed_project_card_frame_headers` | |
| 84 | + | ||
| 85 | + | ## Estimated scope | |
| 86 | + | ||
| 87 | + | ~60 lines handler, ~90 lines template, ~50 lines tests. |
| @@ -0,0 +1,176 @@ | |||
| 1 | + | # Plan: Embed Code Generator UI | |
| 2 | + | ||
| 3 | + | Add "Get embed code" sections to the creator dashboard so creators can copy embed snippets for their items, projects, and profile. | |
| 4 | + | ||
| 5 | + | ## Output | |
| 6 | + | ||
| 7 | + | Embed code sections in: | |
| 8 | + | - Item dashboard page — shows Buy Button, Product Card, and (if audio) Audio Player embed codes | |
| 9 | + | - Project dashboard page — shows Project Card embed code | |
| 10 | + | - Creator settings/profile — shows Tip Button embed code (if tips enabled) | |
| 11 | + | ||
| 12 | + | ## Prerequisites | |
| 13 | + | ||
| 14 | + | - All embed endpoints shipped and working | |
| 15 | + | - Templates rendering correctly | |
| 16 | + | ||
| 17 | + | ## Steps | |
| 18 | + | ||
| 19 | + | ### 1. Item dashboard embed section | |
| 20 | + | ||
| 21 | + | In `templates/pages/dashboard/item.html` (or the item edit page), add an "Embed" collapsible section below the existing content: | |
| 22 | + | ||
| 23 | + | ```html | |
| 24 | + | <details class="embed-section"> | |
| 25 | + | <summary>Embed codes</summary> | |
| 26 | + | <div class="embed-options"> | |
| 27 | + | <!-- Buy Button --> | |
| 28 | + | <div class="embed-option"> | |
| 29 | + | <h4>Buy Button</h4> | |
| 30 | + | <p>Minimal strip with title, price, and buy button. 300x60px.</p> | |
| 31 | + | <div class="embed-preview"> | |
| 32 | + | <iframe src="/embed/i/{{ item.id }}/button" width="300" height="60" frameborder="0"></iframe> | |
| 33 | + | </div> | |
| 34 | + | <div class="embed-code"> | |
| 35 | + | <code><iframe src="https://makenot.work/embed/i/{{ item.id }}/button" width="300" height="60" frameborder="0" title="Buy {{ item.title }} on Makenot.work"></iframe></code> | |
| 36 | + | <button onclick="copyEmbed(this)" class="copy-btn">Copy</button> | |
| 37 | + | </div> | |
| 38 | + | </div> | |
| 39 | + | ||
| 40 | + | <!-- Product Card --> | |
| 41 | + | <div class="embed-option"> | |
| 42 | + | <h4>Product Card</h4> | |
| 43 | + | <p>Rich card with cover art, description, and buy button.</p> | |
| 44 | + | <div class="embed-layout-toggle"> | |
| 45 | + | <label><input type="radio" name="card-layout" value="vertical" checked> Vertical (350x400)</label> | |
| 46 | + | <label><input type="radio" name="card-layout" value="horizontal"> Horizontal (600x200)</label> | |
| 47 | + | </div> | |
| 48 | + | <div class="embed-preview"> | |
| 49 | + | <iframe id="card-preview" src="/embed/i/{{ item.id }}/card" width="350" height="400" frameborder="0"></iframe> | |
| 50 | + | </div> | |
| 51 | + | <div class="embed-code"> | |
| 52 | + | <code id="card-code">...</code> | |
| 53 | + | <button onclick="copyEmbed(this)" class="copy-btn">Copy</button> | |
| 54 | + | </div> | |
| 55 | + | </div> | |
| 56 | + | ||
| 57 | + | <!-- Audio Player (only for audio items) --> | |
| 58 | + | {% if item.item_type == "audio" %} | |
| 59 | + | <div class="embed-option"> | |
| 60 | + | <h4>Audio Preview</h4> | |
| 61 | + | <p>30-second preview player with cover art and buy button. 350x120px.</p> | |
| 62 | + | <div class="embed-preview"> | |
| 63 | + | <iframe src="/embed/i/{{ item.id }}/player" width="350" height="120" frameborder="0"></iframe> | |
| 64 | + | </div> | |
| 65 | + | <div class="embed-code"> | |
| 66 | + | <code>...</code> | |
| 67 | + | <button onclick="copyEmbed(this)" class="copy-btn">Copy</button> | |
| 68 | + | </div> | |
| 69 | + | </div> | |
| 70 | + | {% endif %} | |
| 71 | + | </div> | |
| 72 | + | </details> | |
| 73 | + | ``` | |
| 74 | + | ||
| 75 | + | ### 2. Project dashboard embed section | |
| 76 | + | ||
| 77 | + | In the project edit/view page, add a similar collapsible: | |
| 78 | + | ||
| 79 | + | ```html | |
| 80 | + | <details class="embed-section"> | |
| 81 | + | <summary>Embed code</summary> | |
| 82 | + | <div class="embed-option"> | |
| 83 | + | <h4>Project Card</h4> | |
| 84 | + | <div class="embed-preview"> | |
| 85 | + | <iframe src="/embed/p/{{ project.slug }}/card" width="350" height="300" frameborder="0"></iframe> | |
| 86 | + | </div> | |
| 87 | + | <div class="embed-code"> | |
| 88 | + | <code>...</code> | |
| 89 | + | <button onclick="copyEmbed(this)" class="copy-btn">Copy</button> | |
| 90 | + | </div> | |
| 91 | + | </div> | |
| 92 | + | </details> | |
| 93 | + | ``` | |
| 94 | + | ||
| 95 | + | ### 3. Tip button in creator settings | |
| 96 | + | ||
| 97 | + | In the profile/settings page (where tips are configured), add: | |
| 98 | + | ||
| 99 | + | ```html | |
| 100 | + | {% if user.tips_enabled %} | |
| 101 | + | <details class="embed-section"> | |
| 102 | + | <summary>Tip button embed</summary> | |
| 103 | + | <div class="embed-preview"> | |
| 104 | + | <iframe src="/embed/u/{{ user.username }}/tip" width="250" height="50" frameborder="0"></iframe> | |
| 105 | + | </div> | |
| 106 | + | <div class="embed-code"> | |
| 107 | + | <code>...</code> | |
| 108 | + | <button onclick="copyEmbed(this)" class="copy-btn">Copy</button> | |
| 109 | + | </div> | |
| 110 | + | </details> | |
| 111 | + | {% endif %} | |
| 112 | + | ``` | |
| 113 | + | ||
| 114 | + | ### 4. Copy-to-clipboard JavaScript | |
| 115 | + | ||
| 116 | + | Small inline script (reusable across all embed sections): | |
| 117 | + | ||
| 118 | + | ```javascript | |
| 119 | + | function copyEmbed(btn) { | |
| 120 | + | const code = btn.previousElementSibling.textContent; | |
| 121 | + | navigator.clipboard.writeText(code).then(() => { | |
| 122 | + | btn.textContent = 'Copied!'; | |
| 123 | + | setTimeout(() => btn.textContent = 'Copy', 1500); | |
| 124 | + | }); | |
| 125 | + | } | |
| 126 | + | ``` | |
| 127 | + | ||
| 128 | + | ### 5. Layout toggle JavaScript (product card) | |
| 129 | + | ||
| 130 | + | ```javascript | |
| 131 | + | document.querySelectorAll('input[name="card-layout"]').forEach(radio => { | |
| 132 | + | radio.onchange = () => { | |
| 133 | + | const layout = radio.value; | |
| 134 | + | const iframe = document.getElementById('card-preview'); | |
| 135 | + | const code = document.getElementById('card-code'); | |
| 136 | + | const w = layout === 'horizontal' ? 600 : 350; | |
| 137 | + | const h = layout === 'horizontal' ? 200 : 400; | |
| 138 | + | iframe.src = `/embed/i/{{ item.id }}/card?layout=${layout}`; | |
| 139 | + | iframe.width = w; | |
| 140 | + | iframe.height = h; | |
| 141 | + | code.textContent = `<iframe src="https://makenot.work/embed/i/{{ item.id }}/card?layout=${layout}" width="${w}" height="${h}" frameborder="0" title="{{ item.title }} on Makenot.work"></iframe>`; | |
| 142 | + | }; | |
| 143 | + | }); | |
| 144 | + | ``` | |
| 145 | + | ||
| 146 | + | ### 6. Styling | |
| 147 | + | ||
| 148 | + | ```css | |
| 149 | + | .embed-section { margin-top: 1.5rem; } | |
| 150 | + | .embed-section summary { cursor: pointer; font-weight: 600; } | |
| 151 | + | .embed-option { margin: 1rem 0; padding: 1rem; border: 1px solid var(--border); border-radius: 6px; } | |
| 152 | + | .embed-option h4 { margin-bottom: 0.5rem; } | |
| 153 | + | .embed-preview { margin: 0.75rem 0; } | |
| 154 | + | .embed-code { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.75rem; } | |
| 155 | + | .embed-code code { | |
| 156 | + | flex: 1; padding: 0.5rem; background: var(--light-background); | |
| 157 | + | border-radius: 4px; font-size: 11px; overflow-x: auto; | |
| 158 | + | white-space: nowrap; font-family: 'IBM Plex Mono', monospace; | |
| 159 | + | } | |
| 160 | + | .copy-btn { | |
| 161 | + | padding: 6px 12px; font-size: 12px; | |
| 162 | + | background: var(--primary); color: #fff; border: none; | |
| 163 | + | border-radius: 4px; cursor: pointer; | |
| 164 | + | } | |
| 165 | + | ``` | |
| 166 | + | ||
| 167 | + | ## Implementation notes | |
| 168 | + | ||
| 169 | + | - The embed sections use `<details>` so they're collapsed by default (non-intrusive) | |
| 170 | + | - Live preview iframes load the actual embed so creators see exactly what fans will see | |
| 171 | + | - Only show embed section for public items/projects (no point embedding private content) | |
| 172 | + | - Item must be published for embed to render — show "Publish your item first" message if draft | |
| 173 | + | ||
| 174 | + | ## Estimated scope | |
| 175 | + | ||
| 176 | + | ~150 lines template additions across 3 pages, ~30 lines JS, ~40 lines CSS. |
| @@ -0,0 +1,73 @@ | |||
| 1 | + | # Creator Trust Audit — Findings & Fixes | |
| 2 | + | ||
| 3 | + | Audit date: 2026-04-26. Perspective: skeptical creator evaluating MNW. | |
| 4 | + | ||
| 5 | + | ## Deal Breakers | |
| 6 | + | ||
| 7 | + | - [x] **IP scrubbing bug**: `scheduler.rs` line 872 queries `downloaded_at` which doesn't exist (column is `created_at`). IPs never deleted. Violates privacy policy's 30-day claim. | |
| 8 | + | - [x] **Streaming tier sold but unimplemented**: $40/mo tier on `/creators` pricing table has zero code (no RTMP, no chat, no streaming). Blocked from purchase in checkout + labeled "coming soon" on pricing table. | |
| 9 | + | ||
| 10 | + | ## Trust Gaps | |
| 11 | + | ||
| 12 | + | - [x] **Encryption docs overstate implementation**: `tech/security.md` says "AES-256 disk encryption" without clarifying it's infrastructure-provided (Hetzner), not application-level. Misleading to security-conscious creators. | |
| 13 | + | - [x] **Streaming tier on creators page lacks "coming soon"**: Pricing table at `/creators` lists Streaming at $40 without any indication it's unimplemented. | |
| 14 | + | - [x] **Fan access lost on creator account deletion**: 90-day content grace period implemented. Creators with sales get deactivated (not deleted) for 90 days; buyers can still download. Guarantee language updated. | |
| 15 | + | - [x] **Git repos excluded from data export**: Non-issue. Git repos are inherently exportable (`git clone`). Not "uploaded content" in the same sense as media files. Export README already notes how to clone. | |
| 16 | + | - [x] **Video features in Big Files tier not functional**: Was actually implemented (upload, player, access control, tests). Docs were stale. Updated tiers.md — only transcoding/adaptive streaming remain planned. | |
| 17 | + | - [ ] **Content archive guarantee unimplemented**: 12-month content preservation listed under "Guarantees" (albeit in "Planned" subsection). Could be mistaken for current feature. | |
| 18 | + | ||
| 19 | + | ## Missing Information (docs additions needed) | |
| 20 | + | ||
| 21 | + | - [x] **Chargeback fee not documented**: Added dispute/chargeback section to payouts.md. | |
| 22 | + | - [ ] **Custom domains not prominently documented**: Feature exists but hard to find from getting-started flow. | |
| 23 | + | - [ ] **No creator storefront preview/demo**: First-time visitors can't see what a page looks like. | |
| 24 | + | - [x] **Sandbox not linked from /creators page**: Added "Try sandbox mode" link above the CTA. | |
| 25 | + | ||
| 26 | + | ## Competitive Weaknesses (product decisions, not bugs) | |
| 27 | + | ||
| 28 | + | - [ ] No free creator tier ($10/mo minimum vs. $0 on Bandcamp/itch.io/Gumroad) | |
| 29 | + | - [x] No public discovery/browse mechanism: Already implemented (/discover, /discover/tags, /feed, tag filters, search). Added discovery section to fan-guide.md. | |
| 30 | + | - [ ] No mobile fan apps | |
| 31 | + | - [x] Embeddable widgets — all 5 embed types + dashboard UI + guest checkout shipped | |
| 32 | + | - [ ] Low social proof (unknown creator count shown on page) | |
| 33 | + | ||
| 34 | + | ## Embeds (plans at `docs/plans/embed-*.md`) | |
| 35 | + | ||
| 36 | + | - [x] Guest checkout (embed-0) — complete: schema, paid checkout, free claim, buy page, purchase page UI, emails, auto-attach on signup, integration tests. | |
| 37 | + | - [x] Buy button embed (embed-1) — route, CORS, inline HTML, 300x60 strip | |
| 38 | + | - [x] Product card embed (embed-2) — vertical/horizontal layouts with cover, creator, description | |
| 39 | + | - [x] Audio player embed (embed-3) — inline player with play/pause/seek/progress (uses stream endpoint; ffmpeg preview generation deferred) | |
| 40 | + | - [x] Tip button embed (embed-4) — avatar + "Support @username" + button | |
| 41 | + | - [x] Project card embed (embed-5) — cover, title, creator, item count, category | |
| 42 | + | - [x] Dashboard UI (embed-6) — "Embed" tab on item dashboard with live previews, copy buttons, layout toggle, and direct purchase link | |
| 43 | + | ||
| 44 | + | ## Contradictions Found | |
| 45 | + | ||
| 46 | + | | Claim | Reality | Severity | Status | | |
| 47 | + | |-------|---------|----------|--------| | |
| 48 | + | | "IPs deleted after 30 days" (privacy policy) | Query references wrong column; never executes | High | Fixed | | |
| 49 | + | | "All uploaded content in original quality" (guarantees) | Git repos excluded from export | Medium | Non-issue (git clone) | | |
| 50 | + | | "AES-256 encryption at rest" (security.md) | Infrastructure-only, no app-level encryption | Medium | Fixed (docs) | | |
| 51 | + | | "Creators own fan relationships" (guarantees) | Account deletion CASCADE removes fan access | High | Fixed (90-day grace) | | |
| 52 | + | | Streaming tier $40/mo (creators page) | Zero implementation | High | Fixed (blocked + labeled) | | |
| 53 | + | ||
| 54 | + | ## Vaporware (all honestly labeled in docs) | |
| 55 | + | ||
| 56 | + | | Feature | Status | | |
| 57 | + | |---------|--------| | |
| 58 | + | | Live streaming (RTMP, chat, clips) | Not implemented | | |
| 59 | + | | Fan+ subscription | DB only, no UI | | |
| 60 | + | | Video transcoding/adaptive | Partial (upload works, playback missing) | | |
| 61 | + | | Content Archive (12-month preservation) | Not implemented | | |
| 62 | + | | Independent moderation appeals | Not implemented (one-person team) | | |
| 63 | + | | 99.9% uptime | Not implemented (currently 99.5% target) | | |
| 64 | + | ||
| 65 | + | ## Key Paths | |
| 66 | + | ||
| 67 | + | - `server/src/scheduler.rs` — IP scrubbing job | |
| 68 | + | - `server/site-docs/public/tech/security.md` — encryption claims | |
| 69 | + | - `server/site-docs/public/guide/tiers.md` — tier descriptions | |
| 70 | + | - `server/site-docs/public/about/guarantees.md` — binding commitments | |
| 71 | + | - `server/templates/pages/creators.html` — creator pricing table | |
| 72 | + | - `server/src/routes/stripe/` — payment implementation | |
| 73 | + | - `server/src/routes/api/export.rs` — export endpoints |
| @@ -0,0 +1,6 @@ | |||
| 1 | + | -- When a creator with purchased content deletes their account, we keep | |
| 2 | + | -- their items accessible for 90 days so buyers can download their files. | |
| 3 | + | -- After content_removal_at passes, the scheduler removes S3 objects and | |
| 4 | + | -- CASCADE-deletes the user row. | |
| 5 | + | ||
| 6 | + | ALTER TABLE users ADD COLUMN content_removal_at TIMESTAMPTZ; |
| @@ -0,0 +1,26 @@ | |||
| 1 | + | -- Guest checkout: allow purchases without an MNW account. | |
| 2 | + | -- buyer_id becomes nullable; guest purchases store the buyer's email from Stripe. | |
| 3 | + | -- download_token provides authenticated-less download links sent via email. | |
| 4 | + | -- claim_token allows attaching the purchase to an account later. | |
| 5 | + | ||
| 6 | + | ALTER TABLE transactions ALTER COLUMN buyer_id DROP NOT NULL; | |
| 7 | + | ||
| 8 | + | ALTER TABLE transactions ADD COLUMN guest_email VARCHAR(255); | |
| 9 | + | ALTER TABLE transactions ADD COLUMN claim_token UUID; | |
| 10 | + | ALTER TABLE transactions ADD COLUMN claimed_by UUID REFERENCES users(id) ON DELETE SET NULL; | |
| 11 | + | ALTER TABLE transactions ADD COLUMN download_token UUID DEFAULT gen_random_uuid(); | |
| 12 | + | ||
| 13 | + | -- Look up transactions by claim token (one-time claim flow). | |
| 14 | + | CREATE INDEX idx_transactions_claim_token ON transactions(claim_token) WHERE claim_token IS NOT NULL; | |
| 15 | + | ||
| 16 | + | -- Look up guest purchases by email (auto-attach on signup). | |
| 17 | + | CREATE INDEX idx_transactions_guest_email ON transactions(guest_email) WHERE guest_email IS NOT NULL; | |
| 18 | + | ||
| 19 | + | -- Look up transactions by download token (email download links). | |
| 20 | + | CREATE UNIQUE INDEX idx_transactions_download_token ON transactions(download_token) WHERE download_token IS NOT NULL; | |
| 21 | + | ||
| 22 | + | -- The existing partial unique index on (buyer_id, item_id) WHERE status = 'pending' | |
| 23 | + | -- still works for logged-in purchases. Add a separate one for guest deduplication. | |
| 24 | + | CREATE UNIQUE INDEX idx_transactions_pending_guest_item | |
| 25 | + | ON transactions(guest_email, item_id) | |
| 26 | + | WHERE status = 'pending' AND guest_email IS NOT NULL; |
| @@ -46,7 +46,20 @@ Exports include: | |||
| 46 | 46 | ||
| 47 | 47 | When fans subscribe or follow you, they can share contact information directly with you. We facilitate the connection; you own it. | |
| 48 | 48 | ||
| 49 | - | If you leave, your fans come with you. | |
| 49 | + | If you leave, your fan contact list is yours (export it anytime). See Buyer Access below for what happens to purchased content. | |
| 50 | + | ||
| 51 | + | --- | |
| 52 | + | ||
| 53 | + | ## Buyer Access | |
| 54 | + | ||
| 55 | + | **Guarantee:** If a creator deletes their account, fans who purchased content retain download access for 90 days. | |
| 56 | + | ||
| 57 | + | - Purchased items remain accessible for 90 days after the creator leaves. | |
| 58 | + | - After 90 days, content is removed from our servers. | |
| 59 | + | - Buyers are notified when the grace period begins so they can download their files. | |
| 60 | + | - Transaction records are preserved indefinitely (receipts remain valid). | |
| 61 | + | ||
| 62 | + | This gives buyers a reasonable window to retrieve content they paid for, while still allowing creators to fully remove their data from the platform. | |
| 50 | 63 | ||
| 51 | 64 | --- | |
| 52 | 65 |
| @@ -133,7 +133,7 @@ After your first publish, here's what to focus on: | |||
| 133 | 133 | ||
| 134 | 134 | 1. **Fill out your profile.** Bio, avatar, header image, links. This is your storefront. See [Profile](./profile.md). | |
| 135 | 135 | 2. **Set up security.** Enable two-factor authentication and save your backup codes. See [Security](../tech/security.md). | |
| 136 | - | 3. **Share your link.** Post your profile URL or project URL wherever your audience is. | |
| 136 | + | 3. **Share your link.** Post your profile URL, project URL, or direct purchase link (`/buy/{item_id}`) wherever your audience is. Direct purchase links are minimal, focused pages optimized for social media and link-in-bio — fans can buy in one step without an account. | |
| 137 | 137 | 4. **Set up RSS cross-posting.** Connect your RSS feed to social media or newsletter tools. See [RSS](./rss.md). | |
| 138 | 138 | 5. **Fill in metadata.** Good titles, descriptions, tags, and cover art make your content discoverable and shareable. See [Metadata](./metadata.md). Per-file size limits and supported formats depend on your tier — see [Pricing Tiers](./tiers.md) for specifics. | |
| 139 | 139 | 6. **Join the forum.** Say hello at [forums.makenot.work](https://forums.makenot.work). It's where platform feedback, feature requests, and creator-to-creator discussion happen. |
| @@ -47,6 +47,34 @@ Generate single-use codes for free access: | |||
| 47 | 47 | - **Optional expiration** — Codes expire after a date | |
| 48 | 48 | - **Useful for** — Press copies, review access, promotional giveaways | |
| 49 | 49 | ||
| 50 | + | ## Guest Checkout | |
| 51 | + | ||
| 52 | + | Fans can purchase your content without creating an account. This removes the biggest friction point between "I want this" and "I have this." | |
| 53 | + | ||
| 54 | + | **How it works for fans:** | |
| 55 | + | 1. Fan clicks "Buy Now" on your item page, embed, or direct purchase link | |
| 56 | + | 2. Stripe checkout collects their email and payment | |
| 57 | + | 3. Fan receives a download link via email immediately | |
| 58 | + | 4. Optionally, they can create an account later to manage all purchases in one place | |
| 59 | + | ||
| 60 | + | **What this means for you:** | |
| 61 | + | - Higher conversion rates — no sign-up barrier between fan and purchase | |
| 62 | + | - Fans who buy first and create accounts later have all purchases automatically attached | |
| 63 | + | - Works everywhere: your item pages, embedded buy buttons, and direct purchase links | |
| 64 | + | ||
| 65 | + | Guest checkout is the default for logged-out visitors. Fans with accounts still see the normal purchase flow with library integration. | |
| 66 | + | ||
| 67 | + | ## Direct Purchase Links | |
| 68 | + | ||
| 69 | + | Every item has a dedicated purchase page at `/buy/{item_id}` — a clean, focused page with no navigation chrome. Use it for: | |
| 70 | + | ||
| 71 | + | - **Link-in-bio** — A single purchase link on Instagram, TikTok, Twitter, etc. | |
| 72 | + | - **Social media posts** — Share a direct "buy here" link | |
| 73 | + | - **Email newsletters** — Link directly to checkout | |
| 74 | + | - **QR codes** — Print on physical media (posters, merch, liner notes) | |
| 75 | + | ||
| 76 | + | The page shows your item's cover art, title, price, and a "Buy Now" button. Fans purchase in one step without navigating the rest of the platform. | |
| 77 | + | ||
| 50 | 78 | ## Payment Flow | |
| 51 | 79 | ||
| 52 | 80 | All payments go through the payment processor: |
| @@ -99,6 +99,18 @@ When fans purchase from you and opt in to share their email, you get a direct li | |||
| 99 | 99 | ||
| 100 | 100 | See [Contact Sharing](./contact-sharing.md) for the full details. | |
| 101 | 101 | ||
| 102 | + | ### Direct Purchase Links | |
| 103 | + | ||
| 104 | + | Every item gets a dedicated purchase link at `/buy/{item_id}` — a clean page with just the cover, title, price, and a buy button. No navigation, no distractions. | |
| 105 | + | ||
| 106 | + | Use these for: | |
| 107 | + | - Link-in-bio (Instagram, TikTok, Twitter) | |
| 108 | + | - Newsletter CTAs | |
| 109 | + | - Social media posts ("new release — buy here") | |
| 110 | + | - QR codes on physical media | |
| 111 | + | ||
| 112 | + | Fans can purchase in one click without creating an account. Guest checkout handles everything — they enter payment details via Stripe and receive a download link by email. Zero friction between "I want this" and "I have this." | |
| 113 | + | ||
| 102 | 114 | ## Practical Tips | |
| 103 | 115 | ||
| 104 | 116 | ### Set Expectations |
| @@ -6,7 +6,7 @@ Everything you need to know about supporting creators and managing your library | |||
| 6 | 6 | ||
| 7 | 7 | When you buy something here, the creator gets almost everything you paid. The platform takes 0% — the only fee is ~3% from payment processing, and that goes to the processor, not us. | |
| 8 | 8 | ||
| 9 | - | There's no algorithm deciding what you see. No ads. No tracking cookies following you around the internet. You find creators through search, links, or recommendations — the same way you find anything worth finding. | |
| 9 | + | There's no algorithm deciding what you see. No ads. No tracking cookies following you around the internet. You find creators through [search and browsing](/discover), direct links, or recommendations from people you trust. | |
| 10 | 10 | ||
| 11 | 11 | Every item on the platform is classified as Handmade, Assisted, or Generated based on generative AI use. You can filter to see only Handmade content, only human-led work, or everything. See the [Generative AI Policy](../about/generative-ai.md) for what these tiers mean. | |
| 12 | 12 | ||
| @@ -21,6 +21,18 @@ Your purchases are permanent and downloadable. No DRM, no streaming-only restric | |||
| 21 | 21 | ||
| 22 | 22 | This means creators have no incentive to upsell you on platform features. What you pay goes to the person who made the thing. | |
| 23 | 23 | ||
| 24 | + | ### Guest Checkout (No Account Required) | |
| 25 | + | ||
| 26 | + | You can buy content without creating an account: | |
| 27 | + | ||
| 28 | + | 1. Click "Buy Now" on any item page or direct purchase link | |
| 29 | + | 2. Complete payment via the secure checkout (Stripe collects your email) | |
| 30 | + | 3. Receive a download link via email immediately | |
| 31 | + | ||
| 32 | + | No sign-up, no password, no friction. If you later create an account with the same email, all your prior purchases appear in your library automatically. | |
| 33 | + | ||
| 34 | + | Free items work the same way — enter your email and receive a download link. | |
| 35 | + | ||
| 24 | 36 | ## Ways to Support | |
| 25 | 37 | ||
| 26 | 38 | ### One-Time Purchases | |
| @@ -49,6 +61,33 @@ Some creators let you choose your price: | |||
| 49 | 61 | - Minimum is sometimes $0 (free with optional tip) | |
| 50 | 62 | - The full amount above processing fees goes to the creator | |
| 51 | 63 | ||
| 64 | + | ## Finding Content | |
| 65 | + | ||
| 66 | + | ### Discover Page | |
| 67 | + | ||
| 68 | + | The [discover page](/discover) is the main way to browse content on the platform: | |
| 69 | + | ||
| 70 | + | - **Search** — Full-text fuzzy search across all public items and projects | |
| 71 | + | - **Filter by type** — Audio, video, digital, text (with item counts per type) | |
| 72 | + | - **Filter by tag** — Hierarchical tags let you drill down (e.g., Electronic > Ambient > Dark Ambient) | |
| 73 | + | - **Filter by price** — Free, price ranges, or custom min/max | |
| 74 | + | - **Filter by AI tier** — Show only Handmade, Assisted, or everything | |
| 75 | + | - **Sort** — Newest, most sold, price ascending/descending | |
| 76 | + | ||
| 77 | + | Switch between Items and Projects views. Results update in real-time as you adjust filters. | |
| 78 | + | ||
| 79 | + | ### Tag Browser | |
| 80 | + | ||
| 81 | + | The [tag tree](/discover/tags) lets you browse the full tag hierarchy visually. Click into a category to see subcategories and tagged items. | |
| 82 | + | ||
| 83 | + | ### Your Feed | |
| 84 | + | ||
| 85 | + | Once you follow creators, projects, or tags, your [feed](/feed) shows new items from everything you follow — a personalized timeline without algorithmic ranking. | |
| 86 | + | ||
| 87 | + | ### Following | |
| 88 | + | ||
| 89 | + | Follow a creator or project to see their new items in your feed. Follow a tag to see all new items in that category. No notifications by default — just your feed. | |
| 90 | + | ||
| 52 | 91 | ## Your Library | |
| 53 | 92 | ||
| 54 | 93 | Your library at `/library` is where everything you've bought, subscribed to, or claimed lives. | |
| @@ -96,9 +135,9 @@ An account lets you: | |||
| 96 | 135 | ||
| 97 | 136 | ## If a Creator Leaves | |
| 98 | 137 | ||
| 99 | - | Your purchases stay in your library. Download links keep working. Files aren't deleted because the creator closed their account. | |
| 138 | + | When a creator deletes their account, your purchased content remains accessible for 90 days. Download your files during this window — after 90 days, content is removed from our servers. Transaction records and receipts are preserved indefinitely. | |
| 100 | 139 | ||
| 101 | - | This is a core commitment. See our [portability guarantee](../about/how-we-work.md). | |
| 140 | + | See our [buyer access guarantee](../about/guarantees.md#buyer-access). | |
| 102 | 141 | ||
| 103 | 142 | ## If We Shut Down | |
| 104 | 143 |
| @@ -74,6 +74,18 @@ If a fan requests a refund and you approve: | |||
| 74 | 74 | ||
| 75 | 75 | You have full control over refund decisions. | |
| 76 | 76 | ||
| 77 | + | ## Chargebacks (Disputes) | |
| 78 | + | ||
| 79 | + | If a fan disputes a charge with their bank instead of requesting a refund from you: | |
| 80 | + | ||
| 81 | + | - Stripe charges a **$15 dispute fee** regardless of outcome | |
| 82 | + | - The disputed amount is held until the dispute is resolved (typically 60-90 days) | |
| 83 | + | - You can submit evidence (proof of delivery, communication) through your Stripe dashboard | |
| 84 | + | - If you win the dispute, the held funds are released back to you; the $15 fee is also refunded | |
| 85 | + | - If you lose, the fan is refunded and you absorb both the sale amount and the $15 fee | |
| 86 | + | ||
| 87 | + | Chargebacks are rare for digital content but happen occasionally. Clear product descriptions, prompt communication, and a visible refund policy on your profile reduce dispute rates. | |
| 88 | + | ||
| 77 | 89 | ## See Also | |
| 78 | 90 | ||
| 79 | 91 | - [Analytics & Dashboard](./analytics.md) — Revenue charts and transaction history |
| @@ -36,13 +36,15 @@ These are suggestions. Price based on value, not convention. | |||
| 36 | 36 | ||
| 37 | 37 | ### Purchase Flow | |
| 38 | 38 | ||
| 39 | - | 1. Fan clicks "Buy" | |
| 40 | - | 2. Checkout shows price + payment methods | |
| 39 | + | 1. Fan clicks "Buy" (on item page, direct purchase link, or embed) | |
| 40 | + | 2. Checkout shows price + payment methods (no account required) | |
| 41 | 41 | 3. Payment processes instantly | |
| 42 | - | 4. Fan gets immediate access + download links | |
| 42 | + | 4. Fan receives download link via email immediately | |
| 43 | 43 | 5. Receipt emails to fan | |
| 44 | 44 | 6. Funds appear in your payment account | |
| 45 | 45 | ||
| 46 | + | Fans can purchase without creating an account — guest checkout collects only an email via the payment processor. If they create an account later with the same email, all prior purchases attach to their library automatically. | |
| 47 | + | ||
| 46 | 48 | --- | |
| 47 | 49 | ||
| 48 | 50 | ## Pay-What-You-Want |
| @@ -70,20 +70,16 @@ For musicians, podcasters, sound designers, indie developers, and plugin makers. | |||
| 70 | 70 | ||
| 71 | 71 | For game developers, educators, course creators, and anyone producing large content. Up to 20GB per file. | |
| 72 | 72 | ||
| 73 | - | *Video hosting and transcoding are planned but not yet implemented. Digital file downloads of any size (up to 20GB per file) are available now.* | |
| 74 | - | ||
| 75 | 73 | ### What You Get (in addition to Small Files) | |
| 76 | 74 | ||
| 77 | - | - Video uploads up to 4K resolution (planned) | |
| 78 | - | - Automatic transcoding: 360p, 720p, 1080p, 4K (planned) | |
| 79 | - | - Adaptive streaming (planned) | |
| 80 | - | - Thumbnail generation and custom thumbnails (planned) | |
| 75 | + | - Video uploads (MP4, WebM, MOV) up to 20GB per file | |
| 76 | + | - In-browser video player with access control | |
| 81 | 77 | - Subscriber-only and pay-per-view videos | |
| 82 | 78 | - Video series and playlists | |
| 83 | - | - Large binary downloads up to 20GB per file (available now) | |
| 79 | + | - Large binary downloads up to 20GB per file | |
| 84 | 80 | - Per-file size increase available on request for files over 20GB | |
| 85 | - | ||
| 86 | - | **Video formats (planned):** MP4, MOV, AVI, MKV, WebM | |
| 81 | + | - Automatic transcoding and adaptive streaming (planned) | |
| 82 | + | - Thumbnail generation (planned) | |
| 87 | 83 | ||
| 88 | 84 | ### Storage | |
| 89 | 85 |
| @@ -42,11 +42,11 @@ HSTS enabled. Certificate transparency logging. | |||
| 42 | 42 | ||
| 43 | 43 | | Data | Protection | | |
| 44 | 44 | |------|------------| | |
| 45 | - | | Database | AES-256 disk encryption | | |
| 46 | - | | Object storage | AES-256 server-side encryption | | |
| 47 | - | | Backups | AES-256 with separate keys | | |
| 45 | + | | Database | AES-256 disk encryption (infrastructure-provided by hosting provider) | | |
| 46 | + | | Object storage | AES-256 server-side encryption (infrastructure-provided by storage provider) | | |
| 47 | + | | Backups | AES-256 disk encryption (infrastructure-provided) | | |
| 48 | 48 | ||
| 49 | - | We encrypt storage. This protects against physical theft and infrastructure compromise. | |
| 49 | + | Encryption at rest is provided at the infrastructure level by our hosting and storage providers (Hetzner). This protects against physical theft and infrastructure compromise. There is no additional application-level encryption layer — the platform has server-side access to stored data (necessary for indexing, search, and content delivery). | |
| 50 | 50 | ||
| 51 | 51 | --- | |
| 52 | 52 |