v1.0.0.1
StocketBase
- <?php
-
- declare(strict_types=1);
-
- if (count(get_included_files()) === 1) {
- http_response_code(403);
- exit;
- }
-
- require_once __DIR__ . '/../includes/currency.php';
- require_once __DIR__ . '/../includes/digital-files.php';
-
- $db = Database::site();
-
- $productId = (int) ($_GET['id'] ?? 0) ?: null;
- $product = null;
-
- if ($productId !== null) {
- $statement = $db->prepare('SELECT * FROM products WHERE id = :id LIMIT 1');
- $statement->execute(['id' => $productId]);
- $product = $statement->fetch();
-
- if (!$product) {
- echo '<p>We couldn\'t find this product. It may have been deleted.</p>';
- return;
- }
-
- $isOwner = (int) $product['created_by'] === (int) $currentUser['id'];
- $canEditAny = Auth::hasRoleAtLeast(Auth::ROLE_STORE_OWNER);
- $canProofread = Auth::role() === Auth::ROLE_CATALOG_ASSISTANT;
-
- if (!$isOwner && !$canEditAny && !$canProofread) {
- 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. Restore it to make changes.</div>';
- if ($isOwner || $canEditAny) {
- echo '<form method="post">' . Csrf::field()
- . '<input type="hidden" name="action" value="restore_product">'
- . '<input type="hidden" name="product_id" value="' . (int) $product['id'] . '">'
- . '<button type="submit" class="dash-btn dash-btn-primary">Restore product</button></form>';
- }
- return;
- }
- }
-
- $categories = $db->query('SELECT id, name FROM categories ORDER BY name ASC')->fetchAll();
- $blocksJson = $product['content_blocks'] ?? '{"blocks":[]}';
- $readOnlyMeta = $product !== null && Auth::role() === Auth::ROLE_CATALOG_ASSISTANT && (int) $product['created_by'] !== (int) $currentUser['id'];
-
- $storeSettings = SiteFront::settings();
- $pricingModel = $storeSettings['pricing_model'] ?? 'per_product';
- $storeCategory = $storeSettings['store_category'] ?? 'general';
- $paymentProvider = $storeSettings['payment_provider'] ?? 'stripe';
- $currencyCode = Currency::normalize((string) ($product['currency'] ?? $storeSettings['store_currency'] ?? 'USD'));
- [$priceMajor, $priceMinor] = Currency::split((int) ($product['price_cents'] ?? 0), $currencyCode);
- [$comparePriceMajor, $comparePriceMinor] = $product && $product['compare_at_price_cents'] !== null
- ? Currency::split((int) $product['compare_at_price_cents'], $currencyCode)
- : [null, 0];
-
- $variants = $product ? $db->prepare('SELECT * FROM product_variants WHERE product_id = :id ORDER BY sort_order ASC') : null;
- if ($variants) {
- $variants->execute(['id' => (int) $product['id']]);
- $variants = $variants->fetchAll();
- } else {
- $variants = [];
- }
-
- ?>
- <div class="dash-header-row">
- <h1 class="dash-title"><?= Icons::icon('edit', 'icon icon-lg') ?><?= $product ? 'Edit product' : 'New product' ?></h1>
- <?php if ($product): ?>
- <?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') ?>Preview</a>
- <?php endif; ?>
- <?php endif; ?>
- </div>
-
- <form method="post" id="product-form" class="article-form">
- <?= Csrf::field() ?>
- <input type="hidden" name="action" value="save_product">
- <?php if ($product): ?>
- <input type="hidden" name="product_id" value="<?= (int) $product['id'] ?>">
- <?php endif; ?>
- <input type="hidden" name="blocks_json" id="blocks_json_input">
-
- <div class="article-form-grid">
- <div class="article-form-main">
-
- <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 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>
- <div id="block-editor-root" data-initial-blocks='<?= htmlspecialchars($blocksJson, ENT_QUOTES) ?>'></div>
-
- <?php if (!$readOnlyMeta): ?>
- <div class="sidebar-box">
- <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>Different price</th><th>Stock</th><th></th></tr></thead>
- <tbody>
- <?php foreach ($variants as $variant): ?>
- <tr>
- <td><input type="text" name="variant_name[]" value="<?= htmlspecialchars($variant['name']) ?>"></td>
- <td><input type="text" name="variant_sku[]" value="<?= htmlspecialchars((string) ($variant['sku'] ?? '')) ?>"></td>
- <td><input type="number" step="0.01" name="variant_price_cents[]" value="<?= $variant['price_cents'] !== null ? htmlspecialchars((string) ($variant['price_cents'] / 100)) : '' ?>"></td>
- <td><input type="number" name="variant_stock[]" value="<?= htmlspecialchars((string) ($variant['stock_quantity'] ?? '')) ?>"></td>
- <td><button type="button" class="dash-btn-small dash-btn-danger js-remove-variant-row"><?= Icons::icon('x', 'icon icon-sm') ?></button></td>
- </tr>
- <?php endforeach; ?>
- </tbody>
- </table>
- <button type="button" class="dash-btn-small" id="js-add-variant-row"><?= Icons::icon('plus', 'icon icon-sm') ?>Add variant</button>
- </div>
- <script>
- (function () {
- var table = document.getElementById('variants-table').querySelector('tbody');
- document.getElementById('js-add-variant-row').addEventListener('click', function () {
- var row = document.createElement('tr');
- row.innerHTML = '<td><input type="text" name="variant_name[]"></td>'
- + '<td><input type="text" name="variant_sku[]"></td>'
- + '<td><input type="number" step="0.01" name="variant_price_cents[]"></td>'
- + '<td><input type="number" name="variant_stock[]"></td>'
- + '<td><button type="button" class="dash-btn-small dash-btn-danger js-remove-variant-row">×</button></td>';
- table.appendChild(row);
- });
- table.addEventListener('click', function (event) {
- if (event.target.closest('.js-remove-variant-row')) {
- event.target.closest('tr').remove();
- }
- });
- })();
- </script>
- <?php endif; ?>
-
- </div>
-
- <div class="article-form-sidebar">
-
- <div class="sidebar-box">
- <label>Category</label>
- <select name="category_id" <?= $readOnlyMeta ? 'disabled' : '' ?>>
- <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']) ?>
- </option>
- <?php endforeach; ?>
- </select>
-
- <label>What kind of product is it?</label>
- <select name="product_kind" <?= $readOnlyMeta ? 'disabled' : '' ?>>
- <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 (your own product code, optional)</label>
- <input type="text" name="sku" value="<?= htmlspecialchars((string) ($product['sku'] ?? '')) ?>" <?= $readOnlyMeta ? 'readonly' : '' ?>>
- </div>
-
- <div class="sidebar-box">
- <label>Currency</label>
- <input type="text" name="currency" list="currency-list" value="<?= htmlspecialchars($currencyCode) ?>" maxlength="3" <?= $readOnlyMeta ? 'readonly' : '' ?>>
- <datalist id="currency-list">
- <?php foreach (Currency::all() as $code => $label): ?>
- <option value="<?= htmlspecialchars($code) ?>"><?= htmlspecialchars($label) ?></option>
- <?php endforeach; ?>
- </datalist>
-
- <?php if ($pricingModel === 'subscription'): ?>
- <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' : '' ?>>Once (keep forever)</option>
- </select>
- <label>Price</label>
- <div style="display:flex;gap:6px;">
- <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>
- <?php else: ?>
- <label>Price</label>
- <div style="display:flex;gap:6px;">
- <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>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' : '' ?>> 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 in grams (optional, for shipping)</label>
- <input type="number" name="weight_grams" value="<?= htmlspecialchars((string) ($product['weight_grams'] ?? '')) ?>" <?= $readOnlyMeta ? 'readonly' : '' ?>>
- <?php endif; ?>
- </div>
-
- <?php if ($product && !$readOnlyMeta && Auth::role() !== Auth::ROLE_CATALOG_ASSISTANT): ?>
- <?php
- $productFiles = DigitalFiles::filesForProduct((int) $product['id']);
- $downloadLoginRequired = ($storeSettings['require_account_to_purchase'] ?? '0') === '1';
- ?>
- <div class="sidebar-box" id="product-files">
- <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') ?>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">
- <?php foreach ($productFiles as $productFile): ?>
- <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 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), 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 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. 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, 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 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 (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; ?>
-
- <?php if ($product && !$readOnlyMeta && Languages::enabled() && !empty(Languages::all())): ?>
- <?php $translatedProductLanguages = []; foreach (Languages::all() as $translationLanguage) { $found = Languages::translation('product', (int) $product['id'], $translationLanguage['code']); $translatedProductLanguages[$translationLanguage['code']] = $found !== null && trim((string) $found['title']) !== ''; } ?>
- <div class="sidebar-box">
- <label>Translations</label>
- <ul style="list-style:none;padding:0;margin:0;display:grid;gap:8px;font-size:13px;">
- <?php foreach (Languages::all() as $translationLanguage): ?>
- <li style="display:flex;justify-content:space-between;align-items:center;gap:8px;">
- <span><?= htmlspecialchars($translationLanguage['name']) ?> <span style="color:var(--muted);"><?= $translatedProductLanguages[$translationLanguage['code']] ? 'translated' : 'not translated' ?></span></span>
- <a href="translate.php?type=product&id=<?= (int) $product['id'] ?>&lang=<?= urlencode($translationLanguage['code']) ?>" class="dash-btn-small"><?= Icons::icon('translate', 'icon icon-sm') ?><?= $translatedProductLanguages[$translationLanguage['code']] ? 'Edit' : 'Translate' ?></a>
- </li>
- <?php endforeach; ?>
- </ul>
- </div>
- <?php endif; ?>
-
- <div class="sidebar-box">
- <label>Main photo</label>
- <input type="text" name="cover_image_path" data-image-upload value="<?= htmlspecialchars($product['cover_image_path'] ?? '') ?>" <?= $readOnlyMeta ? 'readonly' : '' ?>>
-
- <label>Title in Google (optional)</label>
- <input type="text" name="seo_title" value="<?= htmlspecialchars($product['seo_title'] ?? '') ?>" <?= $readOnlyMeta ? 'readonly' : '' ?>>
-
- <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>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') ?>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>
- <?php endif; ?>
- </div>
- </div>
- <?php else: ?>
- <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>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('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>
- </div>
- <?php endif; ?>
-
- <?php if ($product && $product['status'] === 'rejected' && !empty($product['rejection_reason'])): ?>
- <div class="dash-flash dash-flash-error">Sent back with a note: <?= htmlspecialchars($product['rejection_reason']) ?></div>
- <?php endif; ?>
-
- </div>
- </div>
- </form>
-
- <?php if ($product && !$readOnlyMeta && Auth::role() !== Auth::ROLE_CATALOG_ASSISTANT): ?>
- <form method="post" action="dashboard.php?view=product-edit&id=<?= (int) $product['id'] ?>" enctype="multipart/form-data" id="product-file-upload-form">
- <?= Csrf::field() ?>
- <input type="hidden" name="action" value="upload_product_file">
- <input type="hidden" name="product_id" value="<?= (int) $product['id'] ?>">
- </form>
- <form method="post" action="dashboard.php?view=product-edit&id=<?= (int) $product['id'] ?>" id="product-file-label-form">
- <?= Csrf::field() ?>
- <input type="hidden" name="action" value="save_product_file_labels">
- <input type="hidden" name="product_id" value="<?= (int) $product['id'] ?>">
- </form>
- <form method="post" action="dashboard.php?view=product-edit&id=<?= (int) $product['id'] ?>" id="product-file-delete-form">
- <?= Csrf::field() ?>
- <input type="hidden" name="action" value="delete_product_file">
- <input type="hidden" name="product_id" value="<?= (int) $product['id'] ?>">
- </form>
- <?php endif; ?>
-
- <?php if (!empty($productVersions)): ?>
- <form method="post" id="version-restore-form">
- <?= Csrf::field() ?>
- <input type="hidden" name="action" value="restore_version">
- <input type="hidden" name="product_id" value="<?= (int) $product['id'] ?>">
- </form>
- <?php endif; ?>
-