WebOrbiton
v1.0.0.1

StocketBase

221 lines · 9.2 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/slugger.php';
  11. require_once __DIR__ . '/../includes/currency.php';
  12. ​
  13. $title = trim((string) ($_POST['title'] ?? ''));
  14. $categoryId = (int) ($_POST['category_id'] ?? 0) ?: null;
  15. $excerpt = trim((string) ($_POST['excerpt'] ?? ''));
  16. $seoTitle = trim((string) ($_POST['seo_title'] ?? ''));
  17. $seoDescription = trim((string) ($_POST['seo_description'] ?? ''));
  18. $coverImage = trim((string) ($_POST['cover_image_path'] ?? ''));
  19. $blocksJson = BlockEditor::sanitize((string) ($_POST['blocks_json'] ?? '{"blocks":[]}'));
  20. $productId = (int) ($_POST['product_id'] ?? 0) ?: null;
  21. $requestedAction = $_POST['publish_action'] ?? 'save_draft';
  22. $scheduledInput = trim((string) ($_POST['scheduled_at'] ?? ''));
  23. $scheduledTimestamp = $scheduledInput !== '' ? strtotime($scheduledInput) : false;
  24. $scheduledAt = $scheduledTimestamp !== false ? date('Y-m-d H:i:s', $scheduledTimestamp) : '';
  25. ​
  26. $sku = trim((string) ($_POST['sku'] ?? ''));
  27. $productKind = in_array($_POST['product_kind'] ?? '', ['physical', 'digital', 'saas_plan'], true) ? $_POST['product_kind'] : 'physical';
  28. $currencyCode = Currency::normalize((string) ($_POST['currency'] ?? SiteFront::settings()['store_currency'] ?? 'USD'));
  29. $priceMajor = (int) ($_POST['price_major'] ?? 0);
  30. $priceMinor = (int) ($_POST['price_minor'] ?? 0);
  31. $priceCents = Currency::toMinor($priceMajor, $priceMinor, $currencyCode);
  32. $compareMajorRaw = trim((string) ($_POST['compare_price_major'] ?? ''));
  33. $compareAtPriceCents = $compareMajorRaw !== ''
  34. ? Currency::toMinor((int) $compareMajorRaw, (int) ($_POST['compare_price_minor'] ?? 0), $currencyCode)
  35. : null;
  36. $billingInterval = in_array($_POST['billing_interval'] ?? '', ['month', 'year', 'one_time'], true) ? $_POST['billing_interval'] : null;
  37. $trackInventory = isset($_POST['track_inventory']) ? 1 : 0;
  38. $stockQuantity = $trackInventory ? (int) ($_POST['stock_quantity'] ?? 0) : null;
  39. $weightGrams = (int) ($_POST['weight_grams'] ?? 0) ?: null;
  40. $stripePriceId = trim((string) ($_POST['stripe_price_id'] ?? ''));
  41. $polarProductId = trim((string) ($_POST['polar_product_id'] ?? ''));
  42. ​
  43. if ($title === '') {
  44. return ['Give your product a name first.', 'error', null];
  45. }
  46. ​
  47. $slug = trim((string) ($_POST['slug'] ?? ''));
  48. if ($slug === '') {
  49. $slug = Slugger::make($title);
  50. }
  51. $slug = $slug . '-' . substr(bin2hex(random_bytes(3)), 0, 6);
  52. ​
  53. $db = Database::site();
  54. ​
  55. $syncVariants = static function (int $productId) use ($db): void {
  56. $names = $_POST['variant_name'] ?? [];
  57. if (!is_array($names)) {
  58. return;
  59. }
  60. ​
  61. $db->prepare('DELETE FROM product_variants WHERE product_id = :id')->execute(['id' => $productId]);
  62. ​
  63. $skus = $_POST['variant_sku'] ?? [];
  64. $prices = $_POST['variant_price_cents'] ?? [];
  65. $stocks = $_POST['variant_stock'] ?? [];
  66. $insert = $db->prepare(
  67. 'INSERT INTO product_variants (product_id, name, sku, price_cents, stock_quantity, sort_order, is_default)
  68. VALUES (:product_id, :name, :sku, :price, :stock, :sort, :is_default)'
  69. );
  70. ​
  71. $sort = 0;
  72. foreach ($names as $index => $name) {
  73. $name = trim((string) $name);
  74. if ($name === '') {
  75. continue;
  76. }
  77. $insert->execute([
  78. 'product_id' => $productId,
  79. 'name' => $name,
  80. 'sku' => trim((string) ($skus[$index] ?? '')) ?: null,
  81. 'price' => is_numeric($prices[$index] ?? null) ? (int) round(((float) $prices[$index]) * 100) : null,
  82. 'stock' => is_numeric($stocks[$index] ?? null) ? (int) $stocks[$index] : null,
  83. 'sort' => $sort,
  84. 'is_default' => $sort === 0 ? 1 : 0,
  85. ]);
  86. $sort++;
  87. }
  88. };
  89. ​
  90. if ($productId !== null) {
  91. $existingStatement = $db->prepare('SELECT * FROM products WHERE id = :id LIMIT 1');
  92. $existingStatement->execute(['id' => $productId]);
  93. $existing = $existingStatement->fetch();
  94. ​
  95. if (!$existing) {
  96. return ['We couldn\'t find that product.', 'error', null];
  97. }
  98. ​
  99. if ($existing['deleted_at'] !== null) {
  100. return ['This product is in the trash. Restore it first.', 'error', null];
  101. }
  102. ​
  103. $isOwner = (int) $existing['created_by'] === (int) $currentUser['id'];
  104. $canEditAny = Auth::hasRoleAtLeast(Auth::ROLE_STORE_OWNER);
  105. $canProofread = Auth::role() === Auth::ROLE_CATALOG_ASSISTANT;
  106. ​
  107. if (!$isOwner && !$canEditAny && !$canProofread) {
  108. return ['You don\'t have access to this product.', 'error', null];
  109. }
  110. ​
  111. if ($canProofread && !$isOwner) {
  112. $update = $db->prepare(
  113. 'UPDATE products SET content_blocks = :content, updated_at = NOW() WHERE id = :id'
  114. );
  115. $update->execute(['content' => $blocksJson, 'id' => $productId]);
  116. return ['Corrections saved.', 'success', $productId];
  117. }
  118. ​
  119. $status = $existing['status'];
  120. ​
  121. if ($requestedAction === 'submit_for_review') {
  122. $status = 'pending_review';
  123. } elseif ($requestedAction === 'publish_now') {
  124. $status = Auth::canPublishDirectly() || $canEditAny ? 'published' : 'pending_review';
  125. } elseif ($requestedAction === 'schedule' && $scheduledAt !== '') {
  126. $status = 'scheduled';
  127. } elseif ($requestedAction === 'save_draft') {
  128. $status = 'draft';
  129. }
  130. ​
  131. $publishedAt = $status === 'published' ? ($existing['published_at'] ?? date('Y-m-d H:i:s')) : $existing['published_at'];
  132. ​
  133. $update = $db->prepare(
  134. 'UPDATE products SET category_id = :category_id, title = :title, excerpt = :excerpt,
  135. content_blocks = :content, cover_image_path = :cover, sku = :sku, product_kind = :product_kind,
  136. price_cents = :price_cents, compare_at_price_cents = :compare_price, currency = :currency,
  137. billing_interval = :billing_interval, track_inventory = :track_inventory, stock_quantity = :stock_quantity,
  138. weight_grams = :weight_grams, stripe_price_id = :stripe_price_id, polar_product_id = :polar_product_id,
  139. status = :status, seo_title = :seo_title, seo_description = :seo_description,
  140. scheduled_at = :scheduled_at, published_at = :published_at, updated_at = NOW()
  141. WHERE id = :id'
  142. );
  143. $update->execute([
  144. 'category_id' => $categoryId,
  145. 'title' => $title,
  146. 'excerpt' => $excerpt,
  147. 'content' => $blocksJson,
  148. 'cover' => $coverImage,
  149. 'sku' => $sku !== '' ? $sku : null,
  150. 'product_kind' => $productKind,
  151. 'price_cents' => $priceCents,
  152. 'compare_price' => $compareAtPriceCents,
  153. 'currency' => $currencyCode,
  154. 'billing_interval' => $billingInterval,
  155. 'track_inventory' => $trackInventory,
  156. 'stock_quantity' => $stockQuantity,
  157. 'weight_grams' => $weightGrams,
  158. 'stripe_price_id' => $stripePriceId !== '' ? $stripePriceId : null,
  159. 'polar_product_id' => $polarProductId !== '' ? $polarProductId : null,
  160. 'status' => $status,
  161. 'seo_title' => $seoTitle,
  162. 'seo_description' => $seoDescription,
  163. 'scheduled_at' => $status === 'scheduled' ? $scheduledAt : null,
  164. 'published_at' => $publishedAt,
  165. 'id' => $productId,
  166. ]);
  167. ​
  168. $syncVariants($productId);
  169. ​
  170. return ['Product saved.', 'success', $productId];
  171. }
  172. ​
  173. $status = 'draft';
  174. if ($requestedAction === 'submit_for_review') {
  175. $status = 'pending_review';
  176. } elseif ($requestedAction === 'publish_now') {
  177. $status = Auth::canPublishDirectly() ? 'published' : 'pending_review';
  178. } elseif ($requestedAction === 'schedule' && $scheduledAt !== '') {
  179. $status = 'scheduled';
  180. }
  181. ​
  182. $insert = $db->prepare(
  183. 'INSERT INTO products (created_by, category_id, title, slug, excerpt, content_blocks, cover_image_path,
  184. sku, product_kind, price_cents, compare_at_price_cents, currency, billing_interval, track_inventory, stock_quantity,
  185. weight_grams, stripe_price_id, polar_product_id, status, seo_title, seo_description, scheduled_at, published_at)
  186. VALUES (:created_by, :category_id, :title, :slug, :excerpt, :content, :cover,
  187. :sku, :product_kind, :price_cents, :compare_price, :currency, :billing_interval, :track_inventory, :stock_quantity,
  188. :weight_grams, :stripe_price_id, :polar_product_id, :status, :seo_title, :seo_description, :scheduled_at, :published_at)'
  189. );
  190. $insert->execute([
  191. 'created_by' => $currentUser['id'],
  192. 'category_id' => $categoryId,
  193. 'title' => $title,
  194. 'slug' => $slug,
  195. 'excerpt' => $excerpt,
  196. 'content' => $blocksJson,
  197. 'cover' => $coverImage,
  198. 'sku' => $sku !== '' ? $sku : null,
  199. 'product_kind' => $productKind,
  200. 'price_cents' => $priceCents,
  201. 'compare_price' => $compareAtPriceCents,
  202. 'currency' => $currencyCode,
  203. 'billing_interval' => $billingInterval,
  204. 'track_inventory' => $trackInventory,
  205. 'stock_quantity' => $stockQuantity,
  206. 'weight_grams' => $weightGrams,
  207. 'stripe_price_id' => $stripePriceId !== '' ? $stripePriceId : null,
  208. 'polar_product_id' => $polarProductId !== '' ? $polarProductId : null,
  209. 'status' => $status,
  210. 'seo_title' => $seoTitle,
  211. 'seo_description' => $seoDescription,
  212. 'scheduled_at' => $status === 'scheduled' ? $scheduledAt : null,
  213. 'published_at' => $status === 'published' ? date('Y-m-d H:i:s') : null,
  214. ]);
  215. ​
  216. $newId = (int) $db->lastInsertId();
  217. ​
  218. $syncVariants($newId);
  219. ​
  220. return ['Product created.', 'success', $newId];
  221. ​