Code
StocketBase
Self-hosted digital commerce, without commission fees or SaaS lock-in.
Version history
Version 1.0.0.1 2026-09-26
Browse filesFiles changed65
Lines added+1039
Lines removed-593
activity.php
⋮ 20 unchanged lines ⋮ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'clear_activity_log') { if (!Csrf::verify($_POST['csrf_token'] ?? null)) { - $flashMessage = 'Security check failed, please try again.'; + $flashMessage = 'Your session expired. Please try again.'; $flashType = 'error'; } else { $days = max(0, (int) ($_POST['older_than_days'] ?? 0)); ⋮ 4 unchanged lines ⋮ } else { $db->exec('DELETE FROM activity_log'); } - $flashMessage = 'Activity log cleared.'; + $flashMessage = 'Done, those entries are gone.'; } } ⋮ 23 unchanged lines ⋮ <h1 class="dash-title"><?= Icons::icon('stats', 'icon icon-lg') ?>Activity log</h1> <?php if (!$logEnabled): ?> - <div class="dash-flash dash-flash-error"><?= Icons::icon('x', 'icon icon-sm') ?>Activity log is turned off, so new actions are not being recorded. Enable it in Settings → General.</div> + <div class="dash-flash dash-flash-error"><?= Icons::icon('x', 'icon icon-sm') ?>The activity log is off, so nothing new is being recorded. You can turn it on in Settings, General.</div> <?php endif; ?> <table class="dash-table"> - <thead><tr><th>When</th><th>User</th><th>Action</th><th>Item</th><th>Details</th><th>IP</th></tr></thead> + <thead><tr><th>When</th><th>Who</th><th>What they did</th><th>On</th><th>Details</th><th>IP address</th></tr></thead> <tbody> <?php foreach ($entries as $entry): ?> <tr> ⋮ 6 unchanged lines ⋮ </tr> <?php endforeach; ?> <?php if (empty($entries)): ?> - <tr><td colspan="6">No activity recorded yet.</td></tr> + <tr><td colspan="6">Nothing recorded yet.</td></tr> <?php endif; ?> </tbody> </table> ⋮ 6 unchanged lines ⋮ </p> <?php endif; ?> - <form method="post" style="margin-top:24px;" onsubmit="return confirm('Delete these log entries?');"> + <form method="post" style="margin-top:24px;" onsubmit="return confirm('Delete these entries? You can\'t get them back.');"> <?= Csrf::field() ?> <input type="hidden" name="action" value="clear_activity_log"> - <label>Clear entries</label> + <label>Clean up the log</label> <select name="older_than_days"> <option value="30">Older than 30 days</option> <option value="90">Older than 90 days</option> <option value="365">Older than 1 year</option> - <option value="0">All entries</option> + <option value="0">Everything</option> </select> <button type="submit" class="dash-btn dash-btn-danger" style="margin-top:12px;"><?= Icons::icon('trash', 'icon icon-sm') ?>Clear</button> </form> ⋮ 3 unchanged lines ⋮
admin.php
⋮ 40 unchanged lines ⋮ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!Csrf::verify($_POST['csrf_token'] ?? null)) { - $flashMessage = 'Security check failed, please try again.'; + $flashMessage = 'Your session expired. Please try again.'; $flashType = 'error'; } else { $action = $_POST['action'] ?? ''; if ($action === 'suspend_customer') { if (!$isSuperAdmin) { - $flashMessage = 'Only the Super Admin can suspend customer accounts.'; + $flashMessage = 'Only the Super Admin can block customers.'; $flashType = 'error'; } else { $customerId = (int) ($_POST['customer_id'] ?? 0); $update = $usersDb->prepare("UPDATE user_accounts SET status = 'suspended' WHERE id = :id"); $update->execute(['id' => $customerId]); - $flashMessage = 'Customer account suspended.'; + $flashMessage = 'Customer blocked. They can no longer log in.'; } $activeTab = 'customers'; } if ($action === 'reactivate_customer') { if (!$isSuperAdmin) { - $flashMessage = 'Only the Super Admin can reactivate customer accounts.'; + $flashMessage = 'Only the Super Admin can unblock customers.'; $flashType = 'error'; } else { $customerId = (int) ($_POST['customer_id'] ?? 0); $update = $usersDb->prepare("UPDATE user_accounts SET status = 'active' WHERE id = :id"); $update->execute(['id' => $customerId]); - $flashMessage = 'Customer account reactivated.'; + $flashMessage = 'Customer unblocked.'; } $activeTab = 'customers'; } ⋮ 6 unchanged lines ⋮ $role = (string) ($_POST['role'] ?? ''); if ($username === '' || $email === '' || $password === '' || !in_array($role, $assignableRoles, true)) { - $flashMessage = 'All fields are required and role must be valid for your permission level.'; + $flashMessage = 'Please fill in every field and pick a role you are allowed to give.'; $flashType = 'error'; } elseif ($role === Auth::ROLE_SUPER_ADMIN) { - $flashMessage = 'Only one Super Admin account may exist; it cannot be created here.'; + $flashMessage = 'There can only be one Super Admin.'; $flashType = 'error'; } elseif (strlen($password) < 10) { - $flashMessage = 'Password must be at least 10 characters.'; + $flashMessage = 'The password needs at least 10 characters.'; $flashType = 'error'; } else { $newAvatar = null; ⋮ 17 unchanged lines ⋮ 'status' => 'active', 'avatar' => $newAvatar, ]); - $flashMessage = 'Account created.'; + $flashMessage = 'Account created. They can log in now.'; } catch (InvalidArgumentException $exception) { $flashMessage = $exception->getMessage(); $flashType = 'error'; } catch (Throwable $exception) { Avatar::delete($newAvatar); - $flashMessage = 'Could not create account (username or email may already exist).'; + $flashMessage = 'That username or email is already taken.'; $flashType = 'error'; } } ⋮ 20 unchanged lines ⋮ $flashMessage = 'Account not found.'; $flashType = 'error'; } elseif (!$isSuperAdmin && !$isSelfEdit && !in_array($targetAccount['role'], $assignableRoles, true)) { - $flashMessage = 'You can only manage accounts with a lower role than yours.'; + $flashMessage = 'You can only change people with a lower role than yours.'; $flashType = 'error'; } elseif ($targetAccount['role'] === Auth::ROLE_SUPER_ADMIN && (int) $targetAccount['id'] !== (int) $currentUser['id']) { - $flashMessage = 'The Super Admin account can only be edited by itself.'; + $flashMessage = 'Only the Super Admin can change their own account.'; $flashType = 'error'; } elseif ($role === Auth::ROLE_SUPER_ADMIN && $targetAccount['role'] !== Auth::ROLE_SUPER_ADMIN) { - $flashMessage = 'Super Admin role cannot be assigned to another account.'; + $flashMessage = 'You can\'t make someone else Super Admin.'; $flashType = 'error'; } elseif (!$isSelfEdit && !in_array($role, array_merge($assignableRoles, [Auth::ROLE_SUPER_ADMIN]), true)) { - $flashMessage = 'Invalid role for your permission level.'; + $flashMessage = 'You can\'t give that role.'; $flashType = 'error'; } else { $fields = ['display_name = :display_name', 'role = :role', 'status = :status']; ⋮ 9 unchanged lines ⋮ if ($newPassword !== '') { if (strlen($newPassword) < 10) { - $saveError = 'New password must be at least 10 characters.'; + $saveError = 'The new password needs at least 10 characters.'; } else { $fields[] = 'password_hash = :hash'; $params['hash'] = password_hash($newPassword, PASSWORD_DEFAULT); ⋮ 36 unchanged lines ⋮ Avatar::delete((string) ($targetAccount['avatar_path'] ?? '')); } - $flashMessage = 'Account updated.'; + $flashMessage = 'Changes saved.'; } } } ⋮ 9 unchanged lines ⋮ $flashMessage = 'Account not found.'; $flashType = 'error'; } elseif ($targetAccount['role'] === Auth::ROLE_SUPER_ADMIN) { - $flashMessage = 'The Super Admin account cannot be deleted.'; + $flashMessage = 'The Super Admin can\'t be deleted.'; $flashType = 'error'; } elseif ((int) $targetAccount['id'] === (int) $currentUser['id']) { - $flashMessage = 'You cannot delete your own account.'; + $flashMessage = 'You can\'t delete your own account.'; $flashType = 'error'; } elseif (!$isSuperAdmin && !in_array($targetAccount['role'], $assignableRoles, true)) { - $flashMessage = 'You can only manage accounts with a lower role than yours.'; + $flashMessage = 'You can only change people with a lower role than yours.'; $flashType = 'error'; } else { - $delete = $db->prepare('DELETE FROM team_accounts WHERE id = :id'); - $delete->execute(['id' => $targetId]); - Avatar::delete((string) ($targetAccount['avatar_path'] ?? '')); - $flashMessage = 'Account deleted.'; + $transferTo = (int) ($_POST['transfer_to'] ?? 0); + $receiver = null; + if ($transferTo > 0 && $transferTo !== $targetId) { + $receiverStatement = $db->prepare('SELECT id, display_name FROM team_accounts WHERE id = :id LIMIT 1'); + $receiverStatement->execute(['id' => $transferTo]); + $receiver = $receiverStatement->fetch() ?: null; + } + + if ($receiver === null) { + $flashMessage = 'Pick who should take over this person\'s products, pages, ads and discount codes.'; + $flashType = 'error'; + } else { + try { + $db->beginTransaction(); + $transferSql = [ + 'UPDATE products SET created_by = :to WHERE created_by = :from', + 'UPDATE pages SET author_id = :to WHERE author_id = :from AND is_home = 0', + 'UPDATE ads SET created_by = :to WHERE created_by = :from', + 'UPDATE discounts SET created_by = :to WHERE created_by = :from', + ]; + foreach ($transferSql as $sql) { + $db->prepare($sql)->execute(['to' => (int) $receiver['id'], 'from' => $targetId]); + } + $db->prepare('UPDATE pages SET author_id = NULL WHERE author_id = :from AND is_home = 1')->execute(['from' => $targetId]); + $delete = $db->prepare('DELETE FROM team_accounts WHERE id = :id'); + $delete->execute(['id' => $targetId]); + $db->commit(); + Avatar::delete((string) ($targetAccount['avatar_path'] ?? '')); + $flashMessage = 'Account deleted. Everything they made now belongs to ' . $receiver['display_name'] . '.'; + } catch (Throwable $exception) { + if ($db->inTransaction()) { + $db->rollBack(); + } + error_log('StocketBase: account delete failed: ' . $exception->getMessage()); + $flashMessage = 'Something went wrong, so nothing was deleted. Please try again.'; + $flashType = 'error'; + } + } } } } ⋮ 1 unchanged line ⋮ $accounts = $db->query('SELECT * FROM team_accounts ORDER BY FIELD(role, \'super_admin\',\'store_owner\',\'store_manager\',\'catalog_editor\',\'product_editor\',\'catalog_assistant\'), display_name ASC')->fetchAll(); + $ownedCounts = []; + foreach ([ + 'products' => 'SELECT created_by AS owner, COUNT(*) AS c FROM products WHERE created_by IS NOT NULL GROUP BY created_by', + 'pages' => 'SELECT author_id AS owner, COUNT(*) AS c FROM pages WHERE author_id IS NOT NULL AND is_home = 0 GROUP BY author_id', + 'ads' => 'SELECT created_by AS owner, COUNT(*) AS c FROM ads WHERE created_by IS NOT NULL GROUP BY created_by', + 'discounts' => 'SELECT created_by AS owner, COUNT(*) AS c FROM discounts WHERE created_by IS NOT NULL GROUP BY created_by', + ] as $ownedKey => $ownedSql) { + try { + foreach ($db->query($ownedSql)->fetchAll() as $ownedRow) { + $ownedCounts[(int) $ownedRow['owner']][$ownedKey] = (int) $ownedRow['c']; + } + } catch (PDOException $exception) { + } + } + $customers = []; if ($isSuperAdmin) { $customers = $usersDb->query('SELECT * FROM user_accounts ORDER BY created_at DESC')->fetchAll(); } $dashActivePage = 'admin'; - $dashPageTitle = 'Team accounts'; + $dashPageTitle = 'Team'; require __DIR__ . '/includes/dash-header.php'; ⋮ 7 unchanged lines ⋮ <?php if ($isSuperAdmin): ?> <div class="settings-tabs"> - <a href="admin.php?tab=team" class="<?= $activeTab === 'team' ? 'active' : '' ?>"><?= Icons::icon("team", "icon icon-sm") ?>Team accounts</a> - <a href="admin.php?tab=customers" class="<?= $activeTab === 'customers' ? 'active' : '' ?>"><?= Icons::icon("book", "icon icon-sm") ?>Customer accounts</a> + <a href="admin.php?tab=team" class="<?= $activeTab === 'team' ? 'active' : '' ?>"><?= Icons::icon("team", "icon icon-sm") ?>Your team</a> + <a href="admin.php?tab=customers" class="<?= $activeTab === 'customers' ? 'active' : '' ?>"><?= Icons::icon("book", "icon icon-sm") ?>Customers</a> </div> <?php endif; ?> ⋮ 6 unchanged lines ⋮ <th><?= Icons::icon('user', 'icon icon-sm') ?>Email</th> <th><?= Icons::icon('flag', 'icon icon-sm') ?>Status</th> <th><?= Icons::icon('stats', 'icon icon-sm') ?>Registered</th> - <th><?= Icons::icon('eye', 'icon icon-sm') ?>Last login</th> + <th><?= Icons::icon('eye', 'icon icon-sm') ?>Last seen</th> <th></th> </tr> </thead> <tbody> <?php foreach ($customers as $customer): ?> <tr> - <td><?= htmlspecialchars((string) ($customer['display_name'] ?? '—')) ?></td> + <td><?= htmlspecialchars((string) ($customer['display_name'] ?? 'No name')) ?></td> <td><?= htmlspecialchars($customer['email']) ?></td> - <td><span class="status-pill status-<?= $customer['status'] === 'active' ? 'published' : 'rejected' ?>"><?= htmlspecialchars($customer['status']) ?></span></td> + <td><span class="status-pill status-<?= $customer['status'] === 'active' ? 'published' : 'rejected' ?>"><?= htmlspecialchars(StatusLabel::get((string) $customer['status'])) ?></span></td> <td><?= htmlspecialchars($customer['created_at']) ?></td> - <td><?= htmlspecialchars((string) ($customer['last_login_at'] ?? '—')) ?></td> + <td><?= htmlspecialchars((string) ($customer['last_login_at'] ?? 'Never')) ?></td> <td class="dash-table-actions"> <?php if ($customer['status'] === 'active'): ?> - <form method="post" style="display:inline;" onsubmit="return confirm('Suspend this customer account? They will be logged out and unable to sign in.');"> + <form method="post" style="display:inline;" onsubmit="return confirm('Block this customer? They will be logged out and won\'t be able to log in.');"> <?= Csrf::field() ?> <input type="hidden" name="action" value="suspend_customer"> <input type="hidden" name="customer_id" value="<?= (int) $customer['id'] ?>"> - <button type="submit" class="dash-btn-small dash-btn-danger"><?= Icons::icon('lock', 'icon icon-sm') ?>Suspend</button> + <button type="submit" class="dash-btn-small dash-btn-danger"><?= Icons::icon('lock', 'icon icon-sm') ?>Block</button> </form> <?php else: ?> <form method="post" style="display:inline;"> <?= Csrf::field() ?> <input type="hidden" name="action" value="reactivate_customer"> <input type="hidden" name="customer_id" value="<?= (int) $customer['id'] ?>"> - <button type="submit" class="dash-btn-small dash-btn-success"><?= Icons::icon('check', 'icon icon-sm') ?>Reactivate</button> + <button type="submit" class="dash-btn-small dash-btn-success"><?= Icons::icon('check', 'icon icon-sm') ?>Unblock</button> </form> <?php endif; ?> </td> ⋮ 1 unchanged line ⋮ <?php endforeach; ?> <?php if (empty($customers)): ?> <tr> - <td colspan="6">No customer accounts yet.</td> + <td colspan="6">No customers yet.</td> </tr> <?php endif; ?> </tbody> ⋮ 10 unchanged lines ⋮ <th><?= Icons::icon('team', 'icon icon-sm') ?>Role</th> <th><?= Icons::icon('flag', 'icon icon-sm') ?>Status</th> <th><?= Icons::icon('lock', 'icon icon-sm') ?>2FA</th> - <th><?= Icons::icon('eye', 'icon icon-sm') ?>Last login</th> + <th><?= Icons::icon('eye', 'icon icon-sm') ?>Last seen</th> <th></th> </tr> </thead> ⋮ 4 unchanged lines ⋮ <td><?= htmlspecialchars($account['username']) ?></td> <td><?= htmlspecialchars((string) $account['email']) ?></td> <td><?= htmlspecialchars(Auth::roleLabel($account['role'])) ?></td> - <td><?= htmlspecialchars($account['status']) ?></td> - <td><?= TwoFactor::isEnabled($account) ? 'On' : '—' ?></td> - <td><?= htmlspecialchars((string) ($account['last_login_at'] ?? '—')) ?></td> + <td><?= htmlspecialchars(StatusLabel::get((string) $account['status'])) ?></td> + <td><?= TwoFactor::isEnabled($account) ? 'On' : 'Off' ?></td> + <td><?= htmlspecialchars((string) ($account['last_login_at'] ?? 'Never')) ?></td> <td class="dash-table-actions"> <?php if ($account['role'] !== Auth::ROLE_SUPER_ADMIN || (int) $account['id'] === (int) $currentUser['id']): ?> <button type="button" class="dash-btn-small js-edit-account" ⋮ 5 unchanged lines ⋮ data-status="<?= htmlspecialchars($account['status'], ENT_QUOTES) ?>"><?= Icons::icon('edit', 'icon icon-sm') ?>Edit</button> <?php endif; ?> <?php if ($account['role'] !== Auth::ROLE_SUPER_ADMIN && (int) $account['id'] !== (int) $currentUser['id']): ?> - <form method="post" style="display:inline;" onsubmit="return confirm('Delete this account?');"> - <?= Csrf::field() ?> - <input type="hidden" name="action" value="delete_account"> - <input type="hidden" name="account_id" value="<?= (int) $account['id'] ?>"> - <button type="submit" class="dash-btn-small dash-btn-danger"><?= Icons::icon('trash', 'icon icon-sm') ?>Delete</button> - </form> + <?php $owned = $ownedCounts[(int) $account['id']] ?? []; ?> + <button type="button" class="dash-btn-small dash-btn-danger js-delete-account" + data-id="<?= (int) $account['id'] ?>" + data-name="<?= htmlspecialchars($account['display_name'], ENT_QUOTES) ?>" + data-products="<?= (int) ($owned['products'] ?? 0) ?>" + data-pages="<?= (int) ($owned['pages'] ?? 0) ?>" + data-ads="<?= (int) ($owned['ads'] ?? 0) ?>" + data-discounts="<?= (int) ($owned['discounts'] ?? 0) ?>"><?= Icons::icon('trash', 'icon icon-sm') ?>Delete</button> <?php endif; ?> </td> </tr> ⋮ 1 unchanged line ⋮ </tbody> </table> - <h2 class="dash-subtitle"><?= Icons::icon('plus', 'icon icon-sm') ?>Create account</h2> + <div class="sidebar-box" id="delete-account-box" style="display:none;margin-top:20px;"> + <h2 class="dash-subtitle" style="margin-top:0;"><?= Icons::icon('trash', 'icon icon-sm') ?>Delete account: <span id="delete_account_name"></span></h2> + <p id="delete_account_summary" style="color:var(--muted);margin:0 0 14px;"></p> + <form method="post" id="delete-account-form"> + <?= Csrf::field() ?> + <input type="hidden" name="action" value="delete_account"> + <input type="hidden" name="account_id" id="delete_account_id" value=""> + + <label><?= Icons::icon('users', 'icon icon-sm') ?>Who takes over their products, pages, ads and discount codes?</label> + <select name="transfer_to" id="delete_transfer_to" required> + <?php foreach ($accounts as $receiverAccount): ?> + <option value="<?= (int) $receiverAccount['id'] ?>" <?= (int) $receiverAccount['id'] === (int) $currentUser['id'] ? 'selected' : '' ?>><?= htmlspecialchars($receiverAccount['display_name']) ?> (<?= htmlspecialchars(Auth::roleLabel($receiverAccount['role'])) ?>)</option> + <?php endforeach; ?> + </select> + + <div class="publish-actions"> + <button type="submit" class="dash-btn dash-btn-danger"><?= Icons::icon('trash', 'icon icon-sm') ?>Hand over and delete</button> + <button type="button" class="dash-btn" onclick="document.getElementById('delete-account-box').style.display='none';"><?= Icons::icon('x', 'icon icon-sm') ?>Cancel</button> + </div> + </form> + </div> + + <h2 class="dash-subtitle"><?= Icons::icon('plus', 'icon icon-sm') ?>Add a team member</h2> <form method="post" enctype="multipart/form-data"> <?= Csrf::field() ?> <input type="hidden" name="action" value="create_account"> ⋮ 4 unchanged lines ⋮ <label><?= Icons::icon('hash', 'icon icon-sm') ?>Email</label> <input type="text" name="email" required> - <label><?= Icons::icon('heading', 'icon icon-sm') ?>Display name</label> + <label><?= Icons::icon('heading', 'icon icon-sm') ?>Name shown in the dashboard</label> <input type="text" name="display_name"> <label><?= Icons::icon('lock', 'icon icon-sm') ?>Password</label> <input type="password" name="password" minlength="10" required> - <label><?= Icons::icon('user', 'icon icon-sm') ?>Profile photo (optional, JPG / PNG / WebP, max 2MB)</label> + <label><?= Icons::icon('user', 'icon icon-sm') ?>Photo (optional, JPG, PNG or WebP up to 2 MB)</label> <input type="file" name="avatar" accept="image/jpeg,image/png,image/webp"> <label><?= Icons::icon('team', 'icon icon-sm') ?>Role</label> ⋮ 6 unchanged lines ⋮ <?php endforeach; ?> </select> - <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:16px;"><?= Icons::icon('plus', 'icon icon-sm') ?>Create account</button> + <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:16px;"><?= Icons::icon('plus', 'icon icon-sm') ?>Add to team</button> </form> - <h2 class="dash-subtitle" id="edit-account-title" style="display:none;"><?= Icons::icon('edit', 'icon icon-sm') ?>Edit account</h2> + <h2 class="dash-subtitle" id="edit-account-title" style="display:none;"><?= Icons::icon('edit', 'icon icon-sm') ?>Edit team member</h2> <form method="post" id="edit-account-form" style="display:none;" enctype="multipart/form-data"> <?= Csrf::field() ?> <input type="hidden" name="action" value="update_account"> <input type="hidden" name="account_id" id="edit_account_id"> - <label><?= Icons::icon('user', 'icon icon-sm') ?>Profile photo (JPG / PNG / WebP, max 2MB)</label> + <label><?= Icons::icon('user', 'icon icon-sm') ?>Photo (JPG, PNG or WebP up to 2 MB)</label> <div class="avatar-edit-preview"> <div class="dash-user-avatar" id="edit_avatar_preview"></div> <input type="file" name="avatar" accept="image/jpeg,image/png,image/webp"> </div> - <label><input type="checkbox" name="remove_avatar" id="edit_remove_avatar"> Remove current photo (the initial letter is shown instead)</label> + <label><input type="checkbox" name="remove_avatar" id="edit_remove_avatar"> Remove the photo (we'll show their first letter instead)</label> - <label><?= Icons::icon('heading', 'icon icon-sm') ?>Display name</label> + <label><?= Icons::icon('heading', 'icon icon-sm') ?>Name shown in the dashboard</label> <input type="text" name="display_name" id="edit_display_name"> <label><?= Icons::icon('team', 'icon icon-sm') ?>Role</label> ⋮ 7 unchanged lines ⋮ <label><?= Icons::icon('flag', 'icon icon-sm') ?>Status</label> <select name="status" id="edit_status"> <option value="active">Active</option> - <option value="suspended">Suspended</option> + <option value="suspended">Blocked</option> </select> - <label><?= Icons::icon('lock', 'icon icon-sm') ?>New password (leave blank to keep current)</label> + <label><?= Icons::icon('lock', 'icon icon-sm') ?>New password (leave empty to keep the old one)</label> <input type="password" name="new_password" minlength="10"> - <label><input type="checkbox" name="reset_2fa" id="edit_reset_2fa"> Reset two-factor authentication (use when the user lost their phone and recovery codes)</label> + <label><input type="checkbox" name="reset_2fa" id="edit_reset_2fa"> Turn off their two-step login (if they lost their phone and backup codes)</label> <div class="publish-actions"> <button type="submit" class="dash-btn dash-btn-primary"><?= Icons::icon('check', 'icon icon-sm') ?>Save changes</button> ⋮ 2 unchanged lines ⋮ </form> <script> + document.querySelectorAll('.js-delete-account').forEach(function(button) { + button.addEventListener('click', function() { + var box = document.getElementById('delete-account-box'); + var select = document.getElementById('delete_transfer_to'); + var targetId = this.dataset.id; + document.getElementById('delete_account_id').value = targetId; + document.getElementById('delete_account_name').textContent = this.dataset.name; + var parts = []; + [['products', 'product'], ['pages', 'page'], ['ads', 'ad'], ['discounts', 'discount code']].forEach(function(entry) { + var count = parseInt(button.dataset[entry[0]] || '0', 10); + if (count > 0) { + parts.push(count + ' ' + entry[1] + (count === 1 ? '' : 's')); + } + }); + document.getElementById('delete_account_summary').textContent = parts.length + ? 'This person made ' + parts.join(', ') + '. Nothing gets deleted, it all moves to the person you pick below. Orders and customers stay as they are.' + : 'This person hasn\'t made any products, pages, ads or discount codes. Orders and customers stay as they are.'; + var firstAllowed = null; + Array.prototype.forEach.call(select.options, function(option) { + option.disabled = option.value === targetId; + option.hidden = option.value === targetId; + if (!option.disabled && firstAllowed === null) { + firstAllowed = option; + } + }); + if (select.selectedOptions.length === 0 || select.selectedOptions[0].disabled) { + select.value = firstAllowed ? firstAllowed.value : ''; + } + box.style.display = ''; + box.scrollIntoView({ behavior: 'smooth' }); + }); + }); + + document.getElementById('delete-account-form').addEventListener('submit', function(event) { + var select = document.getElementById('delete_transfer_to'); + var receiver = select.selectedOptions.length ? select.selectedOptions[0].textContent : ''; + if (!confirm('Delete ' + document.getElementById('delete_account_name').textContent + ' and give everything they made to ' + receiver + '?')) { + event.preventDefault(); + } + }); + document.querySelectorAll('.js-edit-account').forEach(function(button) { button.addEventListener('click', function() { document.getElementById('edit-account-title').style.display = ''; ⋮ 29 unchanged lines ⋮
ads.php
⋮ 20 unchanged lines ⋮ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!Csrf::verify($_POST['csrf_token'] ?? null)) { - $flashMessage = 'Security check failed, please try again.'; + $flashMessage = 'Your session expired. Please try again.'; $flashType = 'error'; } else { require __DIR__ . '/dashboard-actions/manage-ads.php'; ActivityLog::record('ads.' . (string) ($_POST['action'] ?? '')); - $flashMessage = 'Saved.'; + $flashMessage = 'Changes saved.'; } } ⋮ 49 unchanged lines ⋮ data-content="<?= htmlspecialchars($ad['content'], ENT_QUOTES) ?>" data-link-url="<?= htmlspecialchars((string) $ad['link_url'], ENT_QUOTES) ?>" data-is-active="<?= (int) $ad['is_active'] ?>"><?= Icons::icon('edit', 'icon icon-sm') ?>Edit</button> - <form method="post" style="display:inline;" onsubmit="return confirm('Delete this ad?');"> + <form method="post" style="display:inline;" onsubmit="return confirm('Delete this ad? It will disappear from your store.');"> <?= Csrf::field() ?> <input type="hidden" name="action" value="delete_ad"> <input type="hidden" name="ad_id" value="<?= (int) $ad['id'] ?>"> ⋮ 4 unchanged lines ⋮ <?php endforeach; ?> <?php if (empty($ads)): ?> <tr> - <td colspan="5">No ads yet.</td> + <td colspan="5">No ads yet. Use them to promote a sale or show a banner.</td> </tr> <?php endif; ?> </tbody> ⋮ 11 unchanged lines ⋮ <label><?= Icons::icon('ads', 'icon icon-sm') ?>Type</label> <select name="type" id="ad_type_select"> - <option value="static_text" <?= ($editingAd['type'] ?? '') === 'static_text' ? 'selected' : '' ?>>Static — text</option> - <option value="static_image" <?= ($editingAd['type'] ?? '') === 'static_image' ? 'selected' : '' ?>>Static — image</option> - <option value="script" <?= ($editingAd['type'] ?? '') === 'script' ? 'selected' : '' ?>>Script (HTML / JS)</option> + <option value="static_text" <?= ($editingAd['type'] ?? '') === 'static_text' ? 'selected' : '' ?>>Text</option> + <option value="static_image" <?= ($editingAd['type'] ?? '') === 'static_image' ? 'selected' : '' ?>>Image</option> + <option value="script" <?= ($editingAd['type'] ?? '') === 'script' ? 'selected' : '' ?>>Code from an ad network (HTML or JS)</option> </select> - <label><?= Icons::icon('flag', 'icon icon-sm') ?>Placement — where on the site this ad appears</label> + <label><?= Icons::icon('flag', 'icon icon-sm') ?>Where it shows up</label> <select name="placement" id="ad_placement_input" required> <?php foreach (SiteFront::AD_PLACEMENTS as $placementKey => $placementLabel): ?> <option value="<?= htmlspecialchars($placementKey, ENT_QUOTES) ?>" <?= ($editingAd['placement'] ?? 'header') === $placementKey ? 'selected' : '' ?>><?= htmlspecialchars($placementLabel) ?></option> <?php endforeach; ?> </select> - <label id="content_label"><?= Icons::icon('quote', 'icon icon-sm') ?>Content (text, image URL, or raw HTML/JS)</label> + <label id="content_label"><?= Icons::icon('quote', 'icon icon-sm') ?>Content (text, an image, or ad code)</label> <textarea name="content" id="ad_content_input" rows="6" class="be-code"><?= htmlspecialchars($editingAd['content'] ?? '') ?></textarea> - <label><?= Icons::icon('external', 'icon icon-sm') ?>Link URL (optional, ignored for script ads)</label> + <label><?= Icons::icon('external', 'icon icon-sm') ?>Link (optional, not used for ad code)</label> <input type="text" name="link_url" id="ad_link_url_input" value="<?= htmlspecialchars($editingAd['link_url'] ?? '') ?>"> - <label><input type="checkbox" name="is_active" id="ad_is_active_input" <?= ($editingAd['is_active'] ?? 1) ? 'checked' : '' ?>> Active</label> + <label><input type="checkbox" name="is_active" id="ad_is_active_input" <?= ($editingAd['is_active'] ?? 1) ? 'checked' : '' ?>> Show it on the store</label> <div class="publish-actions"> <button type="submit" class="dash-btn dash-btn-primary"><?= Icons::icon('check', 'icon icon-sm') ?>Save ad</button> ⋮ 54 unchanged lines ⋮
ai-chat.php
⋮ 21 unchanged lines ⋮ if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { header('Allow: POST'); - aiRespond(405, ['ok' => false, 'error' => 'Method not allowed.']); + aiRespond(405, ['ok' => false, 'error' => 'Something went wrong. Please refresh the page.']); } Auth::boot(); if (!Auth::check()) { - aiRespond(401, ['ok' => false, 'error' => 'Your session has expired. Reload the page and sign in again.']); + aiRespond(401, ['ok' => false, 'error' => 'You were logged out. Refresh the page and log in again.']); } if (!Csrf::verify($_SERVER['HTTP_X_CSRF_TOKEN'] ?? null)) { - aiRespond(403, ['ok' => false, 'error' => 'Security check failed. Reload the page and try again.']); + aiRespond(403, ['ok' => false, 'error' => 'Your session expired. Refresh the page and try again.']); } if (!Ai::availableFor(Auth::role())) { ⋮ 2 unchanged lines ⋮ $declaredLength = (int) ($_SERVER['CONTENT_LENGTH'] ?? 0); if ($declaredLength > AI_MAX_BODY_BYTES) { - aiRespond(413, ['ok' => false, 'error' => 'The request is too large.']); + aiRespond(413, ['ok' => false, 'error' => 'That\'s too much text at once. Try a shorter message or a smaller part.']); } $rawBody = (string) file_get_contents('php://input', false, null, 0, AI_MAX_BODY_BYTES + 1); if (strlen($rawBody) > AI_MAX_BODY_BYTES) { - aiRespond(413, ['ok' => false, 'error' => 'The request is too large.']); + aiRespond(413, ['ok' => false, 'error' => 'That\'s too much text at once. Try a shorter message or a smaller part.']); } $input = json_decode($rawBody, true); if (!is_array($input)) { - aiRespond(400, ['ok' => false, 'error' => 'Invalid request.']); + aiRespond(400, ['ok' => false, 'error' => 'Something went wrong. Please refresh the page and try again.']); } $message = is_string($input['message'] ?? null) ? trim($input['message']) : ''; if ($message === '' || mb_strlen($message) > 4000) { - aiRespond(400, ['ok' => false, 'error' => 'Write a message of up to 4000 characters.']); + aiRespond(400, ['ok' => false, 'error' => 'Please keep your message under 4000 characters.']); } $rawBlocks = is_array($input['blocks'] ?? null) ? array_slice($input['blocks'], 0, 300) : []; $blocks = Ai::normalizeBlocks($rawBlocks); $fields = Ai::normalizeFields(is_array($input['fields'] ?? null) ? $input['fields'] : []); if ($blocks === [] && $fields === []) { - aiRespond(400, ['ok' => false, 'error' => 'The article has no blocks to work with.']); + aiRespond(400, ['ok' => false, 'error' => 'There\'s nothing written yet for me to work with.']); } $scope = ($input['scope'] ?? 'article') === 'block' ? 'block' : 'article'; ⋮ 5 unchanged lines ⋮ try { if (Ai::rateLimited($userId)) { - aiRespond(429, ['ok' => false, 'error' => 'You reached the hourly limit of AI requests. Try again later.']); + aiRespond(429, ['ok' => false, 'error' => 'You\'ve used up this hour\'s AI messages. Try again a bit later.']); } Ai::recordRequest($userId); } catch (Throwable $exception) { ⋮ 17 unchanged lines ⋮
analytics.php
⋮ 19 unchanged lines ⋮ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!Csrf::verify($_POST['csrf_token'] ?? null)) { - $flashMessage = 'Security check failed, please try again.'; + $flashMessage = 'Your session expired. Please try again.'; $flashType = 'error'; } else { require __DIR__ . '/dashboard-actions/manage-analytics.php'; ActivityLog::record('analytics.' . (string) ($_POST['action'] ?? '')); - $flashMessage = 'Saved.'; + $flashMessage = 'Changes saved.'; } } ⋮ 33 unchanged lines ⋮ <form method="post"> <?= Csrf::field() ?> <input type="hidden" name="action" value="save_consent_settings"> - <label><input type="checkbox" name="consent_manager_enabled" <?= $consentEnabled ? 'checked' : '' ?>> Enable consent manager (controls whether consent-gated scripts run before the visitor accepts)</label> - <label><input type="checkbox" name="consent_footer_icon_enabled" <?= $consentFooterIconEnabled ? 'checked' : '' ?>> Show cookie icon in footer (lets visitors reopen the consent manager)</label> + <label><input type="checkbox" name="consent_manager_enabled" <?= $consentEnabled ? 'checked' : '' ?>> Ask visitors for cookie consent (scripts marked below only run after they agree)</label> + <label><input type="checkbox" name="consent_footer_icon_enabled" <?= $consentFooterIconEnabled ? 'checked' : '' ?>> Show a cookie icon in the footer so visitors can change their mind</label> <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:12px;"><?= Icons::icon('check', 'icon icon-sm') ?>Save</button> </form> </div> ⋮ 9 unchanged lines ⋮ <td><?= $script['is_active'] ? 'Yes' : 'No' ?></td> <td class="dash-table-actions"> <a href="analytics.php?edit=<?= (int) $script['id'] ?>" class="dash-btn-small"><?= Icons::icon('edit', 'icon icon-sm') ?>Edit</a> - <form method="post" style="display:inline;" onsubmit="return confirm('Delete this script?');"> + <form method="post" style="display:inline;" onsubmit="return confirm('Delete this script? It will stop running on your store.');"> <?= Csrf::field() ?> <input type="hidden" name="action" value="delete_analytics_script"> <input type="hidden" name="script_id" value="<?= (int) $script['id'] ?>"> ⋮ 3 unchanged lines ⋮ </tr> <?php endforeach; ?> <?php if (empty($scripts)): ?> - <tr><td colspan="5">No analytics scripts yet.</td></tr> + <tr><td colspan="5">No tracking scripts yet. Add one for Google Analytics, a Meta pixel and so on.</td></tr> <?php endif; ?> </tbody> </table> ⋮ 9 unchanged lines ⋮ <label>Name</label> <input type="text" name="name" value="<?= htmlspecialchars($editingScript['name'] ?? '') ?>" required> - <label>Script code (raw HTML / <style> / <script>)</label> + <label>Code (paste it exactly as the service gives it to you)</label> <textarea name="script_code" rows="8" class="be-code"><?= htmlspecialchars($editingScript['script_code'] ?? '') ?></textarea> - <label>Placement</label> + <label>Where to add it</label> <select name="placement"> - <option value="head" <?= ($editingScript['placement'] ?? 'head') === 'head' ? 'selected' : '' ?>><head></option> - <option value="body_start" <?= ($editingScript['placement'] ?? '') === 'body_start' ? 'selected' : '' ?>>Start of <body></option> - <option value="body_end" <?= ($editingScript['placement'] ?? '') === 'body_end' ? 'selected' : '' ?>>End of <body></option> + <option value="head" <?= ($editingScript['placement'] ?? 'head') === 'head' ? 'selected' : '' ?>>Page head (<head>, most common)</option> + <option value="body_start" <?= ($editingScript['placement'] ?? '') === 'body_start' ? 'selected' : '' ?>>Top of the page (<body> start)</option> + <option value="body_end" <?= ($editingScript['placement'] ?? '') === 'body_end' ? 'selected' : '' ?>>Bottom of the page (<body> end)</option> </select> - <label><input type="checkbox" name="requires_consent" <?= ($editingScript['requires_consent'] ?? 1) ? 'checked' : '' ?>> Requires visitor consent before running</label> - <label><input type="checkbox" name="is_active" <?= ($editingScript['is_active'] ?? 1) ? 'checked' : '' ?>> Active</label> + <label><input type="checkbox" name="requires_consent" <?= ($editingScript['requires_consent'] ?? 1) ? 'checked' : '' ?>> Only run after the visitor accepts cookies</label> + <label><input type="checkbox" name="is_active" <?= ($editingScript['is_active'] ?? 1) ? 'checked' : '' ?>> Turned on</label> <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:16px;"><?= Icons::icon('check', 'icon icon-sm') ?>Save script</button> </form> ⋮ 3 unchanged lines ⋮
assets/ai-chat.js
⋮ 81 unchanged lines ⋮ return window.stocketbaseBlockEditor || null; } - var FIELD_LABELS = { title: 'title', excerpt: 'excerpt', seo_title: 'SEO title', seo_description: 'SEO description' }; + var FIELD_LABELS = { title: 'title', excerpt: 'short summary', seo_title: 'Google title', seo_description: 'Google description' }; function fieldElement(name) { var target = editor(); ⋮ 148 unchanged lines ⋮ function undoEntry(entry) { if (changes[changes.length - 1] !== entry) { - addMessage('note', 'Undo the newer AI changes first.'); + addMessage('note', 'Undo the newer changes first.'); return; } ⋮ 8 unchanged lines ⋮ return input !== null && String(input.value || '') !== entry.fieldsAfter[name]; }); - if ((JSON.stringify(target.getBlocks()) !== entry.after || fieldsEdited) && !window.confirm('The article was edited after this AI change. Undoing it will also discard those edits. Continue?')) { + if ((JSON.stringify(target.getBlocks()) !== entry.after || fieldsEdited) && !window.confirm('You made edits after this change. Undoing it will throw those edits away too. Go ahead?')) { return; } ⋮ 70 unchanged lines ⋮ } if (typeof active.replaceBlocks !== 'function') { - status.textContent = 'Reload the page (Ctrl+F5) to load the updated editor.'; + status.textContent = 'Please refresh the page (Ctrl+F5) and try again.'; return; } ⋮ 13 unchanged lines ⋮ var blocksChanged = JSON.stringify(next) !== JSON.stringify(before); if (!blocksChanged && Object.keys(fieldsAfter).length === 0) { - status.textContent = 'Nothing to change.'; + status.textContent = 'Nothing needed changing.'; applyButton.style.display = 'none'; discardButton.style.display = 'none'; return; ⋮ 68 unchanged lines ⋮ var target = editor(); if (!target) { - addMessage('error', 'The editor is not ready yet.'); + addMessage('error', 'The editor is still loading. Give it a second.'); return; } ⋮ 3 unchanged lines ⋮ if (scope === 'block') { if (focusId === '') { - addMessage('error', 'Click into a block first, or switch the scope to "Whole article".'); + addMessage('error', 'Click into a block first, or switch to "Everything".'); return; } blocks = blocks.filter(function (block) { return block.id === focusId; }); ⋮ 11 unchanged lines ⋮ }) .then(function (response) { return response.json().catch(function () { - return { ok: false, error: 'Unexpected response from the server.' }; + return { ok: false, error: 'Something went wrong on the server. Please try again.' }; }); }) .then(function (data) { setBusy(false); if (!data.ok) { - addMessage('error', data.error || 'The request failed.'); + addMessage('error', data.error || 'That didn\'t work. Please try again.'); return; } ⋮ 12 unchanged lines ⋮ }) .catch(function () { setBusy(false); - addMessage('error', 'Could not reach the server.'); + addMessage('error', 'We couldn\'t reach your store. Check your connection.'); }); } ⋮ 8 unchanged lines ⋮ function build() { ui.fab = el('button', 'ai-fab'); ui.fab.type = 'button'; - ui.fab.setAttribute('aria-label', 'Open the AI helper'); + ui.fab.setAttribute('aria-label', 'Open the AI assistant'); ui.fab.appendChild(icon('sparkles')); ui.fab.appendChild(el('span', null, 'AI')); ⋮ 12 unchanged lines ⋮ var headerActions = el('div', 'ai-header-actions'); ui.undo = el('button', 'dash-btn-small'); ui.undo.type = 'button'; - ui.undo.title = 'Undo the last applied AI change'; + ui.undo.title = 'Undo the last change'; ui.undo.appendChild(icon('refresh')); ui.undo.appendChild(document.createTextNode('Undo')); ui.undo.disabled = true; var closeButton = el('button', 'ai-icon-button'); closeButton.type = 'button'; - closeButton.setAttribute('aria-label', 'Close the AI helper'); + closeButton.setAttribute('aria-label', 'Close the AI assistant'); closeButton.appendChild(icon('x')); headerActions.appendChild(ui.undo); headerActions.appendChild(closeButton); ⋮ 23 unchanged lines ⋮ var scopeRow = el('div', 'ai-scope'); scopeRow.appendChild(el('label', null, 'Scope')); ui.scope = el('select'); - [['article', 'Whole article'], ['block', 'Selected block']].forEach(function (option) { + [['article', 'Everything'], ['block', 'Selected block']].forEach(function (option) { var node = el('option', null, option[1]); node.value = option[0]; ui.scope.appendChild(node); ⋮ 4 unchanged lines ⋮ ui.input = el('textarea'); ui.input.rows = 2; ui.input.maxLength = 4000; - ui.input.placeholder = 'Ask the AI…'; + ui.input.placeholder = 'What should I help with?'; ui.send = el('button', 'dash-btn dash-btn-primary ai-send'); ui.send.type = 'button'; ui.send.setAttribute('aria-label', 'Send'); ⋮ 12 unchanged lines ⋮ document.body.appendChild(ui.fab); document.body.appendChild(ui.panel); - addMessage('note', 'Your article text is sent to ' + (config.dataset.provider || 'the AI provider') + '. Changes are only proposals: nothing is applied until you press Apply, and you can undo it afterwards.'); + addMessage('note', 'Your text is sent to ' + (config.dataset.provider || 'the AI service') + '. You see every suggestion first, nothing changes until you press Apply, and you can always undo.'); ui.fab.addEventListener('click', function () { togglePanel(true); }); closeButton.addEventListener('click', function () { togglePanel(false); }); ⋮ 38 unchanged lines ⋮
assets/block-editor.js
Not shown (binary file or too large to diff).
assets/dashboard.css
Not shown (binary file or too large to diff).
assets/site.css
Not shown (binary file or too large to diff).
category.php
⋮ 25 unchanged lines ⋮ if (!$category) { http_response_code(404); - $pageTitle = 'Not found - ' . $settings['site_name']; + $pageTitle = Language::get('page_title_not_found', 'Not found') . ' - ' . $settings['site_name']; require __DIR__ . '/includes/front-header.php'; echo '<p>' . htmlspecialchars(Language::get('category_not_found', 'Category not found.')) . '</p>'; require __DIR__ . '/includes/front-footer.php'; ⋮ 57 unchanged lines ⋮
checkout.php
⋮ 66 unchanged lines ⋮ if (!$singleProduct) { http_response_code(404); - $pageTitle = 'Not found - ' . $settings['site_name']; + $pageTitle = Language::get('page_title_not_found', 'Not found') . ' - ' . $settings['site_name']; require __DIR__ . '/includes/front-header.php'; echo '<p>' . htmlspecialchars(Language::get('product_not_found', 'Product not found.')) . '</p>'; require __DIR__ . '/includes/front-footer.php'; ⋮ 254 unchanged lines ⋮ $stripe = new StripeClient(); $couponId = null; if ($discountCents > 0) { - $couponId = $stripe->createOneTimeCoupon($discountCents, $cartCurrency, 'Discount ' . (string) $appliedCode); + $couponId = $stripe->createOneTimeCoupon($discountCents, $cartCurrency, Language::get('checkout_discount_name', 'Discount') . ' ' . (string) $appliedCode); } if ($discountCents > 0 && $couponId === null) { ⋮ 151 unchanged lines ⋮
dashboard-actions/delete-page.php
⋮ 18 unchanged lines ⋮ return; } - $statement = $db->prepare('DELETE FROM pages WHERE id = :id'); + $statement = $db->prepare('DELETE FROM pages WHERE id = :id AND is_home = 0'); $statement->execute(['id' => $pageId]);
dashboard-actions/product-files.php
⋮ 15 unchanged lines ⋮ $fileProduct = $productStatement->fetch(); if (!$fileProduct) { - return ['Product not found.', 'error', null]; + return ['We couldn\'t find that product.', 'error', null]; } $canManageFiles = Auth::role() !== Auth::ROLE_CATALOG_ASSISTANT && ((int) $fileProduct['created_by'] === (int) $currentUser['id'] || Auth::hasRoleAtLeast(Auth::ROLE_STORE_OWNER)); if (!$canManageFiles) { - return ['You do not have permission to manage files for this product.', 'error', null]; + return ['You don\'t have access to this product\'s files.', 'error', null]; } if ($action === 'upload_product_file') { ⋮ 14 unchanged lines ⋮ } } - return ['Button texts saved.', 'success', $productId]; + return ['Button text saved.', 'success', $productId]; } if ($action === 'delete_product_file') { $fileId = (int) ($_POST['file_id'] ?? 0); $file = DigitalFiles::find($fileId); if ($file === null || (int) $file['product_id'] !== $productId) { - return ['File not found.', 'error', $productId]; + return ['We couldn\'t find that file.', 'error', $productId]; } DigitalFiles::delete($fileId); ⋮ 2 unchanged lines ⋮ return ['File deleted.', 'success', $productId]; } - return ['Unknown action.', 'error', $productId]; + return ['Something went wrong. Please try again.', 'error', $productId];
dashboard-actions/refund-order.php
⋮ 7 unchanged lines ⋮ } if (!Auth::hasRoleAtLeast(Auth::ROLE_STORE_MANAGER)) { - return ['You do not have permission to refund orders.', 'error', null]; + return ['You don\'t have access to refunds.', 'error', null]; } $orderId = (int) ($_POST['order_id'] ?? 0); if ($orderId <= 0) { - return ['Invalid order.', 'error', null]; + return ['We couldn\'t find that order.', 'error', null]; } $usersDb = Database::users(); ⋮ 2 unchanged lines ⋮ $order = $statement->fetch(); if (!$order) { - return ['Order not found.', 'error', null]; + return ['We couldn\'t find that order.', 'error', null]; } if ($order['status'] === 'refunded') { - return ['This order has already been refunded.', 'error', $orderId]; + return ['This order was already refunded.', 'error', $orderId]; } if (empty($order['provider_payment_id'])) { - return ['This order has no recorded payment to refund.', 'error', null]; + return ['This order hasn\'t been paid, so there\'s nothing to refund.', 'error', null]; } $refunded = false; ⋮ 8 unchanged lines ⋮ } if (!$refunded) { - return ['The refund could not be processed with the payment provider. No changes were made.', 'error', null]; + return ['The payment company didn\'t accept the refund, so nothing changed. Please try again or refund it from their site.', 'error', null]; } $usersDb->prepare("UPDATE orders SET status = 'refunded', updated_at = NOW() WHERE id = :id")->execute(['id' => $orderId]); ⋮ 4 unchanged lines ⋮ "INSERT INTO order_status_history (order_id, status, note, changed_by_team_account_id) VALUES (:id, 'refunded', 'Refunded by staff', :account_id)" )->execute(['id' => $orderId, 'account_id' => $currentUser['id']]); - return ['Order refunded.', 'success', $orderId]; + return ['Refund done. The money is on its way back to the customer.', 'success', $orderId];
dashboard-actions/restore-version.php
⋮ 10 unchanged lines ⋮ $versionId = (int) ($_POST['version_id'] ?? 0); if ($productId <= 0 || $versionId <= 0) { - return ['Version not found.', 'error', null]; + return ['We couldn\'t find that version.', 'error', null]; } $statement = Database::site()->prepare('SELECT created_by, deleted_at FROM products WHERE id = :id LIMIT 1'); ⋮ 1 unchanged line ⋮ $product = $statement->fetch(); if (!$product || $product['deleted_at'] !== null) { - return ['Product not found.', 'error', null]; + return ['We couldn\'t find that product.', 'error', null]; } $isOwner = (int) $product['created_by'] === (int) $currentUser['id']; if (!$isOwner && !Auth::hasRoleAtLeast(Auth::ROLE_STORE_OWNER)) { - return ['You do not have permission to restore versions of this product.', 'error', null]; + return ['You don\'t have access to this product\'s history.', 'error', null]; } if (!ProductVersions::restore($productId, $versionId, (int) $currentUser['id'], (string) $currentUser['display_name'])) { - return ['Version not found.', 'error', null]; + return ['We couldn\'t find that version.', 'error', null]; } ActivityLog::record('product.restore_version', 'product', $productId, 'version #' . $versionId); - return ['Version restored.', 'success', $productId]; + return ['Earlier version restored.', 'success', $productId];
dashboard-actions/save-discount.php
⋮ 9 unchanged lines ⋮ require_once __DIR__ . '/../includes/currency.php'; if (!Auth::hasRoleAtLeast(Auth::ROLE_STORE_MANAGER)) { - return ['You do not have permission to manage discounts.', 'error', false]; + return ['You don\'t have access to discounts.', 'error', false]; } $db = Database::site(); ⋮ 17 unchanged lines ⋮ $isActive = isset($_POST['is_active']) ? 1 : 0; if ($code === '') { - return ['A discount code is required.', 'error', false]; + return ['Please type a code for the discount.', 'error', false]; } try { ⋮ 15 unchanged lines ⋮ 'id' => $discountId, ]); - return ['Discount updated.', 'success', true]; + return ['Discount saved.', 'success', true]; } $insert = $db->prepare( ⋮ 12 unchanged lines ⋮ 'created_by' => $currentUser['id'], ]); - return ['Discount created.', 'success', true]; + return ['Discount created. Customers can use it now.', 'success', true]; } catch (PDOException $exception) { if ((int) $exception->getCode() === 23000 || str_contains($exception->getMessage(), 'uq_discount_code')) { - return ['That discount code is already in use. Choose a different code.', 'error', false]; + return ['That code is taken. Try a different one.', 'error', false]; } error_log('StocketBase: could not save discount: ' . $exception->getMessage()); - return ['Could not save the discount. Please try again.', 'error', false]; + return ['Something went wrong while saving. Please try again.', 'error', false]; }
dashboard-actions/save-nav.php
⋮ 76 unchanged lines ⋮ if ($refId <= 0) { return; } - $exists = $db->prepare('SELECT id FROM pages WHERE id = :id LIMIT 1'); + $exists = $db->prepare('SELECT id FROM pages WHERE id = :id AND is_home = 0 LIMIT 1'); $exists->execute(['id' => $refId]); if (!$exists->fetch()) { return; ⋮ 100 unchanged lines ⋮
dashboard-actions/save-order-status.php
⋮ 7 unchanged lines ⋮ } if (!Auth::hasRoleAtLeast(Auth::ROLE_STORE_MANAGER)) { - return ['You do not have permission to update orders.', 'error', null]; + return ['You don\'t have access to orders.', 'error', null]; } $orderId = (int) ($_POST['order_id'] ?? 0); ⋮ 2 unchanged lines ⋮ $trackingNumber = trim((string) ($_POST['tracking_number'] ?? '')); $trackingUrl = trim((string) ($_POST['tracking_url'] ?? '')); if ($trackingUrl !== '' && (!preg_match('#^https?://#i', $trackingUrl) || filter_var($trackingUrl, FILTER_VALIDATE_URL) === false)) { - return ['Tracking URL must be a valid http(s) link.', 'error', $orderId > 0 ? $orderId : null]; + return ['The tracking link should start with http:// or https://.', 'error', $orderId > 0 ? $orderId : null]; } $trackingNumber = mb_substr($trackingNumber, 0, 120); $note = mb_substr($note, 0, 2000); $note = trim((string) ($_POST['note'] ?? '')); if ($orderId <= 0 || $status === null) { - return ['Invalid order or status.', 'error', null]; + return ['Something doesn\'t look right with that order. Please try again.', 'error', null]; } $usersDb = Database::users(); ⋮ 2 unchanged lines ⋮ $order = $statement->fetch(); if (!$order) { - return ['Order not found.', 'error', null]; + return ['We couldn\'t find that order.', 'error', null]; } $fulfilledAt = $order['fulfilled_at']; ⋮ 51 unchanged lines ⋮ Mailer::send($recipientEmail, $email['subject'], $email['html']); } catch (Throwable $mailException) { error_log('StocketBase: order status email failed — ' . $mailException->getMessage()); - return ['Order status updated, but the notification email could not be sent.', 'error', $orderId]; + return ['Status saved, but the email to the customer didn\'t go out.', 'error', $orderId]; } } } - return ['Order status updated.', 'success', $orderId]; + return ['Status saved.', 'success', $orderId];
dashboard-actions/save-page.php
⋮ 14 unchanged lines ⋮ $pageId = (int) ($_POST['page_id'] ?? 0) ?: null; $status = ($_POST['status'] ?? 'draft') === 'published' ? 'published' : 'draft'; $showInMenu = isset($_POST['show_in_menu']) ? 1 : 0; - $blocksJson = BlockEditor::sanitize((string) ($_POST['blocks_json'] ?? '{"blocks":[]}'), true); if ($title === '') { exit; ⋮ 1 unchanged line ⋮ require_once __DIR__ . '/../includes/slugger.php'; - $slug = Slugger::make($title); $db = Database::site(); + $isHomePage = false; + + if ($pageId !== null) { + $homeCheck = $db->prepare('SELECT is_home FROM pages WHERE id = :id LIMIT 1'); + $homeCheck->execute(['id' => $pageId]); + $isHomePage = (int) $homeCheck->fetchColumn() === 1; + } + + if ($isHomePage) { + $blocksJson = BlockEditor::sanitize((string) ($_POST['blocks_json'] ?? '{"blocks":[]}'), false, true); + $statement = $db->prepare('UPDATE pages SET title = :title, content_blocks = :content, status = :status, show_in_menu = 0 WHERE id = :id AND is_home = 1'); + $statement->execute([ + 'title' => mb_substr($title, 0, 255), + 'content' => $blocksJson, + 'status' => 'published', + 'id' => $pageId, + ]); + + return; + } + + $blocksJson = BlockEditor::sanitize((string) ($_POST['blocks_json'] ?? '{"blocks":[]}'), true); + $slug = Slugger::make($title); if ($pageId !== null) { $previousMenu = $db->prepare('SELECT show_in_menu FROM pages WHERE id = :id LIMIT 1'); ⋮ 36 unchanged lines ⋮
dashboard-actions/save-product.php
⋮ 40 unchanged lines ⋮ $polarProductId = trim((string) ($_POST['polar_product_id'] ?? '')); if ($title === '') { - return ['Product title is required.', 'error', null]; + return ['Give your product a name first.', 'error', null]; } $slug = trim((string) ($_POST['slug'] ?? '')); ⋮ 45 unchanged lines ⋮ $existing = $existingStatement->fetch(); if (!$existing) { - return ['Product not found.', 'error', null]; + return ['We couldn\'t find that product.', 'error', null]; } if ($existing['deleted_at'] !== null) { ⋮ 5 unchanged lines ⋮ $canProofread = Auth::role() === Auth::ROLE_CATALOG_ASSISTANT; if (!$isOwner && !$canEditAny && !$canProofread) { - return ['You do not have permission to edit this product.', 'error', null]; + return ['You don\'t have access to this product.', 'error', null]; } if ($canProofread && !$isOwner) { ⋮ 1 unchanged line ⋮ 'UPDATE products SET content_blocks = :content, updated_at = NOW() WHERE id = :id' ); $update->execute(['content' => $blocksJson, 'id' => $productId]); - return ['Proofreading changes saved.', 'success', $productId]; + return ['Corrections saved.', 'success', $productId]; } $status = $existing['status']; ⋮ 47 unchanged lines ⋮ $syncVariants($productId); - return ['Product updated.', 'success', $productId]; + return ['Product saved.', 'success', $productId]; } $status = 'draft'; ⋮ 48 unchanged lines ⋮
dashboard-actions/security.php
⋮ 18 unchanged lines ⋮ $passwordValid = password_verify((string) ($_POST['password'] ?? ''), (string) $currentUser['password_hash']); if (!$passwordValid || !TwoFactor::verify($currentUser, (string) ($_POST['code'] ?? ''))) { LoginThrottle::recordFailure('totp_manage', (string) $accountId); - return 'The password or the code is not correct.'; + return 'The password or code isn\'t right.'; } LoginThrottle::clear('totp_manage', (string) $accountId); ⋮ 18 unchanged lines ⋮ if ($action === 'totp_confirm') { $secret = (string) ($_SESSION['totp_setup_secret'] ?? ''); if ($twoFactorEnabled || $secret === '') { - return ['Start the setup again.', 'error', false]; + return ['That took too long. Please start the setup again.', 'error', false]; } $wait = LoginThrottle::secondsUntilAllowed('totp_setup', (string) $accountId); ⋮ 5 unchanged lines ⋮ if ($step === null) { LoginThrottle::recordFailure('totp_setup', (string) $accountId); - return ['That code is not valid. Check the time on your phone and try again.', 'error', false]; + return ['That code didn\'t work. Make sure the time on your phone is correct and try again.', 'error', false]; } LoginThrottle::clear('totp_setup', (string) $accountId); ⋮ 6 unchanged lines ⋮ if ($action === 'totp_disable' || $action === 'totp_regenerate') { if (!$twoFactorEnabled) { - return ['Two-factor authentication is not enabled.', 'error', false]; + return ['Two-step login is already off.', 'error', false]; } $problem = $confirmIdentity(); ⋮ 12 unchanged lines ⋮ return [null, 'success', true]; } - return ['Unknown action.', 'error', false]; + return ['Something went wrong. Please try again.', 'error', false];
dashboard-actions/send-test-email.php
⋮ 9 unchanged lines ⋮ require_once __DIR__ . '/../includes/mailer.php'; if (!Auth::hasRoleAtLeast(Auth::ROLE_STORE_OWNER)) { - $flashMessage = 'You do not have permission to send test emails.'; + $flashMessage = 'You don\'t have access to test emails.'; $flashType = 'error'; return; ⋮ 2 unchanged lines ⋮ $testRecipient = trim((string) ($currentUser['email'] ?? '')); if ($testRecipient === '' || filter_var($testRecipient, FILTER_VALIDATE_EMAIL) === false) { - $flashMessage = 'Your team account has no valid email address on file — add one in Profile first.'; + $flashMessage = 'Your account doesn\'t have an email address yet. Ask the Super Admin to add one.'; $flashType = 'error'; return; ⋮ 9 unchanged lines ⋮ $sent = Mailer::send($testRecipient, 'Test email from ' . $siteName, $html); if ($sent) { - $flashMessage = 'Test email sent to ' . $testRecipient . '.'; + $flashMessage = 'Test email sent to ' . $testRecipient . '. Check your inbox.'; $flashType = 'success'; } else { - $flashMessage = 'Could not send the test email — check the settings and the server error log for details.'; + $flashMessage = 'The test email didn\'t go out. Check your email settings and try again.'; $flashType = 'error'; }
dashboard-views/categories.php
⋮ 7 unchanged lines ⋮ } if (!Auth::hasRoleAtLeast(Auth::ROLE_STORE_OWNER)) { - echo '<p>You do not have permission to manage categories.</p>'; + echo '<p>You don\'t have access to categories.</p>'; return; } ⋮ 44 unchanged lines ⋮ <?php if (Languages::enabled()): ?> <a href="translate.php?type=category&id=<?= (int) $category['id'] ?>" class="dash-btn-small"><?= Icons::icon('translate', 'icon icon-sm') ?>Translate</a> <?php endif; ?> - <form method="post" style="display:inline;" onsubmit="return confirm('Delete this category? Products in it will become uncategorized.');"> + <form method="post" style="display:inline;" onsubmit="return confirm('Delete this category? Its products stay, they just won\'t have a category.');"> <?= Csrf::field() ?> <input type="hidden" name="action" value="delete_category"> <input type="hidden" name="category_id" value="<?= (int) $category['id'] ?>"> ⋮ 4 unchanged lines ⋮ <?php endforeach; ?> <?php if (empty($categories)): ?> <tr> - <td colspan="4">No categories yet.</td> + <td colspan="4">No categories yet. Add one to group your products.</td> </tr> <?php endif; ?> </tbody> ⋮ 12 unchanged lines ⋮ <label>Description</label> <textarea name="description" id="category_description_input" rows="2"></textarea> - <label>Parent category</label> + <label>Put it inside another category (optional)</label> <select name="parent_id" id="category_parent_input"> - <option value="">— none —</option> + <option value="">No, keep it on its own</option> <?php foreach ($categories as $category): ?> <option value="<?= (int) $category['id'] ?>"><?= htmlspecialchars($category['name']) ?></option> <?php endforeach; ?> </select> - <label><input type="checkbox" name="show_in_menu" id="category_show_in_menu_input" checked> Show in site menu</label> + <label><input type="checkbox" name="show_in_menu" id="category_show_in_menu_input" checked> Show in the store menu</label> <div class="publish-actions"> <button type="submit" class="dash-btn dash-btn-primary"><?= Icons::icon('check', 'icon icon-sm') ?>Save category</button> ⋮ 49 unchanged lines ⋮
dashboard-views/customer-detail.php
⋮ 9 unchanged lines ⋮ require_once __DIR__ . '/../includes/currency.php'; if (!Auth::hasRoleAtLeast(Auth::ROLE_STORE_MANAGER)) { - echo '<p>You do not have permission to view customers.</p>'; + echo '<p>You don\'t have access to customers.</p>'; return; } ⋮ 5 unchanged lines ⋮ $customer = $statement->fetch(); if (!$customer) { - echo '<p>Customer not found.</p>'; + echo '<p>We couldn\'t find this customer.</p>'; return; } ⋮ 15 unchanged lines ⋮ <div class="sidebar-box" style="max-width:480px;"> <p>Email: <?= htmlspecialchars($customer['email']) ?><br> - Status: <span class="status-pill status-<?= $customer['status'] === 'active' ? 'published' : 'rejected' ?>"><?= htmlspecialchars($customer['status']) ?></span><br> + Status: <span class="status-pill status-<?= $customer['status'] === 'active' ? 'published' : 'rejected' ?>"><?= htmlspecialchars(StatusLabel::get((string) $customer['status'])) ?></span><br> Joined: <?= htmlspecialchars($customer['created_at']) ?><br> - Last login: <?= htmlspecialchars((string) ($customer['last_login_at'] ?? '—')) ?></p> + Last seen: <?= htmlspecialchars((string) ($customer['last_login_at'] ?? 'Never')) ?></p> </div> <?php if (!empty($subscriptions)): ?> <h2 class="dash-subtitle"><?= Icons::icon('credit-card', 'icon icon-sm') ?>Subscriptions</h2> <table class="dash-table"> - <thead><tr><th>Provider</th><th>Plan</th><th>Status</th><th>Renews / ends</th></tr></thead> + <thead><tr><th>Paid with</th><th>Plan</th><th>Status</th><th>Renews or ends on</th></tr></thead> <tbody> <?php foreach ($subscriptions as $subscription): ?> <tr> <td><?= htmlspecialchars($subscription['provider']) ?></td> - <td><?= htmlspecialchars((string) ($subscription['plan_code'] ?? '—')) ?></td> - <td><span class="status-pill status-<?= htmlspecialchars($subscription['status']) ?>"><?= htmlspecialchars($subscription['status']) ?></span></td> - <td><?= htmlspecialchars((string) ($subscription['current_period_end'] ?? '—')) ?></td> + <td><?= htmlspecialchars((string) ($subscription['plan_code'] ?? 'Unknown')) ?></td> + <td><span class="status-pill status-<?= htmlspecialchars($subscription['status']) ?>"><?= htmlspecialchars(StatusLabel::get((string) $subscription['status'])) ?></span></td> + <td><?= htmlspecialchars((string) ($subscription['current_period_end'] ?? 'No end date')) ?></td> </tr> <?php endforeach; ?> </tbody> ⋮ 2 unchanged lines ⋮ <h2 class="dash-subtitle"><?= Icons::icon('checklist', 'icon icon-sm') ?>Orders</h2> <table class="dash-table"> - <thead><tr><th>Order</th><th>Total</th><th>Status</th><th>Placed</th><th></th></tr></thead> + <thead><tr><th>Order</th><th>Total</th><th>Status</th><th>Date</th><th></th></tr></thead> <tbody> <?php foreach ($orders as $order): ?> <tr> <td><?= htmlspecialchars($order['order_number']) ?></td> <td><?= htmlspecialchars(Currency::format((int) $order['total_cents'], $order['currency'])) ?></td> - <td><span class="status-pill status-<?= htmlspecialchars($order['status']) ?>"><?= htmlspecialchars($order['status']) ?></span></td> + <td><span class="status-pill status-<?= htmlspecialchars($order['status']) ?>"><?= htmlspecialchars(StatusLabel::get((string) $order['status'])) ?></span></td> <td><?= htmlspecialchars($order['created_at']) ?></td> <td><a href="dashboard.php?view=order-detail&id=<?= (int) $order['id'] ?>" class="dash-btn-small"><?= Icons::icon('eye', 'icon icon-sm') ?>View</a></td> </tr> ⋮ 7 unchanged lines ⋮
dashboard-views/customers.php
⋮ 9 unchanged lines ⋮ require_once __DIR__ . '/../includes/currency.php'; if (!Auth::hasRoleAtLeast(Auth::ROLE_STORE_MANAGER)) { - echo '<p>You do not have permission to view customers.</p>'; + echo '<p>You don\'t have access to customers.</p>'; return; } ⋮ 16 unchanged lines ⋮ <th><?= Icons::icon('heading', 'icon icon-sm') ?>Name</th> <th><?= Icons::icon('user', 'icon icon-sm') ?>Email</th> <th><?= Icons::icon('checklist', 'icon icon-sm') ?>Orders</th> - <th><?= Icons::icon('dollar', 'icon icon-sm') ?>Lifetime value</th> + <th><?= Icons::icon('dollar', 'icon icon-sm') ?>Spent in total</th> <th><?= Icons::icon('flag', 'icon icon-sm') ?>Status</th> <th></th> </tr> ⋮ 1 unchanged line ⋮ <tbody> <?php foreach ($customers as $customer): ?> <tr> - <td><?= htmlspecialchars((string) ($customer['display_name'] ?? '—')) ?></td> + <td><?= htmlspecialchars((string) ($customer['display_name'] ?? 'No name')) ?></td> <td><?= htmlspecialchars($customer['email']) ?></td> <td><?= (int) $customer['order_count'] ?></td> <td><?= htmlspecialchars(Currency::format((int) $customer['lifetime_value'], $storeCurrency)) ?></td> - <td><span class="status-pill status-<?= $customer['status'] === 'active' ? 'published' : 'rejected' ?>"><?= htmlspecialchars($customer['status']) ?></span></td> + <td><span class="status-pill status-<?= $customer['status'] === 'active' ? 'published' : 'rejected' ?>"><?= htmlspecialchars(StatusLabel::get((string) $customer['status'])) ?></span></td> <td class="dash-table-actions"><a href="dashboard.php?view=customer-detail&id=<?= (int) $customer['id'] ?>" class="dash-btn-small"><?= Icons::icon('eye', 'icon icon-sm') ?>View</a></td> </tr> <?php endforeach; ?> ⋮ 3 unchanged lines ⋮ </tbody> </table> <?php if (Auth::hasRoleAtLeast(Auth::ROLE_SUPER_ADMIN)): ?> - <p style="font-size:12.5px;color:var(--muted);margin-top:12px;">To suspend or reactivate a customer account, use <a href="admin.php?tab=customers">Team accounts → Customer accounts</a>.</p> + <p style="font-size:12.5px;color:var(--muted);margin-top:12px;">Need to block or unblock someone? Go to <a href="admin.php?tab=customers">Team → Customers</a>.</p> <?php endif; ?>
dashboard-views/discounts.php
⋮ 7 unchanged lines ⋮ } if (!Auth::hasRoleAtLeast(Auth::ROLE_STORE_MANAGER)) { - echo '<p>You do not have permission to manage discounts.</p>'; + echo '<p>You don\'t have access to discounts.</p>'; return; } ⋮ 34 unchanged lines ⋮ data-min-order="<?= $discount['min_order_cents'] !== null ? number_format(((int) $discount['min_order_cents']) / 100, 2, '.', '') : '' ?>" data-max-redemptions="<?= htmlspecialchars((string) ($discount['max_redemptions'] ?? ''), ENT_QUOTES) ?>" data-active="<?= (int) $discount['is_active'] ?>"><?= Icons::icon('edit', 'icon icon-sm') ?>Edit</button> - <form method="post" style="display:inline;" onsubmit="return confirm('Delete this discount code?');"> + <form method="post" style="display:inline;" onsubmit="return confirm('Delete this discount code? Customers won\'t be able to use it anymore.');"> <?= Csrf::field() ?> <input type="hidden" name="action" value="delete_discount"> <input type="hidden" name="discount_id" value="<?= (int) $discount['id'] ?>"> ⋮ 3 unchanged lines ⋮ </tr> <?php endforeach; ?> <?php if (empty($discounts)): ?> - <tr><td colspan="5">No discount codes yet.</td></tr> + <tr><td colspan="5">No discount codes yet. Create one for a sale or a thank-you gift.</td></tr> <?php endif; ?> </tbody> </table> ⋮ 8 unchanged lines ⋮ <label>Code</label> <input type="text" name="code" id="discount_code_input" required> - <label>Description (optional)</label> + <label>Note for yourself (optional)</label> <input type="text" name="description" id="discount_description_input"> - <label>Type</label> + <label>Discount type</label> <select name="discount_type" id="discount_type_input"> - <option value="percentage">Percentage off</option> - <option value="fixed_amount">Fixed amount off</option> + <option value="percentage">Percent off (like 10%)</option> + <option value="fixed_amount">Money off (like 20 PLN)</option> </select> - <label>Value (percent, or amount in major units e.g. 10.00)</label> + <label>How much (a percent, or an amount like 10.00)</label> <input type="text" name="value" id="discount_value_input" required> - <label>Currency (only used for fixed amount)</label> + <label>Currency (only for money off)</label> <input type="text" name="currency" id="discount_currency_input" maxlength="3" value="<?= htmlspecialchars(SiteFront::settings()['store_currency'] ?? 'USD') ?>"> - <label>Minimum order amount (optional, major units)</label> + <label>Minimum order (optional, like 50.00)</label> <input type="text" name="min_order" id="discount_min_order_input"> - <label>Max redemptions (optional)</label> + <label>How many times it can be used (optional)</label> <input type="number" name="max_redemptions" id="discount_max_redemptions_input"> - <label><input type="checkbox" name="is_active" id="discount_active_input" checked> Active</label> + <label><input type="checkbox" name="is_active" id="discount_active_input" checked> Customers can use it now</label> <div class="publish-actions"> <button type="submit" class="dash-btn dash-btn-primary"><?= Icons::icon('check', 'icon icon-sm') ?>Save discount</button> ⋮ 51 unchanged lines ⋮
dashboard-views/order-detail.php
⋮ 9 unchanged lines ⋮ require_once __DIR__ . '/../includes/currency.php'; if (!Auth::hasRoleAtLeast(Auth::ROLE_STORE_MANAGER)) { - echo '<p>You do not have permission to manage orders.</p>'; + echo '<p>You don\'t have access to orders.</p>'; return; } ⋮ 5 unchanged lines ⋮ $order = $statement->fetch(); if (!$order) { - echo '<p>Order not found.</p>'; + echo '<p>We couldn\'t find this order.</p>'; return; } ⋮ 16 unchanged lines ⋮ <div class="article-form-grid"> <div class="article-form-main"> - <h2 class="dash-subtitle"><?= Icons::icon('grid', 'icon icon-sm') ?>Items</h2> + <h2 class="dash-subtitle"><?= Icons::icon('grid', 'icon icon-sm') ?>What they ordered</h2> <table class="dash-table"> - <thead><tr><th>Product</th><th>Variant</th><th>Qty</th><th>Unit price</th><th>Total</th></tr></thead> + <thead><tr><th>Product</th><th>Option</th><th>Quantity</th><th>Price each</th><th>Total</th></tr></thead> <tbody> <?php foreach ($items as $item): ?> <tr> <td><?= htmlspecialchars($item['product_title_snapshot']) ?></td> - <td><?= htmlspecialchars((string) ($item['variant_name_snapshot'] ?? '—')) ?></td> + <td><?= htmlspecialchars((string) ($item['variant_name_snapshot'] ?? 'Standard')) ?></td> <td><?= (int) $item['quantity'] ?></td> <td><?= htmlspecialchars(Currency::format((int) $item['unit_price_cents'], $order['currency'])) ?></td> <td><?= htmlspecialchars(Currency::format((int) $item['total_cents'], $order['currency'])) ?></td> ⋮ 30 unchanged lines ⋮ <p><?= nl2br(htmlspecialchars($order['customer_note'])) ?></p> <?php endif; ?> - <h2 class="dash-subtitle"><?= Icons::icon('activity', 'icon icon-sm') ?>Timeline</h2> + <h2 class="dash-subtitle"><?= Icons::icon('activity', 'icon icon-sm') ?>History</h2> <ul style="list-style:none;padding:0;margin:0;display:grid;gap:8px;font-size:13px;"> <?php foreach ($history as $entry): ?> - <li><span class="status-pill status-<?= htmlspecialchars($entry['status']) ?>"><?= htmlspecialchars($entry['status']) ?></span> <?= htmlspecialchars($entry['created_at']) ?><?= $entry['note'] ? ' — ' . htmlspecialchars($entry['note']) : '' ?></li> + <li><span class="status-pill status-<?= htmlspecialchars($entry['status']) ?>"><?= htmlspecialchars(StatusLabel::get((string) $entry['status'])) ?></span> <?= htmlspecialchars($entry['created_at']) ?><?= $entry['note'] ? ', ' . htmlspecialchars($entry['note']) : '' ?></li> <?php endforeach; ?> <?php if (empty($history)): ?> - <li>No status changes recorded yet.</li> + <li>Nothing has changed yet.</li> <?php endif; ?> </ul> ⋮ 3 unchanged lines ⋮ <div class="sidebar-box"> <label>Customer</label> - <p><?= htmlspecialchars((string) ($order['display_name'] ?: '—')) ?><br><?= htmlspecialchars((string) ($order['account_email'] ?: $order['guest_email'] ?: '—')) ?></p> + <p><?= htmlspecialchars((string) ($order['display_name'] ?: 'Guest')) ?><br><?= htmlspecialchars((string) ($order['account_email'] ?: $order['guest_email'] ?: 'No email')) ?></p> <?php if ($order['user_account_id']): ?> <a href="dashboard.php?view=customer-detail&id=<?= (int) $order['user_account_id'] ?>" class="dash-btn-small"><?= Icons::icon('eye', 'icon icon-sm') ?>View customer</a> <?php endif; ?> ⋮ 1 unchanged line ⋮ <div class="sidebar-box"> <label>Payment</label> - <p>Provider: <?= htmlspecialchars((string) ($order['provider'] ?: '—')) ?><br> - Payment ID: <?= htmlspecialchars((string) ($order['provider_payment_id'] ?: '—')) ?></p> + <p>Paid with: <?= htmlspecialchars((string) ($order['provider'] ? ucfirst((string) $order['provider']) : 'Not paid yet')) ?><br> + Payment reference: <?= htmlspecialchars((string) ($order['provider_payment_id'] ?: 'None yet')) ?></p> </div> <div class="sidebar-box"> - <label>Update status</label> + <label>Change status</label> <form method="post"> <?= Csrf::field() ?> <input type="hidden" name="action" value="update_order_status"> <input type="hidden" name="order_id" value="<?= (int) $order['id'] ?>"> <select name="status"> <?php foreach ($allowedStatuses as $status): ?> - <option value="<?= htmlspecialchars($status) ?>" <?= $order['status'] === $status ? 'selected' : '' ?>><?= htmlspecialchars(ucfirst($status)) ?></option> + <option value="<?= htmlspecialchars($status) ?>" <?= $order['status'] === $status ? 'selected' : '' ?>><?= htmlspecialchars(StatusLabel::get($status)) ?></option> <?php endforeach; ?> </select> <label>Tracking number (optional)</label> <input type="text" name="tracking_number" value="<?= htmlspecialchars((string) ($order['tracking_number'] ?? '')) ?>"> - <label>Tracking URL (optional)</label> + <label>Tracking link (optional)</label> <input type="text" name="tracking_url" value="<?= htmlspecialchars((string) ($order['tracking_url'] ?? '')) ?>"> - <label>Note (optional)</label> + <label>Note for the history (optional)</label> <input type="text" name="note"> - <label><input type="checkbox" name="notify_customer" checked> Email the customer about this update</label> + <label><input type="checkbox" name="notify_customer" checked> Let the customer know by email</label> <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:10px;"><?= Icons::icon('check', 'icon icon-sm') ?>Save status</button> </form> </div> ⋮ 1 unchanged line ⋮ <?php if (in_array($order['status'], ['paid', 'processing', 'fulfilled', 'shipped', 'completed'], true) && !empty($order['provider_payment_id'])): ?> <div class="sidebar-box"> <label>Refund</label> - <form method="post" onsubmit="return confirm('Refund this order via ' + <?= json_encode($order['provider']) ?> + '? This cannot be undone.');"> + <form method="post" onsubmit="return confirm('Refund this order through ' + <?= json_encode($order['provider']) ?> + '? The money goes back to the customer and this can\'t be undone.');"> <?= Csrf::field() ?> <input type="hidden" name="action" value="refund_order"> <input type="hidden" name="order_id" value="<?= (int) $order['id'] ?>"> - <button type="submit" class="dash-btn dash-btn-danger"><?= Icons::icon('x', 'icon icon-sm') ?>Refund order</button> + <button type="submit" class="dash-btn dash-btn-danger"><?= Icons::icon('x', 'icon icon-sm') ?>Refund</button> </form> </div> <?php endif; ?> ⋮ 4 unchanged lines ⋮
dashboard-views/orders.php
⋮ 9 unchanged lines ⋮ require_once __DIR__ . '/../includes/currency.php'; if (!Auth::hasRoleAtLeast(Auth::ROLE_STORE_MANAGER)) { - echo '<p>You do not have permission to manage orders.</p>'; + echo '<p>You don\'t have access to orders.</p>'; return; } ⋮ 20 unchanged lines ⋮ <div class="settings-tabs"> <a href="dashboard.php?view=orders" class="<?= $statusFilter === '' ? 'active' : '' ?>">All</a> <?php foreach ($allowedStatuses as $status): ?> - <a href="dashboard.php?view=orders&status=<?= urlencode($status) ?>" class="<?= $statusFilter === $status ? 'active' : '' ?>"><?= htmlspecialchars(ucfirst($status)) ?></a> + <a href="dashboard.php?view=orders&status=<?= urlencode($status) ?>" class="<?= $statusFilter === $status ? 'active' : '' ?>"><?= htmlspecialchars(StatusLabel::get($status)) ?></a> <?php endforeach; ?> </div> ⋮ 12 unchanged lines ⋮ <?php foreach ($orders as $order): ?> <tr> <td><?= htmlspecialchars($order['order_number']) ?></td> - <td><?= htmlspecialchars((string) ($order['account_email'] ?: $order['guest_email'] ?: '—')) ?></td> + <td><?= htmlspecialchars((string) ($order['account_email'] ?: $order['guest_email'] ?: 'No email')) ?></td> <td><?= htmlspecialchars(Currency::format((int) $order['total_cents'], $order['currency'])) ?></td> - <td><span class="status-pill status-<?= htmlspecialchars($order['status']) ?>"><?= htmlspecialchars($order['status']) ?></span></td> + <td><span class="status-pill status-<?= htmlspecialchars($order['status']) ?>"><?= htmlspecialchars(StatusLabel::get((string) $order['status'])) ?></span></td> <td><?= htmlspecialchars($order['created_at']) ?></td> <td class="dash-table-actions"><a href="dashboard.php?view=order-detail&id=<?= (int) $order['id'] ?>" class="dash-btn-small"><?= Icons::icon('eye', 'icon icon-sm') ?>View</a></td> </tr> <?php endforeach; ?> <?php if (empty($orders)): ?> - <tr><td colspan="6">No orders found.</td></tr> + <tr><td colspan="6">No orders here yet.</td></tr> <?php endif; ?> </tbody> </table> ⋮ 1 unchanged line ⋮
dashboard-views/overview.php
⋮ 31 unchanged lines ⋮ $recentStatement = $db->prepare( Auth::isAdministrative() - ? 'SELECT p.*, t.display_name FROM products p JOIN team_accounts t ON t.id = p.created_by WHERE p.deleted_at IS NULL ORDER BY p.updated_at DESC LIMIT 8' - : 'SELECT p.*, t.display_name FROM products p JOIN team_accounts t ON t.id = p.created_by WHERE p.created_by = :id AND p.deleted_at IS NULL ORDER BY p.updated_at DESC LIMIT 8' + ? 'SELECT p.*, t.display_name FROM products p LEFT JOIN team_accounts t ON t.id = p.created_by WHERE p.deleted_at IS NULL ORDER BY p.updated_at DESC LIMIT 8' + : 'SELECT p.*, t.display_name FROM products p LEFT JOIN team_accounts t ON t.id = p.created_by WHERE p.created_by = :id AND p.deleted_at IS NULL ORDER BY p.updated_at DESC LIMIT 8' ); if (!Auth::isAdministrative()) { $recentStatement->execute(['id' => $currentUser['id']]); ⋮ 27 unchanged lines ⋮ <div class="dash-cards"> <?php if ($isManager): ?> - <div class="dash-card"><?= Icons::icon('dollar', 'icon icon-lg') ?><div class="dash-card-value"><?= htmlspecialchars(Currency::format($revenueToday, $storeCurrency)) ?></div><div class="dash-card-label">Revenue today</div></div> - <div class="dash-card"><?= Icons::icon('credit-card', 'icon icon-lg') ?><div class="dash-card-value"><?= htmlspecialchars(Currency::format($revenue7d, $storeCurrency)) ?></div><div class="dash-card-label">Revenue (7 days)</div></div> - <div class="dash-card"><?= Icons::icon('stats', 'icon icon-lg') ?><div class="dash-card-value"><?= htmlspecialchars(Currency::format($revenue30d, $storeCurrency)) ?></div><div class="dash-card-label">Revenue (30 days)</div></div> - <div class="dash-card"><?= Icons::icon('checklist', 'icon icon-lg') ?><div class="dash-card-value"><?= $ordersPending ?></div><div class="dash-card-label">Orders awaiting payment</div></div> + <div class="dash-card"><?= Icons::icon('dollar', 'icon icon-lg') ?><div class="dash-card-value"><?= htmlspecialchars(Currency::format($revenueToday, $storeCurrency)) ?></div><div class="dash-card-label">Sales today</div></div> + <div class="dash-card"><?= Icons::icon('credit-card', 'icon icon-lg') ?><div class="dash-card-value"><?= htmlspecialchars(Currency::format($revenue7d, $storeCurrency)) ?></div><div class="dash-card-label">Sales, last 7 days</div></div> + <div class="dash-card"><?= Icons::icon('stats', 'icon icon-lg') ?><div class="dash-card-value"><?= htmlspecialchars(Currency::format($revenue30d, $storeCurrency)) ?></div><div class="dash-card-label">Sales, last 30 days</div></div> + <div class="dash-card"><?= Icons::icon('checklist', 'icon icon-lg') ?><div class="dash-card-value"><?= $ordersPending ?></div><div class="dash-card-label">Orders not paid yet</div></div> <?php endif; ?> <?php if (Auth::isAdministrative()): ?> - <div class="dash-card"><div class="dash-card-value"><?= $publishedCount ?></div><div class="dash-card-label">Published products</div></div> - <div class="dash-card"><div class="dash-card-value"><?= $pendingCount ?></div><div class="dash-card-label">Pending review</div></div> + <div class="dash-card"><?= Icons::icon('grid', 'icon icon-lg') ?><div class="dash-card-value"><?= $publishedCount ?></div><div class="dash-card-label">Products in your store</div></div> + <div class="dash-card"><?= Icons::icon('clock', 'icon icon-lg') ?><div class="dash-card-value"><?= $pendingCount ?></div><div class="dash-card-label">Waiting for your approval</div></div> <?php else: ?> - <div class="dash-card"><div class="dash-card-value"><?= $myProductCount ?></div><div class="dash-card-label">Your products</div></div> - <div class="dash-card"><div class="dash-card-value"><?= $publishedCount ?></div><div class="dash-card-label">Published</div></div> + <div class="dash-card"><?= Icons::icon('edit', 'icon icon-lg') ?><div class="dash-card-value"><?= $myProductCount ?></div><div class="dash-card-label">Your products</div></div> + <div class="dash-card"><?= Icons::icon('grid', 'icon icon-lg') ?><div class="dash-card-value"><?= $publishedCount ?></div><div class="dash-card-label">Live in the store</div></div> <?php endif; ?> </div> ⋮ 6 unchanged lines ⋮ <tr> <td><?= htmlspecialchars($order['order_number']) ?></td> <td><?= htmlspecialchars(Currency::format((int) $order['total_cents'], $order['currency'])) ?></td> - <td><span class="status-pill status-<?= htmlspecialchars($order['status']) ?>"><?= htmlspecialchars($order['status']) ?></span></td> + <td><span class="status-pill status-<?= htmlspecialchars($order['status']) ?>"><?= htmlspecialchars(StatusLabel::get((string) $order['status'])) ?></span></td> <td><?= htmlspecialchars($order['created_at']) ?></td> <td><a href="dashboard.php?view=order-detail&id=<?= (int) $order['id'] ?>" class="dash-btn-small"><?= Icons::icon('eye', 'icon icon-sm') ?>View</a></td> </tr> ⋮ 5 unchanged lines ⋮ </table> <?php if (!empty($lowStockProducts)): ?> - <h2 class="dash-subtitle"><?= Icons::icon('flag', 'icon icon-sm') ?>Low stock</h2> + <h2 class="dash-subtitle"><?= Icons::icon('flag', 'icon icon-sm') ?>Running low</h2> <table class="dash-table"> <thead><tr><th><?= Icons::icon('heading', 'icon icon-sm') ?>Product</th><th><?= Icons::icon('grid', 'icon icon-sm') ?>Stock left</th><th></th></tr></thead> <tbody> ⋮ 9 unchanged lines ⋮ <?php endif; ?> <?php endif; ?> - <h2 class="dash-subtitle"><?= Icons::icon('activity', 'icon icon-sm') ?>Recent products</h2> + <h2 class="dash-subtitle"><?= Icons::icon('activity', 'icon icon-sm') ?>Latest product changes</h2> <table class="dash-table"> - <thead><tr><th><?= Icons::icon('heading', 'icon icon-sm') ?>Title</th><th><?= Icons::icon('user', 'icon icon-sm') ?>Created by</th><th><?= Icons::icon('flag', 'icon icon-sm') ?>Status</th><th><?= Icons::icon('clock', 'icon icon-sm') ?>Updated</th></tr></thead> + <thead><tr><th><?= Icons::icon('heading', 'icon icon-sm') ?>Title</th><th><?= Icons::icon('user', 'icon icon-sm') ?>Added by</th><th><?= Icons::icon('flag', 'icon icon-sm') ?>Status</th><th><?= Icons::icon('clock', 'icon icon-sm') ?>Updated</th></tr></thead> <tbody> <?php foreach ($recentProducts as $product): ?> <tr> <td><a href="dashboard.php?view=product-edit&id=<?= (int) $product['id'] ?>"><?= htmlspecialchars($product['title']) ?></a></td> - <td><?= htmlspecialchars($product['display_name']) ?></td> - <td><span class="status-pill status-<?= htmlspecialchars($product['status']) ?>"><?= htmlspecialchars($product['status']) ?></span></td> + <td><?= htmlspecialchars((string) ($product['display_name'] ?? 'System')) ?></td> + <td><span class="status-pill status-<?= htmlspecialchars($product['status']) ?>"><?= htmlspecialchars(StatusLabel::get((string) $product['status'])) ?></span></td> <td><?= htmlspecialchars($product['updated_at']) ?></td> </tr> <?php endforeach; ?> ⋮ 6 unchanged lines ⋮
dashboard-views/pages.php
⋮ 7 unchanged lines ⋮ } if (!Auth::hasRoleAtLeast(Auth::ROLE_STORE_MANAGER)) { - echo '<p>You do not have permission to manage pages.</p>'; + echo '<p>You don\'t have access to pages.</p>'; return; } $db = Database::site(); + try { + SiteFront::homePage(); + } catch (Throwable $exception) { + } + $pageId = (int) ($_GET['page_id'] ?? 0) ?: null; $editingPage = null; $showForm = $pageId !== null || isset($_GET['new']); ⋮ 4 unchanged lines ⋮ $editingPage = $statement->fetch() ?: null; } - $pages = $db->query('SELECT * FROM pages ORDER BY sort_order ASC, title ASC')->fetchAll(); + $pages = $db->query('SELECT * FROM pages ORDER BY is_home DESC, sort_order ASC, title ASC')->fetchAll(); + $isHomeEditing = $editingPage !== null && (int) ($editingPage['is_home'] ?? 0) === 1; $blocksJson = $editingPage['content_blocks'] ?? '{"blocks":[]}'; + if ($isHomeEditing) { + $blocksJson = BlockEditor::sanitize((string) $blocksJson, false, true); + } ?> <div class="dash-header-row"> ⋮ 7 unchanged lines ⋮ <thead><tr><th><?= Icons::icon('heading', 'icon icon-sm') ?>Title</th><th><?= Icons::icon('flag', 'icon icon-sm') ?>Status</th><th><?= Icons::icon('menu', 'icon icon-sm') ?>In menu</th><th></th></tr></thead> <tbody> <?php foreach ($pages as $page): ?> - <tr> - <td><?= htmlspecialchars($page['title']) ?></td> - <td><span class="status-pill status-<?= htmlspecialchars($page['status']) ?>"><?= htmlspecialchars($page['status']) ?></span></td> - <td><?= $page['show_in_menu'] ? 'Yes' : 'No' ?></td> + <?php $isHomeRow = (int) ($page['is_home'] ?? 0) === 1; ?> + <tr<?= $isHomeRow ? ' class="page-row-home"' : '' ?>> + <td><?php if ($isHomeRow): ?><?= Icons::icon('home', 'icon icon-sm') ?> <?php endif; ?><?= htmlspecialchars($page['title']) ?><?php if ($isHomeRow): ?> <span class="status-pill status-published">Home page</span><?php endif; ?></td> + <td><span class="status-pill status-<?= htmlspecialchars($page['status']) ?>"><?= htmlspecialchars(StatusLabel::get((string) $page['status'])) ?></span></td> + <td><?= $isHomeRow ? 'Always' : ($page['show_in_menu'] ? 'Yes' : 'No') ?></td> <td class="dash-table-actions"> - <a href="dashboard.php?view=pages&page_id=<?= (int) $page['id'] ?>" class="dash-btn-small"><?= Icons::icon('edit', 'icon icon-sm') ?>Edit</a> + <a href="dashboard.php?view=pages&page_id=<?= (int) $page['id'] ?>" class="dash-btn-small"><?= Icons::icon('edit', 'icon icon-sm') ?><?= $isHomeRow ? 'Design' : 'Edit' ?></a> <?php if (Languages::enabled()): ?> <a href="translate.php?type=page&id=<?= (int) $page['id'] ?>" class="dash-btn-small"><?= Icons::icon('translate', 'icon icon-sm') ?>Translate</a> <?php endif; ?> - <form method="post" style="display:inline;" onsubmit="return confirm('Delete this page?');"> + <?php if (!$isHomeRow): ?> + <form method="post" style="display:inline;" onsubmit="return confirm('Delete this page? This can\'t be undone.');"> <?= Csrf::field() ?> <input type="hidden" name="action" value="delete_page"> <input type="hidden" name="page_id" value="<?= (int) $page['id'] ?>"> <button type="submit" class="dash-btn-small dash-btn-danger"><?= Icons::icon('trash', 'icon icon-sm') ?>Delete</button> </form> + <?php endif; ?> </td> </tr> <?php endforeach; ?> <?php if (empty($pages)): ?> - <tr><td colspan="4">No pages yet.</td></tr> + <tr><td colspan="4">No pages yet. Add one for things like About us or Shipping info.</td></tr> <?php endif; ?> </tbody> </table> <?php if ($showForm): ?> <div class="sidebar-box" style="margin-top:20px;"> - <h2 class="dash-subtitle" style="margin-top:0;"><?= Icons::icon('pages', 'icon icon-sm') ?><?= $editingPage ? 'Edit page' : 'New page' ?></h2> + <h2 class="dash-subtitle" style="margin-top:0;"><?= Icons::icon($isHomeEditing ? 'home' : 'pages', 'icon icon-sm') ?><?= $isHomeEditing ? 'Design home page' : ($editingPage ? 'Edit page' : 'New page') ?></h2> + <?php if ($isHomeEditing): ?> + <p style="color:var(--muted);margin:0 0 14px;">This is your store's front page. <strong>Featured products</strong> and <strong>Product list</strong> always stay on it. You can move them up or down, and add anything you like around them. The title is only for you, customers won't see it.</p> + <?php endif; ?> <form method="post" id="page-form"> <?= Csrf::field() ?> ⋮ 7 unchanged lines ⋮ <input type="text" name="title" value="<?= htmlspecialchars($editingPage['title'] ?? '') ?>" required> <label><?= Icons::icon('quote', 'icon icon-sm') ?>Content</label> + <?php if ($isHomeEditing): ?> + <div id="block-editor-root" data-initial-blocks='<?= htmlspecialchars($blocksJson, ENT_QUOTES) ?>' data-home="1"></div> + <?php else: ?> <div id="block-editor-root" data-initial-blocks='<?= htmlspecialchars($blocksJson, ENT_QUOTES) ?>'<?= (SiteFront::settings()['contact_enabled'] ?? '0') === '1' ? ' data-allow-contact="1"' : '' ?>></div> - <label><input type="checkbox" name="show_in_menu" <?= !empty($editingPage['show_in_menu']) ? 'checked' : '' ?>> Show in site menu</label> + <label><input type="checkbox" name="show_in_menu" <?= !empty($editingPage['show_in_menu']) ? 'checked' : '' ?>> Show in the store menu</label> <label><?= Icons::icon('flag', 'icon icon-sm') ?>Status</label> <select name="status"> <option value="draft" <?= ($editingPage['status'] ?? 'draft') === 'draft' ? 'selected' : '' ?>>Draft</option> - <option value="published" <?= ($editingPage['status'] ?? '') === 'published' ? 'selected' : '' ?>>Published</option> + <option value="published" <?= ($editingPage['status'] ?? '') === 'published' ? 'selected' : '' ?>>Live</option> </select> + <?php endif; ?> <div class="publish-actions"> <button type="submit" class="dash-btn dash-btn-primary"><?= Icons::icon('check', 'icon icon-sm') ?>Save page</button> ⋮ 10 unchanged lines ⋮
dashboard-views/product-edit.php
⋮ 20 unchanged lines ⋮ $product = $statement->fetch(); if (!$product) { - echo '<p>Product not found.</p>'; + echo '<p>We couldn\'t find this product. It may have been deleted.</p>'; return; } ⋮ 2 unchanged lines ⋮ $canProofread = Auth::role() === Auth::ROLE_CATALOG_ASSISTANT; if (!$isOwner && !$canEditAny && !$canProofread) { - echo '<p>You do not have permission to view this product.</p>'; + echo '<p>You don\'t have access to this product.</p>'; return; } if ($product['deleted_at'] !== null) { - echo '<div class="dash-flash dash-flash-error">This product is in the trash.</div>'; + echo '<div class="dash-flash dash-flash-error">This product is in the trash. Restore it to make changes.</div>'; if ($isOwner || $canEditAny) { echo '<form method="post">' . Csrf::field() . '<input type="hidden" name="action" value="restore_product">' ⋮ 33 unchanged lines ⋮ <?php if ($product['status'] === 'published' && !empty($product['slug'])): ?> <a href="<?= htmlspecialchars(SiteFront::productUrl($product['slug'])) ?>" class="dash-btn-small" target="_blank" rel="noopener noreferrer"><?= Icons::icon('eye', 'icon icon-sm') ?>View</a> <?php else: ?> - <a href="product.php?preview=<?= (int) $product['id'] ?>" class="dash-btn-small" target="_blank" rel="noopener noreferrer"><?= Icons::icon('eye', 'icon icon-sm') ?>View in preview mode</a> + <a href="product.php?preview=<?= (int) $product['id'] ?>" class="dash-btn-small" target="_blank" rel="noopener noreferrer"><?= Icons::icon('eye', 'icon icon-sm') ?>Preview</a> <?php endif; ?> <?php endif; ?> </div> ⋮ 12 unchanged lines ⋮ <label><?= Icons::icon('heading', 'icon icon-sm') ?>Title</label> <input type="text" name="title" value="<?= htmlspecialchars($product['title'] ?? '') ?>" <?= $readOnlyMeta ? 'readonly' : '' ?> required> - <label><?= Icons::icon('quote', 'icon icon-sm') ?>Short description</label> + <label><?= Icons::icon('quote', 'icon icon-sm') ?>Short summary (shown on product cards)</label> <textarea name="excerpt" rows="2" <?= $readOnlyMeta ? 'readonly' : '' ?>><?= htmlspecialchars($product['excerpt'] ?? '') ?></textarea> <label><?= Icons::icon('grid', 'icon icon-sm') ?>Full description</label> ⋮ 1 unchanged line ⋮ <?php if (!$readOnlyMeta): ?> <div class="sidebar-box"> - <label><?= Icons::icon('layers', 'icon icon-sm') ?>Variants (optional — e.g. size or color, one per row)</label> + <label><?= Icons::icon('layers', 'icon icon-sm') ?>Variants (optional, like sizes or colors, one per row)</label> <table class="dash-table" id="variants-table"> - <thead><tr><th>Name</th><th>SKU</th><th>Price override</th><th>Stock</th><th></th></tr></thead> + <thead><tr><th>Name</th><th>SKU</th><th>Different price</th><th>Stock</th><th></th></tr></thead> <tbody> <?php foreach ($variants as $variant): ?> <tr> ⋮ 36 unchanged lines ⋮ <div class="sidebar-box"> <label>Category</label> <select name="category_id" <?= $readOnlyMeta ? 'disabled' : '' ?>> - <option value="">— none —</option> + <option value="">No category</option> <?php foreach ($categories as $category): ?> <option value="<?= (int) $category['id'] ?>" <?= (int) ($product['category_id'] ?? 0) === (int) $category['id'] ? 'selected' : '' ?>> <?= htmlspecialchars($category['name']) ?> ⋮ 1 unchanged line ⋮ <?php endforeach; ?> </select> - <label>Product type</label> + <label>What kind of product is it?</label> <select name="product_kind" <?= $readOnlyMeta ? 'disabled' : '' ?>> - <option value="physical" <?= ($product['product_kind'] ?? 'physical') === 'physical' ? 'selected' : '' ?>>Physical good</option> - <option value="digital" <?= ($product['product_kind'] ?? '') === 'digital' ? 'selected' : '' ?>>Digital download</option> - <option value="saas_plan" <?= ($product['product_kind'] ?? '') === 'saas_plan' ? 'selected' : '' ?>>SaaS plan</option> + <option value="physical" <?= ($product['product_kind'] ?? 'physical') === 'physical' ? 'selected' : '' ?>>Something I ship</option> + <option value="digital" <?= ($product['product_kind'] ?? '') === 'digital' ? 'selected' : '' ?>>A file to download</option> + <option value="saas_plan" <?= ($product['product_kind'] ?? '') === 'saas_plan' ? 'selected' : '' ?>>A software plan (SaaS)</option> </select> - <label>SKU</label> + <label>SKU (your own product code, optional)</label> <input type="text" name="sku" value="<?= htmlspecialchars((string) ($product['sku'] ?? '')) ?>" <?= $readOnlyMeta ? 'readonly' : '' ?>> </div> ⋮ 7 unchanged lines ⋮ </datalist> <?php if ($pricingModel === 'subscription'): ?> - <label>Billing interval</label> + <label>Customers pay</label> <select name="billing_interval" <?= $readOnlyMeta ? 'disabled' : '' ?>> <option value="month" <?= ($product['billing_interval'] ?? $storeSettings['subscription_interval']) === 'month' ? 'selected' : '' ?>>Monthly</option> <option value="year" <?= ($product['billing_interval'] ?? '') === 'year' ? 'selected' : '' ?>>Yearly</option> - <option value="one_time" <?= ($product['billing_interval'] ?? '') === 'one_time' ? 'selected' : '' ?>>One-time (lifetime access)</option> + <option value="one_time" <?= ($product['billing_interval'] ?? '') === 'one_time' ? 'selected' : '' ?>>Once (keep forever)</option> </select> <label>Price</label> <div style="display:flex;gap:6px;"> ⋮ 6 unchanged lines ⋮ <input type="number" name="price_major" value="<?= (int) $priceMajor ?>" min="0" style="flex:2;" <?= $readOnlyMeta ? 'readonly' : '' ?>> <input type="number" name="price_minor" value="<?= (int) $priceMinor ?>" min="0" style="flex:1;" <?= $readOnlyMeta ? 'readonly' : '' ?>> </div> - <label>Compare-at price (optional, shown crossed out)</label> + <label>Old price (optional, shown crossed out)</label> <div style="display:flex;gap:6px;"> <input type="number" name="compare_price_major" value="<?= $comparePriceMajor !== null ? (int) $comparePriceMajor : '' ?>" min="0" style="flex:2;" <?= $readOnlyMeta ? 'readonly' : '' ?>> <input type="number" name="compare_price_minor" value="<?= (int) $comparePriceMinor ?>" min="0" style="flex:1;" <?= $readOnlyMeta ? 'readonly' : '' ?>> </div> - <label><input type="checkbox" name="track_inventory" <?= (int) ($product['track_inventory'] ?? 0) === 1 ? 'checked' : '' ?> <?= $readOnlyMeta ? 'disabled' : '' ?>> Track inventory</label> - <label>Stock quantity</label> + <label><input type="checkbox" name="track_inventory" <?= (int) ($product['track_inventory'] ?? 0) === 1 ? 'checked' : '' ?> <?= $readOnlyMeta ? 'disabled' : '' ?>> Keep track of stock</label> + <label>How many in stock</label> <input type="number" name="stock_quantity" value="<?= htmlspecialchars((string) ($product['stock_quantity'] ?? '0')) ?>" <?= $readOnlyMeta ? 'readonly' : '' ?>> - <label>Weight (grams, for shipping — optional)</label> + <label>Weight in grams (optional, for shipping)</label> <input type="number" name="weight_grams" value="<?= htmlspecialchars((string) ($product['weight_grams'] ?? '')) ?>" <?= $readOnlyMeta ? 'readonly' : '' ?>> <?php endif; ?> </div> ⋮ 4 unchanged lines ⋮ $downloadLoginRequired = ($storeSettings['require_account_to_purchase'] ?? '0') === '1'; ?> <div class="sidebar-box" id="product-files"> - <label><?= Icons::icon('download', 'icon icon-sm') ?>Downloadable files</label> - <p class="product-files-hint">Customers get these files after paying for the product. If the price is 0, anyone can download them for free<?= $downloadLoginRequired ? ' after logging in (because "Require a customer account to purchase" is on)' : ' without an account' ?>.</p> + <label><?= Icons::icon('download', 'icon icon-sm') ?>Files to download</label> + <p class="product-files-hint">Customers get these files once they pay. If the price is 0, anyone can grab them for free<?= $downloadLoginRequired ? ' after logging in' : ', no account needed' ?>.</p> <?php if (!empty($productFiles) && DigitalFiles::isFree($product, $variants)): ?> - <div class="dash-flash dash-flash-error" style="margin:0 0 10px;"><?= Icons::icon('info', 'icon icon-sm') ?>This product's price is 0, so these files are FREE to download<?= $downloadLoginRequired ? ' for any logged-in customer' : ' for anyone' ?>. Set a price to sell them.</div> + <div class="dash-flash dash-flash-error" style="margin:0 0 10px;"><?= Icons::icon('info', 'icon icon-sm') ?>The price is 0, so<?= $downloadLoginRequired ? ' any logged-in customer' : ' anyone' ?> can download these files for free. Set a price if you want to sell them.</div> <?php endif; ?> <?php if (!empty($productFiles)): ?> <ul class="product-files-list"> ⋮ 1 unchanged line ⋮ <li class="product-files-item"> <div class="product-files-row"> <span class="product-files-name"><?= htmlspecialchars($productFile['original_name']) ?><small><?= htmlspecialchars(DigitalFiles::formatSize((int) $productFile['size_bytes'])) ?></small></span> - <button type="submit" form="product-file-delete-form" name="file_id" value="<?= (int) $productFile['id'] ?>" class="dash-btn-small dash-btn-danger" onclick="return confirm('Delete this file? Customers will no longer be able to download it.');"><?= Icons::icon('trash', 'icon icon-sm') ?></button> + <button type="submit" form="product-file-delete-form" name="file_id" value="<?= (int) $productFile['id'] ?>" class="dash-btn-small dash-btn-danger" onclick="return confirm('Delete this file? Customers won\'t be able to download it anymore.');"><?= Icons::icon('trash', 'icon icon-sm') ?></button> </div> - <input type="text" name="button_label[<?= (int) $productFile['id'] ?>]" form="product-file-label-form" value="<?= htmlspecialchars((string) ($productFile['button_label'] ?? '')) ?>" maxlength="120" placeholder="Button text (optional), e.g. Download theme" aria-label="Button text for <?= htmlspecialchars($productFile['original_name']) ?>"> + <input type="text" name="button_label[<?= (int) $productFile['id'] ?>]" form="product-file-label-form" value="<?= htmlspecialchars((string) ($productFile['button_label'] ?? '')) ?>" maxlength="120" placeholder="Button text (optional), like Download theme" aria-label="Button text for <?= htmlspecialchars($productFile['original_name']) ?>"> </li> <?php endforeach; ?> </ul> - <button type="submit" form="product-file-label-form" class="dash-btn-small"><?= Icons::icon('check', 'icon icon-sm') ?>Save button texts</button> - <p class="product-files-hint">Leave the button text empty to show "Download" and the file name.</p> + <button type="submit" form="product-file-label-form" class="dash-btn-small"><?= Icons::icon('check', 'icon icon-sm') ?>Save button text</button> + <p class="product-files-hint">With no button text, we show "Download" and the file name.</p> <?php endif; ?> <input type="file" name="product_file" form="product-file-upload-form" required> <button type="submit" form="product-file-upload-form" class="dash-btn-small"><?= Icons::icon('upload', 'icon icon-sm') ?>Upload file</button> - <p class="product-files-hint">Max <?= htmlspecialchars((string) ini_get('upload_max_filesize')) ?> per file on this server. Files are stored privately and only served through a protected download link.</p> + <p class="product-files-hint">Max <?= htmlspecialchars((string) ini_get('upload_max_filesize')) ?> per file. Files are kept private and can only be downloaded through a secure link.</p> </div> <?php endif; ?> <div class="sidebar-box"> - <label>Stripe price ID (optional override — leave blank to charge the price above directly)</label> + <label>Stripe price ID (optional, leave empty to charge the price above)</label> <input type="text" name="stripe_price_id" value="<?= htmlspecialchars((string) ($product['stripe_price_id'] ?? '')) ?>" placeholder="price_..." <?= $readOnlyMeta ? 'readonly' : '' ?>> <?php if ($storeCategory === 'saas' && $paymentProvider === 'polar'): ?> <label>Polar.sh product ID</label> <input type="text" name="polar_product_id" value="<?= htmlspecialchars((string) ($product['polar_product_id'] ?? '')) ?>" <?= $readOnlyMeta ? 'readonly' : '' ?>> - <p style="font-size:12px;color:var(--muted);margin:6px 0 0;">Create this plan in your Polar.sh dashboard first, then paste its product ID here.</p> + <p style="font-size:12px;color:var(--muted);margin:6px 0 0;">Create the plan in Polar.sh first, then paste its product ID here.</p> <?php endif; ?> </div> <?php if (ProductTags::isEnabled()): ?> <div class="sidebar-box"> - <label>Tags (comma separated, up to <?= ProductTags::MAX_TAGS ?>)</label> + <label>Tags (separate with commas, up to <?= ProductTags::MAX_TAGS ?>)</label> <input type="text" name="tags" maxlength="700" value="<?= htmlspecialchars($product ? ProductTags::csvFor((int) $product['id']) : '') ?>" placeholder="new-arrival, bestseller" <?= $readOnlyMeta ? 'readonly' : '' ?>> </div> <?php endif; ?> ⋮ 14 unchanged lines ⋮ <?php endif; ?> <div class="sidebar-box"> - <label>Cover image URL</label> + <label>Main photo</label> <input type="text" name="cover_image_path" data-image-upload value="<?= htmlspecialchars($product['cover_image_path'] ?? '') ?>" <?= $readOnlyMeta ? 'readonly' : '' ?>> - <label>SEO title</label> + <label>Title in Google (optional)</label> <input type="text" name="seo_title" value="<?= htmlspecialchars($product['seo_title'] ?? '') ?>" <?= $readOnlyMeta ? 'readonly' : '' ?>> - <label>SEO description</label> + <label>Description in Google (optional)</label> <textarea name="seo_description" rows="2" <?= $readOnlyMeta ? 'readonly' : '' ?>><?= htmlspecialchars($product['seo_description'] ?? '') ?></textarea> </div> <?php if (!$readOnlyMeta): ?> <div class="sidebar-box"> - <label>Schedule for</label> + <label>Publish on</label> <input type="datetime-local" name="scheduled_at" value="<?= htmlspecialchars(!empty($product['scheduled_at']) ? date('Y-m-d\TH:i', strtotime((string) $product['scheduled_at'])) : '') ?>"> <div class="publish-actions"> <button type="submit" name="publish_action" value="save_draft" class="dash-btn"><?= Icons::icon('pages', 'icon icon-sm') ?>Save draft</button> <?php if (!Auth::canPublishDirectly()): ?> - <button type="submit" name="publish_action" value="submit_for_review" class="dash-btn dash-btn-primary"><?= Icons::icon('check', 'icon icon-sm') ?>Submit for review</button> + <button type="submit" name="publish_action" value="submit_for_review" class="dash-btn dash-btn-primary"><?= Icons::icon('check', 'icon icon-sm') ?>Send for approval</button> <?php else: ?> <button type="submit" name="publish_action" value="publish_now" class="dash-btn dash-btn-primary"><?= Icons::icon('check', 'icon icon-sm') ?>Publish now</button> <button type="submit" name="publish_action" value="schedule" class="dash-btn"><?= Icons::icon('stats', 'icon icon-sm') ?>Schedule</button> ⋮ 1 unchanged line ⋮ </div> </div> <?php else: ?> - <button type="submit" class="dash-btn dash-btn-primary"><?= Icons::icon('check', 'icon icon-sm') ?>Save proofreading</button> + <button type="submit" class="dash-btn dash-btn-primary"><?= Icons::icon('check', 'icon icon-sm') ?>Save corrections</button> <?php endif; ?> <?php $productVersions = ($product && !$readOnlyMeta) ? ProductVersions::recent((int) $product['id']) : []; ?> <?php if (!empty($productVersions)): ?> <div class="sidebar-box"> - <label>Versions</label> + <label>Earlier versions</label> <ul style="list-style:none;padding:0;margin:0;display:grid;gap:10px;font-size:13px;"> <?php foreach ($productVersions as $version): ?> <li style="display:flex;justify-content:space-between;align-items:center;gap:8px;"> <span><?= htmlspecialchars($version['created_at']) ?><br><span style="color:var(--muted);"><?= htmlspecialchars((string) $version['saved_by_name']) ?> · <?= htmlspecialchars($version['note']) ?></span></span> - <button type="submit" form="version-restore-form" name="version_id" value="<?= (int) $version['id'] ?>" class="dash-btn-small" onclick="return confirm('Restore this version? The current text is saved as a version first.');"><?= Icons::icon('refresh', 'icon icon-sm') ?>Restore</button> + <button type="submit" form="version-restore-form" name="version_id" value="<?= (int) $version['id'] ?>" class="dash-btn-small" onclick="return confirm('Go back to this version? Don\'t worry, we\'ll keep the current one too.');"><?= Icons::icon('refresh', 'icon icon-sm') ?>Restore</button> </li> <?php endforeach; ?> </ul> ⋮ 1 unchanged line ⋮ <?php endif; ?> <?php if ($product && $product['status'] === 'rejected' && !empty($product['rejection_reason'])): ?> - <div class="dash-flash dash-flash-error">Rejected: <?= htmlspecialchars($product['rejection_reason']) ?></div> + <div class="dash-flash dash-flash-error">Sent back with a note: <?= htmlspecialchars($product['rejection_reason']) ?></div> <?php endif; ?> </div> ⋮ 29 unchanged lines ⋮
dashboard-views/products.php
⋮ 13 unchanged lines ⋮ $isAdmin = Auth::isAdministrative(); $statement = $isAdmin - ? $db->query('SELECT p.*, t.display_name, c.name AS category_name FROM products p JOIN team_accounts t ON t.id = p.created_by LEFT JOIN categories c ON c.id = p.category_id WHERE p.deleted_at IS NULL ORDER BY p.updated_at DESC') - : $db->prepare('SELECT p.*, t.display_name, c.name AS category_name FROM products p JOIN team_accounts t ON t.id = p.created_by LEFT JOIN categories c ON c.id = p.category_id WHERE p.created_by = :id AND p.deleted_at IS NULL ORDER BY p.updated_at DESC'); + ? $db->query('SELECT p.*, t.display_name, c.name AS category_name FROM products p LEFT JOIN team_accounts t ON t.id = p.created_by LEFT JOIN categories c ON c.id = p.category_id WHERE p.deleted_at IS NULL ORDER BY p.updated_at DESC') + : $db->prepare('SELECT p.*, t.display_name, c.name AS category_name FROM products p LEFT JOIN team_accounts t ON t.id = p.created_by LEFT JOIN categories c ON c.id = p.category_id WHERE p.created_by = :id AND p.deleted_at IS NULL ORDER BY p.updated_at DESC'); if (!$isAdmin) { $statement->execute(['id' => $currentUser['id']]); ⋮ 32 unchanged lines ⋮ <?php foreach ($products as $product): ?> <tr> <td><a href="dashboard.php?view=product-edit&id=<?= (int) $product['id'] ?>"><?= htmlspecialchars($product['title']) ?></a></td> - <td><?= htmlspecialchars($product['category_name'] ?? '—') ?></td> + <td><?= htmlspecialchars($product['category_name'] ?? 'No category') ?></td> <td> <?php if (!empty($product['price_cents'])): ?> <?= htmlspecialchars(Currency::format((int) $product['price_cents'], (string) ($product['currency'] ?: $storeCurrency))) ?><?= $product['billing_interval'] && $product['billing_interval'] !== 'one_time' ? '/' . htmlspecialchars((string) $product['billing_interval']) : '' ?> <?php else: ?> - — + Free <?php endif; ?> </td> - <td><span class="status-pill status-<?= htmlspecialchars($product['status']) ?>"><?= htmlspecialchars($product['status']) ?></span></td> + <td><span class="status-pill status-<?= htmlspecialchars($product['status']) ?>"><?= htmlspecialchars(StatusLabel::get((string) $product['status'])) ?></span></td> <td><?= htmlspecialchars($product['updated_at']) ?></td> <td class="dash-table-actions"> ⋮ 5 unchanged lines ⋮ <input type="hidden" name="decision" value="approve"> <button type="submit" class="dash-btn-small dash-btn-success"><?= Icons::icon('check', 'icon icon-sm') ?>Approve</button> </form> - <button type="button" class="dash-btn-small dash-btn-danger js-reject-btn" data-product-id="<?= (int) $product['id'] ?>"><?= Icons::icon('x', 'icon icon-sm') ?>Reject</button> + <button type="button" class="dash-btn-small dash-btn-danger js-reject-btn" data-product-id="<?= (int) $product['id'] ?>"><?= Icons::icon('x', 'icon icon-sm') ?>Send back</button> <?php endif; ?> - <form method="post" style="display:inline;" onsubmit="return confirm('Move this product to the trash?');"> + <form method="post" style="display:inline;" onsubmit="return confirm('Move this product to the trash? You can bring it back later.');"> <?= Csrf::field() ?> <input type="hidden" name="action" value="delete_product"> <input type="hidden" name="product_id" value="<?= (int) $product['id'] ?>"> ⋮ 2 unchanged lines ⋮ <?php if ($product['status'] === 'published' && !empty($product['slug'])): ?> <a href="<?= htmlspecialchars(SiteFront::productUrl($product['slug'])) ?>" class="dash-btn-small" target="_blank" rel="noopener noreferrer"><?= Icons::icon('eye', 'icon icon-sm') ?>View</a> <?php else: ?> - <a href="product.php?preview=<?= (int) $product['id'] ?>" class="dash-btn-small" target="_blank" rel="noopener noreferrer"><?= Icons::icon('eye', 'icon icon-sm') ?>View in preview mode</a> + <a href="product.php?preview=<?= (int) $product['id'] ?>" class="dash-btn-small" target="_blank" rel="noopener noreferrer"><?= Icons::icon('eye', 'icon icon-sm') ?>Preview</a> <?php endif; ?> </td> </tr> ⋮ 6 unchanged lines ⋮ <input type="hidden" name="action" value="moderate_product"> <input type="hidden" name="product_id" value="<?= (int) $product['id'] ?>"> <input type="hidden" name="decision" value="reject"> - <input type="text" name="rejection_reason" placeholder="Rejection reason" class="dash-input-inline"> - <button type="submit" class="dash-btn-small dash-btn-danger"><?= Icons::icon('check', 'icon icon-sm') ?>Confirm reject</button> + <input type="text" name="rejection_reason" placeholder="What should be fixed?" class="dash-input-inline"> + <button type="submit" class="dash-btn-small dash-btn-danger"><?= Icons::icon('check', 'icon icon-sm') ?>Send back</button> </form> </td> </tr> ⋮ 1 unchanged line ⋮ <?php endforeach; ?> <?php if (empty($products)): ?> - <tr><td colspan="6">No products found.</td></tr> + <tr><td colspan="6">No products yet. Click "New product" to add your first one.</td></tr> <?php endif; ?> </tbody> </table> ⋮ 1 unchanged line ⋮
dashboard-views/reviews.php
⋮ 7 unchanged lines ⋮ } if (!Auth::hasRoleAtLeast(Auth::ROLE_STORE_MANAGER)) { - echo '<p>You do not have permission to moderate reviews.</p>'; + echo '<p>You don\'t have access to reviews.</p>'; return; } ⋮ 26 unchanged lines ⋮ </div> <div class="settings-tabs"> - <a href="dashboard.php?view=reviews&status=flagged" class="<?= $statusFilter === 'flagged' ? 'active' : '' ?>">Flagged</a> + <a href="dashboard.php?view=reviews&status=flagged" class="<?= $statusFilter === 'flagged' ? 'active' : '' ?>">Reported</a> <a href="dashboard.php?view=reviews&status=visible" class="<?= $statusFilter === 'visible' ? 'active' : '' ?>">Visible</a> <a href="dashboard.php?view=reviews&status=hidden" class="<?= $statusFilter === 'hidden' ? 'active' : '' ?>">Hidden</a> </div> ⋮ 15 unchanged lines ⋮ <td><?= htmlspecialchars($productTitles[(int) $review['product_id']] ?? ('#' . (int) $review['product_id'])) ?></td> <td><?= htmlspecialchars((string) ($review['display_name'] ?? $review['email'] ?? 'Anonymous')) ?></td> <td><?= $review['rating'] ? str_repeat('� - ', (int) $review['rating']) . str_repeat('☆', 5 - (int) $review['rating']) : '—' ?></td> + ', (int) $review['rating']) . str_repeat('☆', 5 - (int) $review['rating']) : 'No rating' ?></td> <td><?= htmlspecialchars(mb_strimwidth((string) $review['body'], 0, 140, '…')) ?></td> <td><?= htmlspecialchars($review['created_at']) ?></td> <td class="dash-table-actions"> ⋮ 19 unchanged lines ⋮ </tr> <?php endforeach; ?> <?php if (empty($reviews)): ?> - <tr><td colspan="6">No reviews here.</td></tr> + <tr><td colspan="6">Nothing here right now.</td></tr> <?php endif; ?> </tbody> </table> ⋮ 1 unchanged line ⋮
dashboard-views/security.php
⋮ 17 unchanged lines ⋮ <?php if (is_array($newCodes) && !empty($newCodes)): ?> <div class="sidebar-box" style="max-width:640px;margin-bottom:20px;"> - <h2 class="dash-subtitle">Recovery codes</h2> - <p>Save these codes in a safe place. Each one works once if you lose access to your authenticator app. They will not be shown again.</p> + <h2 class="dash-subtitle">Backup codes</h2> + <p>Keep these somewhere safe. If you lose your phone, each code gets you in once. You won't see them again.</p> <pre style="font-size:16px;line-height:1.8;"><?= htmlspecialchars(implode("\n", $newCodes)) ?></pre> </div> <?php endif; ?> <div class="sidebar-box" style="max-width:640px;"> - <h2 class="dash-subtitle">Two-factor authentication</h2> + <h2 class="dash-subtitle">Two-step login</h2> <?php if ($twoFactorEnabled): ?> - <p>Status: <span class="status-pill status-published">Enabled</span> · recovery codes left: <?= TwoFactor::remainingRecoveryCodes($currentUser) ?></p> + <p>Status: <span class="status-pill status-published">On</span> · backup codes left: <?= TwoFactor::remainingRecoveryCodes($currentUser) ?></p> <form method="post" style="margin-top:16px;"> <?= Csrf::field() ?> <input type="hidden" name="action" value="totp_regenerate"> <label>Password</label> <input type="password" name="password" required autocomplete="current-password"> - <label>Authentication code or a recovery code</label> + <label>Code from your app, or a backup code</label> <input type="text" name="code" required autocomplete="one-time-code" maxlength="16" spellcheck="false"> - <button type="submit" class="dash-btn" style="margin-top:12px;"><?= Icons::icon('refresh', 'icon icon-sm') ?>Generate new recovery codes</button> + <button type="submit" class="dash-btn" style="margin-top:12px;"><?= Icons::icon('refresh', 'icon icon-sm') ?>Get new backup codes</button> </form> - <form method="post" style="margin-top:24px;" onsubmit="return confirm('Turn off two-factor authentication?');"> + <form method="post" style="margin-top:24px;" onsubmit="return confirm('Turn off two-step login? Your account will only be protected by your password.');"> <?= Csrf::field() ?> <input type="hidden" name="action" value="totp_disable"> <label>Password</label> <input type="password" name="password" required autocomplete="current-password"> - <label>Authentication code or a recovery code</label> + <label>Code from your app, or a backup code</label> <input type="text" name="code" required autocomplete="one-time-code" maxlength="16" spellcheck="false"> - <button type="submit" class="dash-btn dash-btn-danger" style="margin-top:12px;"><?= Icons::icon('x', 'icon icon-sm') ?>Turn off two-factor authentication</button> + <button type="submit" class="dash-btn dash-btn-danger" style="margin-top:12px;"><?= Icons::icon('x', 'icon icon-sm') ?>Turn off two-step login</button> </form> <?php elseif ($setupSecret !== ''): ?> - <p>1. In your authenticator app (Google Authenticator, Authy, 1Password, Aegis…) scan this QR code. The code is drawn in your browser and the key never leaves this page.</p> + <p>1. Open an authenticator app (Google Authenticator, Authy, 1Password, Aegis and so on) and scan this QR code.</p> <div id="totp-qr" data-uri="<?= htmlspecialchars(Totp::uri($setupSecret, (string) $currentUser['username'], $issuer), ENT_QUOTES) ?>" style="width:220px;max-width:100%;margin:12px 0;border-radius:8px;overflow:hidden;background:#fff;"></div> - <p style="font-size:13px;color:var(--muted);">Cannot scan it? Add the account manually (time based) with this key:</p> + <p style="font-size:13px;color:var(--muted);">Can't scan it? Type this key into the app instead:</p> <p style="font-size:20px;letter-spacing:.08em;margin:8px 0;"><code><?= htmlspecialchars(Totp::formatSecret($setupSecret)) ?></code></p> - <p style="font-size:12px;color:var(--muted);word-break:break-all;">Account: <?= htmlspecialchars((string) $currentUser['username']) ?> · Issuer: <?= htmlspecialchars($issuer) ?></p> + <p style="font-size:12px;color:var(--muted);word-break:break-all;">Account: <?= htmlspecialchars((string) $currentUser['username']) ?> · Store: <?= htmlspecialchars($issuer) ?></p> <?= Asset::js('assets/vendor/qrcode.js') ?> <script> (function () { ⋮ 6 unchanged lines ⋮ box.innerHTML = code.createSvgTag({ cellSize: 4, margin: 16, scalable: true }); })(); </script> - <p style="margin-top:14px;">2. Enter the 6-digit code shown by the app to finish.</p> + <p style="margin-top:14px;">2. Type the 6-digit code from the app to finish.</p> <form method="post"> <?= Csrf::field() ?> <input type="hidden" name="action" value="totp_confirm"> - <label>Authentication code</label> + <label>6-digit code</label> <input type="text" name="code" required inputmode="numeric" autocomplete="one-time-code" maxlength="8" pattern="[0-9 ]*" spellcheck="false"> <div class="publish-actions"> <button type="submit" class="dash-btn dash-btn-primary"><?= Icons::icon('check', 'icon icon-sm') ?>Turn on</button> ⋮ 8 unchanged lines ⋮ <?php else: ?> <p>Status: <span class="status-pill status-draft">Off</span></p> - <p style="margin:10px 0 14px;">Add a second step to your login: a 6-digit code from an authenticator app, in addition to your password.</p> + <p style="margin:10px 0 14px;">Make your account harder to break into. After your password, you'll also type a 6-digit code from your phone.</p> <form method="post"> <?= Csrf::field() ?> <input type="hidden" name="action" value="totp_begin"> ⋮ 5 unchanged lines ⋮
dashboard-views/stats.php
⋮ 54 unchanged lines ⋮ <h1 class="dash-title"><?= Icons::icon("stats", "icon icon-lg") ?>Statistics</h1> <div class="dash-cards"> - <div class="dash-card"><?= Icons::icon('eye', 'icon icon-lg') ?><div class="dash-card-value"><?= number_format($totalViews) ?></div><div class="dash-card-label">Total views</div></div> - <div class="dash-card"><?= Icons::icon('cart', 'icon icon-lg') ?><div class="dash-card-value"><?= number_format($totalSales) ?></div><div class="dash-card-label">Total sales</div></div> - <div class="dash-card"><?= Icons::icon('download', 'icon icon-lg') ?><div class="dash-card-value"><?= number_format($totalDownloads) ?></div><div class="dash-card-label">Total downloads</div></div> + <div class="dash-card"><?= Icons::icon('eye', 'icon icon-lg') ?><div class="dash-card-value"><?= number_format($totalViews) ?></div><div class="dash-card-label">Product views</div></div> + <div class="dash-card"><?= Icons::icon('cart', 'icon icon-lg') ?><div class="dash-card-value"><?= number_format($totalSales) ?></div><div class="dash-card-label">Items sold</div></div> + <div class="dash-card"><?= Icons::icon('download', 'icon icon-lg') ?><div class="dash-card-value"><?= number_format($totalDownloads) ?></div><div class="dash-card-label">Downloads</div></div> </div> <?php if ($isManager && !empty($revenueByDay)): ?> - <h2 class="dash-subtitle"><?= Icons::icon('dollar', 'icon icon-sm') ?>Revenue (last 30 days)</h2> + <h2 class="dash-subtitle"><?= Icons::icon('dollar', 'icon icon-sm') ?>Sales in the last 30 days</h2> <table class="dash-table"> <thead><tr><th><?= Icons::icon('clock', 'icon icon-sm') ?>Day</th><th><?= Icons::icon('dollar', 'icon icon-sm') ?>Revenue</th></tr></thead> <tbody> ⋮ 7 unchanged lines ⋮ </table> <?php endif; ?> - <h2 class="dash-subtitle"><?= Icons::icon('star', 'icon icon-sm') ?>Top products</h2> + <h2 class="dash-subtitle"><?= Icons::icon('star', 'icon icon-sm') ?>Best sellers</h2> <table class="dash-table"> <thead><tr><th><?= Icons::icon('heading', 'icon icon-sm') ?>Title</th><th><?= Icons::icon('eye', 'icon icon-sm') ?>Views</th><th><?= Icons::icon('cart', 'icon icon-sm') ?>Sales</th><th><?= Icons::icon('download', 'icon icon-sm') ?>Downloads</th><th><?= Icons::icon('flag', 'icon icon-sm') ?>Status</th></tr></thead> <tbody> ⋮ 3 unchanged lines ⋮ <td><?= number_format((int) $product['views_count']) ?></td> <td><?= number_format((int) $product['sales_count']) ?></td> <td><?= number_format((int) ($product['downloads_count'] ?? 0)) ?></td> - <td><span class="status-pill status-<?= htmlspecialchars($product['status']) ?>"><?= htmlspecialchars($product['status']) ?></span></td> + <td><span class="status-pill status-<?= htmlspecialchars($product['status']) ?>"><?= htmlspecialchars(StatusLabel::get((string) $product['status'])) ?></span></td> </tr> <?php endforeach; ?> <?php if (empty($topProducts)): ?> - <tr><td colspan="5">No data yet.</td></tr> + <tr><td colspan="5">Nothing to show yet. Check back after your first sales.</td></tr> <?php endif; ?> </tbody> </table> ⋮ 1 unchanged line ⋮
dashboard-views/trash.php
⋮ 10 unchanged lines ⋮ $isAdmin = Auth::hasRoleAtLeast(Auth::ROLE_STORE_OWNER); $statement = $isAdmin - ? $db->query('SELECT p.id, p.title, p.trashed_status, p.deleted_at, t.display_name FROM products p JOIN team_accounts t ON t.id = p.created_by WHERE p.deleted_at IS NOT NULL ORDER BY p.deleted_at DESC') - : $db->prepare('SELECT p.id, p.title, p.trashed_status, p.deleted_at, t.display_name FROM products p JOIN team_accounts t ON t.id = p.created_by WHERE p.deleted_at IS NOT NULL AND p.created_by = :id ORDER BY p.deleted_at DESC'); + ? $db->query('SELECT p.id, p.title, p.trashed_status, p.deleted_at, t.display_name FROM products p LEFT JOIN team_accounts t ON t.id = p.created_by WHERE p.deleted_at IS NOT NULL ORDER BY p.deleted_at DESC') + : $db->prepare('SELECT p.id, p.title, p.trashed_status, p.deleted_at, t.display_name FROM products p LEFT JOIN team_accounts t ON t.id = p.created_by WHERE p.deleted_at IS NOT NULL AND p.created_by = :id ORDER BY p.deleted_at DESC'); if (!$isAdmin) { $statement->execute(['id' => $currentUser['id']]); ⋮ 24 unchanged lines ⋮ . '<input type="hidden" name="action" value="' . htmlspecialchars($action) . '">' . ($productId !== null ? '<input type="hidden" name="product_id" value="' . $productId . '">' : '') . '<h2 class="dash-dialog-title">' . Icons::icon('download', 'icon icon-sm') . htmlspecialchars($question) . '</h2>' - . '<p class="dash-dialog-text">This will permanently delete the product' . ($productId === null ? 's' : '') . '. ' - . 'Attached downloadable files: ' . $fileCount . '.</p>' - . '<p class="dash-dialog-text"><strong>Do you also want to remove access to the files?</strong></p>' + . '<p class="dash-dialog-text">' . ($productId === null ? 'These products will be gone for good. ' : 'This product will be gone for good. ') . 'Files attached: ' . $fileCount . '.</p>' + . '<p class="dash-dialog-text"><strong>What should happen to the files?</strong></p>' . '<ul class="dash-dialog-list">' - . '<li><strong>Keep files</strong> — customers who already bought the product can still download them. The files stay under Files.</li>' - . '<li><strong>Delete files</strong> — the files are erased and every customer loses access. This cannot be undone.</li>' + . '<li><strong>Keep the files</strong> if people who already bought it should still be able to download. They stay under Files.</li>' + . '<li><strong>Delete the files</strong> to erase them for everyone, including past buyers. This can\'t be undone.</li>' . '</ul>' . '<div class="dash-dialog-actions">' . '<button type="button" class="dash-btn" onclick="this.closest(\'dialog\').close();">Cancel</button>' - . '<button type="submit" name="file_action" value="keep" class="dash-btn dash-btn-primary">Delete product, keep files</button>' - . '<button type="submit" name="file_action" value="delete" class="dash-btn dash-btn-danger">Delete product and files</button>' + . '<button type="submit" name="file_action" value="keep" class="dash-btn dash-btn-primary">Delete, keep the files</button>' + . '<button type="submit" name="file_action" value="delete" class="dash-btn dash-btn-danger">Delete everything</button>' . '</div>' . '</form>' . '</dialog>'; ⋮ 8 unchanged lines ⋮ <button type="button" class="dash-btn dash-btn-danger" onclick="document.getElementById('trash-dialog-all').showModal();"><?= Icons::icon('trash', 'icon icon-sm') ?>Empty trash</button> <?= $renderFileDialog('trash-dialog-all', 'empty_trash', null, 'Empty the trash?', array_sum($trashedFileCounts)) ?> <?php else: ?> - <form method="post" style="display:inline;" onsubmit="return confirm('Permanently delete everything in the trash? This cannot be undone.');"> + <form method="post" style="display:inline;" onsubmit="return confirm('Empty the trash? Everything in it will be gone for good.');"> <?= Csrf::field() ?> <input type="hidden" name="action" value="empty_trash"> <button type="submit" class="dash-btn dash-btn-danger"><?= Icons::icon('trash', 'icon icon-sm') ?>Empty trash</button> ⋮ 6 unchanged lines ⋮ <thead> <tr> <th><?= Icons::icon('heading', 'icon icon-sm') ?>Title</th> - <th><?= Icons::icon('user', 'icon icon-sm') ?>Created by</th> + <th><?= Icons::icon('user', 'icon icon-sm') ?>Added by</th> <th><?= Icons::icon('flag', 'icon icon-sm') ?>Was</th> <th><?= Icons::icon('clock', 'icon icon-sm') ?>Deleted</th> <th></th> ⋮ 3 unchanged lines ⋮ <?php foreach ($trashed as $product): ?> <tr> <td><?= htmlspecialchars($product['title']) ?><?php if (($trashedFileCounts[(int) $product['id']] ?? 0) > 0): ?> <span class="status-pill status-scheduled"><?= (int) $trashedFileCounts[(int) $product['id']] ?> file<?= $trashedFileCounts[(int) $product['id']] === 1 ? '' : 's' ?></span><?php endif; ?></td> - <td><?= htmlspecialchars($product['display_name']) ?></td> + <td><?= htmlspecialchars((string) ($product['display_name'] ?? 'System')) ?></td> <td><?= htmlspecialchars((string) ($product['trashed_status'] ?? 'draft')) ?></td> <td><?= htmlspecialchars($product['deleted_at']) ?></td> <td class="dash-table-actions"> ⋮ 8 unchanged lines ⋮ <button type="button" class="dash-btn-small dash-btn-danger" onclick="document.getElementById('trash-dialog-<?= (int) $product['id'] ?>').showModal();"><?= Icons::icon('trash', 'icon icon-sm') ?>Delete forever</button> <?= $renderFileDialog('trash-dialog-' . (int) $product['id'], 'purge_product', (int) $product['id'], 'Delete "' . $product['title'] . '" forever?', $productFileCount) ?> <?php else: ?> - <form method="post" style="display:inline;" onsubmit="return confirm('Permanently delete this product? This cannot be undone.');"> + <form method="post" style="display:inline;" onsubmit="return confirm('Delete this product for good? This can\'t be undone.');"> <?= Csrf::field() ?> <input type="hidden" name="action" value="purge_product"> <input type="hidden" name="product_id" value="<?= (int) $product['id'] ?>"> ⋮ 4 unchanged lines ⋮ </tr> <?php endforeach; ?> <?php if (empty($trashed)): ?> - <tr><td colspan="5">The trash is empty.</td></tr> + <tr><td colspan="5">The trash is empty. Nice and tidy.</td></tr> <?php endif; ?> </tbody> </table> ⋮ 1 unchanged line ⋮
dashboard.php
⋮ 32 unchanged lines ⋮ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (empty($_POST) && (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) { - $flashMessage = 'The upload is larger than this server allows (post_max_size = ' . ini_get('post_max_size') . ', upload_max_filesize = ' . ini_get('upload_max_filesize') . ').'; + $flashMessage = 'That file is too big for your server. The limit is ' . ini_get('upload_max_filesize') . ', ask your host if you need more.'; $flashType = 'error'; } elseif (!Csrf::verify($_POST['csrf_token'] ?? null)) { - $flashMessage = 'Security check failed, please try again.'; + $flashMessage = 'Your session expired. Please try again.'; $flashType = 'error'; } else { $action = $_POST['action'] ?? ''; ⋮ 164 unchanged lines ⋮ <?php endif; ?> <?php if (isset($_GET['saved'])): ?> - <div class="dash-flash dash-flash-success"><?= Icons::icon('check', 'icon icon-sm') ?>Saved successfully.</div> + <div class="dash-flash dash-flash-success"><?= Icons::icon('check', 'icon icon-sm') ?>Changes saved.</div> <?php endif; ?> <?php if (isset($_GET['deleted'])): ?> - <div class="dash-flash dash-flash-success"><?= Icons::icon('check', 'icon icon-sm') ?>Deleted successfully.</div> + <div class="dash-flash dash-flash-success"><?= Icons::icon('check', 'icon icon-sm') ?>Deleted.</div> <?php endif; ?> <?php if (isset($_GET['moderated'])): ?> - <div class="dash-flash dash-flash-success"><?= Icons::icon('check', 'icon icon-sm') ?>Moderation action applied.</div> + <div class="dash-flash dash-flash-success"><?= Icons::icon('check', 'icon icon-sm') ?>Status updated.</div> <?php endif; ?> <?php ⋮ 48 unchanged lines ⋮
files.php
⋮ 34 unchanged lines ⋮ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (empty($_POST) && (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) { - $flashMessage = 'The upload is larger than this server allows (post_max_size = ' . ini_get('post_max_size') . ', upload_max_filesize = ' . ini_get('upload_max_filesize') . ').'; + $flashMessage = 'That file is too big for your server. The limit is ' . ini_get('upload_max_filesize') . ', ask your host if you need more.'; $flashType = 'error'; } elseif (!Csrf::verify($_POST['csrf_token'] ?? null)) { - $flashMessage = 'Security check failed, please try again.'; + $flashMessage = 'Your session expired. Please try again.'; $flashType = 'error'; } else { $action = (string) ($_POST['action'] ?? ''); ⋮ 3 unchanged lines ⋮ if ($action === 'upload_file') { $targetProduct = $findLiveProduct((int) ($_POST['product_id'] ?? 0)); if ($targetProduct === null) { - $flashMessage = 'Choose a product for the file.'; + $flashMessage = 'Pick which product this file belongs to.'; $flashType = 'error'; } else { [$stored, $flashMessage] = DigitalFiles::store((int) $targetProduct['id'], $_FILES['product_file'] ?? null); ⋮ 8 unchanged lines ⋮ } elseif ($action === 'rename_file') { $newName = trim((string) ($_POST['name'] ?? '')); if ($newName === '') { - $flashMessage = 'Enter a file name.'; + $flashMessage = 'Please give the file a name.'; $flashType = 'error'; } else { DigitalFiles::rename($fileId, $newName); DigitalFiles::setButtonLabel($fileId, (string) ($_POST['button_label'] ?? '')); ActivityLog::record('product.file_rename', 'product', (int) $file['product_id'], mb_substr($newName, 0, 120)); - $flashMessage = 'File updated.'; + $flashMessage = 'Changes saved.'; } } elseif ($action === 'move_file') { $targetProduct = $findLiveProduct((int) ($_POST['product_id'] ?? 0)); if ($targetProduct === null) { - $flashMessage = 'Choose a product to move the file to.'; + $flashMessage = 'Pick the product to move the file to.'; $flashType = 'error'; } elseif ((int) $targetProduct['id'] === (int) $file['product_id'] && ($file['detached_at'] ?? null) === null) { - $flashMessage = 'The file is already attached to that product.'; + $flashMessage = 'The file already belongs to that product.'; $flashType = 'error'; } else { DigitalFiles::move($fileId, (int) $targetProduct['id']); ⋮ 3 unchanged lines ⋮ } elseif ($action === 'delete_file') { DigitalFiles::delete($fileId); ActivityLog::record('product.file_delete', 'product', (int) $file['product_id'], mb_substr((string) $file['original_name'], 0, 120)); - $flashMessage = 'File deleted. Customers can no longer download it.'; + $flashMessage = 'File deleted. Nobody can download it anymore.'; } if ($flashType === 'success' && $flashMessage !== null) { ⋮ 37 unchanged lines ⋮ <div class="dash-card"> <?= Icons::icon('layers') ?> <div class="dash-card-value"><?= count($files) ?></div> - <div class="dash-card-label"><?= $filter === 'all' && $search === '' ? 'Files' : 'Files shown' ?></div> + <div class="dash-card-label"><?= $filter === 'all' && $search === '' ? 'Files' : 'Matching files' ?></div> </div> <div class="dash-card"> <?= Icons::icon('download') ?> ⋮ 7 unchanged lines ⋮ </div> <div class="dash-card"> <?= Icons::icon('shield') ?> - <div class="dash-card-value" style="font-size:14px;"><?= $storage['path'] === null ? 'Not writable' : ($storage['outside_web_root'] ? 'Outside web root' : 'private-downloads/') ?></div> + <div class="dash-card-value" style="font-size:14px;"><?= $storage['path'] === null ? 'Not set up' : ($storage['outside_web_root'] ? 'Safe, outside your site' : 'Private folder') ?></div> <div class="dash-card-label">Storage<?= $storage['free_bytes'] !== null ? ' · ' . htmlspecialchars(DigitalFiles::formatSize((int) $storage['free_bytes'])) . ' free' : '' ?></div> </div> </div> <?php if ($storage['path'] === null): ?> - <div class="dash-flash dash-flash-error"><?= Icons::icon('info', 'icon icon-sm') ?>No writable folder for downloads. Create "stocketbase-downloads" next to the site folder (recommended) or "private-downloads" inside it, and make it writable by PHP.</div> + <div class="dash-flash dash-flash-error"><?= Icons::icon('info', 'icon icon-sm') ?>There's nowhere to keep files yet. Create a folder called "stocketbase-downloads" next to your site folder (best) or "private-downloads" inside it, and let the site write to it. Your host can help with this.</div> <?php elseif (!$storage['outside_web_root']): ?> - <p class="updater-note"><?= Icons::icon('info', 'icon icon-sm') ?>Files are stored in private-downloads/ inside the site folder, protected by .htaccess. On Nginx, block web access to this folder, or create "stocketbase-downloads" next to the site folder so files are kept outside the web root.</p> + <p class="updater-note"><?= Icons::icon('info', 'icon icon-sm') ?>Files are kept in a private folder inside your site. On most hosts that's safe as it is. If your server uses Nginx, create a "stocketbase-downloads" folder next to your site folder instead so nobody can open the files directly.</p> <?php endif; ?> <h2 class="dash-subtitle"><?= Icons::icon('upload', 'icon icon-sm') ?>Upload a file</h2> ⋮ 3 unchanged lines ⋮ <div> <label>Product</label> <select name="product_id" required> - <option value="">— choose a product —</option> + <option value="">Pick a product</option> <?php foreach ($liveProducts as $liveProduct): ?> <option value="<?= (int) $liveProduct['id'] ?>"><?= htmlspecialchars($liveProduct['title']) ?></option> <?php endforeach; ?> ⋮ 17 unchanged lines ⋮ <select name="filter" onchange="this.form.submit()"> <option value="all" <?= $filter === 'all' ? 'selected' : '' ?>>All files</option> <option value="attached" <?= $filter === 'attached' ? 'selected' : '' ?>>Attached to a product</option> - <option value="detached" <?= $filter === 'detached' ? 'selected' : '' ?>>From deleted products</option> + <option value="detached" <?= $filter === 'detached' ? 'selected' : '' ?>>Left over from deleted products</option> </select> </div> <button type="submit" class="dash-btn"><?= Icons::icon('search', 'icon icon-sm') ?>Filter</button> ⋮ 4 unchanged lines ⋮ <tr> <th>File</th> <th>Product</th> - <th>Access</th> + <th>Who can download</th> <th>Downloads</th> <th>Uploaded</th> <th></th> ⋮ 17 unchanged lines ⋮ <input type="hidden" name="action" value="rename_file"> <input type="hidden" name="file_id" value="<?= (int) $file['id'] ?>"> <input type="text" name="name" value="<?= htmlspecialchars($file['original_name']) ?>" maxlength="180" aria-label="File name" title="File name"> - <input type="text" name="button_label" value="<?= htmlspecialchars((string) ($file['button_label'] ?? '')) ?>" maxlength="120" placeholder="Button text (optional)" aria-label="Button text" title="Button text shown to customers instead of the file name"> + <input type="text" name="button_label" value="<?= htmlspecialchars((string) ($file['button_label'] ?? '')) ?>" maxlength="120" placeholder="Button text (optional)" aria-label="Button text" title="Customers see this on the button instead of the file name"> <button type="submit" class="dash-btn-small"><?= Icons::icon('edit', 'icon icon-sm') ?>Save</button> </form> </td> ⋮ 10 unchanged lines ⋮ <?php if ($isFreeFile): ?> <span class="status-pill status-published">Free</span> <?php elseif ($isDetached): ?> - <span class="status-pill status-scheduled">Past buyers only</span> + <span class="status-pill status-scheduled">Only past buyers</span> <?php else: ?> - <span class="status-pill status-paid">Buyers</span> + <span class="status-pill status-paid">Buyers only</span> <?php endif; ?> </td> <td> <?= number_format((int) ($file['download_count'] ?? 0)) ?> <?php if ($counts !== null): ?> - <br><small style="color:var(--muted);"><?= (int) $counts['links'] ?> email link<?= (int) $counts['links'] === 1 ? '' : 's' ?> issued</small> + <br><small style="color:var(--muted);"><?= (int) $counts['links'] ?> email link<?= (int) $counts['links'] === 1 ? '' : 's' ?> sent</small> <?php endif; ?> </td> <td><?= htmlspecialchars(substr((string) $file['created_at'], 0, 16)) ?></td> <td class="dash-table-actions"> <a href="files.php?download=<?= (int) $file['id'] ?>" class="dash-btn-small"><?= Icons::icon('download', 'icon icon-sm') ?>Download</a> - <form method="post" class="files-inline-form" onsubmit="return confirm('Move this file? Customers who bought the current product will lose access to it, and buyers of the new product will get access.');"> + <form method="post" class="files-inline-form" onsubmit="return confirm('Move this file? People who bought the current product lose access, and buyers of the new one get it.');"> <?= Csrf::field() ?> <input type="hidden" name="action" value="move_file"> <input type="hidden" name="file_id" value="<?= (int) $file['id'] ?>"> ⋮ 5 unchanged lines ⋮ </select> <button type="submit" class="dash-btn-small">Move</button> </form> - <form method="post" style="display:inline;" onsubmit="return confirm('Delete this file permanently? Every customer loses access to it. This cannot be undone.');"> + <form method="post" style="display:inline;" onsubmit="return confirm('Delete this file for good? Nobody will be able to download it anymore.');"> <?= Csrf::field() ?> <input type="hidden" name="action" value="delete_file"> <input type="hidden" name="file_id" value="<?= (int) $file['id'] ?>"> ⋮ 3 unchanged lines ⋮ </tr> <?php endforeach; ?> <?php if (empty($files)): ?> - <tr><td colspan="6"><?= $filter === 'all' && $search === '' ? 'No files yet. Upload one above or from a product\'s edit page.' : 'No files match your filter.' ?></td></tr> + <tr><td colspan="6"><?= $filter === 'all' && $search === '' ? 'No files yet. Upload one above, or from a product\'s page.' : 'Nothing matches your search.' ?></td></tr> <?php endif; ?> </tbody> </table> ⋮ 3 unchanged lines ⋮
includes/ai.php
Not shown (binary file or too large to diff).
includes/antibot.php
⋮ 62 unchanged lines ⋮ $width = 200; $height = 70; $length = strlen($token); - $svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' . $width . '" height="' . $height . '" viewBox="0 0 ' . $width . ' ' . $height . '" role="img" aria-label="Security verification code">'; + $imageLabel = class_exists('Language') ? Language::get('antibot_image_label', 'Security code') : 'Security code'; + $svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' . $width . '" height="' . $height . '" viewBox="0 0 ' . $width . ' ' . $height . '" role="img" aria-label="' . htmlspecialchars($imageLabel, ENT_QUOTES) . '">'; $svg .= '<defs>'; $svg .= '<filter id="antibot-noise">'; $svg .= '<feTurbulence type="fractalNoise" baseFrequency="0.65" numOctaves="3" stitchTiles="stitch"/>'; ⋮ 93 unchanged lines ⋮
includes/block-editor.php
⋮ 21 unchanged lines ⋮ 'embed', ]; - private const SINGLE_USE_TYPES = ['contact_form']; + private const SINGLE_USE_TYPES = ['contact_form', 'home_featured', 'home_products']; + public const HOME_SYSTEM_TYPES = ['home_featured', 'home_products']; + private static bool $interactive = true; - public static function sanitize(string $rawJson, bool $allowContactForm = false): string + public static function sanitize(string $rawJson, bool $allowContactForm = false, bool $homepage = false): string { $decoded = json_decode($rawJson, true); if (!is_array($decoded) || !isset($decoded['blocks']) || !is_array($decoded['blocks'])) { - return json_encode(['blocks' => []]); + if (!$homepage) { + return json_encode(['blocks' => []]); + } + $decoded = ['blocks' => []]; } $cleanBlocks = []; $usedSingleTypes = []; $allowedTypes = $allowContactForm ? array_merge(self::ALLOWED_TYPES, ['contact_form']) : self::ALLOWED_TYPES; + if ($homepage) { + $allowedTypes = array_merge($allowedTypes, ['hero'], self::HOME_SYSTEM_TYPES); + } foreach ($decoded['blocks'] as $block) { if (!is_array($block) || !isset($block['type']) || !in_array($block['type'], $allowedTypes, true)) { ⋮ 16 unchanged lines ⋮ ]; } + if ($homepage) { + $cleanBlocks = self::withSystemBlocks($cleanBlocks); + } + return json_encode(['blocks' => $cleanBlocks], JSON_UNESCAPED_UNICODE); } + private static function withSystemBlocks(array $blocks): array + { + $present = []; + foreach ($blocks as $block) { + $present[(string) ($block['type'] ?? '')] = true; + } + + foreach (self::HOME_SYSTEM_TYPES as $type) { + if (!isset($present[$type])) { + $blocks[] = ['id' => 'sys_' . $type, 'type' => $type, 'data' => []]; + } + } + + return $blocks; + } + + public static function defaultHomepageBlocks(bool $withHero): string + { + $blocks = []; + if ($withHero) { + $blocks[] = ['id' => 'home_hero', 'type' => 'hero', 'data' => ['eyebrow' => '', 'title' => '', 'subtitle' => '', 'show_eyebrow' => true]]; + } + foreach (self::HOME_SYSTEM_TYPES as $type) { + $blocks[] = ['id' => 'sys_' . $type, 'type' => $type, 'data' => []]; + } + + return json_encode(['blocks' => $blocks], JSON_UNESCAPED_UNICODE); + } + + public static function renderHomepage(string $blocksJson, array $settings, array $systemRenderers): string + { + $decoded = json_decode($blocksJson, true); + $blocks = is_array($decoded) && is_array($decoded['blocks'] ?? null) ? $decoded['blocks'] : []; + $blocks = self::withSystemBlocks(array_values(array_filter($blocks, 'is_array'))); + + self::$interactive = true; + $html = ''; + $buffer = ''; + $rendered = []; + + foreach ($blocks as $block) { + $type = (string) ($block['type'] ?? ''); + $data = is_array($block['data'] ?? null) ? $block['data'] : []; + + if (in_array($type, self::SINGLE_USE_TYPES, true)) { + if (isset($rendered[$type])) { + continue; + } + $rendered[$type] = true; + } + + if ($type === 'contact_form') { + continue; + } + + if ($type !== 'hero' && !in_array($type, self::HOME_SYSTEM_TYPES, true)) { + $buffer .= self::renderBlock(['type' => $type, 'data' => $data]); + continue; + } + + if ($buffer !== '') { + $html .= '<div class="article-content home-content-block">' . $buffer . '</div>'; + $buffer = ''; + } + + if ($type === 'hero') { + $html .= self::renderHero($data, $settings); + continue; + } + + $renderer = $systemRenderers[$type] ?? null; + $html .= is_callable($renderer) ? (string) $renderer() : ''; + } + + if ($buffer !== '') { + $html .= '<div class="article-content home-content-block">' . $buffer . '</div>'; + } + + return $html; + } + + private static function renderHero(array $data, array $settings): string + { + $title = trim((string) ($data['title'] ?? '')); + $title = $title !== '' ? $title : trim((string) ($settings['site_name'] ?? '')); + $subtitle = trim((string) ($data['subtitle'] ?? '')); + $subtitle = $subtitle !== '' ? $subtitle : trim((string) ($settings['site_description'] ?? '')); + $eyebrow = trim((string) ($data['eyebrow'] ?? '')); + $eyebrow = $eyebrow !== '' ? $eyebrow : Language::get('nav_home', 'Home'); + $showEyebrow = !array_key_exists('show_eyebrow', $data) || !empty($data['show_eyebrow']); + + if ($title === '' && $subtitle === '') { + return ''; + } + + return '<section class="hero-section">' + . ($showEyebrow ? '<div class="hero-eyebrow">' . htmlspecialchars($eyebrow) . '</div>' : '') + . ($title !== '' ? '<h1 class="hero-title">' . htmlspecialchars($title) . '</h1>' : '') + . ($subtitle !== '' ? '<p class="hero-subtitle">' . htmlspecialchars($subtitle) . '</p>' : '') + . '</section>'; + } + private static function sanitizeBlockData(string $type, array $data): array { return match ($type) { ⋮ 39 unchanged lines ⋮ 'separator' => [], 'contact_form' => [ 'title' => mb_substr(trim(strip_tags((string) ($data['title'] ?? ''))), 0, 120), + ], + 'hero' => [ + 'eyebrow' => mb_substr(trim(strip_tags((string) ($data['eyebrow'] ?? ''))), 0, 80), + 'title' => mb_substr(trim(strip_tags((string) ($data['title'] ?? ''))), 0, 160), + 'subtitle' => mb_substr(trim(strip_tags((string) ($data['subtitle'] ?? ''))), 0, 400), + 'show_eyebrow' => !array_key_exists('show_eyebrow', $data) || !empty($data['show_eyebrow']), ], default => [], }; ⋮ 333 unchanged lines ⋮
includes/dash-header.php
⋮ 14 unchanged lines ⋮ $dashIsAdmin = Auth::hasRoleAtLeast(Auth::ROLE_STORE_OWNER); $dashIsManager = Auth::hasRoleAtLeast(Auth::ROLE_STORE_MANAGER); require_once __DIR__ . '/avatar.php'; + require_once __DIR__ . '/status-label.php'; ?> <!DOCTYPE html> ⋮ 70 unchanged lines ⋮ <div class="dash-mobile-topbar"> <div class="dash-brand"><span class="dash-brand-dot"></span><?= htmlspecialchars($dashSiteName) ?></div> <div class="dash-mobile-topbar-right"> - <button type="button" class="dash-theme-toggle" aria-label="Toggle theme"> + <button type="button" class="dash-theme-toggle" aria-label="Switch between light and dark"> <?= Icons::icon('sun', 'icon icon-sm icon-sun') ?> <?= Icons::icon('moon', 'icon icon-sm icon-moon') ?> </button> ⋮ 6 unchanged lines ⋮ <div class="dash-brand-row"> <div class="dash-brand"><span class="dash-brand-dot"></span><?= htmlspecialchars($dashSiteName) ?></div> - <button type="button" class="dash-theme-toggle" id="dash-theme-toggle-btn" aria-label="Toggle theme"> + <button type="button" class="dash-theme-toggle" id="dash-theme-toggle-btn" aria-label="Switch between light and dark"> <?= Icons::icon('sun', 'icon icon-sm icon-sun') ?> <?= Icons::icon('moon', 'icon icon-sm icon-moon') ?> </button> ⋮ 13 unchanged lines ⋮ <a href="dashboard.php?view=pages" class="<?= ($dashView ?? '') === 'pages' ? 'active' : '' ?>"><?= Icons::icon('pages') ?>Pages</a> <a href="dashboard.php?view=stats" class="<?= ($dashView ?? '') === 'stats' ? 'active' : '' ?>"><?= Icons::icon('stats') ?>Statistics</a> <a href="dashboard.php?view=security" class="<?= ($dashView ?? '') === 'security' ? 'active' : '' ?>"><?= Icons::icon('lock') ?>Security</a> - <a href="interface.php" class="<?= ($dashActivePage ?? '') === 'interface' ? 'active' : '' ?>"><?= Icons::icon('sliders') ?>Interface</a> + <a href="interface.php" class="<?= ($dashActivePage ?? '') === 'interface' ? 'active' : '' ?>"><?= Icons::icon('sliders') ?>Appearance</a> <?php if ($dashIsAdmin): ?> <div class="dash-nav-divider"></div> - <a href="admin.php" class="<?= ($dashActivePage ?? '') === 'admin' ? 'active' : '' ?>"><?= Icons::icon('team') ?>Team accounts</a> + <a href="admin.php" class="<?= ($dashActivePage ?? '') === 'admin' ? 'active' : '' ?>"><?= Icons::icon('team') ?>Team</a> <a href="settings.php" class="<?= ($dashActivePage ?? '') === 'settings' ? 'active' : '' ?>"><?= Icons::icon('settings') ?>Settings</a> <a href="languages.php" class="<?= ($dashActivePage ?? '') === 'languages' ? 'active' : '' ?>"><?= Icons::icon('translate') ?>Languages</a> <a href="ads.php" class="<?= ($dashActivePage ?? '') === 'ads' ? 'active' : '' ?>"><?= Icons::icon('ads') ?>Ads</a> ⋮ 18 unchanged lines ⋮
includes/database.php
⋮ 86 unchanged lines ⋮ 'deleted_at' => "ALTER TABLE products ADD COLUMN deleted_at DATETIME NULL", 'trashed_status' => "ALTER TABLE products ADD COLUMN trashed_status VARCHAR(20) NULL", ], + 'pages' => [ + 'is_home' => "ALTER TABLE pages ADD COLUMN is_home TINYINT(1) NOT NULL DEFAULT 0", + ], 'categories' => [ 'show_in_menu' => "ALTER TABLE categories ADD COLUMN show_in_menu TINYINT(1) NOT NULL DEFAULT 1 AFTER sort_order", ], ⋮ 27 unchanged lines ⋮ error_log('StocketBase: could not auto-add column ' . $table . '.' . $column . ': ' . $e->getMessage()); } } + } + } + + $authorColumns = ['products' => 'created_by', 'pages' => 'author_id', 'ads' => 'created_by', 'discounts' => 'created_by']; + $authorState = $pdo->prepare( + "SELECT c.TABLE_NAME, c.IS_NULLABLE, k.CONSTRAINT_NAME, r.DELETE_RULE + FROM information_schema.COLUMNS c + LEFT JOIN information_schema.KEY_COLUMN_USAGE k + ON k.TABLE_SCHEMA = c.TABLE_SCHEMA AND k.TABLE_NAME = c.TABLE_NAME AND k.COLUMN_NAME = c.COLUMN_NAME AND k.REFERENCED_TABLE_NAME = 'team_accounts' + LEFT JOIN information_schema.REFERENTIAL_CONSTRAINTS r + ON r.CONSTRAINT_SCHEMA = k.CONSTRAINT_SCHEMA AND r.CONSTRAINT_NAME = k.CONSTRAINT_NAME AND r.TABLE_NAME = k.TABLE_NAME + WHERE c.TABLE_SCHEMA = :db + AND ((c.TABLE_NAME = 'products' AND c.COLUMN_NAME = 'created_by') + OR (c.TABLE_NAME = 'pages' AND c.COLUMN_NAME = 'author_id') + OR (c.TABLE_NAME = 'ads' AND c.COLUMN_NAME = 'created_by') + OR (c.TABLE_NAME = 'discounts' AND c.COLUMN_NAME = 'created_by'))" + ); + $authorState->execute(['db' => $dbName]); + foreach ($authorState->fetchAll(PDO::FETCH_ASSOC) as $state) { + $table = (string) $state['TABLE_NAME']; + $column = $authorColumns[$table] ?? null; + if ($column === null) { + continue; + } + $constraint = (string) ($state['CONSTRAINT_NAME'] ?? ''); + $needsNullable = $state['IS_NULLABLE'] === 'NO'; + $needsRule = $constraint !== '' && strtoupper((string) $state['DELETE_RULE']) !== 'SET NULL'; + if (!$needsNullable && !$needsRule) { + continue; + } + if (preg_match('/^[A-Za-z0-9_]+$/', $constraint) !== 1 && $constraint !== '') { + continue; + } + try { + if ($needsRule) { + $pdo->exec('ALTER TABLE ' . $table . ' DROP FOREIGN KEY ' . $constraint); + } + if ($needsNullable) { + $pdo->exec('ALTER TABLE ' . $table . ' MODIFY ' . $column . ' INT UNSIGNED NULL'); + } + if ($needsRule) { + $pdo->exec('ALTER TABLE ' . $table . ' ADD CONSTRAINT ' . $constraint . ' FOREIGN KEY (' . $column . ') REFERENCES team_accounts(id) ON DELETE SET NULL'); + } + if ($table === 'pages') { + $pdo->exec('UPDATE pages SET author_id = NULL WHERE is_home = 1'); + } + } catch (PDOException $e) { + error_log('StocketBase: could not update author key on ' . $table . ': ' . $e->getMessage()); } } ⋮ 135 unchanged lines ⋮
includes/language.php
⋮ 42 unchanged lines ⋮ public const ADDED_DEFAULTS = [ 'language_switcher_label' => 'Language', - 'article_toc_title' => 'Table of contents', 'back_to_top' => 'Back to top', 'reading_progress_left' => '{percent}% left', 'breadcrumb_label' => 'Breadcrumb', - 'article_written_by' => 'Written by', - 'author_articles_count' => 'Articles: {count}', - 'artist_articles_title' => 'Articles by {name}', - 'artist_not_found' => 'Author not found.', - 'artist_website' => 'Website', - 'artist_no_articles' => 'No published articles yet.', - 'article_tags_label' => 'Tags:', + 'product_tags_label' => 'Tags:', 'tag_page_title' => 'Tagged: {name}', 'tag_not_found' => 'Tag not found.', - 'tag_no_articles' => 'No published articles with this tag yet.', - 'read_aloud_title' => 'Listen to this article', - 'read_aloud_play' => 'Play', - 'read_aloud_pause' => 'Pause', - 'read_aloud_seek' => 'Seek', - 'read_aloud_speed' => 'Playback speed', - 'article_subscribers_only' => 'Subscribers only', - 'article_subscribe_button' => 'Subscribe', - 'article_login_button' => 'Log in', - 'article_already_subscriber' => 'Already a subscriber?', - 'article_buy_lifetime_button' => 'Get lifetime access', - 'subscription_one_time' => 'one-time payment', + 'tag_no_products' => 'No published products with this tag yet.', 'subscription_per_month' => 'month', 'subscription_per_year' => 'year', + 'page_title_not_found' => 'Not found', + 'pagination_label' => 'Pages', + 'product_rating_aria' => 'Average rating {rating} out of 5', + 'antibot_image_label' => 'Security code', + 'checkout_discount_name' => 'Discount', + 'subscription_status_unpaid' => 'unpaid', + 'subscription_status_incomplete' => 'waiting for payment', + 'subscription_status_incomplete_expired' => 'expired', + 'subscription_status_paused' => 'paused', ]; public static function ensureKeys(array $defaults): void ⋮ 33 unchanged lines ⋮
includes/pagination.php
⋮ 52 unchanged lines ⋮ private static function renderNumbered(string $baseUrl, int $currentPage, int $totalPages, array $extraParams): string { - $html = '<nav class="pagination-numbered" aria-label="Pagination">'; + $html = '<nav class="pagination-numbered" aria-label="' . htmlspecialchars(Language::get('pagination_label', 'Pages'), ENT_QUOTES) . '">'; if ($currentPage > 1) { $html .= '<a href="' . htmlspecialchars(self::buildUrl($baseUrl, $currentPage - 1, $extraParams)) . '" class="pagination-link pagination-prev">' . htmlspecialchars(Language::get('pagination_previous', 'Previous')) . '</a>'; ⋮ 62 unchanged lines ⋮
includes/site-front.php
⋮ 12 unchanged lines ⋮ private static ?array $settings = null; public const AD_PLACEMENTS = [ - 'header' => 'Header (below navigation, all pages)', - 'homepage-top' => 'Homepage — top (below hero)', - 'catalog-top' => 'Catalog grid — between cards (homepage & category)', - 'product-page' => 'Product page — after gallery, before description', + 'header' => 'Under the menu, on every page', + 'homepage-top' => 'Home page, above featured products', + 'catalog-top' => 'Between products in lists', + 'product-page' => 'Product page, under the photos', 'checkout' => 'Checkout page', - 'footer' => 'Footer (above footer)', + 'footer' => 'Just above the footer', ]; public static function productUrl(string $slug, ?string $lang = null): string ⋮ 202 unchanged lines ⋮ if ($row['item_type'] === 'page') { $statement = Database::site()->prepare( - "SELECT id, title, slug FROM pages WHERE id = :id AND show_in_menu = 1 AND status = 'published' LIMIT 1" + "SELECT id, title, slug FROM pages WHERE id = :id AND show_in_menu = 1 AND status = 'published' AND is_home = 0 LIMIT 1" ); $statement->execute(['id' => $row['ref_id']]); $page = $statement->fetch(); ⋮ 83 unchanged lines ⋮ try { $pages = Database::site()->query( - "SELECT id, title, slug FROM pages WHERE show_in_menu = 1 AND status = 'published' ORDER BY sort_order ASC, title ASC" + "SELECT id, title, slug FROM pages WHERE show_in_menu = 1 AND status = 'published' AND is_home = 0 ORDER BY sort_order ASC, title ASC" )->fetchAll(); } catch (PDOException $e) { $pages = []; ⋮ 124 unchanged lines ⋮ public static function menuPages(): array { return Database::site()->query( - "SELECT title, slug FROM pages WHERE status = 'published' AND show_in_menu = 1 ORDER BY sort_order ASC, title ASC" + "SELECT title, slug FROM pages WHERE status = 'published' AND show_in_menu = 1 AND is_home = 0 ORDER BY sort_order ASC, title ASC" )->fetchAll(); + } + + public static function homePage(): ?array + { + require_once __DIR__ . '/block-editor.php'; + + $db = Database::site(); + $row = $db->query('SELECT * FROM pages WHERE is_home = 1 ORDER BY id ASC LIMIT 1')->fetch(); + if ($row) { + return $row; + } + + $settings = self::settings(); + $locked = false; + + try { + $locked = (int) $db->query("SELECT GET_LOCK('stocketbase_home_page', 5)")->fetchColumn() === 1; + + $row = $db->query('SELECT * FROM pages WHERE is_home = 1 ORDER BY id ASC LIMIT 1')->fetch(); + if (!$row) { + $slugCheck = $db->prepare('SELECT COUNT(*) FROM pages WHERE slug = :slug'); + $slug = '_home'; + for ($attempt = 2; $attempt < 100; $attempt++) { + $slugCheck->execute(['slug' => $slug]); + if ((int) $slugCheck->fetchColumn() === 0) { + break; + } + $slug = '_home-' . $attempt; + } + + $insert = $db->prepare( + "INSERT INTO pages (author_id, title, slug, content_blocks, status, show_in_menu, sort_order, is_home) VALUES (NULL, :title, :slug, :content, 'published', 0, -1000, 1)" + ); + $insert->execute([ + 'title' => 'Home page', + 'slug' => $slug, + 'content' => BlockEditor::defaultHomepageBlocks(trim((string) ($settings['site_description'] ?? '')) !== ''), + ]); + + $row = $db->query('SELECT * FROM pages WHERE is_home = 1 ORDER BY id ASC LIMIT 1')->fetch(); + } + } catch (PDOException $exception) { + $row = false; + } finally { + if ($locked) { + $db->query("SELECT RELEASE_LOCK('stocketbase_home_page')"); + } + } + + return $row ?: null; } public static function categories(): array ⋮ 343 unchanged lines ⋮
includes/status-label.php
+ <?php + + declare(strict_types=1); + + final class StatusLabel + { + private const LABELS = [ + 'draft' => 'Draft', + 'pending_review' => 'Waiting for approval', + 'scheduled' => 'Scheduled', + 'published' => 'Live', + 'rejected' => 'Sent back', + 'archived' => 'Archived', + 'pending' => 'Waiting for payment', + 'paid' => 'Paid', + 'processing' => 'Being prepared', + 'fulfilled' => 'Ready', + 'shipped' => 'Shipped', + 'completed' => 'Completed', + 'canceled' => 'Canceled', + 'cancelled' => 'Canceled', + 'refunded' => 'Refunded', + 'failed' => 'Payment failed', + 'approved' => 'Approved', + 'active' => 'Active', + 'suspended' => 'Blocked', + 'trialing' => 'Free trial', + 'past_due' => 'Payment overdue', + 'unpaid' => 'Unpaid', + 'expired' => 'Expired', + 'inactive' => 'Inactive', + ]; + + public static function get(string $status): string + { + $key = strtolower(trim($status)); + if (isset(self::LABELS[$key])) { + return self::LABELS[$key]; + } + + return ucfirst(str_replace('_', ' ', $key)); + } + } +
index.php
⋮ 142 unchanged lines ⋮ $pageDescription = $settings['seo_description'] !== '' ? $settings['seo_description'] : $settings['site_description']; $pageType = 'index'; - require __DIR__ . '/includes/front-header.php'; + require_once __DIR__ . '/includes/block-editor.php'; - ?> - <?php if (!empty($settings['site_description'])): ?> - <section class="hero-section"> - <div class="hero-eyebrow"><?= htmlspecialchars(Language::get('nav_home', 'Home')) ?></div> - <h1 class="hero-title"><?= htmlspecialchars($settings['site_name']) ?></h1> - <p class="hero-subtitle"><?= htmlspecialchars($settings['site_description']) ?></p> - </section> - <?php endif; ?> + try { + $homePage = SiteFront::homePage(); + } catch (Throwable $exception) { + $homePage = null; + } - <?php foreach (SiteFront::activeAds('homepage-top') as $homepageTopAd): ?> - <?= SiteFront::renderAd($homepageTopAd) ?> - <?php endforeach; ?> + if ($homePage !== null) { + $homePage = Languages::pages([$homePage])[0]; + $homeBlocksJson = (string) $homePage['content_blocks']; + } else { + $homeBlocksJson = BlockEditor::defaultHomepageBlocks(trim((string) ($settings['site_description'] ?? '')) !== ''); + } - <?php if (($settings['featured_banner_enabled'] ?? '0') === '1'): ?> - <div class="section-heading-row"> - <h2 class="section-heading"><?= htmlspecialchars(Language::get('home_featured', 'Featured')) ?></h2> - </div> + $renderHomeFeatured = static function () use ($settings, $featuredProducts): string { + ob_start(); + foreach (SiteFront::activeAds('homepage-top') as $homepageTopAd) { + echo SiteFront::renderAd($homepageTopAd); + } + if (($settings['featured_banner_enabled'] ?? '0') === '1') { + echo '<div class="section-heading-row"><h2 class="section-heading">' . htmlspecialchars(Language::get('home_featured', 'Featured')) . '</h2></div>'; + if (!empty($featuredProducts)) { + echo renderFeaturedBanner($featuredProducts); + } + } - <?php if (!empty($featuredProducts)): ?> - <?= renderFeaturedBanner($featuredProducts) ?> - <?php endif; ?> - <?php endif; ?> + return (string) ob_get_clean(); + }; - <div class="article-grid" id="article-list" data-pagination-source="home"> - <?php $catalogTopAds = SiteFront::activeAds('catalog-top'); ?> - <?php foreach ($products as $inFeedIndex => $product): ?> - <?= renderProductCard($product) ?> - <?php if ($inFeedIndex === 2 && !empty($catalogTopAds)): ?> - <?php foreach ($catalogTopAds as $catalogTopAd): ?> - <?= SiteFront::renderAd($catalogTopAd) ?> - <?php endforeach; ?> - <?php endif; ?> - <?php endforeach; ?> - <?php if (empty($products)): ?> - <p><?= htmlspecialchars(Language::get('home_no_products', 'No products published yet.')) ?></p> - <?php endif; ?> - </div> + $renderHomeProducts = static function () use ($settings, $products, $isStaticProductLoading, $currentPage, $totalPages): string { + ob_start(); + $catalogTopAds = SiteFront::activeAds('catalog-top'); + echo '<div class="article-grid" id="article-list" data-pagination-source="home">'; + foreach ($products as $inFeedIndex => $product) { + echo renderProductCard($product); + if ($inFeedIndex === 2 && !empty($catalogTopAds)) { + foreach ($catalogTopAds as $catalogTopAd) { + echo SiteFront::renderAd($catalogTopAd); + } + } + } + if (empty($products)) { + echo '<p>' . htmlspecialchars(Language::get('home_no_products', 'No products published yet.')) . '</p>'; + } + echo '</div>'; + echo '<div id="pagination-container">'; + echo $isStaticProductLoading ? '' : Pagination::render($settings['pagination_style'], 'index.php', $currentPage, $totalPages, [], 'article-list'); + echo '</div>'; - <div id="pagination-container"> - <?= $isStaticProductLoading ? '' : Pagination::render($settings['pagination_style'], 'index.php', $currentPage, $totalPages, [], 'article-list') ?> - </div> + return (string) ob_get_clean(); + }; - <?php require __DIR__ . '/includes/front-footer.php'; ?> + require __DIR__ . '/includes/front-header.php'; + + echo BlockEditor::renderHomepage($homeBlocksJson, $settings, [ + 'home_featured' => $renderHomeFeatured, + 'home_products' => $renderHomeProducts, + ]); + + require __DIR__ . '/includes/front-footer.php';
interface.php
⋮ 23 unchanged lines ⋮ } $dashActivePage = 'interface'; - $dashPageTitle = 'Interface'; + $dashPageTitle = 'Appearance'; require __DIR__ . '/includes/dash-header.php'; ?> - <h1 class="dash-title"><?= Icons::icon("sliders", "icon icon-lg") ?>Interface</h1> + <h1 class="dash-title"><?= Icons::icon("sliders", "icon icon-lg") ?>Appearance</h1> <div class="settings-tabs"> - <a href="interface.php?tab=appearance" class="<?= $activeTab === 'appearance' ? 'active' : '' ?>"><?= Icons::icon("palette", "icon icon-sm") ?>Appearance</a> - <a href="interface.php?tab=shortcuts" class="<?= $activeTab === 'shortcuts' ? 'active' : '' ?>"><?= Icons::icon("keyboard", "icon icon-sm") ?>Shortcuts</a> + <a href="interface.php?tab=appearance" class="<?= $activeTab === 'appearance' ? 'active' : '' ?>"><?= Icons::icon("palette", "icon icon-sm") ?>Look</a> + <a href="interface.php?tab=shortcuts" class="<?= $activeTab === 'shortcuts' ? 'active' : '' ?>"><?= Icons::icon("keyboard", "icon icon-sm") ?>Keyboard shortcuts</a> </div> <?php if ($activeTab === 'appearance'): ?> <div class="settings-section"> - <p style="font-size:12.5px;color:var(--muted);margin:-4px 0 20px;">Personal preferences for how the dashboard and product editor look and behave on this device — text size, fonts, editor helpers. Everything here is stored in this browser only and applies just to you; it's never saved to the server or shared with other team members.</p> + <p style="font-size:12.5px;color:var(--muted);margin:-4px 0 20px;">Make the dashboard comfortable for you. These choices are saved only in this browser, so they don't affect anyone else on your team.</p> <label>Sidebar text size</label> <select id="appearance-sidebar-scale" style="width:100%;padding:10px;border-radius:8px;border:1px solid var(--border);background:var(--panel);color:var(--text);margin:6px 0 18px;"> ⋮ 7 unchanged lines ⋮ <option value="150">150%</option> </select> - <label>Main content text size</label> + <label>Page text size</label> <select id="appearance-main-scale" style="width:100%;padding:10px;border-radius:8px;border:1px solid var(--border);background:var(--panel);color:var(--text);margin:6px 0 18px;"> <option value="80">80%</option> <option value="90">90%</option> ⋮ 5 unchanged lines ⋮ <option value="150">150%</option> </select> - <label>Editor interface font</label> + <label>Dashboard font</label> <select id="appearance-ui-font" style="width:100%;padding:10px;border-radius:8px;border:1px solid var(--border);background:var(--panel);color:var(--text);margin:6px 0 18px;"> <option value="">Default</option> <?php foreach ($interfaceAvailableFonts as $interfaceFont): ?> ⋮ 1 unchanged line ⋮ <?php endforeach; ?> </select> - <label>Content font (product editor text)</label> + <label>Font while writing products</label> <select id="appearance-content-font" style="width:100%;padding:10px;border-radius:8px;border:1px solid var(--border);background:var(--panel);color:var(--text);margin:6px 0 18px;"> <option value="">Default</option> <?php foreach ($interfaceAvailableFonts as $interfaceFont): ?> ⋮ 1 unchanged line ⋮ <?php endforeach; ?> </select> - <label style="margin-top:18px;"><input type="checkbox" id="appearance-glass"> Glass effect (translucent buttons and blurred backgrounds). Turn off for solid colors.</label> - <label><input type="checkbox" id="appearance-minimal"> Minimalism (removes all transitions, animations and gradients; also turns the glass effect off). Recommended for older computers and devices.</label> - <label><input type="checkbox" id="appearance-block-icons"> Show block type icons in the product editor</label> - <label><input type="checkbox" id="appearance-spellcheck"> Show browser spell check underlines in the product editor</label> - <label><input type="checkbox" id="appearance-paste-cleanup"> Always strip formatting when pasting into the product editor (Ctrl+Shift+V always does this regardless)</label> + <label style="margin-top:18px;"><input type="checkbox" id="appearance-glass"> Glass look (see-through buttons and soft blur). Turn off for plain colors.</label> + <label><input type="checkbox" id="appearance-minimal"> Simple mode (no animations or effects). Good for older computers.</label> + <label><input type="checkbox" id="appearance-block-icons"> Show little icons next to each block in the editor</label> + <label><input type="checkbox" id="appearance-spellcheck"> Underline spelling mistakes in the editor</label> + <label><input type="checkbox" id="appearance-paste-cleanup"> Paste as plain text in the editor (Ctrl+Shift+V always does this)</label> <?php if ($interfaceAiAvailable): ?> - <label><input type="checkbox" id="appearance-ai-helper"> Show the AI helper button in the product and page editors</label> + <label><input type="checkbox" id="appearance-ai-helper"> Show the AI assistant button in the editor</label> <?php endif; ?> <div class="publish-actions"> ⋮ 202 unchanged lines ⋮ </style> <div class="settings-section"> - <p style="font-size:12.5px;color:var(--muted);margin:-4px 0 20px;">Keyboard shortcuts for the product editor. Stored in this browser only.</p> + <p style="font-size:12.5px;color:var(--muted);margin:-4px 0 20px;">Shortcuts for the editor. They're saved only in this browser.</p> <div id="shortcuts-list"></div> ⋮ 31 unchanged lines ⋮ comboBtn.type = 'button'; comboBtn.className = 'shortcut-combo-btn'; comboBtn.textContent = comboLabel(entry.combo); - comboBtn.title = 'Click, then press a new key combination'; + comboBtn.title = 'Click, then press the keys you want'; var resetBtn = document.createElement('button'); resetBtn.type = 'button'; ⋮ 22 unchanged lines ⋮ } captureRow = btn; btn.classList.add('capturing'); - btn.textContent = 'Press a key…'; + btn.textContent = 'Press the keys…'; function onKeydown(event) { event.preventDefault(); ⋮ 43 unchanged lines ⋮
languages.php
⋮ 19 unchanged lines ⋮ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!Csrf::verify($_POST['csrf_token'] ?? null)) { - $flashMessage = 'Security check failed, please try again.'; + $flashMessage = 'Your session expired. Please try again.'; $flashType = 'error'; } else { $action = (string) ($_POST['action'] ?? ''); ⋮ 5 unchanged lines ⋮ $defaultName = trim(strip_tags((string) ($_POST['multilang_default_name'] ?? ''))); if (!Languages::isValidCode($defaultCode)) { - $flashMessage = 'The original language code must be 2-3 letters, optionally with a region such as pt-br.'; + $flashMessage = 'The language code should be 2 or 3 letters, like en, pl or pt-br.'; $flashType = 'error'; } elseif (Languages::find($defaultCode) !== null) { - $flashMessage = 'The original language code is already used by one of the added languages.'; + $flashMessage = 'You already added a language with that code.'; $flashType = 'error'; } else { Languages::setSetting('multilang_enabled', isset($_POST['multilang_enabled']) ? '1' : '0'); ⋮ 1 unchanged line ⋮ Languages::setSetting('multilang_default_name', $defaultName !== '' ? mb_substr($defaultName, 0, 60) : 'English'); Languages::setSetting('multilang_switcher', isset($_POST['multilang_switcher']) ? '1' : '0'); ActivityLog::record('language.settings'); - $flashMessage = 'Saved.'; + $flashMessage = 'Changes saved.'; } } elseif ($action === 'add_language') { $error = Languages::addLanguage((string) ($_POST['code'] ?? ''), (string) ($_POST['name'] ?? '')); ⋮ 2 unchanged lines ⋮ $flashType = 'error'; } else { ActivityLog::record('language.add', null, null, $postedCode); - $flashMessage = 'Language added. Translate the interface, then activate it.'; + $flashMessage = 'Language added. Translate the texts, then switch it on.'; } } elseif ($action === 'toggle_language' && $knownCode) { $statement = $db->prepare('UPDATE site_languages SET is_active = 1 - is_active WHERE code = :code'); $statement->execute(['code' => $postedCode]); ActivityLog::record('language.toggle', null, null, $postedCode); - $flashMessage = 'Saved.'; + $flashMessage = 'Changes saved.'; } elseif ($action === 'delete_language' && $knownCode) { $statement = $db->prepare('DELETE FROM site_languages WHERE code = :code'); $statement->execute(['code' => $postedCode]); ActivityLog::record('language.delete', null, null, $postedCode); - $flashMessage = 'Language removed. Its translated files and texts are kept, so adding it again restores them.'; + $flashMessage = 'Language removed. The translations are kept, so they come back if you add it again.'; } elseif ($action === 'save_strings' && $knownCode) { $submitted = is_array($_POST['strings'] ?? null) ? $_POST['strings'] : []; if (Languages::saveStrings($postedCode, $submitted)) { ActivityLog::record('language.strings', null, null, $postedCode); - $flashMessage = 'Saved.'; + $flashMessage = 'Changes saved.'; } else { - $flashMessage = 'Could not write the translation file. Check that the translations folder is writable.'; + $flashMessage = 'We couldn\'t save the translation file. Ask your host to make the translations folder writable.'; $flashType = 'error'; } } elseif ($action === 'save_site' && $knownCode) { Languages::saveSiteValues($postedCode, is_array($_POST['site'] ?? null) ? $_POST['site'] : []); ActivityLog::record('language.site', null, null, $postedCode); - $flashMessage = 'Saved.'; + $flashMessage = 'Changes saved.'; } elseif ($action === 'copy_site' && $knownCode) { $added = Languages::copySiteMissing($postedCode); ActivityLog::record('language.site_copy', null, null, $postedCode); $flashMessage = $added > 0 ? $added . ' missing text' . ($added === 1 ? '' : 's') . ' copied from the original. Existing translations were not changed.' - : 'Nothing to copy: every text already has a translation.'; + : 'Everything is already translated.'; } elseif ($action === 'copy_missing' && $knownCode) { $added = Languages::copyMissingStrings($postedCode); ActivityLog::record('language.copy', null, null, $postedCode); $flashMessage = $added > 0 ? $added . ' missing text' . ($added === 1 ? '' : 's') . ' copied from the original language. Existing translations were not changed.' - : 'Nothing to copy: every text already has a translation.'; + : 'Everything is already translated.'; } else { - $flashMessage = 'Unknown action.'; + $flashMessage = 'Something went wrong. Please try again.'; $flashType = 'error'; } ⋮ 37 unchanged lines ⋮ <div class="settings-tabs"> <a href="languages.php" class="<?= $editing === null && $siteEditing === null ? 'active' : '' ?>"><?= Icons::icon('settings', 'icon icon-sm') ?>Overview</a> <?php if ($siteEditing !== null): ?> - <a href="languages.php?site=<?= urlencode($siteEditing['code']) ?>" class="active"><?= Icons::icon('globe', 'icon icon-sm') ?>Site texts: <?= htmlspecialchars($siteEditing['name']) ?></a> + <a href="languages.php?site=<?= urlencode($siteEditing['code']) ?>" class="active"><?= Icons::icon('globe', 'icon icon-sm') ?>Store texts: <?= htmlspecialchars($siteEditing['name']) ?></a> <?php endif; ?> <?php if ($editing !== null): ?> <a href="languages.php?edit=<?= urlencode($editing['code']) ?>" class="active"><?= Icons::icon('translate', 'icon icon-sm') ?>Interface: <?= htmlspecialchars($editing['name']) ?></a> ⋮ 6 unchanged lines ⋮ <?= Csrf::field() ?> <input type="hidden" name="action" value="save_settings"> - <label><input type="checkbox" name="multilang_enabled" <?= $multilangEnabled ? 'checked' : '' ?>> Enable multiple languages (adds a language switcher and language versions of the site under /<em>code</em>/)</label> + <label><input type="checkbox" name="multilang_enabled" <?= $multilangEnabled ? 'checked' : '' ?>> Offer your store in more than one language</label> <div class="settings-grid"> <div> - <label>Original language code</label> + <label>Main language code</label> <input type="text" name="multilang_default_code" value="<?= htmlspecialchars($defaultCode) ?>" maxlength="10" spellcheck="false"> </div> <div> - <label>Original language name</label> + <label>Main language name</label> <input type="text" name="multilang_default_name" value="<?= htmlspecialchars($defaultName) ?>" maxlength="60"> </div> </div> - <p style="font-size:12.5px;color:var(--muted);margin:6px 0 0;">The original language is the one your products and pages are written in. It stays at the normal address, without a prefix.</p> + <p style="font-size:12.5px;color:var(--muted);margin:6px 0 0;">This is the language you write your products and pages in. It keeps your normal web address.</p> - <label><input type="checkbox" name="multilang_switcher" <?= $switcherEnabled ? 'checked' : '' ?>> Show the language switcher in the site footer</label> + <label><input type="checkbox" name="multilang_switcher" <?= $switcherEnabled ? 'checked' : '' ?>> Show a language picker in the footer</label> <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:20px;"><?= Icons::icon('check', 'icon icon-sm') ?>Save</button> </form> ⋮ 4 unchanged lines ⋮ <thead> <tr> <th>Language</th> - <th>Address</th> - <th>Interface</th> + <th>Web address</th> + <th>Store texts</th> <th>Status</th> <th></th> </tr> ⋮ 2 unchanged lines ⋮ <tr> <td><?= htmlspecialchars($defaultName) ?> <span style="color:var(--muted);">(<?= htmlspecialchars($defaultCode) ?>)</span></td> <td>/</td> - <td>Original</td> - <td><span class="status-pill status-published">original</span></td> + <td>Normal address</td> + <td><span class="status-pill status-published">Main language</span></td> <td></td> </tr> <?php foreach ($languages as $language): ?> ⋮ 2 unchanged lines ⋮ <td><?= htmlspecialchars($language['name']) ?> <span style="color:var(--muted);">(<?= htmlspecialchars($language['code']) ?>)</span></td> <td>/<?= htmlspecialchars($language['code']) ?>/</td> <td><?= $missing === 0 ? 'Complete' : $missing . ' texts missing' ?></td> - <td><span class="status-pill status-<?= (int) $language['is_active'] === 1 ? 'published' : 'draft' ?>"><?= (int) $language['is_active'] === 1 ? 'active' : 'inactive' ?></span></td> + <td><span class="status-pill status-<?= (int) $language['is_active'] === 1 ? 'published' : 'draft' ?>"><?= (int) $language['is_active'] === 1 ? 'Visible' : 'Hidden' ?></span></td> <td class="dash-table-actions"> <a href="languages.php?edit=<?= urlencode($language['code']) ?>" class="dash-btn-small"><?= Icons::icon('edit', 'icon icon-sm') ?>Interface</a> - <a href="languages.php?site=<?= urlencode($language['code']) ?>" class="dash-btn-small"><?= Icons::icon('globe', 'icon icon-sm') ?>Site texts</a> + <a href="languages.php?site=<?= urlencode($language['code']) ?>" class="dash-btn-small"><?= Icons::icon('globe', 'icon icon-sm') ?>Store texts</a> <form method="post" style="display:inline;"> <?= Csrf::field() ?> <input type="hidden" name="action" value="toggle_language"> <input type="hidden" name="code" value="<?= htmlspecialchars($language['code'], ENT_QUOTES) ?>"> - <button type="submit" class="dash-btn-small"><?= Icons::icon((int) $language['is_active'] === 1 ? 'x' : 'check', 'icon icon-sm') ?><?= (int) $language['is_active'] === 1 ? 'Deactivate' : 'Activate' ?></button> + <button type="submit" class="dash-btn-small"><?= Icons::icon((int) $language['is_active'] === 1 ? 'x' : 'check', 'icon icon-sm') ?><?= (int) $language['is_active'] === 1 ? 'Hide' : 'Show' ?></button> </form> - <form method="post" style="display:inline;" onsubmit="return confirm('Remove this language? Its translations are kept and come back if you add it again.');"> + <form method="post" style="display:inline;" onsubmit="return confirm('Remove this language? Don\'t worry, the translations come back if you add it again.');"> <?= Csrf::field() ?> <input type="hidden" name="action" value="delete_language"> <input type="hidden" name="code" value="<?= htmlspecialchars($language['code'], ENT_QUOTES) ?>"> ⋮ 11 unchanged lines ⋮ <input type="hidden" name="action" value="add_language"> <div class="settings-grid"> <div> - <label>Code (e.g. pl, de, pt-br)</label> + <label>Code (like pl, de or pt-br)</label> <input type="text" name="code" maxlength="10" required spellcheck="false" autocomplete="off"> </div> <div> - <label>Name shown to visitors (e.g. Polski)</label> + <label>Name customers see (like Polski)</label> <input type="text" name="name" maxlength="60" required> </div> </div> - <p style="font-size:12.5px;color:var(--muted);margin:6px 0 0;">The language is created inactive. Translate the interface and your content first, then activate it. Products, pages and categories are translated from their edit screens.</p> + <p style="font-size:12.5px;color:var(--muted);margin:6px 0 0;">New languages start hidden. Translate the store texts and your content, then switch it on. You translate products, pages and categories from their own edit pages.</p> <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:20px;"><?= Icons::icon('plus', 'icon icon-sm') ?>Add language</button> </form> ⋮ 6 unchanged lines ⋮ <?php $editMissing = Languages::missingStringCount($editing['code']); ?> <div class="sidebar-box" style="max-width:640px;margin-bottom:20px;"> - <p style="margin:0 0 12px;">Interface texts for <strong><?= htmlspecialchars($editing['name']) ?></strong> are stored in <code>translations/language-<?= htmlspecialchars($editing['code']) ?>.php</code>. A text left empty falls back to the original language.</p> + <p style="margin:0 0 12px;">Store texts in <strong><?= htmlspecialchars($editing['name']) ?></strong>. Anything you leave empty shows in your main language.</p> <form method="post"> <?= Csrf::field() ?> <input type="hidden" name="action" value="copy_missing"> <input type="hidden" name="code" value="<?= htmlspecialchars($editing['code'], ENT_QUOTES) ?>"> - <button type="submit" class="dash-btn" <?= $editMissing === 0 ? 'disabled' : '' ?>><?= Icons::icon('copy', 'icon icon-sm') ?>Copy missing texts from the original (<?= $editMissing ?>)</button> + <button type="submit" class="dash-btn" <?= $editMissing === 0 ? 'disabled' : '' ?>><?= Icons::icon('copy', 'icon icon-sm') ?>Fill empty texts with the original (<?= $editMissing ?>)</button> </form> - <p style="font-size:12.5px;color:var(--muted);margin:10px 0 0;">Only empty texts are filled in. Texts you already translated are never overwritten.</p> + <p style="font-size:12.5px;color:var(--muted);margin:10px 0 0;">Only empty fields get filled, your translations stay as they are.</p> </div> <form method="post" class="settings-section" style="max-width:none;"> ⋮ 21 unchanged lines ⋮
llms.php
⋮ 33 unchanged lines ⋮ echo "\n"; } - $pages = $db->query("SELECT title, slug FROM pages WHERE status = 'published' ORDER BY title ASC")->fetchAll(); + $pages = $db->query("SELECT title, slug FROM pages WHERE status = 'published' AND is_home = 0 ORDER BY title ASC")->fetchAll(); if (!empty($pages)) { echo "## Pages\n\n"; foreach ($pages as $page) { ⋮ 18 unchanged lines ⋮
media-library.php
⋮ 13 unchanged lines ⋮ if ($_SERVER['REQUEST_METHOD'] !== 'GET') { http_response_code(405); - echo json_encode(['success' => false, 'error' => 'Invalid request.']); + echo json_encode(['success' => false, 'error' => 'Something went wrong. Please refresh the page and try again.']); exit; } ⋮ 29 unchanged lines ⋮
media-upload.php
⋮ 13 unchanged lines ⋮ if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !Csrf::verify($_POST['csrf_token'] ?? null)) { http_response_code(403); - echo json_encode(['success' => false, 'error' => 'Invalid request.']); + echo json_encode(['success' => false, 'error' => 'Something went wrong. Please refresh the page and try again.']); exit; } if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) { http_response_code(400); - echo json_encode(['success' => false, 'error' => 'Upload failed.']); + echo json_encode(['success' => false, 'error' => 'The upload didn\'t work. Please try again.']); exit; } ⋮ 2 unchanged lines ⋮ if ($file['size'] > $maxBytes) { http_response_code(400); - echo json_encode(['success' => false, 'error' => 'File too large (max 25MB).']); + echo json_encode(['success' => false, 'error' => 'That file is too big. The limit is 25 MB.']); exit; } ⋮ 11 unchanged lines ⋮ if (!isset($allowedTypes[$mimeType])) { http_response_code(400); - echo json_encode(['success' => false, 'error' => 'Unsupported file type.']); + echo json_encode(['success' => false, 'error' => 'That file type isn\'t supported. Use JPG, PNG, GIF, WebP, MP4 or WebM.']); exit; } ⋮ 9 unchanged lines ⋮ if (!move_uploaded_file($file['tmp_name'], $targetPath)) { http_response_code(500); - echo json_encode(['success' => false, 'error' => 'Could not save file.']); + echo json_encode(['success' => false, 'error' => 'We couldn\'t save the file. Please try again.']); exit; } ⋮ 5 unchanged lines ⋮
page.php
⋮ 21 unchanged lines ⋮ if (!$page) { http_response_code(404); - $pageTitle = 'Not found - ' . $settings['site_name']; + $pageTitle = Language::get('page_title_not_found', 'Not found') . ' - ' . $settings['site_name']; require __DIR__ . '/includes/front-header.php'; echo '<p>' . htmlspecialchars(Language::get('page_not_found', 'Page not found.')) . '</p>'; require __DIR__ . '/includes/front-footer.php'; + exit; + } + + if ((int) ($page['is_home'] ?? 0) === 1) { + header('Location: index.php', true, 301); exit; } ⋮ 22 unchanged lines ⋮
product.php
⋮ 50 unchanged lines ⋮ } $query = $isPreview - ? "SELECT p.*, c.name AS category_name, c.slug AS category_slug, t.display_name AS author_name + ? "SELECT p.*, c.name AS category_name, c.slug AS category_slug, COALESCE(t.display_name, 'System') AS author_name FROM products p LEFT JOIN categories c ON c.id = p.category_id - JOIN team_accounts t ON t.id = p.created_by + LEFT JOIN team_accounts t ON t.id = p.created_by WHERE p.id = :id LIMIT 1" - : "SELECT p.*, c.name AS category_name, c.slug AS category_slug, t.display_name AS author_name + : "SELECT p.*, c.name AS category_name, c.slug AS category_slug, COALESCE(t.display_name, 'System') AS author_name FROM products p LEFT JOIN categories c ON c.id = p.category_id - JOIN team_accounts t ON t.id = p.created_by + LEFT JOIN team_accounts t ON t.id = p.created_by WHERE p.slug = :slug AND p.status = 'published' LIMIT 1"; $statement = $db->prepare($query); $statement->execute($isPreview ? ['id' => $previewId] : ['slug' => $slug]); ⋮ 1 unchanged line ⋮ if (!$product) { http_response_code(404); - $pageTitle = 'Not found - ' . $settings['site_name']; + $pageTitle = Language::get('page_title_not_found', 'Not found') . ' - ' . $settings['site_name']; require __DIR__ . '/includes/front-header.php'; echo '<p>' . htmlspecialchars(Language::get('product_not_found', 'Product not found.')) . '</p>'; require __DIR__ . '/includes/front-footer.php'; ⋮ 161 unchanged lines ⋮ <h1 class="article-title"><?= htmlspecialchars($product['title']) ?></h1> <?php if ($averageRating > 0): ?> - <div class="product-rating-summary" aria-label="Average rating <?= htmlspecialchars(number_format($averageRating, 1)) ?> out of 5"> + <div class="product-rating-summary" aria-label="<?= htmlspecialchars(str_replace('{rating}', number_format($averageRating, 1), Language::get('product_rating_aria', 'Average rating {rating} out of 5')), ENT_QUOTES) ?>"> <?php for ($star = 1; $star <= 5; $star++): ?><?= Icons::icon('star', 'icon star' . ($star <= round($averageRating) ? ' star-filled' : '')) ?><?php endfor; ?> <span class="product-rating-count"><?= htmlspecialchars(number_format($averageRating, 1)) ?> (<?= count($reviews) ?>)</span> </div> ⋮ 230 unchanged lines ⋮
search.php
⋮ 18 unchanged lines ⋮ if ($settings['search_enabled'] !== '1') { http_response_code(404); - $pageTitle = 'Not found - ' . $settings['site_name']; + $pageTitle = Language::get('page_title_not_found', 'Not found') . ' - ' . $settings['site_name']; require __DIR__ . '/includes/front-header.php'; echo '<p>' . htmlspecialchars(Language::get('search_not_available', 'Search is not available.')) . '</p>'; require __DIR__ . '/includes/front-footer.php'; ⋮ 145 unchanged lines ⋮
settings.php
Not shown (binary file or too large to diff).
sitemap.php
⋮ 16 unchanged lines ⋮ $baseUrl = rtrim(Config::get('APP_URL', ''), '/') . Config::get('APP_BASE_PATH', ''); $products = $db->query("SELECT id, slug, updated_at FROM products WHERE status = 'published' ORDER BY updated_at DESC")->fetchAll(); - $pages = $db->query("SELECT slug, updated_at FROM pages WHERE status = 'published'")->fetchAll(); + $pages = $db->query("SELECT slug, updated_at FROM pages WHERE status = 'published' AND is_home = 0")->fetchAll(); $categories = $db->query('SELECT slug FROM categories')->fetchAll(); header('Content-Type: application/xml; charset=utf-8'); ⋮ 30 unchanged lines ⋮
sql/schema_site.sql
⋮ 50 unchanged lines ⋮ CREATE TABLE IF NOT EXISTS products ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - created_by INT UNSIGNED NOT NULL, + created_by INT UNSIGNED NULL, category_id INT UNSIGNED NULL, title VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL, ⋮ 26 unchanged lines ⋮ KEY idx_product_category (category_id), KEY idx_product_status (status), KEY idx_product_published (published_at), - CONSTRAINT fk_product_author FOREIGN KEY (created_by) REFERENCES team_accounts(id) ON DELETE CASCADE, + CONSTRAINT fk_product_author FOREIGN KEY (created_by) REFERENCES team_accounts(id) ON DELETE SET NULL, CONSTRAINT fk_product_category FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ⋮ 25 unchanged lines ⋮ CREATE TABLE IF NOT EXISTS pages ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - author_id INT UNSIGNED NOT NULL, + author_id INT UNSIGNED NULL, title VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL, content_blocks LONGTEXT NOT NULL, status ENUM('draft','published') NOT NULL DEFAULT 'draft', show_in_menu TINYINT(1) NOT NULL DEFAULT 0, sort_order INT NOT NULL DEFAULT 0, + is_home TINYINT(1) NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uq_page_slug (slug), - CONSTRAINT fk_page_author FOREIGN KEY (author_id) REFERENCES team_accounts(id) ON DELETE CASCADE + CONSTRAINT fk_page_author FOREIGN KEY (author_id) REFERENCES team_accounts(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE IF NOT EXISTS site_settings ( ⋮ 10 unchanged lines ⋮ content LONGTEXT NOT NULL, link_url VARCHAR(255) NULL, is_active TINYINT(1) NOT NULL DEFAULT 1, - created_by INT UNSIGNED NOT NULL, + created_by INT UNSIGNED NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - CONSTRAINT fk_ad_creator FOREIGN KEY (created_by) REFERENCES team_accounts(id) ON DELETE CASCADE + CONSTRAINT fk_ad_creator FOREIGN KEY (created_by) REFERENCES team_accounts(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE IF NOT EXISTS analytics_scripts ( ⋮ 35 unchanged lines ⋮ starts_at DATETIME NULL, ends_at DATETIME NULL, is_active TINYINT(1) NOT NULL DEFAULT 1, - created_by INT UNSIGNED NOT NULL, + created_by INT UNSIGNED NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uq_discount_code (code), - CONSTRAINT fk_discount_creator FOREIGN KEY (created_by) REFERENCES team_accounts(id) ON DELETE CASCADE + CONSTRAINT fk_discount_creator FOREIGN KEY (created_by) REFERENCES team_accounts(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE IF NOT EXISTS product_files ( ⋮ 19 unchanged lines ⋮
tag.php
⋮ 20 unchanged lines ⋮ if ($tag === null) { http_response_code(404); - $pageTitle = 'Not found - ' . $settings['site_name']; + $pageTitle = Language::get('page_title_not_found', 'Not found') . ' - ' . $settings['site_name']; require __DIR__ . '/includes/front-header.php'; echo '<p>' . htmlspecialchars(Language::get('tag_not_found', 'Tag not found.')) . '</p>'; require __DIR__ . '/includes/front-footer.php'; ⋮ 59 unchanged lines ⋮
team-login.php
⋮ 39 unchanged lines ⋮ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!Csrf::verify($_POST['csrf_token'] ?? null)) { - $error = 'Security check failed, please try again.'; + $error = 'Your session expired. Please try again.'; } elseif ($pending !== null) { $identifier = (string) $pending['id']; $wait = LoginThrottle::secondsUntilAllowed('2fa', $identifier); ⋮ 7 unchanged lines ⋮ exit; } else { LoginThrottle::recordFailure('2fa', $identifier); - $error = 'Invalid code.'; + $error = 'That code didn\'t work. Please try again.'; } } elseif (!AntiBot::verify('team', $_POST['antibot_answer'] ?? null, $_POST['antibot_started'] ?? null, $_POST['website'] ?? null)) { - $error = 'Security verification failed, please try again.'; + $error = 'The security code wasn\'t right. Please try again.'; } else { $username = trim((string) ($_POST['username'] ?? '')); $password = (string) ($_POST['password'] ?? ''); if ($username === '' || $password === '') { - $error = 'Please enter your username and password.'; + $error = 'Please type your username and password.'; } else { $wait = LoginThrottle::secondsUntilAllowed('team', $username); ⋮ 4 unchanged lines ⋮ if ($account === null) { LoginThrottle::recordFailure('team', $username); - $error = 'Invalid credentials.'; + $error = 'That username or password isn\'t right.'; } else { LoginThrottle::clear('team', $username); ⋮ 42 unchanged lines ⋮ </script> <div class="auth-card"> <h1>Team login</h1> - <p class="auth-subtitle"><?= htmlspecialchars($siteName) ?> editorial access</p> + <p class="auth-subtitle">Sign in to manage <?= htmlspecialchars($siteName) ?></p> <?php if ($error !== null): ?> <div class="auth-error"><?= htmlspecialchars($error) ?></div> ⋮ 2 unchanged lines ⋮ <?php if ($pending !== null): ?> <form method="post"> <?= Csrf::field() ?> - <label>Authentication code (or a recovery code)</label> + <label>Code from your app (or a backup code)</label> <input type="text" name="code" required autofocus autocomplete="one-time-code" inputmode="text" maxlength="16" spellcheck="false"> <button type="submit">Verify</button> <p style="margin-top:14px;font-size:13px;"><a href="team-login.php?cancel=1">Back to login</a></p> ⋮ 21 unchanged lines ⋮
translate.php
⋮ 47 unchanged lines ⋮ if (!$canTranslate) { http_response_code(403); - exit('You do not have permission to translate this item.'); + exit('You don\'t have access to translate this.'); } $originalTitle = (string) ($type === 'category' ? $original['name'] : $original['title']); $originalExcerpt = (string) ($type === 'category' ? ($original['description'] ?? '') : ($original['excerpt'] ?? '')); $originalBlocks = (string) ($original['content_blocks'] ?? ''); + $isHomeSource = $type === 'page' && (int) ($original['is_home'] ?? 0) === 1; $originalSeoTitle = (string) ($original['seo_title'] ?? ''); $originalSeoDescription = (string) ($original['seo_description'] ?? ''); ⋮ 22 unchanged lines ⋮ if ($noLanguages === false && $_SERVER['REQUEST_METHOD'] === 'POST') { if (!Csrf::verify($_POST['csrf_token'] ?? null)) { - $flashMessage = 'Security check failed, please try again.'; + $flashMessage = 'Your session expired. Please try again.'; $flashType = 'error'; } else { $action = (string) ($_POST['action'] ?? ''); ⋮ 28 unchanged lines ⋮ $fields = [ 'title' => $title, 'excerpt' => in_array('excerpt', $source['fields'], true) ? trim((string) ($_POST['excerpt'] ?? '')) : '', - 'content_blocks' => in_array('blocks', $source['fields'], true) ? BlockEditor::sanitize((string) ($_POST['blocks_json'] ?? '{"blocks":[]}'), $type === 'page') : '', + 'content_blocks' => in_array('blocks', $source['fields'], true) ? BlockEditor::sanitize((string) ($_POST['blocks_json'] ?? '{"blocks":[]}'), $type === 'page' && !$isHomeSource, $isHomeSource) : '', 'seo_title' => in_array('seo_title', $source['fields'], true) ? mb_substr(trim((string) ($_POST['seo_title'] ?? '')), 0, 255) : '', 'seo_description' => in_array('seo_description', $source['fields'], true) ? mb_substr(trim((string) ($_POST['seo_description'] ?? '')), 0, 500) : '', ]; if ($title === '') { - $flashMessage = 'The translated title is required. To remove the translation use "Remove translation".'; + $flashMessage = 'Please fill in the title. If you want to drop this translation, use "Remove translation" instead.'; $flashType = 'error'; } else { Languages::saveTranslation($type, $entityId, $lang, $fields); ⋮ 7 unchanged lines ⋮ $done = (string) ($_GET['done'] ?? ''); if ($flashMessage === null && $done !== '') { - $flashMessage = ['saved' => 'Translation saved.', 'copied' => 'Missing fields copied from the original. Existing translations were not changed.', 'deleted' => 'Translation removed.'][$done] ?? null; + $flashMessage = ['saved' => 'Translation saved.', 'copied' => 'Empty fields were filled with the original text. Nothing you already translated was changed.', 'deleted' => 'Translation removed.'][$done] ?? null; } $translation = $noLanguages ? null : Languages::translation($type, $entityId, $lang); ⋮ 11 unchanged lines ⋮ $formSeoTitle = (string) ($translation['seo_title'] ?? ''); $formSeoDescription = (string) ($translation['seo_description'] ?? ''); $formBlocks = $translation !== null && !$blocksAreEmpty((string) ($translation['content_blocks'] ?? '')) ? (string) $translation['content_blocks'] : '{"blocks":[]}'; + if ($isHomeSource) { + $formBlocks = BlockEditor::sanitize($blocksAreEmpty($formBlocks) ? $originalBlocks : $formBlocks, false, true); + } $hasBlocks = in_array('blocks', $source['fields'], true); $dashActivePage = $type === 'category' ? 'categories' : ''; ⋮ 19 unchanged lines ⋮ <div class="dash-header-row"> <h1 class="dash-title"><?= Icons::icon('translate', 'icon icon-lg') ?>Translate <?= htmlspecialchars($source['label']) ?></h1> - <a href="<?= htmlspecialchars($source['back'] . $entityId, ENT_QUOTES) ?>" class="dash-btn"><?= Icons::icon('arrow-up', 'icon icon-sm') ?>Back to original</a> + <a href="<?= htmlspecialchars($source['back'] . $entityId, ENT_QUOTES) ?>" class="dash-btn"><?= Icons::icon('arrow-up', 'icon icon-sm') ?>Back</a> </div> <?php if ($flashMessage !== null): ?> ⋮ 2 unchanged lines ⋮ <?php if ($noLanguages): ?> <div class="sidebar-box" style="max-width:640px;"> - <p style="margin:0 0 12px;">No languages have been added yet.</p> + <p style="margin:0 0 12px;">You haven't added any languages yet.</p> <?php if ($isEditor): ?> <a href="languages.php" class="dash-btn dash-btn-primary"><?= Icons::icon('translate', 'icon icon-sm') ?>Add a language</a> <?php else: ?> - <p style="margin:0;color:var(--muted);">Ask an editor in chief to add one in Languages.</p> + <p style="margin:0;color:var(--muted);">Ask the store owner to add one in Languages.</p> <?php endif; ?> </div> <?php else: ?> ⋮ 8 unchanged lines ⋮ <p style="color:var(--muted);margin:0 0 18px;">Original (<?= htmlspecialchars(Languages::defaultName()) ?>): <strong><?= htmlspecialchars($originalTitle) ?></strong> · Translating to <strong><?= htmlspecialchars((string) $currentLanguage['name']) ?></strong> - <?php if ((int) $currentLanguage['is_active'] !== 1): ?>· <span class="status-pill status-draft">language inactive</span><?php endif; ?></p> + <?php if ((int) $currentLanguage['is_active'] !== 1): ?>· <span class="status-pill status-draft">hidden from customers</span><?php endif; ?></p> <form method="post" id="article-form" class="article-form"> <?= Csrf::field() ?> ⋮ 15 unchanged lines ⋮ <?php if ($hasBlocks): ?> <label><?= Icons::icon('grid', 'icon icon-sm') ?>Content</label> - <div id="block-editor-root" data-initial-blocks='<?= htmlspecialchars($formBlocks, ENT_QUOTES) ?>'></div> + <div id="block-editor-root" data-initial-blocks='<?= htmlspecialchars($formBlocks, ENT_QUOTES) ?>'<?= $isHomeSource ? ' data-home="1"' : '' ?>></div> <?php if (!$blocksAreEmpty($originalBlocks)): ?> <details class="sidebar-box" style="margin-top:14px;"> - <summary style="cursor:pointer;font-weight:600;">Show original content</summary> + <summary style="cursor:pointer;font-weight:600;">Show the original text</summary> <div class="article-content" style="margin-top:12px;"><?= BlockEditor::render($originalBlocks, false) ?></div> </details> <?php endif; ?> <?php endif; ?> <?php if (in_array('seo_title', $source['fields'], true)): ?> - <label>SEO title</label> + <label>Title in Google</label> <input type="text" name="seo_title" value="<?= htmlspecialchars($formSeoTitle) ?>" maxlength="255"> <?= $hint($originalSeoTitle) ?> - <label>SEO description</label> + <label>Description in Google</label> <textarea name="seo_description" rows="2" maxlength="500"><?= htmlspecialchars($formSeoDescription) ?></textarea> <?= $hint($originalSeoDescription) ?> <?php endif; ?> ⋮ 9 unchanged lines ⋮ <?= Csrf::field() ?> <input type="hidden" name="action" value="copy"> <input type="hidden" name="lang" value="<?= htmlspecialchars($lang, ENT_QUOTES) ?>"> - <button type="submit" class="dash-btn"><?= Icons::icon('copy', 'icon icon-sm') ?>Copy missing fields from the original</button> + <button type="submit" class="dash-btn"><?= Icons::icon('copy', 'icon icon-sm') ?>Fill empty fields with the original</button> </form> <?php if ($translation !== null): ?> - <form method="post" onsubmit="return confirm('Remove this translation? The original is not affected.');"> + <form method="post" onsubmit="return confirm('Remove this translation? The original stays as it is.');"> <?= Csrf::field() ?> <input type="hidden" name="action" value="delete"> <input type="hidden" name="lang" value="<?= htmlspecialchars($lang, ENT_QUOTES) ?>"> ⋮ 1 unchanged line ⋮ </form> <?php endif; ?> </div> - <p style="font-size:12.5px;color:var(--muted);margin:10px 0 0;">Copying only fills fields that are still empty. Anything already translated stays as it is. Fields left empty show the original text to visitors.</p> + <p style="font-size:12.5px;color:var(--muted);margin:10px 0 0;">Filling only touches empty fields, your translations stay. Anything left empty shows the original text to customers.</p> <?php endif; ?> ⋮ 2 unchanged lines ⋮
translations/language.php
⋮ 270 unchanged lines ⋮ 'subscription_status_past_due' => 'payment overdue', 'subscription_status_canceled' => 'canceled', 'subscription_status_expired' => 'expired', + 'subscription_status_unpaid' => 'unpaid', + 'subscription_status_incomplete' => 'waiting for payment', + 'subscription_status_incomplete_expired' => 'expired', + 'subscription_status_paused' => 'paused', 'email_order_subject' => 'Order confirmation — {site} #{order}', 'email_order_heading' => 'Thanks for your order!', ⋮ 32 unchanged lines ⋮ 'contact_email_page' => 'Page', 'contact_email_date' => 'Date', 'contact_email_reply_hint' => 'Reply to this email to answer the sender directly.', + 'page_title_not_found' => 'Not found', + 'pagination_label' => 'Pages', + 'product_rating_aria' => 'Average rating {rating} out of 5', + 'antibot_image_label' => 'Security code', + 'checkout_discount_name' => 'Discount', ];
updater.php
⋮ 21 unchanged lines ⋮ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!Csrf::verify($_POST['csrf_token'] ?? null)) { - $flashMessage = 'Security check failed, please try again.'; + $flashMessage = 'Your session expired. Please try again.'; $flashType = 'error'; } else { $action = $_POST['action'] ?? ''; if ($action === 'toggle_updater') { if ($lockedByConfig) { - $flashMessage = 'The updater is disabled in the server configuration.'; + $flashMessage = 'Updates are switched off in your server settings.'; $flashType = 'error'; } else { $turnOn = ($_POST['enabled'] ?? '0') === '1'; Updater::setEnabled($db, $turnOn); - $flashMessage = $turnOn ? 'Updater enabled.' : 'Updater disabled.'; + $flashMessage = $turnOn ? 'Updates turned on.' : 'Updates turned off.'; } } if ($action === 'save_auto_update' || $action === 'regenerate_cron_token') { if (!$canInstall) { - $flashMessage = 'Only the Super Admin can manage automatic updates.'; + $flashMessage = 'Only the Super Admin can change automatic updates.'; $flashType = 'error'; } elseif (!Updater::enabled($db)) { - $flashMessage = 'Turn on the updater first.'; + $flashMessage = 'Turn on updates first.'; $flashType = 'error'; } elseif ($action === 'regenerate_cron_token') { Updater::regenerateCronToken($db); - $flashMessage = 'A new cron token was generated. Update the URL in your cron job — the old one no longer works.'; + $flashMessage = 'New link created. Put it in your cron job, the old one won\'t work anymore.'; } else { $turnOnAuto = ($_POST['auto_enabled'] ?? '0') === '1'; if ($turnOnAuto && ($_POST['auto_risk_ack'] ?? '') !== '1') { - $flashMessage = 'To turn on automatic updates, confirm that you understand the risks.'; + $flashMessage = 'Please tick the box to confirm you understand the risks first.'; $flashType = 'error'; } else { Updater::saveAutoSettings($db, $turnOnAuto, ($_POST['auto_htaccess'] ?? '0') === '1'); - $flashMessage = $turnOnAuto ? 'Automatic updates turned on. Add the cron job below to run them.' : 'Automatic updates turned off.'; + $flashMessage = $turnOnAuto ? 'Automatic updates are on. Add the cron job below so they actually run.' : 'Automatic updates are off.'; } } } if ($action === 'check_updates' || $action === 'download_release') { if (!Updater::enabled($db)) { - $flashMessage = 'The updater is disabled.'; + $flashMessage = 'Updates are turned off.'; $flashType = 'error'; } else { session_write_close(); ⋮ 9 unchanged lines ⋮ if (in_array($action, ['install_release', 'restore_backup', 'delete_backup'], true)) { if (!$canInstall) { - $flashMessage = 'Only the Super Admin can install updates or manage backups.'; + $flashMessage = 'Only the Super Admin can install updates or use backups.'; $flashType = 'error'; } elseif ($action === 'install_release' && !Updater::enabled($db)) { - $flashMessage = 'The updater is disabled.'; + $flashMessage = 'Updates are turned off.'; $flashType = 'error'; } else { session_write_close(); ⋮ 3 unchanged lines ⋮ if ($action === 'install_release') { $outcome = Updater::install($currentVersion, ($_POST['update_htaccess'] ?? '1') === '1'); $flashMessage = $outcome['ok'] - ? 'Updated to ' . $outcome['version'] . ' (' . $outcome['installed'] . ' files). A backup was saved as ' . $outcome['backup'] . '.' + ? 'Updated to ' . $outcome['version'] . ' (' . $outcome['installed'] . ' files changed). We saved a backup first: ' . $outcome['backup'] . '.' : $outcome['error']; } elseif ($action === 'restore_backup') { $outcome = Updater::restore($backupId); ⋮ 2 unchanged lines ⋮ : $outcome['error']; } else { $outcome = ['ok' => Updater::deleteBackup($backupId)]; - $flashMessage = $outcome['ok'] ? 'Backup deleted.' : 'Backup not found.'; + $flashMessage = $outcome['ok'] ? 'Backup deleted.' : 'We couldn\'t find that backup.'; } $flashType = $outcome['ok'] ? 'success' : 'error'; ⋮ 32 unchanged lines ⋮ <div class="dash-card"> <?= Icons::icon('layers') ?> <div class="dash-card-value"><?= htmlspecialchars($currentVersion) ?></div> - <div class="dash-card-label">Installed version</div> + <div class="dash-card-label">Your version</div> </div> <div class="dash-card"> <?= Icons::icon('toggle') ?> - <div class="dash-card-value"><span class="status-pill status-<?= $updaterEnabled ? 'published' : 'archived' ?>"><?= $updaterEnabled ? 'Enabled' : 'Disabled' ?></span></div> - <div class="dash-card-label">Updater status</div> + <div class="dash-card-value"><span class="status-pill status-<?= $updaterEnabled ? 'published' : 'archived' ?>"><?= $updaterEnabled ? 'On' : 'Off' ?></span></div> + <div class="dash-card-label">Updates</div> </div> </div> <?php if ($lockedByConfig): ?> - <p class="updater-note"><?= Icons::icon('info', 'icon icon-sm') ?>The updater is turned off in the server configuration (UPDATER_DISABLED=1) and sends no requests.</p> + <p class="updater-note"><?= Icons::icon('info', 'icon icon-sm') ?>Updates are switched off in your server settings (UPDATER_DISABLED=1), so this site never checks for them.</p> <?php else: ?> <div class="publish-actions"> <?php if ($updaterEnabled): ?> ⋮ 8 unchanged lines ⋮ <input type="hidden" name="action" value="toggle_updater"> <input type="hidden" name="enabled" value="<?= $updaterEnabled ? '0' : '1' ?>"> <?php if ($updaterEnabled): ?> - <button type="submit" class="dash-btn dash-btn-danger"><?= Icons::icon('x', 'icon icon-sm') ?>Disable updater</button> + <button type="submit" class="dash-btn dash-btn-danger"><?= Icons::icon('x', 'icon icon-sm') ?>Turn off updates</button> <?php else: ?> - <button type="submit" class="dash-btn"><?= Icons::icon('check', 'icon icon-sm') ?>Enable updater</button> + <button type="submit" class="dash-btn"><?= Icons::icon('check', 'icon icon-sm') ?>Turn on updates</button> <?php endif; ?> </form> </div> <?php if (!$updaterEnabled): ?> - <p class="updater-note"><?= Icons::icon('info', 'icon icon-sm') ?>The updater is off by default. While it is off, this site sends no requests to the update server.</p> + <p class="updater-note"><?= Icons::icon('info', 'icon icon-sm') ?>Updates are off until you turn them on. While they're off, your store never contacts the update server.</p> <?php endif; ?> <?php if ($updaterEnabled): ?> - <h2 class="dash-subtitle"><?= Icons::icon('clock', 'icon icon-sm') ?>Automatic updates (cron)</h2> + <h2 class="dash-subtitle"><?= Icons::icon('clock', 'icon icon-sm') ?>Automatic updates</h2> <div class="updater-auto-warning" role="alert"> - <strong><?= Icons::icon('shield', 'icon icon-sm') ?>Warning: automatic updates can be dangerous for your store.</strong> + <strong><?= Icons::icon('shield', 'icon icon-sm') ?>Heads up: automatic updates can break your store.</strong> <ul> - <li>New versions are downloaded and installed <strong>without any human review or confirmation</strong>.</li> - <li>An update can <strong>overwrite or change features, templates and files</strong> — including your own edits to StocketBase files — and <strong>nobody will be asked or warned first</strong>.</li> - <li>A broken or incompatible release can take the storefront, checkout or dashboard offline until someone restores a backup.</li> - <li>Every automatic install makes a backup (kept for 30 days) that can be restored below, but restoring requires a person to log in.</li> + <li>New versions get installed <strong>without anyone checking them first</strong>.</li> + <li>An update can <strong>change or replace features and files</strong>, including your own edits, and <strong>nobody gets asked or warned</strong>.</li> + <li>A bad release could take your store, checkout or dashboard down until someone restores a backup.</li> + <li>We back up before every install and keep backups for 30 days, but someone still has to log in to restore one.</li> </ul> - <p>Leave this off unless you accept these risks. Manual updates above let you review every change before installing.</p> + <p>Leave this off unless you're OK with that. With manual updates you see every change before it goes in.</p> </div> <div class="dash-cards"> ⋮ 5 unchanged lines ⋮ <div class="dash-card"> <?= Icons::icon('clock') ?> <div class="dash-card-value" style="font-size:14px;"><?= $autoUpdate['last_run'] !== null ? htmlspecialchars(substr((string) $autoUpdate['last_run']['at'], 0, 19)) : 'Never' ?></div> - <div class="dash-card-label">Last cron run</div> + <div class="dash-card-label">Last automatic check</div> </div> </div> <?php if ($autoUpdate['last_run'] !== null): ?> - <p class="updater-note"><?= Icons::icon('info', 'icon icon-sm') ?>Last result (<?= htmlspecialchars((string) $autoUpdate['last_run']['status']) ?>): <?= htmlspecialchars((string) $autoUpdate['last_run']['message']) ?></p> + <p class="updater-note"><?= Icons::icon('info', 'icon icon-sm') ?>Last time (<?= htmlspecialchars((string) $autoUpdate['last_run']['status']) ?>): <?= htmlspecialchars((string) $autoUpdate['last_run']['message']) ?></p> <?php endif; ?> <?php if ($canInstall): ?> <form method="post" class="settings-section" style="max-width:720px;"> <?= Csrf::field() ?> <input type="hidden" name="action" value="save_auto_update"> - <label><input type="checkbox" name="auto_enabled" value="1" <?= $autoUpdate['enabled'] ? 'checked' : '' ?>> Install new releases automatically when the cron job runs</label> - <label><input type="checkbox" name="auto_htaccess" value="1" <?= $autoUpdate['htaccess'] ? 'checked' : '' ?>> Also overwrite .htaccess files during automatic updates (leave unchecked to keep your own server rules)</label> - <label><input type="checkbox" name="auto_risk_ack" value="1" <?= $autoUpdate['enabled'] ? 'checked' : '' ?>> I understand that automatic updates can break the store and overwrite features without anyone being informed</label> - <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:12px;"><?= Icons::icon('check', 'icon icon-sm') ?>Save automatic update settings</button> + <label><input type="checkbox" name="auto_enabled" value="1" <?= $autoUpdate['enabled'] ? 'checked' : '' ?>> Install new versions automatically</label> + <label><input type="checkbox" name="auto_htaccess" value="1" <?= $autoUpdate['htaccess'] ? 'checked' : '' ?>> Also replace .htaccess files (leave this off if you changed them yourself)</label> + <label><input type="checkbox" name="auto_risk_ack" value="1" <?= $autoUpdate['enabled'] ? 'checked' : '' ?>> I understand automatic updates can break my store and change things without telling anyone</label> + <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:12px;"><?= Icons::icon('check', 'icon icon-sm') ?>Save</button> </form> <?php if ($autoUpdate['enabled']): ?> <h2 class="dash-subtitle"><?= Icons::icon('code', 'icon icon-sm') ?>Cron job</h2> - <p class="updater-note"><?= Icons::icon('info', 'icon icon-sm') ?>Add one of these to your hosting's cron jobs (once a day is enough). Nothing happens until the cron job runs.</p> - <label>Command line (recommended)</label> + <p class="updater-note"><?= Icons::icon('info', 'icon icon-sm') ?>Add one of these as a cron job in your hosting panel. Once a day is plenty. Nothing happens until it runs.</p> + <label>Command (best option)</label> <input type="text" readonly value="<?= htmlspecialchars($cronCliCommand) ?>" onclick="this.select();"> - <p class="updater-note"><?= Icons::icon('info', 'icon icon-sm') ?>Run it as the same system user as the web server, otherwise updated files may end up with the wrong owner.</p> - <label>URL (for hosts without command-line cron)</label> + <p class="updater-note"><?= Icons::icon('info', 'icon icon-sm') ?>Run it as the same user as your website, or the new files may end up with the wrong permissions.</p> + <label>Link (if your host can't run commands)</label> <input type="text" readonly value="<?= htmlspecialchars('wget -q -O - "' . $cronHttpUrl . '"') ?>" onclick="this.select();"> - <p class="updater-note"><?= Icons::icon('lock', 'icon icon-sm') ?>The token in this URL lets anyone who knows it start an update. Keep it private.</p> - <form method="post" onsubmit="return confirm('Generate a new token? The current cron URL will stop working.');"> + <p class="updater-note"><?= Icons::icon('lock', 'icon icon-sm') ?>Anyone with this link can start an update, so keep it to yourself.</p> + <form method="post" onsubmit="return confirm('Create a new link? The current one will stop working.');"> <?= Csrf::field() ?> <input type="hidden" name="action" value="regenerate_cron_token"> - <button type="submit" class="dash-btn-small"><?= Icons::icon('refresh', 'icon icon-sm') ?>Generate new token</button> + <button type="submit" class="dash-btn-small"><?= Icons::icon('refresh', 'icon icon-sm') ?>Create a new link</button> </form> <?php endif; ?> <?php else: ?> - <p class="updater-note"><?= Icons::icon('lock', 'icon icon-sm') ?>Only the Super Admin can change automatic updates.</p> + <p class="updater-note"><?= Icons::icon('lock', 'icon icon-sm') ?>Only the Super Admin can change this.</p> <?php endif; ?> <?php endif; ?> <?php endif; ?> ⋮ 2 unchanged lines ⋮ <h2 class="dash-subtitle"><?= Icons::icon('download', 'icon icon-sm') ?>Result</h2> <?php if ($result['status'] === 'update'): ?> - <div class="dash-flash dash-flash-success"><?= Icons::icon('info', 'icon icon-sm') ?>Update available: <?= htmlspecialchars($result['remote_version']) ?><?= $result['released_at'] !== '' ? ' (' . htmlspecialchars($result['released_at']) . ')' : '' ?></div> + <div class="dash-flash dash-flash-success"><?= Icons::icon('info', 'icon icon-sm') ?>New version available: <?= htmlspecialchars($result['remote_version']) ?><?= $result['released_at'] !== '' ? ' (' . htmlspecialchars($result['released_at']) . ')' : '' ?></div> <?php elseif ($result['status'] === 'current'): ?> - <div class="dash-flash dash-flash-success"><?= Icons::icon('check', 'icon icon-sm') ?>You are running the latest version.</div> + <div class="dash-flash dash-flash-success"><?= Icons::icon('check', 'icon icon-sm') ?>You're up to date.</div> <?php else: ?> - <div class="dash-flash dash-flash-success"><?= Icons::icon('info', 'icon icon-sm') ?>This installation (<?= htmlspecialchars($currentVersion) ?>) is newer than the published version (<?= htmlspecialchars($result['remote_version']) ?>).</div> + <div class="dash-flash dash-flash-success"><?= Icons::icon('info', 'icon icon-sm') ?>This installation (<?= htmlspecialchars($currentVersion) ?>) is newer than the latest release (<?= htmlspecialchars($result['remote_version']) ?>).</div> <?php endif; ?> <?php if (!empty($result['changelog'])): ?> ⋮ 9 unchanged lines ⋮ <form method="post" class="publish-actions"> <?= Csrf::field() ?> <input type="hidden" name="action" value="download_release"> - <button type="submit" class="dash-btn dash-btn-primary"><?= Icons::icon('download', 'icon icon-sm') ?>Download and show changes</button> + <button type="submit" class="dash-btn dash-btn-primary"><?= Icons::icon('download', 'icon icon-sm') ?>See what's changed</button> </form> - <p class="updater-note"><?= Icons::icon('info', 'icon icon-sm') ?>The release is downloaded temporarily and compared with your files. Nothing is installed or changed.</p> + <p class="updater-note"><?= Icons::icon('info', 'icon icon-sm') ?>We just compare the new version with your files. Nothing gets installed yet.</p> <?php elseif (empty($result['files'])): ?> - <p class="updater-note"><?= Icons::icon('check', 'icon icon-sm') ?>Your files are identical to the release.</p> + <p class="updater-note"><?= Icons::icon('check', 'icon icon-sm') ?>Your files already match this version.</p> <?php endif; ?> <?php ⋮ 7 unchanged lines ⋮ <?php if ($result['compared'] && $result['status'] !== 'ahead' && !empty($result['files'])): ?> <?php if ($canInstall): ?> - <form method="post" class="publish-actions" onsubmit="return confirm('<?= $result['status'] === 'update' ? 'Install version' : 'Sync your files with version' ?> <?= htmlspecialchars($result['remote_version'], ENT_QUOTES) ?>? A backup of the files being replaced is saved first, and everything is rolled back if a file cannot be written.');"> + <form method="post" class="publish-actions" onsubmit="return confirm('<?= $result['status'] === 'update' ? 'Install version' : 'Match your files to version' ?> <?= htmlspecialchars($result['remote_version'], ENT_QUOTES) ?>? We back up first, and undo everything if something goes wrong.');"> <?= Csrf::field() ?> <input type="hidden" name="action" value="install_release"> <?php if ($htaccessCount > 0): ?> <input type="hidden" name="update_htaccess" value="0"> - <label style="flex-basis:100%;"><input type="checkbox" name="update_htaccess" value="1" checked> Also update .htaccess files (<?= (int) $htaccessCount ?>). Uncheck to keep your own server rules.</label> + <label style="flex-basis:100%;"><input type="checkbox" name="update_htaccess" value="1" checked> Also replace .htaccess files (<?= (int) $htaccessCount ?>). Untick if you changed them yourself.</label> <?php endif; ?> - <button type="submit" class="dash-btn dash-btn-primary"><?= Icons::icon('download', 'icon icon-sm') ?><?= $result['status'] === 'update' ? 'Install update' : 'Sync files with release' ?></button> + <button type="submit" class="dash-btn dash-btn-primary"><?= Icons::icon('download', 'icon icon-sm') ?><?= $result['status'] === 'update' ? 'Install update' : 'Match my files' ?></button> </form> - <p class="updater-note"><?= Icons::icon('shield', 'icon icon-sm') ?>Files are backed up to updater-files/backups before being replaced. Backups older than 30 days are deleted automatically.</p> + <p class="updater-note"><?= Icons::icon('shield', 'icon icon-sm') ?>We back up your files before replacing anything. Backups are kept for 30 days.</p> <?php else: ?> <p class="updater-note"><?= Icons::icon('lock', 'icon icon-sm') ?>Only the Super Admin can install updates.</p> <?php endif; ?> <?php endif; ?> <?php if (!empty($result['files'])): ?> - <h2 class="dash-subtitle"><?= Icons::icon('code', 'icon icon-sm') ?>Files (<?= count($result['files']) ?>)</h2> + <h2 class="dash-subtitle"><?= Icons::icon('code', 'icon icon-sm') ?>Changed files (<?= count($result['files']) ?>)</h2> <?php foreach ($result['files'] as $file): ?> <details class="updater-file"> ⋮ 45 unchanged lines ⋮ <?php endforeach; ?> <?php if ($result['skipped'] > 0): ?> - <p class="updater-note"><?= Icons::icon('info', 'icon icon-sm') ?><?= (int) $result['skipped'] ?> files were skipped because they are identical to yours or have an invalid path.</p> + <p class="updater-note"><?= Icons::icon('info', 'icon icon-sm') ?><?= (int) $result['skipped'] ?> files were skipped because they're already the same or couldn't be used.</p> <?php endif; ?> <?php endif; ?> <?php endif; ?> ⋮ 22 unchanged lines ⋮ <?php elseif ($backup['completed']): ?> <span class="status-pill status-published">Installed</span> <?php else: ?> - <span class="status-pill status-rejected">Not completed</span> + <span class="status-pill status-rejected">Didn't finish</span> <?php endif; ?> </td> <td class="dash-table-actions"> <?php if ($canInstall): ?> - <form method="post" onsubmit="return confirm('Restore the files from this backup? Files installed by the update will be replaced with the saved versions.');"> + <form method="post" onsubmit="return confirm('Restore this backup? Files from the update will be swapped back to the saved ones.');"> <?= Csrf::field() ?> <input type="hidden" name="action" value="restore_backup"> <input type="hidden" name="backup_id" value="<?= htmlspecialchars($backup['id']) ?>"> <button type="submit" class="dash-btn-small"><?= Icons::icon('refresh', 'icon icon-sm') ?>Restore</button> </form> - <form method="post" onsubmit="return confirm('Delete this backup permanently?');"> + <form method="post" onsubmit="return confirm('Delete this backup? You can\'t get it back.');"> <?= Csrf::field() ?> <input type="hidden" name="action" value="delete_backup"> <input type="hidden" name="backup_id" value="<?= htmlspecialchars($backup['id']) ?>"> ⋮ 12 unchanged lines ⋮
version.txt
- 1.0.0.0 + 1.0.0.1