Changeset c3d12a6 in Klonkt


Ignore:
Timestamp:
07/21/2026 02:07:18 AM (7 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
9019a3e
Parents:
603d246
git-author:
Robin <roboburr@…> (07/21/2026 02:07:07 AM)
git-committer:
Robin <roboburr@…> (07/21/2026 02:07:18 AM)
Message:

Feature: a way to actually become a supporter, and a styled expired page

Bart's test surfaced two gaps in the visitor flow:

  • After "Allow" on Patreon, a non-supporter landed on "Nog geen supporter" with no way to actually pledge. The page now shows a primary "Word supporter op Patreon" button. The site owner sets their public Patreon page in Beheer -> Betaalde posts (new field); it's also linked from the gate itself ("Nog geen supporter? Word het op Patreon").
  • The invalid/expired callback replied with a raw res.send() plain-text line. It now renders the normal paid-result page (reason 'expired').

Changed files:
src/config/database.js

  • paid_patreon.patreon_url column (additive)

src/services/PaidPatreonService.js

  • patreonUrl in config/status/save (undefined keeps, empty clears) + patreonUrl(siteId) helper

src/routes/admin-paid.js

  • save patreon_url from the form

src/views/pages/admin-paid.ejs

  • "Openbare Patreon-pagina" field

src/routes/paid.js

  • expired callback renders paid-result; pass patronUrl to the result pages

src/routes/posts.js

  • pass pgPatronUrl to the gate

src/views/pages/paid-gate.ejs

  • "Word supporter" join line under the unlock button

src/views/pages/paid-result.ejs

  • "Word supporter op Patreon" primary button, 'expired' reason, ghost back button always has a label

test/paid-patron.test.js

  • patreonUrl set/keep/clear semantics

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

Files:
9 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    r603d246 rc3d12a6  
    469469  ensureColumn('posts', 'paid', 'INTEGER DEFAULT 0');        // paid post (klonkt-demo-aki)
    470470  ensureColumn('posts', 'paid_min_cents', 'INTEGER');        // required support; null = owner default
     471  ensureColumn('paid_patreon', 'patreon_url', 'TEXT');       // owner's public Patreon page → "Word supporter" link (klonkt-demo-aki)
    471472  ensureColumn('ap_outbox', 'visibility', 'TEXT');  // 'direct' = private mention, never Public (shaer-tqc)
    472473  ensureColumn('ap_outbox', 'to_actors', 'TEXT');   // JSON array of recipient actor URIs for direct notes
  • src/routes/admin-paid.js

    r603d246 rc3d12a6  
    6060      accessToken: (b.access_token || '').trim() || undefined,
    6161      refreshToken: (b.refresh_token || '').trim() || undefined,
     62      // Empty clears it (null), a value sets it. Unlike secrets, this is not
     63      // sensitive and there's a clear "remove the link" intent.
     64      patreonUrl: (b.patreon_url || '').trim() || null,
    6265      defaultMinCents: Number.isFinite(cents) ? cents : undefined,
    6366    });
  • src/routes/paid.js

    r603d246 rc3d12a6  
    5454  const payload = verifyBlob(String(req.query.state || ''));
    5555  if (!payload || payload.purpose !== 'patron' || payload.siteId !== r.site.id) {
    56     return res.status(400).send('Ongeldige of verlopen aanvraag. Probeer opnieuw vanaf de post.');
     56    return renderPage(req, res, 'pages/paid-result', {
     57      pageTitle: 'Ontgrendelen', bodyClass: 'on-special', ok: false, reason: 'expired',
     58    });
    5759  }
     60  const patronUrl = PaidPatreon.patreonUrl(r.site.id);
    5861  const code = String(req.query.code || '');
    5962  if (req.query.error || !code) {
    60     return renderPage(req, res, 'pages/paid-result', { pageTitle: 'Ontgrendelen', bodyClass: 'on-special', ok: false, reason: 'declined', postSlug: payload.post });
     63    return renderPage(req, res, 'pages/paid-result', { pageTitle: 'Ontgrendelen', bodyClass: 'on-special', ok: false, reason: 'declined', postSlug: payload.post, patronUrl });
    6164  }
    6265  const membership = await PaidPatreon.verifyPatron(r.site.id, code, baseUrl(req) + '/paid/callback').catch(() => null);
     
    6669    return renderPage(req, res, 'pages/paid-result', {
    6770      pageTitle: 'Ontgrendelen', bodyClass: 'on-special', ok: false,
    68       reason: active ? 'tier' : 'notpatron', neededCents: payload.cents, haveCents: cents, postSlug: payload.post,
     71      reason: active ? 'tier' : 'notpatron', neededCents: payload.cents, haveCents: cents, postSlug: payload.post, patronUrl,
    6972    });
    7073  }
  • src/routes/posts.js

    r603d246 rc3d12a6  
    2121import ActivityPubService from '../services/ActivityPubService.js';
    2222import { premiumUnlocked } from '../services/PatreonService.js';
    23 import { defaultMinCents as paidDefaultMinCents } from '../services/PaidPatreonService.js';
     23import { defaultMinCents as paidDefaultMinCents, patreonUrl as paidPatronUrl } from '../services/PaidPatreonService.js';
    2424import MusicMeta from '../services/MusicMeta.js';
    2525
     
    12471247      pgCents: post.paid_min_cents || paidDefaultMinCents(site.id),
    12481248      pgSlug: post.slug,
     1249      pgPatronUrl: paidPatronUrl(site.id),
    12491250      newerPost,
    12501251      olderPost,
  • src/services/PaidPatreonService.js

    r603d246 rc3d12a6  
    2121    tokenExp: row.token_exp || 0,
    2222    defaultMinCents: row.default_min_cents || 0,
     23    patreonUrl: row.patreon_url || null,
    2324  };
    2425}
     
    3637    tokenExp: c.tokenExp || 0,
    3738    hasSecret: !!c.clientSecret,
     39    patreonUrl: c.patreonUrl || null,
    3840  };
     41}
     42
     43// The owner's public Patreon page, for the "Word supporter" link. Null when unset.
     44export function patreonUrl(siteId) {
     45  const c = getOwnerConfig(siteId);
     46  return c && c.patreonUrl ? c.patreonUrl : null;
    3947}
    4048
     
    5260    tokenExp: patch.tokenExp ?? cur.tokenExp ?? 0,
    5361    defaultMinCents: patch.defaultMinCents ?? cur.defaultMinCents ?? 0,
     62    // undefined = keep (e.g. token refresh doesn't touch it); null/'' = clear.
     63    patreonUrl: patch.patreonUrl !== undefined ? (patch.patreonUrl || null) : (cur.patreonUrl ?? null),
    5464  };
    5565  db.prepare(`INSERT INTO paid_patreon
    56       (site_id, client_id, client_secret_enc, campaign_id, access_token_enc, refresh_token_enc, token_exp, default_min_cents, updated_at)
    57       VALUES (?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)
     66      (site_id, client_id, client_secret_enc, campaign_id, access_token_enc, refresh_token_enc, token_exp, default_min_cents, patreon_url, updated_at)
     67      VALUES (?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)
    5868    ON CONFLICT(site_id) DO UPDATE SET
    5969      client_id=excluded.client_id, client_secret_enc=excluded.client_secret_enc,
    6070      campaign_id=excluded.campaign_id, access_token_enc=excluded.access_token_enc,
    6171      refresh_token_enc=excluded.refresh_token_enc, token_exp=excluded.token_exp,
    62       default_min_cents=excluded.default_min_cents, updated_at=CURRENT_TIMESTAMP`)
     72      default_min_cents=excluded.default_min_cents, patreon_url=excluded.patreon_url, updated_at=CURRENT_TIMESTAMP`)
    6373    .run(
    6474      siteId,
     
    7080      merged.tokenExp || 0,
    7181      Math.max(0, parseInt(merged.defaultMinCents, 10) || 0),
     82      merged.patreonUrl || null,
    7283    );
    7384}
     
    171182export default {
    172183  getOwnerConfig, ownerStatus, saveOwnerConfig, disconnect,
    173   defaultMinCents, needsRefresh, refreshCreatorToken, creatorAccessToken,
     184  defaultMinCents, patreonUrl, needsRefresh, refreshCreatorToken, creatorAccessToken,
    174185  pickCampaignMembership, verifyPatron,
    175186};
  • src/views/pages/admin-paid.ejs

    r603d246 rc3d12a6  
    4747      <input type="text" name="campaign_id" value="<%= status.campaignId || '' %>" autocomplete="off" style="width:100%">
    4848    </label>
     49    <label>Openbare Patreon-pagina
     50      <input type="url" name="patreon_url" value="<%= status.patreonUrl || '' %>" placeholder="https://www.patreon.com/jouwnaam" autocomplete="off" style="width:100%">
     51      <small style="color:var(--ink-soft,#888)">De link waar bezoekers supporter kunnen worden. Getoond als "Word supporter" wanneer iemand nog niet doneert.</small>
     52    </label>
    4953    <label>Creator access token
    5054      <input type="password" name="access_token" placeholder="blijft ongewijzigd" autocomplete="off" style="width:100%">
  • src/views/pages/paid-gate.ejs

    r603d246 rc3d12a6  
    2020    <button type="button" id="pg-unlock" class="pg-btn">Ontgrendelen</button>
    2121    <p id="pg-status" class="pg-status" hidden></p>
     22    <% if (typeof pgPatronUrl !== 'undefined' && pgPatronUrl) { %>
     23      <p class="pg-join">Nog geen supporter? <a href="<%= pgPatronUrl %>" target="_blank" rel="noopener">Word het op Patreon</a> en ontgrendel daarna met je passkey.</p>
     24    <% } %>
    2225  </section>
    2326</article>
     
    8891  .pg-status { margin: 12px 0 0; opacity: .9; }
    8992  .pg-status.is-err { color: #c0392b; }
     93  .pg-join { margin: 14px 0 0; font-size: .92rem; opacity: .8; }
    9094</style>
  • src/views/pages/paid-result.ejs

    r603d246 rc3d12a6  
     1<%
     2  var _slug = (typeof postSlug !== 'undefined' && postSlug) ? postSlug : '';
     3  var _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
     4  var _patron = (typeof patronUrl !== 'undefined' && patronUrl) ? patronUrl : '';
     5%>
    16<section class="pr">
    27  <div class="pr-card">
     
    49    <% if (reason === 'notpatron') { %>
    510      <h1 class="pr-h1">Nog geen supporter</h1>
    6       <p class="pr-sub">Je bent (nog) geen actieve supporter van deze site op Patreon. Word supporter en probeer het opnieuw.</p>
     11      <p class="pr-sub">Je bent (nog) geen actieve supporter van deze site op Patreon. Word supporter en probeer het daarna opnieuw vanaf de post.</p>
    712    <% } else if (reason === 'tier') { %>
    813      <h1 class="pr-h1">Een niveau hoger nodig</h1>
     
    1116        Jouw steun is nu &euro;<%= ((typeof haveCents !== 'undefined' ? haveCents : 0)/100).toFixed(2) %>. Verhoog je steun en probeer opnieuw.
    1217      </p>
     18    <% } else if (reason === 'expired') { %>
     19      <h1 class="pr-h1">Aanvraag verlopen</h1>
     20      <p class="pr-sub">Deze ontgrendel-link is verlopen of al gebruikt. Ga terug naar de post en probeer het opnieuw.</p>
    1321    <% } else { %>
    1422      <h1 class="pr-h1">Ontgrendelen afgebroken</h1>
    1523      <p class="pr-sub">Er is niets gekoppeld. Je kunt het opnieuw proberen vanaf de post.</p>
    1624    <% } %>
    17     <% if (typeof postSlug !== 'undefined' && postSlug) { %>
    18       <p><a class="pr-btn" href="<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : '') %>/<%= postSlug %>">Terug naar de post</a></p>
    19     <% } %>
     25
     26    <div class="pr-actions">
     27      <% if ((reason === 'notpatron' || reason === 'tier') && _patron) { %>
     28        <a class="pr-btn" href="<%= _patron %>" target="_blank" rel="noopener">Word supporter op Patreon</a>
     29      <% } %>
     30      <% if (_slug) { %>
     31        <a class="pr-btn pr-btn-ghost" href="<%= _base %>/<%= _slug %>">Terug naar de post</a>
     32      <% } else { %>
     33        <a class="pr-btn pr-btn-ghost" href="<%= _base %>/">Terug naar de site</a>
     34      <% } %>
     35    </div>
    2036  </div>
    2137</section>
     
    2642  .pr-ic { font-size: 38px; margin-bottom: 6px; }
    2743  .pr-h1 { font-size: 23px; margin: 0 0 8px; }
    28   .pr-sub { opacity: .85; line-height: 1.6; margin: 0 0 18px; }
     44  .pr-sub { opacity: .85; line-height: 1.6; margin: 0 0 20px; }
     45  .pr-actions { display: flex; flex-direction: column; gap: 10px; align-items: stretch; }
    2946  .pr-btn { display: inline-block; padding: 11px 20px; border-radius: 10px; background: var(--accent,#6b8f71); color: #fff; text-decoration: none; font-weight: 600; }
     47  .pr-btn-ghost { background: transparent; color: var(--ink,#222); border: 1px solid color-mix(in srgb, var(--ink,#000) 22%, transparent); }
    3048</style>
  • test/paid-patron.test.js

    r603d246 rc3d12a6  
    7373});
    7474
     75test('patreonUrl: set, kept on unrelated save, cleared on empty', () => {
     76  PP.saveOwnerConfig('s2', { clientId: 'c', clientSecret: 's', campaignId: '7', patreonUrl: 'https://patreon.com/x' });
     77  assert.equal(PP.patreonUrl('s2'), 'https://patreon.com/x');
     78  // a save that does NOT mention patreonUrl (e.g. token refresh) keeps it
     79  PP.saveOwnerConfig('s2', { defaultMinCents: 200 });
     80  assert.equal(PP.patreonUrl('s2'), 'https://patreon.com/x');
     81  // an explicit empty value clears it
     82  PP.saveOwnerConfig('s2', { patreonUrl: '' });
     83  assert.equal(PP.patreonUrl('s2'), null);
     84});
     85
    7586test('deleteEntitlement removes the row (forget-passkey path)', () => {
    7687  Passkey.storeEntitlement({ credentialId: 'cred3', siteId: 's1', publicKey: 'PK', minCents: 100 });
Note: See TracChangeset for help on using the changeset viewer.