WebOrbiton
v1.0.0.1

StocketBase

351 lines · 19.4 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. if (count(get_included_files()) === 1) {
  6. http_response_code(403);
  7. exit;
  8. }
  9. ​
  10. require_once __DIR__ . '/../includes/currency.php';
  11. require_once __DIR__ . '/../includes/digital-files.php';
  12. ​
  13. $db = Database::site();
  14. ​
  15. $productId = (int) ($_GET['id'] ?? 0) ?: null;
  16. $product = null;
  17. ​
  18. if ($productId !== null) {
  19. $statement = $db->prepare('SELECT * FROM products WHERE id = :id LIMIT 1');
  20. $statement->execute(['id' => $productId]);
  21. $product = $statement->fetch();
  22. ​
  23. if (!$product) {
  24. echo '<p>We couldn\'t find this product. It may have been deleted.</p>';
  25. return;
  26. }
  27. ​
  28. $isOwner = (int) $product['created_by'] === (int) $currentUser['id'];
  29. $canEditAny = Auth::hasRoleAtLeast(Auth::ROLE_STORE_OWNER);
  30. $canProofread = Auth::role() === Auth::ROLE_CATALOG_ASSISTANT;
  31. ​
  32. if (!$isOwner && !$canEditAny && !$canProofread) {
  33. echo '<p>You don\'t have access to this product.</p>';
  34. return;
  35. }
  36. ​
  37. if ($product['deleted_at'] !== null) {
  38. echo '<div class="dash-flash dash-flash-error">This product is in the trash. Restore it to make changes.</div>';
  39. if ($isOwner || $canEditAny) {
  40. echo '<form method="post">' . Csrf::field()
  41. . '<input type="hidden" name="action" value="restore_product">'
  42. . '<input type="hidden" name="product_id" value="' . (int) $product['id'] . '">'
  43. . '<button type="submit" class="dash-btn dash-btn-primary">Restore product</button></form>';
  44. }
  45. return;
  46. }
  47. }
  48. ​
  49. $categories = $db->query('SELECT id, name FROM categories ORDER BY name ASC')->fetchAll();
  50. $blocksJson = $product['content_blocks'] ?? '{"blocks":[]}';
  51. $readOnlyMeta = $product !== null && Auth::role() === Auth::ROLE_CATALOG_ASSISTANT && (int) $product['created_by'] !== (int) $currentUser['id'];
  52. ​
  53. $storeSettings = SiteFront::settings();
  54. $pricingModel = $storeSettings['pricing_model'] ?? 'per_product';
  55. $storeCategory = $storeSettings['store_category'] ?? 'general';
  56. $paymentProvider = $storeSettings['payment_provider'] ?? 'stripe';
  57. $currencyCode = Currency::normalize((string) ($product['currency'] ?? $storeSettings['store_currency'] ?? 'USD'));
  58. [$priceMajor, $priceMinor] = Currency::split((int) ($product['price_cents'] ?? 0), $currencyCode);
  59. [$comparePriceMajor, $comparePriceMinor] = $product && $product['compare_at_price_cents'] !== null
  60. ? Currency::split((int) $product['compare_at_price_cents'], $currencyCode)
  61. : [null, 0];
  62. ​
  63. $variants = $product ? $db->prepare('SELECT * FROM product_variants WHERE product_id = :id ORDER BY sort_order ASC') : null;
  64. if ($variants) {
  65. $variants->execute(['id' => (int) $product['id']]);
  66. $variants = $variants->fetchAll();
  67. } else {
  68. $variants = [];
  69. }
  70. ​
  71. ?>
  72. <div class="dash-header-row">
  73. <h1 class="dash-title"><?= Icons::icon('edit', 'icon icon-lg') ?><?= $product ? 'Edit product' : 'New product' ?></h1>
  74. <?php if ($product): ?>
  75. <?php if ($product['status'] === 'published' && !empty($product['slug'])): ?>
  76. <a href="<?= htmlspecialchars(SiteFront::productUrl($product['slug'])) ?>" class="dash-btn-small" target="_blank" rel="noopener noreferrer"><?= Icons::icon('eye', 'icon icon-sm') ?>View</a>
  77. <?php else: ?>
  78. <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>
  79. <?php endif; ?>
  80. <?php endif; ?>
  81. </div>
  82. ​
  83. <form method="post" id="product-form" class="article-form">
  84. <?= Csrf::field() ?>
  85. <input type="hidden" name="action" value="save_product">
  86. <?php if ($product): ?>
  87. <input type="hidden" name="product_id" value="<?= (int) $product['id'] ?>">
  88. <?php endif; ?>
  89. <input type="hidden" name="blocks_json" id="blocks_json_input">
  90. ​
  91. <div class="article-form-grid">
  92. <div class="article-form-main">
  93. ​
  94. <label><?= Icons::icon('heading', 'icon icon-sm') ?>Title</label>
  95. <input type="text" name="title" value="<?= htmlspecialchars($product['title'] ?? '') ?>" <?= $readOnlyMeta ? 'readonly' : '' ?> required>
  96. ​
  97. <label><?= Icons::icon('quote', 'icon icon-sm') ?>Short summary (shown on product cards)</label>
  98. <textarea name="excerpt" rows="2" <?= $readOnlyMeta ? 'readonly' : '' ?>><?= htmlspecialchars($product['excerpt'] ?? '') ?></textarea>
  99. ​
  100. <label><?= Icons::icon('grid', 'icon icon-sm') ?>Full description</label>
  101. <div id="block-editor-root" data-initial-blocks='<?= htmlspecialchars($blocksJson, ENT_QUOTES) ?>'></div>
  102. ​
  103. <?php if (!$readOnlyMeta): ?>
  104. <div class="sidebar-box">
  105. <label><?= Icons::icon('layers', 'icon icon-sm') ?>Variants (optional, like sizes or colors, one per row)</label>
  106. <table class="dash-table" id="variants-table">
  107. <thead><tr><th>Name</th><th>SKU</th><th>Different price</th><th>Stock</th><th></th></tr></thead>
  108. <tbody>
  109. <?php foreach ($variants as $variant): ?>
  110. <tr>
  111. <td><input type="text" name="variant_name[]" value="<?= htmlspecialchars($variant['name']) ?>"></td>
  112. <td><input type="text" name="variant_sku[]" value="<?= htmlspecialchars((string) ($variant['sku'] ?? '')) ?>"></td>
  113. <td><input type="number" step="0.01" name="variant_price_cents[]" value="<?= $variant['price_cents'] !== null ? htmlspecialchars((string) ($variant['price_cents'] / 100)) : '' ?>"></td>
  114. <td><input type="number" name="variant_stock[]" value="<?= htmlspecialchars((string) ($variant['stock_quantity'] ?? '')) ?>"></td>
  115. <td><button type="button" class="dash-btn-small dash-btn-danger js-remove-variant-row"><?= Icons::icon('x', 'icon icon-sm') ?></button></td>
  116. </tr>
  117. <?php endforeach; ?>
  118. </tbody>
  119. </table>
  120. <button type="button" class="dash-btn-small" id="js-add-variant-row"><?= Icons::icon('plus', 'icon icon-sm') ?>Add variant</button>
  121. </div>
  122. <script>
  123. (function () {
  124. var table = document.getElementById('variants-table').querySelector('tbody');
  125. document.getElementById('js-add-variant-row').addEventListener('click', function () {
  126. var row = document.createElement('tr');
  127. row.innerHTML = '<td><input type="text" name="variant_name[]"></td>'
  128. + '<td><input type="text" name="variant_sku[]"></td>'
  129. + '<td><input type="number" step="0.01" name="variant_price_cents[]"></td>'
  130. + '<td><input type="number" name="variant_stock[]"></td>'
  131. + '<td><button type="button" class="dash-btn-small dash-btn-danger js-remove-variant-row">&times;</button></td>';
  132. table.appendChild(row);
  133. });
  134. table.addEventListener('click', function (event) {
  135. if (event.target.closest('.js-remove-variant-row')) {
  136. event.target.closest('tr').remove();
  137. }
  138. });
  139. })();
  140. </script>
  141. <?php endif; ?>
  142. ​
  143. </div>
  144. ​
  145. <div class="article-form-sidebar">
  146. ​
  147. <div class="sidebar-box">
  148. <label>Category</label>
  149. <select name="category_id" <?= $readOnlyMeta ? 'disabled' : '' ?>>
  150. <option value="">No category</option>
  151. <?php foreach ($categories as $category): ?>
  152. <option value="<?= (int) $category['id'] ?>" <?= (int) ($product['category_id'] ?? 0) === (int) $category['id'] ? 'selected' : '' ?>>
  153. <?= htmlspecialchars($category['name']) ?>
  154. </option>
  155. <?php endforeach; ?>
  156. </select>
  157. ​
  158. <label>What kind of product is it?</label>
  159. <select name="product_kind" <?= $readOnlyMeta ? 'disabled' : '' ?>>
  160. <option value="physical" <?= ($product['product_kind'] ?? 'physical') === 'physical' ? 'selected' : '' ?>>Something I ship</option>
  161. <option value="digital" <?= ($product['product_kind'] ?? '') === 'digital' ? 'selected' : '' ?>>A file to download</option>
  162. <option value="saas_plan" <?= ($product['product_kind'] ?? '') === 'saas_plan' ? 'selected' : '' ?>>A software plan (SaaS)</option>
  163. </select>
  164. ​
  165. <label>SKU (your own product code, optional)</label>
  166. <input type="text" name="sku" value="<?= htmlspecialchars((string) ($product['sku'] ?? '')) ?>" <?= $readOnlyMeta ? 'readonly' : '' ?>>
  167. </div>
  168. ​
  169. <div class="sidebar-box">
  170. <label>Currency</label>
  171. <input type="text" name="currency" list="currency-list" value="<?= htmlspecialchars($currencyCode) ?>" maxlength="3" <?= $readOnlyMeta ? 'readonly' : '' ?>>
  172. <datalist id="currency-list">
  173. <?php foreach (Currency::all() as $code => $label): ?>
  174. <option value="<?= htmlspecialchars($code) ?>"><?= htmlspecialchars($label) ?></option>
  175. <?php endforeach; ?>
  176. </datalist>
  177. ​
  178. <?php if ($pricingModel === 'subscription'): ?>
  179. <label>Customers pay</label>
  180. <select name="billing_interval" <?= $readOnlyMeta ? 'disabled' : '' ?>>
  181. <option value="month" <?= ($product['billing_interval'] ?? $storeSettings['subscription_interval']) === 'month' ? 'selected' : '' ?>>Monthly</option>
  182. <option value="year" <?= ($product['billing_interval'] ?? '') === 'year' ? 'selected' : '' ?>>Yearly</option>
  183. <option value="one_time" <?= ($product['billing_interval'] ?? '') === 'one_time' ? 'selected' : '' ?>>Once (keep forever)</option>
  184. </select>
  185. <label>Price</label>
  186. <div style="display:flex;gap:6px;">
  187. <input type="number" name="price_major" value="<?= (int) $priceMajor ?>" min="0" style="flex:2;" <?= $readOnlyMeta ? 'readonly' : '' ?>>
  188. <input type="number" name="price_minor" value="<?= (int) $priceMinor ?>" min="0" style="flex:1;" <?= $readOnlyMeta ? 'readonly' : '' ?>>
  189. </div>
  190. <?php else: ?>
  191. <label>Price</label>
  192. <div style="display:flex;gap:6px;">
  193. <input type="number" name="price_major" value="<?= (int) $priceMajor ?>" min="0" style="flex:2;" <?= $readOnlyMeta ? 'readonly' : '' ?>>
  194. <input type="number" name="price_minor" value="<?= (int) $priceMinor ?>" min="0" style="flex:1;" <?= $readOnlyMeta ? 'readonly' : '' ?>>
  195. </div>
  196. <label>Old price (optional, shown crossed out)</label>
  197. <div style="display:flex;gap:6px;">
  198. <input type="number" name="compare_price_major" value="<?= $comparePriceMajor !== null ? (int) $comparePriceMajor : '' ?>" min="0" style="flex:2;" <?= $readOnlyMeta ? 'readonly' : '' ?>>
  199. <input type="number" name="compare_price_minor" value="<?= (int) $comparePriceMinor ?>" min="0" style="flex:1;" <?= $readOnlyMeta ? 'readonly' : '' ?>>
  200. </div>
  201. <label><input type="checkbox" name="track_inventory" <?= (int) ($product['track_inventory'] ?? 0) === 1 ? 'checked' : '' ?> <?= $readOnlyMeta ? 'disabled' : '' ?>> Keep track of stock</label>
  202. <label>How many in stock</label>
  203. <input type="number" name="stock_quantity" value="<?= htmlspecialchars((string) ($product['stock_quantity'] ?? '0')) ?>" <?= $readOnlyMeta ? 'readonly' : '' ?>>
  204. <label>Weight in grams (optional, for shipping)</label>
  205. <input type="number" name="weight_grams" value="<?= htmlspecialchars((string) ($product['weight_grams'] ?? '')) ?>" <?= $readOnlyMeta ? 'readonly' : '' ?>>
  206. <?php endif; ?>
  207. </div>
  208. ​
  209. <?php if ($product && !$readOnlyMeta && Auth::role() !== Auth::ROLE_CATALOG_ASSISTANT): ?>
  210. <?php
  211. $productFiles = DigitalFiles::filesForProduct((int) $product['id']);
  212. $downloadLoginRequired = ($storeSettings['require_account_to_purchase'] ?? '0') === '1';
  213. ?>
  214. <div class="sidebar-box" id="product-files">
  215. <label><?= Icons::icon('download', 'icon icon-sm') ?>Files to download</label>
  216. <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>
  217. <?php if (!empty($productFiles) && DigitalFiles::isFree($product, $variants)): ?>
  218. <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>
  219. <?php endif; ?>
  220. <?php if (!empty($productFiles)): ?>
  221. <ul class="product-files-list">
  222. <?php foreach ($productFiles as $productFile): ?>
  223. <li class="product-files-item">
  224. <div class="product-files-row">
  225. <span class="product-files-name"><?= htmlspecialchars($productFile['original_name']) ?><small><?= htmlspecialchars(DigitalFiles::formatSize((int) $productFile['size_bytes'])) ?></small></span>
  226. <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>
  227. </div>
  228. <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']) ?>">
  229. </li>
  230. <?php endforeach; ?>
  231. </ul>
  232. <button type="submit" form="product-file-label-form" class="dash-btn-small"><?= Icons::icon('check', 'icon icon-sm') ?>Save button text</button>
  233. <p class="product-files-hint">With no button text, we show "Download" and the file name.</p>
  234. <?php endif; ?>
  235. <input type="file" name="product_file" form="product-file-upload-form" required>
  236. <button type="submit" form="product-file-upload-form" class="dash-btn-small"><?= Icons::icon('upload', 'icon icon-sm') ?>Upload file</button>
  237. <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>
  238. </div>
  239. <?php endif; ?>
  240. ​
  241. <div class="sidebar-box">
  242. <label>Stripe price ID (optional, leave empty to charge the price above)</label>
  243. <input type="text" name="stripe_price_id" value="<?= htmlspecialchars((string) ($product['stripe_price_id'] ?? '')) ?>" placeholder="price_..." <?= $readOnlyMeta ? 'readonly' : '' ?>>
  244. <?php if ($storeCategory === 'saas' && $paymentProvider === 'polar'): ?>
  245. <label>Polar.sh product ID</label>
  246. <input type="text" name="polar_product_id" value="<?= htmlspecialchars((string) ($product['polar_product_id'] ?? '')) ?>" <?= $readOnlyMeta ? 'readonly' : '' ?>>
  247. <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>
  248. <?php endif; ?>
  249. </div>
  250. ​
  251. <?php if (ProductTags::isEnabled()): ?>
  252. <div class="sidebar-box">
  253. <label>Tags (separate with commas, up to <?= ProductTags::MAX_TAGS ?>)</label>
  254. <input type="text" name="tags" maxlength="700" value="<?= htmlspecialchars($product ? ProductTags::csvFor((int) $product['id']) : '') ?>" placeholder="new-arrival, bestseller" <?= $readOnlyMeta ? 'readonly' : '' ?>>
  255. </div>
  256. <?php endif; ?>
  257. ​
  258. <?php if ($product && !$readOnlyMeta && Languages::enabled() && !empty(Languages::all())): ?>
  259. <?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']) !== ''; } ?>
  260. <div class="sidebar-box">
  261. <label>Translations</label>
  262. <ul style="list-style:none;padding:0;margin:0;display:grid;gap:8px;font-size:13px;">
  263. <?php foreach (Languages::all() as $translationLanguage): ?>
  264. <li style="display:flex;justify-content:space-between;align-items:center;gap:8px;">
  265. <span><?= htmlspecialchars($translationLanguage['name']) ?> <span style="color:var(--muted);"><?= $translatedProductLanguages[$translationLanguage['code']] ? 'translated' : 'not translated' ?></span></span>
  266. <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>
  267. </li>
  268. <?php endforeach; ?>
  269. </ul>
  270. </div>
  271. <?php endif; ?>
  272. ​
  273. <div class="sidebar-box">
  274. <label>Main photo</label>
  275. <input type="text" name="cover_image_path" data-image-upload value="<?= htmlspecialchars($product['cover_image_path'] ?? '') ?>" <?= $readOnlyMeta ? 'readonly' : '' ?>>
  276. ​
  277. <label>Title in Google (optional)</label>
  278. <input type="text" name="seo_title" value="<?= htmlspecialchars($product['seo_title'] ?? '') ?>" <?= $readOnlyMeta ? 'readonly' : '' ?>>
  279. ​
  280. <label>Description in Google (optional)</label>
  281. <textarea name="seo_description" rows="2" <?= $readOnlyMeta ? 'readonly' : '' ?>><?= htmlspecialchars($product['seo_description'] ?? '') ?></textarea>
  282. </div>
  283. ​
  284. <?php if (!$readOnlyMeta): ?>
  285. <div class="sidebar-box">
  286. <label>Publish on</label>
  287. <input type="datetime-local" name="scheduled_at" value="<?= htmlspecialchars(!empty($product['scheduled_at']) ? date('Y-m-d\TH:i', strtotime((string) $product['scheduled_at'])) : '') ?>">
  288. ​
  289. <div class="publish-actions">
  290. <button type="submit" name="publish_action" value="save_draft" class="dash-btn"><?= Icons::icon('pages', 'icon icon-sm') ?>Save draft</button>
  291. <?php if (!Auth::canPublishDirectly()): ?>
  292. <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>
  293. <?php else: ?>
  294. <button type="submit" name="publish_action" value="publish_now" class="dash-btn dash-btn-primary"><?= Icons::icon('check', 'icon icon-sm') ?>Publish now</button>
  295. <button type="submit" name="publish_action" value="schedule" class="dash-btn"><?= Icons::icon('stats', 'icon icon-sm') ?>Schedule</button>
  296. <?php endif; ?>
  297. </div>
  298. </div>
  299. <?php else: ?>
  300. <button type="submit" class="dash-btn dash-btn-primary"><?= Icons::icon('check', 'icon icon-sm') ?>Save corrections</button>
  301. <?php endif; ?>
  302. ​
  303. <?php $productVersions = ($product && !$readOnlyMeta) ? ProductVersions::recent((int) $product['id']) : []; ?>
  304. <?php if (!empty($productVersions)): ?>
  305. <div class="sidebar-box">
  306. <label>Earlier versions</label>
  307. <ul style="list-style:none;padding:0;margin:0;display:grid;gap:10px;font-size:13px;">
  308. <?php foreach ($productVersions as $version): ?>
  309. <li style="display:flex;justify-content:space-between;align-items:center;gap:8px;">
  310. <span><?= htmlspecialchars($version['created_at']) ?><br><span style="color:var(--muted);"><?= htmlspecialchars((string) $version['saved_by_name']) ?> &middot; <?= htmlspecialchars($version['note']) ?></span></span>
  311. <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>
  312. </li>
  313. <?php endforeach; ?>
  314. </ul>
  315. </div>
  316. <?php endif; ?>
  317. ​
  318. <?php if ($product && $product['status'] === 'rejected' && !empty($product['rejection_reason'])): ?>
  319. <div class="dash-flash dash-flash-error">Sent back with a note: <?= htmlspecialchars($product['rejection_reason']) ?></div>
  320. <?php endif; ?>
  321. ​
  322. </div>
  323. </div>
  324. </form>
  325. ​
  326. <?php if ($product && !$readOnlyMeta && Auth::role() !== Auth::ROLE_CATALOG_ASSISTANT): ?>
  327. <form method="post" action="dashboard.php?view=product-edit&amp;id=<?= (int) $product['id'] ?>" enctype="multipart/form-data" id="product-file-upload-form">
  328. <?= Csrf::field() ?>
  329. <input type="hidden" name="action" value="upload_product_file">
  330. <input type="hidden" name="product_id" value="<?= (int) $product['id'] ?>">
  331. </form>
  332. <form method="post" action="dashboard.php?view=product-edit&amp;id=<?= (int) $product['id'] ?>" id="product-file-label-form">
  333. <?= Csrf::field() ?>
  334. <input type="hidden" name="action" value="save_product_file_labels">
  335. <input type="hidden" name="product_id" value="<?= (int) $product['id'] ?>">
  336. </form>
  337. <form method="post" action="dashboard.php?view=product-edit&amp;id=<?= (int) $product['id'] ?>" id="product-file-delete-form">
  338. <?= Csrf::field() ?>
  339. <input type="hidden" name="action" value="delete_product_file">
  340. <input type="hidden" name="product_id" value="<?= (int) $product['id'] ?>">
  341. </form>
  342. <?php endif; ?>
  343. ​
  344. <?php if (!empty($productVersions)): ?>
  345. <form method="post" id="version-restore-form">
  346. <?= Csrf::field() ?>
  347. <input type="hidden" name="action" value="restore_version">
  348. <input type="hidden" name="product_id" value="<?= (int) $product['id'] ?>">
  349. </form>
  350. <?php endif; ?>
  351. ​