PluginProbe ʕ •ᴥ•ʔ
SureCart – Ecommerce Made Easy For Selling Physical Products, Digital Downloads, Subscriptions, Donations, & Payments / 4.5.1
SureCart – Ecommerce Made Easy For Selling Physical Products, Digital Downloads, Subscriptions, Donations, & Payments v4.5.1
4.6.2 4.6.1 4.6.0 4.5.1 4.5.0 4.4.2 4.4.1 4.4.0 4.3.3 4.3.2 4.3.1 4.3.0 4.2.3 4.2.2 4.2.1 1.0.3 1.0.4 1.0.5 1.0.6 1.1.0 1.1.1 1.1.10 1.1.11 1.1.12 1.1.13 1.1.14 1.1.15 1.1.16 1.1.17 1.1.18 1.1.19 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.1.9 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.11.0 1.11.1 1.11.2 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.4.0 1.4.1 1.4.2 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.7.0 1.7.1 1.7.2 1.8.0 1.8.1 1.8.2 1.8.3 1.8.4 1.8.5 1.9.0 1.9.1 1.9.2 1.9.3 1.9.4 1.9.5 2.0.0 2.0.1 2.1.0 2.1.1 2.1.2 2.1.3 2.1.4 2.10.0 2.10.1 2.11.0 2.11.1 2.11.2 2.11.3 2.11.4 2.12.0 2.13.0 2.14.0 2.14.1 2.15.0 2.15.1 2.16.0 2.16.1 2.16.2 2.16.3 2.17.0 2.17.1 2.17.2 2.18.0 2.19.0 2.19.2 2.19.3 2.19.4 2.2.0 2.2.1 2.20.0 2.20.1 2.20.2 2.20.3 2.20.4 2.20.5 2.20.6 2.21.0 2.22.0 2.22.1 2.23.0 2.24.0 2.25.0 2.25.1 2.25.2 2.26.0 2.27.0 2.27.1 2.28.0 2.29.0 2.29.1 2.29.2 2.29.3 2.29.4 2.3.0 2.3.1 2.30.0 2.31.0 2.31.1 2.31.2 2.31.3 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.40.0 2.40.1 2.5.0 2.5.1 2.5.2 2.6.0 2.6.1 2.6.2 2.7.0 2.7.1 2.7.2 2.7.3 2.7.4 2.7.5 2.8.0 2.8.1 2.8.2 2.8.3 2.8.4 2.9.0 3.0.0 3.0.0-RC1 3.0.0-RC2 3.0.0-beta1 3.0.0-beta2 3.0.1 3.0.2 3.0.3 3.0.4 3.0.5 3.1.0 3.1.1 3.1.2 3.1.3 3.1.4 3.1.5 3.1.6 3.10.0 3.10.1 3.11.0 3.12.0 3.13.0 3.13.1 3.13.2 3.13.3 3.13.4 3.14.0 3.15.0 3.15.1 3.15.2 3.15.3 3.15.4 3.15.5 3.16.0 3.16.1 3.16.2 3.16.3 3.16.4 3.16.5 3.16.6 3.16.7 3.16.8 3.17.0 3.17.1 3.17.2 3.17.3 3.17.4 3.17.5 3.17.6 3.18.0 3.19.0 3.19.1 3.19.2 3.2.0 3.2.1 3.2.2 3.20.0 3.20.1 3.3.0 3.3.1 3.4.0 3.4.1 3.4.2 3.4.3 3.5.0 3.5.1 3.5.2 3.5.3 3.6.0 3.6.1 3.6.2 3.7.0 3.7.1 3.7.2 3.7.3 3.8.0 3.8.1 3.8.2 3.8.3 3.8.4 3.8.5 3.9.0 4.0.0 4.0.1 4.0.2 4.0.3 trunk 4.1.0 0.2.19.1 4.1.1 1.0.0 4.2.0 1.0.1 1.0.2
surecart / app / src / Controllers / Admin / Products / ProductsController.php
surecart / app / src / Controllers / Admin / Products Last commit date
ProductScriptsController.php 2 weeks ago ProductsController.php 2 weeks ago ProductsListTable.php 2 weeks ago
ProductsController.php
559 lines
1 <?php
2
3 namespace SureCart\Controllers\Admin\Products;
4
5 use SureCart\Controllers\Admin\AdminController;
6 use SureCart\Controllers\Admin\RendersEnhancedAdminView;
7 use SureCart\Controllers\Admin\Products\ProductsListTable;
8 use SureCart\Models\Product;
9 use SureCart\Models\ImportRow;
10 use SureCart\Background\BulkActionService;
11 use SureCart\Sync\ImportState;
12
13 /**
14 * Handles product admin requests.
15 */
16 class ProductsController extends AdminController {
17 use RendersEnhancedAdminView;
18
19 /**
20 * WooCommerce import state tracker.
21 *
22 * @var ImportState
23 */
24 private $woo_import_state;
25
26 /**
27 * Constructor.
28 *
29 * @param ImportState $woo_import_state Import state for WooCommerce runs.
30 */
31 public function __construct( ImportState $woo_import_state ) {
32 $this->woo_import_state = $woo_import_state;
33 }
34
35 /**
36 * Products index.
37 */
38 protected function renderWpListView() {
39 // instantiate the bulk actions service.
40 $bulk_action_service = new BulkActionService();
41 $bulk_action_service->bootstrap();
42
43 // instantiate the products list table.
44 $table = new ProductsListTable( $bulk_action_service );
45 $table->prepare_items();
46
47 // add header.
48 $this->withHeader(
49 array(
50 'breadcrumbs' => [
51 'products' => [
52 'title' => __( 'Products', 'surecart' ),
53 ],
54 ],
55 'suffix' => isset( $_GET['debug'] ) ? $this->syncDropdown() : null, // phpcs:ignore WordPress.Security.NonceVerification.Recommended
56 'enhanced_view_promo' => admin_url( 'admin.php?page=sc-products' ),
57 ),
58 );
59
60 // add notices.
61 $this->withNotices(
62 array(
63 'sync_success' => __( 'Product synced successfully.', 'surecart' ),
64 'archived' => __( 'Product archived.', 'surecart' ),
65 'unarchived' => __( 'Product unarchived.', 'surecart' ),
66 'duplicated' => __( 'Product duplicated successfully.', 'surecart' ),
67 )
68 );
69
70 // return view.
71 return \SureCart::view( 'admin/products/index' )->with( [ 'table' => $table ] );
72 }
73
74 /**
75 * Render the SPA view for products.
76 */
77 protected function renderSpaView() {
78 $this->enqueueSpaScripts( ProductScriptsController::class );
79
80 $bulk_action_service = new BulkActionService();
81 $bulk_action_service->bootstrap();
82
83 add_action(
84 'admin_notices',
85 function () use ( $bulk_action_service ) {
86 $bulk_action_service->showBulkActionAdminNotice( 'delete_products' );
87 }
88 );
89
90 return $this->renderSpaShell( 'admin/products/spa', 'products', __( 'Products', 'surecart' ) );
91 }
92
93 /**
94 * Sync dropdown.
95 */
96 public function syncDropdown() {
97 ob_start();
98 ?>
99 <sc-dropdown>
100 <sc-button slot="trigger" type="text" circle>
101 <sc-icon name="more-horizontal" style="font-size: 20px"></sc-icon>
102 </sc-button>
103 <sc-menu>
104 <sc-menu-item href="<?php echo esc_url( \SureCart::getUrl()->syncAll( 'products' ) ); ?>">
105 <sc-icon slot="prefix" name="refresh-cw"></sc-icon>
106 <?php esc_html_e( 'Sync Products', 'surecart' ); ?>
107 </sc-menu-item>
108 </sc-menu>
109 </sc-dropdown>
110 <?php
111 return ob_get_clean();
112 }
113
114 /**
115 * Confirm Bulk Delete.
116 */
117 public function confirmBulkDelete() {
118 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only view; nonce is enforced on the downstream POST to bulkDelete().
119 $raw_ids = isset( $_REQUEST['bulk_action_product_ids'] ) ? wp_unslash( $_REQUEST['bulk_action_product_ids'] ) : [];
120 $product_ids = is_array( $raw_ids ) ? array_map( 'sanitize_text_field', $raw_ids ) : [];
121
122 // find the products queued for bulk deletion.
123 if ( empty( $product_ids ) ) {
124 wp_die(
125 sprintf(
126 '%s <a href="%s">%s</a>',
127 esc_html__( 'No products selected. Please choose at least one product to delete.', 'surecart' ),
128 esc_url( admin_url( 'admin.php?page=sc-products' ) ),
129 esc_html__( 'Go Back', 'surecart' )
130 )
131 );
132 }
133
134 if ( $this->isEnhancedAdminViewsEnabled() ) {
135 return $this->renderSpaView();
136 }
137
138 $products = Product::where(
139 [
140 'ids' => $product_ids,
141 ]
142 )->get();
143
144 // handle empty.
145 if ( empty( $products ) ) {
146 wp_die( esc_html( _n( 'This product has already been deleted.', 'These products have already been deleted.', count( $product_ids ), 'surecart' ) ) );
147 }
148
149 // handle error.
150 if ( is_wp_error( $products ) ) {
151 wp_die( implode( ' ', array_map( 'esc_html', $products->get_error_messages() ) ) );
152 }
153
154 // add header.
155 $this->withHeader(
156 [
157 'delete' => [
158 'title' => _n( 'Delete Product', 'Delete Products.', count( $products ), 'surecart' ),
159 ],
160 ],
161 );
162
163 // return view.
164 return \SureCart::view( 'admin/products/confirm-bulk-delete' )->with( [ 'products' => $products ] );
165 }
166
167 /**
168 * Bulk Delete.
169 */
170 public function bulkDelete() {
171 // Route middleware `nonce:bulk_delete_nonce` has already verified the
172 // nonce; we only need to coerce the payload to a sanitized array here.
173 $product_ids = isset( $_REQUEST['bulk_action_product_ids'] ) && is_array( $_REQUEST['bulk_action_product_ids'] )
174 ? array_map( 'sanitize_text_field', wp_unslash( $_REQUEST['bulk_action_product_ids'] ) )
175 : [];
176
177 if ( empty( $product_ids ) ) {
178 return \SureCart::redirect()->to( esc_url_raw( admin_url( 'admin.php?page=sc-products' ) ) );
179 }
180
181 // get all posts where the sc_id meta key is in the product_ids using wp_query.
182 $query = new \WP_Query(
183 [
184 'post_type' => 'sc_product',
185 'posts_per_page' => -1,
186 'meta_query' => [
187 [
188 'key' => 'sc_id',
189 'value' => $product_ids,
190 'compare' => 'IN',
191 ],
192 ],
193 ]
194 );
195
196 // handle error.
197 if ( is_wp_error( $query ) ) {
198 wp_die( implode( ' ', array_map( 'esc_html', $query->get_error_messages() ) ) );
199 }
200
201 // delete the posts.
202 foreach ( $query->posts as $post ) {
203 wp_delete_post( $post->ID, true );
204 }
205
206 // create bulk action.
207 $action = \SureCart::bulkAction()->createBulkAction(
208 'delete_products',
209 $product_ids
210 );
211
212 // handle error.
213 if ( is_wp_error( $action ) ) {
214 wp_die( implode( ' ', array_map( 'esc_html', $action->get_error_messages() ) ) );
215 }
216
217 // redirect.
218 return \SureCart::redirect()->to( esc_url_raw( admin_url( 'admin.php?page=sc-products' ) ) );
219 }
220
221 /**
222 * Edit a product.
223 *
224 * @param \SureCartCore\Requests\RequestInterface $request Request.
225 */
226 public function edit( $request ) {
227 // enqueue needed script.
228 $this->enqueueSpaScripts( ProductScriptsController::class );
229
230 // define product.
231 $product = null;
232
233 // find the product for preloading.
234 if ( $request->query( 'id' ) ) {
235 $product = Product::find( $request->query( 'id' ) );
236
237 if ( is_wp_error( $product ) ) {
238 wp_die( implode( ' ', array_map( 'esc_html', $product->get_error_messages() ) ) );
239 }
240 }
241
242 // preload paths.
243 if ( ! empty( $product ) ) {
244 $gallery_paths = [];
245 $gallery = $product->gallery_ids ?? [];
246 foreach ( $gallery as $gallery_item ) {
247 $id = is_numeric( $gallery_item ) ? (int) $gallery_item : intval( ( (object) $gallery_item )->id ?? 0 );
248
249 if ( $id > 0 ) {
250 $gallery_paths[] = '/wp/v2/media/' . $id . '?context=edit';
251 }
252 }
253 $taxonomies = array_diff( get_object_taxonomies( 'sc_product' ), array( 'sc_account', 'sc_collection' ) );
254
255 if ( ! empty( $taxonomies ) ) {
256 $taxonomy_paths = [];
257 foreach ( $taxonomies as $taxonomy ) {
258 $taxonomy_paths[] = '/wp/v2/taxonomies/' . $taxonomy . '?context=edit';
259 $taxonomy_paths[] = '/wp/v2/' . $taxonomy;
260 }
261 }
262
263 $this->preloadPaths(
264 array_merge(
265 [
266 [ '/wp/v2/templates', 'OPTIONS' ],
267 '/wp/v2/settings',
268 '/wp/v2/types/wp_template?context=edit',
269 '/wp/v2/types/wp_template-part?context=edit',
270 '/wp/v2/templates?context=edit&per_page=-1',
271 '/wp/v2/template-parts?context=edit&per_page=-1',
272 '/wp/v2/users/me',
273 '/wp/v2/types?context=view',
274 '/wp/v2/types?context=edit',
275 '/wp/v2/templates/' . $product->template_id . '?context=edit',
276 '/wp/v2/template-parts/' . $product->template_part_id . '?context=edit',
277 '/wp/v2/taxonomies?context=view',
278 '/wp/v2/taxonomies?context=edit&per_page=100',
279 '/wp/v2/sc_product?context=edit&sc_id[0]=' . $product->id . '&per_page=1&_locale=user',
280 '/surecart/v1/products/' . $product->id . '?context=edit',
281 '/surecart/v1/integrations?context=edit&model_ids[0]=' . $product->id . '&per_page=50',
282 '/surecart/v1/integration_providers?context=edit',
283 '/surecart/v1/integration_provider_items?context=edit',
284 ],
285 $gallery_paths,
286 $taxonomy_paths ?? []
287 )
288 );
289 }
290
291 // add product link.
292 if ( ! empty( $product ) ) {
293 add_action(
294 'admin_bar_menu',
295 function ( $wp_admin_bar ) use ( $product ) {
296 $wp_admin_bar->add_node(
297 [
298 'id' => 'view-product-page',
299 'title' => __( 'View Product', 'surecart' ),
300 'href' => esc_url( $product->permalink ?? '#' ),
301 'meta' => [
302 'class' => empty( $product->permalink ) ? 'hidden' : '',
303 ],
304 ]
305 );
306 },
307 99
308 );
309 }
310
311 return $this->renderSpaShell( 'admin/products/spa' );
312 }
313
314 /**
315 * Change the archived attribute in the model
316 *
317 * @param \SureCartCore\Requests\RequestInterface $request Request.
318 *
319 * @return \SureCartCore\Responses\RedirectResponse
320 */
321 public function toggleArchive( $request ) {
322 $product = Product::find( $request->query( 'id' ) );
323
324 if ( is_wp_error( $product ) ) {
325 wp_die( implode( ' ', array_map( 'esc_html', $product->get_error_messages() ) ) );
326 }
327
328 $updated = $product->update(
329 [
330 'archived' => ! (bool) $product->archived,
331 ]
332 );
333
334 if ( is_wp_error( $updated ) ) {
335 wp_die( implode( ' ', array_map( 'esc_html', $updated->get_error_messages() ) ) );
336 }
337
338 return \SureCart::redirect()->to(
339 esc_url_raw(
340 add_query_arg(
341 $updated->archived ? [ 'archived' => 1 ] : [ 'unarchived' => 1 ],
342 \SureCart::getUrl()->index( 'products' )
343 )
344 )
345 );
346 }
347
348 /**
349 * Start product sync.
350 *
351 * @return \SureCartCore\Responses\RedirectResponse
352 */
353 public function syncAll() {
354 // dispatch the sync job.
355 \SureCart::sync()->products()->dispatch();
356
357 // redirect to products page.
358 return \SureCart::redirect()->to( esc_url_raw( \SureCart::getUrl()->index( 'products' ) ) );
359 }
360
361 /**
362 * Import results page.
363 *
364 * @param \SureCartCore\Requests\RequestInterface $request Request.
365 *
366 * @return \SureCartCore\View
367 */
368 public function importResults( $request ) {
369 // add header.
370 $this->withHeader(
371 [
372 'breadcrumbs' => [
373 'products' => [
374 'title' => __( 'Products', 'surecart' ),
375 'href' => \SureCart::getUrl()->index( 'products' ),
376 ],
377 'import_results' => [
378 'title' => __( 'Import Results', 'surecart' ),
379 ],
380 ],
381 ],
382 );
383
384 // Parse import IDs from request (comma-separated), with fallback to legacy singular param.
385 $import_ids_raw = $request->query( 'import_ids' );
386 if ( empty( $import_ids_raw ) ) {
387 $import_ids_raw = $request->query( 'import_id' );
388 }
389
390 if ( empty( $import_ids_raw ) ) {
391 // Check if this is an all-skipped import.
392 $all_skipped_session_id = $this->woo_import_state->getAllSkippedSessionId();
393
394 // Fallback: if option was already cleaned up, check URL param (e.g. page refresh).
395 if ( ! $all_skipped_session_id ) {
396 $raw_session_id = $request->query( 'session_id' );
397 $all_skipped_session_id = $raw_session_id ? substr( sanitize_key( $raw_session_id ), 0, 36 ) : '';
398 }
399
400 if ( $all_skipped_session_id ) {
401 // Fetch skipped products via session ID.
402 $skipped_products = $this->woo_import_state->getSkippedItemsBySession( $all_skipped_session_id );
403
404 // Clean up completion notice options now that the user has viewed results.
405 $this->woo_import_state->reset();
406
407 return \SureCart::view( 'admin/products/import-results' )->with(
408 [
409 'succeeded_count' => 0,
410 'failed_rows' => [],
411 'skipped_products' => $skipped_products,
412 'all_skipped' => true,
413 'results_capped' => false,
414 ]
415 );
416 }
417
418 // No import IDs and no skipped products - show empty state.
419 return \SureCart::view( 'admin/products/import-results' )->with(
420 [
421 'succeeded_count' => 0,
422 'failed_rows' => [],
423 'skipped_products' => [],
424 'all_skipped' => false,
425 'results_capped' => false,
426 ]
427 );
428 }
429
430 // Sanitize, split into array, and cap at 50 IDs to prevent abuse.
431 $import_ids = array_slice(
432 array_filter( array_map( 'sanitize_key', explode( ',', $import_ids_raw ) ) ),
433 0,
434 50
435 );
436
437 // Parse session_id from query (for skipped products lookup).
438 $raw = $request->query( 'session_id' );
439 $session_id = $raw ? substr( sanitize_key( $raw ), 0, 36 ) : '';
440
441 // Fallback: use current session if available (for backward compatibility).
442 if ( ! $session_id ) {
443 $session_id = $this->woo_import_state->getSessionId();
444 }
445
446 // Fetch all import rows (capped at 50 pages to prevent timeouts).
447 $succeeded_count = 0;
448 $failed_rows = [];
449 $page = 1;
450 $per_page = 100;
451 $max_pages = 50;
452 $has_next_page = false;
453
454 do {
455 $collection = ImportRow::where( [ 'import_ids' => $import_ids ] )
456 ->paginate(
457 [
458 'page' => $page,
459 'per_page' => $per_page,
460 ]
461 );
462
463 // Handle API errors gracefully.
464 if ( is_wp_error( $collection ) ) {
465 break;
466 }
467
468 foreach ( ( $collection->data ?? [] ) as $row ) {
469 if ( 'succeeded' === ( $row->status ?? '' ) ) {
470 ++$succeeded_count;
471 } else {
472 $import_data = $row->import_data ?? null;
473 $failed_rows[] = [
474 'name' => is_object( $import_data ) ? ( $import_data->name ?? __( 'Unknown', 'surecart' ) ) : __( 'Unknown', 'surecart' ),
475 'reason' => $row->failure_reason ?? __( 'Unknown error', 'surecart' ),
476 ];
477 }
478 }
479
480 $has_next_page = $collection->hasNextPage();
481 ++$page;
482 } while ( $has_next_page && $page <= $max_pages );
483
484 // Flag if results were capped (more rows exist than the pagination limit allows).
485 $results_capped = ( $page > $max_pages && $has_next_page );
486
487 // Fetch skipped products from transient.
488 $skipped_products = [];
489 if ( $session_id ) {
490 $skipped_products = $this->woo_import_state->getSkippedItemsBySession( $session_id );
491 }
492
493 // Clean up completion notice options now that the user has viewed results.
494 // The results page uses URL query params, so these options are no longer needed.
495 $this->woo_import_state->reset();
496
497 return \SureCart::view( 'admin/products/import-results' )->with(
498 [
499 'succeeded_count' => $succeeded_count,
500 'failed_rows' => $failed_rows,
501 'skipped_products' => $skipped_products,
502 'all_skipped' => false,
503 'results_capped' => $results_capped,
504 ]
505 );
506 }
507
508 /**
509 * Start product sync.
510 *
511 * @param \SureCartCore\Requests\RequestInterface $request Request.
512 *
513 * @return \SureCartCore\Responses\RedirectResponse
514 */
515 public function sync( $request ) {
516 $product = Product::sync( $request->query( 'id' ) );
517
518 if ( is_wp_error( $product ) ) {
519 wp_die( implode( ' ', array_map( 'esc_html', $product->get_error_messages() ) ) );
520 }
521
522 // redirect to products page.
523 return \SureCart::redirect()->to(
524 esc_url_raw(
525 add_query_arg(
526 [ 'sync_success' => true ],
527 \SureCart::getUrl()->index( 'products' )
528 )
529 )
530 );
531 }
532
533 /**
534 * Duplicate a product.
535 *
536 * @param \SureCartCore\Requests\RequestInterface $request Request.
537 *
538 * @return \SureCartCore\Responses\RedirectResponse
539 */
540 public function duplicate( $request ) {
541 $duplicated = Product::duplicate( $request->query( 'id' ) );
542
543 if ( is_wp_error( $duplicated ) ) {
544 wp_die( implode( ' ', array_map( 'esc_html', $duplicated->get_error_messages() ) ) );
545 }
546
547 return \SureCart::redirect()->to(
548 esc_url_raw(
549 add_query_arg(
550 [
551 'duplicated' => true,
552 ],
553 \SureCart::getUrl()->index( 'products' )
554 )
555 )
556 );
557 }
558 }
559