Plugin Directory

Changeset 3796384


Ignore:
Timestamp:
11/11/2026 01:20:18 PM (less than one hour ago)
Author:
trudslev
Message:

Release 1.7.0

Location:
members-for-kofi
Files:
8 added
24 edited
1 copied

Legend:

Unmodified
Added
Removed
  • members-for-kofi/tags/1.7.0/assets/js/admin-settings.js

    r3703946 r3796384  
    1 function membersForKofiInit() {
    2     const tabs = document.querySelectorAll('.nav-tab');
    3     const tabContents = document.querySelectorAll('.members-for-kofi-tab');
    4     const activeTabInput = document.getElementById('active_tab');
    5     // Save button now lives only inside the visible general tab container; no JS hide needed.
     1/**
     2 * Members for Ko-fi admin page.
     3 *
     4 * The tabs, filters and paging are plain links and forms; this only adds
     5 * what needs a script: copying the webhook URL, adding and removing tier
     6 * rows, and confirming a log deletion.
     7 */
     8(function () {
     9    'use strict';
    610
    7     tabs.forEach(tab => {
    8         tab.addEventListener('click', function (e) {
    9             e.preventDefault();
    10             const activeTabId = this.dataset.tab;
    11             if (!activeTabId) return;
     11    function init() {
     12        const copyButton = document.getElementById('copy-webhook-url');
     13        const urlInput = document.getElementById('webhook-kofi-url');
     14        const copyStatus = document.getElementById('mfk-copy-status');
    1215
    13             // Update active classes
    14             tabs.forEach(t => {
    15                 t.classList.remove('nav-tab-active');
    16                 t.setAttribute('aria-selected', 'false');
    17             });
    18             this.classList.add('nav-tab-active');
    19             this.setAttribute('aria-selected', 'true');
    20 
    21             // Toggle content visibility
    22             tabContents.forEach(content => {
    23                 if (content.id === `members-for-kofi-tab-${activeTabId}`) {
    24                     content.style.display = '';
     16        if (copyButton && urlInput) {
     17            copyButton.addEventListener('click', function () {
     18                const done = function () {
     19                    if (copyStatus) {
     20                        copyStatus.textContent = (window.kofiMembers && window.kofiMembers.copied) || 'Copied';
     21                        window.setTimeout(function () { copyStatus.textContent = ''; }, 3000);
     22                    }
     23                };
     24                urlInput.select();
     25                if (navigator.clipboard && navigator.clipboard.writeText) {
     26                    navigator.clipboard.writeText(urlInput.value).then(done).catch(function () {
     27                        document.execCommand('copy');
     28                        done();
     29                    });
    2530                } else {
    26                     content.style.display = 'none';
    27                 }
    28             });
    29 
    30             // Hidden input (used only if form submission needs to know last active tab)
    31             if (activeTabInput) activeTabInput.value = activeTabId;
    32 
    33             // Optionally reflect tab in URL without reload
    34             if (window.history && window.history.replaceState) {
    35                 const url = new URL(window.location.href);
    36                 url.searchParams.set('tab', activeTabId);
    37                 window.history.replaceState({}, '', url.toString());
    38             }
    39         });
    40     });
    41 
    42     // Handle log type selector dropdown
    43     function handleLogTypeChange() {
    44         const logTypeSelector = document.getElementById('log-type-selector');
    45         if (logTypeSelector && !logTypeSelector.dataset.bound) {
    46             logTypeSelector.dataset.bound = '1';
    47             logTypeSelector.addEventListener('change', function() {
    48                 const logType = this.value;
    49                 const searchInput = document.getElementById('kofi-members-log-search');
    50                 const rowsPerPage = document.getElementById('rows_per_page')?.value || 10;
    51                
    52                 // Update the search label based on log type
    53                 const searchLabel = document.querySelector('label[for="kofi-members-log-search"]');
    54                 if (searchLabel) {
    55                     if (logType === 'request') {
    56                         searchLabel.textContent = kofiMembers.searchLabelRequest || 'Search (email / tier / status)';
    57                     } else {
    58                         searchLabel.textContent = kofiMembers.searchLabelUser || 'Search (email / action / role)';
    59                     }
    60                 }
    61                
    62                 // Clear search input
    63                 if (searchInput) searchInput.value = '';
    64                
    65                 // Reload the logs table with the new log type
    66                 loadLogsWithType(logType, '', 1, rowsPerPage);
    67             });
    68         }
    69     }
    70 
    71     function loadLogsWithType(logType, search, paged, rowsPerPage) {
    72         fetch(kofiMembers.ajaxurl, {
    73             method: 'POST',
    74             headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    75             body: new URLSearchParams({
    76                 action: 'members_for_kofi_switch_log_type',
    77                 log_type: logType,
    78                 search: search || '',
    79                 paged: paged || 1,
    80                 rows_per_page: rowsPerPage || 10,
    81                 _ajax_nonce: kofiMembers.switchLogTypeNonce
    82             }),
    83         })
    84             .then(r => r.json())
    85             .then(data => {
    86                 if (data.success) {
    87                     const tableContainer = document.querySelector('#logs-table-container');
    88                     if (tableContainer) {
    89                         tableContainer.innerHTML = data.data;
    90                         tableContainer.dataset.logType = logType;
    91                     }
    92                     reapplyEventListeners();
    93                     handleLogTypeChange(); // Re-bind after content update
    94                 } else {
    95                     alert(data.data || kofiMembers.errorMessage);
    96                 }
    97             })
    98             .catch(err => console.error('Log type switch error:', err));
    99     }
    100 
    101     // No need to toggle save button; it is only present within the general tab container.
    102 
    103     function reapplyEventListeners() {
    104         // Handle Clear Logs button click
    105         const clearLogsButton = document.querySelector('button[name="clear_logs"]');
    106         if (clearLogsButton && !clearLogsButton.dataset.bound) {
    107             clearLogsButton.dataset.bound = '1';
    108             clearLogsButton.addEventListener('click', function (e) {
    109                 e.preventDefault();
    110                 const currentSearch = document.getElementById('kofi-members-current-search')?.value || '';
    111                 const logType = document.querySelector('#log-type-selector')?.value || 'user';
    112 
    113                 if (confirm(kofiMembers.clearLogsConfirm)) {
    114                     fetch(kofiMembers.ajaxurl, {
    115                         method: 'POST',
    116                         headers: {
    117                             'Content-Type': 'application/x-www-form-urlencoded',
    118                         },
    119                         body: new URLSearchParams({
    120                             action: 'members_for_kofi_clear_logs',
    121                             _ajax_nonce: kofiMembers.clearLogsNonce,
    122                             search: currentSearch,
    123                             log_type: logType,
    124                         }),
    125                     })
    126                         .then(response => response.json())
    127                         .then(data => {
    128                             if (data.success) {
    129                                 document.querySelector('#logs-table-container').innerHTML = data.data;
    130                                 reapplyEventListeners(); // Reapply listeners after DOM update
    131                                 // Don't call handleLogTypeChange() here - dropdown is outside the container
    132                             } else {
    133                                 alert(data.data || kofiMembers.errorMessage);
    134                             }
    135                         })
    136                         .catch(error => console.error('Error clearing logs:', error));
     31                    document.execCommand('copy');
     32                    done();
    13733                }
    13834            });
    13935        }
    14036
    141         // Handle Rows Per Page dropdown change
    142     const rowsPerPageDropdown = document.querySelector('#rows_per_page');
    143         if (rowsPerPageDropdown && !rowsPerPageDropdown.dataset.bound) {
    144             rowsPerPageDropdown.dataset.bound = '1';
    145             rowsPerPageDropdown.addEventListener('change', function (e) {
    146                 e.preventDefault();
     37        const tbody = document.querySelector('#tier-role-map-table tbody');
     38        const template = document.getElementById('tier-role-row-template');
    14739
    148                 const rowsPerPage = rowsPerPageDropdown.value;
    149         const currentSearch = document.getElementById('kofi-members-current-search')?.value || '';
    150                 const logType = document.querySelector('#log-type-selector')?.value || 'user';
     40        function addRow(tierName) {
     41            if (!tbody || !template) {
     42                return null;
     43            }
     44            const row = template.content.firstElementChild.cloneNode(true);
     45            tbody.appendChild(row);
     46            const input = row.querySelector('.tier-name-input');
     47            if (input) {
     48                if (tierName) {
     49                    input.value = tierName;
     50                    const select = row.querySelector('.tier-role-select');
     51                    if (select) {
     52                        select.focus();
     53                    }
     54                } else {
     55                    input.focus();
     56                }
     57            }
     58            return row;
     59        }
    15160
    152                 fetch(kofiMembers.ajaxurl, {
    153                     method: 'POST',
    154                     headers: {
    155                         'Content-Type': 'application/x-www-form-urlencoded',
    156                     },
    157                     body: new URLSearchParams({
    158                         action: 'members_for_kofi_update_rows_per_page',
    159                         rows_per_page: rowsPerPage,
    160             search: currentSearch,
    161                         log_type: logType,
    162                         _ajax_nonce: kofiMembers.rowsPerPageNonce,
    163                     }),
    164                 })
    165                     .then(response => response.json())
    166                     .then(data => {
    167                         if (data.success) {
    168                             document.querySelector('#logs-table-container').innerHTML = data.data;
    169                             reapplyEventListeners(); // Reapply listeners after DOM update
    170                             // Don't call handleLogTypeChange() here - dropdown is outside the container and doesn't need rebinding
    171                         } else {
    172                             alert(data.data || kofiMembers.errorMessage);
    173                         }
    174                     })
    175                     .catch(error => console.error('Error updating rows per page:', error));
     61        const addButton = document.getElementById('add-tier-role-row');
     62        if (addButton) {
     63            addButton.addEventListener('click', function () {
     64                addRow('');
    17665            });
    17766        }
    17867
    179         // Search / Reset / Refresh buttons (bind after lazy load and after table updates)
    180         const searchBtn = document.getElementById('kofi-members-log-search-btn');
    181         const resetBtn = document.getElementById('kofi-members-log-reset-btn');
    182         const refreshBtn = document.getElementById('kofi-members-log-refresh-btn');
    183         const searchInput = document.getElementById('kofi-members-log-search');
     68        document.addEventListener('click', function (event) {
     69            const remove = event.target.closest('.remove-tier-role-row');
     70            if (remove && tbody && tbody.contains(remove)) {
     71                const row = remove.closest('tr');
     72                if (row) {
     73                    row.remove();
     74                }
     75                if (addButton) {
     76                    addButton.focus();
     77                }
     78                return;
     79            }
    18480
    185         function loadLogs(params) {
    186             fetch(kofiMembers.ajaxurl, {
    187                 method: 'POST',
    188                 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    189                 body: new URLSearchParams(params),
    190             })
    191                 .then(r => r.json())
    192                 .then(data => {
    193                     if (data.success) {
    194                         const tableContainer = document.querySelector('#logs-table-container');
    195                         if (tableContainer) tableContainer.innerHTML = data.data;
    196                         const hiddenSearch = document.getElementById('kofi-members-current-search');
    197                         if (hiddenSearch) hiddenSearch.value = params.search || '';
    198                         reapplyEventListeners();
    199                         handleLogTypeChange();
    200                     } else {
    201                         alert(data.data || kofiMembers.errorMessage);
    202                     }
    203                 })
    204                 .catch(err => console.error('Log load error:', err));
    205         }
     81            const unmapped = event.target.closest('.mfk-add-unmapped');
     82            if (unmapped) {
     83                addRow(unmapped.getAttribute('data-tier') || '');
     84                const box = unmapped.closest('.mfk-unmapped');
     85                unmapped.remove();
     86                if (box && !box.querySelector('.mfk-add-unmapped')) {
     87                    box.remove();
     88                }
     89            }
     90        });
    20691
    207         if (searchBtn && !searchBtn.dataset.bound) {
    208             searchBtn.dataset.bound = '1';
    209             searchBtn.addEventListener('click', function (e) {
    210                 e.preventDefault();
    211                 loadLogs({
    212                     action: 'members_for_kofi_filter_logs',
    213                     search: (searchInput?.value || '').trim(),
    214                     paged: 1,
    215                     log_type: document.querySelector('#log-type-selector')?.value || 'user',
    216                     rows_per_page: document.getElementById('rows_per_page')?.value || 10,
    217                     _ajax_nonce: kofiMembers.filterNonce,
    218                 });
    219             });
    220         }
    221         if (resetBtn && !resetBtn.dataset.bound) {
    222             resetBtn.dataset.bound = '1';
    223             resetBtn.addEventListener('click', function (e) {
    224                 e.preventDefault();
    225                 if (searchInput) searchInput.value = '';
    226                 loadLogs({
    227                     action: 'members_for_kofi_filter_logs',
    228                     search: '',
    229                     paged: 1,
    230                     log_type: document.querySelector('#log-type-selector')?.value || 'user',
    231                     rows_per_page: document.getElementById('rows_per_page')?.value || 10,
    232                     _ajax_nonce: kofiMembers.filterNonce,
    233                 });
    234             });
    235         }
    236         if (refreshBtn && !refreshBtn.dataset.bound) {
    237             refreshBtn.dataset.bound = '1';
    238             refreshBtn.addEventListener('click', function (e) {
    239                 e.preventDefault();
    240                 const currentSearch = document.getElementById('kofi-members-current-search')?.value || '';
    241                 loadLogs({
    242                     action: 'members_for_kofi_refresh_logs',
    243                     search: currentSearch,
    244                     paged: 1,
    245                     rows_per_page: document.getElementById('rows_per_page')?.value || 10,
    246                     _ajax_nonce: kofiMembers.refreshNonce,
    247                 });
    248             });
    249         }
    250     }
    251 
    252     reapplyEventListeners();
    253     handleLogTypeChange();
    254 
    255     // Handle Pagination link clicks
    256     document.addEventListener('click', function (e) {
    257         const btn = e.target.closest('.members-for-kofi-page-btn');
    258         if (!btn) return;
    259         e.preventDefault();
    260         const page = btn.getAttribute('data-page');
    261         const currentSearch = document.getElementById('kofi-members-current-search')?.value || '';
    262         const logType = document.querySelector('#log-type-selector')?.value || 'user';
    263         fetch(kofiMembers.ajaxurl, {
    264             method: 'POST',
    265             headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    266             body: new URLSearchParams({
    267                 action: 'members_for_kofi_pagination',
    268                 paged: page,
    269                 search: currentSearch,
    270                 log_type: logType,
    271                 _ajax_nonce: kofiMembers.paginationNonce,
    272             }),
    273         })
    274             .then(r => r.json())
    275             .then(data => {
    276                 if (data.success) {
    277                     document.querySelector('#logs-table-container').innerHTML = data.data;
    278                     reapplyEventListeners();
    279                     handleLogTypeChange();
    280                 } else {
    281                     console.error('Error fetching logs:', data.data);
     92        document.querySelectorAll('form[data-mfk-confirm]').forEach(function (form) {
     93            form.addEventListener('submit', function (event) {
     94                if (!window.confirm(form.getAttribute('data-mfk-confirm'))) {
     95                    event.preventDefault();
    28296                }
    283             })
    284             .catch(err => console.error('Error fetching logs:', err));
    285     });
    286 
    287     const copyButton = document.getElementById('copy-webhook-url');
    288     const webhookInput = document.getElementById('webhook-kofi-url');
    289 
    290     if (copyButton && webhookInput) {
    291         copyButton.addEventListener('click', function () {
    292             // Select the text in the input field
    293             webhookInput.select();
    294             webhookInput.setSelectionRange(0, 99999); // For mobile devices
    295 
    296             // Copy the text to the clipboard
    297             navigator.clipboard.writeText(webhookInput.value).then(() => {
    298                 alert('Webhook URL copied to clipboard!');
    299             }).catch(err => {
    300                 console.error('Failed to copy webhook URL:', err);
    30197            });
    30298        });
    30399    }
    304100
    305     // Tier to Role Mapping dynamic rows
    306     const addTierRoleBtn = document.getElementById('add-tier-role-row');
    307     const tierRoleTableBody = document.querySelector('#tier-role-map-table tbody');
    308     const tierRowTemplate = document.getElementById('tier-role-row-template');
    309 
    310     function addTierRoleRow() {
    311         if (!tierRowTemplate || !tierRoleTableBody) return;
    312         const html = tierRowTemplate.innerHTML.trim();
    313         const temp = document.createElement('tbody');
    314         temp.innerHTML = html;
    315         const row = temp.firstElementChild;
    316         if (row) {
    317             tierRoleTableBody.appendChild(row);
    318         }
     101    if (document.readyState === 'loading') {
     102        document.addEventListener('DOMContentLoaded', init);
     103    } else {
     104        init();
    319105    }
    320 
    321     if (addTierRoleBtn) {
    322         addTierRoleBtn.addEventListener('click', function (e) {
    323             e.preventDefault();
    324             addTierRoleRow();
    325         });
    326     }
    327 
    328     // Delegate remove button clicks
    329     document.addEventListener('click', function (e) {
    330         const removeBtn = e.target.closest('.remove-tier-role-row');
    331         if (removeBtn && tierRoleTableBody && tierRoleTableBody.contains(removeBtn)) {
    332             e.preventDefault();
    333             const row = removeBtn.closest('tr');
    334             if (row) {
    335                 row.remove();
    336             }
    337         }
    338     });
    339 
    340 }
    341 
    342 if (document.readyState === 'loading') {
    343     document.addEventListener('DOMContentLoaded', membersForKofiInit);
    344 } else {
    345     // DOM already ready (script loaded late in footer) -> initialize immediately.
    346     membersForKofiInit();
    347 }
    348 
    349 // Auto-activate user logs tab if URL param present (after init attaches listeners)
    350 (() => {
    351     const params = new URLSearchParams(window.location.search);
    352     if (params.get('tab') === 'user_logs') {
    353         const userLogsTab = document.querySelector('.nav-tab[data-tab="user_logs"]');
    354         if (userLogsTab) {
    355             // Defer to allow initial init to finish.
    356             setTimeout(() => userLogsTab.click(), 0);
    357         }
    358     }
    359 })();
     106}());
  • members-for-kofi/tags/1.7.0/languages/members-for-kofi-da_DK.po

    r3796045 r3796384  
    44msgid ""
    55msgstr ""
    6 "Project-Id-Version: Members for Ko-fi 1.4.0\n"
     6"Project-Id-Version: Members for Ko-fi 1.7.0\n"
    77"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/members-for-kofi\n"
    88"Language: da_DK\n"
    … …  
    1515#. Plugin Name of the plugin
    1616#: members-for-kofi.php
    17 #: src/Plugin.php:434
    18 #: src/Plugin.php:435
    19 #: src/Privacy/PersonalData.php:333
     17#: src/Admin/AdminSettings.php:162
     18#: src/Plugin.php:436
     19#: src/Plugin.php:437
     20#: src/Privacy/PersonalData.php:360
    2021msgid "Members for Ko-fi"
    2122msgstr "Members for Ko-fi"
    … …  
    4142msgstr "https://foodgeek.io"
    4243
    43 #: src/Admin/AdminSettings.php:107
    44 #: src/Admin/AdminSettings.php:667
    45 msgid "Ko-fi Settings"
    46 msgstr "Ko-fi-indstillinger"
    47 
    48 #: src/Admin/AdminSettings.php:108
    49 msgid "Verification Token"
    50 msgstr "Verifikationstoken"
    51 
    52 #: src/Admin/AdminSettings.php:111
    53 #: src/Admin/AdminSettings.php:671
    54 msgid "Role Assignment"
    55 msgstr "Tildeling af roller"
    56 
    57 #: src/Admin/AdminSettings.php:112
    58 msgid "Only Accept Subscriptions"
    59 msgstr "Accepter kun abonnementer"
    60 
    61 #: src/Admin/AdminSettings.php:113
    62 msgid "Tier to Role Mapping"
    63 msgstr "Tilknytning af niveauer til roller"
    64 
    65 #: src/Admin/AdminSettings.php:114
    66 msgid "Default Role (if no match)"
    67 msgstr "Standardrolle (hvis intet matcher)"
    68 
    69 #: src/Admin/AdminSettings.php:115
    70 msgid "Enable Expiry"
    71 msgstr "Slå udløb til"
    72 
    73 #: src/Admin/AdminSettings.php:116
    74 msgid "Role Expiry (days)"
    75 msgstr "Rollens gyldighed (dage)"
    76 
    77 #: src/Admin/AdminSettings.php:119
    78 #: src/Admin/AdminSettings.php:675
    79 msgid "Logging"
    80 msgstr "Logning"
    81 
    82 #: src/Admin/AdminSettings.php:120
    83 msgid "Automatically Clear Logs"
    84 msgstr "Ryd logs automatisk"
    85 
    86 #: src/Admin/AdminSettings.php:121
     44#: src/Admin/AdminSettings.php:110
     45msgid "Copied"
     46msgstr "Kopieret"
     47
     48#: src/Admin/AdminSettings.php:156
     49msgid "Settings"
     50msgstr "Indstillinger"
     51
     52#: src/Admin/AdminSettings.php:157
     53msgid "Members"
     54msgstr "Medlemmer"
     55
     56#: src/Admin/AdminSettings.php:158
     57msgid "Logs"
     58msgstr "Logs"
     59
     60#: src/Admin/AdminSettings.php:171
     61msgid "Members for Ko-fi sections"
     62msgstr "Sektioner i Members for Ko-fi"
     63
     64#: src/Admin/AdminSettings.php:201
     65msgid "Ko-fi connection"
     66msgstr "Forbindelse til Ko-fi"
     67
     68#: src/Admin/AdminSettings.php:208
     69msgid "Press \"Send test\" on Ko-fi"
     70msgstr "Tryk på \"Send test\" hos Ko-fi"
     71
     72#. translators: %s is a link to the request log.
     73#: src/Admin/AdminSettings.php:213
     74#, php-format
     75msgid "It shows up in the status line above and in %s. A test never creates an account."
     76msgstr "Den vises i statuslinjen ovenfor og i %s. En test opretter aldrig en konto."
     77
     78#: src/Admin/AdminSettings.php:214
     79msgid "the request log"
     80msgstr "forespørgselsloggen"
     81
     82#: src/Admin/AdminSettings.php:223
     83msgid "Who gets a role"
     84msgstr "Hvem får en rolle"
     85
     86#: src/Admin/AdminSettings.php:232
     87msgid "When membership ends"
     88msgstr "Når medlemskabet ophører"
     89
     90#: src/Admin/AdminSettings.php:239
     91msgid "Ko-fi subscriptions renew monthly, so 35 days leaves a few days' grace. A role the user held before paying is never removed."
     92msgstr "Ko-fi-abonnementer fornyes hver måned, så 35 dage giver et par dages ekstra frist. En rolle, som brugeren havde før betalingen, fjernes aldrig."
     93
     94#: src/Admin/AdminSettings.php:243
     95msgid "Log history"
     96msgstr "Loghistorik"
     97
     98#: src/Admin/AdminSettings.php:250
     99msgid "Untick to keep everything, for example when another tool reads the request log."
     100msgstr "Fjern fluebenet for at beholde alt, for eksempel hvis et andet værktøj læser forespørgselsloggen."
     101
     102#: src/Admin/AdminSettings.php:272
     103msgid "Not connected: no token is saved, so Ko-fi's requests are refused."
     104msgstr "Ikke forbundet: der er ikke gemt et token, så forespørgsler fra Ko-fi afvises."
     105
     106#: src/Admin/AdminSettings.php:277
     107msgid "Waiting for Ko-fi: no request has arrived yet."
     108msgstr "Venter på Ko-fi: der er ikke kommet nogen forespørgsel endnu."
     109
     110#. translators: %s is a time span, e.g. "2 hours".
     111#: src/Admin/AdminSettings.php:283
     112#, php-format
     113msgid "Working: the last request from Ko-fi, %s ago, was accepted."
     114msgstr "Forbindelsen virker: den seneste forespørgsel fra Ko-fi for %s siden blev accepteret."
     115
     116#. translators: 1: a time span, e.g. "2 hours", 2: why it was refused, e.g. "the token doesn't match".
     117#: src/Admin/AdminSettings.php:287
     118#, php-format
     119msgid "The last request, %1$s ago, was refused: %2$s"
     120msgstr "Den seneste forespørgsel for %1$s siden blev afvist: %2$s"
     121
     122#: src/Admin/AdminSettings.php:302
     123msgid "Ko-fi's webhook settings"
     124msgstr "Ko-fis webhook-indstillinger"
     125
     126#: src/Admin/AdminSettings.php:303
     127msgid "(opens in a new tab)"
     128msgstr "(åbner i en ny fane)"
     129
     130#: src/Admin/AdminSettings.php:305
     131msgid "Give Ko-fi this webhook URL"
     132msgstr "Giv Ko-fi denne webhook-URL"
     133
     134#: src/Admin/AdminSettings.php:308
     135msgid "Copy URL"
     136msgstr "Kopiér URL"
     137
     138#. translators: %s is a link to Ko-fi's webhook settings.
     139#: src/Admin/AdminSettings.php:314
     140#, php-format
     141msgid "Paste it into %s."
     142msgstr "Indsæt den i %s."
     143
     144#: src/Admin/AdminSettings.php:329
     145msgid "Paste Ko-fi's verification token"
     146msgstr "Indsæt Ko-fis verifikationstoken"
     147
     148#: src/Admin/AdminSettings.php:332
     149msgid "A token is saved"
     150msgstr "Der er gemt et token"
     151
     152#: src/Admin/AdminSettings.php:334
     153msgid "fingerprint, to compare with another site"
     154msgstr "fingeraftryk til sammenligning med et andet websted"
     155
     156#: src/Admin/AdminSettings.php:337
     157msgid "No token is saved yet."
     158msgstr "Der er ikke gemt noget token endnu."
     159
     160#: src/Admin/AdminSettings.php:343
     161msgid "Leave blank to keep the saved token"
     162msgstr "Lad feltet stå tomt for at beholde det gemte token"
     163
     164#: src/Admin/AdminSettings.php:344
     165msgid "On Ko-fi's webhook page, open Advanced and copy the token. Only a hash is stored, so it can't be shown again."
     166msgstr "Åbn Advanced på Ko-fis webhook-side, og kopiér tokenet. Der gemmes kun en hashværdi, så det kan ikke vises igen."
     167
     168#: src/Admin/AdminSettings.php:358
     169msgid "Payments that grant a role"
     170msgstr "Betalinger, der giver en rolle"
     171
     172#: src/Admin/AdminSettings.php:359
     173msgid "Donations and subscriptions"
     174msgstr "Donationer og abonnementer"
     175
     176#: src/Admin/AdminSettings.php:360
     177msgid "Subscriptions only"
     178msgstr "Kun abonnementer"
     179
     180#: src/Admin/AdminSettings.php:360
     181msgid "(one-off donations are logged and ignored)"
     182msgstr "(enkeltdonationer logges og ignoreres)"
     183
     184#: src/Admin/AdminSettings.php:393
     185#: src/Admin/AdminSettings.php:421
     186msgid "Tier name on Ko-fi"
     187msgstr "Niveaunavn hos Ko-fi"
     188
     189#: src/Admin/AdminSettings.php:394
     190msgid "Role for this tier"
     191msgstr "Rolle for dette niveau"
     192
     193#: src/Admin/AdminSettings.php:395
     194msgid "Remove this tier"
     195msgstr "Fjern dette niveau"
     196
     197#: src/Admin/AdminSettings.php:395
     198msgid "Remove"
     199msgstr "Fjern"
     200
     201#: src/Admin/AdminSettings.php:416
     202msgid "Ko-fi tiers"
     203msgstr "Ko-fi-niveauer"
     204
     205#: src/Admin/AdminSettings.php:422
     206msgid "Role it grants"
     207msgstr "Rolle, der tildeles"
     208
     209#: src/Admin/AdminSettings.php:423
     210msgid "Actions"
     211msgstr "Handlinger"
     212
     213#: src/Admin/AdminSettings.php:433
     214msgid "Add tier"
     215msgstr "Tilføj niveau"
     216
     217#: src/Admin/AdminSettings.php:434
     218msgid "Names match whatever their capitalisation."
     219msgstr "Navne matcher uanset store og små bogstaver."
     220
     221#: src/Admin/AdminSettings.php:438
     222msgid "Tiers Ko-fi sent that aren't listed:"
     223msgstr "Niveauer fra Ko-fi, der ikke er på listen:"
     224
     225#. translators: 1: a tier name, 2: how many payments named it.
     226#: src/Admin/AdminSettings.php:445
     227#, php-format
     228msgid "Add \"%1$s\" (%2$d payment)"
     229msgid_plural "Add \"%1$s\" (%2$d payments)"
     230msgstr[0] "Tilføj \"%1$s\" (%2$d betaling)"
     231msgstr[1] "Tilføj \"%1$s\" (%2$d betalinger)"
     232
     233#: src/Admin/AdminSettings.php:469
     234msgid "Any other tier, or no tier"
     235msgstr "Andre niveauer eller intet niveau"
     236
     237#: src/Admin/AdminSettings.php:471
     238msgid "— No role —"
     239msgstr "— Ingen rolle —"
     240
     241#: src/Admin/AdminSettings.php:474
     242msgid "With \"No role\", those payers still get an account, but no role. Administrator and other roles that can run the site are never offered."
     243msgstr "Med \"Ingen rolle\" får disse betalere stadig en konto, men ingen rolle. Administrator og andre roller, der kan styre webstedet, tilbydes aldrig."
     244
     245#: src/Admin/AdminSettings.php:489
     246msgid "Remove the role"
     247msgstr "Fjern rollen"
     248
     249#: src/Admin/AdminSettings.php:500
     250msgid "Days after the last payment"
     251msgstr "Dage efter seneste betaling"
     252
     253#: src/Admin/AdminSettings.php:502
     254msgid "days after the member's last payment"
     255msgstr "dage efter medlemmets seneste betaling"
     256
     257#: src/Admin/AdminSettings.php:518
     258msgid "Delete log entries older than"
     259msgstr "Slet logposter, der er ældre end"
     260
     261#: src/Admin/AdminSettings.php:529
    87262msgid "Number of Days to Keep Logs"
    88263msgstr "Antal dage, logs gemmes"
    89264
    90 #: src/Admin/AdminSettings.php:150
    91 msgid "Are you sure you want to clear all logs? This action cannot be undone."
    92 msgstr "Er du sikker på, at du vil rydde alle logs? Det kan ikke fortrydes."
    93 
    94 #: src/Admin/AdminSettings.php:151
    95 msgid "An error occurred. Please try again."
    96 msgstr "Der opstod en fejl. Prøv igen."
    97 
    98 #: src/Admin/AdminSettings.php:152
    99 #: src/Admin/AdminSettings.php:748
    100 msgid "Search (email / action / role)"
    101 msgstr "Søg (e-mail / handling / rolle)"
    102 
    103 #: src/Admin/AdminSettings.php:153
    104 #: src/Admin/AdminSettings.php:746
    105 msgid "Search (email / tier / status)"
    106 msgstr "Søg (e-mail / niveau / status)"
    107 
    108 #: src/Admin/AdminSettings.php:188
     265#: src/Admin/AdminSettings.php:531
     266msgid "days"
     267msgstr "dage"
     268
     269#. translators: %s is a human-readable time span, e.g. "3 days".
     270#: src/Admin/AdminSettings.php:575
     271#, php-format
     272msgid "Scheduled tasks are not running: role expiry and log cleanup are %s overdue. Make sure WP-Cron runs, or that a system cron calls wp-cron.php."
     273msgstr "Planlagte opgaver kører ikke: udløb af roller og oprydning i logs er %s forsinket. Sørg for, at WP-Cron kører, eller at et system-cronjob kalder wp-cron.php."
     274
     275#. translators: 1: email address, 2: comma-separated role names, or "no role".
     276#: src/Admin/AdminSettings.php:627
     277#, php-format
     278msgid "Members for Ko-fi: an account was created by Ko-fi's \"Send test\" button (%1$s, role: %2$s). It is not a real supporter; earlier versions treated tests as payments. You can safely delete it."
     279msgstr "Members for Ko-fi: Ko-fi's \"Send test\"-knap har oprettet en konto (%1$s, rolle: %2$s). Det er ikke en rigtig supporter; tidligere versioner behandlede test som betalinger. Du kan roligt slette den."
     280
     281#: src/Admin/AdminSettings.php:629
     282msgid "none"
     283msgstr "ingen"
     284
     285#: src/Admin/AdminSettings.php:633
     286msgid "Review the account"
     287msgstr "Se kontoen"
     288
     289#: src/Admin/AdminSettings.php:643
     290msgid "Members for Ko-fi: two different verification tokens are stored, and webhooks are checked against the hashed one. Re-enter your Ko-fi verification token in the settings to resolve this."
     291msgstr "Members for Ko-fi: der er gemt to forskellige verifikationstokens, og webhooks tjekkes mod det hashede. Indtast dit Ko-fi-verifikationstoken igen i indstillingerne for at løse det."
     292
     293#: src/Admin/AdminSettings.php:668
     294msgid "Open settings"
     295msgstr "Åbn indstillinger"
     296
     297#: src/Admin/AdminSettings.php:701
    109298msgid "Verification Token is required."
    110299msgstr "Verifikationstoken skal udfyldes."
    111300
    112 #: src/Admin/AdminSettings.php:193
     301#: src/Admin/AdminSettings.php:706
    113302msgid "Role Expiry Days is required and must be a number when expiry is enabled."
    114303msgstr "Når udløb er slået til, skal du angive rollens gyldighed i dage."
    115304
    116305#. translators: %s is the role name that was rejected.
    117 #: src/Admin/AdminSettings.php:213
    118 #: src/Admin/AdminSettings.php:229
     306#: src/Admin/AdminSettings.php:726
     307#: src/Admin/AdminSettings.php:742
    119308#, php-format
    120309msgid "Security: Role \"%s\" cannot be assigned via webhook for security reasons."
    … …  
    122311
    123312#. translators: %s is the role name that was rejected.
    124 #: src/Admin/AdminSettings.php:241
     313#: src/Admin/AdminSettings.php:754
    125314#, php-format
    126315msgid "Security: Role \"%s\" cannot be used as default role for security reasons."
    127316msgstr "Sikkerhed: Rollen \"%s\" kan af sikkerhedshensyn ikke bruges som standardrolle."
    128317
    129 #: src/Admin/AdminSettings.php:290
    130 msgid "A token is saved. Leave this blank to keep it, or paste a new one to replace it."
    131 msgstr "Der er gemt et token. Lad feltet stå tomt for at beholde det, eller indsæt et nyt for at erstatte det."
    132 
    133 #. translators: %s is the first eight characters of the saved token's SHA-256 hash.
    134 #: src/Admin/AdminSettings.php:293
    135 #, php-format
    136 msgid "Saved token fingerprint: %s"
    137 msgstr "Fingeraftryk af det gemte token: %s"
    138 
    139 #: src/Admin/AdminSettings.php:297
    140 msgid "No token set yet. Webhooks are rejected until one is saved."
    141 msgstr "Der er endnu ikke gemt et token. Webhook-forespørgsler afvises, indtil du gemmer et."
    142 
    143 #. translators: %s is a link to the Ko-fi Webhooks Management page.
    144 #: src/Admin/AdminSettings.php:307
    145 #, php-format
    146 msgid "This token is used to verify incoming webhook requests from Ko-fi. Paste the verification token from this page: %s and open the Advanced box. Only a hash of it is stored, so it cannot be shown again; to change it, paste the new one."
    147 msgstr "Dette token bruges til at bekræfte, at webhook-forespørgsler kommer fra Ko-fi. Åbn %s, find sektionen Advanced, og kopiér verifikationstokenet. Der gemmes kun en hashværdi af tokenet, så det kan ikke vises igen. Indsæt et nyt token for at erstatte det."
    148 
    149 #: src/Admin/AdminSettings.php:308
    150 msgid "Ko-fi Webhooks Management"
    151 msgstr "Ko-fi Webhooks Management"
    152 
    153 #: src/Admin/AdminSettings.php:322
    154 msgid "Webhook URL:"
    155 msgstr "Webhook-URL:"
    156 
    157 #: src/Admin/AdminSettings.php:325
    158 msgid "Copy to Clipboard"
    159 msgstr "Kopiér til udklipsholder"
    160 
    161 #: src/Admin/AdminSettings.php:327
    162 msgid "Use this URL to configure your Ko-fi webhook."
    163 msgstr "Brug denne URL, når du opsætter din webhook i Ko-fi."
    164 
    165 #. translators: %s is a human-readable time span, e.g. "3 days".
    166 #: src/Admin/AdminSettings.php:372
    167 #, php-format
    168 msgid "Scheduled tasks are not running: role expiry and log cleanup are %s overdue. Make sure WP-Cron runs, or that a system cron calls wp-cron.php."
    169 msgstr "Planlagte opgaver kører ikke: udløb af roller og oprydning i logs er %s forsinket. Sørg for, at WP-Cron kører, eller at et system-cronjob kalder wp-cron.php."
    170 
    171 #. translators: 1: email address, 2: comma-separated role names, or "no role".
    172 #: src/Admin/AdminSettings.php:424
    173 #, php-format
    174 msgid "Members for Ko-fi: an account was created by Ko-fi's \"Send test\" button (%1$s, role: %2$s). It is not a real supporter; earlier versions treated tests as payments. You can safely delete it."
    175 msgstr "Members for Ko-fi: Ko-fi's \"Send test\"-knap har oprettet en konto (%1$s, rolle: %2$s). Det er ikke en rigtig supporter; tidligere versioner behandlede test som betalinger. Du kan roligt slette den."
    176 
    177 #: src/Admin/AdminSettings.php:426
    178 msgid "none"
    179 msgstr "ingen"
    180 
    181 #: src/Admin/AdminSettings.php:430
    182 msgid "Review the account"
    183 msgstr "Se kontoen"
    184 
    185 #: src/Admin/AdminSettings.php:440
    186 msgid "Members for Ko-fi: two different verification tokens are stored, and webhooks are checked against the hashed one. Re-enter your Ko-fi verification token in the settings to resolve this."
    187 msgstr "Members for Ko-fi: der er gemt to forskellige verifikationstokens, og webhooks tjekkes mod det hashede. Indtast dit Ko-fi-verifikationstoken igen i indstillingerne for at løse det."
    188 
    189 #: src/Admin/AdminSettings.php:465
    190 msgid "Open settings"
    191 msgstr "Åbn indstillinger"
    192 
    193 #: src/Admin/AdminSettings.php:481
    194 msgid "When enabled, users must be active subscribers to receive a role."
    195 msgstr "Når det er slået til, skal brugere have et aktivt abonnement for at få en rolle."
    196 
    197 #: src/Admin/AdminSettings.php:498
    198 msgid "Ko-fi Tier"
    199 msgstr "Ko-fi-niveau"
    200 
    201 #: src/Admin/AdminSettings.php:498
    202 #: src/Admin/AdminSettings.php:1097
     318#: src/Admin/LogsTab.php:245
     319msgid "the token doesn't match"
     320msgstr "tokenet passer ikke"
     321
     322#: src/Admin/LogsTab.php:247
     323msgid "no token is saved"
     324msgstr "der er ikke gemt noget token"
     325
     326#: src/Admin/LogsTab.php:252
     327msgid "the request was malformed"
     328msgstr "forespørgslen var ugyldig"
     329
     330#: src/Admin/LogsTab.php:254
     331msgid "the account could not be created"
     332msgstr "kontoen kunne ikke oprettes"
     333
     334#. translators: %s is a time of day.
     335#: src/Admin/LogsTab.php:292
     336#, php-format
     337msgid "Today, %s"
     338msgstr "I dag, %s"
     339
     340#. translators: %s is a time of day.
     341#: src/Admin/LogsTab.php:297
     342#, php-format
     343msgid "Yesterday, %s"
     344msgstr "I går, %s"
     345
     346#: src/Admin/LogsTab.php:365
     347msgid "The log was deleted."
     348msgstr "Loggen blev ryddet."
     349
     350#: src/Admin/LogsTab.php:383
     351msgid "Member activity"
     352msgstr "Medlemsaktivitet"
     353
     354#: src/Admin/LogsTab.php:396
     355msgid "Requests from Ko-fi"
     356msgstr "Forespørgsler fra Ko-fi"
     357
     358#. translators: %s is the site's time zone, e.g. "Europe/Copenhagen".
     359#: src/Admin/LogsTab.php:401
     360#, php-format
     361msgid "Times are in the site's time zone (%s)."
     362msgstr "Tidspunkter er i webstedets tidszone (%s)."
     363
     364#: src/Admin/LogsTab.php:412
     365#: src/Admin/LogsTab.php:477
     366msgid "Outcome"
     367msgstr "Resultat"
     368
     369#: src/Admin/LogsTab.php:414
     370msgid "All outcomes"
     371msgstr "Alle resultater"
     372
     373#: src/Admin/LogsTab.php:415
     374#: src/Admin/LogsTab.php:547
     375msgid "Accepted"
     376msgstr "Accepteret"
     377
     378#: src/Admin/LogsTab.php:416
     379msgid "Refused"
     380msgstr "Afvist"
     381
     382#: src/Admin/LogsTab.php:424
     383msgid "Search email, tier or reason"
     384msgstr "Søg i e-mail, niveau eller årsag"
     385
     386#: src/Admin/LogsTab.php:426
     387msgid "Search email, action or role"
     388msgstr "Søg i e-mail, handling eller rolle"
     389
     390#: src/Admin/LogsTab.php:433
     391msgid "Per page"
     392msgstr "Pr. side"
     393
     394#: src/Admin/LogsTab.php:440
     395msgid "Filter"
     396msgstr "Filtrér"
     397
     398#: src/Admin/LogsTab.php:473
     399msgid "Received"
     400msgstr "Modtaget"
     401
     402#: src/Admin/LogsTab.php:474
     403#: src/Privacy/PersonalData.php:321
     404#: src/Webhook/Webhook.php:911
     405#: src/Webhook/Webhook.php:931
     406msgid "Supporter"
     407msgstr "Supporter"
     408
     409#: src/Admin/LogsTab.php:475
     410msgid "Tier"
     411msgstr "Niveau"
     412
     413#: src/Admin/LogsTab.php:476
     414msgid "Payment"
     415msgstr "Betaling"
     416
     417#: src/Admin/LogsTab.php:478
     418msgid "Details"
     419msgstr "Detaljer"
     420
     421#: src/Admin/LogsTab.php:483
     422#: src/Admin/LogsTab.php:610
     423msgid "No entries match."
     424msgstr "Ingen poster matcher."
     425
     426#: src/Admin/LogsTab.php:483
     427#: src/Admin/LogsTab.php:610
     428msgid "No entries yet."
     429msgstr "Ingen poster endnu."
     430
     431#: src/Admin/LogsTab.php:504
     432msgid "Ko-fi test"
     433msgstr "Ko-fi-test"
     434
     435#: src/Admin/LogsTab.php:506
     436msgid "Not stored"
     437msgstr "Ikke gemt"
     438
     439#: src/Admin/LogsTab.php:522
     440msgid "not mapped"
     441msgstr "ikke tilknyttet"
     442
     443#: src/Admin/LogsTab.php:530
     444msgid "Test, no account made"
     445msgstr "Test, ingen konto oprettet"
     446
     447#. translators: %s is an amount with its currency, e.g. "5.00 EUR".
     448#: src/Admin/LogsTab.php:536
     449#, php-format
     450msgid "%s · subscription"
     451msgstr "%s · abonnement"
     452
     453#. translators: %s is an amount with its currency, e.g. "5.00 EUR".
     454#: src/Admin/LogsTab.php:538
     455#, php-format
     456msgid "%s · one-off"
     457msgstr "%s · enkeltbetaling"
     458
     459#. translators: %s is why the request was refused, e.g. "the token doesn't match".
     460#: src/Admin/LogsTab.php:552
     461#, php-format
     462msgid "Refused: %s"
     463msgstr "Afvist: %s"
     464
     465#: src/Admin/LogsTab.php:560
     466msgid "Payload"
     467msgstr "Indhold"
     468
     469#: src/Admin/LogsTab.php:602
     470msgid "When"
     471msgstr "Tidspunkt"
     472
     473#: src/Admin/LogsTab.php:603
     474#: src/Admin/MembersTab.php:264
     475msgid "Member"
     476msgstr "Medlem"
     477
     478#: src/Admin/LogsTab.php:604
     479msgid "What happened"
     480msgstr "Hændelse"
     481
     482#: src/Admin/LogsTab.php:605
     483#: src/Admin/MembersTab.php:265
    203484msgid "Role"
    204485msgstr "Rolle"
    205486
    206 #: src/Admin/AdminSettings.php:536
    207 msgid "Add Tier Mapping"
    208 msgstr "Tilføj niveautilknytning"
    209 
    210 #: src/Admin/AdminSettings.php:537
    211 msgid "Map Ko-fi tiers to WordPress roles. Only exact tier names will be matched."
    212 msgstr "Knyt Ko-fi-niveauer til WordPress-roller. Kun niveaunavne, der er helt ens, matcher."
    213 
    214 #: src/Admin/AdminSettings.php:554
    215 msgid "— No default —"
    216 msgstr "— Ingen standard —"
    217 
    218 #: src/Admin/AdminSettings.php:563
    219 msgid "If no tier matches, or if you are not using tiers, this role will be assigned to the user."
    220 msgstr "Hvis intet niveau matcher, eller hvis du ikke bruger niveauer, får brugeren denne rolle."
    221 
    222 #: src/Admin/AdminSettings.php:594
    223 msgid "Number of days the role remains active (only used if expiry is enabled)."
    224 msgstr "Antal dage rollen er aktiv (bruges kun, når udløb er slået til)."
    225 
    226 #: src/Admin/AdminSettings.php:606
    227 msgid "Enable automatic deletion of old logs based on retention period."
    228 msgstr "Slet automatisk logs, der er ældre end opbevaringsperioden."
    229 
    230 #: src/Admin/AdminSettings.php:618
    231 msgid "Number of days to keep logs before automatic deletion (minimum 1 day)."
    232 msgstr "Antal dage, logs gemmes, før de slettes automatisk (mindst 1 dag)."
    233 
    234 #: src/Admin/AdminSettings.php:646
    235 msgid "Members for Ko-fi Settings"
    236 msgstr "Indstillinger for Members for Ko-fi"
    237 
    238 #: src/Admin/AdminSettings.php:657
    239 msgid "Settings"
    240 msgstr "Indstillinger"
    241 
    242 #: src/Admin/AdminSettings.php:660
    243 #: src/Admin/AdminSettings.php:683
    244 msgid "Logs"
    245 msgstr "Logs"
    246 
    247 #: src/Admin/AdminSettings.php:728
    248 msgid "Log Type:"
    249 msgstr "Logtype:"
    250 
    251 #: src/Admin/AdminSettings.php:732
    252 msgid "User Logs"
    253 msgstr "Brugerlog"
    254 
    255 #: src/Admin/AdminSettings.php:735
    256 msgid "Request Logs"
    257 msgstr "Forespørgselslog"
    258 
    259 #: src/Admin/AdminSettings.php:755
    260 msgid "Search"
    261 msgstr "Søg"
    262 
    263 #: src/Admin/AdminSettings.php:756
    264 msgid "Reset"
    265 msgstr "Nulstil"
    266 
    267 #: src/Admin/AdminSettings.php:759
    268 msgid "Refresh"
    269 msgstr "Opdater"
    270 
    271 #: src/Admin/AdminSettings.php:790
    272 #: src/Admin/AdminSettings.php:826
    273 #: src/Admin/AdminSettings.php:900
    274 #: src/Admin/AdminSettings.php:929
    275 #: src/Admin/AdminSettings.php:960
    276 #: src/Admin/AdminSettings.php:983
     487#: src/Admin/LogsTab.php:651
     488msgid "First page"
     489msgstr "Første side"
     490
     491#: src/Admin/LogsTab.php:652
     492#: src/Admin/MembersTab.php:351
     493msgid "Previous page"
     494msgstr "Forrige side"
     495
     496#: src/Admin/LogsTab.php:653
     497#: src/Admin/MembersTab.php:362
     498msgid "Next page"
     499msgstr "Næste side"
     500
     501#: src/Admin/LogsTab.php:654
     502msgid "Last page"
     503msgstr "Sidste side"
     504
     505#: src/Admin/LogsTab.php:665
     506msgid "Current page"
     507msgstr "Aktuel side"
     508
     509#: src/Admin/LogsTab.php:676
     510#: src/Admin/MembersTab.php:349
     511msgid "Pages"
     512msgstr "Sider"
     513
     514#. translators: 1: an input holding the current page number, 2: the number of pages.
     515#. translators: 1: the current page number, 2: the number of pages.
     516#: src/Admin/LogsTab.php:683
     517#: src/Admin/MembersTab.php:356
     518#, php-format
     519msgid "Page %1$s of %2$s"
     520msgstr "Side %1$s af %2$s"
     521
     522#. translators: %s is the number of entries.
     523#: src/Admin/LogsTab.php:715
     524#, php-format
     525msgid "Removes %s request entry. Member activity is kept."
     526msgid_plural "Removes all %s request entries. Member activity is kept."
     527msgstr[0] "Fjerner %s post i forespørgselsloggen. Medlemsaktiviteten bevares."
     528msgstr[1] "Fjerner alle %s poster i forespørgselsloggen. Medlemsaktiviteten bevares."
     529
     530#. translators: %s is the number of entries.
     531#: src/Admin/LogsTab.php:717
     532#, php-format
     533msgid "Removes %s activity entry. The request log is kept."
     534msgid_plural "Removes all %s activity entries. The request log is kept."
     535msgstr[0] "Fjerner %s post i medlemsaktiviteten. Forespørgselsloggen bevares."
     536msgstr[1] "Fjerner alle %s poster i medlemsaktiviteten. Forespørgselsloggen bevares."
     537
     538#. translators: %s is the number of entries.
     539#: src/Admin/LogsTab.php:720
     540#, php-format
     541msgid "Delete %s entry in this log? This can't be undone."
     542msgid_plural "Delete all %s entries in this log? This can't be undone."
     543msgstr[0] "Ryd %s post i denne log? Det kan ikke fortrydes."
     544msgstr[1] "Ryd alle %s poster i denne log? Det kan ikke fortrydes."
     545
     546#: src/Admin/LogsTab.php:726
     547msgid "Delete this log"
     548msgstr "Ryd denne log"
     549
     550#. translators: %d is a number of days.
     551#: src/Admin/LogsTab.php:734
     552#, php-format
     553msgid "Entries older than %d day are deleted automatically."
     554msgid_plural "Entries older than %d days are deleted automatically."
     555msgstr[0] "Poster ældre end %d dag slettes automatisk."
     556msgstr[1] "Poster ældre end %d dage slettes automatisk."
     557
     558#: src/Admin/LogsTab.php:747
     559msgid "Delete request log…"
     560msgstr "Ryd forespørgselsloggen…"
     561
     562#: src/Admin/LogsTab.php:747
     563msgid "Delete activity log…"
     564msgstr "Ryd aktivitetsloggen…"
     565
     566#: src/Admin/LogsTab.php:761
    277567msgid "You do not have permission to access this resource."
    278568msgstr "Du har ikke adgang til denne ressource."
    279569
    280 #: src/Admin/AdminSettings.php:799
    281 msgid "Invalid page number."
    282 msgstr "Ugyldigt sidetal."
    283 
    284 #: src/Admin/AdminSettings.php:859
    285 msgid "Logs table does not exist."
    286 msgstr "Logtabellen findes ikke."
    287 
    288 #: src/Admin/AdminSettings.php:1093
    289 #: src/Admin/AdminSettings.php:1235
    290 msgid "Timestamp"
    291 msgstr "Tidspunkt"
    292 
    293 #: src/Admin/AdminSettings.php:1094
    294 msgid "User ID"
    295 msgstr "Bruger-ID"
    296 
    297 #: src/Admin/AdminSettings.php:1095
    298 #: src/Admin/AdminSettings.php:1236
    299 msgid "Email"
    300 msgstr "E-mail"
    301 
    302 #: src/Admin/AdminSettings.php:1096
    303 msgid "Action"
    304 msgstr "Handling"
    305 
    306 #: src/Admin/AdminSettings.php:1103
    307 #: src/Admin/AdminSettings.php:1248
    308 msgid "No logs available."
    309 msgstr "Der er ingen logs."
    310 
    311 #: src/Admin/AdminSettings.php:1125
    312 #: src/Admin/AdminSettings.php:1273
    313 msgid "Clear Logs"
    314 msgstr "Ryd logs"
    315 
    316 #: src/Admin/AdminSettings.php:1133
    317 #: src/Admin/AdminSettings.php:1281
    318 msgid "Rows per page:"
    319 msgstr "Rækker pr. side:"
    320 
    321 #: src/Admin/AdminSettings.php:1148
    322 #: src/Admin/AdminSettings.php:1296
    323 msgid "Previous"
    324 msgstr "Forrige"
    325 
    326 #: src/Admin/AdminSettings.php:1155
    327 #: src/Admin/AdminSettings.php:1303
    328 msgid "Next"
    329 msgstr "Næste"
    330 
    331 #: src/Admin/AdminSettings.php:1237
    332 msgid "Tier Name"
    333 msgstr "Niveaunavn"
    334 
    335 #: src/Admin/AdminSettings.php:1238
    336 msgid "Amount"
    337 msgstr "Beløb"
    338 
    339 #: src/Admin/AdminSettings.php:1239
    340 msgid "Subscription"
    341 msgstr "Abonnement"
    342 
    343 #: src/Admin/AdminSettings.php:1240
     570#: src/Admin/MembersTab.php:195
     571msgid "Unknown"
     572msgstr "Ukendt"
     573
     574#: src/Admin/MembersTab.php:214
     575msgid "All"
     576msgstr "Alle"
     577
     578#: src/Admin/MembersTab.php:215
     579#: src/Admin/MembersTab.php:316
     580msgid "Active"
     581msgstr "Aktive"
     582
     583#. translators: The members whose access ends within the next seven days.
     584#: src/Admin/MembersTab.php:217
     585msgid "Ending within 7 days"
     586msgstr "Ophører inden for 7 dage"
     587
     588#: src/Admin/MembersTab.php:218
     589#: src/Admin/MembersTab.php:305
     590msgid "Ended"
     591msgstr "Ophørt"
     592
     593#: src/Admin/MembersTab.php:254
     594#: src/Admin/MembersTab.php:256
     595msgid "Search members"
     596msgstr "Søg efter medlemmer"
     597
     598#: src/Admin/MembersTab.php:255
     599msgid "Name or email"
     600msgstr "Navn eller e-mail"
     601
     602#: src/Admin/MembersTab.php:266
    344603msgid "Status"
    345604msgstr "Status"
    346605
    347 #: src/Admin/AdminSettings.php:1241
    348 msgid "Success"
    349 msgstr "Gennemført"
    350 
    351 #: src/Admin/AdminSettings.php:1242
    352 msgid "Error"
    353 msgstr "Fejl"
    354 
    355 #: src/Admin/AdminSettings.php:1257
    356 msgid "Yes"
    357 msgstr "Ja"
    358 
    359 #: src/Admin/AdminSettings.php:1257
    360 msgid "No"
    361 msgstr "Nej"
     606#: src/Admin/MembersTab.php:267
     607msgid "Member since"
     608msgstr "Medlem siden"
     609
     610#: src/Admin/MembersTab.php:268
     611msgid "Last payment"
     612msgstr "Seneste betaling"
     613
     614#: src/Admin/MembersTab.php:269
     615msgid "Access until"
     616msgstr "Adgang indtil"
     617
     618#: src/Admin/MembersTab.php:274
     619msgid "Nobody has become a member through Ko-fi yet."
     620msgstr "Ingen er endnu blevet medlem via Ko-fi."
     621
     622#: src/Admin/MembersTab.php:274
     623msgid "No members match."
     624msgstr "Ingen medlemmer matcher."
     625
     626#. translators: %s is the name of a role that was removed.
     627#: src/Admin/MembersTab.php:293
     628#, php-format
     629msgid "%s, removed"
     630msgstr "%s, fjernet"
     631
     632#: src/Admin/MembersTab.php:297
     633msgid "Granted before payment; never removed"
     634msgstr "Tildelt før betaling; fjernes aldrig"
     635
     636#. translators: %d is a number of days.
     637#: src/Admin/MembersTab.php:311
     638#, php-format
     639msgid "Ends in %d day"
     640msgid_plural "Ends in %d days"
     641msgstr[0] "Udløber om %d dag"
     642msgstr[1] "Udløber om %d dage"
     643
     644#: src/Admin/MembersTab.php:325
     645msgid "subscription"
     646msgstr "abonnement"
     647
     648#: src/Admin/MembersTab.php:325
     649msgid "one-off"
     650msgstr "enkeltbetaling"
     651
     652#: src/Admin/MembersTab.php:332
     653msgid "No expiry"
     654msgstr "Udløber ikke"
     655
     656#. translators: %s is a date.
     657#: src/Admin/MembersTab.php:335
     658#, php-format
     659msgid "Ended %s"
     660msgstr "Ophørte %s"
     661
     662#: src/Admin/MembersTab.php:367
     663msgid "Cancelling happens on Ko-fi, not here."
     664msgstr "Opsigelse sker hos Ko-fi, ikke her."
    362665
    363666#: src/Membership.php:198
    … …  
    381684msgstr "Log ind på Ko-fi, og gå til Account settings og Subscriptions. Betaler du med PayPal, kan du også opsige det i PayPal."
    382685
    383 #: src/Privacy/PersonalData.php:64
    384 #: src/Privacy/PersonalData.php:79
     686#: src/Privacy/PersonalData.php:65
     687#: src/Privacy/PersonalData.php:80
    385688msgid "Members for Ko-fi logs"
    386689msgstr "Logs fra Members for Ko-fi"
    387690
    388 #: src/Privacy/PersonalData.php:115
     691#: src/Privacy/PersonalData.php:119
    389692msgid "Ko-fi membership"
    390693msgstr "Ko-fi-medlemskab"
    391694
    392 #: src/Privacy/PersonalData.php:145
     695#: src/Privacy/PersonalData.php:148
    393696msgid "Ko-fi membership activity"
    394697msgstr "Aktivitet knyttet til Ko-fi-medlemskab"
    395698
    396 #: src/Privacy/PersonalData.php:165
     699#: src/Privacy/PersonalData.php:166
    397700msgid "Ko-fi webhook requests"
    398701msgstr "Ko-fi-webhook-forespørgsler"
    399702
    400 #: src/Privacy/PersonalData.php:227
     703#: src/Privacy/PersonalData.php:242
    401704msgid "An active Ko-fi membership was kept, so the person keeps the access they pay for. It is removed when the membership ends."
    402705msgstr "Et aktivt Ko-fi-medlemskab blev bevaret, så personen fortsat har den adgang, vedkommende betaler for. Adgangen fjernes, når medlemskabet ophører."
    403706
    404 #: src/Privacy/PersonalData.php:294
    405 #: src/Webhook/Webhook.php:753
    406 #: src/Webhook/Webhook.php:772
    407 msgid "Supporter"
    408 msgstr "Supporter"
    409 
    410 #: src/Privacy/PersonalData.php:335
     707#: src/Privacy/PersonalData.php:362
    411708msgid "When you support us on Ko-fi, Ko-fi sends this site your email address, the name and message you entered, the amount, and your membership tier. We use your email address to create or find your account here and give it the access your support pays for."
    412709msgstr "Når du støtter os på Ko-fi, sender Ko-fi din e-mailadresse, det navn og den besked, du skrev, beløbet og dit medlemsniveau til denne side. Vi bruger din e-mailadresse til at oprette eller finde din konto her og give dig den adgang, din støtte betaler for."
    413710
    414 #: src/Privacy/PersonalData.php:336
     711#: src/Privacy/PersonalData.php:363
    415712msgid "A record of each payment and of the access granted or removed is kept for the number of days set by the site owner, then deleted automatically when log cleanup is enabled. You can ask for a copy of this data, or for it to be erased, through the site's personal data request process."
    416713msgstr "Hver betaling og hver tildelt eller fjernet adgang registreres og gemmes i det antal dage, sidens ejer har valgt, og slettes derefter automatisk, når oprydning i logs er slået til. Du kan bede om en kopi af disse data eller om at få dem slettet via sidens procedure for anmodninger om persondata."
    417 
    418 #: src/Admin/AdminSettings.php
    419 msgid "Only assign roles to subscribers."
    420 msgstr "Tildel kun roller til abonnenter."
    421 
    422 #: src/Admin/AdminSettings.php
    423 msgid "If checked, role will be removed after the set number of days."
    424 msgstr "Hvis markeret, fjernes rollen efter det angivne antal dage."
  • members-for-kofi/tags/1.7.0/languages/members-for-kofi.pot

    r3796045 r3796384  
    33msgid ""
    44msgstr ""
    5 "Project-Id-Version: Members for Ko-fi 1.6.1\n"
     5"Project-Id-Version: Members for Ko-fi 1.7.0\n"
    66"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/members-for-kofi\n"
    77"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
    … …  
    1010"Content-Type: text/plain; charset=UTF-8\n"
    1111"Content-Transfer-Encoding: 8bit\n"
    12 "POT-Creation-Date: 2026-11-11T09:51:50+00:00\n"
     12"POT-Creation-Date: 2026-11-11T11:11:06+00:00\n"
    1313"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
    1414"X-Generator: WP-CLI 2.12.0\n"
    … …  
    1717#. Plugin Name of the plugin
    1818#: members-for-kofi.php
     19#: src/Admin/AdminSettings.php:162
    1920#: src/Plugin.php:436
    2021#: src/Plugin.php:437
    21 #: src/Privacy/PersonalData.php:363
     22#: src/Privacy/PersonalData.php:360
    2223msgid "Members for Ko-fi"
    2324msgstr ""
    … …  
    4344msgstr ""
    4445
    45 #: src/Admin/AdminSettings.php:107
    46 #: src/Admin/AdminSettings.php:671
    47 msgid "Ko-fi Settings"
    48 msgstr ""
    49 
    50 #: src/Admin/AdminSettings.php:108
    51 msgid "Verification Token"
    52 msgstr ""
    53 
    54 #: src/Admin/AdminSettings.php:111
    55 #: src/Admin/AdminSettings.php:675
    56 msgid "Role Assignment"
    57 msgstr ""
    58 
    59 #: src/Admin/AdminSettings.php:112
    60 msgid "Only Accept Subscriptions"
    61 msgstr ""
    62 
    63 #: src/Admin/AdminSettings.php:113
    64 msgid "Tier to Role Mapping"
    65 msgstr ""
    66 
    67 #: src/Admin/AdminSettings.php:114
    68 msgid "Default Role (if no match)"
    69 msgstr ""
    70 
    71 #: src/Admin/AdminSettings.php:115
    72 msgid "Enable Expiry"
    73 msgstr ""
    74 
    75 #: src/Admin/AdminSettings.php:116
    76 msgid "Role Expiry (days)"
    77 msgstr ""
    78 
    79 #: src/Admin/AdminSettings.php:119
    80 #: src/Admin/AdminSettings.php:679
    81 msgid "Logging"
    82 msgstr ""
    83 
    84 #: src/Admin/AdminSettings.php:120
    85 msgid "Automatically Clear Logs"
    86 msgstr ""
    87 
    88 #: src/Admin/AdminSettings.php:121
     46#: src/Admin/AdminSettings.php:110
     47msgid "Copied"
     48msgstr ""
     49
     50#: src/Admin/AdminSettings.php:156
     51msgid "Settings"
     52msgstr ""
     53
     54#: src/Admin/AdminSettings.php:157
     55msgid "Members"
     56msgstr ""
     57
     58#: src/Admin/AdminSettings.php:158
     59msgid "Logs"
     60msgstr ""
     61
     62#: src/Admin/AdminSettings.php:171
     63msgid "Members for Ko-fi sections"
     64msgstr ""
     65
     66#: src/Admin/AdminSettings.php:201
     67msgid "Ko-fi connection"
     68msgstr ""
     69
     70#: src/Admin/AdminSettings.php:208
     71msgid "Press \"Send test\" on Ko-fi"
     72msgstr ""
     73
     74#. translators: %s is a link to the request log.
     75#: src/Admin/AdminSettings.php:213
     76#, php-format
     77msgid "It shows up in the status line above and in %s. A test never creates an account."
     78msgstr ""
     79
     80#: src/Admin/AdminSettings.php:214
     81msgid "the request log"
     82msgstr ""
     83
     84#: src/Admin/AdminSettings.php:223
     85msgid "Who gets a role"
     86msgstr ""
     87
     88#: src/Admin/AdminSettings.php:232
     89msgid "When membership ends"
     90msgstr ""
     91
     92#: src/Admin/AdminSettings.php:239
     93msgid "Ko-fi subscriptions renew monthly, so 35 days leaves a few days' grace. A role the user held before paying is never removed."
     94msgstr ""
     95
     96#: src/Admin/AdminSettings.php:243
     97msgid "Log history"
     98msgstr ""
     99
     100#: src/Admin/AdminSettings.php:250
     101msgid "Untick to keep everything, for example when another tool reads the request log."
     102msgstr ""
     103
     104#: src/Admin/AdminSettings.php:272
     105msgid "Not connected: no token is saved, so Ko-fi's requests are refused."
     106msgstr ""
     107
     108#: src/Admin/AdminSettings.php:277
     109msgid "Waiting for Ko-fi: no request has arrived yet."
     110msgstr ""
     111
     112#. translators: %s is a time span, e.g. "2 hours".
     113#: src/Admin/AdminSettings.php:283
     114#, php-format
     115msgid "Working: the last request from Ko-fi, %s ago, was accepted."
     116msgstr ""
     117
     118#. translators: 1: a time span, e.g. "2 hours", 2: why it was refused, e.g. "the token doesn't match".
     119#: src/Admin/AdminSettings.php:287
     120#, php-format
     121msgid "The last request, %1$s ago, was refused: %2$s"
     122msgstr ""
     123
     124#: src/Admin/AdminSettings.php:302
     125msgid "Ko-fi's webhook settings"
     126msgstr ""
     127
     128#: src/Admin/AdminSettings.php:303
     129msgid "(opens in a new tab)"
     130msgstr ""
     131
     132#: src/Admin/AdminSettings.php:305
     133msgid "Give Ko-fi this webhook URL"
     134msgstr ""
     135
     136#: src/Admin/AdminSettings.php:308
     137msgid "Copy URL"
     138msgstr ""
     139
     140#. translators: %s is a link to Ko-fi's webhook settings.
     141#: src/Admin/AdminSettings.php:314
     142#, php-format
     143msgid "Paste it into %s."
     144msgstr ""
     145
     146#: src/Admin/AdminSettings.php:329
     147msgid "Paste Ko-fi's verification token"
     148msgstr ""
     149
     150#: src/Admin/AdminSettings.php:332
     151msgid "A token is saved"
     152msgstr ""
     153
     154#: src/Admin/AdminSettings.php:334
     155msgid "fingerprint, to compare with another site"
     156msgstr ""
     157
     158#: src/Admin/AdminSettings.php:337
     159msgid "No token is saved yet."
     160msgstr ""
     161
     162#: src/Admin/AdminSettings.php:343
     163msgid "Leave blank to keep the saved token"
     164msgstr ""
     165
     166#: src/Admin/AdminSettings.php:344
     167msgid "On Ko-fi's webhook page, open Advanced and copy the token. Only a hash is stored, so it can't be shown again."
     168msgstr ""
     169
     170#: src/Admin/AdminSettings.php:358
     171msgid "Payments that grant a role"
     172msgstr ""
     173
     174#: src/Admin/AdminSettings.php:359
     175msgid "Donations and subscriptions"
     176msgstr ""
     177
     178#: src/Admin/AdminSettings.php:360
     179msgid "Subscriptions only"
     180msgstr ""
     181
     182#: src/Admin/AdminSettings.php:360
     183msgid "(one-off donations are logged and ignored)"
     184msgstr ""
     185
     186#: src/Admin/AdminSettings.php:393
     187#: src/Admin/AdminSettings.php:421
     188msgid "Tier name on Ko-fi"
     189msgstr ""
     190
     191#: src/Admin/AdminSettings.php:394
     192msgid "Role for this tier"
     193msgstr ""
     194
     195#: src/Admin/AdminSettings.php:395
     196msgid "Remove this tier"
     197msgstr ""
     198
     199#: src/Admin/AdminSettings.php:395
     200msgid "Remove"
     201msgstr ""
     202
     203#: src/Admin/AdminSettings.php:416
     204msgid "Ko-fi tiers"
     205msgstr ""
     206
     207#: src/Admin/AdminSettings.php:422
     208msgid "Role it grants"
     209msgstr ""
     210
     211#: src/Admin/AdminSettings.php:423
     212msgid "Actions"
     213msgstr ""
     214
     215#: src/Admin/AdminSettings.php:433
     216msgid "Add tier"
     217msgstr ""
     218
     219#: src/Admin/AdminSettings.php:434
     220msgid "Names match whatever their capitalisation."
     221msgstr ""
     222
     223#: src/Admin/AdminSettings.php:438
     224msgid "Tiers Ko-fi sent that aren't listed:"
     225msgstr ""
     226
     227#. translators: 1: a tier name, 2: how many payments named it.
     228#: src/Admin/AdminSettings.php:445
     229#, php-format
     230msgid "Add \"%1$s\" (%2$d payment)"
     231msgid_plural "Add \"%1$s\" (%2$d payments)"
     232msgstr[0] ""
     233msgstr[1] ""
     234
     235#: src/Admin/AdminSettings.php:469
     236msgid "Any other tier, or no tier"
     237msgstr ""
     238
     239#: src/Admin/AdminSettings.php:471
     240msgid "— No role —"
     241msgstr ""
     242
     243#: src/Admin/AdminSettings.php:474
     244msgid "With \"No role\", those payers still get an account, but no role. Administrator and other roles that can run the site are never offered."
     245msgstr ""
     246
     247#: src/Admin/AdminSettings.php:489
     248msgid "Remove the role"
     249msgstr ""
     250
     251#: src/Admin/AdminSettings.php:500
     252msgid "Days after the last payment"
     253msgstr ""
     254
     255#: src/Admin/AdminSettings.php:502
     256msgid "days after the member's last payment"
     257msgstr ""
     258
     259#: src/Admin/AdminSettings.php:518
     260msgid "Delete log entries older than"
     261msgstr ""
     262
     263#: src/Admin/AdminSettings.php:529
    89264msgid "Number of Days to Keep Logs"
    90265msgstr ""
    91266
    92 #: src/Admin/AdminSettings.php:150
    93 msgid "Are you sure you want to clear all logs? This action cannot be undone."
    94 msgstr ""
    95 
    96 #: src/Admin/AdminSettings.php:151
    97 msgid "An error occurred. Please try again."
    98 msgstr ""
    99 
    100 #: src/Admin/AdminSettings.php:152
    101 #: src/Admin/AdminSettings.php:752
    102 msgid "Search (email / action / role)"
    103 msgstr ""
    104 
    105 #: src/Admin/AdminSettings.php:153
    106 #: src/Admin/AdminSettings.php:750
    107 msgid "Search (email / tier / status)"
    108 msgstr ""
    109 
    110 #: src/Admin/AdminSettings.php:188
     267#: src/Admin/AdminSettings.php:531
     268msgid "days"
     269msgstr ""
     270
     271#. translators: %s is a human-readable time span, e.g. "3 days".
     272#: src/Admin/AdminSettings.php:575
     273#, php-format
     274msgid "Scheduled tasks are not running: role expiry and log cleanup are %s overdue. Make sure WP-Cron runs, or that a system cron calls wp-cron.php."
     275msgstr ""
     276
     277#. translators: 1: email address, 2: comma-separated role names, or "no role".
     278#: src/Admin/AdminSettings.php:627
     279#, php-format
     280msgid "Members for Ko-fi: an account was created by Ko-fi's \"Send test\" button (%1$s, role: %2$s). It is not a real supporter; earlier versions treated tests as payments. You can safely delete it."
     281msgstr ""
     282
     283#: src/Admin/AdminSettings.php:629
     284msgid "none"
     285msgstr ""
     286
     287#: src/Admin/AdminSettings.php:633
     288msgid "Review the account"
     289msgstr ""
     290
     291#: src/Admin/AdminSettings.php:643
     292msgid "Members for Ko-fi: two different verification tokens are stored, and webhooks are checked against the hashed one. Re-enter your Ko-fi verification token in the settings to resolve this."
     293msgstr ""
     294
     295#: src/Admin/AdminSettings.php:668
     296msgid "Open settings"
     297msgstr ""
     298
     299#: src/Admin/AdminSettings.php:701
    111300msgid "Verification Token is required."
    112301msgstr ""
    113302
    114 #: src/Admin/AdminSettings.php:193
     303#: src/Admin/AdminSettings.php:706
    115304msgid "Role Expiry Days is required and must be a number when expiry is enabled."
    116305msgstr ""
    117306
    118307#. translators: %s is the role name that was rejected.
    119 #: src/Admin/AdminSettings.php:213
    120 #: src/Admin/AdminSettings.php:229
     308#: src/Admin/AdminSettings.php:726
     309#: src/Admin/AdminSettings.php:742
    121310#, php-format
    122311msgid "Security: Role \"%s\" cannot be assigned via webhook for security reasons."
    … …  
    124313
    125314#. translators: %s is the role name that was rejected.
    126 #: src/Admin/AdminSettings.php:241
     315#: src/Admin/AdminSettings.php:754
    127316#, php-format
    128317msgid "Security: Role \"%s\" cannot be used as default role for security reasons."
    129318msgstr ""
    130319
    131 #: src/Admin/AdminSettings.php:290
    132 msgid "A token is saved. Leave this blank to keep it, or paste a new one to replace it."
    133 msgstr ""
    134 
    135 #. translators: %s is the first eight characters of the saved token's SHA-256 hash.
    136 #: src/Admin/AdminSettings.php:293
    137 #, php-format
    138 msgid "Saved token fingerprint: %s"
    139 msgstr ""
    140 
    141 #: src/Admin/AdminSettings.php:297
    142 msgid "No token set yet. Webhooks are rejected until one is saved."
    143 msgstr ""
    144 
    145 #. translators: %s is a link to the Ko-fi Webhooks Management page.
    146 #: src/Admin/AdminSettings.php:307
    147 #, php-format
    148 msgid "This token is used to verify incoming webhook requests from Ko-fi. Paste the verification token from this page: %s and open the Advanced box. Only a hash of it is stored, so it cannot be shown again; to change it, paste the new one."
    149 msgstr ""
    150 
    151 #: src/Admin/AdminSettings.php:308
    152 msgid "Ko-fi Webhooks Management"
    153 msgstr ""
    154 
    155 #: src/Admin/AdminSettings.php:322
    156 msgid "Webhook URL:"
    157 msgstr ""
    158 
    159 #: src/Admin/AdminSettings.php:325
    160 msgid "Copy to Clipboard"
    161 msgstr ""
    162 
    163 #: src/Admin/AdminSettings.php:327
    164 msgid "Use this URL to configure your Ko-fi webhook."
    165 msgstr ""
    166 
    167 #. translators: %s is a human-readable time span, e.g. "3 days".
    168 #: src/Admin/AdminSettings.php:372
    169 #, php-format
    170 msgid "Scheduled tasks are not running: role expiry and log cleanup are %s overdue. Make sure WP-Cron runs, or that a system cron calls wp-cron.php."
    171 msgstr ""
    172 
    173 #. translators: 1: email address, 2: comma-separated role names, or "no role".
    174 #: src/Admin/AdminSettings.php:424
    175 #, php-format
    176 msgid "Members for Ko-fi: an account was created by Ko-fi's \"Send test\" button (%1$s, role: %2$s). It is not a real supporter; earlier versions treated tests as payments. You can safely delete it."
    177 msgstr ""
    178 
    179 #: src/Admin/AdminSettings.php:426
    180 msgid "none"
    181 msgstr ""
    182 
    183 #: src/Admin/AdminSettings.php:430
    184 msgid "Review the account"
    185 msgstr ""
    186 
    187 #: src/Admin/AdminSettings.php:440
    188 msgid "Members for Ko-fi: two different verification tokens are stored, and webhooks are checked against the hashed one. Re-enter your Ko-fi verification token in the settings to resolve this."
    189 msgstr ""
    190 
    191 #: src/Admin/AdminSettings.php:465
    192 msgid "Open settings"
    193 msgstr ""
    194 
    195 #: src/Admin/AdminSettings.php:480
    196 msgid "Only assign roles to subscribers."
    197 msgstr ""
    198 
    199 #: src/Admin/AdminSettings.php:481
    200 msgid "When enabled, users must be active subscribers to receive a role."
    201 msgstr ""
    202 
    203 #: src/Admin/AdminSettings.php:498
    204 msgid "Ko-fi Tier"
    205 msgstr ""
    206 
    207 #: src/Admin/AdminSettings.php:498
    208 #: src/Admin/AdminSettings.php:1101
     320#: src/Admin/LogsTab.php:245
     321msgid "the token doesn't match"
     322msgstr ""
     323
     324#: src/Admin/LogsTab.php:247
     325msgid "no token is saved"
     326msgstr ""
     327
     328#: src/Admin/LogsTab.php:252
     329msgid "the request was malformed"
     330msgstr ""
     331
     332#: src/Admin/LogsTab.php:254
     333msgid "the account could not be created"
     334msgstr ""
     335
     336#. translators: %s is a time of day.
     337#: src/Admin/LogsTab.php:292
     338#, php-format
     339msgid "Today, %s"
     340msgstr ""
     341
     342#. translators: %s is a time of day.
     343#: src/Admin/LogsTab.php:297
     344#, php-format
     345msgid "Yesterday, %s"
     346msgstr ""
     347
     348#: src/Admin/LogsTab.php:365
     349msgid "The log was deleted."
     350msgstr ""
     351
     352#: src/Admin/LogsTab.php:383
     353msgid "Member activity"
     354msgstr ""
     355
     356#: src/Admin/LogsTab.php:396
     357msgid "Requests from Ko-fi"
     358msgstr ""
     359
     360#. translators: %s is the site's time zone, e.g. "Europe/Copenhagen".
     361#: src/Admin/LogsTab.php:401
     362#, php-format
     363msgid "Times are in the site's time zone (%s)."
     364msgstr ""
     365
     366#: src/Admin/LogsTab.php:412
     367#: src/Admin/LogsTab.php:477
     368msgid "Outcome"
     369msgstr ""
     370
     371#: src/Admin/LogsTab.php:414
     372msgid "All outcomes"
     373msgstr ""
     374
     375#: src/Admin/LogsTab.php:415
     376#: src/Admin/LogsTab.php:547
     377msgid "Accepted"
     378msgstr ""
     379
     380#: src/Admin/LogsTab.php:416
     381msgid "Refused"
     382msgstr ""
     383
     384#: src/Admin/LogsTab.php:424
     385msgid "Search email, tier or reason"
     386msgstr ""
     387
     388#: src/Admin/LogsTab.php:426
     389msgid "Search email, action or role"
     390msgstr ""
     391
     392#: src/Admin/LogsTab.php:433
     393msgid "Per page"
     394msgstr ""
     395
     396#: src/Admin/LogsTab.php:440
     397msgid "Filter"
     398msgstr ""
     399
     400#: src/Admin/LogsTab.php:473
     401msgid "Received"
     402msgstr ""
     403
     404#: src/Admin/LogsTab.php:474
     405#: src/Privacy/PersonalData.php:321
     406#: src/Webhook/Webhook.php:911
     407#: src/Webhook/Webhook.php:931
     408msgid "Supporter"
     409msgstr ""
     410
     411#: src/Admin/LogsTab.php:475
     412msgid "Tier"
     413msgstr ""
     414
     415#: src/Admin/LogsTab.php:476
     416msgid "Payment"
     417msgstr ""
     418
     419#: src/Admin/LogsTab.php:478
     420msgid "Details"
     421msgstr ""
     422
     423#: src/Admin/LogsTab.php:483
     424#: src/Admin/LogsTab.php:610
     425msgid "No entries match."
     426msgstr ""
     427
     428#: src/Admin/LogsTab.php:483
     429#: src/Admin/LogsTab.php:610
     430msgid "No entries yet."
     431msgstr ""
     432
     433#: src/Admin/LogsTab.php:504
     434msgid "Ko-fi test"
     435msgstr ""
     436
     437#: src/Admin/LogsTab.php:506
     438msgid "Not stored"
     439msgstr ""
     440
     441#: src/Admin/LogsTab.php:522
     442msgid "not mapped"
     443msgstr ""
     444
     445#: src/Admin/LogsTab.php:530
     446msgid "Test, no account made"
     447msgstr ""
     448
     449#. translators: %s is an amount with its currency, e.g. "5.00 EUR".
     450#: src/Admin/LogsTab.php:536
     451#, php-format
     452msgid "%s · subscription"
     453msgstr ""
     454
     455#. translators: %s is an amount with its currency, e.g. "5.00 EUR".
     456#: src/Admin/LogsTab.php:538
     457#, php-format
     458msgid "%s · one-off"
     459msgstr ""
     460
     461#. translators: %s is why the request was refused, e.g. "the token doesn't match".
     462#: src/Admin/LogsTab.php:552
     463#, php-format
     464msgid "Refused: %s"
     465msgstr ""
     466
     467#: src/Admin/LogsTab.php:560
     468msgid "Payload"
     469msgstr ""
     470
     471#: src/Admin/LogsTab.php:602
     472msgid "When"
     473msgstr ""
     474
     475#: src/Admin/LogsTab.php:603
     476#: src/Admin/MembersTab.php:264
     477msgid "Member"
     478msgstr ""
     479
     480#: src/Admin/LogsTab.php:604
     481msgid "What happened"
     482msgstr ""
     483
     484#: src/Admin/LogsTab.php:605
     485#: src/Admin/MembersTab.php:265
    209486msgid "Role"
    210487msgstr ""
    211488
    212 #: src/Admin/AdminSettings.php:536
    213 msgid "Add Tier Mapping"
    214 msgstr ""
    215 
    216 #: src/Admin/AdminSettings.php:537
    217 msgid "Map Ko-fi tiers to WordPress roles. Only exact tier names will be matched."
    218 msgstr ""
    219 
    220 #: src/Admin/AdminSettings.php:554
    221 msgid "— No default —"
    222 msgstr ""
    223 
    224 #: src/Admin/AdminSettings.php:563
    225 msgid "If no tier matches, or if you are not using tiers, this role will be assigned to the user."
    226 msgstr ""
    227 
    228 #: src/Admin/AdminSettings.php:578
    229 msgid "If checked, role will be removed after the set number of days."
    230 msgstr ""
    231 
    232 #: src/Admin/AdminSettings.php:594
    233 msgid "Number of days the role remains active (only used if expiry is enabled)."
    234 msgstr ""
    235 
    236 #: src/Admin/AdminSettings.php:610
    237 msgid "Enable automatic deletion of old logs based on retention period."
    238 msgstr ""
    239 
    240 #: src/Admin/AdminSettings.php:622
    241 msgid "Number of days to keep logs before automatic deletion (minimum 1 day)."
    242 msgstr ""
    243 
    244 #: src/Admin/AdminSettings.php:650
    245 msgid "Members for Ko-fi Settings"
    246 msgstr ""
    247 
    248 #: src/Admin/AdminSettings.php:661
    249 msgid "Settings"
    250 msgstr ""
    251 
    252 #: src/Admin/AdminSettings.php:664
    253 #: src/Admin/AdminSettings.php:687
    254 msgid "Logs"
    255 msgstr ""
    256 
    257 #: src/Admin/AdminSettings.php:732
    258 msgid "Log Type:"
    259 msgstr ""
    260 
    261 #: src/Admin/AdminSettings.php:736
    262 msgid "User Logs"
    263 msgstr ""
    264 
    265 #: src/Admin/AdminSettings.php:739
    266 msgid "Request Logs"
    267 msgstr ""
    268 
    269 #: src/Admin/AdminSettings.php:759
    270 msgid "Search"
    271 msgstr ""
    272 
    273 #: src/Admin/AdminSettings.php:760
    274 msgid "Reset"
    275 msgstr ""
    276 
    277 #: src/Admin/AdminSettings.php:763
    278 msgid "Refresh"
    279 msgstr ""
    280 
    281 #: src/Admin/AdminSettings.php:794
    282 #: src/Admin/AdminSettings.php:830
    283 #: src/Admin/AdminSettings.php:904
    284 #: src/Admin/AdminSettings.php:933
    285 #: src/Admin/AdminSettings.php:964
    286 #: src/Admin/AdminSettings.php:987
     489#: src/Admin/LogsTab.php:651
     490msgid "First page"
     491msgstr ""
     492
     493#: src/Admin/LogsTab.php:652
     494#: src/Admin/MembersTab.php:351
     495msgid "Previous page"
     496msgstr ""
     497
     498#: src/Admin/LogsTab.php:653
     499#: src/Admin/MembersTab.php:362
     500msgid "Next page"
     501msgstr ""
     502
     503#: src/Admin/LogsTab.php:654
     504msgid "Last page"
     505msgstr ""
     506
     507#: src/Admin/LogsTab.php:665
     508msgid "Current page"
     509msgstr ""
     510
     511#: src/Admin/LogsTab.php:676
     512#: src/Admin/MembersTab.php:349
     513msgid "Pages"
     514msgstr ""
     515
     516#. translators: 1: an input holding the current page number, 2: the number of pages.
     517#. translators: 1: the current page number, 2: the number of pages.
     518#: src/Admin/LogsTab.php:683
     519#: src/Admin/MembersTab.php:356
     520#, php-format
     521msgid "Page %1$s of %2$s"
     522msgstr ""
     523
     524#. translators: %s is the number of entries.
     525#: src/Admin/LogsTab.php:715
     526#, php-format
     527msgid "Removes %s request entry. Member activity is kept."
     528msgid_plural "Removes all %s request entries. Member activity is kept."
     529msgstr[0] ""
     530msgstr[1] ""
     531
     532#. translators: %s is the number of entries.
     533#: src/Admin/LogsTab.php:717
     534#, php-format
     535msgid "Removes %s activity entry. The request log is kept."
     536msgid_plural "Removes all %s activity entries. The request log is kept."
     537msgstr[0] ""
     538msgstr[1] ""
     539
     540#. translators: %s is the number of entries.
     541#: src/Admin/LogsTab.php:720
     542#, php-format
     543msgid "Delete %s entry in this log? This can't be undone."
     544msgid_plural "Delete all %s entries in this log? This can't be undone."
     545msgstr[0] ""
     546msgstr[1] ""
     547
     548#: src/Admin/LogsTab.php:726
     549msgid "Delete this log"
     550msgstr ""
     551
     552#. translators: %d is a number of days.
     553#: src/Admin/LogsTab.php:734
     554#, php-format
     555msgid "Entries older than %d day are deleted automatically."
     556msgid_plural "Entries older than %d days are deleted automatically."
     557msgstr[0] ""
     558msgstr[1] ""
     559
     560#: src/Admin/LogsTab.php:747
     561msgid "Delete request log…"
     562msgstr ""
     563
     564#: src/Admin/LogsTab.php:747
     565msgid "Delete activity log…"
     566msgstr ""
     567
     568#: src/Admin/LogsTab.php:761
    287569msgid "You do not have permission to access this resource."
    288570msgstr ""
    289571
    290 #: src/Admin/AdminSettings.php:803
    291 msgid "Invalid page number."
    292 msgstr ""
    293 
    294 #: src/Admin/AdminSettings.php:863
    295 msgid "Logs table does not exist."
    296 msgstr ""
    297 
    298 #: src/Admin/AdminSettings.php:1097
    299 #: src/Admin/AdminSettings.php:1239
    300 msgid "Timestamp"
    301 msgstr ""
    302 
    303 #: src/Admin/AdminSettings.php:1098
    304 msgid "User ID"
    305 msgstr ""
    306 
    307 #: src/Admin/AdminSettings.php:1099
    308 #: src/Admin/AdminSettings.php:1240
    309 msgid "Email"
    310 msgstr ""
    311 
    312 #: src/Admin/AdminSettings.php:1100
    313 msgid "Action"
    314 msgstr ""
    315 
    316 #: src/Admin/AdminSettings.php:1107
    317 #: src/Admin/AdminSettings.php:1252
    318 msgid "No logs available."
    319 msgstr ""
    320 
    321 #: src/Admin/AdminSettings.php:1129
    322 #: src/Admin/AdminSettings.php:1277
    323 msgid "Clear Logs"
    324 msgstr ""
    325 
    326 #: src/Admin/AdminSettings.php:1137
    327 #: src/Admin/AdminSettings.php:1285
    328 msgid "Rows per page:"
    329 msgstr ""
    330 
    331 #: src/Admin/AdminSettings.php:1152
    332 #: src/Admin/AdminSettings.php:1300
    333 msgid "Previous"
    334 msgstr ""
    335 
    336 #: src/Admin/AdminSettings.php:1159
    337 #: src/Admin/AdminSettings.php:1307
    338 msgid "Next"
    339 msgstr ""
    340 
    341 #: src/Admin/AdminSettings.php:1241
    342 msgid "Tier Name"
    343 msgstr ""
    344 
    345 #: src/Admin/AdminSettings.php:1242
    346 msgid "Amount"
    347 msgstr ""
    348 
    349 #: src/Admin/AdminSettings.php:1243
    350 msgid "Subscription"
    351 msgstr ""
    352 
    353 #: src/Admin/AdminSettings.php:1244
     572#: src/Admin/MembersTab.php:195
     573msgid "Unknown"
     574msgstr ""
     575
     576#: src/Admin/MembersTab.php:214
     577msgid "All"
     578msgstr ""
     579
     580#: src/Admin/MembersTab.php:215
     581#: src/Admin/MembersTab.php:316
     582msgid "Active"
     583msgstr ""
     584
     585#. translators: The members whose access ends within the next seven days.
     586#: src/Admin/MembersTab.php:217
     587msgid "Ending within 7 days"
     588msgstr ""
     589
     590#: src/Admin/MembersTab.php:218
     591#: src/Admin/MembersTab.php:305
     592msgid "Ended"
     593msgstr ""
     594
     595#: src/Admin/MembersTab.php:254
     596#: src/Admin/MembersTab.php:256
     597msgid "Search members"
     598msgstr ""
     599
     600#: src/Admin/MembersTab.php:255
     601msgid "Name or email"
     602msgstr ""
     603
     604#: src/Admin/MembersTab.php:266
    354605msgid "Status"
    355606msgstr ""
    356607
    357 #: src/Admin/AdminSettings.php:1245
    358 msgid "Success"
    359 msgstr ""
    360 
    361 #: src/Admin/AdminSettings.php:1246
    362 msgid "Error"
    363 msgstr ""
    364 
    365 #: src/Admin/AdminSettings.php:1261
    366 msgid "Yes"
    367 msgstr ""
    368 
    369 #: src/Admin/AdminSettings.php:1261
    370 msgid "No"
     608#: src/Admin/MembersTab.php:267
     609msgid "Member since"
     610msgstr ""
     611
     612#: src/Admin/MembersTab.php:268
     613msgid "Last payment"
     614msgstr ""
     615
     616#: src/Admin/MembersTab.php:269
     617msgid "Access until"
     618msgstr ""
     619
     620#: src/Admin/MembersTab.php:274
     621msgid "Nobody has become a member through Ko-fi yet."
     622msgstr ""
     623
     624#: src/Admin/MembersTab.php:274
     625msgid "No members match."
     626msgstr ""
     627
     628#. translators: %s is the name of a role that was removed.
     629#: src/Admin/MembersTab.php:293
     630#, php-format
     631msgid "%s, removed"
     632msgstr ""
     633
     634#: src/Admin/MembersTab.php:297
     635msgid "Granted before payment; never removed"
     636msgstr ""
     637
     638#. translators: %d is a number of days.
     639#: src/Admin/MembersTab.php:311
     640#, php-format
     641msgid "Ends in %d day"
     642msgid_plural "Ends in %d days"
     643msgstr[0] ""
     644msgstr[1] ""
     645
     646#: src/Admin/MembersTab.php:325
     647msgid "subscription"
     648msgstr ""
     649
     650#: src/Admin/MembersTab.php:325
     651msgid "one-off"
     652msgstr ""
     653
     654#: src/Admin/MembersTab.php:332
     655msgid "No expiry"
     656msgstr ""
     657
     658#. translators: %s is a date.
     659#: src/Admin/MembersTab.php:335
     660#, php-format
     661msgid "Ended %s"
     662msgstr ""
     663
     664#: src/Admin/MembersTab.php:367
     665msgid "Cancelling happens on Ko-fi, not here."
    371666msgstr ""
    372667
    … …  
    408703msgstr ""
    409704
    410 #: src/Privacy/PersonalData.php:245
     705#: src/Privacy/PersonalData.php:242
    411706msgid "An active Ko-fi membership was kept, so the person keeps the access they pay for. It is removed when the membership ends."
    412707msgstr ""
    413708
    414 #: src/Privacy/PersonalData.php:324
    415 #: src/Webhook/Webhook.php:911
    416 #: src/Webhook/Webhook.php:931
    417 msgid "Supporter"
    418 msgstr ""
    419 
    420 #: src/Privacy/PersonalData.php:365
     709#: src/Privacy/PersonalData.php:362
    421710msgid "When you support us on Ko-fi, Ko-fi sends this site your email address, the name and message you entered, the amount, and your membership tier. We use your email address to create or find your account here and give it the access your support pays for."
    422711msgstr ""
    423712
    424 #: src/Privacy/PersonalData.php:366
     713#: src/Privacy/PersonalData.php:363
    425714msgid "A record of each payment and of the access granted or removed is kept for the number of days set by the site owner, then deleted automatically when log cleanup is enabled. You can ask for a copy of this data, or for it to be erased, through the site's personal data request process."
    426715msgstr ""
  • members-for-kofi/tags/1.7.0/members-for-kofi.php

    r3796045 r3796384  
    2525 * Plugin URI:        https://github.com/trudslev/members-for-kofi
    2626 * Description:       Integrate with Ko-fi to manage WordPress users or roles via webhook.
    27  * Version:           1.6.1
     27 * Version:           1.7.0
    2828 * Author:            Sune Adelowo Trudslev
    2929 * Author URI:        https://foodgeek.io
  • members-for-kofi/tags/1.7.0/readme.txt

    r3796045 r3796384  
    66Tested up to: 7.1
    77Requires PHP: 7.4
    8 Stable tag: 1.6.1
     8Stable tag: 1.7.0
    99License: GPLv3 or later
    1010License URI: https://www.gnu.org/licenses/gpl-3.0.html
    … …  
    140140
    141141== Changelog ==
     142
     143= 1.7.0 =
     144* Redesign: The settings page is reorganised around how you set it up. "Ko-fi connection" walks through the three steps in Ko-fi's own order (copy the webhook URL, paste the token, press Send test) and shows whether Ko-fi's requests are arriving and being accepted.
     145* Feature: Tiers Ko-fi has sent that aren't mapped to a role are listed, and can be added with one click.
     146* Feature: A new Members tab lists everyone who has or had a membership through Ko-fi, with their role, status, member-since date, last payment and when access ends. It can show only active members, those ending within a week, or those that ended.
     147* Logs: The request log can show only refused requests, says in words why a request was refused, shows times in the site's time zone, links to the member's profile, and shows each request's payload. Paging no longer prints a button for every page.
     148* Logs: Deleting a log now says which log it empties and leaves the other one alone.
     149* Accessibility: Every checkbox and button has a label, and the copy button confirms in place instead of with a pop-up.
     150* Fix: The descriptions of "Subscriptions only" and tier matching were inaccurate; they now say what the settings do.
     151* Security: The log viewer no longer runs on AJAX endpoints, so the many low-privilege accounts this plugin creates have nothing of it to call. Viewing and deleting logs still require an administrator.
     152* Translation: Complete Danish translation of the new screens.
    142153
    143154= 1.6.1 =
    … …  
    223234== Upgrade Notice ==
    224235
     236= 1.7.0 =
     237A redesigned admin page: a connection status, a Members tab, and a log viewer that can show only refused requests. No settings change.
     238
    225239= 1.6.1 =
    226240"Automatically Clear Logs" can now be switched off. If you unticked it before, untick it again and save after updating.
  • members-for-kofi/tags/1.7.0/src/Admin/AdminSettings.php

    r3796045 r3796384  
    2626use MembersForKofi\Webhook\VerificationToken;
    2727
    28 use function add_settings_section;
    29 use function add_settings_field;
    3028use function register_setting;
    3129use function add_settings_error;
    … …  
    3331
    3432/**
    35  * Handles the admin settings for the Members for Ko-fi plugin.
     33 * The plugin's admin page: Settings, Members and Logs tabs.
    3634 *
    37  * This class is responsible for registering and rendering the settings
    38  * for the plugin in the WordPress admin dashboard.
     35 * Each tab is its own URL (`?page=members-for-kofi&tab=…`), so a tab or a
     36 * filtered log can be linked and reloaded. The Members and Logs tabs live in
     37 * MembersTab and LogsTab; this class renders the page, the Settings tab and
     38 * the admin notices, and sanitizes the settings.
    3939 *
    4040 * @package MembersForKofi
    … …  
    4343
    4444        /**
    45          * Page sizes the log viewer offers.
    46          *
    47          * @var array<int>
    48          */
    49         public const ROWS_PER_PAGE_OPTIONS = array( 10, 25, 50, 100 );
    50 
    51         /**
    52          * Page size used when none is given, or when an invalid one is.
    53          *
    54          * @var int
    55          */
    56         public const DEFAULT_ROWS_PER_PAGE = 10;
    57         /**
    58          * Resolve the current logs table name, handling legacy name mismatch.
    59          *
    60          * @return string Fully qualified table name with prefix.
    61          */
    62         private function get_logs_table_name(): string {
    63                 global $wpdb;
    64                 return $wpdb->prefix . 'members_for_kofi_user_logs';
    65         }
    66 
    67         /**
    68          * Constructor for the AdminSettings class.
    69          *
    70          * Initializes the class by adding the necessary WordPress actions.
     45         * The page's tabs, in order.
     46         *
     47         * @var array<int,string>
     48         */
     49        public const TABS = array( 'settings', 'members', 'logs' );
     50
     51        /**
     52         * Registers the page's handlers and assets.
    7153         */
    7254        public function __construct() {
    73                 // Register settings.
    74                 add_action( 'admin_init', array( $this, 'register_settings' ) );
    75 
    76                 // Enqueue admin scripts.
    7755                add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_scripts' ) );
    78 
    79                 // Register AJAX handlers.
    80                 add_action( 'wp_ajax_members_for_kofi_pagination', array( $this, 'handle_pagination' ) );
    81                 add_action( 'wp_ajax_members_for_kofi_clear_logs', array( $this, 'handle_clear_logs' ) );
    82                 add_action( 'wp_ajax_members_for_kofi_update_rows_per_page', array( $this, 'handle_update_rows_per_page' ) );
    83                 add_action( 'wp_ajax_members_for_kofi_filter_logs', array( $this, 'handle_filter_logs' ) );
    84                 add_action( 'wp_ajax_members_for_kofi_refresh_logs', array( $this, 'handle_refresh_logs' ) );
    85                 add_action( 'wp_ajax_members_for_kofi_switch_log_type', array( $this, 'handle_switch_log_type' ) );
    86         }
    87 
    88 
    89         /**
    90          * Registers the settings for the Members for Ko-fi plugin.
    91          *
    92          * This function sets up the settings sections and fields for the plugin
    93          * in the WordPress admin dashboard.
     56                LogsTab::register();
     57        }
     58
     59        /**
     60         * Registers the plugin's setting and its sanitizer.
     61         *
     62         * The form is rendered by render_settings_tab(), not by Settings API
     63         * sections, so only the setting itself is registered here.
    9464         *
    9565         * @return void
    … …  
    10373                        )
    10474                );
    105 
    106                 // Ko-fi Settings section.
    107                 add_settings_section( 'members_for_kofi_general', __( 'Ko-fi Settings', 'members-for-kofi' ), '__return_null', 'members-for-kofi' );
    108                 add_settings_field( 'verification_token', __( 'Verification Token', 'members-for-kofi' ), array( $this, 'render_verification_token_field' ), 'members-for-kofi', 'members_for_kofi_general' );
    109 
    110                 // Role Assignment section.
    111                 add_settings_section( 'members_for_kofi_role_assignment', __( 'Role Assignment', 'members-for-kofi' ), '__return_null', 'members-for-kofi' );
    112                 add_settings_field( 'only_subscriptions', __( 'Only Accept Subscriptions', 'members-for-kofi' ), array( $this, 'render_only_subscriptions_field' ), 'members-for-kofi', 'members_for_kofi_role_assignment' );
    113                 add_settings_field( 'tier_role_map', __( 'Tier to Role Mapping', 'members-for-kofi' ), array( $this, 'render_tier_role_map_field' ), 'members-for-kofi', 'members_for_kofi_role_assignment' );
    114                 add_settings_field( 'default_role', __( 'Default Role (if no match)', 'members-for-kofi' ), array( $this, 'render_default_role_field' ), 'members-for-kofi', 'members_for_kofi_role_assignment' );
    115                 add_settings_field( 'enable_expiry', __( 'Enable Expiry', 'members-for-kofi' ), array( $this, 'render_expiry_toggle_field' ), 'members-for-kofi', 'members_for_kofi_role_assignment' );
    116                 add_settings_field( 'role_expiry_days', __( 'Role Expiry (days)', 'members-for-kofi' ), array( $this, 'render_role_expiry_field' ), 'members-for-kofi', 'members_for_kofi_role_assignment' );
    117 
    118                 // Logging section.
    119                 add_settings_section( 'members_for_kofi_logging', __( 'Logging', 'members-for-kofi' ), '__return_null', 'members-for-kofi' );
    120                 add_settings_field( 'auto_clear_logs', __( 'Automatically Clear Logs', 'members-for-kofi' ), array( $this, 'render_auto_clear_logs_field' ), 'members-for-kofi', 'members_for_kofi_logging' );
    121                 add_settings_field( 'log_retention_days', __( 'Number of Days to Keep Logs', 'members-for-kofi' ), array( $this, 'render_log_retention_days_field' ), 'members-for-kofi', 'members_for_kofi_logging' );
    122         }   /**
    123                  * Enqueues the admin JavaScript file for the Members for Ko-fi settings page.
    124                  *
    125                  * @return void
    126                  */
     75        }
     76
     77        /**
     78         * Enqueues the page's script and stylesheet, on this page only.
     79         *
     80         * @return void
     81         */
    12782        public function enqueue_admin_scripts(): void {
    12883                $screen = get_current_screen();
    12984
    130                 if ( isset( $screen->id ) && 'toplevel_page_members-for-kofi' === $screen->id ) {
    131                         wp_enqueue_script(
    132                                 'members-for-kofi-admin-settings',
    133                                 plugins_url( 'assets/js/admin-settings.js', dirname( __DIR__ ) ),
    134                                 array(),
    135                                 '1.0.6',
    136                                 true
    137                         );
    138 
    139                         wp_localize_script(
    140                                 'members-for-kofi-admin-settings',
    141                                 'kofiMembers',
    142                                 array(
    143                                         'ajaxurl'            => admin_url( 'admin-ajax.php' ),
    144                                         'paginationNonce'    => wp_create_nonce( 'members_for_kofi_pagination' ),
    145                                         'clearLogsNonce'     => wp_create_nonce( 'members_for_kofi_clear_logs' ),
    146                                         'rowsPerPageNonce'   => wp_create_nonce( 'members_for_kofi_update_rows_per_page' ),
    147                                         'filterNonce'        => wp_create_nonce( 'members_for_kofi_filter_logs' ),
    148                                         'refreshNonce'       => wp_create_nonce( 'members_for_kofi_refresh_logs' ),
    149                                         'switchLogTypeNonce' => wp_create_nonce( 'members_for_kofi_switch_log_type' ),
    150                                         'clearLogsConfirm'   => __( 'Are you sure you want to clear all logs? This action cannot be undone.', 'members-for-kofi' ),
    151                                         'errorMessage'       => __( 'An error occurred. Please try again.', 'members-for-kofi' ),
    152                                         'searchLabelUser'    => __( 'Search (email / action / role)', 'members-for-kofi' ),
    153                                         'searchLabelRequest' => __( 'Search (email / tier / status)', 'members-for-kofi' ),
     85                if ( ! isset( $screen->id ) || 'toplevel_page_members-for-kofi' !== $screen->id ) {
     86                        return;
     87                }
     88
     89                $version = get_file_data( MEMBERS_FOR_KOFI_PLUGIN_FILE, array( 'Version' => 'Version' ) )['Version'];
     90
     91                wp_enqueue_style(
     92                        'members-for-kofi-admin',
     93                        plugins_url( 'assets/css/admin.css', dirname( __DIR__ ) ),
     94                        array(),
     95                        $version
     96                );
     97
     98                wp_enqueue_script(
     99                        'members-for-kofi-admin-settings',
     100                        plugins_url( 'assets/js/admin-settings.js', dirname( __DIR__ ) ),
     101                        array(),
     102                        $version,
     103                        true
     104                );
     105
     106                wp_localize_script(
     107                        'members-for-kofi-admin-settings',
     108                        'kofiMembers',
     109                        array(
     110                                'copied' => __( 'Copied', 'members-for-kofi' ),
     111                        )
     112                );
     113        }
     114
     115        /**
     116         * The tab the URL asks for.
     117         *
     118         * @return string
     119         */
     120        public static function current_tab(): string {
     121                // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Choosing which tab to display.
     122                $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'settings';
     123
     124                // The log viewer used to link to its own tab name.
     125                if ( 'user_logs' === $tab ) {
     126                        return 'logs';
     127                }
     128
     129                return in_array( $tab, self::TABS, true ) ? $tab : 'settings';
     130        }
     131
     132        /**
     133         * URL of one of the page's tabs.
     134         *
     135         * @param string $tab Tab key.
     136         * @return string
     137         */
     138        public static function tab_url( string $tab ): string {
     139                $args = array( 'page' => 'members-for-kofi' );
     140
     141                if ( 'settings' !== $tab ) {
     142                        $args['tab'] = $tab;
     143                }
     144
     145                return add_query_arg( $args, admin_url( 'admin.php' ) );
     146        }
     147
     148        /**
     149         * Renders the admin page.
     150         *
     151         * @return void
     152         */
     153        public function render_settings_page(): void {
     154                $active = self::current_tab();
     155                $labels = array(
     156                        'settings' => __( 'Settings', 'members-for-kofi' ),
     157                        'members'  => __( 'Members', 'members-for-kofi' ),
     158                        'logs'     => __( 'Logs', 'members-for-kofi' ),
     159                );
     160                ?>
     161                <div class="wrap mfk-wrap">
     162                        <h1><?php esc_html_e( 'Members for Ko-fi', 'members-for-kofi' ); ?></h1>
     163                        <?php
     164                        // WordPress prints Settings API messages by itself only on pages
     165                        // under Settings. This is a top-level page, so without this call a
     166                        // rejected save ("Verification Token is required", a disallowed
     167                        // role) was silently dropped, and a good one got no confirmation.
     168                        settings_errors();
     169                        $this->render_cron_warning();
     170                        ?>
     171                        <nav class="nav-tab-wrapper" aria-label="<?php esc_attr_e( 'Members for Ko-fi sections', 'members-for-kofi' ); ?>">
     172                                <?php foreach ( $labels as $tab => $label ) : ?>
     173                                        <a href="<?php echo esc_url( self::tab_url( $tab ) ); ?>" class="nav-tab<?php echo $tab === $active ? ' nav-tab-active' : ''; ?>"<?php echo $tab === $active ? ' aria-current="page"' : ''; ?>><?php echo esc_html( $label ); ?></a>
     174                                <?php endforeach; ?>
     175                        </nav>
     176                        <?php
     177                        if ( 'members' === $active ) {
     178                                MembersTab::render();
     179                        } elseif ( 'logs' === $active ) {
     180                                LogsTab::render();
     181                        } else {
     182                                $this->render_settings_tab();
     183                        }
     184                        ?>
     185                </div>
     186                <?php
     187        }
     188
     189        /**
     190         * Renders the Settings tab.
     191         *
     192         * @return void
     193         */
     194        public function render_settings_tab(): void {
     195                ?>
     196                <form method="post" action="options.php" id="members-for-kofi-settings-form" class="mfk-settings">
     197                        <?php settings_fields( 'members_for_kofi_options' ); ?>
     198
     199                        <section class="mfk-card" aria-labelledby="mfk-connection">
     200                                <div class="mfk-card-head">
     201                                        <h2 id="mfk-connection"><?php esc_html_e( 'Ko-fi connection', 'members-for-kofi' ); ?></h2>
     202                                        <?php $this->render_connection_status(); ?>
     203                                </div>
     204                                <ol class="mfk-steps">
     205                                        <li><?php $this->render_webhook_url_field(); ?></li>
     206                                        <li><?php $this->render_verification_token_field(); ?></li>
     207                                        <li>
     208                                                <p class="mfk-step-title"><?php esc_html_e( 'Press "Send test" on Ko-fi', 'members-for-kofi' ); ?></p>
     209                                                <p class="description">
     210                                                        <?php
     211                                                        printf(
     212                                                                // translators: %s is a link to the request log.
     213                                                                esc_html__( 'It shows up in the status line above and in %s. A test never creates an account.', 'members-for-kofi' ),
     214                                                                '<a href="' . esc_url( add_query_arg( 'log', 'requests', self::tab_url( 'logs' ) ) ) . '">' . esc_html__( 'the request log', 'members-for-kofi' ) . '</a>'
     215                                                        );
     216                                                        ?>
     217                                                </p>
     218                                        </li>
     219                                </ol>
     220                        </section>
     221
     222                        <section class="mfk-card mfk-card-body" aria-labelledby="mfk-roles">
     223                                <h2 id="mfk-roles"><?php esc_html_e( 'Who gets a role', 'members-for-kofi' ); ?></h2>
     224                                <?php
     225                                $this->render_only_subscriptions_field();
     226                                $this->render_tier_role_map_field();
     227                                $this->render_default_role_field();
     228                                ?>
     229                        </section>
     230
     231                        <section class="mfk-card mfk-card-body" aria-labelledby="mfk-expiry">
     232                                <h2 id="mfk-expiry"><?php esc_html_e( 'When membership ends', 'members-for-kofi' ); ?></h2>
     233                                <p class="mfk-sentence">
     234                                        <?php
     235                                        $this->render_expiry_toggle_field();
     236                                        $this->render_role_expiry_field();
     237                                        ?>
     238                                </p>
     239                                <p class="description"><?php esc_html_e( 'Ko-fi subscriptions renew monthly, so 35 days leaves a few days\' grace. A role the user held before paying is never removed.', 'members-for-kofi' ); ?></p>
     240                        </section>
     241
     242                        <section class="mfk-card mfk-card-body" aria-labelledby="mfk-log-history">
     243                                <h2 id="mfk-log-history"><?php esc_html_e( 'Log history', 'members-for-kofi' ); ?></h2>
     244                                <p class="mfk-sentence">
     245                                        <?php
     246                                        $this->render_auto_clear_logs_field();
     247                                        $this->render_log_retention_days_field();
     248                                        ?>
     249                                </p>
     250                                <p class="description"><?php esc_html_e( 'Untick to keep everything, for example when another tool reads the request log.', 'members-for-kofi' ); ?></p>
     251                        </section>
     252
     253                        <?php submit_button(); ?>
     254                </form>
     255                <?php
     256        }
     257
     258        /**
     259         * Says whether Ko-fi's requests are arriving and being accepted.
     260         *
     261         * Based on the newest row of the request log, which also holds refused
     262         * requests from anyone who finds the URL: a refusal says "the last
     263         * request", not "Ko-fi".
     264         *
     265         * @return void
     266         */
     267        public function render_connection_status(): void {
     268                $options = get_option( VerificationToken::OPTION );
     269
     270                if ( '' === VerificationToken::stored_hash( $options ) ) {
     271                        $class = 'mfk-status-error';
     272                        $text  = __( 'Not connected: no token is saved, so Ko-fi\'s requests are refused.', 'members-for-kofi' );
     273                } else {
     274                        $latest = LogsTab::latest_request();
     275                        if ( null === $latest ) {
     276                                $class = 'mfk-status-waiting';
     277                                $text  = __( 'Waiting for Ko-fi: no request has arrived yet.', 'members-for-kofi' );
     278                        } else {
     279                                $ago = human_time_diff( LogsTab::to_unix( (string) $latest['timestamp'] ) );
     280                                if ( '1' === (string) $latest['success'] ) {
     281                                        $class = 'mfk-status-ok';
     282                                        // translators: %s is a time span, e.g. "2 hours".
     283                                        $text = sprintf( __( 'Working: the last request from Ko-fi, %s ago, was accepted.', 'members-for-kofi' ), $ago );
     284                                } else {
     285                                        $class = 'mfk-status-error';
     286                                        // translators: 1: a time span, e.g. "2 hours", 2: why it was refused, e.g. "the token doesn't match".
     287                                        $text = sprintf( __( 'The last request, %1$s ago, was refused: %2$s', 'members-for-kofi' ), $ago, LogsTab::refusal_reason( (string) $latest['error'] ) );
     288                                }
     289                        }
     290                }
     291
     292                echo '<p class="mfk-status ' . esc_attr( $class ) . '">' . esc_html( $text ) . '</p>';
     293        }
     294
     295        /**
     296         * Step 1: the webhook URL to give Ko-fi, with a copy button.
     297         *
     298         * @return void
     299         */
     300        public function render_webhook_url_field(): void {
     301                $link = '<a href="' . esc_url( 'https://ko-fi.com/manage/webhooks?src=sidemenu' ) . '" target="_blank" rel="noopener noreferrer">'
     302                        . esc_html__( 'Ko-fi\'s webhook settings', 'members-for-kofi' )
     303                        . '<span class="screen-reader-text"> ' . esc_html__( '(opens in a new tab)', 'members-for-kofi' ) . '</span></a>';
     304                ?>
     305                <label for="webhook-kofi-url" class="mfk-step-title"><?php esc_html_e( 'Give Ko-fi this webhook URL', 'members-for-kofi' ); ?></label>
     306                <div class="mfk-copy-row">
     307                        <input type="text" id="webhook-kofi-url" value="<?php echo esc_url( self::webhook_url() ); ?>" readonly class="regular-text code">
     308                        <button type="button" id="copy-webhook-url" class="button"><?php esc_html_e( 'Copy URL', 'members-for-kofi' ); ?></button>
     309                        <span id="mfk-copy-status" class="mfk-copy-status" role="status"></span>
     310                </div>
     311                <p class="description">
     312                        <?php
     313                        // translators: %s is a link to Ko-fi's webhook settings.
     314                        printf( esc_html__( 'Paste it into %s.', 'members-for-kofi' ), $link ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Built from escaped parts above.
     315                        ?>
     316                </p>
     317                <?php
     318        }
     319
     320        /**
     321         * Step 2: the verification token. Write-only: the saved one is never shown.
     322         *
     323         * @return void
     324         */
     325        public function render_verification_token_field(): void {
     326                $options     = get_option( 'members_for_kofi_options' );
     327                $fingerprint = VerificationToken::fingerprint( $options );
     328                ?>
     329                <label for="mfk-token" class="mfk-step-title"><?php esc_html_e( 'Paste Ko-fi\'s verification token', 'members-for-kofi' ); ?></label>
     330                <?php if ( '' !== $fingerprint ) : ?>
     331                        <p class="mfk-token-state">
     332                                <?php esc_html_e( 'A token is saved', 'members-for-kofi' ); ?>
     333                                <code><?php echo esc_html( $fingerprint ); ?></code>
     334                                <span class="mfk-muted"><?php esc_html_e( 'fingerprint, to compare with another site', 'members-for-kofi' ); ?></span>
     335                        </p>
     336                <?php else : ?>
     337                        <p class="mfk-token-state mfk-status-error"><?php esc_html_e( 'No token is saved yet.', 'members-for-kofi' ); ?></p>
     338                <?php endif; ?>
     339                <?php if ( VerificationToken::has_conflict( $options ) ) : ?>
     340                        <p class="mfk-status-error"><?php echo esc_html( self::token_conflict_message() ); ?></p>
     341                <?php endif; ?>
     342                <?php // Write-only: the saved token is never rendered back into the page, and only its hash is stored, so the value is always empty. ?>
     343                <input type="password" id="mfk-token" name="members_for_kofi_options[verification_token]" value="" class="regular-text" autocomplete="new-password"<?php echo '' !== $fingerprint ? ' placeholder="' . esc_attr__( 'Leave blank to keep the saved token', 'members-for-kofi' ) . '"' : ''; ?>>
     344                <p class="description"><?php esc_html_e( 'On Ko-fi\'s webhook page, open Advanced and copy the token. Only a hash is stored, so it can\'t be shown again.', 'members-for-kofi' ); ?></p>
     345                <?php
     346        }
     347
     348        /**
     349         * Which payments grant a role. Radios, so both answers are always sent.
     350         *
     351         * @return void
     352         */
     353        public static function render_only_subscriptions_field(): void {
     354                $options = get_option( 'members_for_kofi_options', array() );
     355                $only    = ! empty( $options['only_subscriptions'] );
     356                ?>
     357                <fieldset class="mfk-field">
     358                        <legend class="mfk-field-title"><?php esc_html_e( 'Payments that grant a role', 'members-for-kofi' ); ?></legend>
     359                        <label><input type="radio" name="members_for_kofi_options[only_subscriptions]" value="0"<?php checked( ! $only ); ?>> <?php esc_html_e( 'Donations and subscriptions', 'members-for-kofi' ); ?></label><br>
     360                        <label><input type="radio" name="members_for_kofi_options[only_subscriptions]" value="1"<?php checked( $only ); ?>> <?php esc_html_e( 'Subscriptions only', 'members-for-kofi' ); ?> <span class="mfk-muted"><?php esc_html_e( '(one-off donations are logged and ignored)', 'members-for-kofi' ); ?></span></label>
     361                </fieldset>
     362                <?php
     363        }
     364
     365        /**
     366         * Options for a role select: assignable roles only.
     367         *
     368         * @param string $selected Selected role slug.
     369         * @return string Escaped `<option>` elements.
     370         */
     371        private function role_options( string $selected ): string {
     372                $html = '';
     373
     374                foreach ( get_editable_roles() as $key => $details ) {
     375                        if ( ! Webhook::is_assignable_role( $key ) ) {
     376                                continue;
     377                        }
     378                        $html .= '<option value="' . esc_attr( $key ) . '"' . selected( $selected, $key, false ) . '>' . esc_html( translate_user_role( $details['name'] ) ) . '</option>';
     379                }
     380
     381                return $html;
     382        }
     383
     384        /**
     385         * One row of the tier table.
     386         *
     387         * @param string $tier Tier name.
     388         * @param string $role Role slug.
     389         * @return string
     390         */
     391        private function tier_row( string $tier, string $role ): string {
     392                return '<tr>'
     393                        . '<td><input type="text" name="members_for_kofi_options[tier_role_map][tier][]" value="' . esc_attr( $tier ) . '" class="regular-text tier-name-input" aria-label="' . esc_attr__( 'Tier name on Ko-fi', 'members-for-kofi' ) . '"></td>'
     394                        . '<td><select name="members_for_kofi_options[tier_role_map][role][]" class="tier-role-select" aria-label="' . esc_attr__( 'Role for this tier', 'members-for-kofi' ) . '">' . $this->role_options( $role ) . '</select></td>'
     395                        . '<td class="mfk-actions"><button type="button" class="button-link button-link-delete remove-tier-role-row" aria-label="' . esc_attr__( 'Remove this tier', 'members-for-kofi' ) . '">' . esc_html__( 'Remove', 'members-for-kofi' ) . '</button></td>'
     396                        . '</tr>';
     397        }
     398
     399        /**
     400         * The tier table, with tiers Ko-fi sent that are not mapped yet.
     401         *
     402         * @return void
     403         */
     404        public function render_tier_role_map_field(): void {
     405                $options = get_option( 'members_for_kofi_options', array() );
     406                $map     = isset( $options['tier_role_map'] ) && is_array( $options['tier_role_map'] ) ? $options['tier_role_map'] : array();
     407
     408                $rows = '';
     409                foreach ( $map as $tier => $role ) {
     410                        $rows .= $this->tier_row( (string) $tier, (string) $role );
     411                }
     412
     413                $unmapped = LogsTab::unmapped_tiers( array_map( 'strval', array_keys( $map ) ) );
     414                ?>
     415                <div class="mfk-field">
     416                        <p class="mfk-field-title"><?php esc_html_e( 'Ko-fi tiers', 'members-for-kofi' ); ?></p>
     417                        <div class="mfk-table-wrap">
     418                                <table id="tier-role-map-table" class="widefat mfk-table mfk-tier-table">
     419                                        <thead>
     420                                                <tr>
     421                                                        <th scope="col"><?php esc_html_e( 'Tier name on Ko-fi', 'members-for-kofi' ); ?></th>
     422                                                        <th scope="col"><?php esc_html_e( 'Role it grants', 'members-for-kofi' ); ?></th>
     423                                                        <th scope="col"><span class="screen-reader-text"><?php esc_html_e( 'Actions', 'members-for-kofi' ); ?></span></th>
     424                                                </tr>
     425                                        </thead>
     426                                        <tbody>
     427                                                <?php echo $rows; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Built by tier_row(), which escapes every value. ?>
     428                                        </tbody>
     429                                </table>
     430                        </div>
     431                        <template id="tier-role-row-template"><?php echo $this->tier_row( '', '' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped in tier_row(). ?></template>
     432                        <p class="mfk-inline">
     433                                <button type="button" class="button" id="add-tier-role-row"><?php esc_html_e( 'Add tier', 'members-for-kofi' ); ?></button>
     434                                <span class="mfk-muted"><?php esc_html_e( 'Names match whatever their capitalisation.', 'members-for-kofi' ); ?></span>
     435                        </p>
     436                        <?php if ( array() !== $unmapped ) : ?>
     437                                <div class="mfk-unmapped">
     438                                        <span><?php esc_html_e( 'Tiers Ko-fi sent that aren\'t listed:', 'members-for-kofi' ); ?></span>
     439                                        <?php foreach ( $unmapped as $tier => $payments ) : ?>
     440                                                <button type="button" class="button mfk-add-unmapped" data-tier="<?php echo esc_attr( $tier ); ?>">
     441                                                        <?php
     442                                                        echo esc_html(
     443                                                                sprintf(
     444                                                                        // translators: 1: a tier name, 2: how many payments named it.
     445                                                                        _n( 'Add "%1$s" (%2$d payment)', 'Add "%1$s" (%2$d payments)', $payments, 'members-for-kofi' ),
     446                                                                        $tier,
     447                                                                        $payments
     448                                                                )
     449                                                        );
     450                                                        ?>
     451                                                </button>
     452                                        <?php endforeach; ?>
     453                                </div>
     454                        <?php endif; ?>
     455                </div>
     456                <?php
     457        }
     458
     459        /**
     460         * The role for payments no tier matches.
     461         *
     462         * @return void
     463         */
     464        public function render_default_role_field(): void {
     465                $options  = get_option( 'members_for_kofi_options', array() );
     466                $selected = isset( $options['default_role'] ) && is_string( $options['default_role'] ) ? $options['default_role'] : '';
     467                ?>
     468                <div class="mfk-field">
     469                        <label for="mfk-default-role" class="mfk-field-title"><?php esc_html_e( 'Any other tier, or no tier', 'members-for-kofi' ); ?></label>
     470                        <select id="mfk-default-role" name="members_for_kofi_options[default_role]">
     471                                <option value=""><?php esc_html_e( '— No role —', 'members-for-kofi' ); ?></option>
     472                                <?php echo $this->role_options( $selected ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped in role_options(). ?>
     473                        </select>
     474                        <p class="description"><?php esc_html_e( 'With "No role", those payers still get an account, but no role. Administrator and other roles that can run the site are never offered.', 'members-for-kofi' ); ?></p>
     475                </div>
     476                <?php
     477        }
     478
     479        /**
     480         * "Remove the role" checkbox: the start of the expiry sentence.
     481         *
     482         * @return void
     483         */
     484        public static function render_expiry_toggle_field(): void {
     485                $options = get_option( 'members_for_kofi_options', array() );
     486                $checked = ! empty( $options['enable_expiry'] );
     487                echo '<input type="hidden" name="members_for_kofi_options[enable_expiry]" value="0">';
     488                echo '<input type="checkbox" id="members_for_kofi_enable_expiry" name="members_for_kofi_options[enable_expiry]" value="1"' . checked( $checked, true, false ) . '> ';
     489                echo '<label for="members_for_kofi_enable_expiry">' . esc_html__( 'Remove the role', 'members-for-kofi' ) . '</label> ';
     490        }
     491
     492        /**
     493         * "[35] days after the member's last payment": the end of the sentence.
     494         *
     495         * @return void
     496         */
     497        public static function render_role_expiry_field(): void {
     498                $options = get_option( 'members_for_kofi_options', array() );
     499                $value   = isset( $options['role_expiry_days'] ) ? intval( $options['role_expiry_days'] ) : 35;
     500                echo '<label for="members_for_kofi_expiry_days" class="screen-reader-text">' . esc_html__( 'Days after the last payment', 'members-for-kofi' ) . '</label>';
     501                echo '<input type="number" id="members_for_kofi_expiry_days" name="members_for_kofi_options[role_expiry_days]" value="' . esc_attr( (string) $value ) . '" min="1" class="small-text"> ';
     502                echo '<span>' . esc_html__( 'days after the member\'s last payment', 'members-for-kofi' ) . '</span>';
     503        }
     504
     505        /**
     506         * "Delete log entries older than" checkbox.
     507         *
     508         * @return void
     509         */
     510        public static function render_auto_clear_logs_field(): void {
     511                $options = get_option( 'members_for_kofi_options', array() );
     512                $checked = isset( $options['auto_clear_logs'] ) ? (bool) $options['auto_clear_logs'] : true;
     513                // A browser sends nothing for an unchecked box, and the sanitizer reads a
     514                // missing value as on (the default for installs that predate the
     515                // setting). Without this hidden "0", unticking the box never stuck.
     516                echo '<input type="hidden" name="members_for_kofi_options[auto_clear_logs]" value="0">';
     517                echo '<input type="checkbox" id="members_for_kofi_auto_clear_logs" name="members_for_kofi_options[auto_clear_logs]" value="1"' . checked( $checked, true, false ) . '> ';
     518                echo '<label for="members_for_kofi_auto_clear_logs">' . esc_html__( 'Delete log entries older than', 'members-for-kofi' ) . '</label> ';
     519        }
     520
     521        /**
     522         * "[30] days": the end of the log sentence.
     523         *
     524         * @return void
     525         */
     526        public static function render_log_retention_days_field(): void {
     527                $options = get_option( 'members_for_kofi_options', array() );
     528                $value   = isset( $options['log_retention_days'] ) ? intval( $options['log_retention_days'] ) : 30;
     529                echo '<label for="members_for_kofi_log_retention_days" class="screen-reader-text">' . esc_html__( 'Number of Days to Keep Logs', 'members-for-kofi' ) . '</label>';
     530                echo '<input type="number" id="members_for_kofi_log_retention_days" name="members_for_kofi_options[log_retention_days]" value="' . esc_attr( (string) $value ) . '" min="1" class="small-text"> ';
     531                echo '<span>' . esc_html__( 'days', 'members-for-kofi' ) . '</span>';
     532        }
     533
     534        /**
     535         * The webhook address to give Ko-fi.
     536         *
     537         * `/webhook-kofi/` needs pretty permalinks to reach WordPress at all on
     538         * some servers; the query form works everywhere, so it is what a site on
     539         * plain permalinks is shown.
     540         *
     541         * @return string
     542         */
     543        public static function webhook_url(): string {
     544                if ( '' === (string) get_option( 'permalink_structure' ) ) {
     545                        return add_query_arg( 'kofi_webhook', '1', home_url( '/' ) );
     546                }
     547
     548                return home_url( '/webhook-kofi/' );
     549        }
     550
     551        /**
     552         * Warns when the plugin's scheduled tasks are not running.
     553         *
     554         * With WP-Cron disabled and no system cron, roles never expire -- free
     555         * access for anyone who ever paid -- and logs are never pruned, with
     556         * nothing to say so. An event more than a day overdue is that evidence.
     557         *
     558         * @return void
     559         */
     560        public function render_cron_warning(): void {
     561                $options = get_option( 'members_for_kofi_options', array() );
     562                $hooks   = array( 'kofi_members_cleanup_logs' );
     563
     564                if ( ! empty( $options['enable_expiry'] ?? true ) ) {
     565                        $hooks[] = 'kofi_members_check_expired_roles';
     566                }
     567
     568                foreach ( $hooks as $hook ) {
     569                        $next = wp_next_scheduled( $hook );
     570
     571                        if ( false !== $next && $next < time() - DAY_IN_SECONDS ) {
     572                                echo '<div class="notice notice-warning"><p>' . esc_html(
     573                                        sprintf(
     574                                                // translators: %s is a human-readable time span, e.g. "3 days".
     575                                                __( 'Scheduled tasks are not running: role expiry and log cleanup are %s overdue. Make sure WP-Cron runs, or that a system cron calls wp-cron.php.', 'members-for-kofi' ),
     576                                                human_time_diff( $next )
     577                                        )
     578                                ) . '</p></div>';
     579                                return;
     580                        }
     581                }
     582        }
     583
     584        /**
     585         * Points administrators at an account left behind by Ko-fi's test button.
     586         *
     587         * Before 1.3.0 a "Send test" webhook was processed like a payment, so a
     588         * site that ever pressed it has a "Jo Example" account, usually holding the
     589         * membership role. Shown where an administrator would see and act on it.
     590         *
     591         * @param string|null $screen_id Screen to check; defaults to the current one.
     592         * @return void
     593         */
     594        public static function render_test_account_notice( $screen_id = null ): void {
     595                if ( ! current_user_can( 'delete_users' ) ) {
     596                        return;
     597                }
     598
     599                // do_action( 'admin_notices' ) hands every callback an empty string, so
     600                // '' has to mean "the current screen" just like no argument does.
     601                if ( ! is_string( $screen_id ) || '' === $screen_id ) {
     602                        $screen    = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
     603                        $screen_id = $screen ? (string) $screen->id : '';
     604                }
     605
     606                if ( ! in_array( $screen_id, array( 'dashboard', 'users', 'toplevel_page_members-for-kofi' ), true ) ) {
     607                        return;
     608                }
     609
     610                $user = get_user_by( 'email', Webhook::KOFI_TEST_EMAIL );
     611
     612                if ( ! $user ) {
     613                        return;
     614                }
     615
     616                $names = wp_roles()->get_names();
     617                $roles = array();
     618                foreach ( $user->roles as $slug ) {
     619                        $roles[] = isset( $names[ $slug ] ) ? translate_user_role( $names[ $slug ] ) : $slug;
     620                }
     621
     622                printf(
     623                        '<div class="notice notice-warning"><p>%s <a href="%s">%s</a></p></div>',
     624                        esc_html(
     625                                sprintf(
     626                                        // translators: 1: email address, 2: comma-separated role names, or "no role".
     627                                        __( 'Members for Ko-fi: an account was created by Ko-fi\'s "Send test" button (%1$s, role: %2$s). It is not a real supporter; earlier versions treated tests as payments. You can safely delete it.', 'members-for-kofi' ),
     628                                        Webhook::KOFI_TEST_EMAIL,
     629                                        array() !== $roles ? implode( ', ', $roles ) : __( 'none', 'members-for-kofi' )
    154630                                )
    155                         );
    156                 }
     631                        ),
     632                        esc_url( get_edit_user_link( $user->ID ) ),
     633                        esc_html__( 'Review the account', 'members-for-kofi' )
     634                );
     635        }
     636
     637        /**
     638         * Explains a stored hash and plaintext token that disagree.
     639         *
     640         * @return string
     641         */
     642        private static function token_conflict_message(): string {
     643                return __( 'Members for Ko-fi: two different verification tokens are stored, and webhooks are checked against the hashed one. Re-enter your Ko-fi verification token in the settings to resolve this.', 'members-for-kofi' );
     644        }
     645
     646        /**
     647         * Warns administrators, on every admin screen, about conflicting tokens.
     648         *
     649         * The site is verifying against a hash nobody can inspect, and the debug
     650         * log is off on most sites, so this is the only place it will be noticed.
     651         * Saving a token clears it: the settings never store the plaintext key.
     652         *
     653         * @return void
     654         */
     655        public static function render_token_conflict_notice(): void {
     656                if ( ! current_user_can( 'manage_options' ) ) {
     657                        return;
     658                }
     659
     660                if ( ! VerificationToken::has_conflict( get_option( VerificationToken::OPTION ) ) ) {
     661                        return;
     662                }
     663
     664                printf(
     665                        '<div class="notice notice-error"><p>%s <a href="%s">%s</a></p></div>',
     666                        esc_html( self::token_conflict_message() ),
     667                        esc_url( admin_url( 'admin.php?page=members-for-kofi' ) ),
     668                        esc_html__( 'Open settings', 'members-for-kofi' )
     669                );
    157670        }
    158671
    … …  
    194707                }
    195708
    196                 if ( ! $enable_expiry ) {
    197                         // Preserve existing stored value if expiry disabled, fallback to default.
     709                if ( ! $enable_expiry && empty( $role_expiry_days ) ) {
     710                        // Expiry is off and no usable number was sent: keep the stored one.
    198711                        $role_expiry_days = $stored['role_expiry_days'] ?? 35;
    199712                }
    … …  
    269782                        'log_retention_days'        => $log_retention_days,
    270783                );
    271         }   /**
    272                  * Renders the verification token field in the settings page.
    273                  *
    274                  * This function outputs the HTML for the verification token input field,
    275                  * allowing users to enter the token required for webhook verification.
    276                  *
    277                  * @return void
    278                  */
    279         public function render_verification_token_field(): void {
    280                 $options     = get_option( 'members_for_kofi_options' );
    281                 $webhook_url = self::webhook_url();
    282 
    283                 $fingerprint = VerificationToken::fingerprint( $options );
    284 
    285                 // Write-only: the saved token is never rendered back into the page, and
    286                 // only its hash is stored, so the value is always empty.
    287                 echo '<input type="password" name="members_for_kofi_options[verification_token]" value="" class="regular-text" autocomplete="new-password">';
    288 
    289                 if ( '' !== $fingerprint ) {
    290                         echo '<p class="description">' . esc_html__( 'A token is saved. Leave this blank to keep it, or paste a new one to replace it.', 'members-for-kofi' ) . '</p>';
    291                         echo '<p class="description">' . sprintf(
    292                                 // translators: %s is the first eight characters of the saved token's SHA-256 hash.
    293                                 esc_html__( 'Saved token fingerprint: %s', 'members-for-kofi' ),
    294                                 '<code>' . esc_html( $fingerprint ) . '</code>'
    295                         ) . '</p>';
    296                 } else {
    297                         echo '<p class="description">' . esc_html__( 'No token set yet. Webhooks are rejected until one is saved.', 'members-for-kofi' ) . '</p>';
    298                 }
    299 
    300                 if ( VerificationToken::has_conflict( $options ) ) {
    301                         echo '<p class="description" style="color: #b32d2e;">' . esc_html( self::token_conflict_message() ) . '</p>';
    302                 }
    303 
    304                 // Description for Verification Token.
    305                 $description = sprintf(
    306                         // translators: %s is a link to the Ko-fi Webhooks Management page.
    307                         esc_html__( 'This token is used to verify incoming webhook requests from Ko-fi. Paste the verification token from this page: %s and open the Advanced box. Only a hash of it is stored, so it cannot be shown again; to change it, paste the new one.', 'members-for-kofi' ),
    308                         '<a href="' . esc_url( 'https://ko-fi.com/manage/webhooks?src=sidemenu' ) . '" target="_blank">' . esc_html__( 'Ko-fi Webhooks Management', 'members-for-kofi' ) . '</a>'
    309                 );
    310                 echo '<p class="description">' . wp_kses(
    311                         $description,
    312                         array(
    313                                 'a' => array(
    314                                         'href'   => array(),
    315                                         'target' => array(),
    316                                 ),
    317                         )
    318                 ) . '</p>';
    319 
    320                 // Webhook URL Textbox and Copy Button.
    321                 echo '<div style="margin-top: 20px;">';
    322                 echo '<label for="webhook-kofi-url" style="font-weight: bold; display: block; margin-bottom: 5px;">' . esc_html__( 'Webhook URL:', 'members-for-kofi' ) . '</label>';
    323                 echo '<div style="display: flex; align-items: center; width: 500px;">';
    324                 echo '<input type="text" id="webhook-kofi-url" value="' . esc_url( $webhook_url ) . '" readonly style="flex: 1; margin-right: 10px;" class="regular-text">';
    325                 echo '<button type="button" id="copy-webhook-url" class="button button-secondary">' . esc_html__( 'Copy to Clipboard', 'members-for-kofi' ) . '</button>';
    326                 echo '</div>';
    327                 echo '<p class="description">' . esc_html__( 'Use this URL to configure your Ko-fi webhook.', 'members-for-kofi' ) . '</p>';
    328                 echo '</div>';
    329         }
    330 
    331         /**
    332          * The webhook address to give Ko-fi.
    333          *
    334          * `/webhook-kofi/` needs pretty permalinks to reach WordPress at all on
    335          * some servers; the query form works everywhere, so it is what a site on
    336          * plain permalinks is shown.
    337          *
    338          * @return string
    339          */
    340         public static function webhook_url(): string {
    341                 if ( '' === (string) get_option( 'permalink_structure' ) ) {
    342                         return add_query_arg( 'kofi_webhook', '1', home_url( '/' ) );
    343                 }
    344 
    345                 return home_url( '/webhook-kofi/' );
    346         }
    347 
    348         /**
    349          * Warns when the plugin's scheduled tasks are not running.
    350          *
    351          * With WP-Cron disabled and no system cron, roles never expire -- free
    352          * access for anyone who ever paid -- and logs are never pruned, with
    353          * nothing to say so. An event more than a day overdue is that evidence.
    354          *
    355          * @return void
    356          */
    357         public function render_cron_warning(): void {
    358                 $options = get_option( 'members_for_kofi_options', array() );
    359                 $hooks   = array( 'kofi_members_cleanup_logs' );
    360 
    361                 if ( ! empty( $options['enable_expiry'] ?? true ) ) {
    362                         $hooks[] = 'kofi_members_check_expired_roles';
    363                 }
    364 
    365                 foreach ( $hooks as $hook ) {
    366                         $next = wp_next_scheduled( $hook );
    367 
    368                         if ( false !== $next && $next < time() - DAY_IN_SECONDS ) {
    369                                 echo '<div class="notice notice-warning"><p>' . esc_html(
    370                                         sprintf(
    371                                                 // translators: %s is a human-readable time span, e.g. "3 days".
    372                                                 __( 'Scheduled tasks are not running: role expiry and log cleanup are %s overdue. Make sure WP-Cron runs, or that a system cron calls wp-cron.php.', 'members-for-kofi' ),
    373                                                 human_time_diff( $next )
    374                                         )
    375                                 ) . '</p></div>';
    376                                 return;
    377                         }
    378                 }
    379         }
    380 
    381         /**
    382          * Points administrators at an account left behind by Ko-fi's test button.
    383          *
    384          * Before 1.3.0 a "Send test" webhook was processed like a payment, so a
    385          * site that ever pressed it has a "Jo Example" account, usually holding the
    386          * membership role. Shown where an administrator would see and act on it.
    387          *
    388          * @param string|null $screen_id Screen to check; defaults to the current one.
    389          * @return void
    390          */
    391         public static function render_test_account_notice( $screen_id = null ): void {
    392                 if ( ! current_user_can( 'delete_users' ) ) {
    393                         return;
    394                 }
    395 
    396                 // do_action( 'admin_notices' ) hands every callback an empty string, so
    397                 // '' has to mean "the current screen" just like no argument does.
    398                 if ( ! is_string( $screen_id ) || '' === $screen_id ) {
    399                         $screen    = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
    400                         $screen_id = $screen ? (string) $screen->id : '';
    401                 }
    402 
    403                 if ( ! in_array( $screen_id, array( 'dashboard', 'users', 'toplevel_page_members-for-kofi' ), true ) ) {
    404                         return;
    405                 }
    406 
    407                 $user = get_user_by( 'email', Webhook::KOFI_TEST_EMAIL );
    408 
    409                 if ( ! $user ) {
    410                         return;
    411                 }
    412 
    413                 $names = wp_roles()->get_names();
    414                 $roles = array();
    415                 foreach ( $user->roles as $slug ) {
    416                         $roles[] = isset( $names[ $slug ] ) ? translate_user_role( $names[ $slug ] ) : $slug;
    417                 }
    418 
    419                 printf(
    420                         '<div class="notice notice-warning"><p>%s <a href="%s">%s</a></p></div>',
    421                         esc_html(
    422                                 sprintf(
    423                                         // translators: 1: email address, 2: comma-separated role names, or "no role".
    424                                         __( 'Members for Ko-fi: an account was created by Ko-fi\'s "Send test" button (%1$s, role: %2$s). It is not a real supporter; earlier versions treated tests as payments. You can safely delete it.', 'members-for-kofi' ),
    425                                         Webhook::KOFI_TEST_EMAIL,
    426                                         array() !== $roles ? implode( ', ', $roles ) : __( 'none', 'members-for-kofi' )
    427                                 )
    428                         ),
    429                         esc_url( get_edit_user_link( $user->ID ) ),
    430                         esc_html__( 'Review the account', 'members-for-kofi' )
    431                 );
    432         }
    433 
    434         /**
    435          * Explains a stored hash and plaintext token that disagree.
    436          *
    437          * @return string
    438          */
    439         private static function token_conflict_message(): string {
    440                 return __( 'Members for Ko-fi: two different verification tokens are stored, and webhooks are checked against the hashed one. Re-enter your Ko-fi verification token in the settings to resolve this.', 'members-for-kofi' );
    441         }
    442 
    443         /**
    444          * Warns administrators, on every admin screen, about conflicting tokens.
    445          *
    446          * The site is verifying against a hash nobody can inspect, and the debug
    447          * log is off on most sites, so this is the only place it will be noticed.
    448          * Saving a token clears it: the settings never store the plaintext key.
    449          *
    450          * @return void
    451          */
    452         public static function render_token_conflict_notice(): void {
    453                 if ( ! current_user_can( 'manage_options' ) ) {
    454                         return;
    455                 }
    456 
    457                 if ( ! VerificationToken::has_conflict( get_option( VerificationToken::OPTION ) ) ) {
    458                         return;
    459                 }
    460 
    461                 printf(
    462                         '<div class="notice notice-error"><p>%s <a href="%s">%s</a></p></div>',
    463                         esc_html( self::token_conflict_message() ),
    464                         esc_url( admin_url( 'admin.php?page=members-for-kofi' ) ),
    465                         esc_html__( 'Open settings', 'members-for-kofi' )
    466                 );
    467         }
    468 
    469         /**
    470          * Renders the "Only Accept Subscriptions" field in the settings page.
    471          *
    472          * This function outputs the HTML for the checkbox that allows users
    473          * to enable or disable role assignment only for active subscribers.
    474          *
    475          * @return void
    476          */
    477         public static function render_only_subscriptions_field(): void {
    478                 $options = get_option( 'members_for_kofi_options', array() );
    479                 $checked = ! empty( $options['only_subscriptions'] );
    480                 echo '<label><input type="checkbox" name="members_for_kofi_options[only_subscriptions]" value="1" ' . checked( $checked, true, false ) . '> ' . esc_html__( 'Only assign roles to subscribers.', 'members-for-kofi' ) . '</label>';
    481                 echo '<p class="description">' . esc_html__( 'When enabled, users must be active subscribers to receive a role.', 'members-for-kofi' ) . '</p>';
    482         }
    483 
    484         /**
    485          * Renders the Tier to Role Mapping field in the settings page.
    486          *
    487          * This function outputs the HTML for mapping Ko-fi tiers to WordPress roles,
    488          * allowing users to assign roles based on Ko-fi tier names.
    489          *
    490          * @return void
    491          */
    492         public function render_tier_role_map_field(): void {
    493                 $options = get_option( 'members_for_kofi_options', array() );
    494                 $map     = isset( $options['tier_role_map'] ) && is_array( $options['tier_role_map'] ) ? $options['tier_role_map'] : array();
    495                 $roles   = get_editable_roles();
    496 
    497                 echo '<table id="tier-role-map-table" class="form-table" style="margin-top:0">';
    498                 echo '<thead><tr><th>' . esc_html__( 'Ko-fi Tier', 'members-for-kofi' ) . '</th><th>' . esc_html__( 'Role', 'members-for-kofi' ) . '</th><th></th></tr></thead><tbody>';
    499 
    500                 if ( ! empty( $map ) ) {
    501                         foreach ( $map as $tier => $role ) {
    502                                 $tier_val = esc_attr( $tier );
    503                                 $role_val = esc_attr( $role );
    504                                 echo '<tr>';
    505                                 echo '<td><input type="text" name="members_for_kofi_options[tier_role_map][tier][]" value="' . esc_attr( $tier_val ) . '" class="regular-text"></td>';
    506                                 echo '<td><select name="members_for_kofi_options[tier_role_map][role][]" class="regular-text">';
    507                                 foreach ( $roles as $key => $details ) {
    508                                         if ( ! Webhook::is_assignable_role( $key ) ) {
    509                                                 continue;
    510                                         }
    511                                         echo '<option value="' . esc_attr( $key ) . '"' . selected( $role_val, $key, false ) . '>' . esc_html( $details['name'] ) . '</option>';
    512                                 }
    513                                 echo '</select></td>';
    514                                 echo '<td><button type="button" class="button remove-tier-role-row">×</button></td>';
    515                                 echo '</tr>';
    516                         }
    517                 }
    518 
    519                 echo '</tbody></table>';
    520 
    521                 echo '<template id="tier-role-row-template">';
    522                 echo '<tr>';
    523                 echo '<td><input type="text" name="members_for_kofi_options[tier_role_map][tier][]" class="regular-text tier-name-input"></td>';
    524                 echo '<td><select name="members_for_kofi_options[tier_role_map][role][]" class="regular-text tier-role-select">';
    525                 foreach ( $roles as $key => $details ) {
    526                         if ( ! Webhook::is_assignable_role( $key ) ) {
    527                                 continue;
    528                         }
    529                         echo '<option value="' . esc_attr( $key ) . '">' . esc_html( $details['name'] ) . '</option>';
    530                 }
    531                 echo '</select></td>';
    532                 echo '<td><button type="button" class="button remove-tier-role-row">×</button></td>';
    533                 echo '</tr>';
    534                 echo '</template>';
    535 
    536                 echo '<p><button type="button" class="button" id="add-tier-role-row">' . esc_html__( 'Add Tier Mapping', 'members-for-kofi' ) . '</button></p>';
    537                 echo '<p><em>' . esc_html__( 'Map Ko-fi tiers to WordPress roles. Only exact tier names will be matched.', 'members-for-kofi' ) . '</em></p>';
    538         }
    539 
    540         /**
    541          * Renders the default role field in the settings page.
    542          *
    543          * This function outputs the HTML for selecting a default WordPress role
    544          * to assign to users when no tier matches or tiers are not used.
    545          *
    546          * @return void
    547          */
    548         public function render_default_role_field(): void {
    549                 $options  = get_option( 'members_for_kofi_options', array() );
    550                 $roles    = get_editable_roles();
    551                 $selected = $options['default_role'] ?? array();
    552 
    553                 echo '<select name="members_for_kofi_options[default_role]">';
    554                 echo '<option value="">' . esc_html__( '— No default —', 'members-for-kofi' ) . '</option>';
    555                 foreach ( $roles as $role_key => $role_details ) {
    556                         // Skip disallowed roles (e.g., administrator) for security.
    557                         if ( ! Webhook::is_assignable_role( $role_key ) ) {
    558                                 continue;
    559                         }
    560                         echo '<option value="' . esc_attr( $role_key ) . '"' . selected( $role_key, $selected, false ) . '>' . esc_html( $role_details['name'] ) . '</option>';
    561                 }
    562                 echo '</select>';
    563                 echo '<p class="description">' . esc_html__( 'If no tier matches, or if you are not using tiers, this role will be assigned to the user.', 'members-for-kofi' ) . '</p>';
    564         }
    565 
    566         /**
    567          * Renders the "Enable Expiry" toggle field in the settings page.
    568          *
    569          * This function outputs the HTML for the checkbox that allows users
    570          * to enable or disable role expiry after a set number of days.
    571          *
    572          * @return void
    573          */
    574         public static function render_expiry_toggle_field(): void {
    575                 $options = get_option( 'members_for_kofi_options', array() );
    576                 $checked = ! empty( $options['enable_expiry'] );
    577                 echo '<label><input type="checkbox" name="members_for_kofi_options[enable_expiry]" value="1" ' . checked( $checked, true, false ) . '></label>';
    578                 echo '<p class="description">' . esc_html__( 'If checked, role will be removed after the set number of days.', 'members-for-kofi' ) . '</p>';
    579         }
    580 
    581         /**
    582          * Renders the role expiry field in the settings page.
    583          *
    584          * This function outputs the HTML for the input field that allows users
    585          * to specify the number of days a role remains active for non-subscribers.
    586          *
    587          * @return void
    588          */
    589         public static function render_role_expiry_field(): void {
    590                 $options       = get_option( 'members_for_kofi_options', array() );
    591                 $value         = isset( $options['role_expiry_days'] ) ? intval( $options['role_expiry_days'] ) : 35;
    592                 $expiry_enable = ! empty( $options['enable_expiry'] );
    593                 echo '<input type="number" id="members_for_kofi_expiry_days" name="members_for_kofi_options[role_expiry_days]" value="' . esc_attr( $value ) . '" min="1" ' . disabled( ! $expiry_enable, true, false ) . ' class="small-text">';
    594                 echo '<p class="description">' . esc_html__( 'Number of days the role remains active (only used if expiry is enabled).', 'members-for-kofi' ) . '</p>';
    595         }
    596 
    597         /**
    598          * Renders the auto clear logs checkbox field.
    599          *
    600          * @return void
    601          */
    602         public static function render_auto_clear_logs_field(): void {
    603                 $options = get_option( 'members_for_kofi_options', array() );
    604                 $checked = isset( $options['auto_clear_logs'] ) ? (bool) $options['auto_clear_logs'] : true;
    605                 // A browser sends nothing for an unchecked box, and the sanitizer reads a
    606                 // missing value as on (the default for installs that predate the
    607                 // setting). Without this hidden "0", unticking the box never stuck.
    608                 echo '<input type="hidden" name="members_for_kofi_options[auto_clear_logs]" value="0">';
    609                 echo '<input type="checkbox" id="members_for_kofi_auto_clear_logs" name="members_for_kofi_options[auto_clear_logs]" value="1" ' . checked( $checked, true, false ) . '>';
    610                 echo '<p class="description">' . esc_html__( 'Enable automatic deletion of old logs based on retention period.', 'members-for-kofi' ) . '</p>';
    611         }
    612 
    613         /**
    614          * Renders the log retention days field.
    615          *
    616          * @return void
    617          */
    618         public static function render_log_retention_days_field(): void {
    619                 $options = get_option( 'members_for_kofi_options', array() );
    620                 $value   = isset( $options['log_retention_days'] ) ? intval( $options['log_retention_days'] ) : 30;
    621                 echo '<input type="number" id="members_for_kofi_log_retention_days" name="members_for_kofi_options[log_retention_days]" value="' . esc_attr( $value ) . '" min="1" class="small-text">';
    622                 echo '<p class="description">' . esc_html__( 'Number of days to keep logs before automatic deletion (minimum 1 day).', 'members-for-kofi' ) . '</p>';
    623         }
    624 
    625         /**
    626          * Renders the log level field in the settings page.
    627          *
    628          * This function outputs the HTML for selecting the minimum severity
    629          * of log messages to record in the plugin's log file.
    630          *
    631          * @return void
    632          */
    633         // Deprecated: log level field removed with lightweight logger; method retained intentionally removed.
    634 
    635         /**
    636          * Renders the settings page for the Members for Ko-fi plugin.
    637          * This function outputs the HTML for the settings page in the WordPress admin dashboard,
    638          * allowing users to configure the plugin's settings.
    639          *
    640          * @return void
    641          */
    642         public function render_settings_page(): void {
    643                 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading tab from URL for display only
    644                 $active_tab = isset( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : 'settings';
    645                 if ( ! in_array( $active_tab, array( 'settings', 'logs' ), true ) ) {
    646                         $active_tab = 'settings';
    647                 }
    648                 ?>
    649                 <div class="wrap">
    650                         <h1><?php esc_html_e( 'Members for Ko-fi Settings', 'members-for-kofi' ); ?></h1>
    651                         <?php
    652                         // WordPress prints Settings API messages by itself only on pages
    653                         // under Settings. This is a top-level page, so without this call a
    654                         // rejected save ("Verification Token is required", a disallowed
    655                         // role) was silently dropped, and a good one got no confirmation.
    656                         settings_errors();
    657                         $this->render_cron_warning();
    658                         ?>
    659                         <h2 class="nav-tab-wrapper" role="tablist">
    660                                 <a href="#" role="tab" data-tab="settings" aria-selected="<?php echo 'settings' === $active_tab ? 'true' : 'false'; ?>" class="nav-tab <?php echo 'settings' === $active_tab ? 'nav-tab-active' : ''; ?>">
    661                                         <?php esc_html_e( 'Settings', 'members-for-kofi' ); ?>
    662                                 </a>
    663                                 <a href="#" role="tab" data-tab="logs" aria-selected="<?php echo 'logs' === $active_tab ? 'true' : 'false'; ?>" class="nav-tab <?php echo 'logs' === $active_tab ? 'nav-tab-active' : ''; ?>">
    664                                         <?php esc_html_e( 'Logs', 'members-for-kofi' ); ?>
    665                                 </a>
    666                         </h2>
    667                         <input type="hidden" id="active_tab" value="<?php echo esc_attr( $active_tab ); ?>" />
    668                         <form method="post" action="options.php" id="members-for-kofi-settings-form">
    669                                 <?php settings_fields( 'members_for_kofi_options' ); ?>
    670                                 <div id="members-for-kofi-tab-settings" class="members-for-kofi-tab" style="<?php echo 'settings' === $active_tab ? '' : 'display:none;'; ?>">
    671                                         <h2><?php esc_html_e( 'Ko-fi Settings', 'members-for-kofi' ); ?></h2>
    672                                         <table class="form-table">
    673                                                 <?php do_settings_fields( 'members-for-kofi', 'members_for_kofi_general' ); ?>
    674                                         </table>
    675                                         <h2><?php esc_html_e( 'Role Assignment', 'members-for-kofi' ); ?></h2>
    676                                         <table class="form-table">
    677                                                 <?php do_settings_fields( 'members-for-kofi', 'members_for_kofi_role_assignment' ); ?>
    678                                         </table>
    679                                         <h2><?php esc_html_e( 'Logging', 'members-for-kofi' ); ?></h2>
    680                                         <table class="form-table">
    681                                                 <?php do_settings_fields( 'members-for-kofi', 'members_for_kofi_logging' ); ?>
    682                                         </table>
    683                                         <?php submit_button(); ?>
    684                                 </div>
    685                         </form>
    686                         <div id="members-for-kofi-tab-logs" class="members-for-kofi-tab" style="<?php echo 'logs' === $active_tab ? '' : 'display:none;'; ?>">
    687                                 <h2><?php esc_html_e( 'Logs', 'members-for-kofi' ); ?></h2>
    688                                 <?php $this->render_logs_tab(); ?>
    689                         </div>
    690                 </div>
    691                 <?php
    692         }
    693 
    694         /**
    695          * Renders the "Logs" tab in the settings page.
    696          *
    697          * This function outputs the HTML for displaying logs with a dropdown to switch between user and request logs.
    698          *
    699          * @param int|null $paged         The current page number, or null to auto-determine.
    700          * @param int      $rows_per_page The number of rows to display per page.
    701          * @param string   $log_type      The type of logs to display ('user' or 'request').
    702          * @return void
    703          */
    704         public function render_logs_tab( ?int $paged = null, int $rows_per_page = 10, string $log_type = 'user' ): void {
    705                 $rows_per_page = $this->sanitize_rows_per_page( $rows_per_page );
    706                 global $wpdb;
    707 
    708                 // Determine which table to query based on log type.
    709                 if ( 'request' === $log_type ) {
    710                         $table_name    = $wpdb->prefix . 'members_for_kofi_request_logs';
    711                         $transient_key = 'members_for_kofi_total_request_logs';
    712                 } else {
    713                         $table_name    = $this->get_logs_table_name();
    714                         $transient_key = 'members_for_kofi_total_logs';
    715                 }
    716 
    717                 $total_logs = get_transient( $transient_key );
    718 
    719                 if ( false === $total_logs || 0 === (int) $total_logs ) {
    720                         // Recalculate if not cached or cached as zero (could be stale from prior table mismatch).
    721                         // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared -- Counting rows in plugin-owned table.
    722                         $total_logs = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . esc_sql( $table_name ) . '`' );
    723                         set_transient( $transient_key, $total_logs, MINUTE_IN_SECONDS );
    724                 }
    725 
    726                 $total_pages = ceil( $total_logs / $rows_per_page );
    727 
    728                 ?>
    729                 <!-- Log Type Selector -->
    730                 <div style="margin-bottom:15px;">
    731                         <label for="log-type-selector" style="font-weight:bold; margin-right:10px;">
    732                                 <?php esc_html_e( 'Log Type:', 'members-for-kofi' ); ?>
    733                         </label>
    734                         <select id="log-type-selector" style="min-width:150px;">
    735                                 <option value="user" <?php selected( $log_type, 'user' ); ?>>
    736                                         <?php esc_html_e( 'User Logs', 'members-for-kofi' ); ?>
    737                                 </option>
    738                                 <option value="request" <?php selected( $log_type, 'request' ); ?>>
    739                                         <?php esc_html_e( 'Request Logs', 'members-for-kofi' ); ?>
    740                                 </option>
    741                         </select>
    742                 </div>
    743 
    744                 <!-- Logs Table Container -->
    745                 <div id="logs-controls" style="display:flex; gap:10px; align-items:flex-end; margin-bottom:10px;">
    746                         <div>
    747                                 <label for="kofi-members-log-search" style="display:block;">
    748                                         <?php
    749                                         if ( 'request' === $log_type ) {
    750                                                 esc_html_e( 'Search (email / tier / status)', 'members-for-kofi' );
    751                                         } else {
    752                                                 esc_html_e( 'Search (email / action / role)', 'members-for-kofi' );
    753                                         }
    754                                         ?>
    755                                 </label>
    756                                 <input type="search" id="kofi-members-log-search" style="min-width:280px;" />
    757                         </div>
    758                         <div>
    759                                 <button type="button" class="button" id="kofi-members-log-search-btn"><?php esc_html_e( 'Search', 'members-for-kofi' ); ?></button>
    760                                 <button type="button" class="button" id="kofi-members-log-reset-btn"><?php esc_html_e( 'Reset', 'members-for-kofi' ); ?></button>
    761                         </div>
    762                         <div>
    763                                 <button type="button" class="button" id="kofi-members-log-refresh-btn"><?php esc_html_e( 'Refresh', 'members-for-kofi' ); ?></button>
    764                         </div>
    765                 </div>
    766                 <div id="logs-table-container" data-current-search="" data-log-type="<?php echo esc_attr( $log_type ); ?>">
    767                         <?php
    768                         if ( 'request' === $log_type ) {
    769                                 $this->render_request_logs_table( $paged, $rows_per_page );
    770                         } else {
    771                                 $this->render_user_logs_table( $paged, $rows_per_page );
    772                         }
    773                         ?>
    774                 </div>
    775 
    776                 <?php
    777         }
    778 
    779         /**
    780          * Handles the AJAX request for pagination in the user logs tab.
    781          *
    782          * This function verifies the user's permissions, processes the pagination request,
    783          * and returns the rendered logs table for the requested page.
    784          *
    785          * @return void
    786          */
    787         public function handle_pagination(): void {
    788                 // Verify the nonce for security.
    789                 check_ajax_referer( 'members_for_kofi_pagination', '_ajax_nonce' );
    790                 \MembersForKofi\Logging\DebugLogger::debug( 'AJAX pagination request' );
    791 
    792                 // Verify the current user has permission to view logs.
    793                 if ( ! current_user_can( 'manage_options' ) ) {
    794                         wp_send_json_error( __( 'You do not have permission to access this resource.', 'members-for-kofi' ) );
    795                 }
    796 
    797                 $paged    = isset( $_POST['paged'] ) ? absint( $_POST['paged'] ) : 1;
    798                 $search   = isset( $_POST['search'] ) ? sanitize_text_field( wp_unslash( $_POST['search'] ) ) : '';
    799                 $log_type = isset( $_POST['log_type'] ) ? sanitize_text_field( wp_unslash( $_POST['log_type'] ) ) : 'user';
    800 
    801                 // Ensure the paged parameter is valid.
    802                 if ( $paged < 1 ) {
    803                         wp_send_json_error( __( 'Invalid page number.', 'members-for-kofi' ) );
    804                 }
    805 
    806                 // Render only the logs table for the requested page.
    807                 ob_start();
    808                 if ( 'request' === $log_type ) {
    809                         $this->render_request_logs_table( $paged, 10, $search );
    810                 } else {
    811                         $this->render_user_logs_table( $paged, 10, $search );
    812                 }
    813                 $content = ob_get_clean();
    814 
    815                 wp_send_json_success( $content );
    816         }
    817 
    818         /**
    819          * Handles the AJAX request to clear logs.
    820          *
    821          * @return void
    822          */
    823         public function handle_clear_logs(): void {
    824                 // Verify the nonce for security.
    825                 check_ajax_referer( 'members_for_kofi_clear_logs', '_ajax_nonce' );
    826                 \MembersForKofi\Logging\DebugLogger::debug( 'AJAX clear logs request' );
    827 
    828                 // Verify the current user has permission to clear logs.
    829                 if ( ! current_user_can( 'manage_options' ) ) {
    830                         wp_send_json_error( __( 'You do not have permission to access this resource.', 'members-for-kofi' ) );
    831                 }
    832 
    833                 global $wpdb;
    834                 $log_type = isset( $_POST['log_type'] ) ? sanitize_text_field( wp_unslash( $_POST['log_type'] ) ) : 'user';
    835 
    836                 // Determine which table to clear based on log type.
    837                 if ( 'request' === $log_type ) {
    838                         $table_name    = $wpdb->prefix . 'members_for_kofi_request_logs';
    839                         $transient_key = 'members_for_kofi_total_request_logs';
    840                 } else {
    841                         $table_name    = $this->get_logs_table_name();
    842                         $transient_key = 'members_for_kofi_total_logs';
    843                 }
    844 
    845                 // Check if the logs table exists and clear it.
    846                 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Checking for the plugin's own table; a cached answer could be wrong.
    847                 if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) ) ) {
    848                         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Clearing the plugin's own log table on admin request; the cached count is deleted below.
    849                         $wpdb->query( 'DELETE FROM `' . esc_sql( $table_name ) . '`' );
    850                         delete_transient( $transient_key ); // Clear the cached total logs count.
    851 
    852                         // Render the updated logs table (which will now show "No logs available").
    853                         ob_start();
    854                         if ( 'request' === $log_type ) {
    855                                 $this->render_request_logs_table();
    856                         } else {
    857                                 $this->render_user_logs_table();
    858                         }
    859                         $content = ob_get_clean();
    860 
    861                         wp_send_json_success( $content );
    862                 } else {
    863                         wp_send_json_error( __( 'Logs table does not exist.', 'members-for-kofi' ) );
    864                 }
    865         }
    866 
    867         /**
    868          * Handles the AJAX request to update rows per page.
    869          *
    870          * @return void
    871          */
    872         /**
    873          * Clamps a requested page size to one the log viewer actually offers.
    874          *
    875          * Anything the picker cannot produce is rejected rather than squeezed
    876          * into range: 0 reached `ceil( $total_logs / 0 )` and killed the page
    877          * with a DivisionByZeroError, and a huge value went straight into LIMIT
    878          * and loaded the entire table into memory.
    879          *
    880          * @param mixed $raw Untrusted page size, typically straight from $_POST.
    881          * @return int One of ROWS_PER_PAGE_OPTIONS.
    882          */
    883         public function sanitize_rows_per_page( $raw ): int {
    884                 $value = absint( $raw );
    885 
    886                 if ( ! in_array( $value, self::ROWS_PER_PAGE_OPTIONS, true ) ) {
    887                         return self::DEFAULT_ROWS_PER_PAGE;
    888                 }
    889 
    890                 return $value;
    891         }
    892 
    893         /**
    894          * Handles the AJAX request that changes the log table's page size.
    895          *
    896          * @return void
    897          */
    898         public function handle_update_rows_per_page(): void {
    899                 check_ajax_referer( 'members_for_kofi_update_rows_per_page', '_ajax_nonce' );
    900                 \MembersForKofi\Logging\DebugLogger::debug( 'AJAX rows per page update request' );
    901 
    902                 // Verify the current user has permission to update settings.
    903                 if ( ! current_user_can( 'manage_options' ) ) {
    904                         wp_send_json_error( __( 'You do not have permission to access this resource.', 'members-for-kofi' ) );
    905                 }
    906 
    907                 $rows_per_page = $this->sanitize_rows_per_page(
    908                         isset( $_POST['rows_per_page'] ) ? absint( wp_unslash( $_POST['rows_per_page'] ) ) : self::DEFAULT_ROWS_PER_PAGE
    909                 );
    910                 $search        = isset( $_POST['search'] ) ? sanitize_text_field( wp_unslash( $_POST['search'] ) ) : '';
    911                 $log_type      = isset( $_POST['log_type'] ) ? sanitize_text_field( wp_unslash( $_POST['log_type'] ) ) : 'user';
    912 
    913                 ob_start();
    914                 if ( 'request' === $log_type ) {
    915                         $this->render_request_logs_table( null, $rows_per_page, $search );
    916                 } else {
    917                         $this->render_user_logs_table( null, $rows_per_page, $search );
    918                 }
    919                 $content = ob_get_clean();
    920 
    921                 wp_send_json_success( $content );
    922         }
    923 
    924         /**
    925          * Handles AJAX log filtering.
    926          */
    927         public function handle_filter_logs(): void {
    928                 check_ajax_referer( 'members_for_kofi_filter_logs', '_ajax_nonce' );
    929                 \MembersForKofi\Logging\DebugLogger::debug( 'AJAX filter logs request' );
    930 
    931                 // Verify the current user has permission to view logs.
    932                 if ( ! current_user_can( 'manage_options' ) ) {
    933                         wp_send_json_error( __( 'You do not have permission to access this resource.', 'members-for-kofi' ) );
    934                 }
    935 
    936                 $search        = isset( $_POST['search'] ) ? sanitize_text_field( wp_unslash( $_POST['search'] ) ) : '';
    937                 $paged         = isset( $_POST['paged'] ) ? absint( $_POST['paged'] ) : 1;
    938                 $log_type      = isset( $_POST['log_type'] ) ? sanitize_text_field( wp_unslash( $_POST['log_type'] ) ) : 'user';
    939                 $rows_per_page = $this->sanitize_rows_per_page(
    940                         isset( $_POST['rows_per_page'] ) ? absint( wp_unslash( $_POST['rows_per_page'] ) ) : self::DEFAULT_ROWS_PER_PAGE
    941                 );
    942 
    943                 ob_start();
    944                 // Every other log handler honours log_type; this one always rendered the
    945                 // user log, so searching from the Request tab answered with user rows.
    946                 if ( 'request' === $log_type ) {
    947                         $this->render_request_logs_table( $paged, $rows_per_page, $search );
    948                 } else {
    949                         $this->render_user_logs_table( $paged, $rows_per_page, $search );
    950                 }
    951                 $content = ob_get_clean();
    952                 wp_send_json_success( $content );
    953         }
    954 
    955         /**
    956          * Handles AJAX refresh (no search alteration; just reload current page).
    957          */
    958         public function handle_refresh_logs(): void {
    959                 check_ajax_referer( 'members_for_kofi_refresh_logs', '_ajax_nonce' );
    960                 \MembersForKofi\Logging\DebugLogger::debug( 'AJAX refresh logs request' );
    961 
    962                 // Verify the current user has permission to view logs.
    963                 if ( ! current_user_can( 'manage_options' ) ) {
    964                         wp_send_json_error( __( 'You do not have permission to access this resource.', 'members-for-kofi' ) );
    965                 }
    966 
    967                 $paged         = isset( $_POST['paged'] ) ? absint( $_POST['paged'] ) : 1;
    968                 $rows_per_page = $this->sanitize_rows_per_page(
    969                         isset( $_POST['rows_per_page'] ) ? absint( wp_unslash( $_POST['rows_per_page'] ) ) : self::DEFAULT_ROWS_PER_PAGE
    970                 );
    971                 $search        = isset( $_POST['search'] ) ? sanitize_text_field( wp_unslash( $_POST['search'] ) ) : '';
    972                 ob_start();
    973                 $this->render_user_logs_table( $paged, $rows_per_page, $search );
    974                 $content = ob_get_clean();
    975                 wp_send_json_success( $content );
    976         }
    977 
    978         /**
    979          * Handles AJAX log type switch (User vs Request logs).
    980          */
    981         public function handle_switch_log_type(): void {
    982                 check_ajax_referer( 'members_for_kofi_switch_log_type', '_ajax_nonce' );
    983                 \MembersForKofi\Logging\DebugLogger::debug( 'AJAX switch log type request' );
    984 
    985                 // Verify the current user has permission to view logs.
    986                 if ( ! current_user_can( 'manage_options' ) ) {
    987                         wp_send_json_error( __( 'You do not have permission to access this resource.', 'members-for-kofi' ) );
    988                 }
    989 
    990                 $log_type      = isset( $_POST['log_type'] ) ? sanitize_text_field( wp_unslash( $_POST['log_type'] ) ) : 'user';
    991                 $search        = isset( $_POST['search'] ) ? sanitize_text_field( wp_unslash( $_POST['search'] ) ) : '';
    992                 $paged         = isset( $_POST['paged'] ) ? absint( $_POST['paged'] ) : 1;
    993                 $rows_per_page = $this->sanitize_rows_per_page(
    994                         isset( $_POST['rows_per_page'] ) ? absint( wp_unslash( $_POST['rows_per_page'] ) ) : self::DEFAULT_ROWS_PER_PAGE
    995                 );
    996 
    997                 ob_start();
    998                 if ( 'request' === $log_type ) {
    999                         $this->render_request_logs_table( $paged, $rows_per_page, $search );
    1000                 } else {
    1001                         $this->render_user_logs_table( $paged, $rows_per_page, $search );
    1002                 }
    1003                 $content = ob_get_clean();
    1004                 wp_send_json_success( $content );
    1005         }
    1006 
    1007         /**
    1008          * Renders the user logs table.
    1009          *
    1010          * This function outputs the HTML for displaying the user logs in a table,
    1011          * including pagination links.
    1012          *
    1013          * @param int|null $paged         The current page number, or null to auto-determine.
    1014          * @param int      $rows_per_page The number of rows to display per page.
    1015          * @param string   $search        Search filter.
    1016          * @return void
    1017          */
    1018         public function render_user_logs_table( ?int $paged = null, int $rows_per_page = 10, string $search = '' ): void {
    1019                 $rows_per_page = $this->sanitize_rows_per_page( $rows_per_page );
    1020                 global $wpdb;
    1021 
    1022                 $table_name   = $this->get_logs_table_name();
    1023                 $current_page = $paged ?? 1; // Simplified; no $_GET fallback (avoids nonce sniff concerns).
    1024                 $offset       = ( $current_page - 1 ) * $rows_per_page;
    1025 
    1026                 $use_cache  = ( '' === $search );
    1027                 $total_logs = $use_cache ? get_transient( 'members_for_kofi_total_logs' ) : false;
    1028                 $where_sql  = 'WHERE 1=1';
    1029                 $params     = array();
    1030                 $like       = '';
    1031                 if ( '' !== $search ) {
    1032                         $like       = '%' . $wpdb->esc_like( $search ) . '%';
    1033                         $where_sql .= ' AND (email LIKE %s OR action LIKE %s OR role LIKE %s)';
    1034                         $params     = array( $like, $like, $like );
    1035                 }
    1036 
    1037                 if ( false === $total_logs ) {
    1038                         if ( empty( $params ) ) {
    1039                                 // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Counting plugin-owned table.
    1040                                 $total_logs = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . esc_sql( $table_name ) . '`' );
    1041                         } else {
    1042                                 // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared -- $query is literal SQL with %s placeholders; the search term is escaped into $params.
    1043                                 $query      = 'SELECT COUNT(*) FROM `' . esc_sql( $table_name ) . '` ' . $where_sql;
    1044                                 $total_logs = (int) $wpdb->get_var( $wpdb->prepare( $query, $params ) );
    1045                                 // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
    1046                         }
    1047                         if ( ! empty( $wpdb->last_error ) ) {
    1048                                 \MembersForKofi\Logging\DebugLogger::error(
    1049                                         'DB error counting logs',
    1050                                         array(
    1051                                                 'error'      => $wpdb->last_error,
    1052                                                 'last_query' => $wpdb->last_query,
    1053                                         )
    1054                                 );
    1055                         }
    1056                         if ( $use_cache ) {
    1057                                 set_transient( 'members_for_kofi_total_logs', $total_logs, MINUTE_IN_SECONDS );
    1058                         }
    1059                 }
    1060 
    1061                 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Controlled table name.
    1062                 // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Plugin-owned table query.
    1063                 if ( empty( $params ) ) {
    1064                         // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Unfiltered query.
    1065                         $logs = $wpdb->get_results(
    1066                                 $wpdb->prepare(
    1067                                         'SELECT `timestamp`, `user_id`, `email`, `action`, `role` FROM `' . esc_sql( $table_name ) . '` ORDER BY `timestamp` DESC LIMIT %d OFFSET %d',
    1068                                         $rows_per_page,
    1069                                         $offset
    1070                                 ),
    1071                                 ARRAY_A
    1072                         );
    1073                         // Debug removed (noise).
    1074                 } else {
    1075                         // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared -- $query is literal SQL with %s placeholders; the search term is escaped into $params.
    1076                         $query = 'SELECT `timestamp`, `user_id`, `email`, `action`, `role` FROM `' . esc_sql( $table_name ) . '` ' . $where_sql . ' ORDER BY `timestamp` DESC LIMIT %d OFFSET %d';
    1077                         $logs  = $wpdb->get_results(
    1078                                 $wpdb->prepare(
    1079                                         $query,
    1080                                         array_merge( $params, array( $rows_per_page, $offset ) )
    1081                                 ),
    1082                                 ARRAY_A
    1083                         );
    1084                         // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
    1085                         // Debug removed (noise).
    1086                 }
    1087                 if ( empty( $logs ) && (int) $total_logs > 0 ) {
    1088                         \MembersForKofi\Logging\DebugLogger::warning( 'No rows returned despite positive total' );
    1089                 }
    1090 
    1091                 $total_pages = ceil( $total_logs / $rows_per_page );
    1092 
    1093                 ?>
    1094                 <table class="widefat fixed striped">
    1095                         <thead>
    1096                                 <tr>
    1097                                         <th><?php esc_html_e( 'Timestamp', 'members-for-kofi' ); ?></th>
    1098                                         <th><?php esc_html_e( 'User ID', 'members-for-kofi' ); ?></th>
    1099                                         <th><?php esc_html_e( 'Email', 'members-for-kofi' ); ?></th>
    1100                                         <th><?php esc_html_e( 'Action', 'members-for-kofi' ); ?></th>
    1101                                         <th><?php esc_html_e( 'Role', 'members-for-kofi' ); ?></th>
    1102                                 </tr>
    1103                         </thead>
    1104                         <tbody>
    1105                                 <?php if ( empty( $logs ) ) : ?>
    1106                                         <tr>
    1107                                                 <td colspan="5"><?php esc_html_e( 'No logs available.', 'members-for-kofi' ); ?></td>
    1108                                         </tr>
    1109                                 <?php else : ?>
    1110                                         <?php foreach ( $logs as $log ) : ?>
    1111                                                 <tr>
    1112                                                         <td><?php echo esc_html( $log['timestamp'] ); ?></td>
    1113                                                         <td><?php echo esc_html( $log['user_id'] ); ?></td>
    1114                                                         <td><?php echo esc_html( $log['email'] ); ?></td>
    1115                                                         <td><?php echo esc_html( $log['action'] ); ?></td>
    1116                                                         <td><?php echo esc_html( $log['role'] ); ?></td>
    1117                                                 </tr>
    1118                                         <?php endforeach; ?>
    1119                                 <?php endif; ?>
    1120                         </tbody>
    1121                 </table>
    1122 
    1123                 <div style="display: flex; align-items: center; justify-content: space-between; margin-top: 15px;">
    1124                         <input type="hidden" id="kofi-members-current-search" value="<?php echo esc_attr( $search ); ?>" />
    1125                         <!-- Clear Logs Button -->
    1126                         <form method="post" action="<?php echo esc_url( add_query_arg( 'tab', 'user_logs', admin_url( 'admin.php?page=members-for-kofi' ) ) ); ?>" style="margin-right: 15px;">
    1127                                 <?php wp_nonce_field( 'clear_user_logs_action', 'clear_user_logs_nonce' ); ?>
    1128                                 <button type="button" name="clear_logs" class="button button-secondary">
    1129                                         <?php esc_html_e( 'Clear Logs', 'members-for-kofi' ); ?>
    1130                                 </button>
    1131                         </form>
    1132 
    1133                         <!-- Rows Per Page Dropdown -->
    1134                         <form method="get" style="margin-right: 15px;">
    1135                                 <input type="hidden" name="page" value="members-for-kofi">
    1136                                 <input type="hidden" name="tab" value="user_logs">
    1137                                 <label for="rows_per_page" style="margin-right: 10px;"><?php esc_html_e( 'Rows per page:', 'members-for-kofi' ); ?></label>
    1138                                 <select name="rows_per_page" id="rows_per_page" style="width: auto;">
    1139                                         <?php foreach ( self::ROWS_PER_PAGE_OPTIONS as $option ) : ?>
    1140                                                 <option value="<?php echo esc_attr( $option ); ?>" <?php selected( $rows_per_page, $option ); ?>>
    1141                                                         <?php echo esc_html( $option ); ?>
    1142                                                 </option>
    1143                                         <?php endforeach; ?>
    1144                                 </select>
    1145                         </form>
    1146                         <div class="pagination-links" style="display:flex; gap:4px; flex-wrap:wrap;">
    1147                                 <?php if ( $total_pages > 1 ) : ?>
    1148                                         <?php
    1149                                         $prev_page = max( 1, $current_page - 1 );
    1150                                         $next_page = min( $total_pages, $current_page + 1 );
    1151                                         if ( $current_page > 1 ) {
    1152                                                 echo '<button type="button" class="button members-for-kofi-page-btn" data-page="' . esc_attr( $prev_page ) . '">&laquo; ' . esc_html__( 'Previous', 'members-for-kofi' ) . '</button>';
    1153                                         }
    1154                                         for ( $p = 1; $p <= $total_pages; $p++ ) {
    1155                                                 $cls = 'button members-for-kofi-page-btn' . ( $p === $current_page ? ' button-primary' : '' );
    1156                                                 echo '<button type="button" class="' . esc_attr( $cls ) . '" data-page="' . esc_attr( $p ) . '">' . esc_html( (string) $p ) . '</button>';
    1157                                         }
    1158                                         if ( $current_page < $total_pages ) {
    1159                                                 echo '<button type="button" class="button members-for-kofi-page-btn" data-page="' . esc_attr( $next_page ) . '">' . esc_html__( 'Next', 'members-for-kofi' ) . ' &raquo;</button>';
    1160                                         }
    1161                                         ?>
    1162                                 <?php endif; ?>
    1163                         </div>
    1164                 </div>
    1165                 <?php
    1166         }
    1167 
    1168         /**
    1169          * Renders the request logs table.
    1170          *
    1171          * @param int|null $paged         The current page number.
    1172          * @param int      $rows_per_page The number of rows to display per page.
    1173          * @param string   $search        Search filter.
    1174          * @return void
    1175          */
    1176         public function render_request_logs_table( ?int $paged = null, int $rows_per_page = 10, string $search = '' ): void {
    1177                 $rows_per_page = $this->sanitize_rows_per_page( $rows_per_page );
    1178                 global $wpdb;
    1179 
    1180                 $table_name   = $wpdb->prefix . 'members_for_kofi_request_logs';
    1181                 $current_page = $paged ?? 1;
    1182                 $offset       = ( $current_page - 1 ) * $rows_per_page;
    1183 
    1184                 $use_cache  = ( '' === $search );
    1185                 $total_logs = $use_cache ? get_transient( 'members_for_kofi_total_request_logs' ) : false;
    1186                 $where_sql  = 'WHERE 1=1';
    1187                 $params     = array();
    1188 
    1189                 if ( '' !== $search ) {
    1190                         $like       = '%' . $wpdb->esc_like( $search ) . '%';
    1191                         $where_sql .= ' AND (email LIKE %s OR tier_name LIKE %s OR CAST(status_code AS CHAR) LIKE %s OR error LIKE %s)';
    1192                         $params     = array( $like, $like, $like, $like );
    1193                 }
    1194 
    1195                 if ( false === $total_logs ) {
    1196                         if ( empty( $params ) ) {
    1197                                 // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Counting plugin-owned table.
    1198                                 $total_logs = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . esc_sql( $table_name ) . '`' );
    1199                         } else {
    1200                                 // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared -- $query is literal SQL with %s placeholders; the search term is escaped into $params.
    1201                                 $query      = 'SELECT COUNT(*) FROM `' . esc_sql( $table_name ) . '` ' . $where_sql;
    1202                                 $total_logs = (int) $wpdb->get_var( $wpdb->prepare( $query, $params ) );
    1203                                 // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
    1204                         }
    1205                         if ( $use_cache ) {
    1206                                 set_transient( 'members_for_kofi_total_request_logs', $total_logs, MINUTE_IN_SECONDS );
    1207                         }
    1208                 }
    1209 
    1210                 if ( empty( $params ) ) {
    1211                         // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Unfiltered query on a plugin-owned table.
    1212                         $logs = $wpdb->get_results(
    1213                                 $wpdb->prepare(
    1214                                         'SELECT `timestamp`, `email`, `tier_name`, `amount`, `currency`, `is_subscription`, `status_code`, `success`, `error` FROM `' . esc_sql( $table_name ) . '` ORDER BY `timestamp` DESC LIMIT %d OFFSET %d',
    1215                                         $rows_per_page,
    1216                                         $offset
    1217                                 ),
    1218                                 ARRAY_A
    1219                         );
    1220                 } else {
    1221                         // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared -- $query is literal SQL with %s placeholders; the search term is escaped into $params.
    1222                         $query = 'SELECT `timestamp`, `email`, `tier_name`, `amount`, `currency`, `is_subscription`, `status_code`, `success`, `error` FROM `' . esc_sql( $table_name ) . '` ' . $where_sql . ' ORDER BY `timestamp` DESC LIMIT %d OFFSET %d';
    1223                         $logs  = $wpdb->get_results(
    1224                                 $wpdb->prepare(
    1225                                         $query,
    1226                                         array_merge( $params, array( $rows_per_page, $offset ) )
    1227                                 ),
    1228                                 ARRAY_A
    1229                         );
    1230                         // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
    1231                 }
    1232 
    1233                 $total_pages = ceil( $total_logs / $rows_per_page );
    1234 
    1235                 ?>
    1236 <table class="widefat fixed striped">
    1237 <thead>
    1238 <tr>
    1239 <th><?php esc_html_e( 'Timestamp', 'members-for-kofi' ); ?></th>
    1240 <th><?php esc_html_e( 'Email', 'members-for-kofi' ); ?></th>
    1241 <th><?php esc_html_e( 'Tier Name', 'members-for-kofi' ); ?></th>
    1242 <th><?php esc_html_e( 'Amount', 'members-for-kofi' ); ?></th>
    1243 <th><?php esc_html_e( 'Subscription', 'members-for-kofi' ); ?></th>
    1244 <th><?php esc_html_e( 'Status', 'members-for-kofi' ); ?></th>
    1245 <th><?php esc_html_e( 'Success', 'members-for-kofi' ); ?></th>
    1246 <th><?php esc_html_e( 'Error', 'members-for-kofi' ); ?></th>
    1247 </tr>
    1248 </thead>
    1249 <tbody>
    1250                 <?php if ( empty( $logs ) ) : ?>
    1251 <tr>
    1252 <td colspan="8"><?php esc_html_e( 'No logs available.', 'members-for-kofi' ); ?></td>
    1253 </tr>
    1254 <?php else : ?>
    1255         <?php foreach ( $logs as $log ) : ?>
    1256 <tr>
    1257 <td><?php echo esc_html( $log['timestamp'] ); ?></td>
    1258 <td><?php echo esc_html( $log['email'] ); ?></td>
    1259 <td><?php echo esc_html( $log['tier_name'] ); ?></td>
    1260 <td><?php echo esc_html( $log['amount'] ? esc_html( $log['amount'] . ' ' . $log['currency'] ) : '-' ); ?></td>
    1261 <td><?php echo $log['is_subscription'] ? esc_html__( 'Yes', 'members-for-kofi' ) : esc_html__( 'No', 'members-for-kofi' ); ?></td>
    1262 <td><?php echo esc_html( $log['status_code'] ); ?></td>
    1263 <td><?php echo $log['success'] ? '✓' : '✗'; ?></td>
    1264 <td><?php echo esc_html( $log['error'] ); ?></td>
    1265 </tr>
    1266 <?php endforeach; ?>
    1267 <?php endif; ?>
    1268 </tbody>
    1269 </table>
    1270 
    1271 <div style="display: flex; align-items: center; justify-content: space-between; margin-top: 15px;">
    1272 <input type="hidden" id="kofi-members-current-search" value="<?php echo esc_attr( $search ); ?>" />
    1273 <!-- Clear Logs Button -->
    1274 <form method="post" action="<?php echo esc_url( add_query_arg( 'tab', 'logs', admin_url( 'admin.php?page=members-for-kofi' ) ) ); ?>" style="margin-right: 15px;">
    1275                 <?php wp_nonce_field( 'clear_request_logs_action', 'clear_request_logs_nonce' ); ?>
    1276 <button type="button" name="clear_logs" class="button button-secondary">
    1277                 <?php esc_html_e( 'Clear Logs', 'members-for-kofi' ); ?>
    1278 </button>
    1279 </form>
    1280 
    1281 <!-- Rows Per Page Dropdown -->
    1282 <form method="get" style="margin-right: 15px;">
    1283 <input type="hidden" name="page" value="members-for-kofi">
    1284 <input type="hidden" name="tab" value="logs">
    1285 <label for="rows_per_page" style="margin-right: 10px;"><?php esc_html_e( 'Rows per page:', 'members-for-kofi' ); ?></label>
    1286 <select name="rows_per_page" id="rows_per_page" style="width: auto;">
    1287                 <?php foreach ( self::ROWS_PER_PAGE_OPTIONS as $option ) : ?>
    1288 <option value="<?php echo esc_attr( $option ); ?>" <?php selected( $rows_per_page, $option ); ?>>
    1289                         <?php echo esc_html( $option ); ?>
    1290 </option>
    1291 <?php endforeach; ?>
    1292 </select>
    1293 </form>
    1294 <div class="pagination-links" style="display:flex; gap:4px; flex-wrap:wrap;">
    1295                 <?php if ( $total_pages > 1 ) : ?>
    1296                         <?php
    1297                         $prev_page = max( 1, $current_page - 1 );
    1298                         $next_page = min( $total_pages, $current_page + 1 );
    1299                         if ( $current_page > 1 ) {
    1300                                 echo '<button type="button" class="button members-for-kofi-page-btn" data-page="' . esc_attr( $prev_page ) . '">&laquo; ' . esc_html__( 'Previous', 'members-for-kofi' ) . '</button>';
    1301                         }
    1302                         for ( $p = 1; $p <= $total_pages; $p++ ) {
    1303                                 $cls = 'button members-for-kofi-page-btn' . ( $p === $current_page ? ' button-primary' : '' );
    1304                                 echo '<button type="button" class="' . esc_attr( $cls ) . '" data-page="' . esc_attr( $p ) . '">' . esc_html( (string) $p ) . '</button>';
    1305                         }
    1306                         if ( $current_page < $total_pages ) {
    1307                                 echo '<button type="button" class="button members-for-kofi-page-btn" data-page="' . esc_attr( $next_page ) . '">' . esc_html__( 'Next', 'members-for-kofi' ) . ' &raquo;</button>';
    1308                         }
    1309                         ?>
    1310 <?php endif; ?>
    1311 </div>
    1312 </div>
    1313                 <?php
    1314784        }
    1315785}
  • members-for-kofi/tags/1.7.0/src/Logging/RequestLogger.php

    r3728327 r3796384  
    9696                );
    9797
    98                 // Invalidate cached total logs count so UI refresh reflects new entries.
    99                 delete_transient( 'members_for_kofi_total_request_logs' );
    10098        }
    10199
  • members-for-kofi/tags/1.7.0/src/Logging/UserLogger.php

    r3703946 r3796384  
    5959                );
    6060
    61                 // Invalidate cached total logs count so UI refresh reflects new entries.
    62                 delete_transient( 'members_for_kofi_total_logs' );
    6361        }
    6462
  • members-for-kofi/tags/1.7.0/src/Privacy/PersonalData.php

    r3777494 r3796384  
    212212                }
    213213
    214                 delete_transient( 'members_for_kofi_total_logs' );
    215                 delete_transient( 'members_for_kofi_total_request_logs' );
    216 
    217214                // The record of a membership that ended is history, and goes. A
    218215                // membership still being tracked is kept: deleting it would end access
  • members-for-kofi/tags/1.7.0/vendor/composer/autoload_classmap.php

    r3787989 r3796384  
    99    'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
    1010    'MembersForKofi\\Admin\\AdminSettings' => $baseDir . '/src/Admin/AdminSettings.php',
     11    'MembersForKofi\\Admin\\LogsTab' => $baseDir . '/src/Admin/LogsTab.php',
     12    'MembersForKofi\\Admin\\MembersTab' => $baseDir . '/src/Admin/MembersTab.php',
    1113    'MembersForKofi\\Cli\\MemberSinceCommand' => $baseDir . '/src/Cli/MemberSinceCommand.php',
    1214    'MembersForKofi\\Cron\\LogCleanup' => $baseDir . '/src/Cron/LogCleanup.php',
  • members-for-kofi/tags/1.7.0/vendor/composer/autoload_static.php

    r3787989 r3796384  
    2424        'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
    2525        'MembersForKofi\\Admin\\AdminSettings' => __DIR__ . '/../..' . '/src/Admin/AdminSettings.php',
     26        'MembersForKofi\\Admin\\LogsTab' => __DIR__ . '/../..' . '/src/Admin/LogsTab.php',
     27        'MembersForKofi\\Admin\\MembersTab' => __DIR__ . '/../..' . '/src/Admin/MembersTab.php',
    2628        'MembersForKofi\\Cli\\MemberSinceCommand' => __DIR__ . '/../..' . '/src/Cli/MemberSinceCommand.php',
    2729        'MembersForKofi\\Cron\\LogCleanup' => __DIR__ . '/../..' . '/src/Cron/LogCleanup.php',
  • members-for-kofi/trunk/assets/js/admin-settings.js

    r3703946 r3796384  
    1 function membersForKofiInit() {
    2     const tabs = document.querySelectorAll('.nav-tab');
    3     const tabContents = document.querySelectorAll('.members-for-kofi-tab');
    4     const activeTabInput = document.getElementById('active_tab');
    5     // Save button now lives only inside the visible general tab container; no JS hide needed.
     1/**
     2 * Members for Ko-fi admin page.
     3 *
     4 * The tabs, filters and paging are plain links and forms; this only adds
     5 * what needs a script: copying the webhook URL, adding and removing tier
     6 * rows, and confirming a log deletion.
     7 */
     8(function () {
     9    'use strict';
    610
    7     tabs.forEach(tab => {
    8         tab.addEventListener('click', function (e) {
    9             e.preventDefault();
    10             const activeTabId = this.dataset.tab;
    11             if (!activeTabId) return;
     11    function init() {
     12        const copyButton = document.getElementById('copy-webhook-url');
     13        const urlInput = document.getElementById('webhook-kofi-url');
     14        const copyStatus = document.getElementById('mfk-copy-status');
    1215
    13             // Update active classes
    14             tabs.forEach(t => {
    15                 t.classList.remove('nav-tab-active');
    16                 t.setAttribute('aria-selected', 'false');
    17             });
    18             this.classList.add('nav-tab-active');
    19             this.setAttribute('aria-selected', 'true');
    20 
    21             // Toggle content visibility
    22             tabContents.forEach(content => {
    23                 if (content.id === `members-for-kofi-tab-${activeTabId}`) {
    24                     content.style.display = '';
     16        if (copyButton && urlInput) {
     17            copyButton.addEventListener('click', function () {
     18                const done = function () {
     19                    if (copyStatus) {
     20                        copyStatus.textContent = (window.kofiMembers && window.kofiMembers.copied) || 'Copied';
     21                        window.setTimeout(function () { copyStatus.textContent = ''; }, 3000);
     22                    }
     23                };
     24                urlInput.select();
     25                if (navigator.clipboard && navigator.clipboard.writeText) {
     26                    navigator.clipboard.writeText(urlInput.value).then(done).catch(function () {
     27                        document.execCommand('copy');
     28                        done();
     29                    });
    2530                } else {
    26                     content.style.display = 'none';
    27                 }
    28             });
    29 
    30             // Hidden input (used only if form submission needs to know last active tab)
    31             if (activeTabInput) activeTabInput.value = activeTabId;
    32 
    33             // Optionally reflect tab in URL without reload
    34             if (window.history && window.history.replaceState) {
    35                 const url = new URL(window.location.href);
    36                 url.searchParams.set('tab', activeTabId);
    37                 window.history.replaceState({}, '', url.toString());
    38             }
    39         });
    40     });
    41 
    42     // Handle log type selector dropdown
    43     function handleLogTypeChange() {
    44         const logTypeSelector = document.getElementById('log-type-selector');
    45         if (logTypeSelector && !logTypeSelector.dataset.bound) {
    46             logTypeSelector.dataset.bound = '1';
    47             logTypeSelector.addEventListener('change', function() {
    48                 const logType = this.value;
    49                 const searchInput = document.getElementById('kofi-members-log-search');
    50                 const rowsPerPage = document.getElementById('rows_per_page')?.value || 10;
    51                
    52                 // Update the search label based on log type
    53                 const searchLabel = document.querySelector('label[for="kofi-members-log-search"]');
    54                 if (searchLabel) {
    55                     if (logType === 'request') {
    56                         searchLabel.textContent = kofiMembers.searchLabelRequest || 'Search (email / tier / status)';
    57                     } else {
    58                         searchLabel.textContent = kofiMembers.searchLabelUser || 'Search (email / action / role)';
    59                     }
    60                 }
    61                
    62                 // Clear search input
    63                 if (searchInput) searchInput.value = '';
    64                
    65                 // Reload the logs table with the new log type
    66                 loadLogsWithType(logType, '', 1, rowsPerPage);
    67             });
    68         }
    69     }
    70 
    71     function loadLogsWithType(logType, search, paged, rowsPerPage) {
    72         fetch(kofiMembers.ajaxurl, {
    73             method: 'POST',
    74             headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    75             body: new URLSearchParams({
    76                 action: 'members_for_kofi_switch_log_type',
    77                 log_type: logType,
    78                 search: search || '',
    79                 paged: paged || 1,
    80                 rows_per_page: rowsPerPage || 10,
    81                 _ajax_nonce: kofiMembers.switchLogTypeNonce
    82             }),
    83         })
    84             .then(r => r.json())
    85             .then(data => {
    86                 if (data.success) {
    87                     const tableContainer = document.querySelector('#logs-table-container');
    88                     if (tableContainer) {
    89                         tableContainer.innerHTML = data.data;
    90                         tableContainer.dataset.logType = logType;
    91                     }
    92                     reapplyEventListeners();
    93                     handleLogTypeChange(); // Re-bind after content update
    94                 } else {
    95                     alert(data.data || kofiMembers.errorMessage);
    96                 }
    97             })
    98             .catch(err => console.error('Log type switch error:', err));
    99     }
    100 
    101     // No need to toggle save button; it is only present within the general tab container.
    102 
    103     function reapplyEventListeners() {
    104         // Handle Clear Logs button click
    105         const clearLogsButton = document.querySelector('button[name="clear_logs"]');
    106         if (clearLogsButton && !clearLogsButton.dataset.bound) {
    107             clearLogsButton.dataset.bound = '1';
    108             clearLogsButton.addEventListener('click', function (e) {
    109                 e.preventDefault();
    110                 const currentSearch = document.getElementById('kofi-members-current-search')?.value || '';
    111                 const logType = document.querySelector('#log-type-selector')?.value || 'user';
    112 
    113                 if (confirm(kofiMembers.clearLogsConfirm)) {
    114                     fetch(kofiMembers.ajaxurl, {
    115                         method: 'POST',
    116                         headers: {
    117                             'Content-Type': 'application/x-www-form-urlencoded',
    118                         },
    119                         body: new URLSearchParams({
    120                             action: 'members_for_kofi_clear_logs',
    121                             _ajax_nonce: kofiMembers.clearLogsNonce,
    122                             search: currentSearch,
    123                             log_type: logType,
    124                         }),
    125                     })
    126                         .then(response => response.json())
    127                         .then(data => {
    128                             if (data.success) {
    129                                 document.querySelector('#logs-table-container').innerHTML = data.data;
    130                                 reapplyEventListeners(); // Reapply listeners after DOM update
    131                                 // Don't call handleLogTypeChange() here - dropdown is outside the container
    132                             } else {
    133                                 alert(data.data || kofiMembers.errorMessage);
    134                             }
    135                         })
    136                         .catch(error => console.error('Error clearing logs:', error));
     31                    document.execCommand('copy');
     32                    done();
    13733                }
    13834            });
    13935        }
    14036
    141         // Handle Rows Per Page dropdown change
    142     const rowsPerPageDropdown = document.querySelector('#rows_per_page');
    143         if (rowsPerPageDropdown && !rowsPerPageDropdown.dataset.bound) {
    144             rowsPerPageDropdown.dataset.bound = '1';
    145             rowsPerPageDropdown.addEventListener('change', function (e) {
    146                 e.preventDefault();
     37        const tbody = document.querySelector('#tier-role-map-table tbody');
     38        const template = document.getElementById('tier-role-row-template');
    14739
    148                 const rowsPerPage = rowsPerPageDropdown.value;
    149         const currentSearch = document.getElementById('kofi-members-current-search')?.value || '';
    150                 const logType = document.querySelector('#log-type-selector')?.value || 'user';
     40        function addRow(tierName) {
     41            if (!tbody || !template) {
     42                return null;
     43            }
     44            const row = template.content.firstElementChild.cloneNode(true);
     45            tbody.appendChild(row);
     46            const input = row.querySelector('.tier-name-input');
     47            if (input) {
     48                if (tierName) {
     49                    input.value = tierName;
     50                    const select = row.querySelector('.tier-role-select');
     51                    if (select) {
     52                        select.focus();
     53                    }
     54                } else {
     55                    input.focus();
     56                }
     57            }
     58            return row;
     59        }
    15160
    152                 fetch(kofiMembers.ajaxurl, {
    153                     method: 'POST',
    154                     headers: {
    155                         'Content-Type': 'application/x-www-form-urlencoded',
    156                     },
    157                     body: new URLSearchParams({
    158                         action: 'members_for_kofi_update_rows_per_page',
    159                         rows_per_page: rowsPerPage,
    160             search: currentSearch,
    161                         log_type: logType,
    162                         _ajax_nonce: kofiMembers.rowsPerPageNonce,
    163                     }),
    164                 })
    165                     .then(response => response.json())
    166                     .then(data => {
    167                         if (data.success) {
    168                             document.querySelector('#logs-table-container').innerHTML = data.data;
    169                             reapplyEventListeners(); // Reapply listeners after DOM update
    170                             // Don't call handleLogTypeChange() here - dropdown is outside the container and doesn't need rebinding
    171                         } else {
    172                             alert(data.data || kofiMembers.errorMessage);
    173                         }
    174                     })
    175                     .catch(error => console.error('Error updating rows per page:', error));
     61        const addButton = document.getElementById('add-tier-role-row');
     62        if (addButton) {
     63            addButton.addEventListener('click', function () {
     64                addRow('');
    17665            });
    17766        }
    17867
    179         // Search / Reset / Refresh buttons (bind after lazy load and after table updates)
    180         const searchBtn = document.getElementById('kofi-members-log-search-btn');
    181         const resetBtn = document.getElementById('kofi-members-log-reset-btn');
    182         const refreshBtn = document.getElementById('kofi-members-log-refresh-btn');
    183         const searchInput = document.getElementById('kofi-members-log-search');
     68        document.addEventListener('click', function (event) {
     69            const remove = event.target.closest('.remove-tier-role-row');
     70            if (remove && tbody && tbody.contains(remove)) {
     71                const row = remove.closest('tr');
     72                if (row) {
     73                    row.remove();
     74                }
     75                if (addButton) {
     76                    addButton.focus();
     77                }
     78                return;
     79            }
    18480
    185         function loadLogs(params) {
    186             fetch(kofiMembers.ajaxurl, {
    187                 method: 'POST',
    188                 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    189                 body: new URLSearchParams(params),
    190             })
    191                 .then(r => r.json())
    192                 .then(data => {
    193                     if (data.success) {
    194                         const tableContainer = document.querySelector('#logs-table-container');
    195                         if (tableContainer) tableContainer.innerHTML = data.data;
    196                         const hiddenSearch = document.getElementById('kofi-members-current-search');
    197                         if (hiddenSearch) hiddenSearch.value = params.search || '';
    198                         reapplyEventListeners();
    199                         handleLogTypeChange();
    200                     } else {
    201                         alert(data.data || kofiMembers.errorMessage);
    202                     }
    203                 })
    204                 .catch(err => console.error('Log load error:', err));
    205         }
     81            const unmapped = event.target.closest('.mfk-add-unmapped');
     82            if (unmapped) {
     83                addRow(unmapped.getAttribute('data-tier') || '');
     84                const box = unmapped.closest('.mfk-unmapped');
     85                unmapped.remove();
     86                if (box && !box.querySelector('.mfk-add-unmapped')) {
     87                    box.remove();
     88                }
     89            }
     90        });
    20691
    207         if (searchBtn && !searchBtn.dataset.bound) {
    208             searchBtn.dataset.bound = '1';
    209             searchBtn.addEventListener('click', function (e) {
    210                 e.preventDefault();
    211                 loadLogs({
    212                     action: 'members_for_kofi_filter_logs',
    213                     search: (searchInput?.value || '').trim(),
    214                     paged: 1,
    215                     log_type: document.querySelector('#log-type-selector')?.value || 'user',
    216                     rows_per_page: document.getElementById('rows_per_page')?.value || 10,
    217                     _ajax_nonce: kofiMembers.filterNonce,
    218                 });
    219             });
    220         }
    221         if (resetBtn && !resetBtn.dataset.bound) {
    222             resetBtn.dataset.bound = '1';
    223             resetBtn.addEventListener('click', function (e) {
    224                 e.preventDefault();
    225                 if (searchInput) searchInput.value = '';
    226                 loadLogs({
    227                     action: 'members_for_kofi_filter_logs',
    228                     search: '',
    229                     paged: 1,
    230                     log_type: document.querySelector('#log-type-selector')?.value || 'user',
    231                     rows_per_page: document.getElementById('rows_per_page')?.value || 10,
    232                     _ajax_nonce: kofiMembers.filterNonce,
    233                 });
    234             });
    235         }
    236         if (refreshBtn && !refreshBtn.dataset.bound) {
    237             refreshBtn.dataset.bound = '1';
    238             refreshBtn.addEventListener('click', function (e) {
    239                 e.preventDefault();
    240                 const currentSearch = document.getElementById('kofi-members-current-search')?.value || '';
    241                 loadLogs({
    242                     action: 'members_for_kofi_refresh_logs',
    243                     search: currentSearch,
    244                     paged: 1,
    245                     rows_per_page: document.getElementById('rows_per_page')?.value || 10,
    246                     _ajax_nonce: kofiMembers.refreshNonce,
    247                 });
    248             });
    249         }
    250     }
    251 
    252     reapplyEventListeners();
    253     handleLogTypeChange();
    254 
    255     // Handle Pagination link clicks
    256     document.addEventListener('click', function (e) {
    257         const btn = e.target.closest('.members-for-kofi-page-btn');
    258         if (!btn) return;
    259         e.preventDefault();
    260         const page = btn.getAttribute('data-page');
    261         const currentSearch = document.getElementById('kofi-members-current-search')?.value || '';
    262         const logType = document.querySelector('#log-type-selector')?.value || 'user';
    263         fetch(kofiMembers.ajaxurl, {
    264             method: 'POST',
    265             headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    266             body: new URLSearchParams({
    267                 action: 'members_for_kofi_pagination',
    268                 paged: page,
    269                 search: currentSearch,
    270                 log_type: logType,
    271                 _ajax_nonce: kofiMembers.paginationNonce,
    272             }),
    273         })
    274             .then(r => r.json())
    275             .then(data => {
    276                 if (data.success) {
    277                     document.querySelector('#logs-table-container').innerHTML = data.data;
    278                     reapplyEventListeners();
    279                     handleLogTypeChange();
    280                 } else {
    281                     console.error('Error fetching logs:', data.data);
     92        document.querySelectorAll('form[data-mfk-confirm]').forEach(function (form) {
     93            form.addEventListener('submit', function (event) {
     94                if (!window.confirm(form.getAttribute('data-mfk-confirm'))) {
     95                    event.preventDefault();
    28296                }
    283             })
    284             .catch(err => console.error('Error fetching logs:', err));
    285     });
    286 
    287     const copyButton = document.getElementById('copy-webhook-url');
    288     const webhookInput = document.getElementById('webhook-kofi-url');
    289 
    290     if (copyButton && webhookInput) {
    291         copyButton.addEventListener('click', function () {
    292             // Select the text in the input field
    293             webhookInput.select();
    294             webhookInput.setSelectionRange(0, 99999); // For mobile devices
    295 
    296             // Copy the text to the clipboard
    297             navigator.clipboard.writeText(webhookInput.value).then(() => {
    298                 alert('Webhook URL copied to clipboard!');
    299             }).catch(err => {
    300                 console.error('Failed to copy webhook URL:', err);
    30197            });
    30298        });
    30399    }
    304100
    305     // Tier to Role Mapping dynamic rows
    306     const addTierRoleBtn = document.getElementById('add-tier-role-row');
    307     const tierRoleTableBody = document.querySelector('#tier-role-map-table tbody');
    308     const tierRowTemplate = document.getElementById('tier-role-row-template');
    309 
    310     function addTierRoleRow() {
    311         if (!tierRowTemplate || !tierRoleTableBody) return;
    312         const html = tierRowTemplate.innerHTML.trim();
    313         const temp = document.createElement('tbody');
    314         temp.innerHTML = html;
    315         const row = temp.firstElementChild;
    316         if (row) {
    317             tierRoleTableBody.appendChild(row);
    318         }
     101    if (document.readyState === 'loading') {
     102        document.addEventListener('DOMContentLoaded', init);
     103    } else {
     104        init();
    319105    }
    320 
    321     if (addTierRoleBtn) {
    322         addTierRoleBtn.addEventListener('click', function (e) {
    323             e.preventDefault();
    324             addTierRoleRow();
    325         });
    326     }
    327 
    328     // Delegate remove button clicks
    329     document.addEventListener('click', function (e) {
    330         const removeBtn = e.target.closest('.remove-tier-role-row');
    331         if (removeBtn && tierRoleTableBody && tierRoleTableBody.contains(removeBtn)) {
    332             e.preventDefault();
    333             const row = removeBtn.closest('tr');
    334             if (row) {
    335                 row.remove();
    336             }
    337         }
    338     });
    339 
    340 }
    341 
    342 if (document.readyState === 'loading') {
    343     document.addEventListener('DOMContentLoaded', membersForKofiInit);
    344 } else {
    345     // DOM already ready (script loaded late in footer) -> initialize immediately.
    346     membersForKofiInit();
    347 }
    348 
    349 // Auto-activate user logs tab if URL param present (after init attaches listeners)
    350 (() => {
    351     const params = new URLSearchParams(window.location.search);
    352     if (params.get('tab') === 'user_logs') {
    353         const userLogsTab = document.querySelector('.nav-tab[data-tab="user_logs"]');
    354         if (userLogsTab) {
    355             // Defer to allow initial init to finish.
    356             setTimeout(() => userLogsTab.click(), 0);
    357         }
    358     }
    359 })();
     106}());
  • members-for-kofi/trunk/languages/members-for-kofi-da_DK.po

    r3796045 r3796384  
    44msgid ""
    55msgstr ""
    6 "Project-Id-Version: Members for Ko-fi 1.4.0\n"
     6"Project-Id-Version: Members for Ko-fi 1.7.0\n"
    77"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/members-for-kofi\n"
    88"Language: da_DK\n"
    … …  
    1515#. Plugin Name of the plugin
    1616#: members-for-kofi.php
    17 #: src/Plugin.php:434
    18 #: src/Plugin.php:435
    19 #: src/Privacy/PersonalData.php:333
     17#: src/Admin/AdminSettings.php:162
     18#: src/Plugin.php:436
     19#: src/Plugin.php:437
     20#: src/Privacy/PersonalData.php:360
    2021msgid "Members for Ko-fi"
    2122msgstr "Members for Ko-fi"
    … …  
    4142msgstr "https://foodgeek.io"
    4243
    43 #: src/Admin/AdminSettings.php:107
    44 #: src/Admin/AdminSettings.php:667
    45 msgid "Ko-fi Settings"
    46 msgstr "Ko-fi-indstillinger"
    47 
    48 #: src/Admin/AdminSettings.php:108
    49 msgid "Verification Token"
    50 msgstr "Verifikationstoken"
    51 
    52 #: src/Admin/AdminSettings.php:111
    53 #: src/Admin/AdminSettings.php:671
    54 msgid "Role Assignment"
    55 msgstr "Tildeling af roller"
    56 
    57 #: src/Admin/AdminSettings.php:112
    58 msgid "Only Accept Subscriptions"
    59 msgstr "Accepter kun abonnementer"
    60 
    61 #: src/Admin/AdminSettings.php:113
    62 msgid "Tier to Role Mapping"
    63 msgstr "Tilknytning af niveauer til roller"
    64 
    65 #: src/Admin/AdminSettings.php:114
    66 msgid "Default Role (if no match)"
    67 msgstr "Standardrolle (hvis intet matcher)"
    68 
    69 #: src/Admin/AdminSettings.php:115
    70 msgid "Enable Expiry"
    71 msgstr "Slå udløb til"
    72 
    73 #: src/Admin/AdminSettings.php:116
    74 msgid "Role Expiry (days)"
    75 msgstr "Rollens gyldighed (dage)"
    76 
    77 #: src/Admin/AdminSettings.php:119
    78 #: src/Admin/AdminSettings.php:675
    79 msgid "Logging"
    80 msgstr "Logning"
    81 
    82 #: src/Admin/AdminSettings.php:120
    83 msgid "Automatically Clear Logs"
    84 msgstr "Ryd logs automatisk"
    85 
    86 #: src/Admin/AdminSettings.php:121
     44#: src/Admin/AdminSettings.php:110
     45msgid "Copied"
     46msgstr "Kopieret"
     47
     48#: src/Admin/AdminSettings.php:156
     49msgid "Settings"
     50msgstr "Indstillinger"
     51
     52#: src/Admin/AdminSettings.php:157
     53msgid "Members"
     54msgstr "Medlemmer"
     55
     56#: src/Admin/AdminSettings.php:158
     57msgid "Logs"
     58msgstr "Logs"
     59
     60#: src/Admin/AdminSettings.php:171
     61msgid "Members for Ko-fi sections"
     62msgstr "Sektioner i Members for Ko-fi"
     63
     64#: src/Admin/AdminSettings.php:201
     65msgid "Ko-fi connection"
     66msgstr "Forbindelse til Ko-fi"
     67
     68#: src/Admin/AdminSettings.php:208
     69msgid "Press \"Send test\" on Ko-fi"
     70msgstr "Tryk på \"Send test\" hos Ko-fi"
     71
     72#. translators: %s is a link to the request log.
     73#: src/Admin/AdminSettings.php:213
     74#, php-format
     75msgid "It shows up in the status line above and in %s. A test never creates an account."
     76msgstr "Den vises i statuslinjen ovenfor og i %s. En test opretter aldrig en konto."
     77
     78#: src/Admin/AdminSettings.php:214
     79msgid "the request log"
     80msgstr "forespørgselsloggen"
     81
     82#: src/Admin/AdminSettings.php:223
     83msgid "Who gets a role"
     84msgstr "Hvem får en rolle"
     85
     86#: src/Admin/AdminSettings.php:232
     87msgid "When membership ends"
     88msgstr "Når medlemskabet ophører"
     89
     90#: src/Admin/AdminSettings.php:239
     91msgid "Ko-fi subscriptions renew monthly, so 35 days leaves a few days' grace. A role the user held before paying is never removed."
     92msgstr "Ko-fi-abonnementer fornyes hver måned, så 35 dage giver et par dages ekstra frist. En rolle, som brugeren havde før betalingen, fjernes aldrig."
     93
     94#: src/Admin/AdminSettings.php:243
     95msgid "Log history"
     96msgstr "Loghistorik"
     97
     98#: src/Admin/AdminSettings.php:250
     99msgid "Untick to keep everything, for example when another tool reads the request log."
     100msgstr "Fjern fluebenet for at beholde alt, for eksempel hvis et andet værktøj læser forespørgselsloggen."
     101
     102#: src/Admin/AdminSettings.php:272
     103msgid "Not connected: no token is saved, so Ko-fi's requests are refused."
     104msgstr "Ikke forbundet: der er ikke gemt et token, så forespørgsler fra Ko-fi afvises."
     105
     106#: src/Admin/AdminSettings.php:277
     107msgid "Waiting for Ko-fi: no request has arrived yet."
     108msgstr "Venter på Ko-fi: der er ikke kommet nogen forespørgsel endnu."
     109
     110#. translators: %s is a time span, e.g. "2 hours".
     111#: src/Admin/AdminSettings.php:283
     112#, php-format
     113msgid "Working: the last request from Ko-fi, %s ago, was accepted."
     114msgstr "Forbindelsen virker: den seneste forespørgsel fra Ko-fi for %s siden blev accepteret."
     115
     116#. translators: 1: a time span, e.g. "2 hours", 2: why it was refused, e.g. "the token doesn't match".
     117#: src/Admin/AdminSettings.php:287
     118#, php-format
     119msgid "The last request, %1$s ago, was refused: %2$s"
     120msgstr "Den seneste forespørgsel for %1$s siden blev afvist: %2$s"
     121
     122#: src/Admin/AdminSettings.php:302
     123msgid "Ko-fi's webhook settings"
     124msgstr "Ko-fis webhook-indstillinger"
     125
     126#: src/Admin/AdminSettings.php:303
     127msgid "(opens in a new tab)"
     128msgstr "(åbner i en ny fane)"
     129
     130#: src/Admin/AdminSettings.php:305
     131msgid "Give Ko-fi this webhook URL"
     132msgstr "Giv Ko-fi denne webhook-URL"
     133
     134#: src/Admin/AdminSettings.php:308
     135msgid "Copy URL"
     136msgstr "Kopiér URL"
     137
     138#. translators: %s is a link to Ko-fi's webhook settings.
     139#: src/Admin/AdminSettings.php:314
     140#, php-format
     141msgid "Paste it into %s."
     142msgstr "Indsæt den i %s."
     143
     144#: src/Admin/AdminSettings.php:329
     145msgid "Paste Ko-fi's verification token"
     146msgstr "Indsæt Ko-fis verifikationstoken"
     147
     148#: src/Admin/AdminSettings.php:332
     149msgid "A token is saved"
     150msgstr "Der er gemt et token"
     151
     152#: src/Admin/AdminSettings.php:334
     153msgid "fingerprint, to compare with another site"
     154msgstr "fingeraftryk til sammenligning med et andet websted"
     155
     156#: src/Admin/AdminSettings.php:337
     157msgid "No token is saved yet."
     158msgstr "Der er ikke gemt noget token endnu."
     159
     160#: src/Admin/AdminSettings.php:343
     161msgid "Leave blank to keep the saved token"
     162msgstr "Lad feltet stå tomt for at beholde det gemte token"
     163
     164#: src/Admin/AdminSettings.php:344
     165msgid "On Ko-fi's webhook page, open Advanced and copy the token. Only a hash is stored, so it can't be shown again."
     166msgstr "Åbn Advanced på Ko-fis webhook-side, og kopiér tokenet. Der gemmes kun en hashværdi, så det kan ikke vises igen."
     167
     168#: src/Admin/AdminSettings.php:358
     169msgid "Payments that grant a role"
     170msgstr "Betalinger, der giver en rolle"
     171
     172#: src/Admin/AdminSettings.php:359
     173msgid "Donations and subscriptions"
     174msgstr "Donationer og abonnementer"
     175
     176#: src/Admin/AdminSettings.php:360
     177msgid "Subscriptions only"
     178msgstr "Kun abonnementer"
     179
     180#: src/Admin/AdminSettings.php:360
     181msgid "(one-off donations are logged and ignored)"
     182msgstr "(enkeltdonationer logges og ignoreres)"
     183
     184#: src/Admin/AdminSettings.php:393
     185#: src/Admin/AdminSettings.php:421
     186msgid "Tier name on Ko-fi"
     187msgstr "Niveaunavn hos Ko-fi"
     188
     189#: src/Admin/AdminSettings.php:394
     190msgid "Role for this tier"
     191msgstr "Rolle for dette niveau"
     192
     193#: src/Admin/AdminSettings.php:395
     194msgid "Remove this tier"
     195msgstr "Fjern dette niveau"
     196
     197#: src/Admin/AdminSettings.php:395
     198msgid "Remove"
     199msgstr "Fjern"
     200
     201#: src/Admin/AdminSettings.php:416
     202msgid "Ko-fi tiers"
     203msgstr "Ko-fi-niveauer"
     204
     205#: src/Admin/AdminSettings.php:422
     206msgid "Role it grants"
     207msgstr "Rolle, der tildeles"
     208
     209#: src/Admin/AdminSettings.php:423
     210msgid "Actions"
     211msgstr "Handlinger"
     212
     213#: src/Admin/AdminSettings.php:433
     214msgid "Add tier"
     215msgstr "Tilføj niveau"
     216
     217#: src/Admin/AdminSettings.php:434
     218msgid "Names match whatever their capitalisation."
     219msgstr "Navne matcher uanset store og små bogstaver."
     220
     221#: src/Admin/AdminSettings.php:438
     222msgid "Tiers Ko-fi sent that aren't listed:"
     223msgstr "Niveauer fra Ko-fi, der ikke er på listen:"
     224
     225#. translators: 1: a tier name, 2: how many payments named it.
     226#: src/Admin/AdminSettings.php:445
     227#, php-format
     228msgid "Add \"%1$s\" (%2$d payment)"
     229msgid_plural "Add \"%1$s\" (%2$d payments)"
     230msgstr[0] "Tilføj \"%1$s\" (%2$d betaling)"
     231msgstr[1] "Tilføj \"%1$s\" (%2$d betalinger)"
     232
     233#: src/Admin/AdminSettings.php:469
     234msgid "Any other tier, or no tier"
     235msgstr "Andre niveauer eller intet niveau"
     236
     237#: src/Admin/AdminSettings.php:471
     238msgid "— No role —"
     239msgstr "— Ingen rolle —"
     240
     241#: src/Admin/AdminSettings.php:474
     242msgid "With \"No role\", those payers still get an account, but no role. Administrator and other roles that can run the site are never offered."
     243msgstr "Med \"Ingen rolle\" får disse betalere stadig en konto, men ingen rolle. Administrator og andre roller, der kan styre webstedet, tilbydes aldrig."
     244
     245#: src/Admin/AdminSettings.php:489
     246msgid "Remove the role"
     247msgstr "Fjern rollen"
     248
     249#: src/Admin/AdminSettings.php:500
     250msgid "Days after the last payment"
     251msgstr "Dage efter seneste betaling"
     252
     253#: src/Admin/AdminSettings.php:502
     254msgid "days after the member's last payment"
     255msgstr "dage efter medlemmets seneste betaling"
     256
     257#: src/Admin/AdminSettings.php:518
     258msgid "Delete log entries older than"
     259msgstr "Slet logposter, der er ældre end"
     260
     261#: src/Admin/AdminSettings.php:529
    87262msgid "Number of Days to Keep Logs"
    88263msgstr "Antal dage, logs gemmes"
    89264
    90 #: src/Admin/AdminSettings.php:150
    91 msgid "Are you sure you want to clear all logs? This action cannot be undone."
    92 msgstr "Er du sikker på, at du vil rydde alle logs? Det kan ikke fortrydes."
    93 
    94 #: src/Admin/AdminSettings.php:151
    95 msgid "An error occurred. Please try again."
    96 msgstr "Der opstod en fejl. Prøv igen."
    97 
    98 #: src/Admin/AdminSettings.php:152
    99 #: src/Admin/AdminSettings.php:748
    100 msgid "Search (email / action / role)"
    101 msgstr "Søg (e-mail / handling / rolle)"
    102 
    103 #: src/Admin/AdminSettings.php:153
    104 #: src/Admin/AdminSettings.php:746
    105 msgid "Search (email / tier / status)"
    106 msgstr "Søg (e-mail / niveau / status)"
    107 
    108 #: src/Admin/AdminSettings.php:188
     265#: src/Admin/AdminSettings.php:531
     266msgid "days"
     267msgstr "dage"
     268
     269#. translators: %s is a human-readable time span, e.g. "3 days".
     270#: src/Admin/AdminSettings.php:575
     271#, php-format
     272msgid "Scheduled tasks are not running: role expiry and log cleanup are %s overdue. Make sure WP-Cron runs, or that a system cron calls wp-cron.php."
     273msgstr "Planlagte opgaver kører ikke: udløb af roller og oprydning i logs er %s forsinket. Sørg for, at WP-Cron kører, eller at et system-cronjob kalder wp-cron.php."
     274
     275#. translators: 1: email address, 2: comma-separated role names, or "no role".
     276#: src/Admin/AdminSettings.php:627
     277#, php-format
     278msgid "Members for Ko-fi: an account was created by Ko-fi's \"Send test\" button (%1$s, role: %2$s). It is not a real supporter; earlier versions treated tests as payments. You can safely delete it."
     279msgstr "Members for Ko-fi: Ko-fi's \"Send test\"-knap har oprettet en konto (%1$s, rolle: %2$s). Det er ikke en rigtig supporter; tidligere versioner behandlede test som betalinger. Du kan roligt slette den."
     280
     281#: src/Admin/AdminSettings.php:629
     282msgid "none"
     283msgstr "ingen"
     284
     285#: src/Admin/AdminSettings.php:633
     286msgid "Review the account"
     287msgstr "Se kontoen"
     288
     289#: src/Admin/AdminSettings.php:643
     290msgid "Members for Ko-fi: two different verification tokens are stored, and webhooks are checked against the hashed one. Re-enter your Ko-fi verification token in the settings to resolve this."
     291msgstr "Members for Ko-fi: der er gemt to forskellige verifikationstokens, og webhooks tjekkes mod det hashede. Indtast dit Ko-fi-verifikationstoken igen i indstillingerne for at løse det."
     292
     293#: src/Admin/AdminSettings.php:668
     294msgid "Open settings"
     295msgstr "Åbn indstillinger"
     296
     297#: src/Admin/AdminSettings.php:701
    109298msgid "Verification Token is required."
    110299msgstr "Verifikationstoken skal udfyldes."
    111300
    112 #: src/Admin/AdminSettings.php:193
     301#: src/Admin/AdminSettings.php:706
    113302msgid "Role Expiry Days is required and must be a number when expiry is enabled."
    114303msgstr "Når udløb er slået til, skal du angive rollens gyldighed i dage."
    115304
    116305#. translators: %s is the role name that was rejected.
    117 #: src/Admin/AdminSettings.php:213
    118 #: src/Admin/AdminSettings.php:229
     306#: src/Admin/AdminSettings.php:726
     307#: src/Admin/AdminSettings.php:742
    119308#, php-format
    120309msgid "Security: Role \"%s\" cannot be assigned via webhook for security reasons."
    … …  
    122311
    123312#. translators: %s is the role name that was rejected.
    124 #: src/Admin/AdminSettings.php:241
     313#: src/Admin/AdminSettings.php:754
    125314#, php-format
    126315msgid "Security: Role \"%s\" cannot be used as default role for security reasons."
    127316msgstr "Sikkerhed: Rollen \"%s\" kan af sikkerhedshensyn ikke bruges som standardrolle."
    128317
    129 #: src/Admin/AdminSettings.php:290
    130 msgid "A token is saved. Leave this blank to keep it, or paste a new one to replace it."
    131 msgstr "Der er gemt et token. Lad feltet stå tomt for at beholde det, eller indsæt et nyt for at erstatte det."
    132 
    133 #. translators: %s is the first eight characters of the saved token's SHA-256 hash.
    134 #: src/Admin/AdminSettings.php:293
    135 #, php-format
    136 msgid "Saved token fingerprint: %s"
    137 msgstr "Fingeraftryk af det gemte token: %s"
    138 
    139 #: src/Admin/AdminSettings.php:297
    140 msgid "No token set yet. Webhooks are rejected until one is saved."
    141 msgstr "Der er endnu ikke gemt et token. Webhook-forespørgsler afvises, indtil du gemmer et."
    142 
    143 #. translators: %s is a link to the Ko-fi Webhooks Management page.
    144 #: src/Admin/AdminSettings.php:307
    145 #, php-format
    146 msgid "This token is used to verify incoming webhook requests from Ko-fi. Paste the verification token from this page: %s and open the Advanced box. Only a hash of it is stored, so it cannot be shown again; to change it, paste the new one."
    147 msgstr "Dette token bruges til at bekræfte, at webhook-forespørgsler kommer fra Ko-fi. Åbn %s, find sektionen Advanced, og kopiér verifikationstokenet. Der gemmes kun en hashværdi af tokenet, så det kan ikke vises igen. Indsæt et nyt token for at erstatte det."
    148 
    149 #: src/Admin/AdminSettings.php:308
    150 msgid "Ko-fi Webhooks Management"
    151 msgstr "Ko-fi Webhooks Management"
    152 
    153 #: src/Admin/AdminSettings.php:322
    154 msgid "Webhook URL:"
    155 msgstr "Webhook-URL:"
    156 
    157 #: src/Admin/AdminSettings.php:325
    158 msgid "Copy to Clipboard"
    159 msgstr "Kopiér til udklipsholder"
    160 
    161 #: src/Admin/AdminSettings.php:327
    162 msgid "Use this URL to configure your Ko-fi webhook."
    163 msgstr "Brug denne URL, når du opsætter din webhook i Ko-fi."
    164 
    165 #. translators: %s is a human-readable time span, e.g. "3 days".
    166 #: src/Admin/AdminSettings.php:372
    167 #, php-format
    168 msgid "Scheduled tasks are not running: role expiry and log cleanup are %s overdue. Make sure WP-Cron runs, or that a system cron calls wp-cron.php."
    169 msgstr "Planlagte opgaver kører ikke: udløb af roller og oprydning i logs er %s forsinket. Sørg for, at WP-Cron kører, eller at et system-cronjob kalder wp-cron.php."
    170 
    171 #. translators: 1: email address, 2: comma-separated role names, or "no role".
    172 #: src/Admin/AdminSettings.php:424
    173 #, php-format
    174 msgid "Members for Ko-fi: an account was created by Ko-fi's \"Send test\" button (%1$s, role: %2$s). It is not a real supporter; earlier versions treated tests as payments. You can safely delete it."
    175 msgstr "Members for Ko-fi: Ko-fi's \"Send test\"-knap har oprettet en konto (%1$s, rolle: %2$s). Det er ikke en rigtig supporter; tidligere versioner behandlede test som betalinger. Du kan roligt slette den."
    176 
    177 #: src/Admin/AdminSettings.php:426
    178 msgid "none"
    179 msgstr "ingen"
    180 
    181 #: src/Admin/AdminSettings.php:430
    182 msgid "Review the account"
    183 msgstr "Se kontoen"
    184 
    185 #: src/Admin/AdminSettings.php:440
    186 msgid "Members for Ko-fi: two different verification tokens are stored, and webhooks are checked against the hashed one. Re-enter your Ko-fi verification token in the settings to resolve this."
    187 msgstr "Members for Ko-fi: der er gemt to forskellige verifikationstokens, og webhooks tjekkes mod det hashede. Indtast dit Ko-fi-verifikationstoken igen i indstillingerne for at løse det."
    188 
    189 #: src/Admin/AdminSettings.php:465
    190 msgid "Open settings"
    191 msgstr "Åbn indstillinger"
    192 
    193 #: src/Admin/AdminSettings.php:481
    194 msgid "When enabled, users must be active subscribers to receive a role."
    195 msgstr "Når det er slået til, skal brugere have et aktivt abonnement for at få en rolle."
    196 
    197 #: src/Admin/AdminSettings.php:498
    198 msgid "Ko-fi Tier"
    199 msgstr "Ko-fi-niveau"
    200 
    201 #: src/Admin/AdminSettings.php:498
    202 #: src/Admin/AdminSettings.php:1097
     318#: src/Admin/LogsTab.php:245
     319msgid "the token doesn't match"
     320msgstr "tokenet passer ikke"
     321
     322#: src/Admin/LogsTab.php:247
     323msgid "no token is saved"
     324msgstr "der er ikke gemt noget token"
     325
     326#: src/Admin/LogsTab.php:252
     327msgid "the request was malformed"
     328msgstr "forespørgslen var ugyldig"
     329
     330#: src/Admin/LogsTab.php:254
     331msgid "the account could not be created"
     332msgstr "kontoen kunne ikke oprettes"
     333
     334#. translators: %s is a time of day.
     335#: src/Admin/LogsTab.php:292
     336#, php-format
     337msgid "Today, %s"
     338msgstr "I dag, %s"
     339
     340#. translators: %s is a time of day.
     341#: src/Admin/LogsTab.php:297
     342#, php-format
     343msgid "Yesterday, %s"
     344msgstr "I går, %s"
     345
     346#: src/Admin/LogsTab.php:365
     347msgid "The log was deleted."
     348msgstr "Loggen blev ryddet."
     349
     350#: src/Admin/LogsTab.php:383
     351msgid "Member activity"
     352msgstr "Medlemsaktivitet"
     353
     354#: src/Admin/LogsTab.php:396
     355msgid "Requests from Ko-fi"
     356msgstr "Forespørgsler fra Ko-fi"
     357
     358#. translators: %s is the site's time zone, e.g. "Europe/Copenhagen".
     359#: src/Admin/LogsTab.php:401
     360#, php-format
     361msgid "Times are in the site's time zone (%s)."
     362msgstr "Tidspunkter er i webstedets tidszone (%s)."
     363
     364#: src/Admin/LogsTab.php:412
     365#: src/Admin/LogsTab.php:477
     366msgid "Outcome"
     367msgstr "Resultat"
     368
     369#: src/Admin/LogsTab.php:414
     370msgid "All outcomes"
     371msgstr "Alle resultater"
     372
     373#: src/Admin/LogsTab.php:415
     374#: src/Admin/LogsTab.php:547
     375msgid "Accepted"
     376msgstr "Accepteret"
     377
     378#: src/Admin/LogsTab.php:416
     379msgid "Refused"
     380msgstr "Afvist"
     381
     382#: src/Admin/LogsTab.php:424
     383msgid "Search email, tier or reason"
     384msgstr "Søg i e-mail, niveau eller årsag"
     385
     386#: src/Admin/LogsTab.php:426
     387msgid "Search email, action or role"
     388msgstr "Søg i e-mail, handling eller rolle"
     389
     390#: src/Admin/LogsTab.php:433
     391msgid "Per page"
     392msgstr "Pr. side"
     393
     394#: src/Admin/LogsTab.php:440
     395msgid "Filter"
     396msgstr "Filtrér"
     397
     398#: src/Admin/LogsTab.php:473
     399msgid "Received"
     400msgstr "Modtaget"
     401
     402#: src/Admin/LogsTab.php:474
     403#: src/Privacy/PersonalData.php:321
     404#: src/Webhook/Webhook.php:911
     405#: src/Webhook/Webhook.php:931
     406msgid "Supporter"
     407msgstr "Supporter"
     408
     409#: src/Admin/LogsTab.php:475
     410msgid "Tier"
     411msgstr "Niveau"
     412
     413#: src/Admin/LogsTab.php:476
     414msgid "Payment"
     415msgstr "Betaling"
     416
     417#: src/Admin/LogsTab.php:478
     418msgid "Details"
     419msgstr "Detaljer"
     420
     421#: src/Admin/LogsTab.php:483
     422#: src/Admin/LogsTab.php:610
     423msgid "No entries match."
     424msgstr "Ingen poster matcher."
     425
     426#: src/Admin/LogsTab.php:483
     427#: src/Admin/LogsTab.php:610
     428msgid "No entries yet."
     429msgstr "Ingen poster endnu."
     430
     431#: src/Admin/LogsTab.php:504
     432msgid "Ko-fi test"
     433msgstr "Ko-fi-test"
     434
     435#: src/Admin/LogsTab.php:506
     436msgid "Not stored"
     437msgstr "Ikke gemt"
     438
     439#: src/Admin/LogsTab.php:522
     440msgid "not mapped"
     441msgstr "ikke tilknyttet"
     442
     443#: src/Admin/LogsTab.php:530
     444msgid "Test, no account made"
     445msgstr "Test, ingen konto oprettet"
     446
     447#. translators: %s is an amount with its currency, e.g. "5.00 EUR".
     448#: src/Admin/LogsTab.php:536
     449#, php-format
     450msgid "%s · subscription"
     451msgstr "%s · abonnement"
     452
     453#. translators: %s is an amount with its currency, e.g. "5.00 EUR".
     454#: src/Admin/LogsTab.php:538
     455#, php-format
     456msgid "%s · one-off"
     457msgstr "%s · enkeltbetaling"
     458
     459#. translators: %s is why the request was refused, e.g. "the token doesn't match".
     460#: src/Admin/LogsTab.php:552
     461#, php-format
     462msgid "Refused: %s"
     463msgstr "Afvist: %s"
     464
     465#: src/Admin/LogsTab.php:560
     466msgid "Payload"
     467msgstr "Indhold"
     468
     469#: src/Admin/LogsTab.php:602
     470msgid "When"
     471msgstr "Tidspunkt"
     472
     473#: src/Admin/LogsTab.php:603
     474#: src/Admin/MembersTab.php:264
     475msgid "Member"
     476msgstr "Medlem"
     477
     478#: src/Admin/LogsTab.php:604
     479msgid "What happened"
     480msgstr "Hændelse"
     481
     482#: src/Admin/LogsTab.php:605
     483#: src/Admin/MembersTab.php:265
    203484msgid "Role"
    204485msgstr "Rolle"
    205486
    206 #: src/Admin/AdminSettings.php:536
    207 msgid "Add Tier Mapping"
    208 msgstr "Tilføj niveautilknytning"
    209 
    210 #: src/Admin/AdminSettings.php:537
    211 msgid "Map Ko-fi tiers to WordPress roles. Only exact tier names will be matched."
    212 msgstr "Knyt Ko-fi-niveauer til WordPress-roller. Kun niveaunavne, der er helt ens, matcher."
    213 
    214 #: src/Admin/AdminSettings.php:554
    215 msgid "— No default —"
    216 msgstr "— Ingen standard —"
    217 
    218 #: src/Admin/AdminSettings.php:563
    219 msgid "If no tier matches, or if you are not using tiers, this role will be assigned to the user."
    220 msgstr "Hvis intet niveau matcher, eller hvis du ikke bruger niveauer, får brugeren denne rolle."
    221 
    222 #: src/Admin/AdminSettings.php:594
    223 msgid "Number of days the role remains active (only used if expiry is enabled)."
    224 msgstr "Antal dage rollen er aktiv (bruges kun, når udløb er slået til)."
    225 
    226 #: src/Admin/AdminSettings.php:606
    227 msgid "Enable automatic deletion of old logs based on retention period."
    228 msgstr "Slet automatisk logs, der er ældre end opbevaringsperioden."
    229 
    230 #: src/Admin/AdminSettings.php:618
    231 msgid "Number of days to keep logs before automatic deletion (minimum 1 day)."
    232 msgstr "Antal dage, logs gemmes, før de slettes automatisk (mindst 1 dag)."
    233 
    234 #: src/Admin/AdminSettings.php:646
    235 msgid "Members for Ko-fi Settings"
    236 msgstr "Indstillinger for Members for Ko-fi"
    237 
    238 #: src/Admin/AdminSettings.php:657
    239 msgid "Settings"
    240 msgstr "Indstillinger"
    241 
    242 #: src/Admin/AdminSettings.php:660
    243 #: src/Admin/AdminSettings.php:683
    244 msgid "Logs"
    245 msgstr "Logs"
    246 
    247 #: src/Admin/AdminSettings.php:728
    248 msgid "Log Type:"
    249 msgstr "Logtype:"
    250 
    251 #: src/Admin/AdminSettings.php:732
    252 msgid "User Logs"
    253 msgstr "Brugerlog"
    254 
    255 #: src/Admin/AdminSettings.php:735
    256 msgid "Request Logs"
    257 msgstr "Forespørgselslog"
    258 
    259 #: src/Admin/AdminSettings.php:755
    260 msgid "Search"
    261 msgstr "Søg"
    262 
    263 #: src/Admin/AdminSettings.php:756
    264 msgid "Reset"
    265 msgstr "Nulstil"
    266 
    267 #: src/Admin/AdminSettings.php:759
    268 msgid "Refresh"
    269 msgstr "Opdater"
    270 
    271 #: src/Admin/AdminSettings.php:790
    272 #: src/Admin/AdminSettings.php:826
    273 #: src/Admin/AdminSettings.php:900
    274 #: src/Admin/AdminSettings.php:929
    275 #: src/Admin/AdminSettings.php:960
    276 #: src/Admin/AdminSettings.php:983
     487#: src/Admin/LogsTab.php:651
     488msgid "First page"
     489msgstr "Første side"
     490
     491#: src/Admin/LogsTab.php:652
     492#: src/Admin/MembersTab.php:351
     493msgid "Previous page"
     494msgstr "Forrige side"
     495
     496#: src/Admin/LogsTab.php:653
     497#: src/Admin/MembersTab.php:362
     498msgid "Next page"
     499msgstr "Næste side"
     500
     501#: src/Admin/LogsTab.php:654
     502msgid "Last page"
     503msgstr "Sidste side"
     504
     505#: src/Admin/LogsTab.php:665
     506msgid "Current page"
     507msgstr "Aktuel side"
     508
     509#: src/Admin/LogsTab.php:676
     510#: src/Admin/MembersTab.php:349
     511msgid "Pages"
     512msgstr "Sider"
     513
     514#. translators: 1: an input holding the current page number, 2: the number of pages.
     515#. translators: 1: the current page number, 2: the number of pages.
     516#: src/Admin/LogsTab.php:683
     517#: src/Admin/MembersTab.php:356
     518#, php-format
     519msgid "Page %1$s of %2$s"
     520msgstr "Side %1$s af %2$s"
     521
     522#. translators: %s is the number of entries.
     523#: src/Admin/LogsTab.php:715
     524#, php-format
     525msgid "Removes %s request entry. Member activity is kept."
     526msgid_plural "Removes all %s request entries. Member activity is kept."
     527msgstr[0] "Fjerner %s post i forespørgselsloggen. Medlemsaktiviteten bevares."
     528msgstr[1] "Fjerner alle %s poster i forespørgselsloggen. Medlemsaktiviteten bevares."
     529
     530#. translators: %s is the number of entries.
     531#: src/Admin/LogsTab.php:717
     532#, php-format
     533msgid "Removes %s activity entry. The request log is kept."
     534msgid_plural "Removes all %s activity entries. The request log is kept."
     535msgstr[0] "Fjerner %s post i medlemsaktiviteten. Forespørgselsloggen bevares."
     536msgstr[1] "Fjerner alle %s poster i medlemsaktiviteten. Forespørgselsloggen bevares."
     537
     538#. translators: %s is the number of entries.
     539#: src/Admin/LogsTab.php:720
     540#, php-format
     541msgid "Delete %s entry in this log? This can't be undone."
     542msgid_plural "Delete all %s entries in this log? This can't be undone."
     543msgstr[0] "Ryd %s post i denne log? Det kan ikke fortrydes."
     544msgstr[1] "Ryd alle %s poster i denne log? Det kan ikke fortrydes."
     545
     546#: src/Admin/LogsTab.php:726
     547msgid "Delete this log"
     548msgstr "Ryd denne log"
     549
     550#. translators: %d is a number of days.
     551#: src/Admin/LogsTab.php:734
     552#, php-format
     553msgid "Entries older than %d day are deleted automatically."
     554msgid_plural "Entries older than %d days are deleted automatically."
     555msgstr[0] "Poster ældre end %d dag slettes automatisk."
     556msgstr[1] "Poster ældre end %d dage slettes automatisk."
     557
     558#: src/Admin/LogsTab.php:747
     559msgid "Delete request log…"
     560msgstr "Ryd forespørgselsloggen…"
     561
     562#: src/Admin/LogsTab.php:747
     563msgid "Delete activity log…"
     564msgstr "Ryd aktivitetsloggen…"
     565
     566#: src/Admin/LogsTab.php:761
    277567msgid "You do not have permission to access this resource."
    278568msgstr "Du har ikke adgang til denne ressource."
    279569
    280 #: src/Admin/AdminSettings.php:799
    281 msgid "Invalid page number."
    282 msgstr "Ugyldigt sidetal."
    283 
    284 #: src/Admin/AdminSettings.php:859
    285 msgid "Logs table does not exist."
    286 msgstr "Logtabellen findes ikke."
    287 
    288 #: src/Admin/AdminSettings.php:1093
    289 #: src/Admin/AdminSettings.php:1235
    290 msgid "Timestamp"
    291 msgstr "Tidspunkt"
    292 
    293 #: src/Admin/AdminSettings.php:1094
    294 msgid "User ID"
    295 msgstr "Bruger-ID"
    296 
    297 #: src/Admin/AdminSettings.php:1095
    298 #: src/Admin/AdminSettings.php:1236
    299 msgid "Email"
    300 msgstr "E-mail"
    301 
    302 #: src/Admin/AdminSettings.php:1096
    303 msgid "Action"
    304 msgstr "Handling"
    305 
    306 #: src/Admin/AdminSettings.php:1103
    307 #: src/Admin/AdminSettings.php:1248
    308 msgid "No logs available."
    309 msgstr "Der er ingen logs."
    310 
    311 #: src/Admin/AdminSettings.php:1125
    312 #: src/Admin/AdminSettings.php:1273
    313 msgid "Clear Logs"
    314 msgstr "Ryd logs"
    315 
    316 #: src/Admin/AdminSettings.php:1133
    317 #: src/Admin/AdminSettings.php:1281
    318 msgid "Rows per page:"
    319 msgstr "Rækker pr. side:"
    320 
    321 #: src/Admin/AdminSettings.php:1148
    322 #: src/Admin/AdminSettings.php:1296
    323 msgid "Previous"
    324 msgstr "Forrige"
    325 
    326 #: src/Admin/AdminSettings.php:1155
    327 #: src/Admin/AdminSettings.php:1303
    328 msgid "Next"
    329 msgstr "Næste"
    330 
    331 #: src/Admin/AdminSettings.php:1237
    332 msgid "Tier Name"
    333 msgstr "Niveaunavn"
    334 
    335 #: src/Admin/AdminSettings.php:1238
    336 msgid "Amount"
    337 msgstr "Beløb"
    338 
    339 #: src/Admin/AdminSettings.php:1239
    340 msgid "Subscription"
    341 msgstr "Abonnement"
    342 
    343 #: src/Admin/AdminSettings.php:1240
     570#: src/Admin/MembersTab.php:195
     571msgid "Unknown"
     572msgstr "Ukendt"
     573
     574#: src/Admin/MembersTab.php:214
     575msgid "All"
     576msgstr "Alle"
     577
     578#: src/Admin/MembersTab.php:215
     579#: src/Admin/MembersTab.php:316
     580msgid "Active"
     581msgstr "Aktive"
     582
     583#. translators: The members whose access ends within the next seven days.
     584#: src/Admin/MembersTab.php:217
     585msgid "Ending within 7 days"
     586msgstr "Ophører inden for 7 dage"
     587
     588#: src/Admin/MembersTab.php:218
     589#: src/Admin/MembersTab.php:305
     590msgid "Ended"
     591msgstr "Ophørt"
     592
     593#: src/Admin/MembersTab.php:254
     594#: src/Admin/MembersTab.php:256
     595msgid "Search members"
     596msgstr "Søg efter medlemmer"
     597
     598#: src/Admin/MembersTab.php:255
     599msgid "Name or email"
     600msgstr "Navn eller e-mail"
     601
     602#: src/Admin/MembersTab.php:266
    344603msgid "Status"
    345604msgstr "Status"
    346605
    347 #: src/Admin/AdminSettings.php:1241
    348 msgid "Success"
    349 msgstr "Gennemført"
    350 
    351 #: src/Admin/AdminSettings.php:1242
    352 msgid "Error"
    353 msgstr "Fejl"
    354 
    355 #: src/Admin/AdminSettings.php:1257
    356 msgid "Yes"
    357 msgstr "Ja"
    358 
    359 #: src/Admin/AdminSettings.php:1257
    360 msgid "No"
    361 msgstr "Nej"
     606#: src/Admin/MembersTab.php:267
     607msgid "Member since"
     608msgstr "Medlem siden"
     609
     610#: src/Admin/MembersTab.php:268
     611msgid "Last payment"
     612msgstr "Seneste betaling"
     613
     614#: src/Admin/MembersTab.php:269
     615msgid "Access until"
     616msgstr "Adgang indtil"
     617
     618#: src/Admin/MembersTab.php:274
     619msgid "Nobody has become a member through Ko-fi yet."
     620msgstr "Ingen er endnu blevet medlem via Ko-fi."
     621
     622#: src/Admin/MembersTab.php:274
     623msgid "No members match."
     624msgstr "Ingen medlemmer matcher."
     625
     626#. translators: %s is the name of a role that was removed.
     627#: src/Admin/MembersTab.php:293
     628#, php-format
     629msgid "%s, removed"
     630msgstr "%s, fjernet"
     631
     632#: src/Admin/MembersTab.php:297
     633msgid "Granted before payment; never removed"
     634msgstr "Tildelt før betaling; fjernes aldrig"
     635
     636#. translators: %d is a number of days.
     637#: src/Admin/MembersTab.php:311
     638#, php-format
     639msgid "Ends in %d day"
     640msgid_plural "Ends in %d days"
     641msgstr[0] "Udløber om %d dag"
     642msgstr[1] "Udløber om %d dage"
     643
     644#: src/Admin/MembersTab.php:325
     645msgid "subscription"
     646msgstr "abonnement"
     647
     648#: src/Admin/MembersTab.php:325
     649msgid "one-off"
     650msgstr "enkeltbetaling"
     651
     652#: src/Admin/MembersTab.php:332
     653msgid "No expiry"
     654msgstr "Udløber ikke"
     655
     656#. translators: %s is a date.
     657#: src/Admin/MembersTab.php:335
     658#, php-format
     659msgid "Ended %s"
     660msgstr "Ophørte %s"
     661
     662#: src/Admin/MembersTab.php:367
     663msgid "Cancelling happens on Ko-fi, not here."
     664msgstr "Opsigelse sker hos Ko-fi, ikke her."
    362665
    363666#: src/Membership.php:198
    … …  
    381684msgstr "Log ind på Ko-fi, og gå til Account settings og Subscriptions. Betaler du med PayPal, kan du også opsige det i PayPal."
    382685
    383 #: src/Privacy/PersonalData.php:64
    384 #: src/Privacy/PersonalData.php:79
     686#: src/Privacy/PersonalData.php:65
     687#: src/Privacy/PersonalData.php:80
    385688msgid "Members for Ko-fi logs"
    386689msgstr "Logs fra Members for Ko-fi"
    387690
    388 #: src/Privacy/PersonalData.php:115
     691#: src/Privacy/PersonalData.php:119
    389692msgid "Ko-fi membership"
    390693msgstr "Ko-fi-medlemskab"
    391694
    392 #: src/Privacy/PersonalData.php:145
     695#: src/Privacy/PersonalData.php:148
    393696msgid "Ko-fi membership activity"
    394697msgstr "Aktivitet knyttet til Ko-fi-medlemskab"
    395698
    396 #: src/Privacy/PersonalData.php:165
     699#: src/Privacy/PersonalData.php:166
    397700msgid "Ko-fi webhook requests"
    398701msgstr "Ko-fi-webhook-forespørgsler"
    399702
    400 #: src/Privacy/PersonalData.php:227
     703#: src/Privacy/PersonalData.php:242
    401704msgid "An active Ko-fi membership was kept, so the person keeps the access they pay for. It is removed when the membership ends."
    402705msgstr "Et aktivt Ko-fi-medlemskab blev bevaret, så personen fortsat har den adgang, vedkommende betaler for. Adgangen fjernes, når medlemskabet ophører."
    403706
    404 #: src/Privacy/PersonalData.php:294
    405 #: src/Webhook/Webhook.php:753
    406 #: src/Webhook/Webhook.php:772
    407 msgid "Supporter"
    408 msgstr "Supporter"
    409 
    410 #: src/Privacy/PersonalData.php:335
     707#: src/Privacy/PersonalData.php:362
    411708msgid "When you support us on Ko-fi, Ko-fi sends this site your email address, the name and message you entered, the amount, and your membership tier. We use your email address to create or find your account here and give it the access your support pays for."
    412709msgstr "Når du støtter os på Ko-fi, sender Ko-fi din e-mailadresse, det navn og den besked, du skrev, beløbet og dit medlemsniveau til denne side. Vi bruger din e-mailadresse til at oprette eller finde din konto her og give dig den adgang, din støtte betaler for."
    413710
    414 #: src/Privacy/PersonalData.php:336
     711#: src/Privacy/PersonalData.php:363
    415712msgid "A record of each payment and of the access granted or removed is kept for the number of days set by the site owner, then deleted automatically when log cleanup is enabled. You can ask for a copy of this data, or for it to be erased, through the site's personal data request process."
    416713msgstr "Hver betaling og hver tildelt eller fjernet adgang registreres og gemmes i det antal dage, sidens ejer har valgt, og slettes derefter automatisk, når oprydning i logs er slået til. Du kan bede om en kopi af disse data eller om at få dem slettet via sidens procedure for anmodninger om persondata."
    417 
    418 #: src/Admin/AdminSettings.php
    419 msgid "Only assign roles to subscribers."
    420 msgstr "Tildel kun roller til abonnenter."
    421 
    422 #: src/Admin/AdminSettings.php
    423 msgid "If checked, role will be removed after the set number of days."
    424 msgstr "Hvis markeret, fjernes rollen efter det angivne antal dage."
  • members-for-kofi/trunk/languages/members-for-kofi.pot

    r3796045 r3796384  
    33msgid ""
    44msgstr ""
    5 "Project-Id-Version: Members for Ko-fi 1.6.1\n"
     5"Project-Id-Version: Members for Ko-fi 1.7.0\n"
    66"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/members-for-kofi\n"
    77"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
    … …  
    1010"Content-Type: text/plain; charset=UTF-8\n"
    1111"Content-Transfer-Encoding: 8bit\n"
    12 "POT-Creation-Date: 2026-11-11T09:51:50+00:00\n"
     12"POT-Creation-Date: 2026-11-11T11:11:06+00:00\n"
    1313"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
    1414"X-Generator: WP-CLI 2.12.0\n"
    … …  
    1717#. Plugin Name of the plugin
    1818#: members-for-kofi.php
     19#: src/Admin/AdminSettings.php:162
    1920#: src/Plugin.php:436
    2021#: src/Plugin.php:437
    21 #: src/Privacy/PersonalData.php:363
     22#: src/Privacy/PersonalData.php:360
    2223msgid "Members for Ko-fi"
    2324msgstr ""
    … …  
    4344msgstr ""
    4445
    45 #: src/Admin/AdminSettings.php:107
    46 #: src/Admin/AdminSettings.php:671
    47 msgid "Ko-fi Settings"
    48 msgstr ""
    49 
    50 #: src/Admin/AdminSettings.php:108
    51 msgid "Verification Token"
    52 msgstr ""
    53 
    54 #: src/Admin/AdminSettings.php:111
    55 #: src/Admin/AdminSettings.php:675
    56 msgid "Role Assignment"
    57 msgstr ""
    58 
    59 #: src/Admin/AdminSettings.php:112
    60 msgid "Only Accept Subscriptions"
    61 msgstr ""
    62 
    63 #: src/Admin/AdminSettings.php:113
    64 msgid "Tier to Role Mapping"
    65 msgstr ""
    66 
    67 #: src/Admin/AdminSettings.php:114
    68 msgid "Default Role (if no match)"
    69 msgstr ""
    70 
    71 #: src/Admin/AdminSettings.php:115
    72 msgid "Enable Expiry"
    73 msgstr ""
    74 
    75 #: src/Admin/AdminSettings.php:116
    76 msgid "Role Expiry (days)"
    77 msgstr ""
    78 
    79 #: src/Admin/AdminSettings.php:119
    80 #: src/Admin/AdminSettings.php:679
    81 msgid "Logging"
    82 msgstr ""
    83 
    84 #: src/Admin/AdminSettings.php:120
    85 msgid "Automatically Clear Logs"
    86 msgstr ""
    87 
    88 #: src/Admin/AdminSettings.php:121
     46#: src/Admin/AdminSettings.php:110
     47msgid "Copied"
     48msgstr ""
     49
     50#: src/Admin/AdminSettings.php:156
     51msgid "Settings"
     52msgstr ""
     53
     54#: src/Admin/AdminSettings.php:157
     55msgid "Members"
     56msgstr ""
     57
     58#: src/Admin/AdminSettings.php:158
     59msgid "Logs"
     60msgstr ""
     61
     62#: src/Admin/AdminSettings.php:171
     63msgid "Members for Ko-fi sections"
     64msgstr ""
     65
     66#: src/Admin/AdminSettings.php:201
     67msgid "Ko-fi connection"
     68msgstr ""
     69
     70#: src/Admin/AdminSettings.php:208
     71msgid "Press \"Send test\" on Ko-fi"
     72msgstr ""
     73
     74#. translators: %s is a link to the request log.
     75#: src/Admin/AdminSettings.php:213
     76#, php-format
     77msgid "It shows up in the status line above and in %s. A test never creates an account."
     78msgstr ""
     79
     80#: src/Admin/AdminSettings.php:214
     81msgid "the request log"
     82msgstr ""
     83
     84#: src/Admin/AdminSettings.php:223
     85msgid "Who gets a role"
     86msgstr ""
     87
     88#: src/Admin/AdminSettings.php:232
     89msgid "When membership ends"
     90msgstr ""
     91
     92#: src/Admin/AdminSettings.php:239
     93msgid "Ko-fi subscriptions renew monthly, so 35 days leaves a few days' grace. A role the user held before paying is never removed."
     94msgstr ""
     95
     96#: src/Admin/AdminSettings.php:243
     97msgid "Log history"
     98msgstr ""
     99
     100#: src/Admin/AdminSettings.php:250
     101msgid "Untick to keep everything, for example when another tool reads the request log."
     102msgstr ""
     103
     104#: src/Admin/AdminSettings.php:272
     105msgid "Not connected: no token is saved, so Ko-fi's requests are refused."
     106msgstr ""
     107
     108#: src/Admin/AdminSettings.php:277
     109msgid "Waiting for Ko-fi: no request has arrived yet."
     110msgstr ""
     111
     112#. translators: %s is a time span, e.g. "2 hours".
     113#: src/Admin/AdminSettings.php:283
     114#, php-format
     115msgid "Working: the last request from Ko-fi, %s ago, was accepted."
     116msgstr ""
     117
     118#. translators: 1: a time span, e.g. "2 hours", 2: why it was refused, e.g. "the token doesn't match".
     119#: src/Admin/AdminSettings.php:287
     120#, php-format
     121msgid "The last request, %1$s ago, was refused: %2$s"
     122msgstr ""
     123
     124#: src/Admin/AdminSettings.php:302
     125msgid "Ko-fi's webhook settings"
     126msgstr ""
     127
     128#: src/Admin/AdminSettings.php:303
     129msgid "(opens in a new tab)"
     130msgstr ""
     131
     132#: src/Admin/AdminSettings.php:305
     133msgid "Give Ko-fi this webhook URL"
     134msgstr ""
     135
     136#: src/Admin/AdminSettings.php:308
     137msgid "Copy URL"
     138msgstr ""
     139
     140#. translators: %s is a link to Ko-fi's webhook settings.
     141#: src/Admin/AdminSettings.php:314
     142#, php-format
     143msgid "Paste it into %s."
     144msgstr ""
     145
     146#: src/Admin/AdminSettings.php:329
     147msgid "Paste Ko-fi's verification token"
     148msgstr ""
     149
     150#: src/Admin/AdminSettings.php:332
     151msgid "A token is saved"
     152msgstr ""
     153
     154#: src/Admin/AdminSettings.php:334
     155msgid "fingerprint, to compare with another site"
     156msgstr ""
     157
     158#: src/Admin/AdminSettings.php:337
     159msgid "No token is saved yet."
     160msgstr ""
     161
     162#: src/Admin/AdminSettings.php:343
     163msgid "Leave blank to keep the saved token"
     164msgstr ""
     165
     166#: src/Admin/AdminSettings.php:344
     167msgid "On Ko-fi's webhook page, open Advanced and copy the token. Only a hash is stored, so it can't be shown again."
     168msgstr ""
     169
     170#: src/Admin/AdminSettings.php:358
     171msgid "Payments that grant a role"
     172msgstr ""
     173
     174#: src/Admin/AdminSettings.php:359
     175msgid "Donations and subscriptions"
     176msgstr ""
     177
     178#: src/Admin/AdminSettings.php:360
     179msgid "Subscriptions only"
     180msgstr ""
     181
     182#: src/Admin/AdminSettings.php:360
     183msgid "(one-off donations are logged and ignored)"
     184msgstr ""
     185
     186#: src/Admin/AdminSettings.php:393
     187#: src/Admin/AdminSettings.php:421
     188msgid "Tier name on Ko-fi"
     189msgstr ""
     190
     191#: src/Admin/AdminSettings.php:394
     192msgid "Role for this tier"
     193msgstr ""
     194
     195#: src/Admin/AdminSettings.php:395
     196msgid "Remove this tier"
     197msgstr ""
     198
     199#: src/Admin/AdminSettings.php:395
     200msgid "Remove"
     201msgstr ""
     202
     203#: src/Admin/AdminSettings.php:416
     204msgid "Ko-fi tiers"
     205msgstr ""
     206
     207#: src/Admin/AdminSettings.php:422
     208msgid "Role it grants"
     209msgstr ""
     210
     211#: src/Admin/AdminSettings.php:423
     212msgid "Actions"
     213msgstr ""
     214
     215#: src/Admin/AdminSettings.php:433
     216msgid "Add tier"
     217msgstr ""
     218
     219#: src/Admin/AdminSettings.php:434
     220msgid "Names match whatever their capitalisation."
     221msgstr ""
     222
     223#: src/Admin/AdminSettings.php:438
     224msgid "Tiers Ko-fi sent that aren't listed:"
     225msgstr ""
     226
     227#. translators: 1: a tier name, 2: how many payments named it.
     228#: src/Admin/AdminSettings.php:445
     229#, php-format
     230msgid "Add \"%1$s\" (%2$d payment)"
     231msgid_plural "Add \"%1$s\" (%2$d payments)"
     232msgstr[0] ""
     233msgstr[1] ""
     234
     235#: src/Admin/AdminSettings.php:469
     236msgid "Any other tier, or no tier"
     237msgstr ""
     238
     239#: src/Admin/AdminSettings.php:471
     240msgid "— No role —"
     241msgstr ""
     242
     243#: src/Admin/AdminSettings.php:474
     244msgid "With \"No role\", those payers still get an account, but no role. Administrator and other roles that can run the site are never offered."
     245msgstr ""
     246
     247#: src/Admin/AdminSettings.php:489
     248msgid "Remove the role"
     249msgstr ""
     250
     251#: src/Admin/AdminSettings.php:500
     252msgid "Days after the last payment"
     253msgstr ""
     254
     255#: src/Admin/AdminSettings.php:502
     256msgid "days after the member's last payment"
     257msgstr ""
     258
     259#: src/Admin/AdminSettings.php:518
     260msgid "Delete log entries older than"
     261msgstr ""
     262
     263#: src/Admin/AdminSettings.php:529
    89264msgid "Number of Days to Keep Logs"
    90265msgstr ""
    91266
    92 #: src/Admin/AdminSettings.php:150
    93 msgid "Are you sure you want to clear all logs? This action cannot be undone."
    94 msgstr ""
    95 
    96 #: src/Admin/AdminSettings.php:151
    97 msgid "An error occurred. Please try again."
    98 msgstr ""
    99 
    100 #: src/Admin/AdminSettings.php:152
    101 #: src/Admin/AdminSettings.php:752
    102 msgid "Search (email / action / role)"
    103 msgstr ""
    104 
    105 #: src/Admin/AdminSettings.php:153
    106 #: src/Admin/AdminSettings.php:750
    107 msgid "Search (email / tier / status)"
    108 msgstr ""
    109 
    110 #: src/Admin/AdminSettings.php:188
     267#: src/Admin/AdminSettings.php:531
     268msgid "days"
     269msgstr ""
     270
     271#. translators: %s is a human-readable time span, e.g. "3 days".
     272#: src/Admin/AdminSettings.php:575
     273#, php-format
     274msgid "Scheduled tasks are not running: role expiry and log cleanup are %s overdue. Make sure WP-Cron runs, or that a system cron calls wp-cron.php."
     275msgstr ""
     276
     277#. translators: 1: email address, 2: comma-separated role names, or "no role".
     278#: src/Admin/AdminSettings.php:627
     279#, php-format
     280msgid "Members for Ko-fi: an account was created by Ko-fi's \"Send test\" button (%1$s, role: %2$s). It is not a real supporter; earlier versions treated tests as payments. You can safely delete it."
     281msgstr ""
     282
     283#: src/Admin/AdminSettings.php:629
     284msgid "none"
     285msgstr ""
     286
     287#: src/Admin/AdminSettings.php:633
     288msgid "Review the account"
     289msgstr ""
     290
     291#: src/Admin/AdminSettings.php:643
     292msgid "Members for Ko-fi: two different verification tokens are stored, and webhooks are checked against the hashed one. Re-enter your Ko-fi verification token in the settings to resolve this."
     293msgstr ""
     294
     295#: src/Admin/AdminSettings.php:668
     296msgid "Open settings"
     297msgstr ""
     298
     299#: src/Admin/AdminSettings.php:701
    111300msgid "Verification Token is required."
    112301msgstr ""
    113302
    114 #: src/Admin/AdminSettings.php:193
     303#: src/Admin/AdminSettings.php:706
    115304msgid "Role Expiry Days is required and must be a number when expiry is enabled."
    116305msgstr ""
    117306
    118307#. translators: %s is the role name that was rejected.
    119 #: src/Admin/AdminSettings.php:213
    120 #: src/Admin/AdminSettings.php:229
     308#: src/Admin/AdminSettings.php:726
     309#: src/Admin/AdminSettings.php:742
    121310#, php-format
    122311msgid "Security: Role \"%s\" cannot be assigned via webhook for security reasons."
    … …  
    124313
    125314#. translators: %s is the role name that was rejected.
    126 #: src/Admin/AdminSettings.php:241
     315#: src/Admin/AdminSettings.php:754
    127316#, php-format
    128317msgid "Security: Role \"%s\" cannot be used as default role for security reasons."
    129318msgstr ""
    130319
    131 #: src/Admin/AdminSettings.php:290
    132 msgid "A token is saved. Leave this blank to keep it, or paste a new one to replace it."
    133 msgstr ""
    134 
    135 #. translators: %s is the first eight characters of the saved token's SHA-256 hash.
    136 #: src/Admin/AdminSettings.php:293
    137 #, php-format
    138 msgid "Saved token fingerprint: %s"
    139 msgstr ""
    140 
    141 #: src/Admin/AdminSettings.php:297
    142 msgid "No token set yet. Webhooks are rejected until one is saved."
    143 msgstr ""
    144 
    145 #. translators: %s is a link to the Ko-fi Webhooks Management page.
    146 #: src/Admin/AdminSettings.php:307
    147 #, php-format
    148 msgid "This token is used to verify incoming webhook requests from Ko-fi. Paste the verification token from this page: %s and open the Advanced box. Only a hash of it is stored, so it cannot be shown again; to change it, paste the new one."
    149 msgstr ""
    150 
    151 #: src/Admin/AdminSettings.php:308
    152 msgid "Ko-fi Webhooks Management"
    153 msgstr ""
    154 
    155 #: src/Admin/AdminSettings.php:322
    156 msgid "Webhook URL:"
    157 msgstr ""
    158 
    159 #: src/Admin/AdminSettings.php:325
    160 msgid "Copy to Clipboard"
    161 msgstr ""
    162 
    163 #: src/Admin/AdminSettings.php:327
    164 msgid "Use this URL to configure your Ko-fi webhook."
    165 msgstr ""
    166 
    167 #. translators: %s is a human-readable time span, e.g. "3 days".
    168 #: src/Admin/AdminSettings.php:372
    169 #, php-format
    170 msgid "Scheduled tasks are not running: role expiry and log cleanup are %s overdue. Make sure WP-Cron runs, or that a system cron calls wp-cron.php."
    171 msgstr ""
    172 
    173 #. translators: 1: email address, 2: comma-separated role names, or "no role".
    174 #: src/Admin/AdminSettings.php:424
    175 #, php-format
    176 msgid "Members for Ko-fi: an account was created by Ko-fi's \"Send test\" button (%1$s, role: %2$s). It is not a real supporter; earlier versions treated tests as payments. You can safely delete it."
    177 msgstr ""
    178 
    179 #: src/Admin/AdminSettings.php:426
    180 msgid "none"
    181 msgstr ""
    182 
    183 #: src/Admin/AdminSettings.php:430
    184 msgid "Review the account"
    185 msgstr ""
    186 
    187 #: src/Admin/AdminSettings.php:440
    188 msgid "Members for Ko-fi: two different verification tokens are stored, and webhooks are checked against the hashed one. Re-enter your Ko-fi verification token in the settings to resolve this."
    189 msgstr ""
    190 
    191 #: src/Admin/AdminSettings.php:465
    192 msgid "Open settings"
    193 msgstr ""
    194 
    195 #: src/Admin/AdminSettings.php:480
    196 msgid "Only assign roles to subscribers."
    197 msgstr ""
    198 
    199 #: src/Admin/AdminSettings.php:481
    200 msgid "When enabled, users must be active subscribers to receive a role."
    201 msgstr ""
    202 
    203 #: src/Admin/AdminSettings.php:498
    204 msgid "Ko-fi Tier"
    205 msgstr ""
    206 
    207 #: src/Admin/AdminSettings.php:498
    208 #: src/Admin/AdminSettings.php:1101
     320#: src/Admin/LogsTab.php:245
     321msgid "the token doesn't match"
     322msgstr ""
     323
     324#: src/Admin/LogsTab.php:247
     325msgid "no token is saved"
     326msgstr ""
     327
     328#: src/Admin/LogsTab.php:252
     329msgid "the request was malformed"
     330msgstr ""
     331
     332#: src/Admin/LogsTab.php:254
     333msgid "the account could not be created"
     334msgstr ""
     335
     336#. translators: %s is a time of day.
     337#: src/Admin/LogsTab.php:292
     338#, php-format
     339msgid "Today, %s"
     340msgstr ""
     341
     342#. translators: %s is a time of day.
     343#: src/Admin/LogsTab.php:297
     344#, php-format
     345msgid "Yesterday, %s"
     346msgstr ""
     347
     348#: src/Admin/LogsTab.php:365
     349msgid "The log was deleted."
     350msgstr ""
     351
     352#: src/Admin/LogsTab.php:383
     353msgid "Member activity"
     354msgstr ""
     355
     356#: src/Admin/LogsTab.php:396
     357msgid "Requests from Ko-fi"
     358msgstr ""
     359
     360#. translators: %s is the site's time zone, e.g. "Europe/Copenhagen".
     361#: src/Admin/LogsTab.php:401
     362#, php-format
     363msgid "Times are in the site's time zone (%s)."
     364msgstr ""
     365
     366#: src/Admin/LogsTab.php:412
     367#: src/Admin/LogsTab.php:477
     368msgid "Outcome"
     369msgstr ""
     370
     371#: src/Admin/LogsTab.php:414
     372msgid "All outcomes"
     373msgstr ""
     374
     375#: src/Admin/LogsTab.php:415
     376#: src/Admin/LogsTab.php:547
     377msgid "Accepted"
     378msgstr ""
     379
     380#: src/Admin/LogsTab.php:416
     381msgid "Refused"
     382msgstr ""
     383
     384#: src/Admin/LogsTab.php:424
     385msgid "Search email, tier or reason"
     386msgstr ""
     387
     388#: src/Admin/LogsTab.php:426
     389msgid "Search email, action or role"
     390msgstr ""
     391
     392#: src/Admin/LogsTab.php:433
     393msgid "Per page"
     394msgstr ""
     395
     396#: src/Admin/LogsTab.php:440
     397msgid "Filter"
     398msgstr ""
     399
     400#: src/Admin/LogsTab.php:473
     401msgid "Received"
     402msgstr ""
     403
     404#: src/Admin/LogsTab.php:474
     405#: src/Privacy/PersonalData.php:321
     406#: src/Webhook/Webhook.php:911
     407#: src/Webhook/Webhook.php:931
     408msgid "Supporter"
     409msgstr ""
     410
     411#: src/Admin/LogsTab.php:475
     412msgid "Tier"
     413msgstr ""
     414
     415#: src/Admin/LogsTab.php:476
     416msgid "Payment"
     417msgstr ""
     418
     419#: src/Admin/LogsTab.php:478
     420msgid "Details"
     421msgstr ""
     422
     423#: src/Admin/LogsTab.php:483
     424#: src/Admin/LogsTab.php:610
     425msgid "No entries match."
     426msgstr ""
     427
     428#: src/Admin/LogsTab.php:483
     429#: src/Admin/LogsTab.php:610
     430msgid "No entries yet."
     431msgstr ""
     432
     433#: src/Admin/LogsTab.php:504
     434msgid "Ko-fi test"
     435msgstr ""
     436
     437#: src/Admin/LogsTab.php:506
     438msgid "Not stored"
     439msgstr ""
     440
     441#: src/Admin/LogsTab.php:522
     442msgid "not mapped"
     443msgstr ""
     444
     445#: src/Admin/LogsTab.php:530
     446msgid "Test, no account made"
     447msgstr ""
     448
     449#. translators: %s is an amount with its currency, e.g. "5.00 EUR".
     450#: src/Admin/LogsTab.php:536
     451#, php-format
     452msgid "%s · subscription"
     453msgstr ""
     454
     455#. translators: %s is an amount with its currency, e.g. "5.00 EUR".
     456#: src/Admin/LogsTab.php:538
     457#, php-format
     458msgid "%s · one-off"
     459msgstr ""
     460
     461#. translators: %s is why the request was refused, e.g. "the token doesn't match".
     462#: src/Admin/LogsTab.php:552
     463#, php-format
     464msgid "Refused: %s"
     465msgstr ""
     466
     467#: src/Admin/LogsTab.php:560
     468msgid "Payload"
     469msgstr ""
     470
     471#: src/Admin/LogsTab.php:602
     472msgid "When"
     473msgstr ""
     474
     475#: src/Admin/LogsTab.php:603
     476#: src/Admin/MembersTab.php:264
     477msgid "Member"
     478msgstr ""
     479
     480#: src/Admin/LogsTab.php:604
     481msgid "What happened"
     482msgstr ""
     483
     484#: src/Admin/LogsTab.php:605
     485#: src/Admin/MembersTab.php:265
    209486msgid "Role"
    210487msgstr ""
    211488
    212 #: src/Admin/AdminSettings.php:536
    213 msgid "Add Tier Mapping"
    214 msgstr ""
    215 
    216 #: src/Admin/AdminSettings.php:537
    217 msgid "Map Ko-fi tiers to WordPress roles. Only exact tier names will be matched."
    218 msgstr ""
    219 
    220 #: src/Admin/AdminSettings.php:554
    221 msgid "— No default —"
    222 msgstr ""
    223 
    224 #: src/Admin/AdminSettings.php:563
    225 msgid "If no tier matches, or if you are not using tiers, this role will be assigned to the user."
    226 msgstr ""
    227 
    228 #: src/Admin/AdminSettings.php:578
    229 msgid "If checked, role will be removed after the set number of days."
    230 msgstr ""
    231 
    232 #: src/Admin/AdminSettings.php:594
    233 msgid "Number of days the role remains active (only used if expiry is enabled)."
    234 msgstr ""
    235 
    236 #: src/Admin/AdminSettings.php:610
    237 msgid "Enable automatic deletion of old logs based on retention period."
    238 msgstr ""
    239 
    240 #: src/Admin/AdminSettings.php:622
    241 msgid "Number of days to keep logs before automatic deletion (minimum 1 day)."
    242 msgstr ""
    243 
    244 #: src/Admin/AdminSettings.php:650
    245 msgid "Members for Ko-fi Settings"
    246 msgstr ""
    247 
    248 #: src/Admin/AdminSettings.php:661
    249 msgid "Settings"
    250 msgstr ""
    251 
    252 #: src/Admin/AdminSettings.php:664
    253 #: src/Admin/AdminSettings.php:687
    254 msgid "Logs"
    255 msgstr ""
    256 
    257 #: src/Admin/AdminSettings.php:732
    258 msgid "Log Type:"
    259 msgstr ""
    260 
    261 #: src/Admin/AdminSettings.php:736
    262 msgid "User Logs"
    263 msgstr ""
    264 
    265 #: src/Admin/AdminSettings.php:739
    266 msgid "Request Logs"
    267 msgstr ""
    268 
    269 #: src/Admin/AdminSettings.php:759
    270 msgid "Search"
    271 msgstr ""
    272 
    273 #: src/Admin/AdminSettings.php:760
    274 msgid "Reset"
    275 msgstr ""
    276 
    277 #: src/Admin/AdminSettings.php:763
    278 msgid "Refresh"
    279 msgstr ""
    280 
    281 #: src/Admin/AdminSettings.php:794
    282 #: src/Admin/AdminSettings.php:830
    283 #: src/Admin/AdminSettings.php:904
    284 #: src/Admin/AdminSettings.php:933
    285 #: src/Admin/AdminSettings.php:964
    286 #: src/Admin/AdminSettings.php:987
     489#: src/Admin/LogsTab.php:651
     490msgid "First page"
     491msgstr ""
     492
     493#: src/Admin/LogsTab.php:652
     494#: src/Admin/MembersTab.php:351
     495msgid "Previous page"
     496msgstr ""
     497
     498#: src/Admin/LogsTab.php:653
     499#: src/Admin/MembersTab.php:362
     500msgid "Next page"
     501msgstr ""
     502
     503#: src/Admin/LogsTab.php:654
     504msgid "Last page"
     505msgstr ""
     506
     507#: src/Admin/LogsTab.php:665
     508msgid "Current page"
     509msgstr ""
     510
     511#: src/Admin/LogsTab.php:676
     512#: src/Admin/MembersTab.php:349
     513msgid "Pages"
     514msgstr ""
     515
     516#. translators: 1: an input holding the current page number, 2: the number of pages.
     517#. translators: 1: the current page number, 2: the number of pages.
     518#: src/Admin/LogsTab.php:683
     519#: src/Admin/MembersTab.php:356
     520#, php-format
     521msgid "Page %1$s of %2$s"
     522msgstr ""
     523
     524#. translators: %s is the number of entries.
     525#: src/Admin/LogsTab.php:715
     526#, php-format
     527msgid "Removes %s request entry. Member activity is kept."
     528msgid_plural "Removes all %s request entries. Member activity is kept."
     529msgstr[0] ""
     530msgstr[1] ""
     531
     532#. translators: %s is the number of entries.
     533#: src/Admin/LogsTab.php:717
     534#, php-format
     535msgid "Removes %s activity entry. The request log is kept."
     536msgid_plural "Removes all %s activity entries. The request log is kept."
     537msgstr[0] ""
     538msgstr[1] ""
     539
     540#. translators: %s is the number of entries.
     541#: src/Admin/LogsTab.php:720
     542#, php-format
     543msgid "Delete %s entry in this log? This can't be undone."
     544msgid_plural "Delete all %s entries in this log? This can't be undone."
     545msgstr[0] ""
     546msgstr[1] ""
     547
     548#: src/Admin/LogsTab.php:726
     549msgid "Delete this log"
     550msgstr ""
     551
     552#. translators: %d is a number of days.
     553#: src/Admin/LogsTab.php:734
     554#, php-format
     555msgid "Entries older than %d day are deleted automatically."
     556msgid_plural "Entries older than %d days are deleted automatically."
     557msgstr[0] ""
     558msgstr[1] ""
     559
     560#: src/Admin/LogsTab.php:747
     561msgid "Delete request log…"
     562msgstr ""
     563
     564#: src/Admin/LogsTab.php:747
     565msgid "Delete activity log…"
     566msgstr ""
     567
     568#: src/Admin/LogsTab.php:761
    287569msgid "You do not have permission to access this resource."
    288570msgstr ""
    289571
    290 #: src/Admin/AdminSettings.php:803
    291 msgid "Invalid page number."
    292 msgstr ""
    293 
    294 #: src/Admin/AdminSettings.php:863
    295 msgid "Logs table does not exist."
    296 msgstr ""
    297 
    298 #: src/Admin/AdminSettings.php:1097
    299 #: src/Admin/AdminSettings.php:1239
    300 msgid "Timestamp"
    301 msgstr ""
    302 
    303 #: src/Admin/AdminSettings.php:1098
    304 msgid "User ID"
    305 msgstr ""
    306 
    307 #: src/Admin/AdminSettings.php:1099
    308 #: src/Admin/AdminSettings.php:1240
    309 msgid "Email"
    310 msgstr ""
    311 
    312 #: src/Admin/AdminSettings.php:1100
    313 msgid "Action"
    314 msgstr ""
    315 
    316 #: src/Admin/AdminSettings.php:1107
    317 #: src/Admin/AdminSettings.php:1252
    318 msgid "No logs available."
    319 msgstr ""
    320 
    321 #: src/Admin/AdminSettings.php:1129
    322 #: src/Admin/AdminSettings.php:1277
    323 msgid "Clear Logs"
    324 msgstr ""
    325 
    326 #: src/Admin/AdminSettings.php:1137
    327 #: src/Admin/AdminSettings.php:1285
    328 msgid "Rows per page:"
    329 msgstr ""
    330 
    331 #: src/Admin/AdminSettings.php:1152
    332 #: src/Admin/AdminSettings.php:1300
    333 msgid "Previous"
    334 msgstr ""
    335 
    336 #: src/Admin/AdminSettings.php:1159
    337 #: src/Admin/AdminSettings.php:1307
    338 msgid "Next"
    339 msgstr ""
    340 
    341 #: src/Admin/AdminSettings.php:1241
    342 msgid "Tier Name"
    343 msgstr ""
    344 
    345 #: src/Admin/AdminSettings.php:1242
    346 msgid "Amount"
    347 msgstr ""
    348 
    349 #: src/Admin/AdminSettings.php:1243
    350 msgid "Subscription"
    351 msgstr ""
    352 
    353 #: src/Admin/AdminSettings.php:1244
     572#: src/Admin/MembersTab.php:195
     573msgid "Unknown"
     574msgstr ""
     575
     576#: src/Admin/MembersTab.php:214
     577msgid "All"
     578msgstr ""
     579
     580#: src/Admin/MembersTab.php:215
     581#: src/Admin/MembersTab.php:316
     582msgid "Active"
     583msgstr ""
     584
     585#. translators: The members whose access ends within the next seven days.
     586#: src/Admin/MembersTab.php:217
     587msgid "Ending within 7 days"
     588msgstr ""
     589
     590#: src/Admin/MembersTab.php:218
     591#: src/Admin/MembersTab.php:305
     592msgid "Ended"
     593msgstr ""
     594
     595#: src/Admin/MembersTab.php:254
     596#: src/Admin/MembersTab.php:256
     597msgid "Search members"
     598msgstr ""
     599
     600#: src/Admin/MembersTab.php:255
     601msgid "Name or email"
     602msgstr ""
     603
     604#: src/Admin/MembersTab.php:266
    354605msgid "Status"
    355606msgstr ""
    356607
    357 #: src/Admin/AdminSettings.php:1245
    358 msgid "Success"
    359 msgstr ""
    360 
    361 #: src/Admin/AdminSettings.php:1246
    362 msgid "Error"
    363 msgstr ""
    364 
    365 #: src/Admin/AdminSettings.php:1261
    366 msgid "Yes"
    367 msgstr ""
    368 
    369 #: src/Admin/AdminSettings.php:1261
    370 msgid "No"
     608#: src/Admin/MembersTab.php:267
     609msgid "Member since"
     610msgstr ""
     611
     612#: src/Admin/MembersTab.php:268
     613msgid "Last payment"
     614msgstr ""
     615
     616#: src/Admin/MembersTab.php:269
     617msgid "Access until"
     618msgstr ""
     619
     620#: src/Admin/MembersTab.php:274
     621msgid "Nobody has become a member through Ko-fi yet."
     622msgstr ""
     623
     624#: src/Admin/MembersTab.php:274
     625msgid "No members match."
     626msgstr ""
     627
     628#. translators: %s is the name of a role that was removed.
     629#: src/Admin/MembersTab.php:293
     630#, php-format
     631msgid "%s, removed"
     632msgstr ""
     633
     634#: src/Admin/MembersTab.php:297
     635msgid "Granted before payment; never removed"
     636msgstr ""
     637
     638#. translators: %d is a number of days.
     639#: src/Admin/MembersTab.php:311
     640#, php-format
     641msgid "Ends in %d day"
     642msgid_plural "Ends in %d days"
     643msgstr[0] ""
     644msgstr[1] ""
     645
     646#: src/Admin/MembersTab.php:325
     647msgid "subscription"
     648msgstr ""
     649
     650#: src/Admin/MembersTab.php:325
     651msgid "one-off"
     652msgstr ""
     653
     654#: src/Admin/MembersTab.php:332
     655msgid "No expiry"
     656msgstr ""
     657
     658#. translators: %s is a date.
     659#: src/Admin/MembersTab.php:335
     660#, php-format
     661msgid "Ended %s"
     662msgstr ""
     663
     664#: src/Admin/MembersTab.php:367
     665msgid "Cancelling happens on Ko-fi, not here."
    371666msgstr ""
    372667
    … …  
    408703msgstr ""
    409704
    410 #: src/Privacy/PersonalData.php:245
     705#: src/Privacy/PersonalData.php:242
    411706msgid "An active Ko-fi membership was kept, so the person keeps the access they pay for. It is removed when the membership ends."
    412707msgstr ""
    413708
    414 #: src/Privacy/PersonalData.php:324
    415 #: src/Webhook/Webhook.php:911
    416 #: src/Webhook/Webhook.php:931
    417 msgid "Supporter"
    418 msgstr ""
    419 
    420 #: src/Privacy/PersonalData.php:365
     709#: src/Privacy/PersonalData.php:362
    421710msgid "When you support us on Ko-fi, Ko-fi sends this site your email address, the name and message you entered, the amount, and your membership tier. We use your email address to create or find your account here and give it the access your support pays for."
    422711msgstr ""
    423712
    424 #: src/Privacy/PersonalData.php:366
     713#: src/Privacy/PersonalData.php:363
    425714msgid "A record of each payment and of the access granted or removed is kept for the number of days set by the site owner, then deleted automatically when log cleanup is enabled. You can ask for a copy of this data, or for it to be erased, through the site's personal data request process."
    426715msgstr ""
  • members-for-kofi/trunk/members-for-kofi.php

    r3796045 r3796384  
    2525 * Plugin URI:        https://github.com/trudslev/members-for-kofi
    2626 * Description:       Integrate with Ko-fi to manage WordPress users or roles via webhook.
    27  * Version:           1.6.1
     27 * Version:           1.7.0
    2828 * Author:            Sune Adelowo Trudslev
    2929 * Author URI:        https://foodgeek.io
  • members-for-kofi/trunk/readme.txt

    r3796045 r3796384  
    66Tested up to: 7.1
    77Requires PHP: 7.4
    8 Stable tag: 1.6.1
     8Stable tag: 1.7.0
    99License: GPLv3 or later
    1010License URI: https://www.gnu.org/licenses/gpl-3.0.html
    … …  
    140140
    141141== Changelog ==
     142
     143= 1.7.0 =
     144* Redesign: The settings page is reorganised around how you set it up. "Ko-fi connection" walks through the three steps in Ko-fi's own order (copy the webhook URL, paste the token, press Send test) and shows whether Ko-fi's requests are arriving and being accepted.
     145* Feature: Tiers Ko-fi has sent that aren't mapped to a role are listed, and can be added with one click.
     146* Feature: A new Members tab lists everyone who has or had a membership through Ko-fi, with their role, status, member-since date, last payment and when access ends. It can show only active members, those ending within a week, or those that ended.
     147* Logs: The request log can show only refused requests, says in words why a request was refused, shows times in the site's time zone, links to the member's profile, and shows each request's payload. Paging no longer prints a button for every page.
     148* Logs: Deleting a log now says which log it empties and leaves the other one alone.
     149* Accessibility: Every checkbox and button has a label, and the copy button confirms in place instead of with a pop-up.
     150* Fix: The descriptions of "Subscriptions only" and tier matching were inaccurate; they now say what the settings do.
     151* Security: The log viewer no longer runs on AJAX endpoints, so the many low-privilege accounts this plugin creates have nothing of it to call. Viewing and deleting logs still require an administrator.
     152* Translation: Complete Danish translation of the new screens.
    142153
    143154= 1.6.1 =
    … …  
    223234== Upgrade Notice ==
    224235
     236= 1.7.0 =
     237A redesigned admin page: a connection status, a Members tab, and a log viewer that can show only refused requests. No settings change.
     238
    225239= 1.6.1 =
    226240"Automatically Clear Logs" can now be switched off. If you unticked it before, untick it again and save after updating.
  • members-for-kofi/trunk/src/Admin/AdminSettings.php

    r3796045 r3796384  
    2626use MembersForKofi\Webhook\VerificationToken;
    2727
    28 use function add_settings_section;
    29 use function add_settings_field;
    3028use function register_setting;
    3129use function add_settings_error;
    … …  
    3331
    3432/**
    35  * Handles the admin settings for the Members for Ko-fi plugin.
     33 * The plugin's admin page: Settings, Members and Logs tabs.
    3634 *
    37  * This class is responsible for registering and rendering the settings
    38  * for the plugin in the WordPress admin dashboard.
     35 * Each tab is its own URL (`?page=members-for-kofi&tab=…`), so a tab or a
     36 * filtered log can be linked and reloaded. The Members and Logs tabs live in
     37 * MembersTab and LogsTab; this class renders the page, the Settings tab and
     38 * the admin notices, and sanitizes the settings.
    3939 *
    4040 * @package MembersForKofi
    … …  
    4343
    4444        /**
    45          * Page sizes the log viewer offers.
    46          *
    47          * @var array<int>
    48          */
    49         public const ROWS_PER_PAGE_OPTIONS = array( 10, 25, 50, 100 );
    50 
    51         /**
    52          * Page size used when none is given, or when an invalid one is.
    53          *
    54          * @var int
    55          */
    56         public const DEFAULT_ROWS_PER_PAGE = 10;
    57         /**
    58          * Resolve the current logs table name, handling legacy name mismatch.
    59          *
    60          * @return string Fully qualified table name with prefix.
    61          */
    62         private function get_logs_table_name(): string {
    63                 global $wpdb;
    64                 return $wpdb->prefix . 'members_for_kofi_user_logs';
    65         }
    66 
    67         /**
    68          * Constructor for the AdminSettings class.
    69          *
    70          * Initializes the class by adding the necessary WordPress actions.
     45         * The page's tabs, in order.
     46         *
     47         * @var array<int,string>
     48         */
     49        public const TABS = array( 'settings', 'members', 'logs' );
     50
     51        /**
     52         * Registers the page's handlers and assets.
    7153         */
    7254        public function __construct() {
    73                 // Register settings.
    74                 add_action( 'admin_init', array( $this, 'register_settings' ) );
    75 
    76                 // Enqueue admin scripts.
    7755                add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_scripts' ) );
    78 
    79                 // Register AJAX handlers.
    80                 add_action( 'wp_ajax_members_for_kofi_pagination', array( $this, 'handle_pagination' ) );
    81                 add_action( 'wp_ajax_members_for_kofi_clear_logs', array( $this, 'handle_clear_logs' ) );
    82                 add_action( 'wp_ajax_members_for_kofi_update_rows_per_page', array( $this, 'handle_update_rows_per_page' ) );
    83                 add_action( 'wp_ajax_members_for_kofi_filter_logs', array( $this, 'handle_filter_logs' ) );
    84                 add_action( 'wp_ajax_members_for_kofi_refresh_logs', array( $this, 'handle_refresh_logs' ) );
    85                 add_action( 'wp_ajax_members_for_kofi_switch_log_type', array( $this, 'handle_switch_log_type' ) );
    86         }
    87 
    88 
    89         /**
    90          * Registers the settings for the Members for Ko-fi plugin.
    91          *
    92          * This function sets up the settings sections and fields for the plugin
    93          * in the WordPress admin dashboard.
     56                LogsTab::register();
     57        }
     58
     59        /**
     60         * Registers the plugin's setting and its sanitizer.
     61         *
     62         * The form is rendered by render_settings_tab(), not by Settings API
     63         * sections, so only the setting itself is registered here.
    9464         *
    9565         * @return void
    … …  
    10373                        )
    10474                );
    105 
    106                 // Ko-fi Settings section.
    107                 add_settings_section( 'members_for_kofi_general', __( 'Ko-fi Settings', 'members-for-kofi' ), '__return_null', 'members-for-kofi' );
    108                 add_settings_field( 'verification_token', __( 'Verification Token', 'members-for-kofi' ), array( $this, 'render_verification_token_field' ), 'members-for-kofi', 'members_for_kofi_general' );
    109 
    110                 // Role Assignment section.
    111                 add_settings_section( 'members_for_kofi_role_assignment', __( 'Role Assignment', 'members-for-kofi' ), '__return_null', 'members-for-kofi' );
    112                 add_settings_field( 'only_subscriptions', __( 'Only Accept Subscriptions', 'members-for-kofi' ), array( $this, 'render_only_subscriptions_field' ), 'members-for-kofi', 'members_for_kofi_role_assignment' );
    113                 add_settings_field( 'tier_role_map', __( 'Tier to Role Mapping', 'members-for-kofi' ), array( $this, 'render_tier_role_map_field' ), 'members-for-kofi', 'members_for_kofi_role_assignment' );
    114                 add_settings_field( 'default_role', __( 'Default Role (if no match)', 'members-for-kofi' ), array( $this, 'render_default_role_field' ), 'members-for-kofi', 'members_for_kofi_role_assignment' );
    115                 add_settings_field( 'enable_expiry', __( 'Enable Expiry', 'members-for-kofi' ), array( $this, 'render_expiry_toggle_field' ), 'members-for-kofi', 'members_for_kofi_role_assignment' );
    116                 add_settings_field( 'role_expiry_days', __( 'Role Expiry (days)', 'members-for-kofi' ), array( $this, 'render_role_expiry_field' ), 'members-for-kofi', 'members_for_kofi_role_assignment' );
    117 
    118                 // Logging section.
    119                 add_settings_section( 'members_for_kofi_logging', __( 'Logging', 'members-for-kofi' ), '__return_null', 'members-for-kofi' );
    120                 add_settings_field( 'auto_clear_logs', __( 'Automatically Clear Logs', 'members-for-kofi' ), array( $this, 'render_auto_clear_logs_field' ), 'members-for-kofi', 'members_for_kofi_logging' );
    121                 add_settings_field( 'log_retention_days', __( 'Number of Days to Keep Logs', 'members-for-kofi' ), array( $this, 'render_log_retention_days_field' ), 'members-for-kofi', 'members_for_kofi_logging' );
    122         }   /**
    123                  * Enqueues the admin JavaScript file for the Members for Ko-fi settings page.
    124                  *
    125                  * @return void
    126                  */
     75        }
     76
     77        /**
     78         * Enqueues the page's script and stylesheet, on this page only.
     79         *
     80         * @return void
     81         */
    12782        public function enqueue_admin_scripts(): void {
    12883                $screen = get_current_screen();
    12984
    130                 if ( isset( $screen->id ) && 'toplevel_page_members-for-kofi' === $screen->id ) {
    131                         wp_enqueue_script(
    132                                 'members-for-kofi-admin-settings',
    133                                 plugins_url( 'assets/js/admin-settings.js', dirname( __DIR__ ) ),
    134                                 array(),
    135                                 '1.0.6',
    136                                 true
    137                         );
    138 
    139                         wp_localize_script(
    140                                 'members-for-kofi-admin-settings',
    141                                 'kofiMembers',
    142                                 array(
    143                                         'ajaxurl'            => admin_url( 'admin-ajax.php' ),
    144                                         'paginationNonce'    => wp_create_nonce( 'members_for_kofi_pagination' ),
    145                                         'clearLogsNonce'     => wp_create_nonce( 'members_for_kofi_clear_logs' ),
    146                                         'rowsPerPageNonce'   => wp_create_nonce( 'members_for_kofi_update_rows_per_page' ),
    147                                         'filterNonce'        => wp_create_nonce( 'members_for_kofi_filter_logs' ),
    148                                         'refreshNonce'       => wp_create_nonce( 'members_for_kofi_refresh_logs' ),
    149                                         'switchLogTypeNonce' => wp_create_nonce( 'members_for_kofi_switch_log_type' ),
    150                                         'clearLogsConfirm'   => __( 'Are you sure you want to clear all logs? This action cannot be undone.', 'members-for-kofi' ),
    151                                         'errorMessage'       => __( 'An error occurred. Please try again.', 'members-for-kofi' ),
    152                                         'searchLabelUser'    => __( 'Search (email / action / role)', 'members-for-kofi' ),
    153                                         'searchLabelRequest' => __( 'Search (email / tier / status)', 'members-for-kofi' ),
     85                if ( ! isset( $screen->id ) || 'toplevel_page_members-for-kofi' !== $screen->id ) {
     86                        return;
     87                }
     88
     89                $version = get_file_data( MEMBERS_FOR_KOFI_PLUGIN_FILE, array( 'Version' => 'Version' ) )['Version'];
     90
     91                wp_enqueue_style(
     92                        'members-for-kofi-admin',
     93                        plugins_url( 'assets/css/admin.css', dirname( __DIR__ ) ),
     94                        array(),
     95                        $version
     96                );
     97
     98                wp_enqueue_script(
     99                        'members-for-kofi-admin-settings',
     100                        plugins_url( 'assets/js/admin-settings.js', dirname( __DIR__ ) ),
     101                        array(),
     102                        $version,
     103                        true
     104                );
     105
     106                wp_localize_script(
     107                        'members-for-kofi-admin-settings',
     108                        'kofiMembers',
     109                        array(
     110                                'copied' => __( 'Copied', 'members-for-kofi' ),
     111                        )
     112                );
     113        }
     114
     115        /**
     116         * The tab the URL asks for.
     117         *
     118         * @return string
     119         */
     120        public static function current_tab(): string {
     121                // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Choosing which tab to display.
     122                $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'settings';
     123
     124                // The log viewer used to link to its own tab name.
     125                if ( 'user_logs' === $tab ) {
     126                        return 'logs';
     127                }
     128
     129                return in_array( $tab, self::TABS, true ) ? $tab : 'settings';
     130        }
     131
     132        /**
     133         * URL of one of the page's tabs.
     134         *
     135         * @param string $tab Tab key.
     136         * @return string
     137         */
     138        public static function tab_url( string $tab ): string {
     139                $args = array( 'page' => 'members-for-kofi' );
     140
     141                if ( 'settings' !== $tab ) {
     142                        $args['tab'] = $tab;
     143                }
     144
     145                return add_query_arg( $args, admin_url( 'admin.php' ) );
     146        }
     147
     148        /**
     149         * Renders the admin page.
     150         *
     151         * @return void
     152         */
     153        public function render_settings_page(): void {
     154                $active = self::current_tab();
     155                $labels = array(
     156                        'settings' => __( 'Settings', 'members-for-kofi' ),
     157                        'members'  => __( 'Members', 'members-for-kofi' ),
     158                        'logs'     => __( 'Logs', 'members-for-kofi' ),
     159                );
     160                ?>
     161                <div class="wrap mfk-wrap">
     162                        <h1><?php esc_html_e( 'Members for Ko-fi', 'members-for-kofi' ); ?></h1>
     163                        <?php
     164                        // WordPress prints Settings API messages by itself only on pages
     165                        // under Settings. This is a top-level page, so without this call a
     166                        // rejected save ("Verification Token is required", a disallowed
     167                        // role) was silently dropped, and a good one got no confirmation.
     168                        settings_errors();
     169                        $this->render_cron_warning();
     170                        ?>
     171                        <nav class="nav-tab-wrapper" aria-label="<?php esc_attr_e( 'Members for Ko-fi sections', 'members-for-kofi' ); ?>">
     172                                <?php foreach ( $labels as $tab => $label ) : ?>
     173                                        <a href="<?php echo esc_url( self::tab_url( $tab ) ); ?>" class="nav-tab<?php echo $tab === $active ? ' nav-tab-active' : ''; ?>"<?php echo $tab === $active ? ' aria-current="page"' : ''; ?>><?php echo esc_html( $label ); ?></a>
     174                                <?php endforeach; ?>
     175                        </nav>
     176                        <?php
     177                        if ( 'members' === $active ) {
     178                                MembersTab::render();
     179                        } elseif ( 'logs' === $active ) {
     180                                LogsTab::render();
     181                        } else {
     182                                $this->render_settings_tab();
     183                        }
     184                        ?>
     185                </div>
     186                <?php
     187        }
     188
     189        /**
     190         * Renders the Settings tab.
     191         *
     192         * @return void
     193         */
     194        public function render_settings_tab(): void {
     195                ?>
     196                <form method="post" action="options.php" id="members-for-kofi-settings-form" class="mfk-settings">
     197                        <?php settings_fields( 'members_for_kofi_options' ); ?>
     198
     199                        <section class="mfk-card" aria-labelledby="mfk-connection">
     200                                <div class="mfk-card-head">
     201                                        <h2 id="mfk-connection"><?php esc_html_e( 'Ko-fi connection', 'members-for-kofi' ); ?></h2>
     202                                        <?php $this->render_connection_status(); ?>
     203                                </div>
     204                                <ol class="mfk-steps">
     205                                        <li><?php $this->render_webhook_url_field(); ?></li>
     206                                        <li><?php $this->render_verification_token_field(); ?></li>
     207                                        <li>
     208                                                <p class="mfk-step-title"><?php esc_html_e( 'Press "Send test" on Ko-fi', 'members-for-kofi' ); ?></p>
     209                                                <p class="description">
     210                                                        <?php
     211                                                        printf(
     212                                                                // translators: %s is a link to the request log.
     213                                                                esc_html__( 'It shows up in the status line above and in %s. A test never creates an account.', 'members-for-kofi' ),
     214                                                                '<a href="' . esc_url( add_query_arg( 'log', 'requests', self::tab_url( 'logs' ) ) ) . '">' . esc_html__( 'the request log', 'members-for-kofi' ) . '</a>'
     215                                                        );
     216                                                        ?>
     217                                                </p>
     218                                        </li>
     219                                </ol>
     220                        </section>
     221
     222                        <section class="mfk-card mfk-card-body" aria-labelledby="mfk-roles">
     223                                <h2 id="mfk-roles"><?php esc_html_e( 'Who gets a role', 'members-for-kofi' ); ?></h2>
     224                                <?php
     225                                $this->render_only_subscriptions_field();
     226                                $this->render_tier_role_map_field();
     227                                $this->render_default_role_field();
     228                                ?>
     229                        </section>
     230
     231                        <section class="mfk-card mfk-card-body" aria-labelledby="mfk-expiry">
     232                                <h2 id="mfk-expiry"><?php esc_html_e( 'When membership ends', 'members-for-kofi' ); ?></h2>
     233                                <p class="mfk-sentence">
     234                                        <?php
     235                                        $this->render_expiry_toggle_field();
     236                                        $this->render_role_expiry_field();
     237                                        ?>
     238                                </p>
     239                                <p class="description"><?php esc_html_e( 'Ko-fi subscriptions renew monthly, so 35 days leaves a few days\' grace. A role the user held before paying is never removed.', 'members-for-kofi' ); ?></p>
     240                        </section>
     241
     242                        <section class="mfk-card mfk-card-body" aria-labelledby="mfk-log-history">
     243                                <h2 id="mfk-log-history"><?php esc_html_e( 'Log history', 'members-for-kofi' ); ?></h2>
     244                                <p class="mfk-sentence">
     245                                        <?php
     246                                        $this->render_auto_clear_logs_field();
     247                                        $this->render_log_retention_days_field();
     248                                        ?>
     249                                </p>
     250                                <p class="description"><?php esc_html_e( 'Untick to keep everything, for example when another tool reads the request log.', 'members-for-kofi' ); ?></p>
     251                        </section>
     252
     253                        <?php submit_button(); ?>
     254                </form>
     255                <?php
     256        }
     257
     258        /**
     259         * Says whether Ko-fi's requests are arriving and being accepted.
     260         *
     261         * Based on the newest row of the request log, which also holds refused
     262         * requests from anyone who finds the URL: a refusal says "the last
     263         * request", not "Ko-fi".
     264         *
     265         * @return void
     266         */
     267        public function render_connection_status(): void {
     268                $options = get_option( VerificationToken::OPTION );
     269
     270                if ( '' === VerificationToken::stored_hash( $options ) ) {
     271                        $class = 'mfk-status-error';
     272                        $text  = __( 'Not connected: no token is saved, so Ko-fi\'s requests are refused.', 'members-for-kofi' );
     273                } else {
     274                        $latest = LogsTab::latest_request();
     275                        if ( null === $latest ) {
     276                                $class = 'mfk-status-waiting';
     277                                $text  = __( 'Waiting for Ko-fi: no request has arrived yet.', 'members-for-kofi' );
     278                        } else {
     279                                $ago = human_time_diff( LogsTab::to_unix( (string) $latest['timestamp'] ) );
     280                                if ( '1' === (string) $latest['success'] ) {
     281                                        $class = 'mfk-status-ok';
     282                                        // translators: %s is a time span, e.g. "2 hours".
     283                                        $text = sprintf( __( 'Working: the last request from Ko-fi, %s ago, was accepted.', 'members-for-kofi' ), $ago );
     284                                } else {
     285                                        $class = 'mfk-status-error';
     286                                        // translators: 1: a time span, e.g. "2 hours", 2: why it was refused, e.g. "the token doesn't match".
     287                                        $text = sprintf( __( 'The last request, %1$s ago, was refused: %2$s', 'members-for-kofi' ), $ago, LogsTab::refusal_reason( (string) $latest['error'] ) );
     288                                }
     289                        }
     290                }
     291
     292                echo '<p class="mfk-status ' . esc_attr( $class ) . '">' . esc_html( $text ) . '</p>';
     293        }
     294
     295        /**
     296         * Step 1: the webhook URL to give Ko-fi, with a copy button.
     297         *
     298         * @return void
     299         */
     300        public function render_webhook_url_field(): void {
     301                $link = '<a href="' . esc_url( 'https://ko-fi.com/manage/webhooks?src=sidemenu' ) . '" target="_blank" rel="noopener noreferrer">'
     302                        . esc_html__( 'Ko-fi\'s webhook settings', 'members-for-kofi' )
     303                        . '<span class="screen-reader-text"> ' . esc_html__( '(opens in a new tab)', 'members-for-kofi' ) . '</span></a>';
     304                ?>
     305                <label for="webhook-kofi-url" class="mfk-step-title"><?php esc_html_e( 'Give Ko-fi this webhook URL', 'members-for-kofi' ); ?></label>
     306                <div class="mfk-copy-row">
     307                        <input type="text" id="webhook-kofi-url" value="<?php echo esc_url( self::webhook_url() ); ?>" readonly class="regular-text code">
     308                        <button type="button" id="copy-webhook-url" class="button"><?php esc_html_e( 'Copy URL', 'members-for-kofi' ); ?></button>
     309                        <span id="mfk-copy-status" class="mfk-copy-status" role="status"></span>
     310                </div>
     311                <p class="description">
     312                        <?php
     313                        // translators: %s is a link to Ko-fi's webhook settings.
     314                        printf( esc_html__( 'Paste it into %s.', 'members-for-kofi' ), $link ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Built from escaped parts above.
     315                        ?>
     316                </p>
     317                <?php
     318        }
     319
     320        /**
     321         * Step 2: the verification token. Write-only: the saved one is never shown.
     322         *
     323         * @return void
     324         */
     325        public function render_verification_token_field(): void {
     326                $options     = get_option( 'members_for_kofi_options' );
     327                $fingerprint = VerificationToken::fingerprint( $options );
     328                ?>
     329                <label for="mfk-token" class="mfk-step-title"><?php esc_html_e( 'Paste Ko-fi\'s verification token', 'members-for-kofi' ); ?></label>
     330                <?php if ( '' !== $fingerprint ) : ?>
     331                        <p class="mfk-token-state">
     332                                <?php esc_html_e( 'A token is saved', 'members-for-kofi' ); ?>
     333                                <code><?php echo esc_html( $fingerprint ); ?></code>
     334                                <span class="mfk-muted"><?php esc_html_e( 'fingerprint, to compare with another site', 'members-for-kofi' ); ?></span>
     335                        </p>
     336                <?php else : ?>
     337                        <p class="mfk-token-state mfk-status-error"><?php esc_html_e( 'No token is saved yet.', 'members-for-kofi' ); ?></p>
     338                <?php endif; ?>
     339                <?php if ( VerificationToken::has_conflict( $options ) ) : ?>
     340                        <p class="mfk-status-error"><?php echo esc_html( self::token_conflict_message() ); ?></p>
     341                <?php endif; ?>
     342                <?php // Write-only: the saved token is never rendered back into the page, and only its hash is stored, so the value is always empty. ?>
     343                <input type="password" id="mfk-token" name="members_for_kofi_options[verification_token]" value="" class="regular-text" autocomplete="new-password"<?php echo '' !== $fingerprint ? ' placeholder="' . esc_attr__( 'Leave blank to keep the saved token', 'members-for-kofi' ) . '"' : ''; ?>>
     344                <p class="description"><?php esc_html_e( 'On Ko-fi\'s webhook page, open Advanced and copy the token. Only a hash is stored, so it can\'t be shown again.', 'members-for-kofi' ); ?></p>
     345                <?php
     346        }
     347
     348        /**
     349         * Which payments grant a role. Radios, so both answers are always sent.
     350         *
     351         * @return void
     352         */
     353        public static function render_only_subscriptions_field(): void {
     354                $options = get_option( 'members_for_kofi_options', array() );
     355                $only    = ! empty( $options['only_subscriptions'] );
     356                ?>
     357                <fieldset class="mfk-field">
     358                        <legend class="mfk-field-title"><?php esc_html_e( 'Payments that grant a role', 'members-for-kofi' ); ?></legend>
     359                        <label><input type="radio" name="members_for_kofi_options[only_subscriptions]" value="0"<?php checked( ! $only ); ?>> <?php esc_html_e( 'Donations and subscriptions', 'members-for-kofi' ); ?></label><br>
     360                        <label><input type="radio" name="members_for_kofi_options[only_subscriptions]" value="1"<?php checked( $only ); ?>> <?php esc_html_e( 'Subscriptions only', 'members-for-kofi' ); ?> <span class="mfk-muted"><?php esc_html_e( '(one-off donations are logged and ignored)', 'members-for-kofi' ); ?></span></label>
     361                </fieldset>
     362                <?php
     363        }
     364
     365        /**
     366         * Options for a role select: assignable roles only.
     367         *
     368         * @param string $selected Selected role slug.
     369         * @return string Escaped `<option>` elements.
     370         */
     371        private function role_options( string $selected ): string {
     372                $html = '';
     373
     374                foreach ( get_editable_roles() as $key => $details ) {
     375                        if ( ! Webhook::is_assignable_role( $key ) ) {
     376                                continue;
     377                        }
     378                        $html .= '<option value="' . esc_attr( $key ) . '"' . selected( $selected, $key, false ) . '>' . esc_html( translate_user_role( $details['name'] ) ) . '</option>';
     379                }
     380
     381                return $html;
     382        }
     383
     384        /**
     385         * One row of the tier table.
     386         *
     387         * @param string $tier Tier name.
     388         * @param string $role Role slug.
     389         * @return string
     390         */
     391        private function tier_row( string $tier, string $role ): string {
     392                return '<tr>'
     393                        . '<td><input type="text" name="members_for_kofi_options[tier_role_map][tier][]" value="' . esc_attr( $tier ) . '" class="regular-text tier-name-input" aria-label="' . esc_attr__( 'Tier name on Ko-fi', 'members-for-kofi' ) . '"></td>'
     394                        . '<td><select name="members_for_kofi_options[tier_role_map][role][]" class="tier-role-select" aria-label="' . esc_attr__( 'Role for this tier', 'members-for-kofi' ) . '">' . $this->role_options( $role ) . '</select></td>'
     395                        . '<td class="mfk-actions"><button type="button" class="button-link button-link-delete remove-tier-role-row" aria-label="' . esc_attr__( 'Remove this tier', 'members-for-kofi' ) . '">' . esc_html__( 'Remove', 'members-for-kofi' ) . '</button></td>'
     396                        . '</tr>';
     397        }
     398
     399        /**
     400         * The tier table, with tiers Ko-fi sent that are not mapped yet.
     401         *
     402         * @return void
     403         */
     404        public function render_tier_role_map_field(): void {
     405                $options = get_option( 'members_for_kofi_options', array() );
     406                $map     = isset( $options['tier_role_map'] ) && is_array( $options['tier_role_map'] ) ? $options['tier_role_map'] : array();
     407
     408                $rows = '';
     409                foreach ( $map as $tier => $role ) {
     410                        $rows .= $this->tier_row( (string) $tier, (string) $role );
     411                }
     412
     413                $unmapped = LogsTab::unmapped_tiers( array_map( 'strval', array_keys( $map ) ) );
     414                ?>
     415                <div class="mfk-field">
     416                        <p class="mfk-field-title"><?php esc_html_e( 'Ko-fi tiers', 'members-for-kofi' ); ?></p>
     417                        <div class="mfk-table-wrap">
     418                                <table id="tier-role-map-table" class="widefat mfk-table mfk-tier-table">
     419                                        <thead>
     420                                                <tr>
     421                                                        <th scope="col"><?php esc_html_e( 'Tier name on Ko-fi', 'members-for-kofi' ); ?></th>
     422                                                        <th scope="col"><?php esc_html_e( 'Role it grants', 'members-for-kofi' ); ?></th>
     423                                                        <th scope="col"><span class="screen-reader-text"><?php esc_html_e( 'Actions', 'members-for-kofi' ); ?></span></th>
     424                                                </tr>
     425                                        </thead>
     426                                        <tbody>
     427                                                <?php echo $rows; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Built by tier_row(), which escapes every value. ?>
     428                                        </tbody>
     429                                </table>
     430                        </div>
     431                        <template id="tier-role-row-template"><?php echo $this->tier_row( '', '' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped in tier_row(). ?></template>
     432                        <p class="mfk-inline">
     433                                <button type="button" class="button" id="add-tier-role-row"><?php esc_html_e( 'Add tier', 'members-for-kofi' ); ?></button>
     434                                <span class="mfk-muted"><?php esc_html_e( 'Names match whatever their capitalisation.', 'members-for-kofi' ); ?></span>
     435                        </p>
     436                        <?php if ( array() !== $unmapped ) : ?>
     437                                <div class="mfk-unmapped">
     438                                        <span><?php esc_html_e( 'Tiers Ko-fi sent that aren\'t listed:', 'members-for-kofi' ); ?></span>
     439                                        <?php foreach ( $unmapped as $tier => $payments ) : ?>
     440                                                <button type="button" class="button mfk-add-unmapped" data-tier="<?php echo esc_attr( $tier ); ?>">
     441                                                        <?php
     442                                                        echo esc_html(
     443                                                                sprintf(
     444                                                                        // translators: 1: a tier name, 2: how many payments named it.
     445                                                                        _n( 'Add "%1$s" (%2$d payment)', 'Add "%1$s" (%2$d payments)', $payments, 'members-for-kofi' ),
     446                                                                        $tier,
     447                                                                        $payments
     448                                                                )
     449                                                        );
     450                                                        ?>
     451                                                </button>
     452                                        <?php endforeach; ?>
     453                                </div>
     454                        <?php endif; ?>
     455                </div>
     456                <?php
     457        }
     458
     459        /**
     460         * The role for payments no tier matches.
     461         *
     462         * @return void
     463         */
     464        public function render_default_role_field(): void {
     465                $options  = get_option( 'members_for_kofi_options', array() );
     466                $selected = isset( $options['default_role'] ) && is_string( $options['default_role'] ) ? $options['default_role'] : '';
     467                ?>
     468                <div class="mfk-field">
     469                        <label for="mfk-default-role" class="mfk-field-title"><?php esc_html_e( 'Any other tier, or no tier', 'members-for-kofi' ); ?></label>
     470                        <select id="mfk-default-role" name="members_for_kofi_options[default_role]">
     471                                <option value=""><?php esc_html_e( '— No role —', 'members-for-kofi' ); ?></option>
     472                                <?php echo $this->role_options( $selected ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped in role_options(). ?>
     473                        </select>
     474                        <p class="description"><?php esc_html_e( 'With "No role", those payers still get an account, but no role. Administrator and other roles that can run the site are never offered.', 'members-for-kofi' ); ?></p>
     475                </div>
     476                <?php
     477        }
     478
     479        /**
     480         * "Remove the role" checkbox: the start of the expiry sentence.
     481         *
     482         * @return void
     483         */
     484        public static function render_expiry_toggle_field(): void {
     485                $options = get_option( 'members_for_kofi_options', array() );
     486                $checked = ! empty( $options['enable_expiry'] );
     487                echo '<input type="hidden" name="members_for_kofi_options[enable_expiry]" value="0">';
     488                echo '<input type="checkbox" id="members_for_kofi_enable_expiry" name="members_for_kofi_options[enable_expiry]" value="1"' . checked( $checked, true, false ) . '> ';
     489                echo '<label for="members_for_kofi_enable_expiry">' . esc_html__( 'Remove the role', 'members-for-kofi' ) . '</label> ';
     490        }
     491
     492        /**
     493         * "[35] days after the member's last payment": the end of the sentence.
     494         *
     495         * @return void
     496         */
     497        public static function render_role_expiry_field(): void {
     498                $options = get_option( 'members_for_kofi_options', array() );
     499                $value   = isset( $options['role_expiry_days'] ) ? intval( $options['role_expiry_days'] ) : 35;
     500                echo '<label for="members_for_kofi_expiry_days" class="screen-reader-text">' . esc_html__( 'Days after the last payment', 'members-for-kofi' ) . '</label>';
     501                echo '<input type="number" id="members_for_kofi_expiry_days" name="members_for_kofi_options[role_expiry_days]" value="' . esc_attr( (string) $value ) . '" min="1" class="small-text"> ';
     502                echo '<span>' . esc_html__( 'days after the member\'s last payment', 'members-for-kofi' ) . '</span>';
     503        }
     504
     505        /**
     506         * "Delete log entries older than" checkbox.
     507         *
     508         * @return void
     509         */
     510        public static function render_auto_clear_logs_field(): void {
     511                $options = get_option( 'members_for_kofi_options', array() );
     512                $checked = isset( $options['auto_clear_logs'] ) ? (bool) $options['auto_clear_logs'] : true;
     513                // A browser sends nothing for an unchecked box, and the sanitizer reads a
     514                // missing value as on (the default for installs that predate the
     515                // setting). Without this hidden "0", unticking the box never stuck.
     516                echo '<input type="hidden" name="members_for_kofi_options[auto_clear_logs]" value="0">';
     517                echo '<input type="checkbox" id="members_for_kofi_auto_clear_logs" name="members_for_kofi_options[auto_clear_logs]" value="1"' . checked( $checked, true, false ) . '> ';
     518                echo '<label for="members_for_kofi_auto_clear_logs">' . esc_html__( 'Delete log entries older than', 'members-for-kofi' ) . '</label> ';
     519        }
     520
     521        /**
     522         * "[30] days": the end of the log sentence.
     523         *
     524         * @return void
     525         */
     526        public static function render_log_retention_days_field(): void {
     527                $options = get_option( 'members_for_kofi_options', array() );
     528                $value   = isset( $options['log_retention_days'] ) ? intval( $options['log_retention_days'] ) : 30;
     529                echo '<label for="members_for_kofi_log_retention_days" class="screen-reader-text">' . esc_html__( 'Number of Days to Keep Logs', 'members-for-kofi' ) . '</label>';
     530                echo '<input type="number" id="members_for_kofi_log_retention_days" name="members_for_kofi_options[log_retention_days]" value="' . esc_attr( (string) $value ) . '" min="1" class="small-text"> ';
     531                echo '<span>' . esc_html__( 'days', 'members-for-kofi' ) . '</span>';
     532        }
     533
     534        /**
     535         * The webhook address to give Ko-fi.
     536         *
     537         * `/webhook-kofi/` needs pretty permalinks to reach WordPress at all on
     538         * some servers; the query form works everywhere, so it is what a site on
     539         * plain permalinks is shown.
     540         *
     541         * @return string
     542         */
     543        public static function webhook_url(): string {
     544                if ( '' === (string) get_option( 'permalink_structure' ) ) {
     545                        return add_query_arg( 'kofi_webhook', '1', home_url( '/' ) );
     546                }
     547
     548                return home_url( '/webhook-kofi/' );
     549        }
     550
     551        /**
     552         * Warns when the plugin's scheduled tasks are not running.
     553         *
     554         * With WP-Cron disabled and no system cron, roles never expire -- free
     555         * access for anyone who ever paid -- and logs are never pruned, with
     556         * nothing to say so. An event more than a day overdue is that evidence.
     557         *
     558         * @return void
     559         */
     560        public function render_cron_warning(): void {
     561                $options = get_option( 'members_for_kofi_options', array() );
     562                $hooks   = array( 'kofi_members_cleanup_logs' );
     563
     564                if ( ! empty( $options['enable_expiry'] ?? true ) ) {
     565                        $hooks[] = 'kofi_members_check_expired_roles';
     566                }
     567
     568                foreach ( $hooks as $hook ) {
     569                        $next = wp_next_scheduled( $hook );
     570
     571                        if ( false !== $next && $next < time() - DAY_IN_SECONDS ) {
     572                                echo '<div class="notice notice-warning"><p>' . esc_html(
     573                                        sprintf(
     574                                                // translators: %s is a human-readable time span, e.g. "3 days".
     575                                                __( 'Scheduled tasks are not running: role expiry and log cleanup are %s overdue. Make sure WP-Cron runs, or that a system cron calls wp-cron.php.', 'members-for-kofi' ),
     576                                                human_time_diff( $next )
     577                                        )
     578                                ) . '</p></div>';
     579                                return;
     580                        }
     581                }
     582        }
     583
     584        /**
     585         * Points administrators at an account left behind by Ko-fi's test button.
     586         *
     587         * Before 1.3.0 a "Send test" webhook was processed like a payment, so a
     588         * site that ever pressed it has a "Jo Example" account, usually holding the
     589         * membership role. Shown where an administrator would see and act on it.
     590         *
     591         * @param string|null $screen_id Screen to check; defaults to the current one.
     592         * @return void
     593         */
     594        public static function render_test_account_notice( $screen_id = null ): void {
     595                if ( ! current_user_can( 'delete_users' ) ) {
     596                        return;
     597                }
     598
     599                // do_action( 'admin_notices' ) hands every callback an empty string, so
     600                // '' has to mean "the current screen" just like no argument does.
     601                if ( ! is_string( $screen_id ) || '' === $screen_id ) {
     602                        $screen    = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
     603                        $screen_id = $screen ? (string) $screen->id : '';
     604                }
     605
     606                if ( ! in_array( $screen_id, array( 'dashboard', 'users', 'toplevel_page_members-for-kofi' ), true ) ) {
     607                        return;
     608                }
     609
     610                $user = get_user_by( 'email', Webhook::KOFI_TEST_EMAIL );
     611
     612                if ( ! $user ) {
     613                        return;
     614                }
     615
     616                $names = wp_roles()->get_names();
     617                $roles = array();
     618                foreach ( $user->roles as $slug ) {
     619                        $roles[] = isset( $names[ $slug ] ) ? translate_user_role( $names[ $slug ] ) : $slug;
     620                }
     621
     622                printf(
     623                        '<div class="notice notice-warning"><p>%s <a href="%s">%s</a></p></div>',
     624                        esc_html(
     625                                sprintf(
     626                                        // translators: 1: email address, 2: comma-separated role names, or "no role".
     627                                        __( 'Members for Ko-fi: an account was created by Ko-fi\'s "Send test" button (%1$s, role: %2$s). It is not a real supporter; earlier versions treated tests as payments. You can safely delete it.', 'members-for-kofi' ),
     628                                        Webhook::KOFI_TEST_EMAIL,
     629                                        array() !== $roles ? implode( ', ', $roles ) : __( 'none', 'members-for-kofi' )
    154630                                )
    155                         );
    156                 }
     631                        ),
     632                        esc_url( get_edit_user_link( $user->ID ) ),
     633                        esc_html__( 'Review the account', 'members-for-kofi' )
     634                );
     635        }
     636
     637        /**
     638         * Explains a stored hash and plaintext token that disagree.
     639         *
     640         * @return string
     641         */
     642        private static function token_conflict_message(): string {
     643                return __( 'Members for Ko-fi: two different verification tokens are stored, and webhooks are checked against the hashed one. Re-enter your Ko-fi verification token in the settings to resolve this.', 'members-for-kofi' );
     644        }
     645
     646        /**
     647         * Warns administrators, on every admin screen, about conflicting tokens.
     648         *
     649         * The site is verifying against a hash nobody can inspect, and the debug
     650         * log is off on most sites, so this is the only place it will be noticed.
     651         * Saving a token clears it: the settings never store the plaintext key.
     652         *
     653         * @return void
     654         */
     655        public static function render_token_conflict_notice(): void {
     656                if ( ! current_user_can( 'manage_options' ) ) {
     657                        return;
     658                }
     659
     660                if ( ! VerificationToken::has_conflict( get_option( VerificationToken::OPTION ) ) ) {
     661                        return;
     662                }
     663
     664                printf(
     665                        '<div class="notice notice-error"><p>%s <a href="%s">%s</a></p></div>',
     666                        esc_html( self::token_conflict_message() ),
     667                        esc_url( admin_url( 'admin.php?page=members-for-kofi' ) ),
     668                        esc_html__( 'Open settings', 'members-for-kofi' )
     669                );
    157670        }
    158671
    … …  
    194707                }
    195708
    196                 if ( ! $enable_expiry ) {
    197                         // Preserve existing stored value if expiry disabled, fallback to default.
     709                if ( ! $enable_expiry && empty( $role_expiry_days ) ) {
     710                        // Expiry is off and no usable number was sent: keep the stored one.
    198711                        $role_expiry_days = $stored['role_expiry_days'] ?? 35;
    199712                }
    … …  
    269782                        'log_retention_days'        => $log_retention_days,
    270783                );
    271         }   /**
    272                  * Renders the verification token field in the settings page.
    273                  *
    274                  * This function outputs the HTML for the verification token input field,
    275                  * allowing users to enter the token required for webhook verification.
    276                  *
    277                  * @return void
    278                  */
    279         public function render_verification_token_field(): void {
    280                 $options     = get_option( 'members_for_kofi_options' );
    281                 $webhook_url = self::webhook_url();
    282 
    283                 $fingerprint = VerificationToken::fingerprint( $options );
    284 
    285                 // Write-only: the saved token is never rendered back into the page, and
    286                 // only its hash is stored, so the value is always empty.
    287                 echo '<input type="password" name="members_for_kofi_options[verification_token]" value="" class="regular-text" autocomplete="new-password">';
    288 
    289                 if ( '' !== $fingerprint ) {
    290                         echo '<p class="description">' . esc_html__( 'A token is saved. Leave this blank to keep it, or paste a new one to replace it.', 'members-for-kofi' ) . '</p>';
    291                         echo '<p class="description">' . sprintf(
    292                                 // translators: %s is the first eight characters of the saved token's SHA-256 hash.
    293                                 esc_html__( 'Saved token fingerprint: %s', 'members-for-kofi' ),
    294                                 '<code>' . esc_html( $fingerprint ) . '</code>'
    295                         ) . '</p>';
    296                 } else {
    297                         echo '<p class="description">' . esc_html__( 'No token set yet. Webhooks are rejected until one is saved.', 'members-for-kofi' ) . '</p>';
    298                 }
    299 
    300                 if ( VerificationToken::has_conflict( $options ) ) {
    301                         echo '<p class="description" style="color: #b32d2e;">' . esc_html( self::token_conflict_message() ) . '</p>';
    302                 }
    303 
    304                 // Description for Verification Token.
    305                 $description = sprintf(
    306                         // translators: %s is a link to the Ko-fi Webhooks Management page.
    307                         esc_html__( 'This token is used to verify incoming webhook requests from Ko-fi. Paste the verification token from this page: %s and open the Advanced box. Only a hash of it is stored, so it cannot be shown again; to change it, paste the new one.', 'members-for-kofi' ),
    308                         '<a href="' . esc_url( 'https://ko-fi.com/manage/webhooks?src=sidemenu' ) . '" target="_blank">' . esc_html__( 'Ko-fi Webhooks Management', 'members-for-kofi' ) . '</a>'
    309                 );
    310                 echo '<p class="description">' . wp_kses(
    311                         $description,
    312                         array(
    313                                 'a' => array(
    314                                         'href'   => array(),
    315                                         'target' => array(),
    316                                 ),
    317                         )
    318                 ) . '</p>';
    319 
    320                 // Webhook URL Textbox and Copy Button.
    321                 echo '<div style="margin-top: 20px;">';
    322                 echo '<label for="webhook-kofi-url" style="font-weight: bold; display: block; margin-bottom: 5px;">' . esc_html__( 'Webhook URL:', 'members-for-kofi' ) . '</label>';
    323                 echo '<div style="display: flex; align-items: center; width: 500px;">';
    324                 echo '<input type="text" id="webhook-kofi-url" value="' . esc_url( $webhook_url ) . '" readonly style="flex: 1; margin-right: 10px;" class="regular-text">';
    325                 echo '<button type="button" id="copy-webhook-url" class="button button-secondary">' . esc_html__( 'Copy to Clipboard', 'members-for-kofi' ) . '</button>';
    326                 echo '</div>';
    327                 echo '<p class="description">' . esc_html__( 'Use this URL to configure your Ko-fi webhook.', 'members-for-kofi' ) . '</p>';
    328                 echo '</div>';
    329         }
    330 
    331         /**
    332          * The webhook address to give Ko-fi.
    333          *
    334          * `/webhook-kofi/` needs pretty permalinks to reach WordPress at all on
    335          * some servers; the query form works everywhere, so it is what a site on
    336          * plain permalinks is shown.
    337          *
    338          * @return string
    339          */
    340         public static function webhook_url(): string {
    341                 if ( '' === (string) get_option( 'permalink_structure' ) ) {
    342                         return add_query_arg( 'kofi_webhook', '1', home_url( '/' ) );
    343                 }
    344 
    345                 return home_url( '/webhook-kofi/' );
    346         }
    347 
    348         /**
    349          * Warns when the plugin's scheduled tasks are not running.
    350          *
    351          * With WP-Cron disabled and no system cron, roles never expire -- free
    352          * access for anyone who ever paid -- and logs are never pruned, with
    353          * nothing to say so. An event more than a day overdue is that evidence.
    354          *
    355          * @return void
    356          */
    357         public function render_cron_warning(): void {
    358                 $options = get_option( 'members_for_kofi_options', array() );
    359                 $hooks   = array( 'kofi_members_cleanup_logs' );
    360 
    361                 if ( ! empty( $options['enable_expiry'] ?? true ) ) {
    362                         $hooks[] = 'kofi_members_check_expired_roles';
    363                 }
    364 
    365                 foreach ( $hooks as $hook ) {
    366                         $next = wp_next_scheduled( $hook );
    367 
    368                         if ( false !== $next && $next < time() - DAY_IN_SECONDS ) {
    369                                 echo '<div class="notice notice-warning"><p>' . esc_html(
    370                                         sprintf(
    371                                                 // translators: %s is a human-readable time span, e.g. "3 days".
    372                                                 __( 'Scheduled tasks are not running: role expiry and log cleanup are %s overdue. Make sure WP-Cron runs, or that a system cron calls wp-cron.php.', 'members-for-kofi' ),
    373                                                 human_time_diff( $next )
    374                                         )
    375                                 ) . '</p></div>';
    376                                 return;
    377                         }
    378                 }
    379         }
    380 
    381         /**
    382          * Points administrators at an account left behind by Ko-fi's test button.
    383          *
    384          * Before 1.3.0 a "Send test" webhook was processed like a payment, so a
    385          * site that ever pressed it has a "Jo Example" account, usually holding the
    386          * membership role. Shown where an administrator would see and act on it.
    387          *
    388          * @param string|null $screen_id Screen to check; defaults to the current one.
    389          * @return void
    390          */
    391         public static function render_test_account_notice( $screen_id = null ): void {
    392                 if ( ! current_user_can( 'delete_users' ) ) {
    393                         return;
    394                 }
    395 
    396                 // do_action( 'admin_notices' ) hands every callback an empty string, so
    397                 // '' has to mean "the current screen" just like no argument does.
    398                 if ( ! is_string( $screen_id ) || '' === $screen_id ) {
    399                         $screen    = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
    400                         $screen_id = $screen ? (string) $screen->id : '';
    401                 }
    402 
    403                 if ( ! in_array( $screen_id, array( 'dashboard', 'users', 'toplevel_page_members-for-kofi' ), true ) ) {
    404                         return;
    405                 }
    406 
    407                 $user = get_user_by( 'email', Webhook::KOFI_TEST_EMAIL );
    408 
    409                 if ( ! $user ) {
    410                         return;
    411                 }
    412 
    413                 $names = wp_roles()->get_names();
    414                 $roles = array();
    415                 foreach ( $user->roles as $slug ) {
    416                         $roles[] = isset( $names[ $slug ] ) ? translate_user_role( $names[ $slug ] ) : $slug;
    417                 }
    418 
    419                 printf(
    420                         '<div class="notice notice-warning"><p>%s <a href="%s">%s</a></p></div>',
    421                         esc_html(
    422                                 sprintf(
    423                                         // translators: 1: email address, 2: comma-separated role names, or "no role".
    424                                         __( 'Members for Ko-fi: an account was created by Ko-fi\'s "Send test" button (%1$s, role: %2$s). It is not a real supporter; earlier versions treated tests as payments. You can safely delete it.', 'members-for-kofi' ),
    425                                         Webhook::KOFI_TEST_EMAIL,
    426                                         array() !== $roles ? implode( ', ', $roles ) : __( 'none', 'members-for-kofi' )
    427                                 )
    428                         ),
    429                         esc_url( get_edit_user_link( $user->ID ) ),
    430                         esc_html__( 'Review the account', 'members-for-kofi' )
    431                 );
    432         }
    433 
    434         /**
    435          * Explains a stored hash and plaintext token that disagree.
    436          *
    437          * @return string
    438          */
    439         private static function token_conflict_message(): string {
    440                 return __( 'Members for Ko-fi: two different verification tokens are stored, and webhooks are checked against the hashed one. Re-enter your Ko-fi verification token in the settings to resolve this.', 'members-for-kofi' );
    441         }
    442 
    443         /**
    444          * Warns administrators, on every admin screen, about conflicting tokens.
    445          *
    446          * The site is verifying against a hash nobody can inspect, and the debug
    447          * log is off on most sites, so this is the only place it will be noticed.
    448          * Saving a token clears it: the settings never store the plaintext key.
    449          *
    450          * @return void
    451          */
    452         public static function render_token_conflict_notice(): void {
    453                 if ( ! current_user_can( 'manage_options' ) ) {
    454                         return;
    455                 }
    456 
    457                 if ( ! VerificationToken::has_conflict( get_option( VerificationToken::OPTION ) ) ) {
    458                         return;
    459                 }
    460 
    461                 printf(
    462                         '<div class="notice notice-error"><p>%s <a href="%s">%s</a></p></div>',
    463                         esc_html( self::token_conflict_message() ),
    464                         esc_url( admin_url( 'admin.php?page=members-for-kofi' ) ),
    465                         esc_html__( 'Open settings', 'members-for-kofi' )
    466                 );
    467         }
    468 
    469         /**
    470          * Renders the "Only Accept Subscriptions" field in the settings page.
    471          *
    472          * This function outputs the HTML for the checkbox that allows users
    473          * to enable or disable role assignment only for active subscribers.
    474          *
    475          * @return void
    476          */
    477         public static function render_only_subscriptions_field(): void {
    478                 $options = get_option( 'members_for_kofi_options', array() );
    479                 $checked = ! empty( $options['only_subscriptions'] );
    480                 echo '<label><input type="checkbox" name="members_for_kofi_options[only_subscriptions]" value="1" ' . checked( $checked, true, false ) . '> ' . esc_html__( 'Only assign roles to subscribers.', 'members-for-kofi' ) . '</label>';
    481                 echo '<p class="description">' . esc_html__( 'When enabled, users must be active subscribers to receive a role.', 'members-for-kofi' ) . '</p>';
    482         }
    483 
    484         /**
    485          * Renders the Tier to Role Mapping field in the settings page.
    486          *
    487          * This function outputs the HTML for mapping Ko-fi tiers to WordPress roles,
    488          * allowing users to assign roles based on Ko-fi tier names.
    489          *
    490          * @return void
    491          */
    492         public function render_tier_role_map_field(): void {
    493                 $options = get_option( 'members_for_kofi_options', array() );
    494                 $map     = isset( $options['tier_role_map'] ) && is_array( $options['tier_role_map'] ) ? $options['tier_role_map'] : array();
    495                 $roles   = get_editable_roles();
    496 
    497                 echo '<table id="tier-role-map-table" class="form-table" style="margin-top:0">';
    498                 echo '<thead><tr><th>' . esc_html__( 'Ko-fi Tier', 'members-for-kofi' ) . '</th><th>' . esc_html__( 'Role', 'members-for-kofi' ) . '</th><th></th></tr></thead><tbody>';
    499 
    500                 if ( ! empty( $map ) ) {
    501                         foreach ( $map as $tier => $role ) {
    502                                 $tier_val = esc_attr( $tier );
    503                                 $role_val = esc_attr( $role );
    504                                 echo '<tr>';
    505                                 echo '<td><input type="text" name="members_for_kofi_options[tier_role_map][tier][]" value="' . esc_attr( $tier_val ) . '" class="regular-text"></td>';
    506                                 echo '<td><select name="members_for_kofi_options[tier_role_map][role][]" class="regular-text">';
    507                                 foreach ( $roles as $key => $details ) {
    508                                         if ( ! Webhook::is_assignable_role( $key ) ) {
    509                                                 continue;
    510                                         }
    511                                         echo '<option value="' . esc_attr( $key ) . '"' . selected( $role_val, $key, false ) . '>' . esc_html( $details['name'] ) . '</option>';
    512                                 }
    513                                 echo '</select></td>';
    514                                 echo '<td><button type="button" class="button remove-tier-role-row">×</button></td>';
    515                                 echo '</tr>';
    516                         }
    517                 }
    518 
    519                 echo '</tbody></table>';
    520 
    521                 echo '<template id="tier-role-row-template">';
    522                 echo '<tr>';
    523                 echo '<td><input type="text" name="members_for_kofi_options[tier_role_map][tier][]" class="regular-text tier-name-input"></td>';
    524                 echo '<td><select name="members_for_kofi_options[tier_role_map][role][]" class="regular-text tier-role-select">';
    525                 foreach ( $roles as $key => $details ) {
    526                         if ( ! Webhook::is_assignable_role( $key ) ) {
    527                                 continue;
    528                         }
    529                         echo '<option value="' . esc_attr( $key ) . '">' . esc_html( $details['name'] ) . '</option>';
    530                 }
    531                 echo '</select></td>';
    532                 echo '<td><button type="button" class="button remove-tier-role-row">×</button></td>';
    533                 echo '</tr>';
    534                 echo '</template>';
    535 
    536                 echo '<p><button type="button" class="button" id="add-tier-role-row">' . esc_html__( 'Add Tier Mapping', 'members-for-kofi' ) . '</button></p>';
    537                 echo '<p><em>' . esc_html__( 'Map Ko-fi tiers to WordPress roles. Only exact tier names will be matched.', 'members-for-kofi' ) . '</em></p>';
    538         }
    539 
    540         /**
    541          * Renders the default role field in the settings page.
    542          *
    543          * This function outputs the HTML for selecting a default WordPress role
    544          * to assign to users when no tier matches or tiers are not used.
    545          *
    546          * @return void
    547          */
    548         public function render_default_role_field(): void {
    549                 $options  = get_option( 'members_for_kofi_options', array() );
    550                 $roles    = get_editable_roles();
    551                 $selected = $options['default_role'] ?? array();
    552 
    553                 echo '<select name="members_for_kofi_options[default_role]">';
    554                 echo '<option value="">' . esc_html__( '— No default —', 'members-for-kofi' ) . '</option>';
    555                 foreach ( $roles as $role_key => $role_details ) {
    556                         // Skip disallowed roles (e.g., administrator) for security.
    557                         if ( ! Webhook::is_assignable_role( $role_key ) ) {
    558                                 continue;
    559                         }
    560                         echo '<option value="' . esc_attr( $role_key ) . '"' . selected( $role_key, $selected, false ) . '>' . esc_html( $role_details['name'] ) . '</option>';
    561                 }
    562                 echo '</select>';
    563                 echo '<p class="description">' . esc_html__( 'If no tier matches, or if you are not using tiers, this role will be assigned to the user.', 'members-for-kofi' ) . '</p>';
    564         }
    565 
    566         /**
    567          * Renders the "Enable Expiry" toggle field in the settings page.
    568          *
    569          * This function outputs the HTML for the checkbox that allows users
    570          * to enable or disable role expiry after a set number of days.
    571          *
    572          * @return void
    573          */
    574         public static function render_expiry_toggle_field(): void {
    575                 $options = get_option( 'members_for_kofi_options', array() );
    576                 $checked = ! empty( $options['enable_expiry'] );
    577                 echo '<label><input type="checkbox" name="members_for_kofi_options[enable_expiry]" value="1" ' . checked( $checked, true, false ) . '></label>';
    578                 echo '<p class="description">' . esc_html__( 'If checked, role will be removed after the set number of days.', 'members-for-kofi' ) . '</p>';
    579         }
    580 
    581         /**
    582          * Renders the role expiry field in the settings page.
    583          *
    584          * This function outputs the HTML for the input field that allows users
    585          * to specify the number of days a role remains active for non-subscribers.
    586          *
    587          * @return void
    588          */
    589         public static function render_role_expiry_field(): void {
    590                 $options       = get_option( 'members_for_kofi_options', array() );
    591                 $value         = isset( $options['role_expiry_days'] ) ? intval( $options['role_expiry_days'] ) : 35;
    592                 $expiry_enable = ! empty( $options['enable_expiry'] );
    593                 echo '<input type="number" id="members_for_kofi_expiry_days" name="members_for_kofi_options[role_expiry_days]" value="' . esc_attr( $value ) . '" min="1" ' . disabled( ! $expiry_enable, true, false ) . ' class="small-text">';
    594                 echo '<p class="description">' . esc_html__( 'Number of days the role remains active (only used if expiry is enabled).', 'members-for-kofi' ) . '</p>';
    595         }
    596 
    597         /**
    598          * Renders the auto clear logs checkbox field.
    599          *
    600          * @return void
    601          */
    602         public static function render_auto_clear_logs_field(): void {
    603                 $options = get_option( 'members_for_kofi_options', array() );
    604                 $checked = isset( $options['auto_clear_logs'] ) ? (bool) $options['auto_clear_logs'] : true;
    605                 // A browser sends nothing for an unchecked box, and the sanitizer reads a
    606                 // missing value as on (the default for installs that predate the
    607                 // setting). Without this hidden "0", unticking the box never stuck.
    608                 echo '<input type="hidden" name="members_for_kofi_options[auto_clear_logs]" value="0">';
    609                 echo '<input type="checkbox" id="members_for_kofi_auto_clear_logs" name="members_for_kofi_options[auto_clear_logs]" value="1" ' . checked( $checked, true, false ) . '>';
    610                 echo '<p class="description">' . esc_html__( 'Enable automatic deletion of old logs based on retention period.', 'members-for-kofi' ) . '</p>';
    611         }
    612 
    613         /**
    614          * Renders the log retention days field.
    615          *
    616          * @return void
    617          */
    618         public static function render_log_retention_days_field(): void {
    619                 $options = get_option( 'members_for_kofi_options', array() );
    620                 $value   = isset( $options['log_retention_days'] ) ? intval( $options['log_retention_days'] ) : 30;
    621                 echo '<input type="number" id="members_for_kofi_log_retention_days" name="members_for_kofi_options[log_retention_days]" value="' . esc_attr( $value ) . '" min="1" class="small-text">';
    622                 echo '<p class="description">' . esc_html__( 'Number of days to keep logs before automatic deletion (minimum 1 day).', 'members-for-kofi' ) . '</p>';
    623         }
    624 
    625         /**
    626          * Renders the log level field in the settings page.
    627          *
    628          * This function outputs the HTML for selecting the minimum severity
    629          * of log messages to record in the plugin's log file.
    630          *
    631          * @return void
    632          */
    633         // Deprecated: log level field removed with lightweight logger; method retained intentionally removed.
    634 
    635         /**
    636          * Renders the settings page for the Members for Ko-fi plugin.
    637          * This function outputs the HTML for the settings page in the WordPress admin dashboard,
    638          * allowing users to configure the plugin's settings.
    639          *
    640          * @return void
    641          */
    642         public function render_settings_page(): void {
    643                 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading tab from URL for display only
    644                 $active_tab = isset( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : 'settings';
    645                 if ( ! in_array( $active_tab, array( 'settings', 'logs' ), true ) ) {
    646                         $active_tab = 'settings';
    647                 }
    648                 ?>
    649                 <div class="wrap">
    650                         <h1><?php esc_html_e( 'Members for Ko-fi Settings', 'members-for-kofi' ); ?></h1>
    651                         <?php
    652                         // WordPress prints Settings API messages by itself only on pages
    653                         // under Settings. This is a top-level page, so without this call a
    654                         // rejected save ("Verification Token is required", a disallowed
    655                         // role) was silently dropped, and a good one got no confirmation.
    656                         settings_errors();
    657                         $this->render_cron_warning();
    658                         ?>
    659                         <h2 class="nav-tab-wrapper" role="tablist">
    660                                 <a href="#" role="tab" data-tab="settings" aria-selected="<?php echo 'settings' === $active_tab ? 'true' : 'false'; ?>" class="nav-tab <?php echo 'settings' === $active_tab ? 'nav-tab-active' : ''; ?>">
    661                                         <?php esc_html_e( 'Settings', 'members-for-kofi' ); ?>
    662                                 </a>
    663                                 <a href="#" role="tab" data-tab="logs" aria-selected="<?php echo 'logs' === $active_tab ? 'true' : 'false'; ?>" class="nav-tab <?php echo 'logs' === $active_tab ? 'nav-tab-active' : ''; ?>">
    664                                         <?php esc_html_e( 'Logs', 'members-for-kofi' ); ?>
    665                                 </a>
    666                         </h2>
    667                         <input type="hidden" id="active_tab" value="<?php echo esc_attr( $active_tab ); ?>" />
    668                         <form method="post" action="options.php" id="members-for-kofi-settings-form">
    669                                 <?php settings_fields( 'members_for_kofi_options' ); ?>
    670                                 <div id="members-for-kofi-tab-settings" class="members-for-kofi-tab" style="<?php echo 'settings' === $active_tab ? '' : 'display:none;'; ?>">
    671                                         <h2><?php esc_html_e( 'Ko-fi Settings', 'members-for-kofi' ); ?></h2>
    672                                         <table class="form-table">
    673                                                 <?php do_settings_fields( 'members-for-kofi', 'members_for_kofi_general' ); ?>
    674                                         </table>
    675                                         <h2><?php esc_html_e( 'Role Assignment', 'members-for-kofi' ); ?></h2>
    676                                         <table class="form-table">
    677                                                 <?php do_settings_fields( 'members-for-kofi', 'members_for_kofi_role_assignment' ); ?>
    678                                         </table>
    679                                         <h2><?php esc_html_e( 'Logging', 'members-for-kofi' ); ?></h2>
    680                                         <table class="form-table">
    681                                                 <?php do_settings_fields( 'members-for-kofi', 'members_for_kofi_logging' ); ?>
    682                                         </table>
    683                                         <?php submit_button(); ?>
    684                                 </div>
    685                         </form>
    686                         <div id="members-for-kofi-tab-logs" class="members-for-kofi-tab" style="<?php echo 'logs' === $active_tab ? '' : 'display:none;'; ?>">
    687                                 <h2><?php esc_html_e( 'Logs', 'members-for-kofi' ); ?></h2>
    688                                 <?php $this->render_logs_tab(); ?>
    689                         </div>
    690                 </div>
    691                 <?php
    692         }
    693 
    694         /**
    695          * Renders the "Logs" tab in the settings page.
    696          *
    697          * This function outputs the HTML for displaying logs with a dropdown to switch between user and request logs.
    698          *
    699          * @param int|null $paged         The current page number, or null to auto-determine.
    700          * @param int      $rows_per_page The number of rows to display per page.
    701          * @param string   $log_type      The type of logs to display ('user' or 'request').
    702          * @return void
    703          */
    704         public function render_logs_tab( ?int $paged = null, int $rows_per_page = 10, string $log_type = 'user' ): void {
    705                 $rows_per_page = $this->sanitize_rows_per_page( $rows_per_page );
    706                 global $wpdb;
    707 
    708                 // Determine which table to query based on log type.
    709                 if ( 'request' === $log_type ) {
    710                         $table_name    = $wpdb->prefix . 'members_for_kofi_request_logs';
    711                         $transient_key = 'members_for_kofi_total_request_logs';
    712                 } else {
    713                         $table_name    = $this->get_logs_table_name();
    714                         $transient_key = 'members_for_kofi_total_logs';
    715                 }
    716 
    717                 $total_logs = get_transient( $transient_key );
    718 
    719                 if ( false === $total_logs || 0 === (int) $total_logs ) {
    720                         // Recalculate if not cached or cached as zero (could be stale from prior table mismatch).
    721                         // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared -- Counting rows in plugin-owned table.
    722                         $total_logs = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . esc_sql( $table_name ) . '`' );
    723                         set_transient( $transient_key, $total_logs, MINUTE_IN_SECONDS );
    724                 }
    725 
    726                 $total_pages = ceil( $total_logs / $rows_per_page );
    727 
    728                 ?>
    729                 <!-- Log Type Selector -->
    730                 <div style="margin-bottom:15px;">
    731                         <label for="log-type-selector" style="font-weight:bold; margin-right:10px;">
    732                                 <?php esc_html_e( 'Log Type:', 'members-for-kofi' ); ?>
    733                         </label>
    734                         <select id="log-type-selector" style="min-width:150px;">
    735                                 <option value="user" <?php selected( $log_type, 'user' ); ?>>
    736                                         <?php esc_html_e( 'User Logs', 'members-for-kofi' ); ?>
    737                                 </option>
    738                                 <option value="request" <?php selected( $log_type, 'request' ); ?>>
    739                                         <?php esc_html_e( 'Request Logs', 'members-for-kofi' ); ?>
    740                                 </option>
    741                         </select>
    742                 </div>
    743 
    744                 <!-- Logs Table Container -->
    745                 <div id="logs-controls" style="display:flex; gap:10px; align-items:flex-end; margin-bottom:10px;">
    746                         <div>
    747                                 <label for="kofi-members-log-search" style="display:block;">
    748                                         <?php
    749                                         if ( 'request' === $log_type ) {
    750                                                 esc_html_e( 'Search (email / tier / status)', 'members-for-kofi' );
    751                                         } else {
    752                                                 esc_html_e( 'Search (email / action / role)', 'members-for-kofi' );
    753                                         }
    754                                         ?>
    755                                 </label>
    756                                 <input type="search" id="kofi-members-log-search" style="min-width:280px;" />
    757                         </div>
    758                         <div>
    759                                 <button type="button" class="button" id="kofi-members-log-search-btn"><?php esc_html_e( 'Search', 'members-for-kofi' ); ?></button>
    760                                 <button type="button" class="button" id="kofi-members-log-reset-btn"><?php esc_html_e( 'Reset', 'members-for-kofi' ); ?></button>
    761                         </div>
    762                         <div>
    763                                 <button type="button" class="button" id="kofi-members-log-refresh-btn"><?php esc_html_e( 'Refresh', 'members-for-kofi' ); ?></button>
    764                         </div>
    765                 </div>
    766                 <div id="logs-table-container" data-current-search="" data-log-type="<?php echo esc_attr( $log_type ); ?>">
    767                         <?php
    768                         if ( 'request' === $log_type ) {
    769                                 $this->render_request_logs_table( $paged, $rows_per_page );
    770                         } else {
    771                                 $this->render_user_logs_table( $paged, $rows_per_page );
    772                         }
    773                         ?>
    774                 </div>
    775 
    776                 <?php
    777         }
    778 
    779         /**
    780          * Handles the AJAX request for pagination in the user logs tab.
    781          *
    782          * This function verifies the user's permissions, processes the pagination request,
    783          * and returns the rendered logs table for the requested page.
    784          *
    785          * @return void
    786          */
    787         public function handle_pagination(): void {
    788                 // Verify the nonce for security.
    789                 check_ajax_referer( 'members_for_kofi_pagination', '_ajax_nonce' );
    790                 \MembersForKofi\Logging\DebugLogger::debug( 'AJAX pagination request' );
    791 
    792                 // Verify the current user has permission to view logs.
    793                 if ( ! current_user_can( 'manage_options' ) ) {
    794                         wp_send_json_error( __( 'You do not have permission to access this resource.', 'members-for-kofi' ) );
    795                 }
    796 
    797                 $paged    = isset( $_POST['paged'] ) ? absint( $_POST['paged'] ) : 1;
    798                 $search   = isset( $_POST['search'] ) ? sanitize_text_field( wp_unslash( $_POST['search'] ) ) : '';
    799                 $log_type = isset( $_POST['log_type'] ) ? sanitize_text_field( wp_unslash( $_POST['log_type'] ) ) : 'user';
    800 
    801                 // Ensure the paged parameter is valid.
    802                 if ( $paged < 1 ) {
    803                         wp_send_json_error( __( 'Invalid page number.', 'members-for-kofi' ) );
    804                 }
    805 
    806                 // Render only the logs table for the requested page.
    807                 ob_start();
    808                 if ( 'request' === $log_type ) {
    809                         $this->render_request_logs_table( $paged, 10, $search );
    810                 } else {
    811                         $this->render_user_logs_table( $paged, 10, $search );
    812                 }
    813                 $content = ob_get_clean();
    814 
    815                 wp_send_json_success( $content );
    816         }
    817 
    818         /**
    819          * Handles the AJAX request to clear logs.
    820          *
    821          * @return void
    822          */
    823         public function handle_clear_logs(): void {
    824                 // Verify the nonce for security.
    825                 check_ajax_referer( 'members_for_kofi_clear_logs', '_ajax_nonce' );
    826                 \MembersForKofi\Logging\DebugLogger::debug( 'AJAX clear logs request' );
    827 
    828                 // Verify the current user has permission to clear logs.
    829                 if ( ! current_user_can( 'manage_options' ) ) {
    830                         wp_send_json_error( __( 'You do not have permission to access this resource.', 'members-for-kofi' ) );
    831                 }
    832 
    833                 global $wpdb;
    834                 $log_type = isset( $_POST['log_type'] ) ? sanitize_text_field( wp_unslash( $_POST['log_type'] ) ) : 'user';
    835 
    836                 // Determine which table to clear based on log type.
    837                 if ( 'request' === $log_type ) {
    838                         $table_name    = $wpdb->prefix . 'members_for_kofi_request_logs';
    839                         $transient_key = 'members_for_kofi_total_request_logs';
    840                 } else {
    841                         $table_name    = $this->get_logs_table_name();
    842                         $transient_key = 'members_for_kofi_total_logs';
    843                 }
    844 
    845                 // Check if the logs table exists and clear it.
    846                 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Checking for the plugin's own table; a cached answer could be wrong.
    847                 if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) ) ) {
    848                         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Clearing the plugin's own log table on admin request; the cached count is deleted below.
    849                         $wpdb->query( 'DELETE FROM `' . esc_sql( $table_name ) . '`' );
    850                         delete_transient( $transient_key ); // Clear the cached total logs count.
    851 
    852                         // Render the updated logs table (which will now show "No logs available").
    853                         ob_start();
    854                         if ( 'request' === $log_type ) {
    855                                 $this->render_request_logs_table();
    856                         } else {
    857                                 $this->render_user_logs_table();
    858                         }
    859                         $content = ob_get_clean();
    860 
    861                         wp_send_json_success( $content );
    862                 } else {
    863                         wp_send_json_error( __( 'Logs table does not exist.', 'members-for-kofi' ) );
    864                 }
    865         }
    866 
    867         /**
    868          * Handles the AJAX request to update rows per page.
    869          *
    870          * @return void
    871          */
    872         /**
    873          * Clamps a requested page size to one the log viewer actually offers.
    874          *
    875          * Anything the picker cannot produce is rejected rather than squeezed
    876          * into range: 0 reached `ceil( $total_logs / 0 )` and killed the page
    877          * with a DivisionByZeroError, and a huge value went straight into LIMIT
    878          * and loaded the entire table into memory.
    879          *
    880          * @param mixed $raw Untrusted page size, typically straight from $_POST.
    881          * @return int One of ROWS_PER_PAGE_OPTIONS.
    882          */
    883         public function sanitize_rows_per_page( $raw ): int {
    884                 $value = absint( $raw );
    885 
    886                 if ( ! in_array( $value, self::ROWS_PER_PAGE_OPTIONS, true ) ) {
    887                         return self::DEFAULT_ROWS_PER_PAGE;
    888                 }
    889 
    890                 return $value;
    891         }
    892 
    893         /**
    894          * Handles the AJAX request that changes the log table's page size.
    895          *
    896          * @return void
    897          */
    898         public function handle_update_rows_per_page(): void {
    899                 check_ajax_referer( 'members_for_kofi_update_rows_per_page', '_ajax_nonce' );
    900                 \MembersForKofi\Logging\DebugLogger::debug( 'AJAX rows per page update request' );
    901 
    902                 // Verify the current user has permission to update settings.
    903                 if ( ! current_user_can( 'manage_options' ) ) {
    904                         wp_send_json_error( __( 'You do not have permission to access this resource.', 'members-for-kofi' ) );
    905                 }
    906 
    907                 $rows_per_page = $this->sanitize_rows_per_page(
    908                         isset( $_POST['rows_per_page'] ) ? absint( wp_unslash( $_POST['rows_per_page'] ) ) : self::DEFAULT_ROWS_PER_PAGE
    909                 );
    910                 $search        = isset( $_POST['search'] ) ? sanitize_text_field( wp_unslash( $_POST['search'] ) ) : '';
    911                 $log_type      = isset( $_POST['log_type'] ) ? sanitize_text_field( wp_unslash( $_POST['log_type'] ) ) : 'user';
    912 
    913                 ob_start();
    914                 if ( 'request' === $log_type ) {
    915                         $this->render_request_logs_table( null, $rows_per_page, $search );
    916                 } else {
    917                         $this->render_user_logs_table( null, $rows_per_page, $search );
    918                 }
    919                 $content = ob_get_clean();
    920 
    921                 wp_send_json_success( $content );
    922         }
    923 
    924         /**
    925          * Handles AJAX log filtering.
    926          */
    927         public function handle_filter_logs(): void {
    928                 check_ajax_referer( 'members_for_kofi_filter_logs', '_ajax_nonce' );
    929                 \MembersForKofi\Logging\DebugLogger::debug( 'AJAX filter logs request' );
    930 
    931                 // Verify the current user has permission to view logs.
    932                 if ( ! current_user_can( 'manage_options' ) ) {
    933                         wp_send_json_error( __( 'You do not have permission to access this resource.', 'members-for-kofi' ) );
    934                 }
    935 
    936                 $search        = isset( $_POST['search'] ) ? sanitize_text_field( wp_unslash( $_POST['search'] ) ) : '';
    937                 $paged         = isset( $_POST['paged'] ) ? absint( $_POST['paged'] ) : 1;
    938                 $log_type      = isset( $_POST['log_type'] ) ? sanitize_text_field( wp_unslash( $_POST['log_type'] ) ) : 'user';
    939                 $rows_per_page = $this->sanitize_rows_per_page(
    940                         isset( $_POST['rows_per_page'] ) ? absint( wp_unslash( $_POST['rows_per_page'] ) ) : self::DEFAULT_ROWS_PER_PAGE
    941                 );
    942 
    943                 ob_start();
    944                 // Every other log handler honours log_type; this one always rendered the
    945                 // user log, so searching from the Request tab answered with user rows.
    946                 if ( 'request' === $log_type ) {
    947                         $this->render_request_logs_table( $paged, $rows_per_page, $search );
    948                 } else {
    949                         $this->render_user_logs_table( $paged, $rows_per_page, $search );
    950                 }
    951                 $content = ob_get_clean();
    952                 wp_send_json_success( $content );
    953         }
    954 
    955         /**
    956          * Handles AJAX refresh (no search alteration; just reload current page).
    957          */
    958         public function handle_refresh_logs(): void {
    959                 check_ajax_referer( 'members_for_kofi_refresh_logs', '_ajax_nonce' );
    960                 \MembersForKofi\Logging\DebugLogger::debug( 'AJAX refresh logs request' );
    961 
    962                 // Verify the current user has permission to view logs.
    963                 if ( ! current_user_can( 'manage_options' ) ) {
    964                         wp_send_json_error( __( 'You do not have permission to access this resource.', 'members-for-kofi' ) );
    965                 }
    966 
    967                 $paged         = isset( $_POST['paged'] ) ? absint( $_POST['paged'] ) : 1;
    968                 $rows_per_page = $this->sanitize_rows_per_page(
    969                         isset( $_POST['rows_per_page'] ) ? absint( wp_unslash( $_POST['rows_per_page'] ) ) : self::DEFAULT_ROWS_PER_PAGE
    970                 );
    971                 $search        = isset( $_POST['search'] ) ? sanitize_text_field( wp_unslash( $_POST['search'] ) ) : '';
    972                 ob_start();
    973                 $this->render_user_logs_table( $paged, $rows_per_page, $search );
    974                 $content = ob_get_clean();
    975                 wp_send_json_success( $content );
    976         }
    977 
    978         /**
    979          * Handles AJAX log type switch (User vs Request logs).
    980          */
    981         public function handle_switch_log_type(): void {
    982                 check_ajax_referer( 'members_for_kofi_switch_log_type', '_ajax_nonce' );
    983                 \MembersForKofi\Logging\DebugLogger::debug( 'AJAX switch log type request' );
    984 
    985                 // Verify the current user has permission to view logs.
    986                 if ( ! current_user_can( 'manage_options' ) ) {
    987                         wp_send_json_error( __( 'You do not have permission to access this resource.', 'members-for-kofi' ) );
    988                 }
    989 
    990                 $log_type      = isset( $_POST['log_type'] ) ? sanitize_text_field( wp_unslash( $_POST['log_type'] ) ) : 'user';
    991                 $search        = isset( $_POST['search'] ) ? sanitize_text_field( wp_unslash( $_POST['search'] ) ) : '';
    992                 $paged         = isset( $_POST['paged'] ) ? absint( $_POST['paged'] ) : 1;
    993                 $rows_per_page = $this->sanitize_rows_per_page(
    994                         isset( $_POST['rows_per_page'] ) ? absint( wp_unslash( $_POST['rows_per_page'] ) ) : self::DEFAULT_ROWS_PER_PAGE
    995                 );
    996 
    997                 ob_start();
    998                 if ( 'request' === $log_type ) {
    999                         $this->render_request_logs_table( $paged, $rows_per_page, $search );
    1000                 } else {
    1001                         $this->render_user_logs_table( $paged, $rows_per_page, $search );
    1002                 }
    1003                 $content = ob_get_clean();
    1004                 wp_send_json_success( $content );
    1005         }
    1006 
    1007         /**
    1008          * Renders the user logs table.
    1009          *
    1010          * This function outputs the HTML for displaying the user logs in a table,
    1011          * including pagination links.
    1012          *
    1013          * @param int|null $paged         The current page number, or null to auto-determine.
    1014          * @param int      $rows_per_page The number of rows to display per page.
    1015          * @param string   $search        Search filter.
    1016          * @return void
    1017          */
    1018         public function render_user_logs_table( ?int $paged = null, int $rows_per_page = 10, string $search = '' ): void {
    1019                 $rows_per_page = $this->sanitize_rows_per_page( $rows_per_page );
    1020                 global $wpdb;
    1021 
    1022                 $table_name   = $this->get_logs_table_name();
    1023                 $current_page = $paged ?? 1; // Simplified; no $_GET fallback (avoids nonce sniff concerns).
    1024                 $offset       = ( $current_page - 1 ) * $rows_per_page;
    1025 
    1026                 $use_cache  = ( '' === $search );
    1027                 $total_logs = $use_cache ? get_transient( 'members_for_kofi_total_logs' ) : false;
    1028                 $where_sql  = 'WHERE 1=1';
    1029                 $params     = array();
    1030                 $like       = '';
    1031                 if ( '' !== $search ) {
    1032                         $like       = '%' . $wpdb->esc_like( $search ) . '%';
    1033                         $where_sql .= ' AND (email LIKE %s OR action LIKE %s OR role LIKE %s)';
    1034                         $params     = array( $like, $like, $like );
    1035                 }
    1036 
    1037                 if ( false === $total_logs ) {
    1038                         if ( empty( $params ) ) {
    1039                                 // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Counting plugin-owned table.
    1040                                 $total_logs = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . esc_sql( $table_name ) . '`' );
    1041                         } else {
    1042                                 // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared -- $query is literal SQL with %s placeholders; the search term is escaped into $params.
    1043                                 $query      = 'SELECT COUNT(*) FROM `' . esc_sql( $table_name ) . '` ' . $where_sql;
    1044                                 $total_logs = (int) $wpdb->get_var( $wpdb->prepare( $query, $params ) );
    1045                                 // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
    1046                         }
    1047                         if ( ! empty( $wpdb->last_error ) ) {
    1048                                 \MembersForKofi\Logging\DebugLogger::error(
    1049                                         'DB error counting logs',
    1050                                         array(
    1051                                                 'error'      => $wpdb->last_error,
    1052                                                 'last_query' => $wpdb->last_query,
    1053                                         )
    1054                                 );
    1055                         }
    1056                         if ( $use_cache ) {
    1057                                 set_transient( 'members_for_kofi_total_logs', $total_logs, MINUTE_IN_SECONDS );
    1058                         }
    1059                 }
    1060 
    1061                 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Controlled table name.
    1062                 // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Plugin-owned table query.
    1063                 if ( empty( $params ) ) {
    1064                         // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Unfiltered query.
    1065                         $logs = $wpdb->get_results(
    1066                                 $wpdb->prepare(
    1067                                         'SELECT `timestamp`, `user_id`, `email`, `action`, `role` FROM `' . esc_sql( $table_name ) . '` ORDER BY `timestamp` DESC LIMIT %d OFFSET %d',
    1068                                         $rows_per_page,
    1069                                         $offset
    1070                                 ),
    1071                                 ARRAY_A
    1072                         );
    1073                         // Debug removed (noise).
    1074                 } else {
    1075                         // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared -- $query is literal SQL with %s placeholders; the search term is escaped into $params.
    1076                         $query = 'SELECT `timestamp`, `user_id`, `email`, `action`, `role` FROM `' . esc_sql( $table_name ) . '` ' . $where_sql . ' ORDER BY `timestamp` DESC LIMIT %d OFFSET %d';
    1077                         $logs  = $wpdb->get_results(
    1078                                 $wpdb->prepare(
    1079                                         $query,
    1080                                         array_merge( $params, array( $rows_per_page, $offset ) )
    1081                                 ),
    1082                                 ARRAY_A
    1083                         );
    1084                         // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
    1085                         // Debug removed (noise).
    1086                 }
    1087                 if ( empty( $logs ) && (int) $total_logs > 0 ) {
    1088                         \MembersForKofi\Logging\DebugLogger::warning( 'No rows returned despite positive total' );
    1089                 }
    1090 
    1091                 $total_pages = ceil( $total_logs / $rows_per_page );
    1092 
    1093                 ?>
    1094                 <table class="widefat fixed striped">
    1095                         <thead>
    1096                                 <tr>
    1097                                         <th><?php esc_html_e( 'Timestamp', 'members-for-kofi' ); ?></th>
    1098                                         <th><?php esc_html_e( 'User ID', 'members-for-kofi' ); ?></th>
    1099                                         <th><?php esc_html_e( 'Email', 'members-for-kofi' ); ?></th>
    1100                                         <th><?php esc_html_e( 'Action', 'members-for-kofi' ); ?></th>
    1101                                         <th><?php esc_html_e( 'Role', 'members-for-kofi' ); ?></th>
    1102                                 </tr>
    1103                         </thead>
    1104                         <tbody>
    1105                                 <?php if ( empty( $logs ) ) : ?>
    1106                                         <tr>
    1107                                                 <td colspan="5"><?php esc_html_e( 'No logs available.', 'members-for-kofi' ); ?></td>
    1108                                         </tr>
    1109                                 <?php else : ?>
    1110                                         <?php foreach ( $logs as $log ) : ?>
    1111                                                 <tr>
    1112                                                         <td><?php echo esc_html( $log['timestamp'] ); ?></td>
    1113                                                         <td><?php echo esc_html( $log['user_id'] ); ?></td>
    1114                                                         <td><?php echo esc_html( $log['email'] ); ?></td>
    1115                                                         <td><?php echo esc_html( $log['action'] ); ?></td>
    1116                                                         <td><?php echo esc_html( $log['role'] ); ?></td>
    1117                                                 </tr>
    1118                                         <?php endforeach; ?>
    1119                                 <?php endif; ?>
    1120                         </tbody>
    1121                 </table>
    1122 
    1123                 <div style="display: flex; align-items: center; justify-content: space-between; margin-top: 15px;">
    1124                         <input type="hidden" id="kofi-members-current-search" value="<?php echo esc_attr( $search ); ?>" />
    1125                         <!-- Clear Logs Button -->
    1126                         <form method="post" action="<?php echo esc_url( add_query_arg( 'tab', 'user_logs', admin_url( 'admin.php?page=members-for-kofi' ) ) ); ?>" style="margin-right: 15px;">
    1127                                 <?php wp_nonce_field( 'clear_user_logs_action', 'clear_user_logs_nonce' ); ?>
    1128                                 <button type="button" name="clear_logs" class="button button-secondary">
    1129                                         <?php esc_html_e( 'Clear Logs', 'members-for-kofi' ); ?>
    1130                                 </button>
    1131                         </form>
    1132 
    1133                         <!-- Rows Per Page Dropdown -->
    1134                         <form method="get" style="margin-right: 15px;">
    1135                                 <input type="hidden" name="page" value="members-for-kofi">
    1136                                 <input type="hidden" name="tab" value="user_logs">
    1137                                 <label for="rows_per_page" style="margin-right: 10px;"><?php esc_html_e( 'Rows per page:', 'members-for-kofi' ); ?></label>
    1138                                 <select name="rows_per_page" id="rows_per_page" style="width: auto;">
    1139                                         <?php foreach ( self::ROWS_PER_PAGE_OPTIONS as $option ) : ?>
    1140                                                 <option value="<?php echo esc_attr( $option ); ?>" <?php selected( $rows_per_page, $option ); ?>>
    1141                                                         <?php echo esc_html( $option ); ?>
    1142                                                 </option>
    1143                                         <?php endforeach; ?>
    1144                                 </select>
    1145                         </form>
    1146                         <div class="pagination-links" style="display:flex; gap:4px; flex-wrap:wrap;">
    1147                                 <?php if ( $total_pages > 1 ) : ?>
    1148                                         <?php
    1149                                         $prev_page = max( 1, $current_page - 1 );
    1150                                         $next_page = min( $total_pages, $current_page + 1 );
    1151                                         if ( $current_page > 1 ) {
    1152                                                 echo '<button type="button" class="button members-for-kofi-page-btn" data-page="' . esc_attr( $prev_page ) . '">&laquo; ' . esc_html__( 'Previous', 'members-for-kofi' ) . '</button>';
    1153                                         }
    1154                                         for ( $p = 1; $p <= $total_pages; $p++ ) {
    1155                                                 $cls = 'button members-for-kofi-page-btn' . ( $p === $current_page ? ' button-primary' : '' );
    1156                                                 echo '<button type="button" class="' . esc_attr( $cls ) . '" data-page="' . esc_attr( $p ) . '">' . esc_html( (string) $p ) . '</button>';
    1157                                         }
    1158                                         if ( $current_page < $total_pages ) {
    1159                                                 echo '<button type="button" class="button members-for-kofi-page-btn" data-page="' . esc_attr( $next_page ) . '">' . esc_html__( 'Next', 'members-for-kofi' ) . ' &raquo;</button>';
    1160                                         }
    1161                                         ?>
    1162                                 <?php endif; ?>
    1163                         </div>
    1164                 </div>
    1165                 <?php
    1166         }
    1167 
    1168         /**
    1169          * Renders the request logs table.
    1170          *
    1171          * @param int|null $paged         The current page number.
    1172          * @param int      $rows_per_page The number of rows to display per page.
    1173          * @param string   $search        Search filter.
    1174          * @return void
    1175          */
    1176         public function render_request_logs_table( ?int $paged = null, int $rows_per_page = 10, string $search = '' ): void {
    1177                 $rows_per_page = $this->sanitize_rows_per_page( $rows_per_page );
    1178                 global $wpdb;
    1179 
    1180                 $table_name   = $wpdb->prefix . 'members_for_kofi_request_logs';
    1181                 $current_page = $paged ?? 1;
    1182                 $offset       = ( $current_page - 1 ) * $rows_per_page;
    1183 
    1184                 $use_cache  = ( '' === $search );
    1185                 $total_logs = $use_cache ? get_transient( 'members_for_kofi_total_request_logs' ) : false;
    1186                 $where_sql  = 'WHERE 1=1';
    1187                 $params     = array();
    1188 
    1189                 if ( '' !== $search ) {
    1190                         $like       = '%' . $wpdb->esc_like( $search ) . '%';
    1191                         $where_sql .= ' AND (email LIKE %s OR tier_name LIKE %s OR CAST(status_code AS CHAR) LIKE %s OR error LIKE %s)';
    1192                         $params     = array( $like, $like, $like, $like );
    1193                 }
    1194 
    1195                 if ( false === $total_logs ) {
    1196                         if ( empty( $params ) ) {
    1197                                 // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Counting plugin-owned table.
    1198                                 $total_logs = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . esc_sql( $table_name ) . '`' );
    1199                         } else {
    1200                                 // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared -- $query is literal SQL with %s placeholders; the search term is escaped into $params.
    1201                                 $query      = 'SELECT COUNT(*) FROM `' . esc_sql( $table_name ) . '` ' . $where_sql;
    1202                                 $total_logs = (int) $wpdb->get_var( $wpdb->prepare( $query, $params ) );
    1203                                 // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
    1204                         }
    1205                         if ( $use_cache ) {
    1206                                 set_transient( 'members_for_kofi_total_request_logs', $total_logs, MINUTE_IN_SECONDS );
    1207                         }
    1208                 }
    1209 
    1210                 if ( empty( $params ) ) {
    1211                         // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Unfiltered query on a plugin-owned table.
    1212                         $logs = $wpdb->get_results(
    1213                                 $wpdb->prepare(
    1214                                         'SELECT `timestamp`, `email`, `tier_name`, `amount`, `currency`, `is_subscription`, `status_code`, `success`, `error` FROM `' . esc_sql( $table_name ) . '` ORDER BY `timestamp` DESC LIMIT %d OFFSET %d',
    1215                                         $rows_per_page,
    1216                                         $offset
    1217                                 ),
    1218                                 ARRAY_A
    1219                         );
    1220                 } else {
    1221                         // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared -- $query is literal SQL with %s placeholders; the search term is escaped into $params.
    1222                         $query = 'SELECT `timestamp`, `email`, `tier_name`, `amount`, `currency`, `is_subscription`, `status_code`, `success`, `error` FROM `' . esc_sql( $table_name ) . '` ' . $where_sql . ' ORDER BY `timestamp` DESC LIMIT %d OFFSET %d';
    1223                         $logs  = $wpdb->get_results(
    1224                                 $wpdb->prepare(
    1225                                         $query,
    1226                                         array_merge( $params, array( $rows_per_page, $offset ) )
    1227                                 ),
    1228                                 ARRAY_A
    1229                         );
    1230                         // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
    1231                 }
    1232 
    1233                 $total_pages = ceil( $total_logs / $rows_per_page );
    1234 
    1235                 ?>
    1236 <table class="widefat fixed striped">
    1237 <thead>
    1238 <tr>
    1239 <th><?php esc_html_e( 'Timestamp', 'members-for-kofi' ); ?></th>
    1240 <th><?php esc_html_e( 'Email', 'members-for-kofi' ); ?></th>
    1241 <th><?php esc_html_e( 'Tier Name', 'members-for-kofi' ); ?></th>
    1242 <th><?php esc_html_e( 'Amount', 'members-for-kofi' ); ?></th>
    1243 <th><?php esc_html_e( 'Subscription', 'members-for-kofi' ); ?></th>
    1244 <th><?php esc_html_e( 'Status', 'members-for-kofi' ); ?></th>
    1245 <th><?php esc_html_e( 'Success', 'members-for-kofi' ); ?></th>
    1246 <th><?php esc_html_e( 'Error', 'members-for-kofi' ); ?></th>
    1247 </tr>
    1248 </thead>
    1249 <tbody>
    1250                 <?php if ( empty( $logs ) ) : ?>
    1251 <tr>
    1252 <td colspan="8"><?php esc_html_e( 'No logs available.', 'members-for-kofi' ); ?></td>
    1253 </tr>
    1254 <?php else : ?>
    1255         <?php foreach ( $logs as $log ) : ?>
    1256 <tr>
    1257 <td><?php echo esc_html( $log['timestamp'] ); ?></td>
    1258 <td><?php echo esc_html( $log['email'] ); ?></td>
    1259 <td><?php echo esc_html( $log['tier_name'] ); ?></td>
    1260 <td><?php echo esc_html( $log['amount'] ? esc_html( $log['amount'] . ' ' . $log['currency'] ) : '-' ); ?></td>
    1261 <td><?php echo $log['is_subscription'] ? esc_html__( 'Yes', 'members-for-kofi' ) : esc_html__( 'No', 'members-for-kofi' ); ?></td>
    1262 <td><?php echo esc_html( $log['status_code'] ); ?></td>
    1263 <td><?php echo $log['success'] ? '✓' : '✗'; ?></td>
    1264 <td><?php echo esc_html( $log['error'] ); ?></td>
    1265 </tr>
    1266 <?php endforeach; ?>
    1267 <?php endif; ?>
    1268 </tbody>
    1269 </table>
    1270 
    1271 <div style="display: flex; align-items: center; justify-content: space-between; margin-top: 15px;">
    1272 <input type="hidden" id="kofi-members-current-search" value="<?php echo esc_attr( $search ); ?>" />
    1273 <!-- Clear Logs Button -->
    1274 <form method="post" action="<?php echo esc_url( add_query_arg( 'tab', 'logs', admin_url( 'admin.php?page=members-for-kofi' ) ) ); ?>" style="margin-right: 15px;">
    1275                 <?php wp_nonce_field( 'clear_request_logs_action', 'clear_request_logs_nonce' ); ?>
    1276 <button type="button" name="clear_logs" class="button button-secondary">
    1277                 <?php esc_html_e( 'Clear Logs', 'members-for-kofi' ); ?>
    1278 </button>
    1279 </form>
    1280 
    1281 <!-- Rows Per Page Dropdown -->
    1282 <form method="get" style="margin-right: 15px;">
    1283 <input type="hidden" name="page" value="members-for-kofi">
    1284 <input type="hidden" name="tab" value="logs">
    1285 <label for="rows_per_page" style="margin-right: 10px;"><?php esc_html_e( 'Rows per page:', 'members-for-kofi' ); ?></label>
    1286 <select name="rows_per_page" id="rows_per_page" style="width: auto;">
    1287                 <?php foreach ( self::ROWS_PER_PAGE_OPTIONS as $option ) : ?>
    1288 <option value="<?php echo esc_attr( $option ); ?>" <?php selected( $rows_per_page, $option ); ?>>
    1289                         <?php echo esc_html( $option ); ?>
    1290 </option>
    1291 <?php endforeach; ?>
    1292 </select>
    1293 </form>
    1294 <div class="pagination-links" style="display:flex; gap:4px; flex-wrap:wrap;">
    1295                 <?php if ( $total_pages > 1 ) : ?>
    1296                         <?php
    1297                         $prev_page = max( 1, $current_page - 1 );
    1298                         $next_page = min( $total_pages, $current_page + 1 );
    1299                         if ( $current_page > 1 ) {
    1300                                 echo '<button type="button" class="button members-for-kofi-page-btn" data-page="' . esc_attr( $prev_page ) . '">&laquo; ' . esc_html__( 'Previous', 'members-for-kofi' ) . '</button>';
    1301                         }
    1302                         for ( $p = 1; $p <= $total_pages; $p++ ) {
    1303                                 $cls = 'button members-for-kofi-page-btn' . ( $p === $current_page ? ' button-primary' : '' );
    1304                                 echo '<button type="button" class="' . esc_attr( $cls ) . '" data-page="' . esc_attr( $p ) . '">' . esc_html( (string) $p ) . '</button>';
    1305                         }
    1306                         if ( $current_page < $total_pages ) {
    1307                                 echo '<button type="button" class="button members-for-kofi-page-btn" data-page="' . esc_attr( $next_page ) . '">' . esc_html__( 'Next', 'members-for-kofi' ) . ' &raquo;</button>';
    1308                         }
    1309                         ?>
    1310 <?php endif; ?>
    1311 </div>
    1312 </div>
    1313                 <?php
    1314784        }
    1315785}
  • members-for-kofi/trunk/src/Logging/RequestLogger.php

    r3728327 r3796384  
    9696                );
    9797
    98                 // Invalidate cached total logs count so UI refresh reflects new entries.
    99                 delete_transient( 'members_for_kofi_total_request_logs' );
    10098        }
    10199
  • members-for-kofi/trunk/src/Logging/UserLogger.php

    r3703946 r3796384  
    5959                );
    6060
    61                 // Invalidate cached total logs count so UI refresh reflects new entries.
    62                 delete_transient( 'members_for_kofi_total_logs' );
    6361        }
    6462
  • members-for-kofi/trunk/src/Privacy/PersonalData.php

    r3777494 r3796384  
    212212                }
    213213
    214                 delete_transient( 'members_for_kofi_total_logs' );
    215                 delete_transient( 'members_for_kofi_total_request_logs' );
    216 
    217214                // The record of a membership that ended is history, and goes. A
    218215                // membership still being tracked is kept: deleting it would end access
  • members-for-kofi/trunk/vendor/composer/autoload_classmap.php

    r3787989 r3796384  
    99    'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
    1010    'MembersForKofi\\Admin\\AdminSettings' => $baseDir . '/src/Admin/AdminSettings.php',
     11    'MembersForKofi\\Admin\\LogsTab' => $baseDir . '/src/Admin/LogsTab.php',
     12    'MembersForKofi\\Admin\\MembersTab' => $baseDir . '/src/Admin/MembersTab.php',
    1113    'MembersForKofi\\Cli\\MemberSinceCommand' => $baseDir . '/src/Cli/MemberSinceCommand.php',
    1214    'MembersForKofi\\Cron\\LogCleanup' => $baseDir . '/src/Cron/LogCleanup.php',
  • members-for-kofi/trunk/vendor/composer/autoload_static.php

    r3787989 r3796384  
    2424        'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
    2525        'MembersForKofi\\Admin\\AdminSettings' => __DIR__ . '/../..' . '/src/Admin/AdminSettings.php',
     26        'MembersForKofi\\Admin\\LogsTab' => __DIR__ . '/../..' . '/src/Admin/LogsTab.php',
     27        'MembersForKofi\\Admin\\MembersTab' => __DIR__ . '/../..' . '/src/Admin/MembersTab.php',
    2628        'MembersForKofi\\Cli\\MemberSinceCommand' => __DIR__ . '/../..' . '/src/Cli/MemberSinceCommand.php',
    2729        'MembersForKofi\\Cron\\LogCleanup' => __DIR__ . '/../..' . '/src/Cron/LogCleanup.php',
Note: See TracChangeset for help on using the changeset viewer.