class-studiou-wc-fpp-cart.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. <?php
  2. if (!defined('WPINC')) {
  3. die;
  4. }
  5. class Studiou_WC_FPP_Cart {
  6. /** @var Studiou_WC_FPP_DB */
  7. private $db;
  8. public function __construct($db) {
  9. $this->db = $db;
  10. // New per-card add-to-cart / remove-line endpoints (1.5.0)
  11. add_action('wp_ajax_studiou_wcfpp_add_file_to_cart', array($this, 'ajax_add_file_to_cart'));
  12. add_action('wp_ajax_nopriv_studiou_wcfpp_add_file_to_cart', array($this, 'ajax_add_file_to_cart'));
  13. add_action('wp_ajax_studiou_wcfpp_remove_cart_line', array($this, 'ajax_remove_cart_line'));
  14. add_action('wp_ajax_nopriv_studiou_wcfpp_remove_cart_line', array($this, 'ajax_remove_cart_line'));
  15. // Capture file data from WC session on add-to-cart
  16. add_filter('woocommerce_add_cart_item_data', array($this, 'add_cart_item_data'), 10, 3);
  17. // Display uploaded file info in cart
  18. add_filter('woocommerce_get_item_data', array($this, 'display_cart_item_data'), 10, 2);
  19. // Replace cart item thumbnail with uploaded image (classic cart)
  20. add_filter('woocommerce_cart_item_thumbnail', array($this, 'cart_item_thumbnail'), 10, 3);
  21. // Show uploaded photo preview directly after item name in cart (classic cart)
  22. add_action('woocommerce_after_cart_item_name', array($this, 'show_cart_item_photo_preview'), 10, 2);
  23. // Block cart (Store API) reads the thumbnail from $cart_item['data']->get_image_id(). Stamp
  24. // the uploaded attachment onto a per-line clone of the product so each cart line shows its
  25. // OWN photo — even when several lines share the same variation_id.
  26. add_filter('woocommerce_get_cart_item_from_session', array($this, 'hydrate_cart_item_image'), 10, 3);
  27. // Save file reference to order item
  28. add_action('woocommerce_checkout_create_order_line_item', array($this, 'save_order_item_meta'), 10, 4);
  29. // Link file records to order after checkout (classic + block checkout)
  30. add_action('woocommerce_checkout_order_processed', array($this, 'link_files_to_order'), 10, 3);
  31. add_action('woocommerce_store_api_checkout_order_processed', array($this, 'link_files_to_order_from_store_api'), 10, 1);
  32. add_action('woocommerce_thankyou', array($this, 'link_files_to_order_fallback'), 10, 1);
  33. // Replace order item thumbnail with uploaded image (admin)
  34. add_filter('woocommerce_admin_order_item_thumbnail', array($this, 'admin_order_item_thumbnail'), 10, 3);
  35. // Add download link to order item in admin
  36. add_action('woocommerce_after_order_itemmeta', array($this, 'admin_order_item_download_link'), 10, 3);
  37. }
  38. public function add_cart_item_data($cart_item_data, $product_id, $variation_id) {
  39. // Check if this product (or its parent) is FPP enabled
  40. $fpp_product_id = $product_id;
  41. if (!Studiou_WC_FPP_Product::is_fpp_product($product_id)) {
  42. $product = wc_get_product($product_id);
  43. if ($product && $product->get_parent_id() && Studiou_WC_FPP_Product::is_fpp_product($product->get_parent_id())) {
  44. $fpp_product_id = $product->get_parent_id();
  45. } else {
  46. return $cart_item_data;
  47. }
  48. }
  49. // 1) New path (1.5.0+): per-card Add-to-Cart pre-stamped the file_record_id.
  50. if (!empty($cart_item_data['studiou_fpp_file_record_id'])) {
  51. $record = $this->db->get_file_record((int) $cart_item_data['studiou_fpp_file_record_id']);
  52. if ($record && (int) $record->order_id === 0) {
  53. $thumb = wp_get_attachment_image_src((int) $record->attachment_id, 'thumbnail');
  54. $cart_item_data['studiou_fpp_attachment_id'] = (int) $record->attachment_id;
  55. $cart_item_data['studiou_fpp_file_name'] = (string) $record->file_name;
  56. $cart_item_data['studiou_fpp_thumb_url'] = $thumb ? $thumb[0] : '';
  57. $this->db->update_file_record((int) $record->id, array(
  58. 'variation_id' => (int) ($variation_id ?: $product_id),
  59. ));
  60. return $cart_item_data;
  61. }
  62. // Stamped id is bogus — fall through to legacy resolver
  63. unset($cart_item_data['studiou_fpp_file_record_id']);
  64. }
  65. // 2) Legacy single-file path (shortcode / non-card callers) — fall back to the
  66. // oldest unbound upload in the current batch.
  67. $resolved = Studiou_WC_FPP_Single_Product::resolve_uploaded_file($this->db);
  68. if (!$resolved) {
  69. return $cart_item_data;
  70. }
  71. $cart_item_data['studiou_fpp_attachment_id'] = (int) $resolved['attachment_id'];
  72. $cart_item_data['studiou_fpp_file_record_id'] = (int) $resolved['file_record_id'];
  73. $cart_item_data['studiou_fpp_file_name'] = $resolved['file_name'];
  74. $cart_item_data['studiou_fpp_thumb_url'] = $resolved['thumb_url'];
  75. $this->db->update_file_record((int) $resolved['file_record_id'], array(
  76. 'variation_id' => (int) ($variation_id ?: $product_id),
  77. ));
  78. return $cart_item_data;
  79. }
  80. /**
  81. * AJAX: add one (upload × variant × qty) triple as a new cart line.
  82. * Called from the upload-card "Add to Cart" button.
  83. */
  84. public function ajax_add_file_to_cart() {
  85. check_ajax_referer('studiou-wcfpp-front-nonce', 'nonce');
  86. $file_record_id = isset($_POST['file_record_id']) ? absint($_POST['file_record_id']) : 0;
  87. $variation_id = isset($_POST['variation_id']) ? absint($_POST['variation_id']) : 0;
  88. $quantity = isset($_POST['quantity']) ? max(1, absint($_POST['quantity'])) : 1;
  89. if (!$file_record_id || !$variation_id) {
  90. wp_send_json_error(array('message' => __('Invalid request.', 'studiou-wc-free-photo-product')));
  91. return;
  92. }
  93. $record = $this->db->get_file_record($file_record_id);
  94. if (!$record || (int) $record->order_id !== 0) {
  95. wp_send_json_error(array('message' => __('File not found.', 'studiou-wc-free-photo-product')));
  96. return;
  97. }
  98. // Ownership check: record must belong to the caller's batch
  99. $token = Studiou_WC_FPP_Upload::get_batch_token();
  100. if (!empty($token) && !empty($record->batch_token) && $token !== $record->batch_token) {
  101. wp_send_json_error(array('message' => __('Permission denied.', 'studiou-wc-free-photo-product')));
  102. return;
  103. }
  104. $variation = wc_get_product($variation_id);
  105. if (!$variation || $variation->get_type() !== 'variation') {
  106. wp_send_json_error(array('message' => __('Invalid variant.', 'studiou-wc-free-photo-product')));
  107. return;
  108. }
  109. $parent_id = $variation->get_parent_id();
  110. if (!Studiou_WC_FPP_Product::is_fpp_product($parent_id)) {
  111. wp_send_json_error(array('message' => __('Invalid product.', 'studiou-wc-free-photo-product')));
  112. return;
  113. }
  114. if (function_exists('wc_load_cart')) {
  115. wc_load_cart();
  116. }
  117. if (!WC()->cart) {
  118. wp_send_json_error(array('message' => __('Cart unavailable.', 'studiou-wc-free-photo-product')));
  119. return;
  120. }
  121. $variation_attrs = $variation->get_variation_attributes();
  122. $cart_item_data = array('studiou_fpp_file_record_id' => (int) $file_record_id);
  123. $cart_key = WC()->cart->add_to_cart($parent_id, $quantity, $variation_id, $variation_attrs, $cart_item_data);
  124. if (!$cart_key) {
  125. // WC emits notices on failure — surface the first error if available
  126. $notices = function_exists('wc_get_notices') ? wc_get_notices('error') : array();
  127. $msg = __('Could not add to cart.', 'studiou-wc-free-photo-product');
  128. if (!empty($notices[0]['notice'])) {
  129. $msg = wp_strip_all_tags($notices[0]['notice']);
  130. }
  131. if (function_exists('wc_clear_notices')) {
  132. wc_clear_notices();
  133. }
  134. wp_send_json_error(array('message' => $msg));
  135. return;
  136. }
  137. wp_send_json_success(array(
  138. 'cart_key' => $cart_key,
  139. 'overview_html' => $this->render_overview_html($parent_id),
  140. ));
  141. }
  142. /**
  143. * AJAX: remove one cart line (from the order-overview panel).
  144. * Does NOT delete the upload — the × on the upload card handles that.
  145. */
  146. public function ajax_remove_cart_line() {
  147. check_ajax_referer('studiou-wcfpp-front-nonce', 'nonce');
  148. $cart_item_key = isset($_POST['cart_item_key']) ? sanitize_text_field($_POST['cart_item_key']) : '';
  149. if (!$cart_item_key) {
  150. wp_send_json_error(array('message' => __('Invalid request.', 'studiou-wc-free-photo-product')));
  151. return;
  152. }
  153. if (function_exists('wc_load_cart')) {
  154. wc_load_cart();
  155. }
  156. if (!WC()->cart) {
  157. wp_send_json_error(array('message' => __('Cart unavailable.', 'studiou-wc-free-photo-product')));
  158. return;
  159. }
  160. $item = WC()->cart->get_cart_item($cart_item_key);
  161. if (!$item) {
  162. wp_send_json_error(array('message' => __('Cart line not found.', 'studiou-wc-free-photo-product')));
  163. return;
  164. }
  165. $product_id = (int) ($item['product_id'] ?? 0);
  166. WC()->cart->remove_cart_item($cart_item_key);
  167. wp_send_json_success(array(
  168. 'overview_html' => $this->render_overview_html($product_id),
  169. ));
  170. }
  171. /**
  172. * Render the order-overview panel for the given product. Returns the HTML
  173. * so the JS can replace the in-page panel.
  174. */
  175. private function render_overview_html($product_id) {
  176. if (!$product_id) {
  177. return '';
  178. }
  179. $cart = WC()->cart;
  180. if (!$cart) {
  181. return '';
  182. }
  183. // Force a totals recalculation so the lines reflect the tier discount
  184. $cart->calculate_totals();
  185. ob_start();
  186. include STUDIOU_WCFPP_PLUGIN_DIR . 'views/single-product-order-overview.php';
  187. return ob_get_clean();
  188. }
  189. public function cart_item_thumbnail($thumbnail, $cart_item, $cart_item_key) {
  190. $url = $this->get_cart_item_thumb_url($cart_item);
  191. if ($url) {
  192. $alt = isset($cart_item['studiou_fpp_file_name']) ? esc_attr($cart_item['studiou_fpp_file_name']) : '';
  193. return '<img src="' . esc_url($url) . '" alt="' . $alt . '" class="attachment-woocommerce_thumbnail" />';
  194. }
  195. return $thumbnail;
  196. }
  197. /**
  198. * Hydrate each cart line with its own product instance carrying the uploaded image_id.
  199. *
  200. * Fires on session restore (both classic cart render and Store API block cart). Because the
  201. * product instance is cloned per line, setting `set_image_id()` only affects THIS line's
  202. * display — multiple cart lines sharing the same variation_id still render their own photos.
  203. *
  204. * Previous implementation hooked `woocommerce_product_get_image_id` which fires per-product
  205. * and couldn't tell which line was currently being rendered; when two lines shared a
  206. * variation it returned the first match for both, causing mismatched thumbs in block cart.
  207. */
  208. public function hydrate_cart_item_image($cart_item, $values, $cart_item_key) {
  209. if (empty($cart_item['studiou_fpp_attachment_id'])) {
  210. return $cart_item;
  211. }
  212. if (empty($cart_item['data']) || !is_object($cart_item['data'])) {
  213. return $cart_item;
  214. }
  215. if (!method_exists($cart_item['data'], 'set_image_id')) {
  216. return $cart_item;
  217. }
  218. $product = clone $cart_item['data'];
  219. $product->set_image_id((int) $cart_item['studiou_fpp_attachment_id']);
  220. $cart_item['data'] = $product;
  221. return $cart_item;
  222. }
  223. public function display_cart_item_data($item_data, $cart_item) {
  224. if (isset($cart_item['studiou_fpp_file_name'])) {
  225. $item_data[] = array(
  226. 'name' => __('Uploaded Photo', 'studiou-wc-free-photo-product'),
  227. 'value' => esc_html($cart_item['studiou_fpp_file_name']),
  228. 'display' => '',
  229. );
  230. }
  231. return $item_data;
  232. }
  233. public function show_cart_item_photo_preview($cart_item, $cart_item_key) {
  234. $url = $this->get_cart_item_thumb_url($cart_item);
  235. if ($url) {
  236. echo '<div class="studiou-fpp-cart-preview">';
  237. echo '<img src="' . esc_url($url) . '" alt="' . esc_attr($cart_item['studiou_fpp_file_name'] ?? '') . '" />';
  238. echo '</div>';
  239. }
  240. }
  241. private function get_cart_item_thumb_url($cart_item) {
  242. // 1. Use stored URL from session (most reliable)
  243. if (!empty($cart_item['studiou_fpp_thumb_url'])) {
  244. return $cart_item['studiou_fpp_thumb_url'];
  245. }
  246. // 2. Try WP attachment lookup
  247. if (!empty($cart_item['studiou_fpp_attachment_id'])) {
  248. $att_id = (int) $cart_item['studiou_fpp_attachment_id'];
  249. $thumb = wp_get_attachment_image_src($att_id, 'thumbnail');
  250. if ($thumb) {
  251. return $thumb[0];
  252. }
  253. $url = wp_get_attachment_url($att_id);
  254. if ($url) {
  255. return $url;
  256. }
  257. }
  258. return '';
  259. }
  260. public function save_order_item_meta($item, $cart_item_key, $values, $order) {
  261. if (isset($values['studiou_fpp_attachment_id'])) {
  262. $item->add_meta_data('_studiou_fpp_attachment_id', $values['studiou_fpp_attachment_id']);
  263. }
  264. if (isset($values['studiou_fpp_file_record_id'])) {
  265. $item->add_meta_data('_studiou_fpp_file_record_id', $values['studiou_fpp_file_record_id']);
  266. }
  267. if (isset($values['studiou_fpp_file_name'])) {
  268. $item->add_meta_data('_studiou_fpp_file_name', $values['studiou_fpp_file_name']);
  269. $item->add_meta_data(__('Uploaded Photo', 'studiou-wc-free-photo-product'), $values['studiou_fpp_file_name']);
  270. }
  271. if (isset($values['studiou_fpp_thumb_url'])) {
  272. $item->add_meta_data('_studiou_fpp_thumb_url', $values['studiou_fpp_thumb_url']);
  273. }
  274. }
  275. public function link_files_to_order($order_id, $posted_data, $order) {
  276. $this->do_link_files($order);
  277. }
  278. /**
  279. * Block checkout fires this hook with only the $order parameter.
  280. */
  281. public function link_files_to_order_from_store_api($order) {
  282. $this->do_link_files($order);
  283. }
  284. /**
  285. * Fallback: runs on thank-you page to catch any missed linking.
  286. */
  287. public function link_files_to_order_fallback($order_id) {
  288. $order = wc_get_order($order_id);
  289. if ($order) {
  290. $this->do_link_files($order);
  291. }
  292. }
  293. private function do_link_files($order) {
  294. if (!$order) {
  295. return;
  296. }
  297. $order_id = $order->get_id();
  298. foreach ($order->get_items() as $item_id => $item) {
  299. $file_record_id = $item->get_meta('_studiou_fpp_file_record_id');
  300. if ($file_record_id) {
  301. // Only link if not already linked
  302. $record = $this->db->get_file_record((int) $file_record_id);
  303. if ($record && (int) $record->order_id === 0) {
  304. $this->db->link_file_to_order((int) $file_record_id, $order_id, $item_id);
  305. }
  306. }
  307. }
  308. }
  309. public function admin_order_item_thumbnail($thumbnail, $item_id, $item) {
  310. // Try stored URL first
  311. $thumb_url = $item->get_meta('_studiou_fpp_thumb_url');
  312. if ($thumb_url) {
  313. return '<img src="' . esc_url($thumb_url) . '" alt="" class="wc-block-components-product-image" width="50" height="50" />';
  314. }
  315. // Fallback to attachment lookup
  316. $attachment_id = $item->get_meta('_studiou_fpp_attachment_id');
  317. if ($attachment_id) {
  318. $image = wp_get_attachment_image($attachment_id, 'thumbnail');
  319. if ($image) {
  320. return $image;
  321. }
  322. $url = wp_get_attachment_url($attachment_id);
  323. if ($url) {
  324. return '<img src="' . esc_url($url) . '" alt="" width="50" height="50" />';
  325. }
  326. }
  327. return $thumbnail;
  328. }
  329. public function admin_order_item_download_link($item_id, $item, $product) {
  330. $attachment_id = $item->get_meta('_studiou_fpp_attachment_id');
  331. if (!$attachment_id) {
  332. return;
  333. }
  334. $file_url = wp_get_attachment_url($attachment_id);
  335. $file_name = $item->get_meta('_studiou_fpp_file_name');
  336. if (!$file_url) {
  337. return;
  338. }
  339. $file_record_id = $item->get_meta('_studiou_fpp_file_record_id');
  340. // Build download URL that also marks file as downloaded
  341. $download_url = add_query_arg(array(
  342. 'studiou_fpp_download_file' => 1,
  343. 'file_record_id' => $file_record_id ?: 0,
  344. 'attachment_id' => $attachment_id,
  345. '_wpnonce' => wp_create_nonce('studiou-fpp-download-file'),
  346. ), admin_url('admin.php'));
  347. echo '<p class="studiou-fpp-order-download">';
  348. echo '<a href="' . esc_url($download_url) . '" class="button button-small">';
  349. echo '<span class="dashicons dashicons-download" style="line-height:1.4;vertical-align:middle;"></span> ';
  350. echo esc_html__('Download uploaded photo', 'studiou-wc-free-photo-product');
  351. echo '</a>';
  352. if ($file_name) {
  353. echo ' <small>(' . esc_html($file_name) . ')</small>';
  354. }
  355. echo '</p>';
  356. }
  357. }