{ "version": 3, "sources": ["../../../node_modules/@stimulus/core/src/event_listener.ts", "../../../node_modules/@stimulus/core/src/dispatcher.ts", "../../../node_modules/@stimulus/core/src/action_descriptor.ts", "../../../node_modules/@stimulus/core/src/action.ts", "../../../node_modules/@stimulus/core/src/binding.ts", "../../../node_modules/@stimulus/mutation-observers/src/element_observer.ts", "../../../node_modules/@stimulus/mutation-observers/src/attribute_observer.ts", "../../../node_modules/@stimulus/multimap/src/set_operations.ts", "../../../node_modules/@stimulus/multimap/src/multimap.ts", "../../../node_modules/@stimulus/multimap/src/indexed_multimap.ts", "../../../node_modules/@stimulus/multimap/index.ts", "../../../node_modules/@stimulus/mutation-observers/src/token_list_observer.ts", "../../../node_modules/@stimulus/mutation-observers/src/value_list_observer.ts", "../../../node_modules/@stimulus/mutation-observers/index.ts", "../../../node_modules/@stimulus/core/src/binding_observer.ts", "../../../node_modules/@stimulus/core/src/context.ts", "../../../node_modules/@stimulus/core/src/definition.ts", "../../../node_modules/@stimulus/core/src/module.ts", "../../../node_modules/@stimulus/core/src/data_map.ts", "../../../node_modules/@stimulus/core/src/selectors.ts", "../../../node_modules/@stimulus/core/src/target_set.ts", "../../../node_modules/@stimulus/core/src/scope.ts", "../../../node_modules/@stimulus/core/src/scope_observer.ts", "../../../node_modules/@stimulus/core/src/router.ts", "../../../node_modules/@stimulus/core/src/schema.ts", "../../../node_modules/@stimulus/core/src/application.ts", "../../../node_modules/@stimulus/core/src/target_properties.ts", "../../../node_modules/@stimulus/core/src/controller.ts", "../../../node_modules/@stimulus/core/index.ts", "../../../node_modules/stimulus/index.js", "../../javascript/stimulus.js", "../../javascript/controllers/accordion_controller.js", "../../javascript/controllers/add_category_popover_controller.js", "../../javascript/controllers/add_color_popover_controller.js", "../../javascript/controllers/add_line_item_controller.js", "../../javascript/utils/uuid.js", "../../javascript/controllers/add_to_sale_selector_controller.js", "../../javascript/mixins/stimulus_reinit_mixin.js", "../../javascript/controllers/ajax_popup_controller.js", "../../javascript/controllers/async_gallery_controller.js", "../../javascript/mixins/safe_dom_mixin.js", "../../javascript/controllers/async_sidebar_orders_controller.js", "../../javascript/mixins/notification_mixin.js", "../../javascript/mixins/csrf_token_mixin.js", "../../javascript/controllers/autosave_controller.js", "../../javascript/controllers/branding_delete_controller.js", "../../javascript/controllers/branding_form_controller.js", "../../javascript/controllers/branding_toggle_controller.js", "../../javascript/controllers/bulk_delete_products_modal_controller.js", "../../javascript/controllers/bulk_set_category_form_controller.js", "../../javascript/controllers/bulk_set_pricing_mode_controller.js", "../../javascript/controllers/button_debounce_controller.js", "../../javascript/controllers/category_inline_edit_controller.js", "../../javascript/controllers/category_product_new_controller.js", "../../javascript/controllers/clickable_row_controller.js", "../../javascript/controllers/clipboard_controller.js", "../../javascript/mixins/modal_progress_mixin.js", "../../javascript/controllers/clone_sale_form_controller.js", "../../javascript/controllers/clone_site_form_controller.js", "../../javascript/controllers/clone_site_submission_controller.js", "../../javascript/controllers/closeable_container_controller.js", "../../javascript/controllers/color_change_controller.js", "../../javascript/controllers/color_picker_controller.js", "../../javascript/mixins/date_picker_mixin.js", "../../javascript/controllers/coupon_datepicker_controller.js", "../../javascript/controllers/dealer_search_controller.js", "../../javascript/controllers/dealer_search_keywords_form_controller.js", "../../javascript/controllers/dealer_search_keywords_input_controller.js", "../../javascript/controllers/dealer_search_product_add_to_sale_controller.js", "../../javascript/controllers/dealer_search_results_controller.js", "../../javascript/controllers/dealer_search_results_tabs_controller.js", "../../javascript/controllers/dealer_settings_content_add_sizing_option_controller.js", "../../javascript/controllers/dealer_settings_content_invite_user_controller.js", "../../javascript/controllers/dealer_settings_content_user_controller.js", "../../javascript/controllers/dealer_settings_user_filter_controller.js", "../../javascript/controllers/decoration_job_tracker_controller.js", "../../javascript/controllers/decorator_active_archived_tabs_controller.js", "../../javascript/utils/delete_artwork_dialog.js", "../../javascript/controllers/decorator_artwork_item_controller.js", "../../javascript/utils/performance.js", "../../javascript/controllers/decorator_artwork_library_controller.js", "../../javascript/controllers/decorator_artwork_selector_controller.js", "../../javascript/controllers/decorator_artwork_tabs_controller.js", "../../javascript/controllers/decorator_canvas_art_placement.js", "../../javascript/controllers/decorator_canvas_art_placement_parser.js", "../../javascript/controllers/decorator_canvas_image_canvas.js", "../../javascript/controllers/decorator_canvas_artboard_controller.js", "../../javascript/controllers/decorator_canvas_color_controller.js", "../../javascript/controllers/decorator_canvas_colors_controller.js", "../../javascript/controllers/decorator_canvas_dropzone_controller.js", "../../javascript/controllers/decorator_canvas_image_history.js", "../../javascript/controllers/decorator_canvas_history_controller.js", "../../javascript/controllers/decorator_canvas_orientation_image_controller.js", "../../javascript/controllers/decorator_canvas_orientations_controller.js", "../../javascript/controllers/decorator_canvas_publish_controller.js", "../../javascript/controllers/decorator_canvas_template_controller.js", "../../javascript/controllers/decorator_debug_panel_controller.js", "../../javascript/controllers/decorator_modal_controller.js", "../../javascript/controllers/decorator_product_selector_controller.js", "../../javascript/controllers/decorator_product_selector_item_controller.js", "../../javascript/controllers/delete_category_controller.js", "../../javascript/controllers/delete_product_confirm_controller.js", "../../javascript/controllers/delete_teamsite_confirm_controller.js", "../../javascript/controllers/dollar_report_auto_refresh_controller.js", "../../javascript/controllers/dollar_report_refresh_controller.js", "../../javascript/controllers/event_coordinator.js", "../../javascript/controllers/drag_category_controller.js", "../../javascript/controllers/drag_product_controller.js", "../../javascript/controllers/dropdown_controller.js", "../../javascript/controllers/dropzone_controller.js", "../../javascript/controllers/edit_category_button_controller.js", "../../javascript/controllers/event_triggered_popup_controller.js", "../../javascript/controllers/file_upload_trigger_controller.js", "../../javascript/controllers/fixed_footer_controller.js", "../../javascript/controllers/flash_controller.js", "../../javascript/controllers/form_submit_debounce_controller.js", "../../javascript/controllers/image_zoom_modal_controller.js", "../../javascript/controllers/image_zoom_controller.js", "../../javascript/controllers/increment_field_controller.js", "../../javascript/controllers/infinite_scroll_controller.js", "../../javascript/controllers/input_highlight_controller.js", "../../javascript/controllers/input_toggleable_percentage_controller.js", "../../javascript/controllers/lazy_image_controller.js", "../../javascript/controllers/library_product_clone_controller.js", "../../javascript/controllers/library_product_remove_controller.js", "../../javascript/controllers/line_item_highlight_controller.js", "../../javascript/controllers/line_item_row_controller.js", "../../javascript/controllers/line_items_form_controller.js", "../../javascript/controllers/locker_table_row_controller.js", "../../javascript/controllers/logo_color_extractor_controller.js", "../../javascript/controllers/logo_upload_controller.js", "../../javascript/controllers/main_menu_drawer_controller.js", "../../javascript/controllers/messaging_button_controller.js", "../../javascript/controllers/messaging_count_controller.js", "../../javascript/controllers/messaging_drawer_controller.js", "../../javascript/controllers/messaging_reply_form_controller.js", "../../javascript/controllers/messaging_send_form_controller.js", "../../javascript/controllers/messaging_toggle_reply_controller.js", "../../javascript/controllers/order_actions_refund_controller.js", "../../javascript/controllers/order_details_addresses_edit_button_controller.js", "../../javascript/controllers/order_details_addresses_edit_form_controller.js", "../../javascript/controllers/order_details_customer_edit_button_controller.js", "../../javascript/controllers/order_details_customer_edit_form_controller.js", "../../javascript/controllers/order_details_discard_changes_controller.js", "../../javascript/controllers/order_details_total_confirm_action_controller.js", "../../javascript/controllers/order_details_total_coupons_button_controller.js", "../../javascript/controllers/order_fulfillment_change_controller.js", "../../javascript/controllers/order_fulfillment_clear_controller.js", "../../javascript/controllers/order_fulfillment_confirm_controller.js", "../../javascript/controllers/order_fulfillment_dropdown_manage_controller.js", "../../javascript/controllers/select2_base_controller.js", "../../javascript/controllers/order_fulfillment_dropdown_select_controller.js", "../../javascript/controllers/order_fulfillment_line_item_row_controller.js", "../../javascript/controllers/order_fulfillment_partial_controller.js", "../../javascript/controllers/order_fulfillment_refund_controller.js", "../../javascript/controllers/order_line_items_edit_button_controller.js", "../../javascript/controllers/order_line_items_editable_controller.js", "../../javascript/controllers/order_line_items_form_controller.js", "../../javascript/controllers/order_partial_refund_modal_controller.js", "../../javascript/controllers/orders_filter_controller.js", "../../javascript/mixins/bulk_actions_mixin.js", "../../javascript/controllers/product_bulk_actions_category_controller.js", "../../javascript/controllers/product_bulk_actions_decorator_controller.js", "../../javascript/controllers/product_bulk_actions_delete_controller.js", "../../javascript/controllers/product_bulk_actions_price_controller.js", "../../javascript/controllers/product_bulk_actions_set_pricing_controller.js", "../../javascript/controllers/product_bulk_actions_sizing_controller.js", "../../javascript/controllers/product_colors_modal_drag_controller.js", "../../javascript/controllers/product_colors_modal_form_controller.js", "../../javascript/controllers/product_colors_modal_image_delete_controller.js", "../../javascript/controllers/product_colors_modal_image_toggle_controller.js", "../../javascript/controllers/product_colors_modal_size_variant_row_controller.js", "../../javascript/controllers/product_colors_modal_sizes_add_controller.js", "../../javascript/controllers/product_colors_modal_sizes_controller.js", "../../javascript/controllers/product_colors_modal_sizes_drag_controller.js", "../../javascript/controllers/product_colors_modal_sizes_numeric_input_controller.js", "../../javascript/controllers/product_colors_modal_sizes_select_controller.js", "../../javascript/controllers/product_colors_modal_sizing_options_dropdown_controller.js", "../../javascript/controllers/product_colors_table_row_controller.js", "../../javascript/controllers/product_details_swatches/swatch_item_controller.js", "../../javascript/controllers/product_dropdown_add_from_library_controller.js", "../../javascript/controllers/product_dropdown_add_product_controller.js", "../../javascript/controllers/product_dropdown_create_product_controller.js", "../../javascript/controllers/product_edit_coordinator_controller.js", "../../javascript/controllers/product_filter_controller.js", "../../javascript/mixins/sidebar_refresh_mixin.js", "../../javascript/controllers/product_form_availability_toggle_controller.js", "../../javascript/mixins/select2_create_mixin.js", "../../javascript/controllers/product_form_brand_dropdown_controller.js", "../../javascript/controllers/product_form_category_dropdown_controller.js", "../../javascript/controllers/product_form_dirty_tracker_controller.js", "../../javascript/controllers/product_form_pricing_controller.js", "../../javascript/controllers/product_gallery_image_actions_controller.js", "../../javascript/controllers/product_gallery_image_preview_controller.js", "../../javascript/controllers/product_gallery_image_toggle_controller.js", "../../javascript/controllers/product_gallery_refresh_controller.js", "../../javascript/controllers/product_image_fallback_controller.js", "../../javascript/controllers/product_navigation_controller.js", "../../javascript/controllers/product_options_table_custom_field_controller.js", "../../javascript/controllers/product_options_table_custom_field_numeric_input_controller.js", "../../javascript/controllers/debounce.js", "../../javascript/controllers/product_options_table_custom_field_select_controller.js", "../../javascript/controllers/product_options_table_custom_field_text_field_controller.js", "../../javascript/controllers/product_options_table_custom_field_toggle_controller.js", "../../javascript/controllers/product_options_table_dropdown_option_add_choice_row_controller.js", "../../javascript/controllers/product_options_table_dropdown_option_controller.js", "../../javascript/controllers/product_options_table_dropdown_option_numeric_input_controller.js", "../../javascript/controllers/product_options_table_dropdown_option_text_field_controller.js", "../../javascript/controllers/product_report_filter_controller.js", "../../javascript/controllers/product_report_generate_controller.js", "../../javascript/controllers/product_sidebar_bulk_select_controller.js", "../../javascript/controllers/product_sidebar_controller.js", "../../javascript/controllers/product_size_chart_controller.js", "../../javascript/controllers/product_size_chart_image_controller.js", "../../javascript/controllers/product_swatches_controller.js", "../../javascript/controllers/rack_mini_profiler_controller.js", "../../javascript/controllers/refund_modal_reason_select_controller.js", "../../javascript/controllers/rename_category_controller.js", "../../javascript/controllers/sale_coupon_form_controller.js", "../../javascript/controllers/sale_coupons_add_controller.js", "../../javascript/controllers/sale_coupons_row_controller.js", "../../javascript/controllers/sale_details_access_code_controller.js", "../../javascript/controllers/sale_details_close_date_controller.js", "../../javascript/controllers/sale_details_controller.js", "../../javascript/controllers/sale_details_estimated_ship_date_controller.js", "../../javascript/controllers/sale_details_open_date_controller.js", "../../javascript/controllers/sale_details_privacy_toggle_controller.js", "../../javascript/controllers/sale_details_resource_controller.js", "../../javascript/controllers/sale_details_resource_tooltip_controller.js", "../../javascript/controllers/sale_header_print_controller.js", "../../javascript/controllers/sale_header_sale_add_controller.js", "../../javascript/controllers/sale_header_sale_dropdown_controller.js", "../../javascript/controllers/sale_notes_add_controller.js", "../../javascript/controllers/sale_notes_attachments_controller.js", "../../javascript/controllers/sale_notes_bulk_operations_controller.js", "../../javascript/controllers/sale_notes_save_controller.js", "../../javascript/controllers/sale_notes_sidebar_controller.js", "../../javascript/controllers/sale_notes_sidebar_list_note_controller.js", "../../javascript/controllers/sale_notes_tab_content_controller.js", "../../javascript/controllers/sale_orders_bulk_export_csv_controller.js", "../../javascript/controllers/sale_orders_bulk_fulfillment_handler_controller.js", "../../javascript/controllers/sale_orders_bulk_fulfillment_modal_controller.js", "../../javascript/controllers/sale_orders_bulk_packing_slips_controller.js", "../../javascript/controllers/sale_orders_bulk_receipts_controller.js", "../../javascript/controllers/sale_orders_bulk_select_controller.js", "../../javascript/controllers/sale_orders_index_page_header_actions_controller.js", "../../javascript/controllers/sale_orders_index_print_controller.js", "../../javascript/controllers/sale_orders_status_filter_controller.js", "../../javascript/controllers/sale_orders_table_row_controller.js", "../../javascript/controllers/sale_product_add_to_library_controller.js", "../../javascript/controllers/sale_product_details_add_color_controller.js", "../../javascript/controllers/sale_product_details_add_custom_field_controller.js", "../../javascript/controllers/sale_product_details_clone_product_controller.js", "../../javascript/controllers/sale_product_details_product_actions_controller.js", "../../javascript/controllers/sale_product_details_subtab_navigation_controller.js", "../../javascript/controllers/sale_settings_add_checkout_option_controller.js", "../../javascript/controllers/sale_settings_address_card_controller.js", "../../javascript/controllers/sale_settings_address_form_controller.js", "../../javascript/controllers/sale_settings_billing_payment_controller.js", "../../javascript/controllers/sale_settings_checkout_options_choice_drag_controller.js", "../../javascript/controllers/sale_settings_checkout_options_drag_controller.js", "../../javascript/controllers/sale_settings_checkout_options_form_controller.js", "../../javascript/controllers/sale_settings_checkout_options_player_info_controller.js", "../../javascript/controllers/sale_settings_checkout_options_row_controller.js", "../../javascript/controllers/sale_settings_fees_price_controller.js", "../../javascript/controllers/sale_settings_fees_price_toggle_controller.js", "../../javascript/controllers/sale_settings_inline_address_form_controller.js", "../../javascript/controllers/sale_settings_shipping_addresses_controller.js", "../../javascript/controllers/sale_settings_shipping_methods_controller.js", "../../javascript/controllers/sale_settings_shipping_methods_selector_controller.js", "../../javascript/controllers/sale_settings_shipping_rates_controller.js", "../../javascript/controllers/sale_settings_shipping_rates_fixed_auto_submit_controller.js", "../../javascript/controllers/sale_settings_shipping_rates_form_controller.js", "../../javascript/controllers/sale_settings_shipping_rates_free_auto_submit_controller.js", "../../javascript/controllers/sale_settings_shipping_rates_percentage_auto_submit_controller.js", "../../javascript/controllers/sale_settings_shipping_rates_selector_controller.js", "../../javascript/controllers/sale_settings_shipping_rates_table_controller.js", "../../javascript/controllers/sale_settings_shipping_rates_tiered_dropdown_controller.js", "../../javascript/controllers/sale_settings_shipping_rates_tiered_form_controller.js", "../../javascript/controllers/sale_settings_shipping_rates_tiered_table_controller.js", "../../javascript/controllers/sale_settings_subtab_navigation_controller.js", "../../javascript/controllers/sale_sidebar_left_collapse_controller.js", "../../javascript/controllers/sale_sidebar_left_toggle_controller.js", "../../javascript/controllers/sale_tabs_products_controller.js", "../../javascript/controllers/scroll_top_button_controller.js", "../../javascript/controllers/selectable_artworks_controller.js", "../../javascript/controllers/sender_address_add_controller.js", "../../javascript/controllers/sender_address_individual_add_controller.js", "../../javascript/controllers/shipping_bulk_select_controller.js", "../../javascript/controllers/shipping_fulfillment_confirm_controller.js", "../../javascript/controllers/shipping_fulfillment_select_controller.js", "../../javascript/controllers/shipping_label_container_package_name_controller.js", "../../javascript/controllers/shipping_label_datepicker_controller.js", "../../javascript/controllers/shipping_label_form_controller.js", "../../javascript/controllers/shipping_label_package_controller.js", "../../javascript/controllers/shipping_label_sender_address_controller.js", "../../javascript/controllers/shipping_label_view_controller.js", "../../javascript/controllers/shipping_order_row_controller.js", "../../javascript/controllers/shipping_tiered_rate_modal_controller.js", "../../javascript/controllers/shipping_tiered_rate_modal_tier_row_controller.js", "../../javascript/controllers/shipping_tiered_rates_rate_controller.js", "../../javascript/controllers/sidenav_controller.js", "../../javascript/controllers/sizing_option_form_controller.js", "../../javascript/controllers/sizing_option_sizes_drag_controller.js", "../../javascript/controllers/sizing_option_table_add_size_row_controller.js", "../../javascript/controllers/sizing_option_table_cancel_controller.js", "../../javascript/controllers/sizing_option_table_size_controller.js", "../../javascript/controllers/sizing_options_accordion_controller.js", "../../javascript/controllers/sizing_options_filter_controller.js", "../../javascript/controllers/slides_upload_controller.js", "../../javascript/controllers/sortable_table_controller.js", "../../javascript/controllers/swatch_item_controller.js", "../../javascript/controllers/t_cart_sidebar_animation_controller.js", "../../javascript/controllers/t_cart_sidebar_clear_controller.js", "../../javascript/controllers/t_cart_sidebar_container_controller.js", "../../javascript/controllers/t_cart_sidebar_row_controller.js", "../../javascript/controllers/t_checkout_address_form_controller.js", "../../javascript/controllers/t_checkout_address_list_shipping_method_controller.js", "../../javascript/controllers/t_checkout_address_same_as_billing_controller.js", "../../javascript/controllers/t_checkout_address_shipping_method_form_controller.js", "../../javascript/controllers/t_checkout_address_shipping_method_radio_controller.js", "../../javascript/controllers/t_checkout_billing_controller.js", "../../javascript/controllers/t_checkout_clear_cart_controller.js", "../../javascript/controllers/t_checkout_complete_controller.js", "../../javascript/controllers/t_checkout_confirm_controller.js", "../../javascript/controllers/t_checkout_confirm_customer_info_billing_edit_controller.js", "../../javascript/controllers/t_checkout_confirm_customer_info_shipping_edit_controller.js", "../../javascript/controllers/t_checkout_confirm_no_payment_controller.js", "../../javascript/controllers/t_checkout_confirm_terms_controller.js", "../../javascript/controllers/t_checkout_controller.js", "../../javascript/controllers/t_checkout_coupon_form_controller.js", "../../javascript/controllers/t_checkout_coupon_show_controller.js", "../../javascript/controllers/t_checkout_email_check_controller.js", "../../javascript/controllers/t_checkout_login_controller.js", "../../javascript/controllers/t_checkout_login_show_register_controller.js", "../../javascript/controllers/t_checkout_no_payment_controller.js", "../../javascript/controllers/t_checkout_payment_cardholder_name_controller.js", "../../javascript/controllers/t_checkout_payment_controller.js", "../../javascript/controllers/t_checkout_payment_stripe_controller.js", "../../javascript/controllers/t_checkout_progress_controller.js", "../../javascript/controllers/t_checkout_register_controller.js", "../../javascript/controllers/t_checkout_register_show_login_controller.js", "../../javascript/controllers/t_checkout_sidebar_panel_coupon_controller.js", "../../javascript/controllers/t_custom_styles_controller.js", "../../javascript/controllers/t_header_cart_counter_container_controller.js", "../../javascript/controllers/t_header_cart_counter_controller.js", "../../javascript/controllers/t_header_nav_controller.js", "../../javascript/controllers/t_header_responsive_controller.js", "../../javascript/controllers/t_product_categories_scroll_controller.js", "../../javascript/controllers/t_product_details_add_to_cart_block_controller.js", "../../javascript/controllers/t_product_details_add_to_cart_inline_controller.js", "../../javascript/controllers/t_product_details_controller.js", "../../javascript/controllers/t_product_details_custom_field_controller.js", "../../javascript/controllers/t_product_details_gallery_controller.js", "../../javascript/controllers/t_product_details_product_attributes_controller.js", "../../javascript/controllers/t_product_details_size_controller.js", "../../javascript/controllers/t_slideshow_controller.js", "../../javascript/controllers/t_welcome_message_auto_display_controller.js", "../../javascript/controllers/tabs_controller.js", "../../javascript/controllers/teamsite_clear_filters_controller.js", "../../javascript/controllers/teamsite_filter_controller.js", "../../javascript/controllers/teamsite_sales_rep_filter_controller.js", "../../javascript/controllers/teamsite_status_tabs_controller.js", "../../javascript/controllers/teamsites_orders_coupons_controller.js", "../../javascript/controllers/teamsites_orders_coupons_select_controller.js", "../../javascript/controllers/test_dropdown_controller.js", "../../javascript/controllers/tiered_rate_form_controller.js", "../../javascript/controllers/tippy_controller.js", "../../javascript/controllers/toggle_controller.js", "../../javascript/controllers/toggle_product_view_controller.js", "../../javascript/controllers/user_settings_content_account_controller.js", "../../javascript/controllers/welcome_message_body_controller.js", "../../javascript/controllers/welcome_message_templates_add_template_controller.js", "../../javascript/controllers/welcome_message_templates_bulk_delete_controller.js", "../../javascript/controllers/welcome_message_templates_controller.js", "../../javascript/controllers/welcome_message_templates_destroy_controller.js", "../../javascript/controllers/welcome_message_templates_save_controller.js", "../../javascript/controllers/welcome_message_templates_sidebar_actions_controller.js", "../../javascript/controllers/welcome_message_templates_sidebar_list_item_controller.js", "../../javascript/controllers/welcome_message_templates_sidebar_select_all_controller.js", "../../javascript/controllers/welcome_message_templates_templates_controller.js", "../../javascript/controllers/welcome_messages_cog_controller.js", "../../javascript/controllers/welcome_messages_template_controller.js", "../../javascript/controllers/index.js", "../../javascript/application.js"], "sourcesContent": [null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, "export * from \"@stimulus/core\"\n", "// Import Stimulus directly from npm package\nimport { Application, Controller } from 'stimulus';\n\n// Export for consumption by controllers\nexport { Application, Controller };\n", "import { Controller } from '../stimulus';\n\n/**\n * Accordion Controller\n *\n * This controller handles accordion-style expandable/collapsible sections.\n * It replaces jQuery-based DOM manipulation with vanilla JavaScript.\n *\n * Standard Usage (Descendant Pattern):\n *
\n *
accordion#click\" class=\"expanded\">\n * Trigger Element\n *
\n *
\n * Content to collapse/expand\n *
\n *
\n *\n * Table Usage (Sibling Pattern):\n * \n * accordion#click\" class=\"expanded\">\n * Trigger Row\n * \n * \n * Content Row 1\n * \n * \n * Content Row 2\n * \n * \n *\n * Features:\n * - Toggle individual accordion sections\n * - Support for expand/collapse all functionality\n * - Automatic fallback for table row sibling patterns\n */\nexport default class extends Controller {\n static targets = ['content', 'trigger'];\n\n\n connect() {\n this.expanded = true;\n }\n\n click(event) {\n event.preventDefault();\n\n // Handle multiple content targets (e.g., table rows)\n // Stimulus 1.1.0 provides contentTargets (plural) automatically\n if (this.hasContentTarget) {\n // Get all content targets - Stimulus 1.1.0 always provides plural accessor\n const targets = this.contentTargets;\n if (targets && targets.length > 0) {\n targets.forEach(target => {\n target.classList.toggle('expanded');\n target.classList.toggle('collapsed');\n });\n }\n } else {\n // Fallback for table accordions where content rows are siblings, not descendants\n // Find sibling rows with data-target=\"accordion.content\" in the same tbody\n const parent = this.element.parentElement;\n if (parent && parent.tagName === 'TBODY') {\n const siblingTargets = Array.from(parent.querySelectorAll('[data-target=\"accordion.content\"]'));\n siblingTargets.forEach(target => {\n target.classList.toggle('expanded');\n target.classList.toggle('collapsed');\n });\n }\n }\n\n if (this.hasTriggerTarget && this.triggerTarget) {\n this.triggerTarget.classList.toggle('expanded');\n this.triggerTarget.classList.toggle('collapsed');\n } else {\n // Fallback: toggle classes on the controller element itself (for table rows)\n this.element.classList.toggle('expanded');\n this.element.classList.toggle('collapsed');\n }\n }\n\n expandAll() {\n const expandAllButton = document.getElementById('expand-all-button');\n const collapseAllButton = document.getElementById('collapse-all-button');\n\n if (expandAllButton) expandAllButton.style.display = 'none';\n if (collapseAllButton) collapseAllButton.style.display = 'block';\n\n this.toggleClass();\n }\n\n collapseAll() {\n const expandAllButton = document.getElementById('expand-all-button');\n const collapseAllButton = document.getElementById('collapse-all-button');\n\n if (collapseAllButton) collapseAllButton.style.display = 'none';\n if (expandAllButton) expandAllButton.style.display = 'block';\n\n this.toggleClass();\n }\n\n toggleClass() {\n const triggers = document.querySelectorAll('.categories-list .ae-accordion-trigger, .sidebar-products-container .ae-accordion-trigger');\n const contents = document.querySelectorAll('.categories-list .ae-accordion-content, .sidebar-products-container .ae-accordion-content');\n\n if (this.expanded) {\n triggers.forEach(trigger => {\n trigger.classList.remove('expanded');\n trigger.classList.add('collapsed');\n });\n\n contents.forEach(content => {\n content.classList.remove('expanded');\n content.classList.add('collapsed');\n });\n } else {\n triggers.forEach(trigger => {\n trigger.classList.remove('collapsed');\n trigger.classList.add('expanded');\n });\n\n contents.forEach(content => {\n content.classList.remove('collapsed');\n content.classList.add('expanded');\n });\n }\n\n this.expanded = !this.expanded;\n }\n}\n", "import { Controller } from '../stimulus';\n\n/**\n * Add Category Popover Controller\n *\n * A Stimulus controller that provides a popover interface for adding new categories\n * to teamsites sales. Handles popover display, form submission, validation, and user interactions.\n *\n * Features:\n * - Configurable anchor positioning via CSS selector\n * - Real-time input validation and error display\n * - AJAX form submission with CSRF protection\n * - Automatic focus management for better UX\n * - Loading states and error handling\n * - Keyboard shortcuts (Enter to submit, clear errors on input)\n *\n * Usage:\n * ```html\n *
\n *\n * \n * \n *\n * \n * add-category-popover#keydown\">\n * \n * \n *
Error message
\n *
\n * ```\n *\n * Integration with Rails:\n * ```erb\n * <%= render 'shared/add_category_popover',\n * sale: @sale,\n * anchor_selector: '.products-tab-add-dropdown button' %>\n * ```\n *\n * Dependencies:\n * - Requires Tippy.js for popover functionality\n * - Requires Turbolinks for page navigation after successful submission\n * - Requires App.showSuccessFlash and App.showErrorFlash for message display\n * - Requires CSRF token meta tag in document head\n *\n * Notes:\n * - Makes POST requests to `/teamsites/{sale_id}/categories`\n * - Input is trimmed server-side before validation\n * - Automatically refreshes page on successful category creation\n * - Popover anchors to element specified by data-anchor-selector attribute\n */\nexport default class extends Controller {\n\n /**\n * Initialize the controller by setting up instance variables from data attributes.\n */\n initialize() {\n this.saleId = this.element.dataset.saleId;\n this.input = `#tippy-text-field-${this.saleId}`;\n this.errorMessage = `#tippy-error-message-${this.saleId}`;\n this.popoverSelector = `#add-category-popover-${this.saleId}`;\n this.inputField = `#tippy-text-field-${this.saleId}`;\n this.anchorSelector = this.element.dataset.anchorSelector || '.ae-dropdown.products-tab-add-dropdown button';\n }\n\n /**\n * Handle click events to display the add category popover.\n * Creates a Tippy.js popover anchored to the configured element with focus management.\n */\n click(e) {\n e.preventDefault();\n e.stopPropagation();\n\n // Use the configured anchor selector\n const anchorElement = document.querySelector(this.anchorSelector);\n if (!anchorElement) {\n return;\n }\n const tippyContainer = new tippy(anchorElement, {\n html: this.popoverSelector,\n interactive: true,\n reactive: true,\n arrow: true,\n placement: 'bottom-start',\n flipBehavior: ['bottom-start', 'bottom-end'],\n boundary: 'viewport',\n theme: 'light',\n trigger: 'manual',\n onShown: (instance) => {\n // Use requestAnimationFrame to ensure DOM is ready\n requestAnimationFrame(() => {\n const inputField = instance.popper.querySelector('.ae-text-field');\n if (inputField) {\n inputField.focus();\n inputField.select(); // Also select any existing text\n }\n });\n },\n });\n\n const popper = tippyContainer.tooltips[0].popper;\n const ref = popper._tippy;\n ref.show();\n }\n\n\n /**\n * Handle form submission to create a new category.\n * Validates input, shows loading state, submits via AJAX, and handles response.\n */\n add(e) {\n e.preventDefault();\n\n const name = this.element.querySelector('.ae-text-field').value;\n\n if (!this.isNameBlank()) {\n this.showLoadingBars();\n\n const url = `/teamsites/${this.saleId}/categories`;\n const data = new FormData();\n data.append('name', name);\n\n fetch(url, {\n method: 'POST',\n body: data,\n headers: {\n 'X-CSRF-Token': document.querySelector('meta[name=\"csrf-token\"]').content,\n 'X-Requested-With': 'XMLHttpRequest',\n },\n }).then((response) => {\n // Check HTTP status before processing response\n if (!response.ok) {\n // Handle HTTP errors (422, 500, etc.)\n return response.json()\n .then(errorData => {\n // Extract error message from server response\n const errorMessage = errorData.error || 'Failed to create category';\n const error = new Error(errorMessage);\n error.isHttpError = true; // Mark as intentional error\n throw error;\n })\n .catch(parseError => {\n // If it's already our intentional error, re-throw it\n if (parseError.isHttpError) {\n throw parseError;\n }\n // If JSON parsing failed, throw generic error\n throw new Error('Failed to create category');\n });\n }\n return response.json();\n }).then((data) => {\n this.hideLoadingBars();\n this.destroyPopper(e.target);\n if (window.App && typeof window.App.showSuccessFlash === 'function') {\n window.App.showSuccessFlash(data.message || 'Category created successfully');\n }\n\n // Dispatch category created event for sidebar refresh\n const categoryCreatedEvent = new CustomEvent('category:created', {\n detail: {\n categoryId: data.id,\n categoryName: data.name,\n saleId: this.saleId,\n },\n });\n document.dispatchEvent(categoryCreatedEvent);\n\n // Add refresh parameter for index page navigation\n // For edit page, the category:created event will trigger sidebar refresh\n const currentUrl = new URL(window.location.href);\n const isEditPage = window.location.pathname.match(/\\/products\\/\\d+\\/edit/);\n\n if (!isEditPage) {\n // On index page, use Turbolinks navigation to refresh\n currentUrl.searchParams.set('refresh', Date.now().toString());\n Turbolinks.visit(currentUrl.toString());\n }\n // On edit page, the event will trigger sidebar refresh (no navigation needed)\n }).catch((error) => {\n // Now catches BOTH network errors AND HTTP errors\n this.hideLoadingBars();\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n const errorMessage = error.message || 'Failed to create category. Please try again.';\n window.App.showErrorFlash(errorMessage);\n }\n });\n } else {\n this.showErrorMessage('Cannot be blank');\n }\n }\n\n /**\n * Handle cancel button clicks to close the popover without saving.\n */\n cancel(e) {\n e.preventDefault();\n\n this.destroyPopper(e.target);\n }\n\n /**\n * Handle keyboard events for form interaction.\n * Clears error styling and submits form on Enter key press.\n */\n keydown(e) {\n const enter = 13;\n\n this.clearErrorStyle();\n\n if (e.keyCode === enter) {\n this.add(e);\n }\n }\n\n /**\n * Check if the category name input is blank or contains only whitespace.\n */\n isNameBlank() {\n const name = this.element.querySelector('.ae-text-field').value;\n\n if (name.trim().length === 0) {\n return true;\n } else {\n return false;\n }\n }\n\n /**\n * Destroy the Tippy.js popover instance to clean up event listeners and DOM.\n */\n destroyPopper(target) {\n const popper = target.closest('.tippy-popper');\n if (popper && popper._tippy) {\n popper._tippy.destroy();\n }\n }\n\n /**\n * Show loading state by adding CSS class to the controller element.\n */\n showLoadingBars() {\n this.element.classList.add('is-loading');\n }\n\n /**\n * Hide loading state by removing CSS class from the controller element.\n */\n hideLoadingBars() {\n this.element.classList.remove('is-loading');\n }\n\n /**\n * Clear error styling from form elements.\n */\n clearErrorStyle() {\n const errorContainer = this.element.querySelector('.ae-error-msg');\n if (errorContainer) {\n errorContainer.classList.add('ae-hidden');\n }\n\n const popper = this.element.closest('.tippy-popper');\n if (popper) {\n const formField = popper.querySelector('.ae-form-field');\n if (formField) {\n formField.classList.remove('ae-input-error');\n }\n }\n }\n\n /**\n * Display an error message and apply error styling to form elements.\n */\n showErrorMessage(message) {\n const errorContainer = this.element.querySelector('.ae-error-msg');\n if (errorContainer) {\n errorContainer.textContent = message;\n errorContainer.classList.remove('ae-hidden');\n }\n\n const popper = this.element.closest('.tippy-popper');\n if (popper) {\n const formField = popper.querySelector('.ae-form-field');\n if (formField) {\n formField.classList.add('ae-input-error');\n }\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n static targets = ['input', 'error'];\n\n initialize() {\n this.saleId = this.element.dataset.saleId;\n this.productId = this.element.dataset.productId;\n }\n\n connect() {\n // Focus will be handled by the parent controller's tippy onShown callback\n // No need for arbitrary setTimeout here\n }\n\n disconnect() {\n // Cleanup if needed\n }\n\n handleSuccess(data) {\n if (typeof App !== 'undefined' && App.showSuccessFlash) {\n App.showSuccessFlash(data.message || 'Color added successfully');\n }\n this.destroyPopper(this.element);\n\n // Reload the page to show the new color variant\n window.location.reload();\n }\n\n handleError(errorMessage) {\n if (typeof App !== 'undefined' && App.showErrorFlash) {\n App.showErrorFlash(errorMessage || 'Something went wrong');\n }\n this.hideLoadingBars();\n }\n\n add(e) {\n e.preventDefault();\n\n const name = this.inputTarget.value;\n\n if (!this.isNameBlank()) {\n this.showLoadingBars();\n\n const url = `/teamsites/${this.saleId}/products/${this.productId}/colors`;\n const data = { name: name };\n\n return fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded',\n 'X-CSRF-Token': document.querySelector('[name=\"csrf-token\"]').content,\n 'X-Requested-With': 'XMLHttpRequest',\n },\n body: new URLSearchParams(data),\n })\n .then(async response => {\n const contentType = response.headers.get('content-type');\n const isJson = contentType && contentType.includes('application/json');\n\n if (isJson) {\n try {\n const jsonData = await response.json();\n if (response.ok) {\n this.handleSuccess(jsonData);\n } else {\n const errorMessage = jsonData.error || jsonData.message || 'Failed to add color';\n this.showErrorMessage(errorMessage);\n this.handleError(errorMessage);\n }\n } catch {\n this.handleError(`Server returned invalid response (${response.status})`);\n }\n } else {\n // Handle non-JSON responses\n const _text = await response.text();\n this.handleError(`Server error (${response.status}). Please try again.`);\n }\n })\n .catch(_error => {\n this.handleError('Network error. Please check your connection.');\n });\n } else {\n this.showErrorMessage('Cannot be blank.');\n }\n }\n\n cancel(e) {\n e.preventDefault();\n\n this.clear();\n this.destroyPopper(e.target);\n }\n\n keydown(e) {\n this.clearErrorStyle();\n\n if (e.key === 'Enter') {\n this.add(e);\n }\n }\n\n clearErrorStyle() {\n const errorContainer = this.element.querySelector('.ae-error-msg');\n if (errorContainer) {\n errorContainer.classList.add('ae-hidden');\n }\n\n const popper = this.element.closest('.tippy-popper');\n if (popper) {\n const formField = popper.querySelector('.ae-form-field');\n if (formField) {\n formField.classList.remove('ae-input-error');\n }\n }\n }\n\n clear() {\n this.inputTarget.value = '';\n if (this.hasErrorTarget) {\n this.errorTarget.textContent = '';\n }\n this.clearErrorStyle();\n }\n\n destroyPopper(target) {\n const popper = target.closest('.tippy-popper');\n if (popper && popper._tippy) {\n popper._tippy.destroy();\n }\n }\n\n isNameBlank() {\n const name = this.inputTarget.value;\n return name.trim().length === 0;\n }\n\n showLoadingBars() {\n this.element.classList.add('is-loading');\n }\n\n hideLoadingBars() {\n this.element.classList.remove('is-loading');\n }\n\n showErrorMessage(message) {\n const errorContainer = this.element.querySelector('.ae-error-msg');\n\n if (errorContainer) {\n errorContainer.textContent = message;\n errorContainer.classList.remove('ae-hidden');\n }\n\n const popper = this.element.closest('.tippy-popper');\n if (popper) {\n const formField = popper.querySelector('.ae-form-field');\n if (formField) {\n formField.classList.add('ae-input-error');\n }\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n connect() {\n // Bind ESC handler to preserve 'this' context\n this.boundHandleEscape = this.handleEscape.bind(this);\n }\n\n disconnect() {\n // Clean up any remaining listeners on disconnect\n this.removeEscapeListener();\n this.removeBackdrop();\n }\n\n async openModal(event) {\n if (event) {\n event.preventDefault();\n }\n\n const saleId = this.element.dataset.saleId;\n const orderNumber = this.element.dataset.orderNumber;\n const url = `/teamsites/${saleId}/orders/${orderNumber}/line_items/index`;\n\n try {\n const response = await fetch(url, {\n headers: {\n 'X-Requested-With': 'XMLHttpRequest',\n 'Accept': 'text/html',\n },\n });\n\n if (!response.ok) {\n throw new Error(`HTTP ${response.status}: ${response.statusText}`);\n }\n\n const html = await response.text();\n const modalContainer = document.getElementById('new-line-item-container');\n const modal = document.getElementById('new-line-item-modal');\n\n if (modalContainer && modal) {\n // Use DOMParser to safely parse HTML without executing scripts\n const parser = new DOMParser();\n const doc = parser.parseFromString(html, 'text/html');\n\n // Clear existing content\n modalContainer.innerHTML = '';\n\n // Append parsed content (scripts won't execute)\n while (doc.body.firstChild) {\n modalContainer.appendChild(doc.body.firstChild);\n }\n\n // Show modal with backdrop\n this.showModal(modal);\n } else {\n console.error('[AddLineItem] Modal container not found');\n }\n } catch (error) {\n console.error('[AddLineItem] Failed to load product selection:', error);\n this.showError('Failed to load product selection. Please try again.');\n }\n }\n\n showModal(modal) {\n // Remove hide class\n modal.classList.remove('mfp-hide');\n\n // Add visibility class\n modal.classList.add('simple-popup-source');\n\n // Explicitly show with inline styles (simple_popup pattern)\n modal.style.display = 'block';\n modal.style.visibility = 'visible';\n modal.style.opacity = '1';\n\n // Add backdrop\n this.createBackdrop();\n\n // Prevent body scroll\n document.body.classList.add('modal-open');\n\n // Add ESC key listener\n document.addEventListener('keydown', this.boundHandleEscape);\n }\n\n createBackdrop() {\n // Remove any existing backdrop first\n this.removeBackdrop();\n\n // Create backdrop element\n this.backdrop = document.createElement('div');\n this.backdrop.className = 'simple-popup-backdrop';\n this.backdrop.style.display = 'block';\n\n // Click backdrop to close\n this.backdrop.addEventListener('click', (e) => {\n e.preventDefault();\n this.closeModal();\n });\n\n document.body.appendChild(this.backdrop);\n }\n\n removeBackdrop() {\n if (this.backdrop && this.backdrop.parentNode) {\n this.backdrop.parentNode.removeChild(this.backdrop);\n this.backdrop = null;\n }\n }\n\n handleEscape(event) {\n if (event.key === 'Escape' || event.keyCode === 27) {\n this.closeModal();\n }\n }\n\n removeEscapeListener() {\n if (this.boundHandleEscape) {\n document.removeEventListener('keydown', this.boundHandleEscape);\n }\n }\n\n closeModal(event) {\n if (event) {\n event.preventDefault();\n }\n\n const modal = document.getElementById('new-line-item-modal');\n const modalContainer = document.getElementById('new-line-item-container');\n\n // Hide modal (following simple_popup_controller pattern)\n if (modal) {\n // Add back mfp-hide class\n modal.classList.add('mfp-hide');\n\n // Remove visibility class\n modal.classList.remove('simple-popup-source');\n\n // Explicitly hide with inline styles\n modal.style.display = 'none';\n modal.style.visibility = 'hidden';\n modal.style.opacity = '0';\n } else {\n console.error('[AddLineItem] Modal element not found during close');\n }\n\n // Clear content\n if (modalContainer) {\n modalContainer.innerHTML = '';\n }\n\n // Remove backdrop\n this.removeBackdrop();\n\n // Restore body scroll\n document.body.classList.remove('modal-open');\n\n // Remove ESC listener\n this.removeEscapeListener();\n }\n\n showError(message) {\n const modalContainer = document.getElementById('new-line-item-container');\n if (!modalContainer) {\n console.error('[AddLineItem] Modal container not found');\n return;\n }\n\n // Create DOM elements programmatically (no innerHTML for user messages)\n const container = document.createElement('div');\n container.className = 'ae-content-container';\n\n const alert = document.createElement('div');\n alert.className = 'ae-alert ae-alert-danger';\n\n const paragraph = document.createElement('p');\n paragraph.textContent = message; // textContent escapes automatically\n\n const button = document.createElement('button');\n button.type = 'button';\n button.className = 'ae-btn ae-btn-secondary';\n button.setAttribute('data-action', 'click->add-line-item#closeModal');\n button.textContent = 'Close';\n\n alert.appendChild(paragraph);\n container.appendChild(alert);\n container.appendChild(button);\n\n modalContainer.innerHTML = '';\n modalContainer.appendChild(container);\n }\n}\n", "/**\n * Generate a UUID v4 string\n * Uses crypto.randomUUID() when available, falls back to manual generation\n */\nexport function generateUUID() {\n if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {\n return crypto.randomUUID();\n }\n\n // Fallback for older browsers\n return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {\n const r = Math.random() * 16 | 0;\n const v = c === 'x' ? r : (r & 0x3 | 0x8);\n return v.toString(16);\n });\n}\n", "import { Controller } from '../stimulus';\nimport { generateUUID } from '../utils/uuid';\n\nexport default class extends Controller {\n // Simplified targets - no siteSection since we use flat list layout\n static targets = ['input', 'salesContainer', 'saleItem', 'emptyState'];\n\n connect() {\n // Track last search term to avoid redundant filtering\n this.lastSearchTerm = '';\n // Store debounce timeout reference for cleanup\n this.debounceTimeout = null;\n // Prevent double-submission\n this.isProcessing = false;\n }\n\n disconnect() {\n // Clean up debounce timeout\n if (this.debounceTimeout) {\n clearTimeout(this.debounceTimeout);\n this.debounceTimeout = null;\n }\n }\n\n /**\n * Public action handler - debounces calls to performFilter\n * Called on input events from the search field\n */\n filter() {\n // Clear any existing debounce timeout\n if (this.debounceTimeout) {\n clearTimeout(this.debounceTimeout);\n }\n\n // Debounce: Wait 150ms before performing the actual filter\n this.debounceTimeout = setTimeout(() => {\n this.performFilter();\n }, 150);\n }\n\n /**\n * Performs the actual filtering logic\n * Called after debounce delay\n * Filters flat list of sales by name\n */\n performFilter() {\n if (!this.hasInputTarget) return;\n\n const searchTerm = this.inputTarget.value.toLowerCase().trim();\n\n // Skip if search term hasn't changed\n if (searchTerm === this.lastSearchTerm) {\n return;\n }\n this.lastSearchTerm = searchTerm;\n\n let visibleCount = 0;\n\n // Filter sale items by name only (flat list, no site grouping)\n // Use setProperty with 'important' to override Express UI's .ae-flex { display: flex !important }\n this.saleItemTargets.forEach(item => {\n const saleName = item.dataset.saleName || '';\n const matches = saleName.includes(searchTerm) || searchTerm === '';\n\n if (matches) {\n item.style.removeProperty('display'); // Remove inline style, let CSS handle display\n visibleCount++;\n } else {\n item.style.setProperty('display', 'none', 'important'); // Override !important CSS\n }\n });\n\n // Show/hide empty state based on visible count\n this.updateEmptyState(visibleCount);\n }\n\n /**\n * Show/hide empty state message\n */\n updateEmptyState(visibleCount) {\n if (!this.hasEmptyStateTarget) return;\n\n this.emptyStateTarget.style.display = visibleCount === 0 ? 'block' : 'none';\n }\n\n selectSale(event) {\n event.preventDefault();\n event.stopPropagation();\n\n // Prevent double-submission (client-side guard)\n if (this.isProcessing) {\n return;\n }\n this.isProcessing = true;\n\n const button = event.currentTarget;\n const saleId = button.dataset.saleId;\n const productId = this.element.dataset.productId;\n\n if (!productId || !saleId) {\n this.isProcessing = false;\n return;\n }\n\n // Get selected color variant ID from original product in search results\n const colorVariantId = this.getSelectedColorVariantId(productId);\n\n // Show immediate feedback\n if (window.App && typeof window.App.showSuccessFlash === 'function') {\n window.App.showSuccessFlash('Adding to TeamSite...');\n }\n\n // Generate operation_id for server-side idempotency (additional safety layer)\n const operationId = generateUUID();\n\n const url = `/teamsites/${saleId}/products/${productId}/clone`;\n\n fetch(url, {\n method: 'POST',\n headers: {\n 'X-CSRF-Token': document.querySelector('meta[name=\"csrf-token\"]').content,\n 'Accept': 'application/json',\n 'Content-Type': 'application/json',\n },\n credentials: 'same-origin',\n body: JSON.stringify({ color_variant_id: colorVariantId, operation_id: operationId }),\n })\n .then(response => {\n if (!response.ok) {\n throw new Error(`HTTP error! status: ${response.status}`);\n }\n return response.json();\n })\n .then(data => {\n // Show success message\n if (data.message) {\n if (window.App && typeof window.App.showSuccessFlash === 'function') {\n window.App.showSuccessFlash(data.message);\n }\n }\n\n // Redirect to the cloned product's edit page FIRST (before modal close)\n // This prevents any modal close handlers from interfering with navigation\n if (data.sale_id && data.product_id) {\n window.location.href = `/teamsites/${data.sale_id}/products/${data.product_id}/edit?refresh=${Date.now()}`;\n return; // Navigation will happen, no need to close modal\n }\n\n // Only close modal if no redirect (fallback case)\n this.closeModal();\n })\n .catch(() => {\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash('Failed to add product to sale. Please try again.');\n }\n });\n }\n\n getSelectedColorVariantId(productId) {\n // Find the product container in the search results (outside the modal)\n const productButton = document.querySelector(\n `button[data-product-id=\"${productId}\"]`,\n );\n\n if (!productButton) {\n return null;\n }\n\n const productContainer = productButton.closest('.product-list-item');\n\n if (!productContainer) {\n return null;\n }\n\n // Find swatches container\n const swatchesContainer = productContainer.querySelector('[data-controller*=\"product-swatches\"]');\n\n if (!swatchesContainer) {\n return null;\n }\n\n const selectedId = swatchesContainer.dataset.selectedColorVariantId;\n return selectedId ? parseInt(selectedId, 10) : null;\n }\n\n closeModal() {\n // Trigger the mfp-close button click to use existing close logic\n const closeButton = this.element.querySelector('.mfp-close');\n if (closeButton) {\n closeButton.click();\n }\n }\n}\n", "/**\n * Stimulus Reinitialize Mixin\n *\n * Provides a standardized way to reinitialize Stimulus controllers\n * after dynamically updating DOM content.\n *\n * Usage:\n * import StimulusReinitMixin from '../mixins/stimulus_reinit_mixin';\n *\n * export default class extends Controller {\n * initialize() {\n * Object.assign(this, StimulusReinitMixin);\n * }\n *\n * updateContent() {\n * container.innerHTML = newHTML;\n * this.reinitializeStimulusControllers(container);\n * }\n * }\n */\nconst StimulusReinitMixin = {\n /**\n * Reinitialize Stimulus controllers for dynamically added content\n * @param {Element} container - The container element with new content\n */\n reinitializeStimulusControllers(container) {\n // Check if Stimulus is available\n if (typeof window.Stimulus === 'undefined' || !window.Stimulus.application) {\n return;\n }\n\n try {\n // For Stimulus 1.1.0, the most reliable approach is to trigger mutation observer\n // by manipulating the DOM in a way that Stimulus can detect\n\n // Phase 0.99.43: SKIP Method 1 DOM manipulation to prevent parent controller disconnection\n //\n // Original Method 1 moved container element to temp wrapper and back, which triggered\n // Stimulus mutation observer BUT also caused parent controllers (like decorator-modal)\n // to disconnect/reconnect, creating timing issues and TypeError scenarios.\n //\n // Root Cause: Moving container removes it from parent DOM tree \u2192 Stimulus disconnects\n // ALL controllers in parent tree \u2192 reconnection race conditions\n //\n // Solution: Rely solely on Method 2 (attribute manipulation) which only affects\n // controllers INSIDE the container, not parent/ancestor controllers.\n //\n // Backward Compatibility: Method 2 alone has proven sufficient across 23 usage\n // locations. Method 1 was defensive redundancy that caused more problems than it solved.\n //\n // Original Method 1 code (DISABLED):\n // const tempWrapper = document.createElement('div');\n // tempWrapper.style.display = 'none';\n // const originalParent = container.parentNode;\n // const originalNextSibling = container.nextSibling;\n // if (originalParent) {\n // originalParent.insertBefore(tempWrapper, originalNextSibling);\n // tempWrapper.appendChild(container); // \u2190 This disconnects parent controllers!\n // originalParent.insertBefore(container, tempWrapper);\n // tempWrapper.remove();\n // }\n \n // Method 2: Direct controller attribute manipulation (PRIMARY METHOD as of Phase 0.99.43)\n //\n // How it works:\n // 1. Find all elements with data-controller INSIDE the container\n // 2. Remove data-controller attribute (triggers Stimulus disconnect for that controller)\n // 3. Wait 50ms for Stimulus to process\n // 4. Restore data-controller attribute (triggers Stimulus connect for that controller)\n //\n // Why this is safe:\n // - querySelectorAll only finds controllers INSIDE container (not parent/ancestor controllers)\n // - Only controllers from the new AJAX content are affected\n // - Parent controllers (like decorator-modal) remain untouched\n //\n setTimeout(() => {\n const controllerElements = container.querySelectorAll('[data-controller]');\n controllerElements.forEach(element => {\n if (element.dataset.controller) {\n const originalValue = element.dataset.controller;\n element.removeAttribute('data-controller');\n\n // Use longer delay to ensure Stimulus processes the change\n setTimeout(() => {\n if (element.isConnected) {\n element.setAttribute('data-controller', originalValue);\n }\n }, 50);\n }\n });\n }, 10);\n \n } catch {\n // Silent fail - don't break the application\n }\n },\n};\n\nexport default StimulusReinitMixin;\n", "import { Controller } from '../stimulus';\nimport StimulusReinitMixin from '../mixins/stimulus_reinit_mixin';\n\n/**\n * AJAX Popup Controller\n *\n * A native replacement for Magnific Popup's AJAX functionality.\n * Fetches content from a URL and displays it in a popup without external dependencies.\n *\n * ## Usage\n *\n * ```html\n * ajax-popup#click\">\n * Open AJAX Modal\n * \n * ```\n *\n * ## Features\n *\n * - Native fetch API for AJAX loading\n * - Preserves Magnific Popup CSS classes for compatibility\n * - Lifecycle callbacks and custom events\n * - Error handling with graceful fallbacks\n * - Loading states\n * - Keyboard navigation (ESC to close)\n * - Backdrop click to close\n *\n * ## CSS Compatibility\n *\n * This controller maintains compatibility with Magnific Popup CSS:\n * - mfp-bg: backdrop overlay\n * - mfp-wrap: popup wrapper\n * - mfp-close: close button\n * - mfp-hide: hidden state\n * - ae-popup: Express UI popup classes\n */\nexport default class extends Controller {\n // Stimulus 1.1.0 doesn't support static values - use data attributes directly\n\n /**\n * Static method to programmatically open an AJAX popup\n * @param {string} url - The URL to load in the popup\n */\n static open(url) {\n // Create an instance directly without relying on Stimulus controller binding\n // This approach works better with dynamically loaded content\n const instance = new this();\n\n // Create a minimal element to satisfy the controller's element requirement\n const element = document.createElement('div');\n element.dataset.url = url;\n\n // Manually set the element property\n Object.defineProperty(instance, 'element', {\n value: element,\n writable: true,\n configurable: true,\n });\n\n // Initialize properties that connect() would normally set\n instance.isOpen = false;\n\n // Apply the mixin manually\n if (StimulusReinitMixin) {\n Object.assign(instance, StimulusReinitMixin);\n }\n\n // Bind event handlers manually\n instance.handleEscKey = instance.handleKeyDown.bind(instance);\n instance.handleWrapperClick = instance.handleWrapperClick.bind(instance);\n instance.handleCloseEvent = instance.handleCloseEvent.bind(instance);\n\n // Call the async open method directly\n // No need to wait - the method handles everything internally\n instance.open().catch(_error => {\n // Handle any errors silently or show user message\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash('Failed to open modal. Please try again.');\n }\n });\n\n // Return the instance in case callers need it\n return instance;\n }\n\n connect() {\n // Initialize state (critical: prevents stale isOpen state from blocking reopens)\n this.isOpen = false;\n\n // Apply mixins\n Object.assign(this, StimulusReinitMixin);\n\n // Bind methods for event handling\n this.handleEscKey = this.handleKeyDown.bind(this);\n this.handleWrapperClick = this.handleWrapperClick.bind(this);\n this.handleCloseEvent = this.handleCloseEvent.bind(this);\n this.handleGlobalClose = this.handleGlobalCloseEvent.bind(this);\n\n // Listen for custom close events\n this.element.addEventListener('ajax-popup:close', this.handleCloseEvent);\n\n // Listen for global close events from ANY ajax-popup instance (including detached instances)\n // This ensures all controller instances reset their state when any modal closes\n document.addEventListener('ajax-popup:closed', this.handleGlobalClose);\n\n // Support programmatic opening via data-url attribute (Stimulus 1.1.0 compatible)\n const urlValue = this.element.dataset.url;\n if (urlValue) {\n this.open();\n }\n }\n\n disconnect() {\n // Remove event listeners\n this.element.removeEventListener('ajax-popup:close', this.handleCloseEvent);\n document.removeEventListener('ajax-popup:closed', this.handleGlobalClose);\n\n // Don't close the popup on disconnect - it should remain open\n // The popup manages its own lifecycle through close buttons and events\n }\n\n /**\n * Handle click event on trigger element\n * @param {Event} event - The click event\n */\n click(event) {\n event.preventDefault();\n event.stopPropagation();\n this.open();\n }\n\n\n /**\n * Open the popup and load content via AJAX\n */\n async open() {\n // Support URL from data-url attribute or href (Stimulus 1.1.0 compatible)\n const url = this.element.dataset.url || this.element.getAttribute('href');\n\n if (!url || url === '#') {\n // No valid URL provided - silently exit\n return;\n }\n\n // Prevent multiple instances of the same modal\n if (this.isOpen) {\n return;\n }\n\n // Check if another modal is already open with same content\n const existingModal = document.querySelector('.mfp-wrap');\n if (existingModal) {\n const existingContent = existingModal.innerHTML;\n if (existingContent && existingContent.includes(url)) {\n // Same modal is already open\n return;\n }\n }\n\n // Clean up any existing modals before opening\n this.cleanupExistingModals();\n\n try {\n // Emit beforeOpen event\n this.dispatch('beforeOpen', { url });\n\n // Create and show backdrop/wrapper\n this.createBackdrop();\n this.createWrapper();\n this.showLoading();\n\n // Fetch content\n const csrfToken = document.querySelector('meta[name=\"csrf-token\"]')?.getAttribute('content');\n const headers = {\n 'X-Requested-With': 'XMLHttpRequest',\n 'Accept': 'text/html',\n };\n\n if (csrfToken) {\n headers['X-CSRF-Token'] = csrfToken;\n }\n\n const response = await fetch(url, {\n headers,\n credentials: 'same-origin', // Include cookies for authentication\n });\n\n if (!response.ok) {\n throw new Error(`HTTP error! status: ${response.status}`);\n }\n\n const html = await response.text();\n\n // Use DOMParser to safely parse HTML without executing scripts\n // Defense-in-depth: Prevents XSS even if server-side escaping fails\n const parser = new DOMParser();\n const doc = parser.parseFromString(html, 'text/html');\n\n // Clear existing content\n this.wrapper.innerHTML = '';\n\n // Append parsed content (scripts won't execute automatically)\n while (doc.body.firstChild) {\n this.wrapper.appendChild(doc.body.firstChild);\n }\n\n // Phase 4.34: Attach complete modal structure to DOM atomically\n // This allows Stimulus MutationObserver to detect decorator-modal controller properly\n document.body.appendChild(this.wrapper);\n\n // Phase 4.35: Direct Save button wiring (bypass Stimulus timing issues)\n // After 8 phases (4.26-4.34) attempting Stimulus-based solutions, accept that\n // Stimulus 1.1.0 MutationObserver + AJAX content has timing/caching issues.\n // Use traditional event listener for Save button while keeping rest of Stimulus intact.\n this.setupDirectSaveHandler();\n\n // Direct filter handler for add-to-sale-selector modal (bypass Stimulus timing issues)\n this.setupDirectFilterHandler();\n\n // Remove loading state\n this.hideLoading();\n\n // Setup close handlers\n this.setupCloseHandlers();\n\n // Phase 1 Experiment Extended: Test if Stimulus 1.1.0 MutationObserver auto-detects AJAX modal content\n // Phase 4.33: Let Stimulus handle decorator-modal naturally via data-action\n //\n // ROOT CAUSE ANALYSIS (Phases 4.26-4.32):\n // The entire fallback system approach was fundamentally flawed. The working architecture was:\n // 1. AJAX loads content with data-action=\"click->decorator-modal#manualSave\"\n // 2. Stimulus MutationObserver detects new content\n // 3. Stimulus connects decorator-modal controller\n // 4. Stimulus parses data-action and creates click handler\n // 5. User clicks \u2192 handler executes on properly connected controller\n //\n // WHAT BROKE IT:\n // Phase 4.29 removed data-action to \"prevent double handlers\" but this eliminated the ONLY\n // working handler. The fallback system tried to manually wire handlers but failed because:\n // - getControllerForElementAndIdentifier called too early (before Stimulus processed content)\n // - Returned partially-initialized instances (isConnected: true, context: undefined)\n // - Stored stale references or created duplicate instances\n // - Had no connection to Stimulus's lifecycle management\n //\n // THE SOLUTION:\n // 1. Keep data-action in template (Phase 4.30 restored it)\n // 2. Don't call reinitializeStimulusControllers (Phase 4.32 - was breaking connection)\n // 3. Don't call setupModalControllerFallbacks (Phase 4.33 - was creating broken handlers)\n // 4. Keep context guards (Phase 4.29/4.31 - good defensive programming)\n // 5. Let Stimulus handle everything naturally - IT WORKS!\n\n // Mark as open\n this.isOpen = true;\n\n // Add body class\n document.body.classList.add('mfp-open');\n\n // Remove tabindex from wrapper (Magnific Popup compatibility)\n this.wrapper.removeAttribute('tabindex');\n\n // Emit afterOpen event\n this.dispatch('afterOpen', { url, content: html });\n\n } catch (_error) {\n // Handle error gracefully\n this.handleError(_error);\n }\n }\n\n /**\n * Close the popup\n * @param {object} options - Close options\n * @param {boolean} options.skipBeforeClose - Skip beforeClose event (for programmatic close after user confirmation)\n */\n close(options = {}) {\n if (!this.isOpen) return;\n\n // Phase 4.90: Emit beforeClose event and check if prevented\n // Inner controllers (like decorator-modal) can prevent close to show confirmation dialogs\n if (!options.skipBeforeClose) {\n const beforeCloseEvent = this.dispatch('beforeClose');\n if (beforeCloseEvent.defaultPrevented) {\n return; // Close was cancelled by inner controller\n }\n }\n\n // Clean up close button handlers\n if (this.wrapper) {\n const closeButtons = this.wrapper.querySelectorAll('[data-ajax-popup-close-handler=\"true\"]');\n closeButtons.forEach(button => {\n button.dataset.ajaxPopupCloseHandler = 'false';\n // Remove any remaining event listeners\n const newButton = button.cloneNode(true);\n if (button.parentNode) {\n button.parentNode.replaceChild(newButton, button);\n }\n });\n\n // Clean up any Stimulus controllers in the wrapper\n // For Stimulus 1.1.0 compatibility - prepare for clean DOM removal\n try {\n const controllers = this.wrapper.querySelectorAll('[data-controller]');\n controllers.forEach(controllerEl => {\n // Stimulus 1.1.0 automatically calls disconnect() when elements are removed from DOM\n // Clear data attributes to ensure clean removal\n if (controllerEl.dataset.controller) {\n controllerEl.dataset.controller = '';\n }\n // Remove any action bindings\n Object.keys(controllerEl.dataset).forEach(key => {\n if (key.startsWith('action')) {\n delete controllerEl.dataset[key];\n }\n });\n });\n } catch {\n // Ignore controller cleanup errors\n }\n }\n\n // Remove backdrop\n if (this.backdrop) {\n this.backdrop.remove();\n this.backdrop = null;\n }\n\n // Remove wrapper click listener and DOM element\n if (this.wrapper) {\n this.wrapper.removeEventListener('click', this.handleWrapperClick);\n this.wrapper.remove();\n this.wrapper = null;\n }\n\n // Remove body class\n document.body.classList.remove('mfp-open');\n\n // Remove event listeners\n document.removeEventListener('keydown', this.handleEscKey);\n\n // Mark as closed\n this.isOpen = false;\n\n // Dispatch global close event to reset state on ALL ajax-popup controller instances\n // This handles the case where detached instances (from AjaxPopupController.open())\n // need to sync state with DOM-bound instances\n const globalCloseEvent = new CustomEvent('ajax-popup:closed', {\n bubbles: true,\n cancelable: false,\n });\n document.dispatchEvent(globalCloseEvent);\n\n // Emit afterClose event with delay to ensure DOM cleanup completes\n setTimeout(() => {\n this.dispatch('afterClose');\n }, 10);\n }\n\n /**\n * Create backdrop element\n * @private\n */\n createBackdrop() {\n this.backdrop = document.createElement('div');\n this.backdrop.className = 'mfp-bg mfp-ready';\n this.backdrop.style.position = 'fixed';\n this.backdrop.style.top = '0';\n this.backdrop.style.left = '0';\n this.backdrop.style.width = '100%';\n this.backdrop.style.height = '100%';\n this.backdrop.style.zIndex = '1040';\n this.backdrop.style.opacity = '0.8';\n this.backdrop.style.backgroundColor = '#0b0b0b';\n\n document.body.appendChild(this.backdrop);\n }\n\n /**\n * Create wrapper element\n * @private\n */\n createWrapper() {\n this.wrapper = document.createElement('div');\n this.wrapper.className = 'mfp-wrap mfp-close-btn-in mfp-auto-cursor mfp-ajax-holder mfp-ready';\n this.wrapper.style.position = 'fixed';\n this.wrapper.style.top = '0';\n this.wrapper.style.left = '0';\n this.wrapper.style.width = '100%';\n this.wrapper.style.height = '100%';\n this.wrapper.style.zIndex = '1050';\n this.wrapper.style.overflow = 'auto';\n this.wrapper.style.display = 'flex';\n this.wrapper.style.alignItems = 'center';\n this.wrapper.style.justifyContent = 'center';\n\n // Phase 4.34: Don't attach to DOM yet - let open() attach after content loaded\n // This gives Stimulus MutationObserver the complete structure in one atomic operation\n\n // Phase 4.96: Store reference to ajax-popup instance on wrapper for modal content access\n // This allows modal content controllers (e.g., decorator-modal) to close the popup\n this.wrapper.ajaxPopupInstance = this;\n\n // Click on wrapper empty space (around modal content) closes the modal.\n // The wrapper sits above the backdrop (z-index 1050 vs 1040), so backdrop\n // click listeners are dead code \u2014 all clicks land on the wrapper.\n this.wrapper.addEventListener('click', this.handleWrapperClick);\n }\n\n /**\n * Show loading indicator\n * @private\n */\n showLoading() {\n const loader = document.createElement('div');\n loader.className = 'mfp-preloader';\n loader.textContent = 'Loading...';\n loader.style.position = 'absolute';\n loader.style.color = '#fff';\n loader.style.fontSize = '14px';\n this.wrapper.appendChild(loader);\n }\n\n /**\n * Hide loading indicator\n * @private\n */\n hideLoading() {\n const loader = this.wrapper.querySelector('.mfp-preloader');\n if (loader) {\n loader.remove();\n }\n }\n\n /**\n * Phase 4.38: Setup event-based handler for decorator modal Save button\n *\n * Phase 4.35 failed: Assumed human reaction time buffer sufficient\n * Phase 4.36 failed: Polled but checked wrong properties (context.scope instead of scope)\n * Phase 4.37 failed: Polling crashed because scope getter throws before typeof can evaluate\n * Phase 4.38 solution: Use DOM events to bypass controller timing issues entirely\n *\n * Dispatches custom 'decorator:triggerSave' event that decorator_modal_controller handles.\n * This is the standard Stimulus pattern for cross-controller communication.\n *\n * @private\n */\n setupDirectSaveHandler() {\n // Only wire for decorator modal (has Save button)\n const saveButton = this.wrapper.querySelector('[data-target=\"decorator-modal.saveButton\"]');\n if (!saveButton) {\n return; // Not a decorator modal or button not found\n }\n\n // Prevent duplicate handlers\n if (saveButton.dataset.ajaxPopupSaveHandler === 'true') {\n return;\n }\n saveButton.dataset.ajaxPopupSaveHandler = 'true';\n\n saveButton.addEventListener('click', async (e) => {\n e.preventDefault();\n\n // Phase 4.38: Dispatch custom event to trigger autosave\n // The decorator-modal controller (when connected) will handle this event\n const saveEvent = new CustomEvent('decorator:triggerSave', {\n bubbles: true, // Bubble up DOM tree to find controller\n cancelable: true, // Allow controller to signal success/failure\n detail: {\n source: 'footer-save-button',\n button: saveButton,\n },\n });\n\n const dispatched = this.wrapper.dispatchEvent(saveEvent);\n\n if (!dispatched || saveEvent.defaultPrevented) {\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash('Unable to save. Please try again.');\n }\n }\n });\n }\n\n /**\n * Setup direct filter handler for add-to-sale-selector modal\n * Bypasses Stimulus 1.1.0 timing issues with AJAX content\n * @private\n */\n setupDirectFilterHandler() {\n // Only wire for add-to-sale-selector modal\n const filterInput = this.wrapper.querySelector('[data-target=\"add-to-sale-selector.input\"]');\n\n if (!filterInput) {\n return; // Not an add-to-sale-selector modal\n }\n\n // Prevent duplicate handlers\n if (filterInput.dataset.ajaxPopupFilterHandler === 'true') {\n return;\n }\n filterInput.dataset.ajaxPopupFilterHandler = 'true';\n\n const saleItems = this.wrapper.querySelectorAll('[data-target=\"add-to-sale-selector.saleItem\"]');\n const emptyState = this.wrapper.querySelector('[data-target=\"add-to-sale-selector.emptyState\"]');\n\n let debounceTimeout = null;\n let lastSearchTerm = '';\n\n filterInput.addEventListener('input', () => {\n // Debounce: Wait 150ms before performing filter\n if (debounceTimeout) {\n clearTimeout(debounceTimeout);\n }\n\n debounceTimeout = setTimeout(() => {\n const searchTerm = filterInput.value.toLowerCase().trim();\n\n // Skip if search term hasn't changed\n if (searchTerm === lastSearchTerm) {\n return;\n }\n lastSearchTerm = searchTerm;\n\n let visibleCount = 0;\n\n // Filter sale items by name\n // Use setProperty with 'important' to override Express UI's .ae-flex { display: flex !important }\n saleItems.forEach(item => {\n const saleName = item.dataset.saleName || '';\n const matches = saleName.includes(searchTerm) || searchTerm === '';\n\n if (matches) {\n item.style.removeProperty('display'); // Remove inline style, let CSS handle display\n visibleCount++;\n } else {\n item.style.setProperty('display', 'none', 'important'); // Override !important CSS\n }\n });\n\n // Show/hide empty state\n if (emptyState) {\n emptyState.style.display = visibleCount === 0 ? 'block' : 'none';\n }\n }, 150);\n });\n }\n\n /**\n * Setup close button and event handlers\n * @private\n */\n setupCloseHandlers() {\n // Find close buttons\n const closeButtons = this.wrapper.querySelectorAll('.mfp-close, [data-action=\"close\"]');\n\n\n closeButtons.forEach(button => {\n // Prevent duplicate handlers\n if (button.dataset.ajaxPopupCloseHandler === 'true') {\n return;\n }\n button.dataset.ajaxPopupCloseHandler = 'true';\n\n button.addEventListener('click', (e) => {\n e.preventDefault();\n e.stopImmediatePropagation(); // Prevent any other click handlers\n this.close();\n });\n });\n\n // Add ESC key handler\n document.addEventListener('keydown', this.handleEscKey);\n }\n\n /**\n * Handle keyboard events\n * @param {KeyboardEvent} event - The keyboard event\n * @private\n */\n handleKeyDown(event) {\n if (event.key === 'Escape') {\n this.close();\n }\n }\n\n /**\n * Handle wrapper click \u2014 closes modal when clicking empty space around content.\n * Ignores clicks on modal content (event.target !== wrapper) and scrollbar\n * clicks (clientX/clientY beyond the wrapper's client area).\n * @param {MouseEvent} event - The click event\n * @private\n */\n handleWrapperClick(event) {\n if (event.target !== this.wrapper) return;\n const rect = this.wrapper.getBoundingClientRect();\n if (event.clientX > rect.left + this.wrapper.clientWidth) return;\n if (event.clientY > rect.top + this.wrapper.clientHeight) return;\n this.close();\n }\n\n /**\n * Handle custom close event\n * @param {Event} event - The custom event\n * @private\n */\n handleCloseEvent(event) {\n event.preventDefault();\n this.close();\n }\n\n /**\n * Handle global close event from any ajax-popup instance\n * Resets this instance's state to allow reopening\n * @param {Event} _event - The global close event (unused but required by addEventListener)\n * @private\n */\n handleGlobalCloseEvent(_event) {\n this.isOpen = false;\n }\n\n /**\n * Handle errors during loading\n * @param {Error} _error - The error object\n * @private\n */\n handleError(_error) {\n this.hideLoading();\n\n const errorMessage = document.createElement('div');\n errorMessage.className = 'mfp-error ae-popup ae-popup-md';\n errorMessage.setAttribute('role', 'dialog');\n errorMessage.setAttribute('aria-modal', 'true');\n errorMessage.setAttribute('aria-labelledby', 'error-modal-title');\n errorMessage.innerHTML = `\n
\n

Error Loading Content

\n \n
\n
\n

Sorry, there was an error loading the content. Please try again.

\n
\n
\n \n
\n `;\n\n this.wrapper.innerHTML = '';\n this.wrapper.appendChild(errorMessage);\n\n // Ensure wrapper is added to DOM if not already present\n if (!this.wrapper.parentNode) {\n document.body.appendChild(this.wrapper);\n }\n\n // Mark as open so modal can be closed\n this.isOpen = true;\n\n // Add body class\n document.body.classList.add('mfp-open');\n\n this.setupCloseHandlers();\n }\n\n /**\n * Dispatch custom events\n * @param {string} name - Event name\n * @param {object} detail - Event detail data\n * @private\n */\n dispatch(name, detail = {}) {\n const event = new CustomEvent(`ajax-popup:${name}`, {\n detail,\n bubbles: true,\n cancelable: true,\n });\n // Dispatch on document if element doesn't exist (for static open method)\n if (this.element && this.element.parentNode) {\n this.element.dispatchEvent(event);\n } else {\n document.dispatchEvent(event);\n }\n // Phase 4.90: Return event so caller can check if it was prevented\n return event;\n }\n\n /**\n * Clean up any existing modal elements before opening a new one\n * This prevents double backdrops from magnificPopup or other modal systems\n * @private\n */\n cleanupExistingModals() {\n // Remove any existing magnificPopup elements\n const existingBackdrops = document.querySelectorAll('.mfp-bg');\n const existingWrappers = document.querySelectorAll('.mfp-wrap');\n\n existingBackdrops.forEach(el => {\n // Clean up event listeners before removal\n const clickHandlers = el.onclick;\n if (clickHandlers) {\n el.onclick = null;\n }\n el.remove();\n });\n\n existingWrappers.forEach(el => {\n // Clean up any Stimulus controllers in the wrapper\n // For Stimulus 1.1.0 compatibility - rely on DOM removal to trigger disconnect\n if (el.querySelector) {\n try {\n const controllers = el.querySelectorAll('[data-controller]');\n controllers.forEach(controllerEl => {\n // Stimulus 1.1.0 automatically calls disconnect() when elements are removed from DOM\n // We just need to ensure clean removal by clearing data attributes\n if (controllerEl.dataset.controller) {\n controllerEl.dataset.controller = '';\n }\n // Remove any action bindings\n Object.keys(controllerEl.dataset).forEach(key => {\n if (key.startsWith('action')) {\n delete controllerEl.dataset[key];\n }\n });\n });\n } catch {\n // Ignore controller cleanup errors\n }\n }\n el.remove();\n });\n\n // Remove the magnificPopup open class from body\n document.body.classList.remove('mfp-open');\n\n // Clean up global event listeners\n document.removeEventListener('keydown', this.handleEscKey);\n\n // Close any existing magnificPopup instances\n if (window.$ && window.$.magnificPopup && window.$.magnificPopup.instance) {\n window.$.magnificPopup.close();\n }\n }\n\n /**\n * Show toast notification for modal actions\n * @param {string} type - 'success' or 'error'\n * @param {string} message - Notification message\n * @private\n */\n showModalNotification(type, message) {\n const notification = document.createElement('div');\n notification.className = `ae-alert ae-alert-${type === 'success' ? 'success' : 'danger'}`;\n notification.style.cssText = 'position:fixed;top:20px;right:20px;z-index:10000;max-width:400px;';\n notification.textContent = message;\n\n document.body.appendChild(notification);\n\n // Auto-remove after 5 seconds\n setTimeout(() => {\n if (notification.parentNode) {\n notification.parentNode.removeChild(notification);\n }\n }, 5000);\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n static get targets() {\n return ['thumbnails'];\n }\n\n connect() {\n // Load gallery thumbnails after a small delay to let main content render first\n this.loadGalleryTimeout = window.setTimeout(() => {\n this.loadGallery().catch(() => {\n // Silently ignore - errors are handled inside loadGallery\n // This prevents uncaught promise rejection when disconnect() aborts fetch\n });\n }, 100);\n }\n\n disconnect() {\n // Clean up timeout if controller disconnects before gallery loads\n if (this.loadGalleryTimeout) {\n window.clearTimeout(this.loadGalleryTimeout);\n this.loadGalleryTimeout = null;\n }\n\n // Abort any pending fetch request\n if (this.abortController) {\n this.abortController.abort();\n this.abortController = null;\n }\n }\n\n getThumbnailsContainer() {\n // Try using Stimulus target first, then fallback to querySelector\n if (this.hasThumbnailsTarget) {\n return this.thumbnailsTarget;\n }\n return this.element.querySelector('[data-target=\"async-gallery.thumbnails\"]');\n }\n\n setThumbnailsContent(html) {\n const container = this.getThumbnailsContainer();\n if (container) {\n container.innerHTML = html;\n\n // Stimulus should automatically detect and connect controllers in the new content\n // via its MutationObserver. No manual initialization needed.\n }\n }\n\n async loadGallery() {\n const productId = this.element.dataset.productId;\n const saleId = this.element.dataset.saleId;\n\n if (!productId || !saleId) {\n // Missing product ID or sale ID for gallery loading\n return;\n }\n\n // Create abort controller for fetch cancellation\n this.abortController = new AbortController();\n\n try {\n const response = await fetch(`/teamsites/${saleId}/products/${productId}/gallery_thumbnails`, {\n method: 'GET',\n headers: {\n 'X-Requested-With': 'XMLHttpRequest',\n 'Accept': 'text/html',\n 'X-CSRF-Token': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content'),\n },\n credentials: 'same-origin',\n signal: this.abortController.signal,\n });\n\n if (!response.ok) {\n throw new Error(`Failed to load gallery: ${response.status}`);\n }\n\n const html = await response.text();\n this.setThumbnailsContent(html);\n\n } catch (error) {\n // Don't show error for aborted requests (user navigated away)\n if (error.name !== 'AbortError') {\n // Failed to load gallery\n\n // Show error message in place of loading spinner\n this.setThumbnailsContent(`\n
\n

Failed to load gallery images

\n \n
\n `);\n }\n } finally {\n this.abortController = null;\n }\n }\n\n retry() {\n // Show loading spinner again\n this.setThumbnailsContent(`\n
\n \"Loading...\"\n
\n `);\n\n // Try loading again\n this.loadGallery().catch(() => {\n // Silently ignore - errors are handled inside loadGallery\n });\n }\n}\n", "/**\n * Safe DOM Manipulation Mixin for Stimulus Controllers\n *\n * This mixin provides safe alternatives to direct innerHTML manipulation,\n * ensuring XSS protection and proper content sanitization.\n *\n * Usage:\n * import SafeDOMMixin from '../mixins/safe_dom_mixin';\n *\n * export default class extends Controller {\n * connect() {\n * Object.assign(this, SafeDOMMixin);\n * }\n *\n * someAction() {\n * this.safelyUpdateContent(this.element, 'New content');\n * }\n * }\n */\n\nconst SafeDOMMixin = {\n /**\n * Safely update element content with text (prevents XSS)\n * @param {Element} element - The target element\n * @param {string} textContent - The text content to set\n */\n safelyUpdateTextContent(element, textContent) {\n if (!element) return;\n element.textContent = textContent || '';\n },\n\n /**\n * Safely update element content with trusted HTML\n * Use this only for static templates or sanitized content\n * @param {Element} element - The target element\n * @param {string} htmlContent - The HTML content to set\n * @param {Object} options - Options for content update\n * @param {boolean} options.trusted - Whether the content is trusted (default: false)\n */\n safelyUpdateContent(element, htmlContent, options = {}) {\n if (!element) return;\n\n const { trusted = false } = options;\n\n if (trusted) {\n // For trusted static templates only\n element.innerHTML = htmlContent || '';\n } else {\n // For potentially unsafe content, use textContent\n element.textContent = htmlContent || '';\n }\n },\n\n /**\n * Safely replace element content with parsed HTML response\n * @param {Element} element - The target element\n * @param {string} htmlResponse - HTML response from server\n * @param {string} selector - CSS selector to extract from response\n */\n safelyReplaceFromResponse(element, htmlResponse, selector) {\n if (!element || !htmlResponse) return;\n\n try {\n const parser = new DOMParser();\n const doc = parser.parseFromString(htmlResponse, 'text/html');\n \n // Check for suspicious elements\n const suspiciousElements = doc.querySelectorAll('script, iframe, object, embed, link[rel=\"import\"], meta[http-equiv=\"refresh\"]');\n if (suspiciousElements.length > 0) {\n // Error: Response contains potentially unsafe elements\n return false;\n }\n\n const newContent = selector ? doc.querySelector(selector) : doc.body.firstElementChild;\n if (newContent) {\n element.innerHTML = newContent.innerHTML;\n return true;\n }\n } catch (error) {\n // Error: Failed to parse HTML response\n return false;\n }\n\n return false;\n },\n\n /**\n * Create and insert safe DOM elements\n * @param {string} tagName - The element tag name\n * @param {Object} attributes - Element attributes\n * @param {string} textContent - Text content for the element\n * @returns {Element} The created element\n */\n createSafeElement(tagName, attributes = {}, textContent = '') {\n const element = document.createElement(tagName);\n \n // Set attributes safely\n for (const [key, value] of Object.entries(attributes)) {\n if (key === 'className') {\n element.className = value;\n } else if (key === 'style' && typeof value === 'object') {\n Object.assign(element.style, value);\n } else {\n element.setAttribute(key, value);\n }\n }\n\n // Set text content safely\n if (textContent) {\n element.textContent = textContent;\n }\n\n return element;\n },\n\n /**\n * Show unified flash messages using the app-wide flash system\n * @param {string} message - The message to display\n * @param {string} type - The type of message ('success', 'error', 'warning')\n */\n showFlashMessage(message, type = 'error') {\n if (window.App) {\n switch (type) {\n case 'success':\n if (typeof window.App.showSuccessFlash === 'function') {\n window.App.showSuccessFlash(message);\n return;\n }\n break;\n case 'error':\n if (typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash(message);\n return;\n }\n break;\n case 'warning':\n // Use error flash for warnings if no warning flash exists\n if (typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash(message);\n return;\n }\n break;\n }\n }\n \n // Fallback: silent fail for production\n // Previously would have used console, now handled gracefully\n }\n};\n\nexport default SafeDOMMixin;", "import { Controller } from '../stimulus';\nimport SafeDOMMixin from '../mixins/safe_dom_mixin';\n\nexport default class extends Controller {\n\n connect() {\n // Apply SafeDOMMixin\n Object.assign(this, SafeDOMMixin);\n // Check if we have a valid URL before attempting to load\n const url = this.element.dataset.url;\n\n if (!url) {\n this.showFallback();\n return;\n }\n\n // Load orders status after a short delay to let the main page render first\n const delay = this.element.dataset.delay ? parseInt(this.element.dataset.delay, 10) : 100;\n\n this.loadTimeout = setTimeout(() => {\n this.loadOrdersStatus();\n }, delay);\n }\n\n disconnect() {\n // Clean up timeout if controller disconnects before loading\n if (this.loadTimeout && typeof clearTimeout !== 'undefined') {\n clearTimeout(this.loadTimeout);\n }\n }\n\n async loadOrdersStatus() {\n // Double-check we have a valid URL\n const url = this.element.dataset.url;\n\n if (!url) {\n this.showFallback();\n return;\n }\n\n try {\n // Get CSRF token from meta tag\n const token = document.querySelector('meta[name=\"csrf-token\"]')?.content;\n const response = await fetch(url, {\n headers: {\n 'X-Requested-With': 'XMLHttpRequest',\n 'Accept': 'text/html',\n 'X-CSRF-Token': token || '',\n },\n credentials: 'same-origin',\n });\n\n\n if (!response.ok) {\n throw new Error(`HTTP error! status: ${response.status}`);\n }\n\n const html = await response.text();\n\n // Replace the entire li element with the loaded content\n const tempDiv = document.createElement('div');\n tempDiv.innerHTML = html;\n const newLi = tempDiv.firstElementChild;\n\n if (newLi) {\n // Preserve the active class if it exists\n const currentClasses = this.element.className;\n if (currentClasses.includes('active')) {\n newLi.classList.add('active');\n }\n\n // Replace the entire element\n this.element.replaceWith(newLi);\n } else {\n this.showFallback();\n }\n } catch {\n // On error, show a simple version without the badge\n this.showFallback();\n }\n }\n\n showFallback() {\n // Get sale ID from data attribute\n const saleId = this.element.dataset.saleId;\n\n if (!saleId) {\n return;\n }\n\n // Use DOM queries to find elements - works in both test and production\n const loadingElement = this.element.querySelector('[data-target=\"async-sidebar-orders.loading\"]');\n const contentElement = this.element.querySelector('[data-target=\"async-sidebar-orders.content\"]');\n\n // Hide loading, show basic link without status badge\n if (loadingElement) {\n loadingElement.style.display = 'none';\n }\n\n if (contentElement) {\n // Try to get the fallback count from data attribute\n const fallbackCount = this.element.dataset.fallbackCount;\n\n const ordersText = fallbackCount ? `Orders (${fallbackCount})` : 'Orders';\n\n const fallbackTemplate = `\n \n \n ${ordersText}\n \n `;\n this.safelyUpdateContent(contentElement, fallbackTemplate, { trusted: true });\n contentElement.style.display = '';\n }\n }\n\n}\n", "/**\n * Notification Mixin for Stimulus Controllers\n *\n * Provides temporary notification display capabilities as a secondary\n * notification system. Use this for non-critical, temporary feedback\n * that doesn't require user interaction.\n *\n * When to use this mixin:\n * - Auto-save confirmations (e.g., \"Settings saved automatically\")\n * - Background operation status (e.g., \"Processing in background...\")\n * - Temporary validation feedback (e.g., \"Email format updated\")\n * - Quick action confirmations (e.g., \"Item added to list\")\n * - Non-blocking informational messages\n *\n * When NOT to use this mixin:\n * - Critical errors requiring user acknowledgment (use App.showErrorFlash)\n * - Success messages for major operations (use App.showSuccessFlash)\n * - Persistent notifications that need to remain visible\n * - Multi-line or complex messages\n * - Notifications requiring user action (buttons, links)\n *\n * Usage:\n * import NotificationMixin from '../mixins/notification_mixin';\n *\n * export default class extends Controller {\n * connect() {\n * Object.assign(this, NotificationMixin);\n * }\n *\n * async autoSaveField() {\n * // Save field...\n * this.showSaveNotification('Changes saved');\n * }\n *\n * handleMinorError() {\n * this.showErrorNotification('Unable to load preview', { duration: 4000 });\n * }\n *\n * showCustomNotification() {\n * this.showNotification('Processing...', 'info', {\n * duration: 2000,\n * position: 'bottom-right'\n * });\n * }\n * }\n *\n * Options:\n * - duration: Time in milliseconds before auto-removal (default: 3000 for success, 5000 for error)\n * - position: CSS position string (default: 'top-right')\n * - styles: Additional CSS styles to apply\n *\n * Accessibility:\n * - Success notifications use role=\"status\" with aria-live=\"polite\"\n * - Error notifications use role=\"alert\" with aria-live=\"assertive\"\n * - All notifications include descriptive aria-labels\n *\n * Migration from inline notifications:\n * 1. Import NotificationMixin\n * 2. Add Object.assign(this, NotificationMixin) in connect()\n * 3. Remove local showSaveNotification/showErrorNotification methods\n * 4. Update any custom styling through options parameter\n */\n\nconst NotificationMixin = {\n /**\n * Show a success notification (typically for save operations)\n * @param {string} message - The message to display\n * @param {Object} options - Configuration options\n * @param {number} options.duration - Display duration in milliseconds (default: 3000)\n * @param {string} options.position - Position string for CSS (default: 'top-right')\n * @param {Object} options.styles - Additional CSS styles to apply\n */\n showSaveNotification(message, options = {}) {\n const defaultOptions = {\n duration: 3000,\n position: 'top-right',\n styles: {},\n };\n\n const config = { ...defaultOptions, ...options };\n\n // Create notification element\n const notification = document.createElement('div');\n notification.textContent = message;\n notification.setAttribute('role', 'status');\n notification.setAttribute('aria-live', 'polite');\n notification.setAttribute('aria-label', 'Success notification');\n\n // Apply styles\n const position = this._parsePosition(config.position);\n notification.style.cssText = `\n position: fixed;\n ${position.vertical}: 20px;\n ${position.horizontal}: 20px;\n background: #4CAF50;\n color: white;\n padding: 12px 20px;\n border-radius: 4px;\n box-shadow: 0 2px 5px rgba(0,0,0,0.2);\n z-index: 10000;\n font-size: 14px;\n ${this._styleObjectToString(config.styles)}\n `;\n\n // Add to DOM and schedule removal\n this._displayNotification(notification, config.duration);\n },\n\n /**\n * Show an error notification\n * @param {string} message - The error message to display\n * @param {Object} options - Configuration options\n * @param {number} options.duration - Display duration in milliseconds (default: 5000)\n * @param {string} options.position - Position string for CSS (default: 'top-right')\n * @param {Object} options.styles - Additional CSS styles to apply\n */\n showErrorNotification(message, options = {}) {\n const defaultOptions = {\n duration: 5000,\n position: 'top-right',\n styles: {},\n };\n\n const config = { ...defaultOptions, ...options };\n\n // Create notification element\n const notification = document.createElement('div');\n notification.textContent = message;\n notification.setAttribute('role', 'alert');\n notification.setAttribute('aria-live', 'assertive');\n notification.setAttribute('aria-label', 'Error notification');\n\n // Apply styles\n const position = this._parsePosition(config.position);\n notification.style.cssText = `\n position: fixed;\n ${position.vertical}: 20px;\n ${position.horizontal}: 20px;\n background: #f44336;\n color: white;\n padding: 12px 20px;\n border-radius: 4px;\n box-shadow: 0 2px 5px rgba(0,0,0,0.2);\n z-index: 10000;\n font-size: 14px;\n ${this._styleObjectToString(config.styles)}\n `;\n\n // Add to DOM and schedule removal\n this._displayNotification(notification, config.duration);\n },\n\n /**\n * Show a generic notification with custom type\n * @param {string} message - The message to display\n * @param {string} type - Notification type ('success', 'error', 'info', 'warning')\n * @param {Object} options - Configuration options\n */\n showNotification(message, type = 'success', options = {}) {\n // Map types to colors and defaults\n const typeConfig = {\n success: {\n background: '#4CAF50',\n duration: 3000,\n role: 'status',\n ariaLive: 'polite',\n },\n error: {\n background: '#f44336',\n duration: 5000,\n role: 'alert',\n ariaLive: 'assertive',\n },\n info: {\n background: '#2196F3',\n duration: 4000,\n role: 'status',\n ariaLive: 'polite',\n },\n warning: {\n background: '#FF9800',\n duration: 4000,\n role: 'alert',\n ariaLive: 'polite',\n },\n };\n\n const config = typeConfig[type] || typeConfig.success;\n const defaultOptions = {\n duration: config.duration,\n position: 'top-right',\n styles: {},\n };\n\n const finalOptions = { ...defaultOptions, ...options };\n\n // Create notification element\n const notification = document.createElement('div');\n notification.textContent = message;\n notification.setAttribute('role', config.role);\n notification.setAttribute('aria-live', config.ariaLive);\n notification.setAttribute('aria-label', `${type} notification`);\n\n // Apply styles\n const position = this._parsePosition(finalOptions.position);\n notification.style.cssText = `\n position: fixed;\n ${position.vertical}: 20px;\n ${position.horizontal}: 20px;\n background: ${config.background};\n color: white;\n padding: 12px 20px;\n border-radius: 4px;\n box-shadow: 0 2px 5px rgba(0,0,0,0.2);\n z-index: 10000;\n font-size: 14px;\n ${this._styleObjectToString(finalOptions.styles)}\n `;\n\n // Add to DOM and schedule removal\n this._displayNotification(notification, finalOptions.duration);\n },\n\n /**\n * Internal: Display notification and handle removal\n * @private\n */\n _displayNotification(notification, duration) {\n document.body.appendChild(notification);\n\n // Schedule removal with fade effect\n setTimeout(() => {\n notification.style.opacity = '0';\n notification.style.transition = 'opacity 0.3s ease';\n setTimeout(() => notification.remove(), 300);\n }, duration);\n },\n\n /**\n * Internal: Parse position string into CSS properties\n * @private\n */\n _parsePosition(position) {\n const parts = position.split('-');\n const vertical = parts.find(p => ['top', 'bottom'].includes(p)) || 'top';\n const horizontal = parts.find(p => ['left', 'right'].includes(p)) || 'right';\n\n return { vertical, horizontal };\n },\n\n /**\n * Internal: Convert style object to CSS string\n * @private\n */\n _styleObjectToString(styles) {\n return Object.entries(styles)\n .map(([key, value]) => {\n // Convert camelCase to kebab-case\n const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase();\n return `${cssKey}: ${value}`;\n })\n .join('; ');\n },\n};\n\nexport default NotificationMixin;\n", "/**\n * CSRF Token Mixin for Stimulus Controllers\n *\n * This mixin provides a standardized way to retrieve CSRF tokens for AJAX requests.\n * It handles various edge cases including missing meta tags and empty tokens.\n *\n * Usage:\n * import CSRFTokenMixin from '../mixins/csrf_token_mixin';\n *\n * export default class extends Controller {\n * connect() {\n * Object.assign(this, CSRFTokenMixin);\n * }\n *\n * async someAction() {\n * // Method 1: Check validity first (recommended)\n * if (!this.hasValidCSRFToken()) {\n * return; // Error already shown to user\n * }\n * \n * // Method 2: Get token with automatic error handling\n * const token = this.getCSRFToken();\n * if (!token) {\n * return; // Error already shown to user\n * }\n *\n * // Method 3: Silent check (for non-critical operations)\n * const silentToken = this.getCSRFTokenSilently();\n * if (!silentToken) {\n * // Handle gracefully without alerting user\n * // \u2705 PRODUCTION READY - No console logging for production quality\n * }\n *\n * fetch(url, {\n * headers: { 'X-CSRF-Token': token }\n * });\n * }\n * }\n */\n\nconst CSRFTokenMixin = {\n /**\n * Get CSRF token from meta tag\n * @returns {string|null} The CSRF token or null if not found\n */\n getCSRFToken() {\n const tokenElement = document.querySelector('meta[name=\"csrf-token\"]');\n if (!tokenElement) {\n this.handleCSRFError('Security token not found. Please refresh the page.');\n return null;\n }\n\n const token = tokenElement.content;\n if (!token || token.trim() === '') {\n this.handleCSRFError('Security token is invalid. Please refresh the page.');\n return null;\n }\n\n return token;\n },\n\n /**\n * Handle CSRF token errors with user notification\n * @param {string} message - The error message to display\n */\n handleCSRFError(message) {\n // Try to use the app's flash message system if available\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash(message);\n }\n // Silent fallback - no console output in production\n },\n\n /**\n * Check if CSRF token is available and valid\n * Shows user-friendly error if not available\n * @param {boolean} showError - Whether to show error message to user\n * @returns {boolean} True if token is available and valid\n */\n hasValidCSRFToken(showError = true) {\n const token = this.getCSRFTokenSilently();\n const isValid = !!token && token.trim() !== '';\n \n if (!isValid && showError) {\n this.handleCSRFError('Security token is missing. Please refresh the page and try again.');\n }\n \n return isValid;\n },\n\n /**\n * Get CSRF token without triggering error notifications\n * Use this for checking token availability without alerting the user\n * @returns {string|null} The CSRF token or null if not found\n */\n getCSRFTokenSilently() {\n const tokenElement = document.querySelector('meta[name=\"csrf-token\"]');\n if (!tokenElement) {\n return null;\n }\n\n const token = tokenElement.content;\n if (!token || token.trim() === '') {\n return null;\n }\n\n return token;\n },\n\n /**\n * Get CSRF token with fallback options\n * Tries multiple sources in order of preference\n * @returns {string|null} The CSRF token or null if not found\n */\n getCSRFTokenWithFallback() {\n // Primary: meta tag (try silently first)\n const metaToken = this.getCSRFTokenSilently();\n if (metaToken) return metaToken;\n\n // Fallback 1: Rails UJS data\n if (window.Rails && window.Rails.csrfToken) {\n return window.Rails.csrfToken();\n }\n\n // Fallback 2: jQuery meta tag reading (if jQuery is available)\n if (window.$ && window.$.rails && window.$.rails.csrfToken) {\n return window.$.rails.csrfToken();\n }\n\n // Fallback 3: Look for token in form inputs\n const tokenInput = document.querySelector('input[name=\"authenticity_token\"]');\n if (tokenInput && tokenInput.value) {\n return tokenInput.value;\n }\n\n // If all fallbacks fail, show error\n this.handleCSRFError('Security token could not be found. Please refresh the page.');\n return null;\n },\n\n /**\n * Check if CSRF token is available\n * @returns {boolean} True if token is available\n */\n hasCSRFToken() {\n return !!this.getCSRFToken();\n },\n\n /**\n * Get headers object with CSRF token included\n * @param {Object} additionalHeaders - Additional headers to merge\n * @returns {Object} Headers object with CSRF token\n */\n getHeadersWithCSRF(additionalHeaders = {}) {\n const token = this.getCSRFToken();\n const headers = { ...additionalHeaders };\n\n if (token) {\n headers['X-CSRF-Token'] = token;\n }\n\n return headers;\n },\n};\n\nexport default CSRFTokenMixin;\n", "import { Controller } from '../stimulus';\nimport NotificationMixin from '../mixins/notification_mixin';\nimport CSRFTokenMixin from '../mixins/csrf_token_mixin';\n\nexport default class extends Controller {\n static targets = ['field'];\n\n initialize() {\n this.oldValues = {};\n this.autosaveInProgress = false;\n }\n\n connect() {\n // Apply mixins - need to bind 'this' context\n Object.assign(this, NotificationMixin);\n Object.assign(this, CSRFTokenMixin);\n\n // Get configuration from data attributes\n this.url = this.data.get('url');\n this.method = this.data.get('method') || 'PATCH';\n this.paramName = this.data.get('param-name') || 'data';\n\n // Store initial values\n this.storeInitialValues();\n }\n\n storeInitialValues() {\n // In Stimulus 1.1.0, we need to find targets differently\n const fields = this.element.querySelectorAll('[data-target*=\"autosave.field\"]');\n\n fields.forEach(field => {\n const key = this.getFieldKey(field);\n const value = this.getFieldValue(field);\n this.oldValues[key] = value;\n });\n }\n\n getFieldKey(field) {\n return field.dataset.fieldName || field.name || field.id;\n }\n\n getFieldValue(field) {\n if (field.type === 'checkbox') {\n return field.checked;\n }\n return field.value;\n }\n\n handleBlur(event) {\n this.autosaveField(event.target);\n }\n\n handleChange(event) {\n // For checkboxes and selects\n if (event.target.type === 'checkbox' || event.target.tagName === 'SELECT') {\n this.autosaveField(event.target);\n }\n }\n\n autosaveField(field) {\n const key = this.getFieldKey(field);\n const currentValue = this.getFieldValue(field);\n\n // Format currency fields\n if (field.classList.contains('ae-currency') && field.type === 'text' && field.value.trim() !== '') {\n const numericValue = parseFloat(field.value);\n if (!isNaN(numericValue)) {\n field.value = numericValue.toFixed(2);\n }\n }\n\n // Only save if value changed\n if (this.oldValues[key] !== currentValue) {\n this.performAutosave(field, key, currentValue);\n }\n }\n\n async performAutosave(field, key, newValue) {\n if (this.autosaveInProgress) {\n return;\n }\n\n this.autosaveInProgress = true;\n\n // Store the original value in case we need to revert\n const originalValue = this.oldValues[key];\n\n try {\n\n // Build the URL - support dynamic URL patterns\n let url = this.url;\n if (url.includes(':id')) {\n const recordId = field.closest('[data-record-id]')?.dataset.recordId ||\n field.closest('[data-rate-id]')?.dataset.rateId;\n if (recordId) {\n url = url.replace(':id', recordId);\n }\n }\n\n // Build payload\n const fieldName = field.dataset.fieldName || field.name;\n const payload = {\n [this.paramName]: {\n [fieldName]: newValue,\n },\n };\n\n const response = await fetch(url, {\n method: this.method,\n headers: {\n 'X-CSRF-Token': this.getCSRFToken(),\n 'Content-Type': 'application/json',\n 'Accept': 'application/json',\n },\n body: JSON.stringify(payload),\n });\n\n if (response.ok) {\n let data;\n try {\n data = await response.json();\n } catch (parseError) {\n // If we can't parse JSON but response is OK, consider it a success\n this.oldValues[key] = newValue;\n this.showSaveNotification('Saved');\n return;\n }\n\n if (data && !data.success && data.success !== undefined) {\n throw new Error(data.errors?.join(', ') || data.message || 'Validation failed');\n }\n\n // Success - update stored value and show notification\n this.oldValues[key] = newValue;\n try {\n // Method 1: Visual feedback on the field itself\n this.showFieldSavedFeedback(field);\n\n // Method 2: Use the mixin\n if (typeof this.showSaveNotification === 'function') {\n this.showSaveNotification('Saved');\n }\n\n // Method 3: Use App flash if available\n if (window.App && typeof window.App.showSuccessFlash === 'function') {\n window.App.showSuccessFlash('Changes saved');\n }\n } catch (notificationError) {\n // Silently ignore notification errors\n }\n } else if (response.status === 422) {\n const data = await response.json();\n throw new Error(data.errors?.join(', ') || 'Validation failed');\n } else {\n throw new Error(`Network error: ${response.status}`);\n }\n } catch (error) {\n // Revert the field value to original\n if (field.type === 'checkbox') {\n field.checked = originalValue || false;\n } else {\n field.value = originalValue || '';\n }\n\n // Make sure stored value stays at original\n this.oldValues[key] = originalValue;\n\n // Show error\n this.showErrorNotification(`Failed to save: ${error.message}`);\n } finally {\n this.autosaveInProgress = false;\n }\n }\n\n showFieldSavedFeedback(field) {\n // Create a \"Saved\" indicator\n const savedIndicator = document.createElement('span');\n savedIndicator.textContent = '\u2713 Saved';\n savedIndicator.style.cssText = `\n color: #4CAF50;\n font-size: 12px;\n font-weight: bold;\n animation: fadeIn 0.3s ease;\n `;\n\n // Position the indicator based on field type\n const container = field.parentElement;\n\n // For currency fields (Fixed Rate), position below the field\n if (field.classList.contains('ae-currency')) {\n savedIndicator.style.cssText += `\n display: block;\n margin-top: 4px;\n margin-left: 0;\n `;\n // Find the price field container and insert after it\n const priceFieldContainer = field.closest('.ae-price-field');\n if (priceFieldContainer) {\n priceFieldContainer.insertAdjacentElement('afterend', savedIndicator);\n } else {\n // Fallback: insert after the field's immediate container\n const fieldContainer = field.closest('.ae-field');\n if (fieldContainer) {\n fieldContainer.parentElement.insertAdjacentElement('afterend', savedIndicator);\n } else {\n container.appendChild(savedIndicator);\n }\n }\n } else {\n // For other fields, position to the right\n savedIndicator.style.marginLeft = '8px';\n container.appendChild(savedIndicator);\n }\n\n // Remove the indicator after 2 seconds\n setTimeout(() => {\n savedIndicator.style.opacity = '0';\n savedIndicator.style.transition = 'opacity 0.3s ease';\n setTimeout(() => savedIndicator.remove(), 300);\n }, 2000);\n }\n\n getCSRFToken() {\n const token = document.querySelector('meta[name=\"csrf-token\"]');\n return token ? token.getAttribute('content') : '';\n }\n}\n", "import { Controller } from '../stimulus';\n\n/**\n * BrandingDeleteController\n *\n * Provides visual feedback during logo/slide deletion on the branding page.\n * Attaches to `.uploaded-img-container` elements and listens for Rails UJS\n * ajax events to show/hide loading state.\n *\n * Usage:\n *
  • \n * \u00D7\n *
  • \n */\nexport default class extends Controller {\n initialize() {\n this.showLoading = this.showLoading.bind(this);\n this.hideLoading = this.hideLoading.bind(this);\n }\n\n connect() {\n const deleteLink = this.element.querySelector('.remove-icon');\n if (deleteLink) {\n deleteLink.addEventListener('ajax:send', this.showLoading);\n deleteLink.addEventListener('ajax:complete', this.hideLoading);\n }\n }\n\n disconnect() {\n const deleteLink = this.element.querySelector('.remove-icon');\n if (deleteLink) {\n deleteLink.removeEventListener('ajax:send', this.showLoading);\n deleteLink.removeEventListener('ajax:complete', this.hideLoading);\n }\n }\n\n showLoading() {\n this.element.classList.add('is-deleting');\n }\n\n hideLoading() {\n this.element.classList.remove('is-deleting');\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n connect() {\n // Dirty state tracking\n this.hasUnsavedChanges = false;\n\n // Store bound handlers for cleanup\n this.boundHandleSuccess = this.handleSuccess.bind(this);\n this.boundHandleError = this.handleError.bind(this);\n this.boundHandleSaved = this.handleSaved.bind(this);\n this.boundMarkAsDirty = this.markAsDirty.bind(this);\n this.boundHandleLinkClick = this.handleLinkClick.bind(this);\n\n // Listen for ajax:success and ajax:error events on the form\n this.element.addEventListener('ajax:success', this.boundHandleSuccess);\n this.element.addEventListener('ajax:error', this.boundHandleError);\n // Listen for custom save event from update.js.erb (more reliable than ajax:success)\n this.element.addEventListener('branding:saved', this.boundHandleSaved);\n\n // Setup dirty state tracking\n this.setupDirtyTracking();\n this.setupBeforeUnload();\n this.setupLinkInterception();\n }\n\n disconnect() {\n // Clean up event listeners\n this.element.removeEventListener('ajax:success', this.boundHandleSuccess);\n this.element.removeEventListener('ajax:error', this.boundHandleError);\n this.element.removeEventListener('branding:saved', this.boundHandleSaved);\n\n // Clean up beforeunload listener\n if (this.boundBeforeUnload) {\n window.removeEventListener('beforeunload', this.boundBeforeUnload);\n }\n\n // Clean up link interception\n document.removeEventListener('click', this.boundHandleLinkClick);\n\n // Clean up dirty tracking listeners\n this.cleanupDirtyTracking();\n }\n\n // ============================================================================\n // DIRTY STATE TRACKING\n // ============================================================================\n\n setupDirtyTracking() {\n const self = this;\n const form = this.element;\n\n // Store references for cleanup\n this.trackedInputs = [];\n this.documentListeners = [];\n\n // Track standard inputs within the form\n const inputs = form.querySelectorAll('input, select, textarea');\n for (let i = 0; i < inputs.length; i++) {\n const input = inputs[i];\n // Skip hidden fields that are not user-editable\n if (input.type === 'hidden' && !input.name.includes('welcome_body')) {\n continue;\n }\n input.addEventListener('change', this.boundMarkAsDirty);\n input.addEventListener('input', this.boundMarkAsDirty);\n this.trackedInputs.push(input);\n }\n\n // Track Trix editor changes (welcome message body)\n const trixChangeHandler = function(e) {\n if (form.contains(e.target)) {\n self.markAsDirty();\n }\n };\n document.addEventListener('trix-change', trixChangeHandler);\n this.documentListeners.push({ event: 'trix-change', handler: trixChangeHandler });\n\n // Track color picker changes via hidden input (Spectrum syncs to input.value)\n // The color-picker controller updates the hidden input, which triggers 'change'\n }\n\n cleanupDirtyTracking() {\n // Remove input listeners\n if (this.trackedInputs) {\n for (let i = 0; i < this.trackedInputs.length; i++) {\n this.trackedInputs[i].removeEventListener('change', this.boundMarkAsDirty);\n this.trackedInputs[i].removeEventListener('input', this.boundMarkAsDirty);\n }\n this.trackedInputs = [];\n }\n\n // Remove document listeners\n if (this.documentListeners) {\n for (let j = 0; j < this.documentListeners.length; j++) {\n const listener = this.documentListeners[j];\n document.removeEventListener(listener.event, listener.handler);\n }\n this.documentListeners = [];\n }\n }\n\n markAsDirty() {\n this.hasUnsavedChanges = true;\n }\n\n resetDirtyState() {\n this.hasUnsavedChanges = false;\n }\n\n hasDirtyState() {\n return this.hasUnsavedChanges;\n }\n\n // Handler for custom branding:saved event from update.js.erb\n handleSaved() {\n this.resetDirtyState();\n }\n\n // ============================================================================\n // BEFOREUNLOAD HANDLER (Browser tab close / external navigation)\n // ============================================================================\n\n setupBeforeUnload() {\n const self = this;\n this.boundBeforeUnload = function(e) {\n if (self.hasDirtyState()) {\n e.preventDefault();\n e.returnValue = ''; // Required for Chrome\n return '';\n }\n };\n window.addEventListener('beforeunload', this.boundBeforeUnload);\n }\n\n // ============================================================================\n // LINK INTERCEPTION (In-page navigation)\n // ============================================================================\n\n setupLinkInterception() {\n document.addEventListener('click', this.boundHandleLinkClick);\n }\n\n handleLinkClick(e) {\n const link = e.target.closest('a[href]');\n if (!link) return;\n\n // Skip external links, anchors, and javascript: links\n const href = link.getAttribute('href');\n if (!href || href.startsWith('#') || href.startsWith('javascript:')) return;\n\n // Skip links with target=\"_blank\"\n if (link.target === '_blank') return;\n\n // Skip links within dropzone or upload areas (they save immediately)\n if (link.closest('.dropzone') || link.closest('[data-controller*=\"dropzone\"]')) return;\n\n // Skip delete/remove links (they trigger immediate server action)\n if (link.classList.contains('remove-icon')) return;\n\n if (this.hasDirtyState()) {\n e.preventDefault();\n const self = this;\n const pendingHref = href;\n this.showUnsavedChangesModal().then(function(choice) {\n if (choice === 'discard') {\n self.resetDirtyState();\n window.location.href = pendingHref;\n } else if (choice === 'save') {\n self.pendingNavigationHref = pendingHref;\n self.submitFormAndNavigate();\n }\n });\n }\n }\n\n // ============================================================================\n // SAVE AND NAVIGATE (Submit form then navigate after success)\n // ============================================================================\n\n submitFormAndNavigate() {\n const self = this;\n\n // Create one-time listener for successful save\n const savedHandler = function() {\n self.element.removeEventListener('branding:saved', savedHandler);\n // Navigate after successful save\n if (self.pendingNavigationHref) {\n window.location.href = self.pendingNavigationHref;\n self.pendingNavigationHref = null;\n }\n };\n\n this.element.addEventListener('branding:saved', savedHandler);\n\n // Trigger form submission via Rails UJS\n if (window.Rails && window.Rails.fire) {\n window.Rails.fire(this.element, 'submit');\n } else {\n // Fallback: dispatch submit event\n this.element.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));\n }\n }\n\n // ============================================================================\n // UNSAVED CHANGES MODAL (Following decorator modal pattern)\n // ============================================================================\n\n showUnsavedChangesModal() {\n const self = this;\n return new Promise(function(resolve) {\n // Create overlay (max z-index for visibility)\n const overlay = document.createElement('div');\n overlay.style.cssText = 'position: fixed; top: 0; left: 0; right: 0; bottom: 0; ' +\n 'background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; ' +\n 'justify-content: center; z-index: 2147483647;';\n\n // Create dialog\n const dialog = document.createElement('div');\n dialog.style.cssText = 'background: white; padding: 24px; border-radius: 8px; ' +\n 'max-width: 400px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);';\n\n const heading = document.createElement('h3');\n heading.style.cssText = 'margin: 0 0 12px 0; font-size: 18px; color: #333;';\n heading.textContent = 'Unsaved Changes';\n\n const message = document.createElement('p');\n message.style.cssText = 'margin: 0 0 20px 0; color: #666;';\n message.textContent = 'You have unsaved branding changes. Are you sure you want to leave without saving?';\n\n const buttonContainer = document.createElement('div');\n buttonContainer.style.cssText = 'display: flex; gap: 8px; justify-content: flex-end;';\n\n const cancelBtn = document.createElement('button');\n cancelBtn.type = 'button';\n cancelBtn.style.cssText = 'padding: 8px 16px; border: 1px solid #ccc; background: #f5f5f5; ' +\n 'color: #333; border-radius: 4px; cursor: pointer; font-size: 14px;';\n cancelBtn.textContent = 'Stay on Page';\n cancelBtn.dataset.choice = 'cancel';\n\n const saveBtn = document.createElement('button');\n saveBtn.type = 'button';\n saveBtn.style.cssText = 'padding: 8px 16px; border: none; background: #27ae60; ' +\n 'color: white; border-radius: 4px; cursor: pointer; font-size: 14px;';\n saveBtn.textContent = 'Save';\n saveBtn.dataset.choice = 'save';\n\n const discardBtn = document.createElement('button');\n discardBtn.type = 'button';\n discardBtn.style.cssText = 'padding: 8px 16px; border: none; background: #c0392b; ' +\n 'color: white; border-radius: 4px; cursor: pointer; font-size: 14px;';\n discardBtn.textContent = 'Leave Page';\n discardBtn.dataset.choice = 'discard';\n\n buttonContainer.appendChild(cancelBtn);\n buttonContainer.appendChild(saveBtn);\n buttonContainer.appendChild(discardBtn);\n dialog.appendChild(heading);\n dialog.appendChild(message);\n dialog.appendChild(buttonContainer);\n overlay.appendChild(dialog);\n document.body.appendChild(overlay);\n\n // Store reference for potential cleanup\n self.pendingDialogOverlay = overlay;\n\n const cleanup = function(action) {\n document.removeEventListener('keydown', escHandler);\n overlay.remove();\n self.pendingDialogOverlay = null;\n resolve(action);\n };\n\n const escHandler = function(e) {\n if (e.key === 'Escape') {\n e.preventDefault();\n cleanup('cancel');\n }\n };\n document.addEventListener('keydown', escHandler);\n\n overlay.addEventListener('click', function(e) {\n if (e.target === overlay) {\n cleanup('cancel');\n }\n });\n\n dialog.addEventListener('click', function(e) {\n e.stopPropagation();\n const action = e.target.dataset.choice;\n if (action) {\n cleanup(action);\n }\n });\n });\n }\n\n // ============================================================================\n // FORM SUBMISSION HANDLERS\n // ============================================================================\n\n handleSuccess(event) {\n // For Rails UJS with remote: true and JS format responses,\n // the ajax:success event fires but event.detail might be empty or contain the JS response\n if (!event.detail || event.detail.length === 0) {\n // No data means the JS response was executed server-side\n // Still reset dirty state on successful save\n this.resetDirtyState();\n return;\n }\n\n const [data, _status, xhr] = event.detail;\n\n // Check if this is a JS response (not JSON)\n const contentType = xhr && xhr.getResponseHeader && xhr.getResponseHeader('Content-Type');\n if (contentType && contentType.includes('text/javascript')) {\n // JS response will handle its own flash display via update.js.erb\n // Still reset dirty state on successful save\n this.resetDirtyState();\n return;\n }\n\n // Only handle JSON responses here\n\n // Parse the response\n let response;\n try {\n response = typeof data === 'string' ? JSON.parse(data) : data;\n } catch {\n // If parsing fails, assume success with no message\n response = { success: true };\n }\n\n // Show success message if available\n if (response && response.message) {\n if (window.App && window.App.showSuccessFlash) {\n window.App.showSuccessFlash(response.message);\n } else {\n // Fallback if App.showSuccessFlash is not available\n this.showFallbackFlash(response.message, 'success');\n }\n }\n\n // Reset dirty state after successful save\n this.resetDirtyState();\n }\n\n handleError(event) {\n const [data] = event.detail;\n\n // Parse the error response\n let response;\n try {\n response = typeof data === 'string' ? JSON.parse(data) : data;\n } catch {\n response = { error: 'An error occurred while saving' };\n }\n\n // Show error message\n const errorMessage = response.error || 'Failed to save branding settings';\n\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash(errorMessage);\n } else {\n this.showFallbackFlash(errorMessage, 'error');\n }\n\n // Note: Do NOT reset dirty state on error - user still has unsaved changes\n }\n\n showFallbackFlash(message, type) {\n // Fallback flash message display if App methods are not available\n const flashContainer = document.getElementById('flash');\n if (!flashContainer) return;\n\n const alertClass = type === 'success' ? 'ae-alert-success' : 'ae-alert-error';\n const iconClass = type === 'success' ? 'fa-check-circle' : 'fa-exclamation-circle';\n\n const messageEl = document.createElement('div');\n messageEl.className = 'ae-alert ae-alert-fixed ' + alertClass;\n messageEl.setAttribute('data-controller', 'closeable-container');\n\n const p = document.createElement('p');\n const icon = document.createElement('i');\n icon.className = 'fa ' + iconClass + ' fa-fw';\n p.appendChild(icon);\n p.appendChild(document.createTextNode(' ' + message));\n\n const closeButton = document.createElement('span');\n closeButton.className = 'ae-alert-action ae-alert-close';\n closeButton.title = 'Close';\n closeButton.setAttribute('data-target', 'closeable-container.closeButton');\n closeButton.setAttribute('data-action', 'click->closeable-container#close');\n closeButton.textContent = '\u00D7';\n\n messageEl.appendChild(p);\n messageEl.appendChild(closeButton);\n flashContainer.appendChild(messageEl);\n\n // Auto-remove after 5 seconds\n setTimeout(function() {\n if (messageEl.parentNode) {\n messageEl.parentNode.removeChild(messageEl);\n }\n }, 5000);\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n static targets = ['checkBox', 'label'];\n\n connect() {\n // Ensure the toggle visual state matches the checkbox state on load\n if (this.hasCheckBoxTarget) {\n this.updateVisualState();\n }\n }\n\n click(event) {\n event.preventDefault();\n\n if (this.hasCheckBoxTarget) {\n // Toggle the checkbox state\n this.checkBoxTarget.checked = !this.checkBoxTarget.checked;\n\n // Update visual state\n this.updateVisualState();\n\n // Trigger change event so form knows field has changed\n this.checkBoxTarget.dispatchEvent(new Event('change', { bubbles: true }));\n }\n }\n\n updateVisualState() {\n // The visual state is handled by CSS based on the checkbox :checked state\n // This method is here for any future visual feedback needs\n if (this.checkBoxTarget.checked) {\n this.element.classList.add('is-active');\n } else {\n this.element.classList.remove('is-active');\n }\n }\n}\n\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n connect() {\n // Wait a bit for modal content to load\n setTimeout(() => {\n this.setupEventListeners();\n }, 100);\n }\n\n setupEventListeners() {\n // Find checkbox\n const checkbox = this.element.querySelector('input[name=\"confirm_delete\"]');\n const submitButton = this.element.querySelector('#delete-products-save');\n\n if (checkbox && submitButton) {\n // Set initial state\n submitButton.disabled = !checkbox.checked;\n\n // Add checkbox listener\n checkbox.addEventListener('change', (e) => {\n submitButton.disabled = !e.target.checked;\n });\n\n // Add button listener\n submitButton.addEventListener('click', (e) => {\n e.preventDefault();\n this.submitForm();\n });\n } else {\n // Retry after a short delay\n setTimeout(() => this.setupEventListeners(), 200);\n }\n }\n\n submitForm() {\n const form = document.getElementById('delete-products-form');\n if (!form) {\n return;\n }\n\n const submitButton = this.element.querySelector('#delete-products-save');\n if (submitButton) {\n submitButton.disabled = true;\n // Use DOM manipulation instead of innerHTML to prevent XSS\n submitButton.textContent = 'Deleting... ';\n const span = document.createElement('span');\n const icon = document.createElement('i');\n icon.className = 'fa fa-spinner fa-spin fa-fw';\n span.appendChild(icon);\n submitButton.appendChild(span);\n }\n\n // Create a manual form submission since Rails UJS might not be working with form= attribute\n const formData = new FormData(form);\n\n fetch(form.action, {\n method: 'POST',\n body: formData,\n headers: {\n 'X-Requested-With': 'XMLHttpRequest',\n 'X-CSRF-Token': document.querySelector('meta[name=\"csrf-token\"]').content,\n 'Accept': 'application/json',\n },\n })\n .then(response => {\n if (!response.ok) {\n throw new Error(`HTTP error! status: ${response.status}`);\n }\n return response.json();\n })\n .then(data => {\n this.handleDeleteResponse(data);\n })\n .catch(() => {\n // Show error message in flash instead of alert\n this.showErrorFlash('An error occurred while deleting products');\n\n // Re-enable button\n if (submitButton) {\n submitButton.disabled = false;\n // Use DOM manipulation instead of innerHTML to prevent XSS\n submitButton.textContent = 'Delete Products ';\n const span = document.createElement('span');\n const icon = document.createElement('i');\n icon.className = 'fa fa-trash fa-fw';\n span.appendChild(icon);\n submitButton.appendChild(span);\n }\n });\n }\n\n handleDeleteResponse(data) {\n if (data.success) {\n // Handle successful deletion\n this.closeModal();\n\n if (data.message) {\n this.showSuccessFlash(data.message);\n }\n\n // Redirect if URL provided\n if (data.redirect_url) {\n setTimeout(() => {\n if (window.Turbo && typeof window.Turbo.visit === 'function') {\n window.Turbo.visit(data.redirect_url, { action: 'replace' });\n } else {\n window.location.href = data.redirect_url;\n }\n }, 300);\n } else {\n // Reload current page if no redirect URL\n setTimeout(() => {\n window.location.reload();\n }, 300);\n }\n } else {\n // Handle error response\n const errorMessage = data.message || 'An error occurred while deleting products';\n this.showErrorFlash(errorMessage);\n\n // Re-enable button on error\n const submitButton = this.element.querySelector('#delete-products-save');\n if (submitButton) {\n submitButton.disabled = false;\n submitButton.textContent = 'Delete Products ';\n const span = document.createElement('span');\n const icon = document.createElement('i');\n icon.className = 'fa fa-trash fa-fw';\n span.appendChild(icon);\n submitButton.appendChild(span);\n }\n }\n }\n\n closeModal() {\n // Dispatch popup:close event for compatibility\n const modal = document.getElementById('bulk-delete-products');\n if (modal) {\n modal.dispatchEvent(new CustomEvent('popup:close'));\n }\n\n // Find and click the close button\n const closeButton = document.querySelector('.mfp-close');\n if (closeButton) {\n closeButton.click();\n }\n }\n\n showSuccessFlash(message) {\n // Try multiple ways to show the success message\n if (window.App && typeof window.App.showSuccessFlash === 'function') {\n window.App.showSuccessFlash(message);\n } else {\n // Fallback to manually inserting flash message\n const flashContainer = document.querySelector('.ae-flash-messages');\n if (flashContainer) {\n const alertDiv = document.createElement('div');\n alertDiv.className = 'ae-alert ae-alert-success ae-alert-dismissible';\n\n const closeButton = document.createElement('button');\n closeButton.type = 'button';\n closeButton.className = 'ae-close';\n closeButton.setAttribute('data-dismiss', 'alert');\n closeButton.textContent = '\u00D7';\n\n const icon = document.createElement('i');\n icon.className = 'fa fa-check-circle';\n\n alertDiv.appendChild(closeButton);\n alertDiv.appendChild(icon);\n alertDiv.appendChild(document.createTextNode(' ' + message));\n\n flashContainer.appendChild(alertDiv);\n\n // Auto-dismiss after 5 seconds\n setTimeout(() => {\n if (alertDiv.parentNode) {\n alertDiv.parentNode.removeChild(alertDiv);\n }\n }, 5000);\n }\n }\n }\n\n showErrorFlash(message) {\n // Try to find flash container\n const flashContainer = document.querySelector('.ae-flash-messages');\n if (flashContainer) {\n const alertDiv = document.createElement('div');\n alertDiv.className = 'ae-alert ae-alert-danger ae-alert-dismissible';\n\n // Create elements safely without innerHTML\n const closeButton = document.createElement('button');\n closeButton.type = 'button';\n closeButton.className = 'ae-close';\n closeButton.setAttribute('data-dismiss', 'alert');\n closeButton.textContent = '\u00D7';\n\n const icon = document.createElement('i');\n icon.className = 'fa fa-exclamation-circle';\n\n alertDiv.appendChild(closeButton);\n alertDiv.appendChild(icon);\n alertDiv.appendChild(document.createTextNode(' ' + message));\n\n flashContainer.appendChild(alertDiv);\n\n // Auto-dismiss after 5 seconds\n setTimeout(() => {\n if (alertDiv.parentNode) {\n alertDiv.parentNode.removeChild(alertDiv);\n }\n }, 5000);\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\n/**\n * Controller for bulk set category form validation\n * Ensures category names cannot be just whitespace\n */\nexport default class extends Controller {\n static targets = ['categorySelect', 'newCategoryInput'];\n\n connect() {\n // Clear any existing errors when modal opens\n this.clearError();\n\n // Store bound handlers for cleanup\n this.boundClearError = () => this.clearError();\n this.boundInputHandler = () => {\n this.clearError();\n this.newCategoryInputTarget.classList.remove('ae-input-error');\n };\n\n // Initialize Select2 on category dropdown\n if (this.hasCategorySelectTarget) {\n const $select = $(this.categorySelectTarget);\n $select.select2({\n placeholder: 'Choose a category...',\n allowClear: true,\n width: '100%',\n dropdownParent: this.element.closest('.ae-popup-content'),\n });\n\n // Listen for Select2 change events to clear errors\n $select.on('select2:select select2:clear', this.boundClearError);\n }\n\n // Clear errors when user types in the text field\n if (this.hasNewCategoryInputTarget) {\n this.newCategoryInputTarget.addEventListener('input', this.boundInputHandler);\n }\n }\n\n disconnect() {\n // Destroy Select2 and clean up event handlers\n if (this.hasCategorySelectTarget) {\n const $select = $(this.categorySelectTarget);\n if ($select.data('select2')) {\n $select.off('select2:select select2:clear', this.boundClearError);\n $select.select2('destroy');\n }\n }\n\n // Clean up text field event listener\n if (this.hasNewCategoryInputTarget && this.boundInputHandler) {\n this.newCategoryInputTarget.removeEventListener('input', this.boundInputHandler);\n }\n }\n\n /**\n * Validates form on submission\n */\n validate(event) {\n const selectValue = this.hasCategorySelectTarget ? this.categorySelectTarget.value : '';\n const newCategoryValue = this.hasNewCategoryInputTarget ? this.newCategoryInputTarget.value : '';\n\n // If a category is selected from dropdown, that's valid - ignore the text field\n if (selectValue) {\n this.clearError();\n return true;\n }\n\n // No dropdown selection, so check the new category field\n const trimmedNewCategory = newCategoryValue.trim();\n\n // If nothing entered at all\n if (!newCategoryValue) {\n event.preventDefault();\n this.showError('Please select a category or enter a new category name');\n return false;\n }\n\n // If something was entered but it's only whitespace\n if (!trimmedNewCategory) {\n event.preventDefault();\n this.showError('Category name cannot be empty or contain only whitespace');\n\n if (this.hasNewCategoryInputTarget) {\n this.newCategoryInputTarget.focus();\n this.newCategoryInputTarget.classList.add('ae-input-error');\n }\n return false;\n }\n\n // Clear any previous errors\n this.clearError();\n return true;\n }\n\n\n /**\n * Shows error message\n */\n showError(message) {\n // Check if error message already exists\n let errorElement = this.element.querySelector('.ae-form-error-message');\n\n if (!errorElement) {\n errorElement = document.createElement('div');\n errorElement.className = 'ae-form-error-message ae-u-mt-10';\n errorElement.style.color = '#dc3545';\n\n // Insert after the new category form group\n const formGroup = this.hasNewCategoryInputTarget ?\n this.newCategoryInputTarget.closest('.ae-form-group') : null;\n\n if (formGroup) {\n formGroup.appendChild(errorElement);\n } else {\n // Fallback: append to the form element (which is the controller element)\n this.element.appendChild(errorElement);\n }\n }\n\n errorElement.textContent = message;\n errorElement.style.display = 'block';\n }\n\n /**\n * Clears error message\n */\n clearError() {\n const errorElement = this.element.querySelector('.ae-form-error-message');\n if (errorElement) {\n errorElement.style.display = 'none';\n }\n\n if (this.hasNewCategoryInputTarget) {\n this.newCategoryInputTarget.classList.remove('ae-input-error');\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n connect() {\n this.symbolEl = document.getElementById('bulk-set-pricing-symbol');\n this.labelEl = document.getElementById('bulk-set-pricing-label');\n this.updateDisplay();\n }\n\n change() {\n this.updateDisplay();\n }\n\n updateDisplay() {\n const selected = document.querySelector('input[name=\"products_bulk_pricing[mode]\"]:checked');\n if (!selected) return;\n\n if (selected.value === 'percentage') {\n if (this.symbolEl) this.symbolEl.textContent = '%';\n if (this.labelEl) this.labelEl.textContent = 'Markup Percentage';\n } else {\n if (this.symbolEl) this.symbolEl.textContent = '$';\n if (this.labelEl) this.labelEl.textContent = 'Markup Amount';\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\n/**\n * Standard Minimal Button Debouncing Controller\n *\n * Prevents double-clicks/submissions by disabling buttons without\n * changing button text or adding visual indicators.\n *\n * Features:\n * - Disables button (functional + visual via ae-btn-disabled)\n * - Preserves button text (no changes)\n * - No loading indicators (no spinners)\n * - Configurable timeout via data-timeout attribute\n * - Manual reset capability for error handling\n * - Proper cleanup in disconnect()\n * - Stimulus 1.1.0 compatible\n *\n * Usage:\n * \n *\n * Manual Reset (for error handling):\n * const controller = application.getControllerForElementAndIdentifier(element, 'button-debounce');\n * controller.manualReset();\n */\nexport default class extends Controller {\n connect() {\n // Boolean flag to prevent double-execution\n this.isProcessing = false;\n\n // Timeout reference for cleanup\n this.recoveryTimeout = null;\n\n // Get configurable timeout (Stimulus 1.1.0 compatible)\n // Default: 10 seconds\n const timeout = parseInt(this.data.get('timeout') || '10000', 10);\n this.timeoutDuration = isNaN(timeout) ? 10000 : timeout;\n }\n\n disconnect() {\n // Clean up timeout to prevent memory leaks\n if (this.recoveryTimeout) {\n clearTimeout(this.recoveryTimeout);\n this.recoveryTimeout = null;\n }\n }\n\n debounce(event) {\n // Guard clause: prevent double-execution\n if (this.isProcessing) {\n event.preventDefault();\n return;\n }\n\n // Mark as processing\n this.isProcessing = true;\n\n // Get button element (works with button, link, or any element)\n const button = event.currentTarget || event.target;\n\n // Check if button is actually inside a form\n const form = button.closest('form');\n\n if (form) {\n // CRITICAL: For submit buttons, handle form submission appropriately\n if (button.type === 'submit') {\n // Check if form has a Stimulus submit action handler OR is a Rails remote form\n const hasStimulusFormHandler = form.hasAttribute('data-action') &&\n form.dataset.action.includes('submit->');\n const isRemoteForm = form.hasAttribute('data-remote') &&\n form.dataset.remote === 'true';\n\n if (hasStimulusFormHandler || isRemoteForm) {\n // Form has Stimulus submit handler - let natural click\u2192submit flow proceed\n // DO NOT preventDefault, DO NOT dispatchEvent\n // Just disable the button to prevent double-clicks\n setTimeout(() => {\n if ('disabled' in button) {\n button.disabled = true;\n }\n button.classList.add('ae-btn-disabled');\n }, 0);\n } else {\n // No Stimulus handler - prevent default and explicitly submit\n event.preventDefault();\n\n setTimeout(() => {\n if ('disabled' in button) {\n button.disabled = true;\n }\n button.classList.add('ae-btn-disabled');\n form.submit();\n }, 0);\n }\n\n // Set recovery timeout (auto-enable after configured duration)\n this.recoveryTimeout = setTimeout(() => {\n this.reset(button);\n }, this.timeoutDuration);\n\n return; // Exit early for submit buttons\n }\n }\n\n // For non-submit buttons, just defer disable\n setTimeout(() => {\n // Disable button (functional)\n if ('disabled' in button) {\n button.disabled = true;\n }\n\n // Add disabled CSS class (visual feedback via Express UI)\n button.classList.add('ae-btn-disabled');\n }, 0);\n\n // Set recovery timeout (auto-enable after configured duration)\n this.recoveryTimeout = setTimeout(() => {\n this.reset(button);\n }, this.timeoutDuration);\n }\n\n reset(button) {\n // Reset processing flag\n this.isProcessing = false;\n\n // Re-enable button (functional)\n if (button && 'disabled' in button) {\n button.disabled = false;\n }\n\n // Remove disabled CSS class (visual)\n if (button) {\n button.classList.remove('ae-btn-disabled');\n }\n\n // Clean up timeout\n if (this.recoveryTimeout) {\n clearTimeout(this.recoveryTimeout);\n this.recoveryTimeout = null;\n }\n }\n\n // Public method for manual reset (error handling)\n manualReset() {\n const selector = 'button, a, [role=\"button\"]';\n const button = this.element.querySelector(selector) ||\n (this.element.matches && this.element.matches(selector) ? this.element : null);\n this.reset(button);\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n connect() {\n this.savingCategories = new Set();\n }\n\n showForm(event) {\n event.preventDefault();\n event.stopPropagation();\n\n // Get category ID from the clicked element\n const clickedElement = event.currentTarget;\n const categoryHeading = clickedElement.closest('.ae-accordion-heading');\n const categoryId = categoryHeading.id.replace('category-heading-container-', '');\n\n // Get the title element and current name\n const titleElement = categoryHeading.querySelector('.ae-accordion-trigger');\n const currentName = titleElement.querySelector('.heading-title').textContent.trim();\n\n // Check if form already exists\n if (document.getElementById(`category-inline-form-${categoryId}`)) {\n return;\n }\n\n // Hide the title\n titleElement.style.display = 'none';\n\n // Hide the category actions (pencil, trash, drag icons)\n const categoryActions = categoryHeading.querySelector('.category-actions');\n if (categoryActions) {\n categoryActions.style.display = 'none';\n }\n\n // Create form elements using DOM methods\n const formContainer = document.createElement('div');\n formContainer.className = 'edit-category-name-form';\n formContainer.id = `category-inline-form-${categoryId}`;\n formContainer.style.cssText = 'display: flex; align-items: center; margin-left: 20px;';\n\n const input = document.createElement('input');\n input.type = 'text';\n input.id = `category-inline-input-${categoryId}`;\n input.className = 'ae-text-field';\n input.style.cssText = 'height: 24px; padding: 2px 6px; font-size: 13px; flex: 1; margin-right: 8px;';\n input.value = currentName;\n\n const actionsDiv = document.createElement('div');\n actionsDiv.style.cssText = 'display: flex; align-items: center;';\n\n const saveButton = document.createElement('button');\n saveButton.type = 'button';\n saveButton.className = 'ae-btn ae-btn-primary ae-btn-sm';\n saveButton.style.cssText = 'padding: 3px 8px; min-height: 24px; margin-right: 4px; background-color: #28a745; border-color: #28a745; color: white; font-size: 14px; line-height: 1;';\n saveButton.dataset.action = 'click->category-inline-edit#save';\n saveButton.dataset.categoryId = categoryId;\n saveButton.textContent = '\u2713';\n\n const cancelButton = document.createElement('button');\n cancelButton.type = 'button';\n cancelButton.className = 'ae-btn ae-btn-default ae-btn-sm';\n cancelButton.style.cssText = 'padding: 3px 8px; min-height: 24px; background-color: #6c757d; border-color: #6c757d; color: white; font-size: 14px; line-height: 1;';\n cancelButton.dataset.action = 'click->category-inline-edit#cancel';\n cancelButton.dataset.categoryId = categoryId;\n cancelButton.textContent = '\u00D7';\n\n actionsDiv.appendChild(saveButton);\n actionsDiv.appendChild(cancelButton);\n\n formContainer.appendChild(input);\n formContainer.appendChild(actionsDiv);\n\n // Insert form after title element\n titleElement.parentNode.insertBefore(formContainer, titleElement.nextSibling);\n\n // Focus and select the input\n input.focus();\n input.select();\n\n // Store bound handlers for cleanup\n const handlers = {\n keydown: (e) => this.handleKeydown(e, categoryId),\n save: (e) => this.save(e),\n cancel: (e) => this.cancel(e),\n };\n\n // Store handlers on the form container for cleanup later\n formContainer.categoryEditHandlers = handlers;\n\n // Add event listeners\n input.addEventListener('keydown', handlers.keydown);\n saveButton.addEventListener('click', handlers.save);\n cancelButton.addEventListener('click', handlers.cancel);\n }\n\n handleKeydown(event, categoryId) {\n if (event.key === 'Enter') {\n event.preventDefault();\n this.saveCategory(categoryId);\n } else if (event.key === 'Escape') {\n event.preventDefault();\n this.cancelEdit(categoryId);\n }\n }\n\n save(event) {\n event.preventDefault();\n const categoryId = event.currentTarget.dataset.categoryId;\n this.saveCategory(categoryId);\n }\n\n cancel(event) {\n event.preventDefault();\n const categoryId = event.currentTarget.dataset.categoryId;\n this.cancelEdit(categoryId);\n }\n\n saveCategory(categoryId) {\n // Prevent multiple saves for the same category\n if (this.savingCategories.has(categoryId)) {\n return;\n }\n\n const input = document.getElementById(`category-inline-input-${categoryId}`);\n const saveButton = document.querySelector(`[data-category-id=\"${categoryId}\"][data-action*=\"save\"]`);\n const newName = input.value.trim();\n\n if (!newName) {\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash('Category name cannot be empty');\n }\n return;\n }\n\n // Set saving state for this specific category\n this.savingCategories.add(categoryId);\n input.disabled = true;\n input.style.opacity = '0.6';\n if (saveButton) {\n saveButton.disabled = true;\n saveButton.style.opacity = '0.6';\n }\n\n // Get the category heading to access the delete button with sale ID\n const categoryHeading = document.getElementById(`category-heading-container-${categoryId}`);\n const deleteBtn = categoryHeading.querySelector('.category-delete-btn');\n const saleId = deleteBtn ? deleteBtn.dataset.saleId : this.getSaleId();\n\n const url = `/teamsites/${saleId}/categories/${categoryId}`;\n const csrfToken = document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content');\n\n fetch(url, {\n method: 'PATCH',\n headers: {\n 'Content-Type': 'application/json',\n 'X-CSRF-Token': csrfToken,\n 'Accept': 'application/json',\n },\n body: JSON.stringify({ name: newName }),\n })\n .then(response => {\n if (!response.ok) {\n if (response.headers.get('content-type')?.includes('application/json')) {\n return response.json().then(data => {\n throw new Error(data.error || 'Error updating category name');\n });\n } else {\n throw new Error('Error updating category name');\n }\n }\n return response.json();\n })\n .then(_data => {\n // Update the category name in the DOM\n const categoryHeading = document.getElementById(`category-heading-container-${categoryId}`);\n const headingTitle = categoryHeading.querySelector('.heading-title');\n if (headingTitle) {\n headingTitle.textContent = newName;\n headingTitle.dataset.title = newName;\n }\n\n // Update the delete button's data-name attribute\n const deleteBtn = categoryHeading.querySelector('.category-delete-btn');\n if (deleteBtn) {\n deleteBtn.dataset.name = newName;\n }\n\n // Remove the form and show the title\n this.cancelEdit(categoryId);\n\n // Show success message\n if (window.App && typeof window.App.showSuccessFlash === 'function') {\n window.App.showSuccessFlash('Category renamed successfully');\n }\n })\n .catch(error => {\n // Re-enable input and button on error\n this.savingCategories.delete(categoryId);\n input.disabled = false;\n input.style.opacity = '1';\n if (saveButton) {\n saveButton.disabled = false;\n saveButton.style.opacity = '1';\n }\n\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash(error.message || 'Error updating category name');\n }\n })\n .finally(() => {\n // Reset saving state for this category\n this.savingCategories.delete(categoryId);\n });\n }\n\n cancelEdit(categoryId) {\n const categoryHeading = document.getElementById(`category-heading-container-${categoryId}`);\n const form = document.getElementById(`category-inline-form-${categoryId}`);\n const titleElement = categoryHeading.querySelector('.ae-accordion-trigger');\n const categoryActions = categoryHeading.querySelector('.category-actions');\n\n if (form) {\n // Clean up event listeners before removing the form\n if (form.categoryEditHandlers) {\n const input = document.getElementById(`category-inline-input-${categoryId}`);\n const saveButton = form.querySelector('[data-action*=\"save\"]');\n const cancelButton = form.querySelector('[data-action*=\"cancel\"]');\n\n if (input) {\n input.removeEventListener('keydown', form.categoryEditHandlers.keydown);\n }\n if (saveButton) {\n saveButton.removeEventListener('click', form.categoryEditHandlers.save);\n }\n if (cancelButton) {\n cancelButton.removeEventListener('click', form.categoryEditHandlers.cancel);\n }\n\n // Clean up the stored handlers\n delete form.categoryEditHandlers;\n }\n\n form.remove();\n }\n\n if (titleElement) {\n titleElement.style.display = '';\n }\n\n if (categoryActions) {\n categoryActions.style.display = '';\n }\n }\n\n getSaleId() {\n // Try to get sale ID from the delete button in the same category heading\n const categoryHeading = this.element.closest('.ae-accordion-heading');\n if (categoryHeading) {\n const deleteBtn = categoryHeading.querySelector('.category-delete-btn');\n if (deleteBtn && deleteBtn.dataset.saleId) {\n return deleteBtn.dataset.saleId;\n }\n }\n\n // Try to get sale ID from the sidebar container\n const sidebar = document.getElementById('products-sidebar');\n if (sidebar && sidebar.dataset.saleId) {\n return sidebar.dataset.saleId;\n }\n\n // Fallback: get from current URL\n const pathParts = window.location.pathname.split('/');\n const teamsitesIndex = pathParts.indexOf('teamsites');\n if (teamsitesIndex !== -1 && pathParts[teamsitesIndex + 1]) {\n return pathParts[teamsitesIndex + 1];\n }\n\n // Also check for sales URL pattern\n const salesIndex = pathParts.indexOf('sales');\n if (salesIndex !== -1 && pathParts[salesIndex + 1]) {\n return pathParts[salesIndex + 1];\n }\n\n throw new Error('Could not determine sale ID');\n }\n}\n\n", "import { Controller } from '../stimulus';\n\nimport AjaxPopupController from './ajax_popup_controller';\n\nexport default class extends Controller {\n connect() {\n console.log('[CategoryProductNew] Controller connected for product:', this.element.dataset.productId);\n }\n\n disconnect() {\n console.log('[CategoryProductNew] Controller disconnected');\n }\n\n selectProduct(event) {\n event.preventDefault();\n\n const productId = this.element.dataset.productId;\n const saleId = this.element.dataset.saleId;\n const orderNumber = this.element.dataset.orderNumber;\n const url = `/teamsites/${saleId}/orders/${orderNumber}/line_items/configure_product?product_id=${productId}`;\n\n console.log('[CategoryProductNew] Opening line item form modal for product:', productId);\n\n // Open new configure_product modal using AjaxPopupController\n // AjaxPopupController.open() will handle cleanup of existing modals via cleanupExistingModals()\n console.log('[CategoryProductNew] Calling AjaxPopupController.open() with URL:', url);\n AjaxPopupController.open(url);\n }\n}\n", "import { Controller } from '../stimulus';\n\n/**\n * Clickable Row Controller\n *\n * Makes table rows clickable to navigate to a specified URL\n *\n * Required data attributes:\n * - data-clickable-row-url: The URL to navigate to when the row is clicked\n *\n * Optional data attributes:\n * - data-clickable-row-window: Window target (_blank for new tab, defaults to same window)\n * - data-clickable-row-ignore-selectors: Comma-separated list of selectors for elements that should not trigger navigation\n *\n * @example\n * clickable-row#onClick\">\n * Cell content\n * \n */\nexport default class extends Controller {\n static targets = [];\n\n initialize() {\n // Default ignore selectors\n this.ignoreSelectors = 'a,button,input,select,textarea,[data-clickable-ignore]';\n }\n\n connect() {\n this.element.classList.add('ae-clickable');\n this.element.style.cursor = 'pointer';\n }\n\n onClick(event) {\n // Get the URL from the data attribute\n const url = this.data.get('url');\n\n // Get window target attribute (e.g., '_blank' for new tab)\n const windowTarget = this.data.get('window');\n\n // Get custom ignore selectors or use default\n const ignoreSelectors = this.data.get('ignoreSelectors') || this.ignoreSelectors;\n\n // Don't navigate if an interactive element was clicked\n if (ignoreSelectors && event.target.closest(ignoreSelectors)) {\n return;\n }\n\n if (url) {\n // Handle new tab navigation\n if (windowTarget === '_blank') {\n window.open(url, '_blank', 'noopener,noreferrer');\n return;\n }\n\n // Add loading state for same-window navigation\n document.body.classList.add('is-loading');\n\n // Use Turbolinks for navigation\n if (typeof Turbolinks !== 'undefined') {\n try {\n Turbolinks.visit(url);\n } catch {\n // Turbolinks error\n // Fallback to standard navigation\n window.location.href = url;\n }\n } else {\n window.location.href = url;\n }\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n static targets = ['source', 'button'];\n\n updateButtonText() {\n this.buttonTarget.textContent = 'Copied!';\n\n setTimeout(() => {\n this.buttonTarget.textContent = 'Copy';\n }, 3000);\n }\n\n copy() {\n this.sourceTarget.select();\n document.execCommand('copy');\n this.updateButtonText();\n }\n}\n", "/**\n * Modal Progress Mixin for Stimulus Controllers\n *\n * Replaces modal form content with a progress spinner during async operations.\n * Operates at .ae-popup level to cleanly replace all content below the heading.\n *\n * Usage:\n * import ModalProgressMixin from '../mixins/modal_progress_mixin';\n *\n * export default class extends Controller {\n * connect() {\n * Object.assign(this, ModalProgressMixin);\n * }\n *\n * async submit(e) {\n * // On 202 response:\n * this.showModalProgress('Cloning in progress...');\n * this.startPolling(data.status_url);\n * }\n * }\n */\n\nconst ModalProgressMixin = {\n /**\n * Replace modal content with a progress spinner.\n * Operates at .ae-popup level: preserves .ae-popup-heading, removes everything else.\n * Falls back to replacing this.element content when not inside .ae-popup.\n *\n * @param {string} message - Progress message to display\n */\n showModalProgress(message) {\n var popup = this.element.closest('.ae-popup');\n\n // Destroy flatpickr instances safely before clearing content\n try {\n var searchRoot = popup || this.element;\n searchRoot.querySelectorAll('.flatpickr-input').forEach(function(input) {\n if (input._flatpickr) input._flatpickr.destroy();\n });\n } catch (e) {\n // Continue even if flatpickr cleanup fails\n }\n\n if (popup) {\n // Update popup heading title\n var title = popup.querySelector('.ae-popup-title');\n if (title) title.textContent = 'Cloning in progress';\n\n // Remove ALL children except the heading (form, footer, any extras)\n var children = Array.from(popup.children);\n for (var i = 0; i < children.length; i++) {\n if (!children[i].classList.contains('ae-popup-heading')) {\n popup.removeChild(children[i]);\n }\n }\n\n // Append progress content directly to popup (below heading)\n var container = this._createProgressContainer(message);\n popup.appendChild(container);\n container.focus();\n } else {\n // Fallback: replace controller element content only\n this.element.innerHTML = '';\n var container = this._createProgressContainer(message);\n this.element.appendChild(container);\n container.focus();\n }\n },\n\n /**\n * Create progress spinner container element.\n *\n * @param {string} message - Progress message to display\n * @returns {HTMLElement} Container with spinner, message, and hint\n * @private\n */\n _createProgressContainer(message) {\n var container = document.createElement('div');\n container.setAttribute('role', 'status');\n container.setAttribute('aria-live', 'polite');\n container.setAttribute('tabindex', '-1');\n container.style.cssText = 'text-align: center; padding: 40px 20px;';\n\n var spinner = document.createElement('i');\n spinner.className = 'fa fa-spinner fa-spin fa-2x';\n spinner.style.color = '#999';\n container.appendChild(spinner);\n\n var msgP = document.createElement('p');\n msgP.style.cssText = 'margin-top: 15px; color: #666;';\n msgP.textContent = message;\n container.appendChild(msgP);\n\n var hintP = document.createElement('p');\n hintP.style.marginTop = '10px';\n var hintSmall = document.createElement('small');\n hintSmall.style.color = '#999';\n hintSmall.textContent = 'You can close this dialog. Cloning continues in the background.';\n hintP.appendChild(hintSmall);\n container.appendChild(hintP);\n\n return container;\n },\n};\n\nexport default ModalProgressMixin;\n", "import { Controller } from '../stimulus';\nimport ModalProgressMixin from '../mixins/modal_progress_mixin';\n\n/**\n * Clone Sale Form Controller\n *\n * Handles form submission for cloning a sale.\n * Validates input, submits via fetch, handles response.\n *\n * Targets:\n * name: Input field for sale name\n * submitButton: Continue button\n * error: Error message span\n *\n * Data Attributes:\n * data-sale-id: ID of sale to clone\n *\n * Actions:\n * submit: Validate and submit form\n * cancel: Close modal without submitting\n */\nexport default class extends Controller {\n static targets = ['name', 'submitButton', 'error'];\n\n /**\n * Initialize submitting guard on connect.\n * Resets to false so modal reuse works correctly.\n */\n connect() {\n this._submitting = false;\n Object.assign(this, ModalProgressMixin);\n }\n\n /**\n * Cleanup on disconnect\n * Note: We intentionally do NOT stop polling here because the polling\n * is stored on window and should continue after the modal closes.\n * Use cancelPolling() for explicit cancellation (e.g., cancel button).\n */\n disconnect() {\n // Don't stop window-level polling - it needs to survive modal close\n // to redirect the user when cloning completes\n }\n\n /**\n * Handle form submission\n * Submits clone request and starts polling for completion status.\n */\n async submit(e) {\n e.preventDefault();\n e.stopPropagation();\n\n // Guard: reject duplicate submits while request is in-flight\n if (this._submitting) return;\n\n // Clear previous errors\n this.hideError();\n\n // Get and validate name\n const name = this.nameTarget.value.trim();\n\n if (!name) {\n this.showValidationError('Sale name is required');\n\n // Reset button debounce controller\n this.resetButtonDebounce();\n return;\n }\n\n // Get sale ID\n const saleId = this.element.dataset.saleId;\n\n if (!saleId) {\n this.showValidationError('Sale ID not found');\n this.resetButtonDebounce();\n return;\n }\n\n // Set guard AFTER validation passes, BEFORE fetch\n this._submitting = true;\n\n const url = `/teamsites/${saleId}/clone_sale`;\n\n try {\n const response = await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Accept': 'application/json',\n 'X-Requested-With': 'XMLHttpRequest',\n 'X-CSRF-Token': this.getCsrfToken(),\n },\n body: JSON.stringify({ name: name }),\n });\n\n const data = await response.json();\n\n if (response.status === 202) {\n // Accepted - async job started, begin polling\n\n // Show spinner BEFORE starting polling (prevents race if job completes fast)\n this.showModalProgress('Sale cloning in progress. This may take a minute...');\n\n if (data.status_url) {\n this.startPolling(data.status_url);\n }\n } else if (response.ok) {\n // Immediate success (backward compatible with synchronous flow)\n this.showSuccess(data.message || 'Sale cloned successfully');\n\n if (data.redirect) {\n // Redirect after short delay to allow user to see success message\n setTimeout(() => {\n window.location.href = data.redirect;\n }, 1000);\n }\n } else {\n // Server returned error\n this._submitting = false;\n this.showError(data.error || 'Failed to clone sale');\n this.resetButtonDebounce();\n }\n } catch (error) {\n console.error('[CloneSaleForm] Request failed:', error);\n this._submitting = false;\n this.showError('Network error. Please try again.');\n this.resetButtonDebounce();\n }\n }\n\n /**\n * Start polling for clone status\n * Polling is stored on window to survive controller disconnect when modal closes.\n *\n * @param {string} statusUrl - URL to poll for status updates\n */\n startPolling(statusUrl) {\n\n // Clear any existing polling (safety)\n this.stopPolling();\n\n // Helper to stop polling (doesn't use 'this' so it works after disconnect)\n const stopWindowPolling = () => {\n if (window._cloneSalePolling) {\n if (window._cloneSalePolling.interval) {\n clearInterval(window._cloneSalePolling.interval);\n }\n if (window._cloneSalePolling.timeout) {\n clearTimeout(window._cloneSalePolling.timeout);\n }\n window._cloneSalePolling = null;\n }\n };\n\n // Store polling on window to survive controller disconnect\n window._cloneSalePolling = {\n interval: setInterval(async () => {\n try {\n const response = await fetch(statusUrl, {\n headers: {\n Accept: 'application/json',\n 'X-Requested-With': 'XMLHttpRequest',\n },\n });\n const data = await response.json();\n\n if (data.status === 'completed') {\n stopWindowPolling();\n if (window.App && window.App.showSuccessFlash) {\n window.App.showSuccessFlash('Sale cloned successfully!');\n }\n if (data.redirect) {\n window.location.href = data.redirect;\n }\n } else if (data.status === 'unknown' || data.status === 'error' || data.status === 'failed') {\n stopWindowPolling();\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash(\n data.error || 'Cloning may have failed. Please check the teamsites list.',\n );\n }\n }\n // 'in_progress' - keep polling\n } catch (error) {\n console.error('[CloneSaleForm] Polling error:', error);\n // Continue polling on network errors (transient)\n }\n }, 3000),\n\n timeout: setTimeout(() => {\n stopWindowPolling();\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash('Clone operation timed out. Please check the teamsites list.');\n }\n }, 35 * 60 * 1000),\n };\n }\n\n /**\n * Stop polling for clone status\n * Works with window-level polling storage\n */\n stopPolling() {\n if (window._cloneSalePolling) {\n if (window._cloneSalePolling.interval) {\n clearInterval(window._cloneSalePolling.interval);\n }\n if (window._cloneSalePolling.timeout) {\n clearTimeout(window._cloneSalePolling.timeout);\n }\n window._cloneSalePolling = null;\n }\n }\n\n /**\n * Close the modal\n * Directly clicks the close button in the ajax-popup wrapper\n * because event bubbling from this element doesn't reach the ajax-popup controller\n * (they are not in the same DOM hierarchy - wrapper is appended to document.body)\n */\n closeModal() {\n // Find the ajax-popup wrapper and click its close button directly\n const wrapper = document.querySelector('.mfp-wrap');\n if (wrapper) {\n const closeButton = wrapper.querySelector('.mfp-close');\n if (closeButton) {\n closeButton.click();\n return;\n }\n }\n // Fallback: try dispatching to document\n document.dispatchEvent(new CustomEvent('ajax-popup:close'));\n }\n\n /**\n * Handle cancel button click\n * Stops any active polling and closes the modal\n */\n cancel(e) {\n e.preventDefault();\n e.stopPropagation();\n\n // Stop polling if user explicitly cancels\n this.stopPolling();\n\n // Close the modal\n this.closeModal();\n }\n\n /**\n * Get CSRF token from meta tag\n *\n * @returns {string} CSRF token\n */\n getCsrfToken() {\n const token = document.querySelector('meta[name=\"csrf-token\"]');\n return token ? token.content : '';\n }\n\n /**\n * Show validation error inline\n *\n * @param {string} message - Error message\n */\n showValidationError(message) {\n if (this.hasErrorTarget) {\n this.errorTarget.textContent = message;\n this.errorTarget.style.display = 'block';\n }\n }\n\n /**\n * Show error using unified flash system\n *\n * @param {string} message - Error message\n */\n showError(message) {\n if (typeof window.App !== 'undefined' &&\n typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash(message);\n }\n\n // Also show inline\n this.showValidationError(message);\n }\n\n /**\n * Show success using unified flash system\n *\n * @param {string} message - Success message\n */\n showSuccess(message) {\n if (typeof window.App !== 'undefined' &&\n typeof window.App.showSuccessFlash === 'function') {\n window.App.showSuccessFlash(message);\n }\n }\n\n /**\n * Hide error message\n */\n hideError() {\n if (this.hasErrorTarget) {\n this.errorTarget.textContent = '';\n this.errorTarget.style.display = 'none';\n }\n }\n\n /**\n * Reset button debounce controller\n * Called on validation errors to re-enable button\n */\n resetButtonDebounce() {\n if (!this.hasSubmitButtonTarget) return;\n\n const button = this.submitButtonTarget;\n const debounceController = this.application.getControllerForElementAndIdentifier(\n button,\n 'button-debounce',\n );\n\n if (debounceController && typeof debounceController.manualReset === 'function') {\n debounceController.manualReset();\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n static targets = ['openDate', 'closeDate', 'shipDate'];\n\n initialize() {\n const defaultOpenDateMin = this.element.dataset.defaultOpenDate;\n const defaultCloseDateMax = this.element.dataset.defaultCloseDate;\n const defaultEstimatedShipDateMin = this.element.dataset.defaultShipDate;\n\n const shipDateInput = this.shipDateTarget;\n const closeDateInput = this.closeDateTarget;\n const openDateInput = this.openDateTarget;\n\n const dateFormat = 'YYYY-MM-DD H:m';\n const formattedMinDate = moment(defaultOpenDateMin, 'DD/MM/YYYY H:m').format(dateFormat);\n\n const CALENDAR_WIDTH = 308;\n const GAP = 10;\n\n const pickers = [];\n\n const positionCalendarBesideInput = (_selectedDates, _dateStr, instance) => {\n // Close all other open pickers\n pickers.forEach((p) => {\n if (p !== instance) p.close();\n });\n\n requestAnimationFrame(() => {\n const calendar = instance.calendarContainer;\n const input = instance.altInput || instance.input;\n const inputRect = input.getBoundingClientRect();\n const viewportWidth = window.innerWidth;\n\n const rightEdge = inputRect.right + GAP;\n const spaceOnRight = viewportWidth - rightEdge;\n const leftEdge = inputRect.left - GAP - CALENDAR_WIDTH;\n\n let left;\n if (spaceOnRight >= CALENDAR_WIDTH) {\n left = rightEdge; // Prefer right side\n } else if (leftEdge >= 0) {\n left = leftEdge; // Fall back to left side\n } else {\n left = inputRect.left; // Last resort: below input\n }\n\n calendar.style.setProperty('position', 'fixed', 'important');\n calendar.style.setProperty('top', `${inputRect.top}px`, 'important');\n calendar.style.setProperty('left', `${left}px`, 'important');\n calendar.style.setProperty('z-index', '100002', 'important');\n });\n };\n\n const shipDatePicker = flatpickr(shipDateInput, {\n altInput: true,\n altFormat: 'm/d/y h:i K',\n dateFormat: 'Y-m-d h:i K',\n allowInput: true,\n enableTime: true,\n defaultHour: moment(defaultEstimatedShipDateMin, 'DD/MM/YYYY H:m').format('HH'),\n defaultMinute: moment(defaultEstimatedShipDateMin, 'DD/MM/YYYY H:m').format('mm'),\n minDate: 'today',\n appendTo: document.body,\n onOpen: positionCalendarBesideInput,\n onClose: function(selectedDates, dateStr, _instance) {\n const hiddenInput = shipDateInput.parentElement.querySelector('input[type=\"hidden\"]');\n if (hiddenInput) {\n hiddenInput.value = dateStr;\n }\n },\n onChange: (selectedDates, dateStr, _instance) => {\n _instance.close();\n },\n });\n\n pickers.push(shipDatePicker);\n\n const closeDatePicker = flatpickr(closeDateInput, {\n altInput: true,\n altFormat: 'm/d/y h:i K',\n dateFormat: 'Y-m-d h:i K',\n allowInput: true,\n enableTime: true,\n defaultHour: moment(defaultCloseDateMax, 'DD/MM/YYYY H:m').format('HH'),\n defaultMinute: moment(defaultCloseDateMax, 'DD/MM/YYYY H:m').format('mm'),\n minDate: formattedMinDate,\n appendTo: document.body,\n onOpen: positionCalendarBesideInput,\n onClose: function(selectedDates, dateStr, _instance) {\n const hiddenInput = closeDateInput.parentElement.querySelector('input[type=\"hidden\"]');\n if (hiddenInput) {\n hiddenInput.value = dateStr;\n }\n if (dateStr != '') {\n const newMinDate = moment(dateStr, dateFormat).add(1, 'days').format(dateFormat);\n shipDatePicker.set('minDate', newMinDate);\n }\n },\n onChange: (selectedDates, dateStr, _instance) => {\n _instance.close();\n },\n });\n\n pickers.push(closeDatePicker);\n\n const openDatePicker = flatpickr(openDateInput, {\n altInput: true,\n altFormat: 'm/d/y h:i K',\n dateFormat: 'Y-m-d h:i K',\n allowInput: true,\n enableTime: true,\n defaultHour: '0',\n defaultMinute: '0',\n minDate: formattedMinDate,\n appendTo: document.body,\n onOpen: positionCalendarBesideInput,\n onClose: function(selectedDates, dateStr, _instance) {\n const hiddenInput = openDateInput.parentElement.querySelector('input[type=\"hidden\"]');\n if (hiddenInput) {\n hiddenInput.value = dateStr;\n }\n if (dateStr != '') {\n const newMinDate = moment(dateStr, dateFormat).add(1, 'days').format(dateFormat);\n closeDatePicker.set('minDate', newMinDate);\n }\n },\n onChange: (selectedDates, dateStr, _instance) => {\n _instance.close();\n },\n });\n pickers.push(openDatePicker);\n }\n}\n", "import { Controller } from '../stimulus';\nimport ModalProgressMixin from '../mixins/modal_progress_mixin';\n\n/**\n * Clone Site Submission Controller\n *\n * Handles form submission for cloning a site via fetch + JSON API,\n * then polls for completion status and redirects when done.\n *\n * Targets:\n * submitButton: Submit button (for debounce reset)\n * error: Error message span\n *\n * Data Attributes:\n * data-sale-id: ID of sale whose site to clone\n */\nexport default class extends Controller {\n static targets = ['submitButton', 'error'];\n\n /**\n * Initialize submitting guard on connect.\n * Resets to false so modal reuse works correctly.\n */\n connect() {\n this._submitting = false;\n Object.assign(this, ModalProgressMixin);\n }\n\n /**\n * Cleanup on disconnect\n * Note: We intentionally do NOT stop polling here because the polling\n * is stored on window and should continue after the modal closes.\n */\n disconnect() {\n // Don't stop window-level polling - it needs to survive modal close\n // to redirect the user when cloning completes\n }\n\n /**\n * Handle form submission\n * Submits clone request via fetch and starts polling for completion.\n */\n async submit(e) {\n e.preventDefault();\n e.stopPropagation();\n\n // Guard: reject duplicate submits while request is in-flight\n if (this._submitting) return;\n\n this.hideError();\n\n const form = this.element.closest('form') || this.element.querySelector('form');\n if (!form) {\n this.showError('Form not found');\n return;\n }\n\n const saleId = this.element.dataset.saleId;\n if (!saleId) {\n this.showValidationError('Sale ID not found');\n this.resetButtonDebounce();\n return;\n }\n\n // Client-side name validation\n const nameInput = form.querySelector('input[name=\"dealers_site[name]\"]');\n if (nameInput && !nameInput.value.trim()) {\n this.showValidationError('Site name is required');\n this.resetButtonDebounce();\n return;\n }\n\n // Set guard AFTER validation passes, BEFORE fetch\n this._submitting = true;\n\n const formData = new FormData(form);\n\n const url = `/teamsites/${saleId}/clone_site`;\n\n try {\n const response = await fetch(url, {\n method: 'POST',\n headers: {\n 'Accept': 'application/json',\n 'X-Requested-With': 'XMLHttpRequest',\n 'X-CSRF-Token': this.getCsrfToken(),\n },\n body: formData,\n });\n\n const data = await response.json();\n\n if (response.status === 202) {\n\n // Show spinner BEFORE starting polling (prevents race if job completes fast)\n this.showModalProgress('Site cloning in progress. This may take a minute...');\n\n if (data.status_url) {\n this.startPolling(data.status_url);\n }\n } else {\n this._submitting = false;\n this.showError(data.error || 'Failed to clone site');\n this.resetButtonDebounce();\n }\n } catch (error) {\n console.error('[CloneSiteSubmission] Request failed:', error);\n this._submitting = false;\n this.showError('Network error. Please try again.');\n this.resetButtonDebounce();\n }\n }\n\n /**\n * Start polling for clone status\n * Polling is stored on window to survive controller disconnect when modal closes.\n *\n * @param {string} statusUrl - URL to poll for status updates\n */\n startPolling(statusUrl) {\n this.stopPolling();\n\n const stopWindowPolling = () => {\n if (window._cloneSitePolling) {\n if (window._cloneSitePolling.interval) {\n clearInterval(window._cloneSitePolling.interval);\n }\n if (window._cloneSitePolling.timeout) {\n clearTimeout(window._cloneSitePolling.timeout);\n }\n window._cloneSitePolling = null;\n }\n };\n\n window._cloneSitePolling = {\n interval: setInterval(async () => {\n try {\n const response = await fetch(statusUrl, {\n headers: {\n 'Accept': 'application/json',\n 'X-Requested-With': 'XMLHttpRequest',\n },\n });\n const data = await response.json();\n\n if (data.status === 'completed') {\n stopWindowPolling();\n if (data.partial && data.failed_aspects && data.failed_aspects.length > 0) {\n const aspects = data.failed_aspects.join(', ');\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash(\n 'Site cloned with issues: ' + aspects + ' failed. Please review.',\n );\n }\n } else if (window.App && window.App.showSuccessFlash) {\n window.App.showSuccessFlash('Site cloned successfully!');\n }\n if (data.redirect) {\n window.location.href = data.redirect;\n }\n } else if (data.status === 'unknown' || data.status === 'error' || data.status === 'failed') {\n stopWindowPolling();\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash(\n data.error || 'Cloning may have failed. Please check the teamsites list.',\n );\n }\n }\n // 'in_progress' - keep polling\n } catch (error) {\n console.error('[CloneSiteSubmission] Polling error:', error);\n // Continue polling on network errors (transient)\n }\n }, 3000),\n\n timeout: setTimeout(() => {\n stopWindowPolling();\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash('Clone operation timed out. Please check the teamsites list.');\n }\n }, 35 * 60 * 1000),\n };\n }\n\n /**\n * Stop polling for clone status\n */\n stopPolling() {\n if (window._cloneSitePolling) {\n if (window._cloneSitePolling.interval) {\n clearInterval(window._cloneSitePolling.interval);\n }\n if (window._cloneSitePolling.timeout) {\n clearTimeout(window._cloneSitePolling.timeout);\n }\n window._cloneSitePolling = null;\n }\n }\n\n /**\n * Close the modal\n */\n closeModal() {\n const wrapper = document.querySelector('.mfp-wrap');\n if (wrapper) {\n const closeButton = wrapper.querySelector('.mfp-close');\n if (closeButton) {\n closeButton.click();\n return;\n }\n }\n document.dispatchEvent(new CustomEvent('ajax-popup:close'));\n }\n\n /**\n * Get CSRF token from meta tag\n *\n * @returns {string} CSRF token\n */\n getCsrfToken() {\n const token = document.querySelector('meta[name=\"csrf-token\"]');\n return token ? token.content : '';\n }\n\n /**\n * Show validation error inline\n *\n * @param {string} message - Error message\n */\n showValidationError(message) {\n if (this.hasErrorTarget) {\n this.errorTarget.textContent = message;\n this.errorTarget.style.display = 'block';\n }\n }\n\n /**\n * Show error using unified flash system\n *\n * @param {string} message - Error message\n */\n showError(message) {\n if (typeof window.App !== 'undefined' &&\n typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash(message);\n }\n this.showValidationError(message);\n }\n\n /**\n * Show success using unified flash system\n *\n * @param {string} message - Success message\n */\n showSuccess(message) {\n if (typeof window.App !== 'undefined' &&\n typeof window.App.showSuccessFlash === 'function') {\n window.App.showSuccessFlash(message);\n }\n }\n\n /**\n * Hide error message\n */\n hideError() {\n if (this.hasErrorTarget) {\n this.errorTarget.textContent = '';\n this.errorTarget.style.display = 'none';\n }\n }\n\n /**\n * Reset button debounce controller\n */\n resetButtonDebounce() {\n if (!this.hasSubmitButtonTarget) return;\n\n const button = this.submitButtonTarget;\n const debounceController = this.application.getControllerForElementAndIdentifier(\n button,\n 'button-debounce',\n );\n\n if (debounceController && typeof debounceController.manualReset === 'function') {\n debounceController.manualReset();\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\n/**\n * Closeable Container Controller\n *\n * This controller handles containers that can be closed with a button click.\n * It replaces jQuery-based toggle/remove functionality with vanilla JavaScript.\n *\n * Usage:\n *
    \n *
    \n * \n *
    \n * \n *
    \n */\nexport default class extends Controller {\n static targets = ['closeButton'];\n\n connect() {\n if (!this.hasCloseButtonTarget) {\n // Closeable container requires a close button target\n }\n\n // Ensure the container is visible when connected\n if (this.element.classList.contains('ae-alert-fixed')) {\n this.element.style.display = 'block';\n this.element.style.opacity = '1';\n }\n }\n\n close(event) {\n event.preventDefault();\n\n // Fade out and remove using vanilla JS instead of jQuery\n const container = this.element;\n\n // Add transition for fade effect\n container.style.transition = 'opacity 300ms';\n container.style.opacity = '0';\n\n // Remove element after transition completes\n setTimeout(() => {\n container.remove();\n }, 300);\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n changeColor(event) {\n const select = event.target;\n const newColor = select.value;\n const originalColor = select.getAttribute('data-original-color');\n\n // Find the product container (parent with data-product-id)\n const container = select.closest('.product-container');\n if (!container) return;\n\n // Parse the embedded color-sizes JSON\n const jsonScript = container.querySelector('script.color-sizes-data');\n if (!jsonScript) return;\n\n let colorData;\n try {\n colorData = JSON.parse(jsonScript.textContent);\n } catch (err) { // eslint-disable-line no-unused-vars\n return;\n }\n\n const targetColorData = colorData[newColor];\n if (!targetColorData) return;\n\n // Read currency from the container (set server-side), default to USD\n const currency = container.getAttribute('data-currency') || 'USD';\n\n // Build a lookup of available sizes in the target color\n const targetSizeMap = {};\n targetColorData.sizes.forEach(function (sv) {\n targetSizeMap[sv.size] = sv;\n });\n\n // Find all editable line item rows in this product container\n const rows = container.querySelectorAll('tr.line-item-editable');\n const missingSizes = [];\n\n // Check which current sizes are missing in the target color\n rows.forEach(function (row) {\n const currentSize = row.getAttribute('data-current-size');\n if (currentSize && !targetSizeMap[currentSize]) {\n missingSizes.push(currentSize);\n }\n });\n\n // If sizes are missing, show confirmation dialog\n if (missingSizes.length > 0) {\n const message = 'The following sizes are not available in ' + newColor +\n ' and will be set to quantity 0 (refunded on save):\\n\\n' +\n missingSizes.join(', ') +\n '\\n\\nDo you want to continue?';\n\n if (!window.confirm(message)) {\n // Revert the select to the original color\n select.value = originalColor;\n return;\n }\n }\n\n // Apply the color change to each row\n rows.forEach(function (row) {\n const currentSize = row.getAttribute('data-current-size');\n const variantIdInput = row.querySelector('input[name*=\"[variant_id]\"]');\n const quantityInput = row.querySelector('input[name*=\"[quantity]\"]');\n const sizeSelect = row.querySelector('select[name*=\"[product_size]\"]');\n\n if (currentSize && targetSizeMap[currentSize]) {\n // Size exists in target color \u2014 swap variant_id\n const sizeData = targetSizeMap[currentSize];\n if (variantIdInput) {\n variantIdInput.value = sizeData.variant_id;\n }\n\n // Update the subtotal display with the new price\n updateRowSubtotal(row, sizeData.price, quantityInput, currency);\n\n // Rebuild the size dropdown with new color's sizes\n if (sizeSelect) {\n rebuildSizeOptions(sizeSelect, targetColorData.sizes, currentSize);\n }\n } else {\n // Size missing in target color \u2014 set qty to 0 for refund\n if (quantityInput) {\n quantityInput.value = '0';\n quantityInput.dispatchEvent(new Event('change', { bubbles: true }));\n }\n }\n });\n\n // Update the original color tracking\n select.setAttribute('data-original-color', newColor);\n\n // Trigger form-level change event so Save button enables\n const form = container.closest('form');\n if (form) {\n form.dispatchEvent(new Event('input', { bubbles: true }));\n }\n }\n}\n\nfunction updateRowSubtotal(row, price, quantityInput, currency) {\n const subtotalEl = row.querySelector('[data-target*=\"subtotal\"]');\n const totalEl = row.querySelector('.total-column .product-price');\n if (!subtotalEl || !quantityInput) return;\n\n const quantity = parseInt(quantityInput.value) || 1;\n const total = price * quantity;\n\n const formatter = new Intl.NumberFormat('en-US', {\n style: 'currency',\n currency: currency,\n });\n\n const formatted = formatter.format(total);\n subtotalEl.textContent = formatted;\n if (totalEl) {\n totalEl.textContent = formatted;\n }\n}\n\nfunction rebuildSizeOptions(sizeSelect, sizes, currentSize) {\n // Clear existing options\n sizeSelect.innerHTML = '';\n\n // Add prompt option\n const promptOption = document.createElement('option');\n promptOption.value = '';\n promptOption.textContent = 'Select Size';\n sizeSelect.appendChild(promptOption);\n\n // Add size options from the new color's available sizes\n sizes.forEach(function (sv) {\n const option = document.createElement('option');\n option.value = sv.size;\n option.textContent = sv.size;\n if (sv.size === currentSize) {\n option.selected = true;\n }\n sizeSelect.appendChild(option);\n });\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n connect() {\n // Initialize the color picker with a small delay to ensure libraries are loaded\n setTimeout(() => this.initializeSpectrum(), 50);\n }\n\n initializeSpectrum() {\n // Check if jQuery is available\n if (typeof $ === 'undefined' || typeof window.jQuery === 'undefined') {\n setTimeout(() => this.initializeSpectrum(), 100);\n return;\n }\n\n // Check if spectrum is available\n if (!$.fn || !$.fn.spectrum) {\n setTimeout(() => this.initializeSpectrum(), 100);\n return;\n }\n\n // Hide the original input\n this.element.style.display = 'none';\n\n try {\n // Initialize spectrum color picker\n $(this.element).spectrum({\n showInput: true,\n preferredFormat: 'hex',\n allowEmpty: true,\n containerClassName: 'color-picker-container',\n replacerClassName: 'ae-btn ae-btn-md ae-btn-primary ae-btn-border',\n showPalette: true,\n showButtons: false,\n clickoutFiresChange: true,\n showInitial: true,\n showAlpha: false,\n\n // Handle color changes\n change: (color) => {\n if (color) {\n // Update the input value so form submission includes the color\n this.element.value = color.toHexString();\n\n // Trigger a custom event that other controllers can listen to\n const event = new CustomEvent('color-picker:change', {\n detail: {\n color: color.toHexString(),\n element: this.element,\n },\n bubbles: true,\n cancelable: true,\n });\n this.element.dispatchEvent(event);\n }\n },\n });\n\n // Mark as initialized\n this.element.dataset.spectrumInitialized = 'true';\n } catch (error) {\n // Failed to initialize Spectrum color picker\n // Show the original input if initialization fails\n this.element.style.display = '';\n // Remove the initialization flag on failure\n delete this.element.dataset.spectrumInitialized;\n }\n }\n\n disconnect() {\n // Destroy spectrum instance when controller disconnects\n if (this.isSpectrumInitialized()) {\n try {\n $(this.element).spectrum('destroy');\n } catch (error) {\n // Error destroying Spectrum instance\n }\n }\n // Show the original input again\n this.element.style.display = '';\n // Clean up initialization flag\n delete this.element.dataset.spectrumInitialized;\n }\n\n // Method to set color programmatically\n setColor(color) {\n if (this.isSpectrumInitialized()) {\n try {\n $(this.element).spectrum('set', color);\n } catch (error) {\n // Error setting color on Spectrum\n // Fallback to setting input value directly\n this.element.value = color;\n }\n } else {\n // Fallback to setting input value directly\n this.element.value = color;\n }\n }\n\n // Helper method to check if spectrum is properly initialized\n isSpectrumInitialized() {\n return typeof $ !== 'undefined' &&\n $.fn &&\n $.fn.spectrum &&\n this.element.dataset.spectrumInitialized === 'true';\n }\n}\n", "/**\n * Mixin for date picker controllers\n * Provides common logic for preserving existing dates while enforcing constraints\n * \n * Usage: Mix into controller classes that need date picker functionality\n * Example: Object.assign(MyController.prototype, DatePickerMixin);\n */\n\nexport const DatePickerMixin = {\n // Date format used across all date pickers\n propDateFormat: 'YYYY-MM-DD h:mm A',\n\n /**\n * Safely parses a date value using moment.js\n * @param {string} value - The date string to parse\n * @returns {object|null} - Moment instance or null if invalid\n */\n parseDate(value) {\n if (!value || typeof window.moment !== 'function') {\n return null;\n }\n \n const parsed = window.moment(value, DatePickerMixin.propDateFormat);\n return parsed.isValid() ? parsed : null;\n },\n\n /**\n * Safely compares two dates using moment.js\n * @param {Date|moment} date1 - First date to compare\n * @param {Date|moment} date2 - Second date to compare\n * @returns {boolean} - True if date1 is before date2\n */\n isBefore(date1, date2) {\n if (!date1 || !date2) return false;\n \n // Use moment.js if available and properly mocked\n if (typeof window.moment === 'function' && typeof window.moment.isMoment === 'function') {\n const moment1 = window.moment.isMoment(date1) ? date1 : window.moment(date1);\n const moment2 = window.moment.isMoment(date2) ? date2 : window.moment(date2);\n \n if (typeof moment1.isBefore === 'function') {\n return moment1.isBefore(moment2, 'day');\n }\n }\n \n // Fallback to native Date comparison for test environments\n const d1 = date1 instanceof Date ? date1 : new Date(date1);\n const d2 = date2 instanceof Date ? date2 : new Date(date2);\n \n // Compare dates only (ignore time)\n const date1Only = new Date(d1.getFullYear(), d1.getMonth(), d1.getDate());\n const date2Only = new Date(d2.getFullYear(), d2.getMonth(), d2.getDate());\n \n return date1Only < date2Only;\n },\n\n /**\n * Gets the start of today in the local timezone\n * @returns {Date} - Today at 00:00:00\n */\n getTodayStart() {\n if (typeof window.moment === 'function') {\n const today = window.moment();\n // Handle test environments where startOf might not be mocked\n if (typeof today.startOf === 'function') {\n return today.startOf('day').toDate();\n }\n }\n // Fallback to native Date for test environments\n const now = new Date();\n return new Date(now.getFullYear(), now.getMonth(), now.getDate());\n },\n\n /**\n * Safely initializes flatpickr with error handling\n * @param {HTMLElement} element - The target input element\n * @param {object} options - Flatpickr options\n * @returns {object|null} - Flatpickr instance or null if failed\n */\n initializeFlatpickr(element, options) {\n if (!element || typeof window.flatpickr !== 'function') {\n // Warning: Cannot initialize flatpickr - element or library missing\n return null;\n }\n\n try {\n return window.flatpickr(element, options);\n } catch (error) {\n // Error: Failed to initialize flatpickr\n return null;\n }\n },\n\n /**\n * Determines the appropriate minDate for a date picker\n * Preserves existing dates even if they're historical, while preventing new past date selections\n * \n * @param {moment|null} currentValue - The existing date value\n * @param {Date} baseMinDate - The default minimum date (usually today)\n * @returns {Date} - The calculated minimum date\n */\n calculateMinDate(currentValue, baseMinDate) {\n // If there's a valid current value and it's before the base minimum,\n // adjust the minimum to allow displaying the existing date\n if (currentValue && currentValue.isValid()) {\n const currentDate = currentValue.toDate();\n if (DatePickerMixin.isBefore(currentDate, baseMinDate)) {\n return currentDate;\n }\n }\n \n return baseMinDate;\n },\n\n /**\n * Creates a standardized onDayCreate callback for flatpickr\n * Adds appropriate classes and tooltips for disabled dates\n * \n * @param {Date} todayStart - Start of today\n * @param {moment|null} constraintDate - Optional constraint date (e.g., open date for close date picker)\n * @param {string} constraintMessage - Message to show for constraint violations\n * @returns {Function} - The onDayCreate callback function\n */\n createOnDayCreateCallback(todayStart, constraintDate = null, constraintMessage = '') {\n return (dObj, dStr, fp, dayElem) => {\n // Mark past dates\n if (DatePickerMixin.isBefore(dayElem.dateObj, todayStart)) {\n dayElem.classList.add('past-date');\n dayElem.title = 'Past dates cannot be selected';\n }\n\n // Mark dates that violate constraints (e.g., close date before open date)\n if (constraintDate && constraintDate.isValid()) {\n const constraintDateObj = constraintDate.toDate();\n if (dayElem.dateObj <= constraintDateObj) {\n dayElem.classList.add('constraint-violation');\n dayElem.title = constraintMessage;\n }\n }\n };\n },\n\n /**\n * Creates a standardized onChange callback for flatpickr\n * Automatically closes the picker when time matches the current value\n * \n * @param {moment|null} currentValue - The existing date value\n * @param {boolean} enableTime - Whether time selection is enabled\n * @returns {Function} - The onChange callback function\n */\n createOnChangeCallback(currentValue, enableTime = true) {\n return (selectedDates, dateStr, instance) => {\n const selectedDate = selectedDates[0];\n if (!selectedDate) return;\n\n // Auto-close if time hasn't changed (for time-enabled pickers)\n if (enableTime && currentValue && currentValue.isValid()) {\n const newCurrentValue = new Date(currentValue.toDate());\n if (window.moment(selectedDate).format('HH:mm') === window.moment(newCurrentValue).format('HH:mm')) {\n instance.close();\n }\n } else if (!enableTime) {\n instance.close();\n }\n };\n },\n\n /**\n * Sets up dynamic constraint updates between related date pickers\n * @param {HTMLElement} sourceElement - The element that triggers updates\n * @param {HTMLElement} targetElement - The element to update\n * @param {Function} calculateNewMinDate - Function to calculate new minimum date\n */\n setupDynamicConstraints(sourceElement, targetElement, calculateNewMinDate) {\n if (!sourceElement || !targetElement) return;\n\n sourceElement.addEventListener('change', () => {\n const newMinDate = calculateNewMinDate(sourceElement.value);\n const targetFlatpickr = targetElement._flatpickr;\n\n if (targetFlatpickr && newMinDate) {\n targetFlatpickr.set('minDate', newMinDate);\n\n // Clear target if it now violates the constraint\n const selectedDate = targetFlatpickr.selectedDates[0];\n if (selectedDate && DatePickerMixin.isBefore(selectedDate, newMinDate)) {\n targetFlatpickr.clear();\n }\n }\n });\n }\n};", "import { Controller } from '../stimulus';\nimport { DatePickerMixin } from '../mixins/date_picker_mixin';\n\nclass CouponDatepickerController extends Controller {\n connect() {\n // Find the modal's scrollable content container\n const modalContent = this.element.closest('.ae-popup-content');\n\n // Determine if this is the start date or end date field\n const fieldName = this.element.name;\n const isEndDate = fieldName && fieldName.includes('expires_at');\n const isStartDate = fieldName && fieldName.includes('starts_at');\n\n // Parse current value\n const currentValue = this.parseDate(this.element.value);\n\n // Base options for both date pickers\n const options = {\n altInput: true,\n altFormat: 'm/d/Y',\n dateFormat: 'Y-m-d',\n allowInput: false,\n appendTo: modalContent || document.body,\n onChange: this.createOnChangeCallback(currentValue, false),\n onOpen: (_selectedDates, _dateStr, instance) => {\n // Skip manual positioning if not in modal\n if (!modalContent) {\n return;\n }\n\n // Use requestAnimationFrame to ensure DOM is updated\n requestAnimationFrame(() => {\n const calendar = instance.calendarContainer;\n const input = instance.altInput || instance.input;\n\n // Get positioning references\n const inputRect = input.getBoundingClientRect();\n const containerRect = modalContent.getBoundingClientRect();\n\n // Get form field for vertical positioning\n const formField = input.closest('.ae-form-field') || input.closest('.ae-field');\n const formFieldRect = formField ? formField.getBoundingClientRect() : inputRect;\n\n // Use form field bottom for top, but input left for alignment\n const top = formFieldRect.bottom - containerRect.top + 4;\n const left = inputRect.left - containerRect.left;\n\n // Set position with !important to override flatpickr CSS\n calendar.style.setProperty('position', 'absolute', 'important');\n calendar.style.setProperty('top', `${top}px`, 'important');\n calendar.style.setProperty('left', `${left}px`, 'important');\n });\n },\n };\n\n // Add constraint logic for end date field\n if (isEndDate) {\n // Find the start date field in the same form\n const form = this.element.closest('form');\n const startDateField = form ? form.querySelector('[name*=\"starts_at\"]') : null;\n\n if (startDateField) {\n const startDateValue = this.parseDate(startDateField.value);\n\n // Set minimum date to start date if it exists\n if (startDateValue && startDateValue.isValid()) {\n options.minDate = startDateValue.toDate();\n\n // Add visual indicators for invalid dates\n options.onDayCreate = this.createOnDayCreateCallback(\n null,\n startDateValue,\n 'End date must not be before start date',\n );\n }\n }\n }\n\n // Initialize flatpickr with error handling\n this.flatpickr = this.initializeFlatpickr(this.element, options);\n\n // Set up dynamic constraints if this is the start date field\n if (isStartDate) {\n const form = this.element.closest('form');\n const endDateField = form ? form.querySelector('[name*=\"expires_at\"]') : null;\n\n if (endDateField) {\n this.setupDynamicConstraints(this.element, endDateField, (newStartDateValue) => {\n const newStartDate = this.parseDate(newStartDateValue);\n if (newStartDate && newStartDate.isValid()) {\n return newStartDate.toDate();\n }\n return null;\n });\n }\n }\n }\n\n disconnect() {\n // Clean up flatpickr instance when controller disconnects\n if (this.flatpickr) {\n this.flatpickr.destroy();\n this.flatpickr = undefined;\n }\n }\n}\n\n// Mix in the date picker functionality\nObject.assign(CouponDatepickerController.prototype, DatePickerMixin);\n\nexport default CouponDatepickerController;\n", "import { Controller } from '../stimulus';\n\n/**\n * Dealer Search Controller\n *\n * Manages visual feedback and UI state for the dealer search widget.\n * Provides focus/blur visual feedback by dimming sidebar and content areas\n * when the search is active.\n *\n * Event Flow:\n * 1. User focuses on search input\n * 2. dealer-search-keywords-input controller dispatches 'search:input:focus' event\n * 3. This controller receives event and calls activate()\n * 4. Adds 'ae-active' class to search widget\n * 5. Adds 'is-off-focus' class to sidebar and content (dimming effect)\n * 6. On blur, reverse process via deactivate()\n *\n * Usage:\n *
    \n * \n *
    \n */\nexport default class extends Controller {\n initialize() {\n // Bind methods to maintain correct 'this' context when called as event handlers\n this.activate = this.activate.bind(this);\n this.deactivate = this.deactivate.bind(this);\n }\n\n connect() {\n // Listen for custom events dispatched by dealer-search-keywords-input controller\n this.element.addEventListener('search:input:focus', this.activate);\n this.element.addEventListener('search:input:blur', this.deactivate);\n }\n\n disconnect() {\n // Clean up event listeners to prevent memory leaks\n this.element.removeEventListener('search:input:focus', this.activate);\n this.element.removeEventListener('search:input:blur', this.deactivate);\n }\n\n /**\n * Activates the search UI state\n * - Adds 'ae-active' class to search input (visual highlighting)\n * - Adds 'is-off-focus' class to sidebar and content (dimming effect)\n */\n activate() {\n // Add active class to search input (CSS targets .quick-search-input.ae-active)\n const searchInput = this.element.querySelector('.quick-search-input');\n if (searchInput) {\n searchInput.classList.add('ae-active');\n }\n\n // Dim sidebar and content to focus attention on search\n const sidebar = document.querySelector('.js-ae-sidebar');\n const content = document.querySelector('.js-ae-content');\n\n if (sidebar) {\n sidebar.classList.add('is-off-focus');\n }\n\n if (content) {\n content.classList.add('is-off-focus');\n }\n }\n\n /**\n * Deactivates the search UI state\n * - Removes 'ae-active' class from search input\n * - Removes 'is-off-focus' class from sidebar and content\n */\n deactivate() {\n // Remove active class from search input (CSS targets .quick-search-input.ae-active)\n const searchInput = this.element.querySelector('.quick-search-input');\n if (searchInput) {\n searchInput.classList.remove('ae-active');\n }\n\n // Restore sidebar and content to normal focus state\n const sidebar = document.querySelector('.js-ae-sidebar');\n const content = document.querySelector('.js-ae-content');\n\n if (sidebar) {\n sidebar.classList.remove('is-off-focus');\n }\n\n if (content) {\n content.classList.remove('is-off-focus');\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\nconst SUBMIT_DELAY = 500;\nconst MINIMUM_QUERY_LENGTH = 3;\n// Secondary search types for progressive loading\nconst SECONDARY_SEARCH_TYPES = ['orders', 'sites', 'contacts'];\n\nexport default class extends Controller {\n initialize() {\n this.delayTimer = null;\n this.abortController = null;\n this.secondaryAbortControllers = {};\n this.currentQuery = null;\n this.handleInputChange = this.handleInputChange.bind(this);\n this.handleFormSubmit = this.handleFormSubmit.bind(this);\n this.showSpinner = this.showSpinner.bind(this);\n this.hideSpinner = this.hideSpinner.bind(this);\n }\n\n connect() {\n this.element.addEventListener('search:input:change', this.handleInputChange);\n this.element.addEventListener('submit', this.handleFormSubmit);\n }\n\n disconnect() {\n this.element.removeEventListener('search:input:change', this.handleInputChange);\n this.element.removeEventListener('submit', this.handleFormSubmit);\n if (this.delayTimer) {\n clearTimeout(this.delayTimer);\n }\n if (this.abortController) {\n this.abortController.abort();\n }\n // Abort any secondary search requests\n this.abortSecondaryRequests();\n }\n\n abortSecondaryRequests() {\n SECONDARY_SEARCH_TYPES.forEach(type => {\n if (this.secondaryAbortControllers[type]) {\n this.secondaryAbortControllers[type].abort();\n this.secondaryAbortControllers[type] = null;\n }\n });\n }\n\n handleInputChange(event) {\n const { value = '' } = event.detail;\n\n // Cancel pending debounce timer\n clearTimeout(this.delayTimer);\n\n // Abort any in-flight request\n if (this.abortController) {\n this.abortController.abort();\n this.abortController = null;\n }\n // Abort any secondary requests too\n this.abortSecondaryRequests();\n\n if (value.length >= MINIMUM_QUERY_LENGTH) {\n this.currentQuery = value;\n this.delayTimer = setTimeout(() => this.submit(), SUBMIT_DELAY);\n } else {\n this.currentQuery = null;\n this.hideSpinner();\n }\n }\n\n handleFormSubmit(event) {\n event.preventDefault();\n\n // Cancel pending debounce timer to prevent duplicate submission\n clearTimeout(this.delayTimer);\n\n // Abort any in-flight request\n if (this.abortController) {\n this.abortController.abort();\n this.abortController = null;\n }\n\n // Submit immediately when Enter is pressed\n this.submit();\n }\n\n submit() {\n // Abort any previous in-flight request\n if (this.abortController) {\n this.abortController.abort();\n }\n\n const action = this.element.getAttribute('action');\n const method = this.element.getAttribute('method') || 'POST';\n const formData = new FormData(this.element);\n\n // Create new AbortController for this request\n this.abortController = new AbortController();\n\n this.showSpinner();\n this.dispatchCustomEvent('beforeSubmit');\n\n fetch(action, {\n method: method,\n body: formData,\n headers: {\n 'X-Requested-With': 'XMLHttpRequest',\n },\n signal: this.abortController.signal,\n })\n .then(response => {\n if (!response.ok) {\n throw new Error(`HTTP error! status: ${response.status}`);\n }\n return response.text();\n })\n .then(html => this.handleSuccess(html))\n .catch(error => {\n // Ignore AbortError - this is expected when we cancel requests\n if (error.name !== 'AbortError') {\n this.handleError(error);\n }\n })\n .finally(() => {\n // Clear abort controller reference\n this.abortController = null;\n this.handleComplete();\n });\n }\n\n handleSuccess(html) {\n this.hideSpinner();\n\n // Update DOM with HTML response\n const resultsPlaceholder = document.getElementById('quick-search-results-placeholder');\n if (resultsPlaceholder) {\n resultsPlaceholder.innerHTML = html;\n }\n\n this.dispatchCustomEvent('success', { html });\n\n // Progressive loading: fetch secondary results in parallel\n // This improves perceived performance by showing products first\n if (this.currentQuery) {\n this.fetchSecondaryResults();\n }\n }\n\n fetchSecondaryResults() {\n const query = this.currentQuery;\n if (!query) return;\n\n // Get CSRF token for the requests\n const csrfToken = document.querySelector('meta[name=\"csrf-token\"]');\n const token = csrfToken ? csrfToken.getAttribute('content') : '';\n\n // Fire parallel requests for each secondary search type\n SECONDARY_SEARCH_TYPES.forEach(type => {\n this.fetchSecondaryResultType(type, query, token);\n });\n }\n\n fetchSecondaryResultType(type, query, csrfToken) {\n // Abort any existing request for this type\n if (this.secondaryAbortControllers[type]) {\n this.secondaryAbortControllers[type].abort();\n }\n\n // Create new AbortController for this request\n this.secondaryAbortControllers[type] = new AbortController();\n\n const url = `/dealers/search/${type}?query=${encodeURIComponent(query)}`;\n\n fetch(url, {\n method: 'GET',\n headers: {\n 'X-Requested-With': 'XMLHttpRequest',\n 'X-CSRF-Token': csrfToken,\n },\n signal: this.secondaryAbortControllers[type].signal,\n })\n .then(response => {\n if (!response.ok) {\n throw new Error(`HTTP error! status: ${response.status}`);\n }\n return response.text();\n })\n .then(html => this.updateSecondaryResults(type, html))\n .catch(error => {\n // Ignore AbortError - expected when we cancel requests\n if (error.name !== 'AbortError') {\n this.handleSecondaryError(type, error);\n }\n })\n .finally(() => {\n this.secondaryAbortControllers[type] = null;\n });\n }\n\n updateSecondaryResults(type, html) {\n // Find the container for this result type\n // Container IDs follow pattern: search-results-{type}\n const container = document.getElementById(`search-results-${type}`);\n if (container) {\n container.innerHTML = html;\n container.classList.remove('loading');\n }\n }\n\n handleSecondaryError(type, error) {\n const container = document.getElementById(`search-results-${type}`);\n if (container) {\n container.innerHTML = '
    Failed to load results
    ';\n container.classList.remove('loading');\n }\n }\n\n handleError(error) {\n this.hideSpinner();\n this.dispatchCustomEvent('error', { error });\n }\n\n showSpinner() {\n const spinner = document.getElementById('search-spinner');\n if (spinner) {\n spinner.classList.remove('ae-hidden');\n }\n }\n\n hideSpinner() {\n const spinner = document.getElementById('search-spinner');\n if (spinner) {\n spinner.classList.add('ae-hidden');\n }\n }\n\n handleComplete() {\n this.dispatchCustomEvent('complete');\n }\n\n dispatchCustomEvent(eventName, detail = {}) {\n const event = new CustomEvent(`search:form:${eventName}`, {\n bubbles: true,\n detail: detail,\n });\n this.element.dispatchEvent(event);\n }\n}\n", "import { Controller } from '../stimulus';\n\nconst ESC_KEY = 27;\n\nexport default class extends Controller {\n initialize() {\n this.inFocus = false;\n this.handleChange = this.handleChange.bind(this);\n this.handleFocus = this.handleFocus.bind(this);\n this.handleBlur = this.handleBlur.bind(this);\n this.handleGrabFocus = this.handleGrabFocus.bind(this);\n }\n\n connect() {\n this.element.addEventListener('keyup', this.handleChange);\n this.element.addEventListener('focus', this.handleFocus);\n this.element.addEventListener('click', this.handleFocus);\n this.element.addEventListener('blur', this.handleBlur);\n this.element.addEventListener('focusout', this.handleBlur);\n this.element.addEventListener('grabFocus', this.handleGrabFocus);\n }\n\n disconnect() {\n this.element.removeEventListener('keyup', this.handleChange);\n this.element.removeEventListener('focus', this.handleFocus);\n this.element.removeEventListener('click', this.handleFocus);\n this.element.removeEventListener('blur', this.handleBlur);\n this.element.removeEventListener('focusout', this.handleBlur);\n this.element.removeEventListener('grabFocus', this.handleGrabFocus);\n }\n\n handleChange(event) {\n // Don't trigger on ESC key\n if (event.which === ESC_KEY || event.keyCode === ESC_KEY) {\n return;\n }\n\n const value = this.getValue();\n this.dispatchCustomEvent('search:input:change', { value });\n }\n\n handleFocus(event) {\n event.stopPropagation();\n\n if (this.inFocus) return;\n this.inFocus = true;\n\n this.dispatchCustomEvent('search:input:focus');\n }\n\n handleBlur(event) {\n event.stopPropagation();\n\n if (!this.inFocus) return;\n this.inFocus = false;\n\n this.dispatchCustomEvent('search:input:blur');\n }\n\n handleGrabFocus(event) {\n event.preventDefault();\n\n this.element.focus();\n this.element.classList.add('highlight-and-fade');\n\n setTimeout(() => {\n this.element.classList.remove('highlight-and-fade');\n }, 2500);\n\n this.element.setAttribute('placeholder', 'Search for a style ID, brand, or product name');\n }\n\n getValue() {\n return this.element.value.trim();\n }\n\n dispatchCustomEvent(eventName, detail = {}) {\n const event = new CustomEvent(eventName, {\n bubbles: true,\n detail: detail,\n });\n this.element.dispatchEvent(event);\n }\n}\n", "import { Controller } from '../stimulus';\nimport { generateUUID } from '../utils/uuid';\n\nimport AjaxPopupController from './ajax_popup_controller';\n\n// Module-level state - survives controller instance lifecycle\n// Tracks in-flight and recently completed clone operations to prevent duplicates\nconst inFlightClones = new Map();\nconst CLONE_COOLDOWN_MS = 30000; // 30 second cooldown\n\n// Export for testing - allows clearing state between tests\nexport function clearInFlightClones() {\n inFlightClones.clear();\n}\n\n// Export for testing - allows inspecting state\nexport function getInFlightClones() {\n return inFlightClones;\n}\n\nexport default class extends Controller {\n static targets = ['button', 'buttonText'];\n\n connect() {\n this.isProcessing = false;\n this._boundResetButtonDebounce = this.resetButtonDebounce.bind(this);\n document.addEventListener('ajax-popup:closed', this._boundResetButtonDebounce);\n }\n\n disconnect() {\n document.removeEventListener('ajax-popup:closed', this._boundResetButtonDebounce);\n }\n\n resetButtonDebounce() {\n const debounceController = this.application.getControllerForElementAndIdentifier(\n this.element, 'button-debounce',\n );\n if (debounceController && typeof debounceController.manualReset === 'function') {\n debounceController.manualReset();\n }\n }\n\n click(event) {\n event.preventDefault();\n event.stopPropagation();\n\n // Guard against rapid duplicate clicks (< 500ms apart)\n const now = Date.now();\n if (this.lastClickTime && (now - this.lastClickTime) < 500) {\n return;\n }\n this.lastClickTime = now;\n\n const productId = this.element.dataset.productId;\n\n if (!productId) {\n return;\n }\n\n // Check if we have a sale_id in context\n const saleId = this.getSaleIdFromContext();\n\n if (saleId) {\n // Direct clone flow (existing behavior)\n this.cloneToSale(productId, saleId);\n } else {\n // Modal selection flow (new behavior)\n this.showSaleSelector(productId);\n }\n }\n\n getSaleIdFromContext() {\n // Strategy 1: Check if data-sale-id exists on button (backward compatibility)\n if (this.element.dataset.saleId) {\n return this.element.dataset.saleId;\n }\n\n // Strategy 2: Look for search results container with sale context marker\n const searchContainer = document.querySelector('[data-search-sale-id]');\n if (searchContainer && searchContainer.dataset.searchSaleId) {\n return searchContainer.dataset.searchSaleId;\n }\n\n // No sale context found - will trigger modal\n return null;\n }\n\n cloneToSale(productId, saleId) {\n // Module-level duplicate check: prevent cloning same product to same sale within cooldown\n const cloneKey = `${productId}:${saleId}`;\n const existingClone = inFlightClones.get(cloneKey);\n\n if (existingClone && Date.now() - existingClone.timestamp < CLONE_COOLDOWN_MS) {\n // Already processing or recently completed - show feedback and return\n if (window.App && typeof window.App.showSuccessFlash === 'function') {\n window.App.showSuccessFlash('Product is being added...');\n }\n return;\n }\n\n // Instance-level double-submission guard (defense in depth)\n if (this.isProcessing) {\n return;\n }\n this.isProcessing = true;\n\n // Show immediate feedback\n if (window.App && typeof window.App.showSuccessFlash === 'function') {\n window.App.showSuccessFlash('Adding to TeamSite...');\n }\n\n // Capture selected color variant ID from swatches container\n const colorVariantId = this.getSelectedColorVariantId();\n\n // Generate operation_id or reuse from recent attempt for server-side idempotency\n const operationId = existingClone?.operationId || generateUUID();\n\n // Track this clone at module level BEFORE making the request\n inFlightClones.set(cloneKey, {\n operationId,\n timestamp: Date.now(),\n status: 'in_flight',\n });\n\n const url = `/teamsites/${saleId}/products/${productId}/clone`;\n\n fetch(url, {\n method: 'POST',\n headers: {\n 'X-CSRF-Token': document.querySelector('meta[name=\"csrf-token\"]').content,\n 'Accept': 'application/json',\n 'Content-Type': 'application/json',\n },\n credentials: 'same-origin',\n body: JSON.stringify({ color_variant_id: colorVariantId, operation_id: operationId }),\n })\n .then(response => {\n if (!response.ok) {\n throw new Error(`HTTP error! status: ${response.status}`);\n }\n return response.json();\n })\n .then(data => {\n // Show success message\n if (data.message) {\n if (window.App && typeof window.App.showSuccessFlash === 'function') {\n window.App.showSuccessFlash(data.message);\n }\n }\n\n // Redirect to the cloned product's edit page with refresh parameter\n // This matches the behavior of the 'new' action in ProductsController\n if (data.sale_id && data.product_id) {\n window.location.href = `/teamsites/${data.sale_id}/products/${data.product_id}/edit?refresh=${Date.now()}`;\n }\n })\n .catch(() => {\n // Pattern 11 button-debounce controller will auto-recover after 2 seconds\n\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash('Failed to add product to sale. Please try again.');\n }\n })\n .finally(() => {\n // Reset instance-level flag\n this.isProcessing = false;\n\n // Update module-level tracking: mark as completed but keep for cooldown period\n const entry = inFlightClones.get(cloneKey);\n if (entry) {\n entry.status = 'completed';\n entry.timestamp = Date.now();\n }\n });\n }\n\n showSaleSelector(productId) {\n const url = `/dealers/search/sales_for_add_to_product?product_id=${productId}`;\n AjaxPopupController.open(url);\n }\n\n getSelectedColorVariantId() {\n // Find the product-list-item container (common ancestor of swatches and button)\n // In dealer search results, swatches are inside link and button is in sibling .product-actions div\n const productContainer = this.element.closest('.product-list-item');\n\n if (!productContainer) {\n return null;\n }\n\n // Find the swatches container within the product (querySelector works for any descendant, including siblings)\n const swatchesContainer = productContainer.querySelector('[data-controller*=\"product-swatches\"]');\n\n if (!swatchesContainer) {\n return null;\n }\n\n // Get the selected color variant ID from the data attribute set by swatch_item_controller\n const selectedId = swatchesContainer.dataset.selectedColorVariantId;\n\n // Return as integer or null (backend expects integer or null)\n return selectedId ? parseInt(selectedId, 10) : null;\n }\n}\n", "import { Controller } from '../stimulus';\n\n/**\n * Dealer Search Results Controller\n *\n * Manages the dealer search results container lifecycle, including close functionality.\n * This controller handles the search results dropdown that appears when dealers\n * perform searches from the header search bar.\n *\n * Usage:\n *
    \n * dealer-search-results#close\">\u00D7\n * \n *
    \n */\nexport default class extends Controller {\n connect() {\n // Ensure the container is visible when connected\n // This is necessary because the results are dynamically inserted via AJAX\n if (this.element.style.display === 'none') {\n this.element.style.display = 'block';\n }\n\n // Listen for clicks outside the search area to dismiss results\n this.documentClickHandler = this.handleDocumentClick.bind(this);\n document.addEventListener('click', this.documentClickHandler);\n }\n\n disconnect() {\n if (this.documentClickHandler) {\n document.removeEventListener('click', this.documentClickHandler);\n }\n }\n\n handleDocumentClick(event) {\n // Use parent .quick-search container as boundary so clicks on\n // the search input don't dismiss results\n const searchContainer = document.getElementById('quick-search-form');\n const boundary = searchContainer || this.element;\n if (!boundary.contains(event.target)) {\n this.dismissResults();\n }\n }\n\n close(event) {\n event.preventDefault();\n this.dismissResults();\n }\n\n dismissResults() {\n const container = this.element;\n const placeholder = document.getElementById('quick-search-results-placeholder');\n\n container.style.transition = 'opacity 300ms';\n container.style.opacity = '0';\n\n setTimeout(function() {\n if (placeholder) {\n placeholder.textContent = '';\n } else {\n container.remove();\n }\n }, 300);\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n connect() {\n // Track loaded tabs to prevent re-fetching\n this.loadedTabs = new Set(['ae-tab-search-all']); // \"All\" tab is pre-rendered\n }\n\n disconnect() {\n this.loadedTabs = null;\n }\n\n click(event) {\n event.preventDefault();\n\n const clickedTab = event.currentTarget;\n const targetTabId = clickedTab.getAttribute('data-tab');\n const lazyUrl = clickedTab.getAttribute('data-lazy-url');\n\n // Deactivate all tabs\n const allTabs = this.element.querySelectorAll('.ae-tab');\n allTabs.forEach(tab => {\n tab.classList.remove('active-tab');\n });\n\n // Activate clicked tab\n clickedTab.classList.add('active-tab');\n\n // Hide all tab contents\n const allTabContents = document.querySelectorAll('.ae-tab-content');\n allTabContents.forEach(content => {\n content.classList.remove('active-tab');\n });\n\n // Lazy load content if needed\n if (lazyUrl && !this.loadedTabs.has(targetTabId)) {\n this.loadTabContent(targetTabId, lazyUrl, clickedTab);\n }\n\n // Show target tab content\n const targetContent = document.getElementById(targetTabId);\n if (targetContent) {\n targetContent.classList.add('active-tab');\n }\n\n // Dispatch custom event for other components that might need to react\n this.dispatchCustomEvent('tab:changed', { tab: targetTabId });\n }\n\n loadTabContent(targetTabId, url, clickedTab) {\n const targetContent = document.getElementById(targetTabId);\n if (!targetContent) return;\n\n // Show loading indicator using safe DOM methods\n this.setLoadingState(targetContent);\n\n // Fetch content from trusted same-origin Rails endpoint\n fetch(url, {\n method: 'GET',\n headers: {\n 'Accept': 'text/html',\n 'X-Requested-With': 'XMLHttpRequest',\n },\n credentials: 'same-origin',\n })\n .then(response => {\n if (!response.ok) {\n throw new Error('Network response was not ok');\n }\n return response.text();\n })\n .then(html => {\n // Parse and insert trusted server-rendered HTML using DOM API\n this.insertTrustedServerContent(targetContent, html);\n this.loadedTabs.add(targetTabId);\n })\n .catch(error => {\n console.error('Error loading tab content:', error);\n this.setErrorState(targetContent);\n });\n }\n\n setLoadingState(container) {\n // Clear existing content safely\n while (container.firstChild) {\n container.removeChild(container.firstChild);\n }\n // Create loading indicator using safe DOM methods\n const loadingDiv = document.createElement('div');\n loadingDiv.className = 'ae-u-p-20 ae-u-text-center';\n const spinner = document.createElement('i');\n spinner.className = 'fa fa-spinner fa-spin';\n loadingDiv.appendChild(spinner);\n loadingDiv.appendChild(document.createTextNode(' Loading...'));\n container.appendChild(loadingDiv);\n }\n\n setErrorState(container) {\n while (container.firstChild) {\n container.removeChild(container.firstChild);\n }\n const errorDiv = document.createElement('div');\n errorDiv.className = 'ae-u-p-20 ae-text--danger';\n errorDiv.textContent = 'Error loading content. Please try again.';\n container.appendChild(errorDiv);\n }\n\n insertTrustedServerContent(container, html) {\n // Content from same-origin Rails endpoints is trusted (escaped by Rails)\n // Use DOMParser for explicit parsing before insertion\n const parser = new DOMParser();\n const doc = parser.parseFromString(html, 'text/html');\n\n // Clear container\n while (container.firstChild) {\n container.removeChild(container.firstChild);\n }\n\n // Move parsed nodes to container\n const fragment = document.createDocumentFragment();\n while (doc.body.firstChild) {\n fragment.appendChild(doc.body.firstChild);\n }\n container.appendChild(fragment);\n }\n\n dispatchCustomEvent(eventName, detail = {}) {\n const event = new CustomEvent(eventName, {\n bubbles: true,\n detail: detail,\n });\n this.element.dispatchEvent(event);\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n click(e) {\n e.preventDefault();\n\n document.getElementById('new-sizing-option').classList.remove('ae-hidden');\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n click(e) {\n e.preventDefault();\n\n document.getElementById('new-user-row').classList.remove('ae-hidden');\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n static targets = ['showTable', 'editTable', 'newTable', 'form'];\n\n initialize() {\n this.userId = this.element.dataset.userId;\n }\n\n edit(event) {\n event.preventDefault();\n\n if (this.hasShowTableTarget && this.showTableTarget) {\n this.showTableTarget.classList.add('ae-hidden');\n }\n if (this.hasEditTableTarget && this.editTableTarget) {\n this.editTableTarget.classList.remove('ae-hidden');\n }\n }\n\n // toggleActive method has been replaced by the toggle controller\n\n cancel(event) {\n event.preventDefault();\n\n if (this.hasEditTableTarget && this.editTableTarget) {\n this.editTableTarget.classList.add('ae-hidden');\n }\n if (this.hasShowTableTarget && this.showTableTarget) {\n this.showTableTarget.classList.remove('ae-hidden');\n }\n }\n\n cancelInvite(event) {\n event.preventDefault();\n\n document.getElementById('new-user-row').classList.add('ae-hidden');\n }\n\n // Helper method to serialize form data\n _serializeForm(form) {\n const formData = new FormData(form);\n const data = {};\n\n for (const [key, value] of formData.entries()) {\n // Extract the key name from brackets (e.g., user[name] -> name)\n const keyName = key.replace(/^user\\[|\\]$/g, '');\n data[keyName] = value;\n }\n\n return data;\n }\n\n _validateForm(form) {\n if (!form.checkValidity()) {\n form.reportValidity();\n return false;\n }\n return true;\n }\n\n sendInvite(event) {\n event.preventDefault();\n\n if (!this.hasFormTarget || !this.formTarget) return;\n const form = this.formTarget;\n if (!this._validateForm(form)) return;\n const userData = this._serializeForm(form);\n userData.is_active = true;\n\n fetch(form.action, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Accept': 'application/json',\n 'X-CSRF-Token': document.querySelector('meta[name=\"csrf-token\"]').content,\n },\n body: JSON.stringify({ user: userData }),\n })\n .then(response => {\n if (!response.ok) {\n return response.json().catch(() => ({})).then(data => {\n const message = (data.errors && data.errors.length > 0)\n ? data.errors.join(', ')\n : 'Failed to send invitation';\n throw new Error(message);\n });\n }\n\n const url = '/dealer_settings?t=users';\n if (window.Turbolinks) {\n window.Turbolinks.visit(url);\n } else {\n window.location.href = url;\n }\n\n if (window.App && typeof window.App.showSuccessFlash === 'function') {\n window.App.showSuccessFlash('Sending invitation');\n }\n })\n .catch(error => {\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash(error.message || 'Failed to send invitation');\n }\n });\n }\n\n delete(event) {\n event.preventDefault();\n\n if (!window.confirm('Are you sure you want to delete this user? This action cannot be undone.')) {\n return;\n }\n\n fetch(`/dealer_settings/users/${this.userId}`, {\n method: 'DELETE',\n headers: {\n 'Content-Type': 'application/json',\n 'Accept': 'application/json',\n 'X-CSRF-Token': document.querySelector('meta[name=\"csrf-token\"]').content,\n },\n })\n .then(response => {\n if (!response.ok) {\n return response.json().catch(() => ({})).then(data => {\n const message = (data.errors && data.errors.length > 0)\n ? data.errors.join(', ')\n : 'Failed to delete user';\n throw new Error(message);\n });\n }\n\n const url = '/dealer_settings?t=users';\n if (window.Turbolinks) {\n window.Turbolinks.visit(url);\n } else {\n window.location.href = url;\n }\n\n if (window.App && typeof window.App.showSuccessFlash === 'function') {\n window.App.showSuccessFlash('User deleted');\n }\n })\n .catch(error => {\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash(error.message || 'Failed to delete user');\n }\n });\n }\n\n save(event) {\n event.preventDefault();\n\n if (!this.hasFormTarget || !this.formTarget) return;\n const form = this.formTarget;\n if (!this._validateForm(form)) return;\n\n const userData = this._serializeForm(form);\n\n // is_active is managed independently by the toggle controller\n delete userData.is_active;\n\n fetch(form.action, {\n method: 'PATCH',\n headers: {\n 'Content-Type': 'application/json',\n 'Accept': 'application/json',\n 'X-CSRF-Token': document.querySelector('meta[name=\"csrf-token\"]').content,\n },\n body: JSON.stringify({ user: userData }),\n })\n .then(response => {\n if (!response.ok) {\n return response.json().catch(() => ({})).then(data => {\n const message = (data.errors && data.errors.length > 0)\n ? data.errors.join(', ')\n : 'Something went wrong';\n throw new Error(message);\n });\n }\n\n const url = '/dealer_settings?t=users';\n if (window.Turbolinks) {\n window.Turbolinks.visit(url);\n } else {\n window.location.href = url;\n }\n\n if (window.App && typeof window.App.showSuccessFlash === 'function') {\n window.App.showSuccessFlash('Changes saved');\n }\n })\n .catch(error => {\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash(error.message || 'Something went wrong');\n }\n });\n }\n}\n", "/* global MutationObserver */\nimport { Controller } from '../stimulus';\n\nexport default class extends Controller {\n static targets = ['input', 'container', 'emptyState', 'wrapper'];\n\n connect() {\n this.setupRowObserver();\n }\n\n disconnect() {\n if (this.rowObserver) {\n this.rowObserver.disconnect();\n this.rowObserver = null;\n }\n }\n\n filter() {\n const searchTerm = this.inputTarget.value.toLowerCase().trim();\n const rows = this.containerTarget.querySelectorAll('div.user-data-row');\n let visibleCount = 0;\n\n for (let i = 0; i < rows.length; i++) {\n const row = rows[i];\n const searchable = row.getAttribute('data-dealer-settings-user-filter-searchable') || '';\n\n if (searchTerm === '' || searchable.indexOf(searchTerm) !== -1) {\n row.style.display = '';\n visibleCount++;\n } else {\n row.style.display = 'none';\n }\n }\n\n this.updateEmptyState(visibleCount, rows.length);\n }\n\n updateEmptyState(visibleCount, totalCount) {\n if (!this.hasEmptyStateTarget || !this.hasWrapperTarget) return;\n\n if (visibleCount === 0 && totalCount > 0) {\n this.emptyStateTarget.style.display = 'block';\n this.wrapperTarget.style.display = 'none';\n } else {\n this.emptyStateTarget.style.display = 'none';\n this.wrapperTarget.style.display = '';\n }\n }\n\n setupRowObserver() {\n if (!this.hasContainerTarget) return;\n\n const self = this;\n this.rowObserver = new MutationObserver(function(mutations) {\n const hasNewRows = mutations.some(function(m) {\n return m.type === 'childList' && m.addedNodes.length > 0;\n });\n\n if (hasNewRows && self.inputTarget.value.trim() !== '') {\n self.filter();\n }\n });\n\n this.rowObserver.observe(this.containerTarget, { childList: true });\n }\n}\n", "import { Controller } from '../stimulus';\n\n/**\n * Phase 4.109: Decoration Job Tracker Controller\n *\n * Page-level controller that handles background polling for decoration jobs\n * after the decorator modal closes. This allows users to continue working\n * while decorations process in the background.\n *\n * Responsibilities:\n * 1. Listen for 'decorator:job:queued' events from modal\n * 2. Track multiple concurrent jobs (Map of jobId -> metadata)\n * 3. Poll job_progress endpoint every 2 seconds\n * 4. On completion: refresh ALL visible affected images surgically\n * 5. On failure: show error flash message\n * 6. Cleanup completed/failed jobs from tracking Map\n *\n * Usage:\n * Add to body or layout element:\n * \n */\nexport default class extends Controller {\n constructor() {\n super();\n // Track pending jobs: Map\n this.pendingJobs = new Map();\n this.pollingInterval = null;\n this.pollIntervalMs = 2000; // Poll every 2 seconds\n this.isConnected = false;\n\n // Bind event handlers for cleanup\n this._handleJobQueued = this.handleJobQueued.bind(this);\n }\n\n connect() {\n this.isConnected = true;\n\n // Listen for job queued events from decorator modal\n document.addEventListener('decorator:job:queued', this._handleJobQueued);\n }\n\n disconnect() {\n this.isConnected = false;\n\n // Remove event listeners\n document.removeEventListener('decorator:job:queued', this._handleJobQueued);\n\n // Stop polling\n this.stopPolling();\n\n // Clear pending jobs (they'll be lost on navigation)\n this.pendingJobs.clear();\n }\n\n /**\n * Handle decorator:job:queued event from modal\n * @param {CustomEvent} event - Event with job details\n */\n handleJobQueued(event) {\n const { jobId, saleId, productId, productIds, imageIds, timestamp } = event.detail || {};\n\n if (!jobId || !saleId) {\n console.warn('[DecorationJobTracker] Invalid job queued event:', event.detail);\n return;\n }\n\n // Add job to tracking\n this.pendingJobs.set(jobId, {\n saleId,\n productId,\n productIds: productIds || [], // Phase 4.129: Store all selected products for bulk refresh\n imageIds: imageIds || [],\n timestamp: timestamp || Date.now(),\n attempts: 0,\n });\n\n // Start polling if not already running\n this.startPolling();\n }\n\n /**\n * Start the polling interval\n */\n startPolling() {\n if (this.pollingInterval) {\n return; // Already polling\n }\n\n this.pollingInterval = setInterval(() => {\n this.pollAllJobs();\n }, this.pollIntervalMs);\n\n // Also poll immediately\n this.pollAllJobs();\n }\n\n /**\n * Stop the polling interval\n */\n stopPolling() {\n if (this.pollingInterval) {\n clearInterval(this.pollingInterval);\n this.pollingInterval = null;\n }\n }\n\n /**\n * Poll status for all pending jobs\n */\n async pollAllJobs() {\n if (!this.isConnected || this.pendingJobs.size === 0) {\n this.stopPolling();\n return;\n }\n\n // Create a copy of job entries to avoid mutation during iteration\n const jobEntries = Array.from(this.pendingJobs.entries());\n\n for (const [jobId, jobData] of jobEntries) {\n await this.checkJobProgress(jobId, jobData);\n }\n\n // Stop polling if no jobs remain\n if (this.pendingJobs.size === 0) {\n this.stopPolling();\n }\n }\n\n /**\n * Check progress for a single job\n * @param {string} jobId - The job ID to check\n * @param {Object} jobData - Job metadata\n */\n async checkJobProgress(jobId, jobData) {\n const { saleId } = jobData;\n jobData.attempts++;\n\n try {\n const csrfToken = document.querySelector('[name=\"csrf-token\"]')?.content || '';\n const response = await fetch(`/teamsites/${saleId}/decorator/job_progress/${jobId}`, {\n method: 'GET',\n credentials: 'same-origin',\n headers: {\n 'Accept': 'application/json',\n 'X-CSRF-Token': csrfToken,\n },\n });\n\n if (response.status === 404) {\n // Job not found yet - might be a race condition, keep trying\n if (jobData.attempts > 30) {\n // 30 attempts * 2 seconds = 1 minute timeout\n this.handleJobTimeout(jobId, jobData);\n }\n return;\n }\n\n if (!response.ok) {\n // Server error - keep trying unless we've exceeded max attempts\n if (jobData.attempts > 30) {\n this.handleJobFailed(jobId, { error: `Server error: ${response.status}` });\n }\n return;\n }\n\n const progress = await response.json();\n\n if (progress.status === 'completed') {\n this.handleJobComplete(jobId, progress, jobData);\n } else if (progress.status === 'failed') {\n this.handleJobFailed(jobId, progress);\n }\n // For 'processing' status, keep polling\n } catch (error) {\n console.error(`[DecorationJobTracker] Error checking job ${jobId}:`, error);\n // Network error - keep trying unless we've exceeded max attempts\n if (jobData.attempts > 30) {\n this.handleJobFailed(jobId, { error: error.message });\n }\n }\n }\n\n /**\n * Handle successful job completion\n * @param {string} jobId - The completed job ID\n * @param {Object} progressData - Completion data from server\n * @param {Object} jobData - Original job metadata\n */\n handleJobComplete(jobId, progressData, jobData) {\n // Remove from tracking\n this.pendingJobs.delete(jobId);\n\n // Get decorated image IDs from response or fallback to original imageIds\n const decoratedImageIds = progressData.decorated_image_ids || jobData.imageIds || [];\n\n // Phase 4.111: Get decorated URLs from progress data\n // After decoration, the image URL changes entirely (new Spree::Image),\n // so we need the actual new URLs, not just a cache buster on the old URL\n const decoratedUrls = progressData.decorated_urls || {};\n\n // Refresh all affected images on the page\n if (decoratedImageIds.length > 0) {\n this.refreshDecoratedImages(decoratedImageIds, decoratedUrls);\n }\n\n // Phase 4.129: Refresh sidebar thumbnails for ALL decorated products\n // Use productIds array if available, fall back to single productId for backward compatibility\n const productIdsToRefresh = jobData.productIds?.length > 0\n ? jobData.productIds\n : (jobData.productId ? [jobData.productId] : []);\n\n for (const productId of productIdsToRefresh) {\n this.refreshSidebarThumbnail(productId, decoratedUrls);\n }\n\n // Show success flash\n if (window.App && window.App.showSuccessFlash) {\n window.App.showSuccessFlash('Decoration complete!');\n }\n\n // Dispatch completion event for any listeners\n document.dispatchEvent(new CustomEvent('decorator:job:completed', {\n bubbles: true,\n detail: {\n jobId,\n imageIds: decoratedImageIds,\n productId: jobData.productId,\n productIds: productIdsToRefresh, // Phase 4.129: Include all refreshed products\n },\n }));\n }\n\n /**\n * Handle job failure\n * @param {string} jobId - The failed job ID\n * @param {Object} progressData - Error data\n */\n handleJobFailed(jobId, progressData) {\n // Remove from tracking\n this.pendingJobs.delete(jobId);\n\n // Show error flash\n const errorMessage = progressData.error || 'Decoration failed';\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash(errorMessage);\n }\n\n // Dispatch failure event\n document.dispatchEvent(new CustomEvent('decorator:job:failed', {\n bubbles: true,\n detail: {\n jobId,\n error: errorMessage,\n },\n }));\n }\n\n /**\n * Handle job timeout\n * @param {string} jobId - The timed out job ID\n * @param {Object} jobData - Job metadata\n */\n handleJobTimeout(jobId, jobData) {\n // Remove from tracking\n this.pendingJobs.delete(jobId);\n\n // Show warning flash\n if (window.App && window.App.showWarningFlash) {\n window.App.showWarningFlash('Decoration is taking longer than expected. Please refresh to check status.');\n } else if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash('Decoration timeout. Please refresh to check status.');\n }\n\n // Dispatch timeout event\n document.dispatchEvent(new CustomEvent('decorator:job:timeout', {\n bubbles: true,\n detail: {\n jobId,\n productId: jobData.productId,\n },\n }));\n }\n\n /**\n * Surgically refresh all visible instances of decorated images\n * Phase 4.111: Uses actual decorated URLs when available, since decoration\n * creates a new Spree::Image with a different URL (not just cache invalidation)\n * @param {Array} imageIds - Array of image IDs to refresh\n * @param {Object} decoratedUrls - Map of imageId -> { large_url, thumb_url }\n */\n refreshDecoratedImages(imageIds, decoratedUrls = {}) {\n const cacheBuster = `?v=${Date.now()}`;\n const refreshedImages = new Set();\n\n imageIds.forEach(imageId => {\n // Get the new URLs for this image (if available from backend)\n const urlData = decoratedUrls[imageId] || decoratedUrls[String(imageId)] || {};\n\n // Pattern 1a: Images with data-image-id attribute directly on img (main product image)\n const directImages = document.querySelectorAll(`img[data-image-id=\"${imageId}\"]`);\n directImages.forEach(img => {\n // Use new large_url if available, otherwise fall back to cache buster\n if (urlData.large_url) {\n img.src = urlData.large_url;\n } else {\n this.refreshImageSrc(img, cacheBuster);\n }\n refreshedImages.add(img);\n });\n\n // Pattern 1b: Gallery containers with data-image-id (thumbnails)\n const galleryContainers = document.querySelectorAll(`[data-image-id=\"${imageId}\"]:not(img)`);\n galleryContainers.forEach(container => {\n const imgs = container.querySelectorAll('img');\n imgs.forEach(img => {\n if (!refreshedImages.has(img)) {\n // Use thumb_url for thumbnails, fall back to large_url, then cache buster\n if (urlData.thumb_url) {\n img.src = urlData.thumb_url;\n } else if (urlData.large_url) {\n img.src = urlData.large_url;\n } else {\n this.refreshImageSrc(img, cacheBuster);\n }\n refreshedImages.add(img);\n }\n });\n });\n });\n\n // Dispatch event for any custom refresh handlers\n document.dispatchEvent(new CustomEvent('decorator:images:refreshed', {\n bubbles: true,\n detail: { imageIds, decoratedUrls },\n }));\n }\n\n /**\n * Refresh a single image source with cache buster\n * @param {HTMLImageElement} img - The image element\n * @param {string} cacheBuster - Cache buster query string\n */\n refreshImageSrc(img, cacheBuster) {\n if (!img || !img.src) return;\n\n // Remove existing query string and add cache buster\n const baseUrl = img.src.split('?')[0];\n img.src = baseUrl + cacheBuster;\n }\n\n /**\n * Refresh sidebar thumbnail for a product\n * Phase 4.111: Uses decorated URLs when available\n * @param {number|string} productId - The product ID\n * @param {Object} decoratedUrls - Map of imageId -> { large_url, thumb_url }\n */\n refreshSidebarThumbnail(productId, decoratedUrls = {}) {\n const container = document.getElementById(\n `sidebar-product-display-image-container-${productId}`,\n );\n if (!container) return;\n\n const img = container.querySelector('img');\n if (!img) return;\n\n // Try to find the matching URL by checking the img's data-image-id\n const imageId = img.dataset?.imageId;\n const urlData = imageId ? (decoratedUrls[imageId] || decoratedUrls[String(imageId)] || {}) : {};\n\n // Use thumb_url for sidebar (it's a thumbnail), fall back to cache buster\n if (urlData.thumb_url) {\n img.src = urlData.thumb_url;\n } else {\n const cacheBuster = `?v=${Date.now()}`;\n this.refreshImageSrc(img, cacheBuster);\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n static targets = ['tab', 'content'];\n\n connect() {\n const currentTab = this.element.dataset.tab || 'active';\n this.showTab(currentTab);\n }\n\n change(event) {\n event.preventDefault();\n const tab = event.currentTarget.dataset.tab;\n this.showTab(tab);\n }\n\n showTab(tabName) {\n const activeClass = this.element.dataset.activeTabClass || 'active';\n\n // Update active tab\n this.tabTargets.forEach(tab => {\n if (tab.dataset.tab === tabName) {\n tab.classList.add(activeClass);\n } else {\n tab.classList.remove(activeClass);\n }\n });\n\n // Show/hide content\n this.contentTargets.forEach(content => {\n if (content.dataset.tab === tabName) {\n content.style.display = 'block';\n } else {\n content.style.display = 'none';\n }\n });\n\n // Update controller's data attribute\n this.element.dataset.tab = tabName;\n }\n}\n", "/**\n * Shared dialog utility for two-scope artwork deletion.\n *\n * Shows a dialog with two radio options:\n * 1. Remove from This TeamSite Only (local)\n * 2. Delete from Organization Library (organization)\n *\n * Returns a Promise resolving to 'local', 'organization', or 'cancel'.\n */\n\n// Module-level reference for external dismissal (e.g., controller disconnect)\nvar activeCleanup = null;\n\n/**\n * Dismiss any open delete artwork dialog, resolving its promise with 'cancel'.\n */\nexport function dismissDeleteArtworkDialog() {\n if (activeCleanup) {\n activeCleanup('cancel');\n }\n}\n\n/**\n * @returns {Promise<'local'|'organization'|'cancel'>}\n */\nexport function showDeleteArtworkDialog() {\n // Dismiss any existing dialog before opening a new one\n dismissDeleteArtworkDialog();\n\n return new Promise(function (resolve) {\n // Create overlay with max z-index to ensure visibility above all modal layers\n var overlay = document.createElement('div');\n overlay.style.cssText = 'position: fixed; top: 0; left: 0; right: 0; bottom: 0; ' +\n 'background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; ' +\n 'justify-content: center; z-index: 2147483647;';\n\n // Create dialog container\n var dialog = document.createElement('div');\n dialog.style.cssText = 'background: white; padding: 24px; border-radius: 8px; ' +\n 'max-width: 440px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);';\n\n // Heading\n var heading = document.createElement('h3');\n heading.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; color: #333;';\n heading.textContent = 'Delete Artwork';\n\n // Radio group\n var radioGroup = document.createElement('div');\n radioGroup.style.cssText = 'margin: 0 0 16px 0;';\n\n // Option 1: Local removal (pre-selected)\n var label1 = document.createElement('label');\n label1.style.cssText = 'display: flex; align-items: flex-start; gap: 8px; margin-bottom: 12px; cursor: pointer;';\n var radio1 = document.createElement('input');\n radio1.type = 'radio';\n radio1.name = 'delete-scope';\n radio1.value = 'local';\n radio1.checked = true;\n radio1.style.cssText = 'margin-top: 3px;';\n var text1 = document.createElement('div');\n var title1 = document.createElement('div');\n title1.style.cssText = 'font-weight: 600; color: #333; margin-bottom: 2px;';\n title1.textContent = 'Remove from This TeamSite Only';\n var desc1 = document.createElement('div');\n desc1.style.cssText = 'font-size: 13px; color: #666;';\n desc1.textContent = 'Artwork will remain in the Organization Library';\n text1.appendChild(title1);\n text1.appendChild(desc1);\n label1.appendChild(radio1);\n label1.appendChild(text1);\n\n // Option 2: Organization removal\n var label2 = document.createElement('label');\n label2.style.cssText = 'display: flex; align-items: flex-start; gap: 8px; margin-bottom: 12px; cursor: pointer;';\n var radio2 = document.createElement('input');\n radio2.type = 'radio';\n radio2.name = 'delete-scope';\n radio2.value = 'organization';\n radio2.style.cssText = 'margin-top: 3px;';\n var text2 = document.createElement('div');\n var title2 = document.createElement('div');\n title2.style.cssText = 'font-weight: 600; color: #333; margin-bottom: 2px;';\n title2.textContent = 'Delete from Organization Library';\n var desc2 = document.createElement('div');\n desc2.style.cssText = 'font-size: 13px; color: #666;';\n desc2.textContent = 'Permanently removes from all future TeamSites. Closed TeamSites are not affected.';\n text2.appendChild(title2);\n text2.appendChild(desc2);\n label2.appendChild(radio2);\n label2.appendChild(text2);\n\n radioGroup.appendChild(label1);\n radioGroup.appendChild(label2);\n\n // Warning \u2014 updates based on selected scope\n var warning = document.createElement('p');\n warning.style.cssText = 'margin: 0 0 20px 0; color: #666; font-size: 13px;';\n warning.textContent = 'You can re-add this artwork from the Organization Library.';\n\n var updateWarning = function () {\n var selected = dialog.querySelector('input[name=\"delete-scope\"]:checked');\n if (selected && selected.value === 'organization') {\n warning.textContent = 'This artwork will be removed from all open TeamSites.';\n warning.style.color = '#c0392b';\n } else {\n warning.textContent = 'You can re-add this artwork from the Organization Library.';\n warning.style.color = '#666';\n }\n };\n\n radio1.addEventListener('change', updateWarning);\n radio2.addEventListener('change', updateWarning);\n\n // Button container\n var buttonContainer = document.createElement('div');\n buttonContainer.style.cssText = 'display: flex; gap: 8px; justify-content: flex-end;';\n\n var cancelBtn = document.createElement('button');\n cancelBtn.type = 'button';\n cancelBtn.style.cssText = 'padding: 8px 16px; border: 1px solid #ccc; background: #f5f5f5; ' +\n 'color: #333; border-radius: 4px; cursor: pointer; font-size: 14px;';\n cancelBtn.textContent = 'Cancel';\n cancelBtn.dataset.choice = 'cancel';\n\n var confirmBtn = document.createElement('button');\n confirmBtn.type = 'button';\n confirmBtn.style.cssText = 'padding: 8px 16px; border: none; background: #c0392b; ' +\n 'color: white; border-radius: 4px; cursor: pointer; font-size: 14px;';\n confirmBtn.textContent = 'Confirm';\n confirmBtn.dataset.choice = 'confirm';\n\n // Assemble dialog\n buttonContainer.appendChild(cancelBtn);\n buttonContainer.appendChild(confirmBtn);\n dialog.appendChild(heading);\n dialog.appendChild(radioGroup);\n dialog.appendChild(warning);\n dialog.appendChild(buttonContainer);\n overlay.appendChild(dialog);\n document.body.appendChild(overlay);\n\n var boundEscHandler = null;\n\n // Cleanup function\n var cleanup = function (result) {\n if (boundEscHandler) {\n document.removeEventListener('keydown', boundEscHandler);\n boundEscHandler = null;\n }\n activeCleanup = null;\n overlay.remove();\n resolve(result);\n };\n\n activeCleanup = cleanup;\n\n // Escape key handler\n boundEscHandler = function (e) {\n if (e.key === 'Escape') {\n e.preventDefault();\n cleanup('cancel');\n }\n };\n document.addEventListener('keydown', boundEscHandler);\n\n // Overlay click (outside dialog)\n overlay.addEventListener('click', function (e) {\n if (e.target === overlay) {\n cleanup('cancel');\n }\n });\n\n // Button clicks\n dialog.addEventListener('click', function (e) {\n e.stopPropagation();\n var choice = e.target.dataset.choice;\n if (choice === 'cancel') {\n cleanup('cancel');\n } else if (choice === 'confirm') {\n var selected = dialog.querySelector('input[name=\"delete-scope\"]:checked');\n cleanup(selected ? selected.value : 'cancel');\n }\n });\n });\n}\n", "import { Controller } from '../stimulus';\nimport { showDeleteArtworkDialog, dismissDeleteArtworkDialog } from '../utils/delete_artwork_dialog';\n\n/**\n * DecoratorArtworkItemController\n *\n * Handles individual artwork item actions in the decorator modal's artwork library.\n * Supports two-scope delete: local (remove from sale) or organization (soft-delete from org).\n *\n * Data attributes expected on element:\n * data-artwork-id: ID of the artwork\n * data-image-url: URL of the artwork image\n *\n * Data attributes read from parent modal:\n * data-sale-id: Current sale ID (from #decorator-modal)\n */\nexport default class extends Controller {\n initialize() {\n this.artworkId = this.element.dataset.artworkId;\n this.saleId = this.getSaleId();\n }\n\n disconnect() {\n dismissDeleteArtworkDialog();\n }\n\n getSaleId() {\n // Get sale ID from decorator modal data attribute\n const modal = document.getElementById('decorator-modal');\n return modal ? modal.dataset.saleId : undefined;\n }\n\n async delete(event) {\n event.preventDefault();\n event.stopPropagation();\n\n const scope = await showDeleteArtworkDialog();\n if (scope === 'local' || scope === 'organization') {\n await this.performDelete(scope);\n }\n }\n\n async performDelete(scope) {\n const metaTag = document.querySelector('meta[name=\"csrf-token\"]');\n const csrfToken = metaTag ? metaTag.content : undefined;\n const url = '/teamsites/' + this.saleId + '/artworks/' + this.artworkId;\n\n try {\n const response = await fetch(url, {\n method: 'DELETE',\n headers: {\n 'X-CSRF-Token': csrfToken,\n 'Accept': 'application/json',\n 'Content-Type': 'application/json',\n 'X-Requested-With': 'XMLHttpRequest',\n },\n body: JSON.stringify({ scope: scope }),\n credentials: 'same-origin',\n });\n\n if (response.ok) {\n const message = scope === 'organization'\n ? 'Artwork deleted from organization library'\n : 'Artwork removed from this TeamSite';\n window.App && window.App.showSuccessFlash(message);\n\n // Optimistic UI - remove element immediately\n this.element.remove();\n } else {\n const data = await response.json().catch(() => ({}));\n window.App && window.App.showErrorFlash(data.message || 'Failed to delete artwork');\n }\n } catch (error) {\n window.App && window.App.showErrorFlash('Network error. Please try again.');\n }\n }\n}\n", "/**\n * Performance optimization utilities for the decorator\n * \n * Provides throttling and debouncing functions to improve performance\n * of high-frequency events like mousemove, resize, and input changes.\n */\n\n/* global requestAnimationFrame, cancelAnimationFrame, setTimeout, clearTimeout, performance, console, window, URLSearchParams, jest, process */\n\n/**\n * Throttles a function using requestAnimationFrame\n * Ensures the function runs at most once per animation frame (60fps)\n * \n * @param {Function} func - The function to throttle\n * @returns {Function} The throttled function\n */\nexport function throttleRAF(func) {\n let rafId = null;\n let lastArgs = null;\n let lastThis = null;\n\n const throttled = function(...args) {\n lastArgs = args;\n lastThis = this;\n\n if (rafId === null) {\n rafId = requestAnimationFrame(() => {\n func.apply(lastThis, lastArgs);\n rafId = null;\n });\n }\n };\n\n // Add cancel method to clean up pending animation frames\n throttled.cancel = function() {\n if (rafId !== null) {\n cancelAnimationFrame(rafId);\n rafId = null;\n }\n };\n\n return throttled;\n}\n\n/**\n * Debounces a function with a specified delay\n * The function will only execute after the delay has passed without new calls\n * \n * @param {Function} func - The function to debounce\n * @param {number} delay - The delay in milliseconds\n * @returns {Function} The debounced function\n */\nexport function debounce(func, delay) {\n let timeoutId = null;\n\n const debounced = function(...args) {\n // Clear existing timeout\n if (timeoutId !== null) {\n clearTimeout(timeoutId);\n }\n\n // Set new timeout\n timeoutId = setTimeout(() => {\n func.apply(this, args);\n timeoutId = null;\n }, delay);\n };\n\n // Add cancel method to clean up pending timeouts\n debounced.cancel = function() {\n if (timeoutId !== null) {\n clearTimeout(timeoutId);\n timeoutId = null;\n }\n };\n\n return debounced;\n}\n\n/**\n * Performance monitoring utility\n * Helps track and log performance metrics for optimization\n */\nexport class PerformanceMonitor {\n constructor(name) {\n this.name = name;\n this.marks = new Map();\n \n // Only enable in development/test environments\n // Check for common development indicators\n this.enabled = this.isDevelopmentEnvironment();\n }\n \n /**\n * Detect if we're in a development environment\n * @returns {boolean}\n */\n isDevelopmentEnvironment() {\n // Check if we're in a test environment first\n if (this.isTestEnvironment()) {\n return false; // Disable in tests to avoid issues\n }\n \n // Check for localhost or development domains\n const hostname = window.location.hostname;\n const isDevelopmentDomain = hostname === 'localhost' || \n hostname === '127.0.0.1' || \n hostname.includes('.local') ||\n hostname.includes('dev.');\n \n // Check for development port ranges (3000-3999, 8080, etc)\n const port = window.location.port;\n const isDevelopmentPort = port === '3000' || \n (port >= '3000' && port <= '3999') ||\n port === '8080';\n \n // Check URL parameters for debug mode\n const urlParams = new URLSearchParams(window.location.search);\n const hasDebugParam = urlParams.has('debug') || urlParams.has('performance_debug');\n \n return isDevelopmentDomain || isDevelopmentPort || hasDebugParam;\n }\n \n /**\n * Detect if we're in a test environment\n * @returns {boolean}\n */\n isTestEnvironment() {\n // Check for Jest environment\n return typeof jest !== 'undefined' || \n typeof process !== 'undefined' && process.env.NODE_ENV === 'test';\n }\n\n /**\n * Start a performance measurement\n * @param {string} label - The label for this measurement\n */\n start(label) {\n if (!this.enabled) return;\n \n // Check if performance API is available\n if (typeof performance === 'undefined' || typeof performance.mark !== 'function') {\n return;\n }\n \n const markName = `${this.name}:${label}:start`;\n performance.mark(markName);\n this.marks.set(label, markName);\n }\n\n /**\n * End a performance measurement and log the duration\n * @param {string} label - The label for this measurement\n */\n end(label) {\n if (!this.enabled) return;\n \n const startMark = this.marks.get(label);\n if (!startMark) return;\n \n // Check if performance API is available\n if (typeof performance === 'undefined' || typeof performance.mark !== 'function') {\n this.marks.delete(label);\n return;\n }\n\n const endMark = `${this.name}:${label}:end`;\n performance.mark(endMark);\n\n try {\n performance.measure(`${this.name}:${label}`, startMark, endMark);\n \n // Get the measurement\n const entries = performance.getEntriesByName(`${this.name}:${label}`);\n if (entries.length > 0) {\n const duration = entries[entries.length - 1].duration;\n \n // Track slow operations (> 16ms means we're dropping frames)\n // Performance logging disabled for production\n }\n } catch {\n // Ignore errors in older browsers\n }\n\n // Clean up\n this.marks.delete(label);\n }\n\n /**\n * Clear all performance marks and measures\n */\n clear() {\n this.marks.clear();\n \n // Check if performance API is available\n if (typeof performance !== 'undefined' && \n typeof performance.clearMarks === 'function' && \n typeof performance.clearMeasures === 'function') {\n try {\n // Clear all performance entries for this monitor\n performance.clearMarks();\n performance.clearMeasures();\n } catch {\n // Ignore errors in older browsers\n }\n }\n }\n}\n\n/**\n * Frame rate monitor for tracking FPS during interactions\n */\nexport class FrameRateMonitor {\n constructor() {\n this.frameCount = 0;\n this.startTime = 0;\n this.fps = 0;\n this.active = false;\n this.callback = null;\n }\n\n /**\n * Start monitoring frame rate\n * @param {Function} callback - Called with current FPS value\n */\n start(callback) {\n if (this.active) return;\n\n this.active = true;\n this.callback = callback;\n this.frameCount = 0;\n this.startTime = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();\n this.measure();\n }\n\n /**\n * Stop monitoring frame rate\n */\n stop() {\n this.active = false;\n }\n\n measure() {\n if (!this.active) return;\n\n this.frameCount++;\n \n const currentTime = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();\n const elapsed = currentTime - this.startTime;\n\n // Update FPS every second\n if (elapsed >= 1000) {\n this.fps = Math.round((this.frameCount * 1000) / elapsed);\n \n if (this.callback) {\n this.callback(this.fps);\n }\n\n // Reset counters\n this.frameCount = 0;\n this.startTime = currentTime;\n }\n\n requestAnimationFrame(() => this.measure());\n }\n}\n", "import { Controller } from '../stimulus';\nimport { debounce } from '../utils/performance';\n\n/* global MutationObserver */\n\nexport default class extends Controller {\n static targets = ['searchInput', 'grid', 'container'];\n\n connect() {\n // Create debounced search function (300ms delay)\n this.debouncedSearch = debounce(this.performSearch.bind(this), 300);\n\n // Cache artwork items for better performance\n this.artworkItems = null;\n\n // Track current drag image for cleanup\n this.currentDragImage = null;\n\n // Bind dragend handler for cleanup\n this.handleDragEnd = this.handleDragEnd.bind(this);\n\n // Set up observer to detect when grid content changes\n this.setupGridObserver();\n\n // Concurrency guard to prevent parallel refreshGrid calls\n this.isRefreshing = false;\n\n // Phase 4.114.2: Listen for upload success from canvas dropzone\n // Canvas dropzone dispatches to document since it's in a different DOM subtree\n // Uses dedicated event name to avoid collision with generic dropzone's bubbling queuecomplete\n this.boundRefreshGrid = this.refreshGrid.bind(this);\n document.addEventListener('decorator:canvas:upload:complete', this.boundRefreshGrid);\n\n // Draggable items are handled by individual artwork selector controllers\n }\n\n disconnect() {\n // Cancel any pending debounced calls\n if (this.debouncedSearch) {\n this.debouncedSearch.cancel();\n }\n\n // Clean up any lingering drag image\n this.cleanupDragImage();\n\n // Clear artwork cache to prevent memory leaks\n this.invalidateCache();\n\n // Disconnect the observer\n if (this.gridObserver) {\n this.gridObserver.disconnect();\n this.gridObserver = null;\n }\n\n // Phase 4.114.2: Clean up document listener\n if (this.boundRefreshGrid) {\n document.removeEventListener('decorator:canvas:upload:complete', this.boundRefreshGrid);\n }\n }\n\n search(event) {\n // Call the debounced search function\n this.debouncedSearch(event.target.value);\n }\n\n performSearch(searchValue) {\n // Check if grid target exists before proceeding\n if (!this.hasGridTarget) return;\n\n const searchTerm = searchValue.toLowerCase();\n\n // Cache artwork items on first search\n if (!this.artworkItems) {\n this.artworkItems = Array.from(this.gridTarget.querySelectorAll('.artwork-item'));\n }\n\n // Use cached items and CSS classes for better performance\n this.artworkItems.forEach(item => {\n const artworkName = (item.dataset.artworkName || '').toLowerCase();\n const isVisible = artworkName.includes(searchTerm);\n\n // Use classList for better performance than inline styles\n if (isVisible) {\n item.classList.remove('hidden');\n } else {\n item.classList.add('hidden');\n }\n });\n }\n\n select(event) {\n const artworkItem = event.currentTarget;\n const artworkId = artworkItem.dataset.artworkId;\n const artworkUrl = artworkItem.dataset.imageUrl;\n\n // Dispatch event for the canvas to handle\n this.element.dispatchEvent(new CustomEvent('artwork-selected', {\n detail: { artworkId, artworkUrl },\n bubbles: true,\n }));\n }\n\n handleDragStart(event) {\n const artworkItem = event.currentTarget;\n const artworkId = artworkItem.dataset.artworkId;\n const artworkUrl = artworkItem.dataset.imageUrl;\n\n // Create a custom drag image showing only the artwork without card styling\n // Store reference for cleanup in dragend handler\n this.currentDragImage = this.createDragImage(artworkUrl, event);\n\n // Add dragend listener to ensure cleanup when drag completes\n artworkItem.addEventListener('dragend', this.handleDragEnd);\n\n event.dataTransfer.effectAllowed = 'copy';\n event.dataTransfer.setData('artworkId', artworkId);\n event.dataTransfer.setData('artworkUrl', artworkUrl);\n }\n\n handleDragEnd(_event) {\n // Guaranteed cleanup when drag operation completes\n this.cleanupDragImage();\n }\n\n cleanupDragImage() {\n // Centralized cleanup method with defensive checks\n if (this.currentDragImage && this.currentDragImage.parentNode) {\n this.currentDragImage.parentNode.removeChild(this.currentDragImage);\n this.currentDragImage = null;\n }\n }\n\n createDragImage(artworkUrl, event) {\n // Create a temporary image element for drag preview\n const dragImage = document.createElement('img');\n dragImage.src = artworkUrl;\n dragImage.style.cssText = `\n width: 100px;\n height: 100px;\n object-fit: contain;\n background: white;\n border: 2px solid #007cba;\n border-radius: 4px;\n padding: 4px;\n position: absolute;\n top: -1000px;\n left: -1000px;\n pointer-events: none;\n `;\n\n // Add to DOM temporarily\n document.body.appendChild(dragImage);\n\n // Set the custom drag image\n event.dataTransfer.setDragImage(dragImage, 50, 50);\n\n // Fallback cleanup with longer delay to ensure browser finishes rendering\n // Primary cleanup happens in handleDragEnd()\n setTimeout(() => {\n this.cleanupDragImage();\n }, 100);\n\n // Return reference for storage\n return dragImage;\n }\n\n addToBoard(event) {\n event.preventDefault();\n\n const artworkId = event.currentTarget.dataset.artworkId;\n const artworkUrl = event.currentTarget.dataset.imageUrl;\n\n if (!artworkId || !artworkUrl) {\n return;\n }\n\n // Phase 4.236: Get canvas center position for proper coordinate calculation\n // The artboard controller uses clientX/clientY when available for accurate positioning.\n // Without these, it falls back to dropzone-relative coordinates which can be off.\n const canvas = document.querySelector('[data-decorator-board]');\n const canvasRect = canvas ? canvas.getBoundingClientRect() : null;\n\n // Calculate center clientX/clientY if canvas is available\n const centerClientX = canvasRect ? (canvasRect.left + canvasRect.width / 2) : null;\n const centerClientY = canvasRect ? (canvasRect.top + canvasRect.height / 2) : null;\n\n // Dispatch event for canvas to handle\n const artworkDropEvent = new CustomEvent('decorator:artwork:drop', {\n detail: {\n artworkId: artworkId,\n artworkUrl: artworkUrl,\n // Fallback values (used if clientX/clientY unavailable)\n dropX: 400, // Center of 800px canvas\n dropY: 300, // Center of 600px canvas\n // Phase 4.236: Include clientX/clientY for accurate calculation\n clientX: centerClientX,\n clientY: centerClientY,\n },\n bubbles: true,\n });\n\n document.dispatchEvent(artworkDropEvent);\n }\n\n // Refresh the grid after successful artwork upload\n refreshGrid(_event) {\n // Defense-in-depth: prevent concurrent refresh calls from causing DOM churn\n if (this.isRefreshing) return;\n this.isRefreshing = true;\n\n // Get the current tab URL from the parent decorator-artwork-tabs controller\n const artworkTabsController = this.application.getControllerForElementAndIdentifier(\n document.querySelector('[data-controller~=\"decorator-artwork-tabs\"]'),\n 'decorator-artwork-tabs',\n );\n\n if (!artworkTabsController) {\n this.isRefreshing = false;\n window.App.showErrorFlash('Failed to refresh artwork grid');\n return;\n }\n\n // Get the current active tab URL\n const currentTabUrl = artworkTabsController.getCurrentTabUrl();\n\n if (!currentTabUrl) {\n this.isRefreshing = false;\n window.App.showErrorFlash('Failed to determine current tab');\n return;\n }\n\n // Fetch the updated artwork list\n fetch(currentTabUrl, {\n headers: {\n 'X-Requested-With': 'XMLHttpRequest',\n },\n })\n .then(response => {\n if (!response.ok) {\n throw new Error(`HTTP error! status: ${response.status}`);\n }\n return response.text();\n })\n .then(html => {\n // Replace container content only (dropzone + search + grid)\n // This prevents nested controller duplication while refreshing artwork list\n this.containerTarget.innerHTML = html;\n\n window.App.showSuccessFlash('Artwork uploaded successfully');\n })\n .catch(_error => {\n window.App.showErrorFlash('Failed to refresh artwork grid');\n })\n .finally(() => {\n this.isRefreshing = false;\n });\n }\n\n // Invalidate the cached artwork items\n invalidateCache() {\n if (this.artworkItems) {\n // Clear the array reference to allow garbage collection\n this.artworkItems = null;\n }\n }\n\n // Set up observer to detect when grid content is replaced\n setupGridObserver() {\n if (!this.hasGridTarget) return;\n\n // Create a MutationObserver to watch for content changes\n this.gridObserver = new MutationObserver((mutations) => {\n // Check if child nodes were added/removed (indicating content replacement)\n const hasContentChange = mutations.some(mutation =>\n mutation.type === 'childList' &&\n (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0),\n );\n\n if (hasContentChange) {\n // Invalidate cache when content changes\n this.invalidateCache();\n }\n });\n\n // Observe the grid for child list changes\n this.gridObserver.observe(this.gridTarget, {\n childList: true,\n subtree: false, // Only watch direct children\n });\n }\n}\n", "import { Controller } from '../stimulus';\nimport CSRFTokenMixin from '../mixins/csrf_token_mixin';\n\nexport default class extends Controller {\n static targets = ['searchInput', 'grid', 'welcomeSection', 'content'];\n\n connect() {\n Object.assign(this, CSRFTokenMixin);\n this.canvasController = null;\n this.draggedArtwork = null;\n // this.logDebug('Artwork selector connected');\n }\n\n select(event) {\n const element = event.currentTarget;\n const artworkId = element.dataset.artworkId;\n const img = element.querySelector('img');\n const artworkUrl = img.dataset.artworkUrl;\n\n // Get canvas controller - Stimulus 1.1.0 compatible approach\n if (!this.canvasController) {\n const canvasElement = document.querySelector('[data-controller*=\"decorator-canvas-artboard\"]');\n if (canvasElement && canvasElement.controller) {\n this.canvasController = canvasElement.controller;\n }\n }\n\n if (!this.canvasController) {\n // this.logDebug('Canvas controller not found');\n return;\n }\n\n // Check if image is loaded\n if (!this.canvasController.currentImageId) {\n // eslint-disable-next-line no-undef\n alert('Please select a product image first');\n return;\n }\n\n // Add artwork to canvas\n this.canvasController.addArtwork({\n id: artworkId,\n url: artworkUrl,\n });\n\n // Visual feedback\n element.classList.add('artwork-added');\n setTimeout(() => {\n element.classList.remove('artwork-added');\n }, 300);\n }\n\n search(event) {\n const query = event.target.value.toLowerCase().trim();\n const items = this.gridTarget.querySelectorAll('.artwork-item');\n\n items.forEach(item => {\n const name = item.dataset.artworkName || '';\n const isVisible = query === '' || name.includes(query);\n item.style.display = isVisible ? '' : 'none';\n });\n }\n\n // Handle drag start\n handleDragStart(event) {\n const element = event.currentTarget;\n const artworkId = element.dataset.artworkId;\n const img = element.querySelector('img');\n const artworkUrl = img.dataset.artworkUrl;\n\n this.draggedArtwork = {\n id: artworkId,\n url: artworkUrl,\n };\n\n // Add dragging class for visual feedback\n element.classList.add('dragging');\n\n // Set drag effect\n event.dataTransfer.effectAllowed = 'copy';\n event.dataTransfer.setData('text/plain', artworkId);\n }\n\n // Handle drag end\n handleDragEnd(event) {\n const element = event.currentTarget;\n element.classList.remove('dragging');\n this.draggedArtwork = null;\n }\n\n // Handle successful upload from dropzone\n refreshArtworkGrid(event) {\n const { response } = event.detail;\n\n if (response && response.artwork) {\n // Add new artwork to grid\n this.addArtworkToGrid(response.artwork);\n } else {\n // Reload the entire grid\n this.reloadArtworkGrid();\n }\n }\n\n addArtworkToGrid(artwork) {\n const artworkItem = document.createElement('div');\n artworkItem.className = 'artwork-item';\n artworkItem.dataset.artworkId = artwork.id;\n artworkItem.dataset.artworkName = (artwork.name || '').toLowerCase();\n artworkItem.draggable = true;\n artworkItem.dataset.action = 'click->decorator-artwork-selector#select dragstart->decorator-artwork-selector#handleDragStart dragend->decorator-artwork-selector#handleDragEnd';\n\n const thumbDiv = document.createElement('div');\n thumbDiv.className = 'artwork-thumb';\n\n const img = document.createElement('img');\n img.src = artwork.thumb_url;\n img.alt = artwork.name;\n img.dataset.artworkUrl = artwork.url;\n img.dataset.artworkId = artwork.id;\n\n const nameDiv = document.createElement('div');\n nameDiv.className = 'artwork-name';\n nameDiv.textContent = artwork.name;\n\n thumbDiv.appendChild(img);\n artworkItem.appendChild(thumbDiv);\n artworkItem.appendChild(nameDiv);\n\n // Remove \"no artworks\" message if present\n const noArtworks = this.gridTarget.querySelector('.no-artworks');\n if (noArtworks) {\n noArtworks.remove();\n }\n\n // Add to beginning of grid\n this.gridTarget.insertBefore(artworkItem, this.gridTarget.firstChild);\n\n // Flash highlight\n artworkItem.classList.add('artwork-added');\n setTimeout(() => {\n artworkItem.classList.remove('artwork-added');\n }, 1000);\n }\n\n async reloadArtworkGrid() {\n try {\n // Use Stimulus 1.1.0 style data attributes\n const modalElement = this.element.closest('#decorator-modal');\n const saleId = modalElement?.dataset.saleId;\n const response = await fetch(`/teamsites/${saleId}/art.json`, {\n headers: this.getHeadersWithCSRF({\n 'Accept': 'application/json',\n }),\n });\n\n if (!response.ok) {\n throw new Error('Failed to reload artworks');\n }\n\n const data = await response.json();\n this.updateGrid(data.artworks);\n } catch {\n // Error reloading artwork grid - ignore\n }\n }\n\n updateGrid(artworks) {\n // Clear current grid\n this.gridTarget.textContent = '';\n\n if (artworks && artworks.length > 0) {\n artworks.forEach(artwork => {\n this.addArtworkToGrid(artwork);\n });\n } else {\n const noArtworks = document.createElement('div');\n noArtworks.className = 'no-artworks';\n noArtworks.innerHTML = '

    No artworks available.

    Drop files above or click to upload.

    ';\n this.gridTarget.appendChild(noArtworks);\n }\n }\n\n logDebug(_message, ..._args) {\n // Debug logging removed for production\n }\n}\n\n", "import { Controller } from '../stimulus';\n// Phase 1 Experiment: Testing if Stimulus 1.1.0 mutation observer auto-detects innerHTML changes\n// import StimulusReinitMixin from '../mixins/stimulus_reinit_mixin';\n\nexport default class extends Controller {\n // Phase 1 Experiment: Commented out mixin application\n // initialize() {\n // Object.assign(this, StimulusReinitMixin);\n // }\n\n connect() {\n // Initialize targets\n this.tabTargets = this.element.querySelectorAll('[data-target=\"decorator-artwork-tabs.tab\"]');\n this.tabsContainerTarget = this.element.querySelector('[data-target=\"decorator-artwork-tabs.tabsContainer\"]');\n this.contentTarget = this.element.querySelector('[data-target=\"decorator-artwork-tabs.content\"]');\n\n\n // Auto-select first tab\n if (this.tabTargets.length > 0) {\n this.tabTargets[0].click();\n }\n }\n\n select(e) {\n e.preventDefault();\n\n const tab = e.currentTarget;\n const url = tab.dataset.url;\n\n // Store the current tab URL for refresh purposes\n this.currentTabUrl = url;\n\n // Update active tab styling\n this.tabTargets.forEach(t => t.classList.remove('active-tab'));\n tab.classList.add('active-tab');\n\n // Show loading state\n this.showLoading();\n\n // Get CSRF token from meta tag\n const csrfToken = document.querySelector('meta[name=\"csrf-token\"]')?.content;\n\n // Fetch content\n fetch(url, {\n headers: {\n 'Accept': 'text/html',\n 'X-Requested-With': 'XMLHttpRequest',\n 'X-CSRF-Token': csrfToken || '',\n },\n })\n .then(response => response.text())\n .then(html => {\n this.showContent(html);\n })\n .catch(() => {\n // Error loading artworks\n this.hideLoading();\n });\n }\n\n showLoading() {\n // Clear content safely without innerHTML\n while (this.contentTarget.firstChild) {\n this.contentTarget.removeChild(this.contentTarget.firstChild);\n }\n\n // Create loading placeholder element\n const loadingDiv = document.createElement('div');\n loadingDiv.style.height = '100%';\n this.contentTarget.appendChild(loadingDiv);\n\n this.contentTarget.classList.add('is-loading');\n }\n\n hideLoading() {\n this.contentTarget.classList.remove('is-loading');\n }\n\n showContent(html) {\n this.hideLoading();\n\n // Using innerHTML here is safe because the HTML comes from our own Rails backend\n // which sanitizes content through Rails' built-in XSS protection\n this.contentTarget.innerHTML = html;\n\n // Phase 1 Experiment: Testing if Stimulus mutation observer auto-detects innerHTML changes\n // Stimulus 1.1.0 configures MutationObserver with childList: true, subtree: true\n // which should automatically detect and initialize controllers in new DOM content\n // this.reinitializeStimulusControllers(this.contentTarget);\n }\n\n // Get the current active tab URL (used for grid refresh after upload)\n getCurrentTabUrl() {\n return this.currentTabUrl;\n }\n}\n", "/**\n * Represents an art placement element on the canvas.\n * Based on the baseline implementation but adapted for Stimulus and modern JavaScript.\n */\nexport default class ArtPlacement {\n /**\n * Constructor for the ArtPlacement object.\n *\n * @param {object} options - The options for the constructor.\n * @param {HTMLElement} options.template - The template element for cloning.\n * @param {string} options.id - The id for the placement.\n * @param {object} options.data - The placement data.\n * @param {HTMLElement} options.existingElement - Phase 4.85: Optional existing DOM element to wrap\n * instead of cloning a new one. Used during DOM reconstruction to prevent duplicates.\n */\n constructor({ template = null, id = null, data = {}, existingElement = null } = {}) {\n this.template = template;\n this.id = id;\n this.data = data;\n // Phase 4.85: Use existing element if provided (from DOM reconstruction)\n // This prevents creating a duplicate cloned element when syncing Map with DOM\n this.element = existingElement || this.createElement();\n }\n\n /**\n * Clones the template element and assigns data to it.\n * @returns {HTMLElement} The cloned and modified template element.\n */\n createElement() {\n if (!this.template || !this.template.content) {\n // No valid template found\n return this.createFallbackElement();\n }\n\n // Clone the template content\n const element = this.template.content.cloneNode(true).firstElementChild;\n\n // Set unique ID\n element.id = `placement-${this.id}`;\n\n // Set data attributes for compatibility with template controller\n element.dataset.artPlacementId = this.id;\n element.dataset.artworkId = this.data.artworkId;\n element.dataset.artwork = 'true';\n\n // Create the artwork display structure dynamically matching baseline\n // Create rotable-container first\n const rotableContainer = document.createElement('div');\n rotableContainer.className = 'rotable-container';\n\n // Create dz-details and put it INSIDE rotable-container (baseline structure)\n const detailsDiv = document.createElement('div');\n detailsDiv.className = 'dz-details';\n\n const artworkDiv = document.createElement('div');\n artworkDiv.className = 'dz-artwork';\n detailsDiv.appendChild(artworkDiv);\n\n // Add dz-details to rotable-container\n rotableContainer.appendChild(detailsDiv);\n\n // Insert rotable-container after resize controls if they exist\n const resizeControls = element.querySelector('.resizable');\n if (resizeControls && resizeControls.nextSibling) {\n element.insertBefore(rotableContainer, resizeControls.nextSibling);\n } else {\n // If no resize controls, insert at the beginning\n element.insertBefore(rotableContainer, element.firstChild);\n }\n\n // Move rotate controls inside rotable-container (after dz-details)\n const rotateControls = element.querySelector('.rotable');\n if (rotateControls) {\n rotableContainer.appendChild(rotateControls);\n }\n\n // Set dimensions and positioning matching baseline behavior\n // Only set width/height as inline styles, let CSS handle position: absolute\n element.style.width = `${this.data.width}px`;\n element.style.height = `${this.data.height}px`;\n element.style.left = `${this.data.left}px`;\n element.style.top = `${this.data.top}px`;\n\n // Phase 4.274: Log CSS values set for fresh drops\n // The bug: Forward transform should produce ~438px but visual shows ~629px\n // This log verifies what CSS value is actually being set\n\n // Ensure pointer events are enabled for interaction\n element.style.pointerEvents = 'auto';\n\n\n // Apply transformations\n this.applyTransformations(element);\n\n // Set the artwork image\n this.setArtworkImage(element);\n\n // Set up more info button if present\n const moreInfoBtn = element.querySelector('[data-more-info]');\n if (moreInfoBtn) {\n moreInfoBtn.dataset.artworkId = this.data.artworkId;\n }\n\n return element;\n }\n\n /**\n * Creates a fallback element when template is not available.\n * @returns {HTMLElement} A simple div element.\n */\n createFallbackElement() {\n const element = document.createElement('div');\n element.id = `placement-${this.id}`;\n element.className = 'art-placement dz-preview dz-file-preview';\n element.dataset.artwork = 'true';\n element.dataset.artPlacementId = this.id;\n element.dataset.artworkId = this.data.artworkId;\n\n // Create the rotable-container to match baseline structure\n const rotableContainer = document.createElement('div');\n rotableContainer.className = 'rotable-container';\n\n // Create the dz-details inside rotable-container\n const detailsDiv = document.createElement('div');\n detailsDiv.className = 'dz-details';\n\n // Create the dz-artwork div inside dz-details\n const artworkDiv = document.createElement('div');\n artworkDiv.className = 'dz-artwork';\n artworkDiv.style.backgroundImage = `url('${this.data.imageUrl}')`;\n\n // Build the structure: rotable-container > dz-details > dz-artwork\n detailsDiv.appendChild(artworkDiv);\n rotableContainer.appendChild(detailsDiv);\n element.appendChild(rotableContainer);\n\n // Ensure pointer events are enabled for interaction\n element.style.pointerEvents = 'auto';\n\n return element;\n }\n\n /**\n * Sets the artwork image in the placement element.\n * @param {HTMLElement} element - The placement element.\n */\n setArtworkImage(element) {\n if (!this.data.imageUrl) {\n return;\n }\n\n // Find the .dz-artwork div - it's now at: .dz-details > .dz-artwork\n const artworkDiv = element.querySelector('.dz-details .dz-artwork');\n if (artworkDiv) {\n // Use setProperty to avoid quote escaping issues\n artworkDiv.style.setProperty('background-image', `url('${this.data.imageUrl}')`);\n // DO NOT set z-index - parent containers handle stacking properly\n\n // Explicitly set background properties to ensure they're applied\n artworkDiv.style.backgroundSize = 'contain';\n artworkDiv.style.backgroundPosition = 'center';\n artworkDiv.style.backgroundRepeat = 'no-repeat';\n }\n }\n\n /**\n * Applies transformations to the element.\n * @param {HTMLElement} element - The element to transform.\n */\n applyTransformations(element) {\n // Apply rotation to internal elements only, matching baseline behavior\n // Main element should NOT have transform applied\n if (this.data.rotation && this.data.rotation !== 0) {\n const rotationString = `rotate(${this.data.rotation}deg)`;\n const resizable = element.querySelector('.resizable');\n const rotableContainer = element.querySelector('.rotable-container');\n\n if (resizable) {\n resizable.style.transform = rotationString;\n }\n\n if (rotableContainer) {\n rotableContainer.style.transform = rotationString;\n }\n }\n\n // Apply mirroring to the artwork element itself\n const artworkElement = element.querySelector('.dz-artwork');\n if (artworkElement && (this.data.flipX || this.data.flipY)) {\n const transforms = [];\n\n if (this.data.flipX) {\n transforms.push('scaleX(-1)');\n }\n\n if (this.data.flipY) {\n transforms.push('scaleY(-1)');\n }\n\n artworkElement.style.transform = transforms.join(' ');\n }\n }\n\n /**\n * Get the DOM element for this placement.\n * @returns {HTMLElement} The placement element.\n */\n getElement() {\n return this.element;\n }\n\n /**\n * Remove the element from the DOM.\n */\n detach() {\n if (this.element && this.element.parentNode) {\n this.element.remove();\n }\n }\n\n /**\n * Update the placement data and re-apply styling.\n * @param {object} newData - New placement data.\n */\n updateData(newData) {\n this.data = { ...this.data, ...newData };\n\n // Re-apply positioning and transformations\n this.element.style.left = `${this.data.left}px`;\n this.element.style.top = `${this.data.top}px`;\n this.element.style.width = `${this.data.width}px`;\n this.element.style.height = `${this.data.height}px`;\n\n this.applyTransformations(this.element);\n }\n}\n", "/**\n * ArtPlacementParser - Baseline-compatible coordinate conversion\n *\n * This replicates the exact logic from the baseline implementation\n * to ensure coordinates are converted correctly.\n */\nexport default class ArtPlacementParser {\n /**\n * Phase 4.161: Accept explicit imageDimensions to fix dimension mismatch\n * The issue: productImage.width returns CSS-rendered size (440\u00D7768)\n * but ImageCanvas.imageEl.width returns natural size (1000\u00D71250).\n * This caused isVerticalFit to differ between forward/inverse transforms.\n * Fix: ImageCanvas passes explicit dimensions that match what it uses.\n *\n * Phase 4.303: Accept dropTimeBgStart for coordinate consistency on reload\n * The issue: When reloading existing placements, board dimensions may differ\n * from drop-time (e.g., modal with sidebar vs full-page layout).\n * This causes bgStart to be recalculated with wrong values, shifting coordinates.\n * Fix: Use drop-time bgStart for existing placements to ensure round-trip consistency.\n */\n constructor({ productImage, board, imageDimensions, dropTimeBgStart, placementId } = {}) {\n this.productImage = productImage;\n this.board = board;\n this.imageDimensions = imageDimensions;\n // Phase 4.303: Store drop-time bgStart for use in backgroundImageStartXY()\n this.dropTimeBgStart = dropTimeBgStart;\n // Phase 4.309: Store placementId for logging correlation\n this.placementId = placementId;\n\n // Phase 4.316: Verify parser received correct bgStart\n\n // Cache values\n this._scalingFactor = null;\n this._backgroundImageStart = null;\n this._productImageBoardRatio = null;\n this._isVerticalFit = null;\n }\n\n /**\n * Parse placement data using baseline logic\n * @param {Object} data - Placement data with percentage coordinates\n *\n * Phase 0.99.64: Removed skipBgOffset option - bgStart is ALWAYS needed for visual display\n * The inverse transforms (reconstructMapFromDOM, handlePlacementUpdate) ALWAYS subtract bgStart\n * to produce pure image-relative percentages for saving. This ensures round-trip consistency.\n */\n parse(data = {}) {\n const adjustedData = Object.assign({}, data);\n\n // Convert to camelCase (from backend format)\n adjustedData.translateX = data.translate_x || 0;\n adjustedData.translateY = data.translate_y || 0;\n // Ensure we capture the imageUrl from any possible property\n adjustedData.imageUrl = data.imageUrl || data.image_url || data.artworkUrl || data.artworkURL;\n adjustedData.artworkId = data.artworkId || data.artwork_id;\n adjustedData.mirrorHorizontally = data.mirrorHorizontally || data.mirror_horizontally || false;\n adjustedData.mirrorVertically = data.mirrorVertically || data.mirror_vertically || false;\n\n const isVertical = this.isVerticalFit();\n // Phase 4.189: Use orientation-independent bgStart with both X and Y components\n const bgStart = this.backgroundImageStartXY();\n const ratio = this.productImageBoardRatio();\n const imageHeight = this.productImageHeight();\n\n // CRITICAL FIX: Input coordinates are percentages of ACTUAL image height (0-100)\n // Backend sends percentages relative to actual image dimensions\n // We need to convert to pixels then apply scaling for display\n\n // Convert percentage dimensions to pixels using actual image height\n // Then apply scaling ratio for display\n adjustedData.height = ((data.height / 100) * imageHeight) * ratio;\n adjustedData.width = ((data.width / 100) * imageHeight) * ratio;\n\n // Convert percentage positions to pixels using actual image height\n // Then apply scaling ratio and background offset\n // Phase 0.99.64: ALWAYS add bgStart for visual positioning on the board\n // The bgStart offset positions the artwork relative to where the product image\n // actually starts on the board (accounting for centering whitespace)\n\n // Phase 4.156: Log forward transform values for comparison with inverse transform\n // Phase 4.164: Added placementId to identify RELOAD vs fresh interaction\n // - Placement ID starts with 'bulk_' = restored placement during product switch\n // - Placement ID starts with 'new_' = fresh drop\n // - Other ID formats = existing placement loaded from backend (RELOAD scenario)\n // Phase 4.189: Add BOTH bgStart components (one is ~0 based on fit direction)\n // This makes the calculation orientation-independent, fixing coordinate shift\n // caused by isVerticalFit() returning different values during drop session.\n adjustedData.left = (((data.left / 100) * imageHeight) * ratio) + bgStart.x;\n adjustedData.top = (((data.top / 100) * imageHeight) * ratio) + bgStart.y;\n\n // Phase 4.273: Log PARSER calculation to trace forward transform\n // The bug: calculated left should be ~438px but display shows ~629px\n // This traces each step to find the discrepancy\n\n // Phase 4.275: Log PARSER calculation for TOP coordinate (isVerticalFit=false case)\n // When isVerticalFit=false, bgStart.y > 0 and TOP coordinate shift occurs\n // This traces each step to compare with inverse transform\n\n // Phase 4.308: COMPLETE forward transform trace for debugging Y-coordinate shift\n // This traces EVERY parameter to identify mismatch between forward and inverse transforms.\n // The issue: artwork appears HIGHER in baked images than on canvas preview.\n // If baking is correct (Y=211px = 27.43% \u00D7 768), then canvas display must be wrong.\n\n return adjustedData;\n }\n\n /**\n * Calculate scaling factor (baseline logic)\n * NOTE: This method is preserved for compatibility but no longer used in parse()\n * The parse() method now correctly handles percentage-to-pixel conversion\n */\n scalingFactor() {\n if (this._scalingFactor != null) return this._scalingFactor;\n\n const factor = this.isVerticalFit() ?\n this.boardHeight() :\n (this.productImageHeight() * this.productImageBoardRatio());\n\n return this._scalingFactor = factor;\n }\n\n /**\n * Calculate background image start position (baseline logic)\n * @deprecated Use backgroundImageStartXY() for orientation-independent handling\n */\n backgroundImageStart() {\n if (this._backgroundImageStart != null) return this._backgroundImageStart;\n\n if (this.isVerticalFit()) {\n const adjustedProductImageWidth = this.productImageWidth() * this.productImageBoardRatio();\n this._backgroundImageStart = (this.boardWidth() - adjustedProductImageWidth) / 2;\n } else {\n const adjustedProductImageHeight = this.productImageHeight() * this.productImageBoardRatio();\n this._backgroundImageStart = (this.boardHeight() - adjustedProductImageHeight) / 2;\n }\n\n return this._backgroundImageStart;\n }\n\n /**\n * Phase 4.189: Orientation-independent background image start position\n * Returns BOTH x and y offsets. One will be ~0 based on fit direction:\n * - Vertical fit: x = centering offset, y \u2248 0\n * - Horizontal fit: x \u2248 0, y = centering offset\n *\n * This allows the same bgStart values to be used regardless of orientation,\n * fixing the coordinate shift bug caused by orientation changes during drop session.\n *\n * Phase 4.303: Use drop-time bgStart when available for reload consistency.\n * When reloading existing placements, board dimensions may differ from drop-time\n * (e.g., modal with sidebar vs full-page layout), causing bgStart to be\n * recalculated incorrectly. Using the original drop-time values ensures\n * round-trip consistency: drop \u2192 save \u2192 reload produces same visual position.\n */\n backgroundImageStartXY() {\n if (this._backgroundImageStartXY != null) return this._backgroundImageStartXY;\n\n // Phase 4.303: Use drop-time bgStart if available for reload consistency\n // This ensures existing placements maintain their original position\n // regardless of current board dimensions\n if (this.dropTimeBgStart &&\n typeof this.dropTimeBgStart.x === 'number' &&\n typeof this.dropTimeBgStart.y === 'number') {\n this._backgroundImageStartXY = {\n x: this.dropTimeBgStart.x,\n y: this.dropTimeBgStart.y,\n };\n return this._backgroundImageStartXY;\n }\n\n // Fall back to calculating from current board dimensions (for fresh drops)\n const ratio = this.productImageBoardRatio();\n const adjustedProductImageWidth = this.productImageWidth() * ratio;\n const adjustedProductImageHeight = this.productImageHeight() * ratio;\n\n // Phase 4.283: Return ACTUAL letterbox/pillarbox offsets (always non-negative)\n // Use Math.max(0, ...) to ensure we never return negative offsets.\n // When scaled dimension exceeds board dimension, offset becomes 0 (no letterbox/pillarbox).\n // This matches the fix in decorator_canvas_image_canvas.js getBackgroundImageStart().\n this._backgroundImageStartXY = {\n x: Math.max(0, (this.boardWidth() - adjustedProductImageWidth) / 2),\n y: Math.max(0, (this.boardHeight() - adjustedProductImageHeight) / 2),\n };\n\n return this._backgroundImageStartXY;\n }\n\n /**\n * Calculate product image to board ratio (baseline logic)\n */\n productImageBoardRatio() {\n if (this._productImageBoardRatio != null) return this._productImageBoardRatio;\n\n return this._productImageBoardRatio = this.isVerticalFit() ?\n (this.boardHeight() / this.productImageHeight()) :\n (this.boardWidth() / this.productImageWidth());\n }\n\n /**\n * Determine if image fits vertically (baseline logic)\n */\n isVerticalFit() {\n if (this._isVerticalFit != null) return this._isVerticalFit;\n\n const productImageRatio = (this.productImageHeight() / this.productImageWidth());\n const boardRatio = (this.boardHeight() / this.boardWidth());\n\n return this._isVerticalFit = productImageRatio > boardRatio;\n }\n\n // Board dimensions\n boardWidth() {\n return this.board ? this.board.clientWidth : 800;\n }\n\n boardHeight() {\n return this.board ? this.board.clientHeight : 600;\n }\n\n // Product image dimensions\n // Phase 4.161: Use explicit imageDimensions when provided to ensure\n // forward transform uses same dimensions as inverse transform (ImageCanvas)\n productImageWidth() {\n if (this.imageDimensions?.width) {\n return this.imageDimensions.width;\n }\n // Phase 4.305: Use naturalWidth to match inverse transform in handleArtworkDrop\n // The inverse uses: imageEl.naturalWidth || imageEl.width\n // We must use the same source of truth for symmetric transforms\n return this.productImage\n ? (this.productImage.naturalWidth || this.productImage.width)\n : 0;\n }\n\n productImageHeight() {\n if (this.imageDimensions?.height) {\n return this.imageDimensions.height;\n }\n // Phase 4.305: Use naturalHeight to match inverse transform in handleArtworkDrop\n // The inverse uses: imageEl.naturalHeight || imageEl.height\n // We must use the same source of truth for symmetric transforms\n return this.productImage\n ? (this.productImage.naturalHeight || this.productImage.height)\n : 0;\n }\n}\n", "/* eslint-disable no-console */\nimport ArtPlacement from './decorator_canvas_art_placement';\nimport ArtPlacementParser from './decorator_canvas_art_placement_parser';\n\n/**\n * ImageCanvas - Canvas State Management Library\n *\n * Manages the state of a single product image canvas including all artwork\n * placements on that image. Each product orientation has its own ImageCanvas instance.\n * Based on the baseline implementation but adapted for Stimulus.\n *\n * Usage:\n * ```javascript\n * const canvas = new ImageCanvas({\n * id: 123,\n * url: '/path/to/product/image.jpg'\n * });\n *\n * // Load the image\n * canvas.load((loadedCanvas) => {\n * // Add to DOM\n * container.appendChild(loadedCanvas.render());\n * });\n *\n * // Add artwork\n * canvas.addArtPlacement({\n * id: 'placement_1',\n * artworkId: 456,\n * artworkUrl: '/path/to/artwork.png',\n * left: 100,\n * top: 100,\n * width: 150,\n * height: 150,\n * rotation: 0,\n * flipX: false,\n * flipY: false\n * });\n * ```\n *\n * Properties:\n * - imageId: Product image ID\n * - imageUrl: Product image URL\n * - artPlacements: Map of artwork placement ID to ArtPlacement objects\n * - container: DOM container element\n * - isLoaded: Whether product image has loaded\n *\n * Methods:\n * - load(callback): Loads the product image\n * - addArtPlacement(placement): Adds artwork to canvas\n * - removeArtPlacement(id): Removes artwork from canvas\n * - getArtPlacements(): Returns array of placement data for backend\n *\n * Coordinate System:\n * - Backend coordinates are % of image height\n * - Frontend uses absolute pixel coordinates\n * - Conversion happens in this class\n */\nexport default class ImageCanvas {\n constructor(imageData) {\n // Phase 0.99.27: Add unique instance ID for tracking canvas identity\n this.__instanceId = `canvas_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;\n\n this.imageId = imageData.id;\n this.imageUrl = imageData.url;\n this.imageEl = null;\n this.artPlacements = new Map();\n this.container = null;\n this.isLoaded = false;\n // Phase 4.373: boardDimensions is now a getter (see below) - no initialization needed\n // The getter always reads live from DOM, eliminating stale cache issues\n this.pendingPlacements = []; // Queue for placements added before image loads\n this.recoveryAttempts = new Set(); // Track placements that have had recovery attempted\n this.recoveryTimestamps = new Map(); // Track when recovery was last attempted for cooldown\n this.retryAttempts = new Map(); // Track retry attempts per placement\n this.maxRetryAttempts = 3; // Maximum number of retry attempts\n this.retryTimer = null; // Single timer for retry processing (prevents duplicate timeouts)\n\n // Phase 0.99.27: Log canvas instance creation\n }\n\n load(callback) {\n if (this.isLoaded && callback) {\n callback(this);\n return;\n }\n\n // eslint-disable-next-line no-undef\n const img = new Image();\n img.onload = () => {\n this.imageEl = img;\n this.isLoaded = true;\n\n // Process any pending placements now that image is loaded\n if (this.pendingPlacements.length > 0) {\n const pending = [...this.pendingPlacements];\n this.pendingPlacements = [];\n pending.forEach(placement => this.addArtPlacement(placement));\n }\n\n if (callback) {\n callback(this);\n }\n };\n img.onerror = () => {\n };\n img.src = this.imageUrl;\n }\n\n setContainer(container) {\n this.container = container;\n // Get board reference from container (for boardDimensions getter)\n if (container) {\n const board = container.closest('[data-decorator-board]') || container.parentElement;\n if (board) {\n this.board = board;\n // Phase 4.373: Removed cache assignment here\n // boardDimensions is now a getter that reads live from DOM\n // This eliminates stale cached dimensions that caused coordinate shift\n }\n }\n // Also store reference to template for ArtPlacement creation\n this.template = document.querySelector('[data-decorator-template]');\n\n // If image is already loaded and we have pending placements, process them now\n if (this.isLoaded && this.pendingPlacements.length > 0) {\n const pending = [...this.pendingPlacements];\n this.pendingPlacements = [];\n pending.forEach(placement => this.addArtPlacement(placement));\n }\n }\n\n // Phase 4.373: Single source of truth for board dimensions\n // ALWAYS reads live from DOM - eliminates stale cache issues\n // This getter replaces the cached this.boardDimensions property that required\n // manual sync points (Phase 4.181, 4.182, 4.184, 4.293) to stay current.\n // DOM reads are fast (~0.01ms) and this eliminates an entire class of bugs.\n get boardDimensions() {\n if (!this.board) return { width: 0, height: 0 };\n return {\n width: this.board.clientWidth,\n height: this.board.clientHeight,\n };\n }\n\n // Calculate if image fits vertically (taller aspect ratio than board)\n isVerticalFit() {\n if (!this.imageEl || !this.boardDimensions) return false;\n // Phase 4.173: Use natural dimensions to match forward transform (ArtPlacementParser)\n // CSS dimensions (imageEl.width/height) can differ from natural dimensions when\n // the image is scaled to fit the container, causing isVerticalFit to return\n // different values in forward vs inverse transforms.\n const imageWidth = this.imageEl.naturalWidth || this.imageEl.width;\n const imageHeight = this.imageEl.naturalHeight || this.imageEl.height;\n const imageRatio = imageHeight / imageWidth;\n const boardRatio = this.boardDimensions.height / this.boardDimensions.width;\n return imageRatio > boardRatio;\n }\n\n // Calculate the scaling factor for the image\n getScalingFactor() {\n if (!this.imageEl || !this.boardDimensions) return 1;\n\n // Phase 4.173: Use natural dimensions to match forward transform (ArtPlacementParser)\n const imageWidth = this.imageEl.naturalWidth || this.imageEl.width;\n const imageHeight = this.imageEl.naturalHeight || this.imageEl.height;\n\n // Ensure image has valid dimensions\n if (!imageWidth || !imageHeight || imageWidth === 0 || imageHeight === 0) {\n return 1;\n }\n\n // Ensure board has valid dimensions\n if (!this.boardDimensions.width || !this.boardDimensions.height ||\n this.boardDimensions.width === 0 || this.boardDimensions.height === 0) {\n return 1;\n }\n\n if (this.isVerticalFit()) {\n // Image is constrained by height\n return this.boardDimensions.height / imageHeight;\n } else {\n // Image is constrained by width\n return this.boardDimensions.width / imageWidth;\n }\n }\n\n // Calculate the offset for centered image\n getBackgroundImageStart() {\n if (!this.imageEl || !this.boardDimensions) return { x: 0, y: 0 };\n\n // Phase 4.173: Use natural dimensions to match forward transform (ArtPlacementParser)\n const imageWidth = this.imageEl.naturalWidth || this.imageEl.width;\n const imageHeight = this.imageEl.naturalHeight || this.imageEl.height;\n\n const scale = this.getScalingFactor();\n const scaledWidth = imageWidth * scale;\n const scaledHeight = imageHeight * scale;\n\n // Phase 4.283: Return ACTUAL letterbox/pillarbox offsets (always non-negative)\n // Use Math.max(0, ...) to ensure we never return negative offsets.\n // When scaled dimension exceeds board dimension, offset becomes 0 (no letterbox/pillarbox).\n return {\n x: Math.max(0, (this.boardDimensions.width - scaledWidth) / 2),\n y: Math.max(0, (this.boardDimensions.height - scaledHeight) / 2),\n };\n }\n\n // Convert coordinates using baseline-compatible parser\n // @param {Object} placement - Placement data with percentage coordinates\n //\n // Phase 0.99.64: Removed skipBgOffset option - parser ALWAYS adds bgStart for visual display\n // The inverse transforms (reconstructMapFromDOM, handlePlacementUpdate) ALWAYS subtract bgStart\n // to produce pure image-relative percentages for saving. This ensures round-trip consistency.\n //\n // Phase 4.340: Server-Side Only Transforms\n // When placement.serverCssPosition is present, use it directly instead of client-side calculation.\n // This eliminates parameter mismatches between client/server and makes server the single source of truth.\n convertCoordinates(placement) {\n // Phase 4.376: Validate server CSS position against current board dimensions\n // The server calculates CSS position based on board dimensions sent with the request.\n // If those dimensions don't match current (e.g., modal animation in progress, different\n // layout context), the server position will be wrong. Fall back to client calculation.\n if (placement.serverCssPosition && placement.serverComputed) {\n const css = placement.serverCssPosition;\n const serverBoardHeight = placement.serverComputed.boardHeight;\n const serverBoardWidth = placement.serverComputed.boardWidth;\n const currentBoardHeight = this.boardDimensions.height;\n const currentBoardWidth = this.boardDimensions.width;\n\n // Check if dimensions match within tolerance (5px accounts for rounding)\n const heightMatches = Math.abs(serverBoardHeight - currentBoardHeight) < 5;\n const widthMatches = Math.abs(serverBoardWidth - currentBoardWidth) < 5;\n\n if (heightMatches && widthMatches) {\n\n // Return converted placement with server-computed CSS pixels\n return {\n ...placement,\n left: css.left,\n top: css.top,\n width: css.width,\n height: css.height,\n // Preserve imageUrl for artwork rendering\n imageUrl: placement.artworkUrl || placement.imageUrl,\n // Mark as server-transformed for debugging\n transformSource: 'server',\n };\n } else {\n // Fall through to client-side calculation below\n }\n } else if (placement.serverCssPosition) {\n // serverCssPosition exists but serverComputed is missing - can't validate dimensions\n // Fall through to client-side calculation below\n }\n\n // Fallback to client-side transform (for backward compatibility and fresh drops)\n if (!this.imageEl || !this.board) {\n console.warn('[Decorator] Cannot convert coordinates: missing image or board reference', {\n hasImage: !!this.imageEl,\n hasBoard: !!this.board,\n placementId: placement.id,\n });\n return null; // Return null to signal conversion failure\n }\n\n // Validate board has non-zero dimensions (must be laid out)\n const boardWidth = this.board.clientWidth || 0;\n const boardHeight = this.board.clientHeight || 0;\n\n if (boardWidth === 0 || boardHeight === 0) {\n console.warn('[Decorator] Cannot convert coordinates: board has zero dimensions', {\n boardWidth,\n boardHeight,\n placementId: placement.id,\n });\n return null; // Return null to signal board not ready\n }\n\n // Phase 4.373: Removed Phase 4.181 sync code\n // boardDimensions is now a getter that always reads live from DOM\n // No sync needed - getter eliminates stale cache issues entirely\n\n // Create parser with baseline logic\n // Phase 4.161: Pass explicit imageDimensions to ensure forward transform\n // uses same dimensions as inverse transform (handlePlacementUpdate)\n // This fixes coordinate shift caused by productImage.width returning\n // CSS-rendered size instead of natural image size.\n // Phase 4.168: CRITICAL FIX - Use naturalWidth/naturalHeight, NOT width/height\n // The imageEl.width property returns CSS-rendered size (e.g., 440\u00D7768)\n // but we need the actual image dimensions (e.g., 1000\u00D71250) to calculate\n // isVerticalFit() correctly and apply bgStart to the correct axis.\n //\n // Phase 4.351: Reverted Phase 4.350 (broken logical height formula)\n // Phase 4.350 used: boardDimensions.height / scalingFactor as fallback\n // PROBLEM: This only works for VERTICAL FIT, breaks HORIZONTAL FIT\n // - Horizontal fit example: board=860x1627, scalingFactor=0.86, naturalHeight=1258\n // Phase 4.350 fallback: 1627 / 0.86 = 1891.86 (WRONG!)\n // Correct: naturalHeight = 1258\n // Fix: Revert to naturalHeight fallback which works for both fit types\n const storedImageHeight = placement.dropTimeCoordinates?.actualImageHeight;\n const imageHeight = storedImageHeight || this.imageEl.naturalHeight || this.imageEl.height;\n\n // Phase 4.316: Diagnostic - trace bgStart through the conversion chain\n\n // Phase 4.400: Only ignore dropTimeBgStart when contexts DIFFER\n // Phase 4.376 incorrectly ignored ALL stored bgStart when in modal context,\n // but bulk mode drops ALSO happen in modal, so stored bgStart IS correct there.\n // Fix: Track drop-time context and only ignore when contexts differ.\n // Note: Check if closest method exists for test compatibility (mocked boards may not have it)\n const isModalContext = typeof this.board?.closest === 'function'\n && this.board.closest('#decorator-modal');\n const dropWasInModal = placement.dropTimeCoordinates?.isModalContext;\n\n // Use stored bgStart if BOTH contexts match (modal-modal or non-modal-non-modal)\n // OR if dropWasInModal is undefined (legacy placements without isModalContext flag)\n const contextMatch = dropWasInModal === undefined || isModalContext === dropWasInModal;\n const dropTimeBgStart = contextMatch ? placement.dropTimeCoordinates?.bgStart : null;\n\n const parser = new ArtPlacementParser({\n productImage: this.imageEl,\n board: this.board,\n imageDimensions: {\n width: this.imageEl.naturalWidth || this.imageEl.width,\n height: imageHeight,\n },\n // Phase 4.303: Use drop-time bgStart for existing placements\n dropTimeBgStart: dropTimeBgStart ? {\n x: dropTimeBgStart.x,\n y: dropTimeBgStart.y,\n } : null,\n // Phase 4.309: Pass placementId for logging correlation\n placementId: placement.id,\n });\n\n // Phase 4.249: Log FORWARD-TRANSFORM context for comparison with DROP-TIME context\n // If these values differ from [Phase 4.249] DROP-TIME logs, that explains the shift.\n // Key hypothesis: bulk mode drops with different boardDimensions than display context.\n\n // Phase 4.251: Dispatch debug event for visual debug panel\n document.dispatchEvent(new CustomEvent('decorator:debug:transform', {\n detail: {\n placementId: placement.id,\n inputTop: placement.top,\n inputLeft: placement.left,\n boardDimensions: this.boardDimensions,\n liveBoardDimensions: {\n width: boardWidth,\n height: boardHeight,\n },\n bgStart: parser.backgroundImageStartXY?.(),\n scalingFactor: parser.productImageBoardRatio?.(),\n isVerticalFit: parser.isVerticalFit?.(),\n imageNaturalHeight: this.imageEl.naturalHeight,\n imageNaturalWidth: this.imageEl.naturalWidth,\n },\n }));\n\n // Ensure imageUrl is preserved - copy it explicitly\n const placementData = {\n ...placement,\n imageUrl: placement.artworkUrl || placement.imageUrl,\n };\n\n // Use baseline parsing logic - parser ALWAYS adds bgStart for visual positioning\n const converted = parser.parse(placementData);\n\n // Phase 4.273: Log FORWARD TRANSFORM OUTPUT to compare with containerPixelLeft\n // The bug: containerPixelLeft shows ~629px but forward transform should produce ~438px\n // This log reveals if the discrepancy is in the transform or in the display calculation\n // Phase 4.295: Updated to use calculated imageHeight (may be from stored value)\n\n // Validate converted coordinates are reasonable\n if (converted.width === 0 || converted.height === 0) {\n console.warn('[Decorator] Converted coordinates have zero dimensions', {\n converted,\n original: placement,\n });\n return null; // Don't create invisible artworks\n }\n\n return converted;\n }\n\n addArtPlacement(placement, options = {}) {\n // Phase 0.99.26: Log entry point to diagnose why Map is empty at initialization\n // Phase 4.80: options.fromDomReconstruction skips DOM check when syncing Map with existing DOM elements\n\n // Check for existing placement with same ID in memory\n if (this.artPlacements.has(placement.id)) {\n // Placement already exists in map\n return;\n }\n\n // Defensive check: Also verify placement doesn't exist in DOM\n // This prevents duplicates if artPlacements map gets out of sync with DOM\n // Phase 4.80: Skip this check when reconstructing from DOM (element already exists, that's expected)\n if (this.container && !options.fromDomReconstruction) {\n const existingElement = this.container.querySelector(`[data-art-placement-id=\"${placement.id}\"]`);\n if (existingElement) {\n console.warn('[Decorator] Placement exists in DOM but not in map, skipping duplicate', {\n placementId: placement.id,\n });\n return;\n }\n }\n\n // Phase 4.416: Time-windowed artwork-level deduplication\n // Different placement IDs (new_* vs bulk_*) for the same artwork bypass ID-based guards above.\n // Block if same artworkId was added to this canvas within 2 seconds.\n // Time-windowed (2s) rather than absolute \u2014 allows intentional duplicate placement of same\n // artwork in separate actions.\n if (placement.artworkId && !options.fromDomReconstruction) {\n const existingForArtwork = Array.from(this.artPlacements.values()).find(ap => {\n const apArtworkId = ap.originalData?.artworkId || ap.data?.artworkId;\n return String(apArtworkId) === String(placement.artworkId);\n });\n if (existingForArtwork) {\n const apTimestamp = existingForArtwork.originalData?.dropTimeCoordinates?.timestamp || 0;\n if ((Date.now() - apTimestamp) < 2000) {\n console.warn('[Phase 4.416] Duplicate artwork blocked (same artworkId within 2s):', {\n existingId: existingForArtwork.id, newId: placement.id, artworkId: placement.artworkId,\n });\n return;\n }\n }\n\n // DOM fallback: check for artwork element not yet in Map\n if (this.container) {\n const existingEl = this.container.querySelector(`[data-artwork-id=\"${placement.artworkId}\"]`);\n if (existingEl && existingEl.dataset.artPlacementId !== placement.id) {\n console.warn('[Phase 4.416] Duplicate artwork in DOM blocked:', {\n existingPlacementId: existingEl.dataset.artPlacementId, newId: placement.id,\n });\n return;\n }\n }\n }\n\n // If image not loaded yet, queue this placement\n if (!this.isLoaded) {\n this.pendingPlacements.push(placement);\n return;\n }\n\n // Phase 4.316: Verify dropTimeCoordinates before convertCoordinates\n\n // Convert coordinates from % of image height to pixels\n // Phase 0.99.64: Parser always adds bgStart - no skipBgOffset needed\n const convertedPlacement = this.convertCoordinates(placement);\n\n // Phase 0.99.26: Log coordinate conversion result to diagnose filtering\n\n // If conversion failed (null returned), queue for retry\n if (!convertedPlacement) {\n // Track retry attempts for this placement\n const currentAttempts = this.retryAttempts.get(placement.id) || 0;\n\n if (currentAttempts >= this.maxRetryAttempts) {\n console.error('[Decorator] Max retry attempts reached for placement, giving up', {\n placementId: placement.id,\n attempts: currentAttempts,\n hasBoard: !!this.board,\n boardDimensions: this.board ? {\n width: this.board.clientWidth,\n height: this.board.clientHeight,\n } : null,\n });\n // Clean up retry tracking\n this.retryAttempts.delete(placement.id);\n return;\n }\n\n // Increment retry count\n this.retryAttempts.set(placement.id, currentAttempts + 1);\n\n console.warn('[Decorator] Coordinate conversion failed, queueing placement for retry', {\n placementId: placement.id,\n attempt: currentAttempts + 1,\n maxAttempts: this.maxRetryAttempts,\n hasBoard: !!this.board,\n hasContainer: !!this.container,\n });\n\n // Add to pending queue for retry after layout (prevent duplicates)\n if (!this.pendingPlacements.some(p => p.id === placement.id)) {\n this.pendingPlacements.push(placement);\n }\n\n // Schedule retry using SINGLE timer to prevent race conditions\n // Clear any existing timer first\n if (this.retryTimer) {\n clearTimeout(this.retryTimer);\n this.retryTimer = null;\n }\n\n // Use increasing delays for each retry: 0ms, 16ms, 100ms\n const retryDelay = currentAttempts === 0 ? 0 : currentAttempts === 1 ? 16 : 100;\n\n this.retryTimer = setTimeout(() => {\n requestAnimationFrame(() => {\n // Clear timer reference\n this.retryTimer = null;\n\n // Retry processing pending placements\n if (this.pendingPlacements.length > 0 && this.board) {\n const pending = [...this.pendingPlacements];\n this.pendingPlacements = [];\n pending.forEach(p => this.addArtPlacement(p));\n }\n });\n }, retryDelay);\n\n return;\n }\n\n // Clear retry tracking on successful conversion\n this.retryAttempts.delete(placement.id);\n\n // Phase 4.85: Use existing element if provided (DOM reconstruction)\n // When reconstructing from DOM, we pass the actual DOM element to wrap instead of cloning\n // This prevents creating duplicate elements (the root cause of the \"two drag controls\" bug)\n const artPlacement = new ArtPlacement({\n template: this.template,\n id: placement.id,\n data: convertedPlacement,\n existingElement: options.fromDomReconstruction ? placement.existingElement : null,\n });\n\n // Store the original percentage data alongside the ArtPlacement object\n // This is needed for save functionality which requires percentages\n artPlacement.originalData = { ...placement }; // Make a copy to prevent mutations\n\n // Phase 4.170: Copy coordinatesLocked BEFORE events fire (critical timing!)\n // Events fire when DOM element is appended or Stimulus connects (below)\n // If coordinatesLocked isn't set by then, handlePlacementUpdate recalculates percentages\n if (placement.coordinatesLocked) {\n artPlacement.coordinatesLocked = true;\n }\n\n // Phase 4.242: Copy immutable drop coordinates if provided\n // These are used by getAllCurrentPlacements as the source of truth, and are\n // immune to corruption by handlePlacementUpdate. This is critical for bulk\n // mode restoration where coordinatesLocked can be unexpectedly cleared.\n if (placement.dropTimeCoordinates) {\n artPlacement.dropTimeCoordinates = { ...placement.dropTimeCoordinates };\n }\n\n // Phase 4.211: Diagnostic log to trace originalData and coordinatesLocked state\n\n // Validation check for clearly corrupted values (warnings removed for production)\n // Values > 1 billion are considered corrupted coordinates\n\n // Store the ArtPlacement object (not just data)\n this.artPlacements.set(placement.id, artPlacement);\n\n // Phase 0.99.26: Log successful Map population to verify placements are being added\n\n // Phase 4.375: Store percentage coordinates as data attributes on DOM element\n // This fixes the bulk/single mode coordinate shift bug (ping-pong effect).\n // Root cause: reconstructMapFromDOM recalculated percentages using current bgStart,\n // but the CSS positions were set with a different bgStart (from a different mode).\n // Fix: Store original percentages as data attributes, read them in reconstructMapFromDOM.\n const element = artPlacement.getElement();\n if (element && element.dataset) {\n // Store original percentage coordinates (before forward transform)\n // These are image-relative and independent of bgStart\n const percentageData = artPlacement.dropTimeCoordinates || artPlacement.originalData || placement;\n if (percentageData) {\n // Explicitly convert to strings for consistency (real DOM does this automatically)\n element.dataset.percentLeft = String(percentageData.left);\n element.dataset.percentTop = String(percentageData.top);\n element.dataset.percentWidth = String(percentageData.width);\n element.dataset.percentHeight = String(percentageData.height);\n\n }\n }\n\n // Add to DOM if container is set\n // Phase 4.80: Skip DOM append when reconstructing from DOM (element already exists there)\n if (this.container && !options.fromDomReconstruction) {\n const element = artPlacement.getElement();\n this.container.appendChild(element);\n\n // Phase 0.99.65: Force Stimulus to connect controller for dynamically added elements\n // This ensures decorator-canvas-template controller connects and setupDragging() runs\n // Without this, existing placements loaded from backend are frozen (Issue 15)\n // Pattern from decorator_modal_controller.js line 471\n if (window.Stimulus && window.Stimulus.application) {\n // Use requestAnimationFrame to ensure DOM is fully updated before connecting\n requestAnimationFrame(() => {\n // Connect the decorator-canvas-template controller on this element\n // getControllerForElementAndIdentifier will find or create the controller\n window.Stimulus.application.getControllerForElementAndIdentifier(\n element,\n 'decorator-canvas-template',\n );\n });\n }\n }\n }\n\n // Remove placement from canvas\n removeArtPlacement(id) {\n const artPlacement = this.artPlacements.get(id);\n if (artPlacement) {\n artPlacement.detach();\n this.artPlacements.delete(id);\n // Clear recovery state for this placement\n this.clearRecoveryState(id);\n }\n }\n\n // Clear recovery state tracking for a specific placement\n clearRecoveryState(placementId) {\n this.recoveryAttempts.delete(placementId);\n this.recoveryTimestamps.delete(placementId);\n }\n\n // Clear all recovery state (useful for canvas reset)\n clearAllRecoveryState() {\n this.recoveryAttempts.clear();\n this.recoveryTimestamps.clear();\n // Clear retry timer and state\n if (this.retryTimer) {\n clearTimeout(this.retryTimer);\n this.retryTimer = null;\n }\n this.retryAttempts.clear();\n this.pendingPlacements = [];\n }\n\n /**\n * Attempt to recover corrupted placement data using multiple fallback strategies\n * @param {Object} corruptedData - The corrupted placement data\n * @param {Object} artPlacement - The ArtPlacement object (may have visual element)\n * @returns {Object|null} Recovered data or null if recovery fails\n */\n attemptPlacementRecovery(corruptedData, artPlacement) {\n // Attempting comprehensive recovery for corrupted placement data\n\n // Strategy 1: Try to recover from visual DOM state (if element exists and is rendered)\n if (artPlacement.element && this.imageEl && this.boardDimensions) {\n try {\n const domRecovery = this.recoverFromDOMState(corruptedData, artPlacement);\n if (domRecovery && this.validateRecoveredData(domRecovery)) {\n // DOM recovery successful\n return domRecovery;\n }\n } catch {\n // DOM recovery failed\n }\n }\n\n // Strategy 2: Use heuristic recovery based on image dimensions\n try {\n const heuristicRecovery = this.recoverUsingHeuristics(corruptedData);\n if (heuristicRecovery && this.validateRecoveredData(heuristicRecovery)) {\n // Heuristic recovery successful\n return heuristicRecovery;\n }\n } catch {\n // Heuristic recovery failed\n }\n\n // Strategy 3: Return to safe center position (last resort)\n // All recovery strategies failed, using safe center position\n return this.createSafeCenterPosition(corruptedData);\n }\n\n /**\n * Attempt recovery from DOM element's visual state\n */\n recoverFromDOMState(corruptedData, artPlacement) {\n const rect = artPlacement.element.getBoundingClientRect();\n const parentRect = artPlacement.element.parentElement.getBoundingClientRect();\n const scalingFactor = this.getScalingFactor();\n const actualImageHeight = this.imageEl.height;\n\n if (scalingFactor <= 0 || actualImageHeight <= 0) {\n throw new Error('Invalid scaling parameters');\n }\n\n return {\n ...corruptedData,\n left: ((rect.left - parentRect.left) / scalingFactor / actualImageHeight) * 100,\n top: ((rect.top - parentRect.top) / scalingFactor / actualImageHeight) * 100,\n width: (rect.width / scalingFactor / actualImageHeight) * 100,\n height: (rect.height / scalingFactor / actualImageHeight) * 100,\n };\n }\n\n /**\n * Attempt recovery using image dimensions and reasonable assumptions\n */\n recoverUsingHeuristics(corruptedData) {\n // For percentage-based corruption (huge values), try to normalize\n // Assume the corruption is from improper scaling and try to reverse it\n const normalizeIfCorrupted = (value, defaultValue) => {\n if (Math.abs(value) > 10000) {\n // Likely corrupted by factor of 1000 or more, try to scale down\n const normalized = value / 1000;\n return Math.abs(normalized) < 1000 ? normalized : defaultValue;\n }\n return value;\n };\n\n return {\n ...corruptedData,\n left: normalizeIfCorrupted(corruptedData.left, 25), // Default to 25% from left\n top: normalizeIfCorrupted(corruptedData.top, 25), // Default to 25% from top\n width: normalizeIfCorrupted(corruptedData.width, 20), // Default to 20% width\n height: normalizeIfCorrupted(corruptedData.height, 20), // Default to 20% height\n };\n }\n\n /**\n * Create a safe center position as last resort\n */\n createSafeCenterPosition(corruptedData) {\n return {\n ...corruptedData,\n left: 40, // Center-ish position (40% from left)\n top: 40, // Center-ish position (40% from top)\n width: 20, // Reasonable size (20% of image height)\n height: 20, // Reasonable size (20% of image height)\n };\n }\n\n /**\n * Validate that recovered data is reasonable\n */\n validateRecoveredData(data) {\n // Check that all values are reasonable percentages\n const isReasonable = (value) => {\n return typeof value === 'number' && isFinite(value) && !isNaN(value) &&\n Math.abs(value) < 1000; // Conservative validation\n };\n\n return isReasonable(data.left) && isReasonable(data.top) &&\n isReasonable(data.width) && isReasonable(data.height) &&\n data.width > 0 && data.height > 0; // Positive dimensions\n }\n\n /**\n * Detects if originalData contains corrupted coordinates\n * Values > 200% are likely pixel coordinates stored as percentages\n * @param {Object} data - The placement data to check\n * @returns {boolean} True if data appears corrupted\n */\n isCorruptedOriginalData(data) {\n if (!data) {\n // Phase 0.99.34: Log why data is considered corrupted\n return true;\n }\n\n // Values > 200% are likely pixel coordinates stored as percentages\n // Legitimate percentages rarely exceed 200% of image height\n const isCorrupted = Math.abs(data.left) > 200 || Math.abs(data.top) > 200 ||\n Math.abs(data.width) > 200 || Math.abs(data.height) > 200;\n\n return isCorrupted;\n }\n\n /**\n * Derives correct percentages from the current visual state of a placement\n * Used to heal corrupted originalData by calculating from DOM position\n * @param {Object} artPlacement - The ArtPlacement object with visual element\n * @returns {Object} Corrected placement data with proper percentages\n */\n deriveCorrectPercentagesFromVisualState(artPlacement) {\n if (!artPlacement.element || !this.imageEl) {\n return this.createSafeFallbackPosition();\n }\n\n try {\n // Use current DOM position to calculate correct percentages\n const rect = artPlacement.element.getBoundingClientRect();\n const parentRect = artPlacement.element.parentElement.getBoundingClientRect();\n const scalingFactor = this.getScalingFactor();\n const actualImageHeight = this.imageEl.height;\n\n if (scalingFactor <= 0 || actualImageHeight <= 0) {\n return this.createSafeFallbackPosition();\n }\n\n return {\n ...artPlacement.originalData,\n left: ((rect.left - parentRect.left) / scalingFactor / actualImageHeight) * 100,\n top: ((rect.top - parentRect.top) / scalingFactor / actualImageHeight) * 100,\n width: (rect.width / scalingFactor / actualImageHeight) * 100,\n height: (rect.height / scalingFactor / actualImageHeight) * 100,\n };\n } catch {\n // Error deriving coordinates from visual state\n return this.createSafeFallbackPosition();\n }\n }\n\n /**\n * Creates a safe fallback position for corrupted placements\n * @returns {Object} Safe placement data with reasonable coordinates\n */\n createSafeFallbackPosition() {\n return {\n left: 40, // Center-ish position (40% from left)\n top: 40, // Center-ish position (40% from top)\n width: 20, // Reasonable size (20% of image height)\n height: 20, // Reasonable size (20% of image height)\n };\n }\n\n getArtPlacements() {\n // Dual-mode implementation: heal corrupted data while preserving clean data\n const result = Array.from(this.artPlacements.values()).map(artPlacement => {\n // Phase 4.343: Detect intentional drag and use originalData (new position)\n // This mirrors the logic in decorator_canvas_artboard_controller.js getAllCurrentPlacements\n // Problem: User moves existing artwork, but getArtPlacements() returns old dropTimeCoordinates\n // Fix: If user intentionally dragged (diff > threshold%), use originalData (their new position)\n // Phase 4.368: Increase threshold from 2% to 5% to prevent false positives\n // Safari phantom events and animation effects can cause 2-3% coordinate corruption\n // in originalData. 5% threshold filters animation artifacts while detecting real drags.\n const INTENTIONAL_DRAG_THRESHOLD = 5.0; // 5% threshold (same as getAllCurrentPlacements)\n const dropCoords = artPlacement.dropTimeCoordinates;\n const origCoords = artPlacement.originalData;\n\n // Phase 4.353: Per-field format normalization (matches Phase 4.352 pattern)\n // Bug: If dropTimeCoordinates is in decimal format (0.2315) and originalData\n // is in percentage format (23.99), the diff calculation falsely triggers\n // \"intentional drag\" (|23.99 - 0.2315| = 23.75 > 2% threshold).\n // Fix: Normalize values BEFORE comparison to handle mixed formats.\n const normalizeField = (value) => {\n const numValue = parseFloat(value);\n // Decimal format: 0 < value < 2.0 \u2192 multiply by 100 to get percentage\n if (numValue > 0 && numValue < 2.0) {\n return numValue * 100;\n }\n return numValue;\n };\n\n let data;\n if (dropCoords && origCoords) {\n // Phase 4.346: Check if originalData is corrupted BEFORE using for diff calculation\n // Corrupted originalData (pixel values > 200) should NOT trigger intentional drag detection\n // This preserves existing placements from database that have valid dropTimeCoordinates\n // Bug mechanism: handlePlacementUpdate() sets originalData.top = 400 (pixels), but\n // dropTimeCoordinates.top = 34 (valid %). Without this check, topDiff = 366 falsely\n // triggers \"intentional drag\", using corrupted originalData, which then gets filtered out.\n if (this.isCorruptedOriginalData(origCoords)) {\n data = dropCoords;\n } else if (artPlacement.coordinatesLocked === false) {\n // Phase 4.374: Fix single mode drag persistence\n // When coordinatesLocked is false, the user explicitly dragged/resized.\n // Trust originalData unconditionally - the 5% threshold is only needed\n // to filter Safari phantom events, which keep coordinatesLocked=true.\n // Bug mechanism: User drags 2%, handlePlacementUpdate updates originalData,\n // but 5% threshold causes getArtPlacements to return dropTimeCoordinates.\n // Fix: Bypass threshold when coordinatesLocked=false (proven real drag).\n // Phase 4.395: Fix resize not persisting after save\n // Trust origCoords (originalData) for dimensions - it has the resized values.\n // dropCoords (dropTimeCoordinates) only has original drop-time dimensions.\n data = {\n ...origCoords,\n // Phase 4.395: Prefer origCoords dimensions (resized), fall back to dropCoords (original)\n // Use ?? (nullish coalescing) to preserve 0 values as valid dimensions\n width: origCoords.width ?? dropCoords?.width,\n height: origCoords.height ?? dropCoords?.height,\n artworkId: origCoords.artworkId || dropCoords?.artworkId,\n artworkUrl: origCoords.artworkUrl || dropCoords?.artworkUrl,\n // Dimension fields critical for server-side geometry calculations\n actualImageHeight: dropCoords?.actualImageHeight,\n bgStart: dropCoords?.bgStart,\n isVerticalFit: dropCoords?.isVerticalFit,\n scalingFactor: dropCoords?.scalingFactor,\n };\n } else {\n // coordinatesLocked is true - apply 5% threshold (Safari phantom event protection)\n // originalData is valid - proceed with existing intentional drag detection\n // Check if user intentionally moved the artwork\n // Phase 4.353: Normalize values BEFORE comparison to handle mixed formats\n const normalizedOrigTop = normalizeField(origCoords.top);\n const normalizedOrigLeft = normalizeField(origCoords.left);\n const normalizedDropTop = normalizeField(dropCoords.top);\n const normalizedDropLeft = normalizeField(dropCoords.left);\n\n const topDiff = Math.abs((normalizedOrigTop || 0) - normalizedDropTop);\n const leftDiff = Math.abs((normalizedOrigLeft || 0) - normalizedDropLeft);\n const hasIntentionalDrag = topDiff > INTENTIONAL_DRAG_THRESHOLD || leftDiff > INTENTIONAL_DRAG_THRESHOLD;\n\n // Phase 4.368: Log threshold comparison for debugging coordinate shift\n\n if (hasIntentionalDrag) {\n // User moved the artwork - use their new position from originalData\n // BUT: Need to merge artworkId from dropTimeCoordinates if missing in originalData\n // Phase 4.344: Preserve width/height from dropTimeCoordinates to maintain original aspect ratio\n // (originalData.width/height may be recalculated from visual container, causing distortion)\n // Phase 4.349: Preserve actualImageHeight and related dimension fields for server-side baking\n // Without these, server falls back to base_image.height causing coordinate shift\n // (e.g., client has 1258px height but server uses 1250px \u2192 2px upward shift)\n data = {\n ...origCoords,\n width: dropCoords.width,\n height: dropCoords.height,\n artworkId: origCoords.artworkId || dropCoords.artworkId,\n artworkUrl: origCoords.artworkUrl || dropCoords.artworkUrl,\n // Phase 4.349: Dimension fields critical for server-side geometry calculations\n actualImageHeight: dropCoords.actualImageHeight,\n bgStart: dropCoords.bgStart,\n isVerticalFit: dropCoords.isVerticalFit,\n scalingFactor: dropCoords.scalingFactor,\n };\n } else {\n // No significant movement - use immutable dropTimeCoordinates for position\n // Phase 4.391: Merge tool values from originalData\n // Tool operations (flip, rotate) update originalData but NOT dropTimeCoordinates.\n // When user flips artwork without moving it, originalData.flipX is true but\n // dropTimeCoordinates.flipX is still false. Without merging, flip is lost on save.\n data = {\n ...dropCoords,\n // Phase 4.391: Use originalData tool values when available\n flipX: origCoords?.flipX ?? dropCoords.flipX,\n flipY: origCoords?.flipY ?? dropCoords.flipY,\n rotation: origCoords?.rotation ?? dropCoords.rotation,\n };\n }\n }\n } else {\n // Fallback to existing behavior\n data = dropCoords || origCoords || artPlacement.data;\n }\n\n // If we're using artPlacement.data (pixel-based), return as-is\n // Phase 4.347: Still ensure artworkId is included even for pixel-based placements\n if (!artPlacement.dropTimeCoordinates && !artPlacement.originalData) {\n return {\n ...data,\n id: artPlacement.id, // Phase 4.380: Include placement ID for filtering\n // Fallback chain to ensure artworkId is always present\n // Use ?? (nullish coalescing) not || because artworkId=0 is valid but falsy\n artworkId: data.artworkId ?? artPlacement.data?.artworkId,\n };\n }\n\n // Check if data is corrupted (Phase 1 detection)\n if (this.isCorruptedOriginalData(data)) {\n // Filtering out corrupted placement\n return null; // Filter out corrupted placements instead of healing to prevent duplicates\n } else {\n // Backend expects percentages (0-100 range) for MagickGeometry processing\n // MagickGeometry.rb divides by 100 to convert percentages to pixels\n // DO NOT convert to decimals here - breaks composite image generation\n // Phase 4.347: Explicitly include artworkId to prevent existing placements from being lost\n // Bug: If artworkId is missing/undefined, server's artwork_instances.find returns nil\n // and placement is skipped with `next unless artwork_instance`. Ensure all required\n // fields are explicitly passed through, not just relying on ...data spread.\n return {\n ...data,\n id: artPlacement.id, // Phase 4.380: Include placement ID for filtering\n left: data.left,\n top: data.top,\n width: data.width,\n height: data.height,\n // Phase 4.347: Essential fields for server-side processing\n // artworkId: Match placement to artwork_instance (0/undefined causes skip!)\n // rotation/flipX/flipY: Transform settings (undefined \u2192 NaN/undefined on server)\n // actualImageHeight/bgStart: Server-side geometry calculations\n // Use ?? (nullish coalescing) not || because artworkId=0 is valid but falsy\n artworkId: data.artworkId ?? dropCoords?.artworkId ?? origCoords?.artworkId ?? artPlacement.data?.artworkId,\n rotation: data.rotation ?? origCoords?.rotation ?? 0,\n flipX: data.flipX ?? origCoords?.flipX ?? false,\n flipY: data.flipY ?? origCoords?.flipY ?? false,\n actualImageHeight: data.actualImageHeight || dropCoords?.actualImageHeight,\n bgStart: data.bgStart || dropCoords?.bgStart,\n };\n }\n }).filter(placement => placement !== null);\n\n return result;\n }\n\n /**\n * Remove duplicate placements based on artwork ID and position similarity\n */\n removeDuplicatePlacements() {\n const placementsToKeep = new Map();\n const placementsToRemove = [];\n\n for (const [placementId, artPlacement] of this.artPlacements) {\n const data = artPlacement.originalData || artPlacement.data;\n const artworkId = data.artworkId;\n\n if (placementsToKeep.has(artworkId)) {\n // Check if positions are similar (likely duplicates)\n const existing = placementsToKeep.get(artworkId);\n const existingData = existing.originalData || existing.data;\n\n const isSimilarPosition =\n Math.abs(data.left - existingData.left) < 5 &&\n Math.abs(data.top - existingData.top) < 5;\n\n if (isSimilarPosition) {\n placementsToRemove.push(placementId);\n continue;\n }\n }\n\n placementsToKeep.set(artworkId, artPlacement);\n }\n\n // Remove duplicates from DOM and memory\n placementsToRemove.forEach(id => {\n this.removeArtPlacement(id);\n });\n\n // Removed duplicate placements from canvas\n }\n\n detachArtPlacements() {\n // Remove from DOM but keep in memory\n this.artPlacements.forEach((artPlacement) => {\n artPlacement.detach();\n });\n }\n\n destroyArtPlacements() {\n // Remove from DOM and clear memory\n this.detachArtPlacements();\n this.artPlacements.clear();\n // Clear all recovery state tracking\n this.clearAllRecoveryState();\n }\n\n attachArtPlacements() {\n // Re-render all placements\n // Phase 4.385: Batch DOM insertions using DocumentFragment for better performance\n // Single reflow instead of one per placement\n if (this.container) {\n const fragment = document.createDocumentFragment();\n this.artPlacements.forEach((artPlacement) => {\n const element = artPlacement.getElement();\n if (element && !element.parentNode) {\n fragment.appendChild(element);\n }\n });\n if (fragment.hasChildNodes()) {\n this.container.appendChild(fragment);\n }\n }\n }\n}\n", "/* eslint-disable no-console */\n/**\n * Decorator Canvas Artboard Controller\n *\n * Main controller for the decorator canvas functionality. Manages product images,\n * artwork placements, and coordinates between other canvas controllers.\n *\n * HTML Structure:\n * ```html\n *
    \n *\n * \n *
    \n * \n *
    \n *\n * \n * \n *\n * \n *
    \n * \n *
    \n *
    \n * ```\n *\n * Data Attributes:\n * - sale-id: Current sale ID\n * - image-data: JSON array of product images with orientations\n * - existing-decoration: JSON object with existing art placements to load\n * - board-dimensions: Canvas dimensions {width, height}\n *\n * Events Listened:\n * - decorator:orientations:select - When user selects a product orientation\n * - decorator:colors:startFetch - When color variant fetching starts\n * - decorator:colors:endFetch - When color variant fetching ends\n * - decorator:history:reload - When undo/redo is triggered\n * - decorator:publish - When save is triggered\n * - dropzone:add - When file upload starts\n * - dropzone:upload - When file upload completes\n *\n * Events Emitted:\n * - decorator:state:changed - When canvas state changes (for history)\n *\n * Unit Conversions:\n * - Frontend uses pixels and degrees\n * - Backend expects % of height and radians\n * - Conversions happen in getDecorationData() and loadExistingDecoration()\n */\nimport { Controller } from '../stimulus';\nimport CSRFTokenMixin from '../mixins/csrf_token_mixin';\nimport { PerformanceMonitor } from '../utils/performance';\n\nimport ImageCanvas from './decorator_canvas_image_canvas';\n\n// Phase 4.385: Conditional logging to reduce production overhead\n// Hot-path diagnostic logs are wrapped with debugLog() which is a no-op in production\n// Enable in browser console: window.DECORATOR_DEBUG = true\nconst debugLog = (...args) => {\n if (typeof window !== 'undefined' && window.DECORATOR_DEBUG) {\n console.log(...args);\n }\n};\n\n// Module-level placement tracking to prevent duplicates across controller instances\n// This solves the issue where multiple Stimulus controller instances each have their own\n// loadedArtPlacements object, causing each instance to load placements independently\n// Key format: `${saleId}_${imageId}` -> boolean\n// Example: \"123_456\" means placements for image 456 in sale 123 have been loaded\nconst globalLoadedPlacements = new Map();\n\n// Phase 0.99.35: Expose to window for modal controller access\n// Modal controller needs to clear this Map when modal closes to prevent stale tracking\nif (typeof window !== 'undefined') {\n window.globalLoadedPlacements = globalLoadedPlacements;\n}\n\n// Phase 0.99.12: Track controller connection times to distinguish modal reopen from drag reconnections\n// Key format: `${saleId}_${imageId}` -> timestamp (milliseconds)\n// Used to detect if reconnection is:\n// - Rapid (< 1000ms): Spurious reconnection during drag \u2192 Skip reload\n// - Delayed (> 1000ms): Legitimate modal reopen \u2192 Allow reload\nconst controllerLastConnectTime = new Map();\n\n// Phase 0.99.16: Module-level canvas state persistence\n// Problem: this.currentCanvas is instance-level, resets to null on each new instance\n// When controller reconnects (drag), new instance has empty currentCanvas\n// Result: Skip reload (trust tracking) \u2192 Empty canvas \u2192 Save fails with \"No artwork placed\"\n//\n// Solution: Store canvas objects at module level, persist across controller instances\n// Key format: `${saleId}_${imageId}` -> canvas object\n// When reconnecting: Retrieve persisted canvas, attach to new instance\n// This allows skip-reload strategy to work without breaking save functionality\nconst globalCanvasState = new Map();\n\n// Phase 0.99.36: Expose to window for modal controller access\n// Modal controller needs to clear this Map when modal closes to prevent stale canvas data\nif (typeof window !== 'undefined') {\n window.globalCanvasState = globalCanvasState;\n}\n\n// Phase 4.89: Module-level dirty state persistence\n// Problem: this.hasUnsavedChanges resets to false on each connect()\n// When controller reconnects during drag (Phase 0.99.37 spurious disconnect), dirty state is lost\n// Result: User makes changes \u2192 drag causes reconnect \u2192 dirty flag reset \u2192 no confirmation on close\n//\n// Solution: Store dirty state at module level, persist across controller instances\n// Key format: saleId -> boolean\n// When reconnecting: Retrieve persisted dirty state, attach to new instance\nconst globalDirtyState = new Map();\n\n// Expose to window for modal controller access\nif (typeof window !== 'undefined') {\n window.globalDirtyState = globalDirtyState;\n}\n\n// Phase 4.381: Track all canvas changes to preserve across context switches\n// Problem: Canvas changes (deletions, additions, modifications) are lost when switching contexts:\n// - Orientation switch may reload from server\n// - Color switch creates new canvas context\n// - Product switch (bulk mode) doesn't track deletions\n//\n// Solution: Record all canvas modifications and filter them when loading placements\n// Key format: `${saleId}_${imageId}` -> CanvasChanges\n// CanvasChanges: { deleted: Set, added: Set, modified: Map }\nconst globalCanvasChanges = new Map();\n\n// Helper to get or create changes for a canvas\n// Phase 4.389: Added colorVariantId parameter for state isolation per color\nfunction getCanvasChanges(saleId, colorVariantId, imageId) {\n const key = `${saleId}_${colorVariantId || 'default'}_${imageId}`;\n if (!globalCanvasChanges.has(key)) {\n globalCanvasChanges.set(key, {\n deleted: new Set(), // Placement IDs that were deleted\n added: new Set(), // Placement IDs that were newly added\n modified: new Map(), // Placement IDs -> modified data\n });\n }\n return globalCanvasChanges.get(key);\n}\n\n// Expose to window for modal controller access\nif (typeof window !== 'undefined') {\n window.globalCanvasChanges = globalCanvasChanges;\n}\n\n// Phase 4.118: Module-level storage for bulk decoration placements\n// Problem: In bulk mode, switching products clears the canvas (clearAllCanvases)\n// User expectation: Artwork should persist across product switches for preview\n// Solution: Store placements before clearing, restore after new image loads\n// Key format: saleId -> { placements: Array, sourceImageId: String, timestamp: number }\nconst globalBulkDecorationPlacements = new Map();\n\n// Phase 4.414: Module-level debounce tracking for bulk restore race condition\n// Problem: Product switching triggers both loadInitialImage and handleOrientationSelect callbacks\n// Both schedule 100ms setTimeout to call restoreBulkPlacements, causing race condition:\n// 1. First restore: destroy (empty) \u2192 add placements \u2192 artwork appears\n// 2. Second restore: destroyArtPlacements CLEARS first's work \u2192 add new placements\n// If timing is wrong or conversion fails, artwork briefly appears then disappears.\n// Solution: Track last restore timestamp per canvas, skip if called within debounce window.\n// Key format: `${saleId}_${imageId}` -> timestamp (milliseconds)\nconst lastBulkRestoreTimestamp = new Map();\nconst RESTORE_DEBOUNCE_MS = 200;\n\n// Phase 4.416: Module-level drop lock to prevent duplicate artwork\n// Race conditions during drop+restore overlap or multiple controller instances\n// can produce duplicate artwork. This lock prevents:\n// 1. restoreBulkPlacements from firing during an active drop\n// 2. A second controller instance from processing the same drop event\nlet activeDropLock = null; // { artworkId, timestamp, instanceId }\nconst DROP_LOCK_TIMEOUT_MS = 2000; // Safety valve auto-expire\n// Reset function for tests (module-level let can't be reassigned via import)\nfunction resetActiveDropLock() { activeDropLock = null; }\n\n// Expose to window for modal controller access\nif (typeof window !== 'undefined') {\n window.globalBulkDecorationPlacements = globalBulkDecorationPlacements;\n}\n\n// Phase 4.151: Map productId -> ColorVariantImage ID for per-product placement tracking\n// When user switches products in bulk mode, we need to know each product's actual image ID\n// to send per-product placements keyed by TARGET image (not source canvas imageId)\n// Key format: `${saleId}_${productId}` -> ColorVariantImage ID\nconst productImageMapping = new Map();\n\n// Expose to window for modal controller access\nif (typeof window !== 'undefined') {\n window.productImageMapping = productImageMapping;\n}\n\n// Phase 4.194: Resizable handle offset compensation\n// The .resizable element extends -8px on all sides for resize handles (decorator_modal.scss lines 288-293)\n// When reading element bounding rect via getBoundingClientRect(), this offset is included\n// and must be compensated to get the actual artwork position\nconst RESIZABLE_HANDLE_OFFSET = 8;\n\n// Export for testing\nexport { globalLoadedPlacements, controllerLastConnectTime, globalCanvasState, globalDirtyState, globalCanvasChanges, globalBulkDecorationPlacements, productImageMapping, lastBulkRestoreTimestamp, RESTORE_DEBOUNCE_MS, activeDropLock, DROP_LOCK_TIMEOUT_MS, resetActiveDropLock };\n\nexport default class extends Controller {\n static targets = ['artworkTemplate', 'artworksContainer'];\n\n // Getter for currentImageId to support save controller\n get currentImageId() {\n return this.currentCanvas ? this.currentCanvas.imageId : null;\n }\n\n connect() {\n Object.assign(this, CSRFTokenMixin);\n\n // DEBUG: Create unique instance ID to track multiple controller instances\n this.instanceId = `artboard_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;\n\n // Store controller reference on element for other controllers to access\n this.element._stimulusController = this;\n this.element._controller = this; // Fallback property\n\n // Initialize performance monitoring\n this.perfMonitor = new PerformanceMonitor('DecoratorArtboard');\n\n // Parse data attributes (Stimulus 1.1.0 syntax)\n this.saleId = this.element.dataset.saleId;\n this.imageData = JSON.parse(this.element.dataset.imageData || '[]');\n this.existingDecoration = JSON.parse(this.element.dataset.existingDecoration || '{}');\n\n // BULK MODE BYPASS: Force skipExistingDecorations = false when bypass active\n const forceSingleMode = document.getElementById('decorator-modal')?.dataset.forceSingleMode === 'true';\n\n if (forceSingleMode) {\n this.skipExistingDecorations = false;\n console.warn('[BULK BYPASS] skipExistingDecorations forced to false');\n } else {\n // Bulk mode: Skip loading existing decorations to start with blank canvas\n this.skipExistingDecorations = this.element.dataset.skipExistingDecorations === 'true';\n }\n\n // DEBUG: Log placement data from data attribute\n\n // Get actual board dimensions from the element's rendered size\n this.boardDimensions = {\n width: this.element.clientWidth,\n height: this.element.clientHeight,\n };\n\n // Phase 4.322: Diagnostic logging to trace layout-dependent coordinate shift\n // Hypothesis: Modal animation causes width change between connect() and drop time\n console.log('[Phase 4.322] connect() boardDimensions:', {\n width: this.boardDimensions.width,\n height: this.boardDimensions.height,\n elementWidth: this.element.clientWidth,\n elementHeight: this.element.clientHeight,\n timestamp: Date.now(),\n saleId: this.saleId,\n });\n\n // Phase 4.372: Store initial dimensions for three-point comparison\n // Track canvas dimensions at connect time to detect dimension instability\n this.connectTimeDimensions = {\n width: this.element.clientWidth,\n height: this.element.clientHeight,\n timestamp: Date.now(),\n computed: window.getComputedStyle(this.element).width, // CSS vs layout\n };\n\n console.log('[PHASE 4.372] CONNECT boardDimensions:', this.connectTimeDimensions);\n\n this.canvases = {};\n this.currentCanvas = null;\n // NOTE: loadedArtPlacements tracking moved to module-level globalLoadedPlacements Map\n // to prevent duplicate loading across multiple controller instances\n this.isRestoringFromHistory = false;\n\n // Phase 4.89: Restore dirty state from global to persist across reconnections\n // Instead of resetting to false, check if we have persisted dirty state for this sale\n this.hasUnsavedChanges = globalDirtyState.get(this.saleId) || false;\n\n // Autosave: Initialize debounced autosave (1.5 second delay)\n this.autosaveTimeoutId = null;\n this.autosaveDelay = 1500;\n\n // Listen for events (no ActionCable)\n this.setupEventListeners();\n\n\n // Load initial image if available\n if (this.imageData.length > 0) {\n this.loadInitialImage();\n }\n\n }\n\n disconnect() {\n // DEBUG: Log controller destruction\n\n // Autosave: Cancel any pending debounced autosave\n if (this.autosaveTimeoutId) {\n clearTimeout(this.autosaveTimeoutId);\n this.autosaveTimeoutId = null;\n }\n\n // NOTE: Intentionally NOT clearing globalLoadedPlacements here\n // Previous implementation cleared tracking on disconnect(), which caused duplicates:\n // Instance 1: disconnect() \u2192 clears global tracking\n // Instance 2: connect() \u2192 checks global tracking (empty!) \u2192 loads duplicates\n //\n // Global tracking MUST persist across controller lifecycle to prevent duplicates.\n // It's only cleared in clearAllCanvases() when switching products.\n //\n // Memory leak concern is minimal:\n // - Map stores only booleans (tiny footprint)\n // - Limited to ~10-20 entries per sale (one per image)\n // - Sale-scoped keys prevent cross-sale conflicts\n // - Cleared on product switch and page reload\n\n // Clean up controller references\n if (this.element._stimulusController === this) {\n delete this.element._stimulusController;\n }\n if (this.element._controller === this) {\n delete this.element._controller;\n }\n\n // Clean up event listeners\n document.removeEventListener('decorator:product:selected', this.onProductSelected);\n document.removeEventListener('decorator:artwork:drop', this.onArtworkDrop);\n document.removeEventListener('decorator:colors:select', this.onColorSelect);\n document.removeEventListener('decorator:orientations:select', this.onSelectOrientation);\n document.removeEventListener('decorator:history:reload', this.onHistoryReload);\n this.element.removeEventListener('decorator:colors:startFetch', this.showLoading);\n this.element.removeEventListener('decorator:colors:endFetch', this.hideLoading);\n this.element.removeEventListener('decorator:history:publish', this.onPublish);\n this.element.removeEventListener('decorator:publish', this.onPublish);\n // Phase 4.108: Remove from document (matching setupEventListeners)\n document.removeEventListener('decorator:placement:updated', this.onPlacementUpdate);\n document.removeEventListener('decorator:placement:deleted', this.onPlacementDelete);\n document.removeEventListener('decorator:placement:clone', this.onPlacementClone);\n this.element.removeEventListener('decorator:requestController', this.handleControllerRequest);\n // Phase 4.89: Clean up dirty state listeners from document (matching setupEventListeners)\n document.removeEventListener('decorator:placement:updated', this.markAsDirty);\n document.removeEventListener('decorator:placement:deleted', this.markAsDirty);\n document.removeEventListener('decorator:placement:clone', this.markAsDirty);\n document.removeEventListener('decorator:artwork:drop', this.markAsDirty);\n\n // Phase 4.167: Clean up drag start listener\n document.removeEventListener('decorator:placement:dragstart', this.onPlacementDragStart);\n\n // Phase 4.257: Clean up debug request-placements listener\n document.removeEventListener('decorator:debug:request-placements', this.onDebugRequestPlacements);\n\n // Clear loading timeout\n if (this.loadingTimeout) {\n clearTimeout(this.loadingTimeout);\n this.loadingTimeout = null;\n }\n\n // Clean up performance monitoring\n this.perfMonitor.clear();\n }\n\n setupEventListeners() {\n // Listen for product selection from gallery\n this.onProductSelected = this.handleProductSelected.bind(this);\n document.addEventListener('decorator:product:selected', this.onProductSelected);\n\n // Listen for artwork drop from library\n this.onArtworkDrop = this.handleArtworkDrop.bind(this);\n document.addEventListener('decorator:artwork:drop', this.onArtworkDrop);\n\n // Listen for orientation selection - listen on document like color selection\n this.onSelectOrientation = this.handleOrientationSelect.bind(this);\n document.addEventListener('decorator:orientations:select', this.onSelectOrientation);\n\n // Listen for color selection\n this.onColorSelect = this.handleColorSelect.bind(this);\n document.addEventListener('decorator:colors:select', this.onColorSelect);\n\n // Listen for color loading events\n this.showLoading = () => this.element.classList.add('is-loading');\n this.hideLoading = () => this.element.classList.remove('is-loading');\n this.element.addEventListener('decorator:colors:startFetch', this.showLoading);\n this.element.addEventListener('decorator:colors:endFetch', this.hideLoading);\n\n // Listen for history events - history reload comes from document\n this.onPublish = this.handlePublish.bind(this);\n this.element.addEventListener('decorator:history:publish', this.onPublish);\n this.onHistoryReload = this.handleHistoryReload.bind(this);\n document.addEventListener('decorator:history:reload', this.onHistoryReload);\n\n // Listen for publish event\n this.element.addEventListener('decorator:publish', this.onPublish);\n\n // Listen for dropzone events\n this.element.addEventListener('dropzone:add', this.showLoading);\n this.element.addEventListener('dropzone:upload', this.hideLoading);\n\n // Listen for placement update/delete/clone events from template controller\n // Phase 4.108: Listen on document since template_controller dispatches to document\n // Events dispatched to document don't bubble DOWN to child elements\n this.onPlacementUpdate = this.handlePlacementUpdate.bind(this);\n this.onPlacementDelete = this.handlePlacementDelete.bind(this);\n this.onPlacementClone = this.handlePlacementClone.bind(this);\n document.addEventListener('decorator:placement:updated', this.onPlacementUpdate);\n document.addEventListener('decorator:placement:deleted', this.onPlacementDelete);\n document.addEventListener('decorator:placement:clone', this.onPlacementClone);\n\n // Phase 4.89: Listen for canvas modification events on document (not this.element)\n // This ensures dirty state tracking works even when controller reconnects during drag\n // All decorator:placement:* events will be dispatched to document (Phase 4.89)\n this.markAsDirty = this.markAsDirty.bind(this);\n document.addEventListener('decorator:placement:updated', this.markAsDirty);\n document.addEventListener('decorator:placement:deleted', this.markAsDirty);\n document.addEventListener('decorator:placement:clone', this.markAsDirty);\n document.addEventListener('decorator:artwork:drop', this.markAsDirty);\n\n // Phase 4.167: Listen for drag start to clear coordinatesLocked\n // This allows handlePlacementUpdate to recalculate percentages when user explicitly drags\n this.onPlacementDragStart = this.handlePlacementDragStart.bind(this);\n document.addEventListener('decorator:placement:dragstart', this.onPlacementDragStart);\n\n // Listen for controller request events (fallback communication)\n this.handleControllerRequest = this.handleControllerRequest.bind(this);\n this.element.addEventListener('decorator:requestController', this.handleControllerRequest);\n\n // Phase 4.257: Listen for debug panel placement request\n this.onDebugRequestPlacements = () => this.dispatchPlacementsChanged();\n document.addEventListener('decorator:debug:request-placements', this.onDebugRequestPlacements);\n }\n\n loadInitialImage() {\n // Phase 4.187: Re-read dimensions before creating canvas\n // connect() may run during modal animation when container has wrong size\n // This ensures we get the final post-animation dimensions\n this.boardDimensions = {\n width: this.element.clientWidth,\n height: this.element.clientHeight,\n };\n\n // Phase 4.322: Diagnostic logging to trace layout-dependent coordinate shift\n // This should show updated dimensions after modal animation completes\n console.log('[Phase 4.322] loadInitialImage() boardDimensions:', {\n width: this.boardDimensions.width,\n height: this.boardDimensions.height,\n elementWidth: this.element.clientWidth,\n elementHeight: this.element.clientHeight,\n timestamp: Date.now(),\n saleId: this.saleId,\n });\n\n const firstImage = this.imageData[0];\n const canvas = this.findOrCreateCanvas(firstImage);\n\n // Phase 0.99.31: Track canvas instance before calling load()\n\n canvas.load((loadedCanvas) => {\n // Phase 0.99.31: Track canvas instance passed to callback\n\n this.onCanvasLoad(loadedCanvas);\n\n // Phase 0.99.12: Track connection time for this sale+image to distinguish\n // rapid reconnections (drag) from delayed reconnections (modal reopen)\n const connectKey = `${this.saleId}_${loadedCanvas.imageId}`;\n const now = Date.now();\n const lastConnectTime = controllerLastConnectTime.get(connectKey) || 0;\n const timeSinceLastConnect = now - lastConnectTime;\n controllerLastConnectTime.set(connectKey, now);\n\n // Check if data attribute has placements\n // Phase 4.68: Support both legacy format (art_placements) and new format (art_placements_by_image_id)\n const hasLegacyPlacements = this.existingDecoration.art_placements &&\n Array.isArray(this.existingDecoration.art_placements) &&\n this.existingDecoration.art_placements.length > 0;\n const hasImageKeyedPlacements = this.existingDecoration.art_placements_by_image_id &&\n typeof this.existingDecoration.art_placements_by_image_id === 'object' &&\n Object.keys(this.existingDecoration.art_placements_by_image_id).length > 0;\n const hasDataPlacements = hasLegacyPlacements || hasImageKeyedPlacements;\n\n // Phase 0.99.6: DOM guard for fast path to prevent duplicates during spurious reconnections\n // Phase 4.68: Use container-level query to prevent cross-contamination between images\n // Document-level query could find placements from other images sharing the same container\n // Phase 0.99.51: Use [data-art-placement-id] selector instead of [data-artwork=\"true\"]\n // This is more reliable because data-art-placement-id is always present and unique\n // while data-artwork may have timing issues (template has data-artwork without value,\n // ArtPlacement sets data-artwork=\"true\" - selector mismatch during drag reconnections)\n const container = this.element.querySelector('.dropzone-previews');\n const existingPlacementsInDOM = container ?\n container.querySelectorAll('[data-art-placement-id]').length : 0;\n\n\n // Phase 4.212: Entry diagnostic - trace which code path is used on modal reopen\n console.log('[Phase 4.212] Canvas load callback ENTRY:', {\n skipExistingDecorations: this.skipExistingDecorations,\n saleId: this.saleId,\n imageId: loadedCanvas.imageId,\n hasBulkData: globalBulkDecorationPlacements.has(this.saleId),\n hasDataPlacements,\n existingPlacementsInDOM,\n });\n\n // Phase 4.212: Decision point diagnostic\n console.log('[Phase 4.212] Decision point:', {\n hasDataPlacements,\n existingPlacementsInDOM,\n skipExistingDecorations: this.skipExistingDecorations,\n branch: hasDataPlacements\n ? (existingPlacementsInDOM > 0 ? 'DATA+DOM' : 'DATA_ONLY')\n : 'AJAX_PATH',\n });\n\n if (hasDataPlacements) {\n // Phase 0.99.6: Check if placements already rendered before loading from data attribute\n // This prevents duplicates when controller reconnects during drag operations\n if (existingPlacementsInDOM > 0) {\n // Phase 4.201: Skip DOM reconstruction in bulk mode to prevent coordinate shift\n // In bulk mode (skipExistingDecorations), DOM placements may have stale bgStart\n // offsets from previous modal session. Safari's layout timing can cause bgStart\n // to differ between save and reload, resulting in ~1px coordinate shift.\n // Always fetch fresh data from server in bulk mode instead of reconstructing.\n if (this.skipExistingDecorations) {\n console.log('[Phase 4.201] Bulk mode: Skipping DOM reconstruction, will fetch fresh from server', {\n existingPlacementsInDOM,\n skipExistingDecorations: this.skipExistingDecorations,\n });\n // Clear stale DOM elements to prevent fallback path from using them\n const container = this.element.querySelector('.dropzone-previews');\n if (container) {\n while (container.firstChild) {\n container.removeChild(container.firstChild);\n }\n }\n // Fall through to AJAX fetch path (do NOT return here)\n } else {\n // Single mode: Reconstruct from DOM as before\n // Phase 0.99.44: FIX - Reconstruct canvas state Map from DOM before returning\n // Problem: Placements exist in DOM but canvas.artPlacements Map is empty (size: 0)\n // Result: Save reads empty Map \u2192 \"No artwork placed on canvas\" error\n // Solution: Use existing reconstructMapFromDOM() to sync Map with DOM state\n const reconstructedCount = this.reconstructMapFromDOM(loadedCanvas);\n\n // Phase 4.345: Update debug panel after reconstruction\n // Without this, debug panel shows \"ALL PLACEMENTS (0)\" even though Map was just populated.\n // This ensures the debug panel displays correct count immediately after modal reopens.\n if (reconstructedCount > 0) {\n this.dispatchPlacementsChanged();\n console.log('[Phase 4.345] Single mode reconstruction complete - dispatched placementsChanged:', {\n reconstructedCount,\n canvasImageId: loadedCanvas.imageId,\n mapSize: loadedCanvas.artPlacements?.size || 0,\n });\n }\n\n // Phase 0.99.8: CRITICAL FIX - Exit function to prevent fallback path execution\n // Without this return, code continues to fallback path which loads placements via AJAX\n return;\n }\n } else {\n // DOM is empty - legitimate first load\n // Fast path: Use data attribute (page load with pre-rendered data)\n this.loadExistingDecorationData(this.existingDecoration);\n // Phase 0.99.8: Exit after fast path to prevent fallback path execution\n return;\n }\n } else {\n // Fallback path: Data attribute is empty, fetch from server\n // This handles cases where modal was reopened after save (stale data attribute)\n\n // Phase 0.99.4: Check BOTH global tracking AND DOM state before loading\n // This prevents duplicate AJAX calls in TWO scenarios:\n // 1. Multiple controller instances (canvas.isLoaded causes immediate callback)\n // 2. Spurious controller reconnections during drag (DOM already has placements)\n const trackingKey = `${this.saleId}_${loadedCanvas.imageId}`;\n // Phase 4.68: Use container-level query to prevent cross-contamination between images\n // Phase 0.99.51: Use [data-art-placement-id] selector for reliable DOM guard\n const fallbackContainer = this.element.querySelector('.dropzone-previews');\n const existingPlacementsInDOM = fallbackContainer ?\n fallbackContainer.querySelectorAll('[data-art-placement-id]').length : 0;\n\n\n if (!globalLoadedPlacements.has(trackingKey) && existingPlacementsInDOM === 0) {\n // Phase 0.99.15: Set tracking IMMEDIATELY before load call (test-and-set pattern)\n //\n // Critical fix: Multiple controller instances can be created during drag START\n // before tracking is set. Without immediate tracking, all instances pass this\n // guard and call loadExistingArtPlacements() simultaneously \u2192 duplicates!\n //\n // Race condition timeline:\n // 1. Instance 1: Check tracking \u2192 NOT FOUND \u2192 Enter this branch\n // 2. Instance 1: Call loadExistingArtPlacements() (line 360)\n // 3. Instance 2 created BEFORE Instance 1 sets tracking (inside function)\n // 4. Instance 2: Check tracking \u2192 STILL NOT FOUND \u2192 Enter this branch\n // 5. Instance 2: Call loadExistingArtPlacements() AGAIN\n // 6. Result: BOTH load simultaneously \u2192 Duplicates!\n //\n // Solution: Set tracking HERE, BEFORE function call (atomic test-and-set)\n // Phase 0.99.1 established this pattern for the AJAX path (line 960)\n // Phase 0.99.15 applies the same pattern to the FIRST LOAD path\n globalLoadedPlacements.set(trackingKey, true);\n\n // Only load if tracking didn't exist AND DOM is empty\n // This ensures we load on first open but skip on spurious reconnections\n\n // Phase 0.99.31: Track canvas instance before calling loadExistingArtPlacements\n\n this.loadExistingArtPlacements(loadedCanvas);\n } else if (existingPlacementsInDOM > 0) {\n // Placements already rendered in DOM - spurious reconnection during drag\n // Skip loading to prevent duplicates\n } else {\n // Phase 0.99.12: Time-based reconnection detection\n //\n // Problem: Cannot distinguish modal reopen from spurious drag reconnection using state alone:\n // - Tracking: Exists in BOTH scenarios\n // - Canvas Map: Empty in BOTH scenarios (timing)\n // - DOM: Can be empty in BOTH scenarios\n //\n // Solution: Use time since last connection:\n // - Drag operations: Multiple rapid reconnections (< 1000ms apart)\n // - Modal reopen: Significant time gap (> 1000ms)\n //\n // If timeSinceLastConnect > 1000ms: Reload (modal reopen - need canvas for save)\n // If timeSinceLastConnect <= 1000ms: Skip (drag operation - prevent duplicates)\n\n if (timeSinceLastConnect > 1000) {\n // Significant time gap - likely modal reopen\n // Reload to populate canvas Map (required for save functionality)\n\n // Phase 0.99.31: Track canvas instance before calling loadExistingArtPlacements\n\n this.loadExistingArtPlacements(loadedCanvas);\n } else {\n // Phase 0.99.14: Different strategies for drag vs modal reopen\n //\n // Phase 0.99.13 Problem: Checked canvas state during drag, but canvas Map\n // is empty on EVERY reconnection (new instance created each time).\n // This caused repeated reloads during drag \u2192 duplicates returned!\n //\n // Root Cause Discovery:\n // - Each controller reconnection creates NEW canvas instance\n // - Canvas Map always empty in new instance, even after reload\n // - Phase 0.99.13: Empty canvas \u2192 Reload \u2192 Next reconnection \u2192 Empty again \u2192 Reload again\n // - Result: Multiple reloads during single drag operation \u2192 Duplicates!\n //\n // Key Insight: Canvas state reliability varies by scenario:\n // - DRAG: Canvas state UNRELIABLE (always empty due to new instances)\n // - MODAL REOPEN: Canvas state RELIABLE (stable instance, time to populate)\n //\n // Solution: Use different logic for each scenario:\n // - Drag (\u22641000ms): Trust tracking, IGNORE canvas state\n // - Modal reopen (>1000ms): Check canvas state, reload if needed\n\n if (timeSinceLastConnect <= 1000) {\n // DRAG OPERATION: Recent reconnection - trust tracking, ignore canvas state\n // Canvas state is unreliable during drag (new instances = empty Maps)\n // Trust that tracking means placements were loaded by first instance\n } else {\n // MODAL REOPEN: Significant time gap - check canvas state\n // Canvas state is reliable here (stable instance after modal reopen)\n const canvasMapSize = loadedCanvas.artPlacements?.size || 0;\n\n if (canvasMapSize > 0) {\n // Canvas already populated - skip reload\n } else {\n // Canvas empty - reload to enable save functionality\n\n // Phase 0.99.31: Track canvas instance before calling loadExistingArtPlacements\n\n this.loadExistingArtPlacements(loadedCanvas);\n }\n }\n }\n }\n }\n\n // Phase 4.119: After canvas loads, restore bulk placements with per-product override support\n // CRITICAL: Only restore if canvas is empty to prevent duplicates on multiple callbacks\n\n // Phase 4.212: Bulk restore check diagnostic\n console.log('[Phase 4.212] Bulk restore check:', {\n skipExistingDecorations: this.skipExistingDecorations,\n bulkDataExists: !!globalBulkDecorationPlacements.get(this.saleId),\n bulkDataDefault: globalBulkDecorationPlacements.get(this.saleId)?.default\n ? { placementsCount: globalBulkDecorationPlacements.get(this.saleId).default.placements?.length }\n : null,\n });\n\n if (this.skipExistingDecorations) {\n const bulkData = globalBulkDecorationPlacements.get(this.saleId);\n if (bulkData) {\n // Phase 4.118.1 FIX: Guard against duplicate restoration\n const canvasHasPlacements = loadedCanvas.artPlacements && loadedCanvas.artPlacements.size > 0;\n if (!canvasHasPlacements) {\n // Phase 4.119: Get current product ID and check for product-specific override\n const currentProductId = this.getCurrentProductId();\n\n // Phase 4.415: 3-tier lookup: imageOverride > productOverride > default\n // Each color has a unique imageId; checking imageOverrides first ensures\n // edits on one color don't cascade to other colors of the same product.\n const imageOverride = bulkData.imageOverrides?.[loadedCanvas.imageId];\n\n // Phase 4.420: Skip restore for intentionally cleared images\n if (imageOverride?.cleared) {\n // Image was intentionally cleared by user, keep canvas empty\n } else {\n const productOverride = bulkData.productOverrides?.[currentProductId];\n const placementsToRestore = imageOverride?.placements || productOverride?.placements || bulkData.default?.placements;\n\n if (placementsToRestore && placementsToRestore.length > 0) {\n // Small delay to ensure canvas is fully ready\n // Phase 4.144: Pass loadedCanvas to avoid race condition where\n // this.currentCanvas is nulled by clearAllCanvases() during setTimeout\n setTimeout(() => {\n // Phase 4.174: Re-check canvas state to prevent duplicates from race condition\n // The guard check outside this timeout happens BEFORE the setTimeout, so multiple\n // callbacks could schedule restoration if they fire within 100ms of each other.\n // With destroyArtPlacements() in restoreBulkPlacements, this is belt-and-suspenders.\n const stillEmpty = !loadedCanvas.artPlacements || loadedCanvas.artPlacements.size === 0;\n if (stillEmpty) {\n this.restoreBulkPlacements(placementsToRestore, loadedCanvas);\n }\n }, 100);\n }\n }\n }\n }\n }\n });\n }\n\n async handleProductSelected(event) {\n // Product data is at event.detail root level (not nested)\n // Event structure: { detail: productData } not { detail: { productData: ... } }\n const productData = event.detail;\n\n if (!productData) {\n return;\n }\n\n // Autosave: Check for unsaved changes before switching\n // CRITICAL: Autosave MUST happen BEFORE updating modal context\n // Otherwise autosave will save to the WRONG product (new product instead of old)\n if (this.hasUnsavedChanges) {\n const saved = await this.triggerAutosave();\n\n if (!saved) {\n // Save failed - show error dialog with retry mechanism\n const userChoice = await this.showSaveErrorDialog();\n\n if (userChoice === 'retry') {\n // Retry the entire product switch (includes autosave)\n return this.handleProductSelected(event);\n } else if (userChoice === 'cancel') {\n // User cancelled - abort product switch\n return;\n }\n // 'discard' choice falls through to proceed with switch\n }\n }\n\n // Phase 4.119: In bulk mode, preserve current placements with per-product support\n // CRITICAL: This must run BEFORE modal product ID update so getCurrentProductId()\n // returns the PREVIOUS product (the one whose placements we're storing)\n // Phase 4.143: Removed this.currentCanvas check - use globalCanvasState fallback instead\n if (this.skipExistingDecorations) {\n // Phase 4.143: First try to get canvas from instance, then fallback to globalCanvasState\n let targetCanvas = this.currentCanvas;\n\n // Phase 4.143: Fallback to globalCanvasState if this.currentCanvas is null\n // This handles rapid product switches where controller may be stale\n if (!targetCanvas && this.saleId) {\n const salePrefix = `${this.saleId}_`;\n for (const [key, savedCanvas] of globalCanvasState.entries()) {\n if (key.startsWith(salePrefix) && savedCanvas) {\n const placementCount = savedCanvas.artPlacements?.size || 0;\n if (placementCount > 0) {\n targetCanvas = savedCanvas;\n break;\n }\n }\n }\n }\n\n if (targetCanvas) {\n const placements = this.getAllCurrentPlacements(targetCanvas);\n if (placements.length > 0) {\n let bulkData = globalBulkDecorationPlacements.get(this.saleId);\n\n // Initialize if first time\n if (!bulkData) {\n bulkData = {\n default: null,\n productOverrides: {},\n imageOverrides: {},\n };\n globalBulkDecorationPlacements.set(this.saleId, bulkData);\n }\n\n // Get current product ID from modal (still the PREVIOUS product at this point)\n const currentProductId = this.getCurrentProductId();\n\n // If no default yet, this is the first drop - set as default template\n if (!bulkData.default) {\n bulkData.default = {\n placements: placements,\n sourceImageId: targetCanvas.imageId,\n timestamp: Date.now(),\n };\n } else if (currentProductId) {\n // Phase 4.415: Store per-image override for color-level isolation.\n // Only imageOverrides are written \u2014 productOverrides are intentionally\n // left empty to prevent edits on one color cascading to other colors\n // of the same product (which share the same productId).\n // The coordinatesLocked guard was removed because resize operations don't always\n // trigger dragstart (which unlocks coordinates). The value comparison in\n // placementsDifferFromDefault is sufficient to detect real user changes.\n const currentImageId = targetCanvas.imageId;\n if (currentImageId) {\n if (!bulkData.imageOverrides) bulkData.imageOverrides = {};\n if (this.placementsDifferFromDefault(placements, bulkData.default.placements)) {\n bulkData.imageOverrides[currentImageId] = {\n placements: placements,\n sourceImageId: currentImageId,\n productId: currentProductId,\n timestamp: Date.now(),\n };\n } else {\n delete bulkData.imageOverrides[currentImageId];\n }\n }\n }\n } else {\n // Phase 4.420: Empty canvas + default exists = user intentionally cleared\n const bulkData = globalBulkDecorationPlacements.get(this.saleId);\n const currentProductId = this.getCurrentProductId();\n if (bulkData?.default && currentProductId) {\n this.storeClearedSentinel(bulkData, targetCanvas.imageId, currentProductId);\n }\n }\n }\n }\n\n // CRITICAL: Update modal's product context AFTER storing placements\n // This ensures autosave saves to the OLD product (with unsaved changes)\n // and the modal context is updated to the NEW product only after save completes\n const modalElement = document.querySelector('#decorator-modal');\n if (modalElement) {\n modalElement.dataset.productId = productData.id;\n modalElement.dataset.colorVariantId = productData.color_variant_id || '';\n }\n\n // CRITICAL: Clear ALL canvases, background image, and DOM IMMEDIATELY\n // This prevents both flickering AND old artwork from appearing on new product\n // This happens BEFORE the async fetch so users see instant visual feedback\n this.clearAllCanvases();\n\n // Reset existing decoration data to prevent stale data from previous product\n this.existingDecoration = { art_placements: [] };\n\n // Show loading state\n this.element.classList.add('is-loading');\n\n try {\n // Fetch product color variants and images\n // Use sale ID from controller data (Stimulus 1.1.0 style)\n const saleId = this.saleId || this.element.closest('#decorator-modal')?.dataset.saleId;\n\n // Phase 4.392: Include selected_color_id to ensure correct color variant images are loaded\n // Without this, the endpoint falls back to display_color_variant_id (default color)\n let url = `/teamsites/${saleId}/decorator/product_colors?product_id=${productData.id}`;\n if (productData.selected_color_id || productData.color_variant_id) {\n url += `&selected_color_id=${productData.selected_color_id || productData.color_variant_id}`;\n }\n\n const response = await fetch(url, {\n headers: this.getHeadersWithCSRF({\n 'Accept': 'application/json',\n }),\n });\n\n if (!response.ok) throw new Error('Failed to load product data');\n\n const data = await response.json();\n\n // Update our image data with the new product's images\n if (data.images && data.images.length > 0) {\n this.imageData = data.images;\n\n // Phase 4.117: Don't overwrite existing_decoration in bulk mode\n // The AJAX response contains real decorations, but bulk mode should stay blank\n if (!this.skipExistingDecorations) {\n this.existingDecoration = data.existing_decoration || { art_placements: [] };\n }\n\n // Load the first image of the selected product\n this.loadInitialImage();\n }\n } catch (error) {\n // Error loading product - fail silently\n } finally {\n this.element.classList.remove('is-loading');\n }\n }\n\n async handleColorSelect(event) {\n // In the baseline implementation, the artboard doesn't directly handle color selection\n // Instead, it waits for the orientation selection event that comes after\n // the orientations controller auto-selects the first image\n // This maintains the proper event flow: color select \u2192 orientations update \u2192 orientation select \u2192 canvas update\n\n // We can optionally show loading state while waiting for the orientation selection\n const colorData = event.detail;\n if (!colorData || !colorData.id) return;\n\n // Phase 4.389: Track current color variant for cache key isolation\n // This ensures different color variants have separate canvas states even if they share imageIds\n this.currentColorVariantId = colorData.id;\n\n // Just show loading state - the actual canvas update will happen\n // when the orientation image is selected\n this.element.classList.add('is-loading');\n\n // Remove loading state after a timeout if no orientation selection occurs\n this.loadingTimeout = setTimeout(() => {\n this.element.classList.remove('is-loading');\n }, 2000);\n }\n\n handleOrientationSelect(event) {\n const imageData = event.detail;\n\n // Phase 4.378: Dimension stability check during orientation switch\n // Detect if dimensions have changed significantly since connect()\n const currentDims = { width: this.element.clientWidth, height: this.element.clientHeight };\n if (this.connectTimeDimensions) {\n const widthDiff = Math.abs(currentDims.width - this.connectTimeDimensions.width);\n const heightDiff = Math.abs(currentDims.height - this.connectTimeDimensions.height);\n // Phase 4.385: Use debugLog for hot-path diagnostic\n if (widthDiff > 5 || heightDiff > 5) {\n debugLog('[Phase 4.378] Dimension changed since connect (orientation switch):', {\n connect: this.connectTimeDimensions,\n current: currentDims,\n widthDelta: widthDiff,\n heightDelta: heightDiff,\n note: 'May cause coordinate shift if placements use stale dimensions',\n });\n }\n }\n\n if (this.currentCanvas) {\n // Phase 4.137: Only detach (remove from DOM, keep in Map) - don't destroy\n // This preserves user position changes when switching orientations\n // The detached elements will be re-attached via attachArtPlacements() when\n // switching back to this orientation\n this.currentCanvas.detachArtPlacements();\n }\n\n const canvas = this.findOrCreateCanvas(imageData);\n\n canvas.load((loadedCanvas) => {\n this.onCanvasLoad(loadedCanvas);\n\n // Phase 4.413: Add bulk restore logic (matches loadInitialImage pattern)\n // During product switch, orientation auto-selects and this callback runs.\n // Without this logic, bulk placements were never restored after switch.\n if (this.skipExistingDecorations) {\n const bulkData = globalBulkDecorationPlacements.get(this.saleId);\n\n // Phase 4.413: Diagnostic logging for bulk restore in orientation select\n console.log('[Phase 4.413] handleOrientationSelect bulk restore check:', {\n skipExistingDecorations: this.skipExistingDecorations,\n bulkDataExists: !!bulkData,\n bulkDefaultPlacements: bulkData?.default?.placements?.length || 0,\n });\n\n if (bulkData) {\n const canvasHasPlacements = loadedCanvas.artPlacements && loadedCanvas.artPlacements.size > 0;\n if (!canvasHasPlacements) {\n // Phase 4.415: 3-tier lookup: imageOverride > productOverride > default\n const currentProductId = this.getCurrentProductId();\n const imageOverride = bulkData.imageOverrides?.[loadedCanvas.imageId];\n\n // Phase 4.420: Skip restore for intentionally cleared images\n if (imageOverride?.cleared) {\n // Image was intentionally cleared by user, keep canvas empty\n } else {\n const productOverride = bulkData.productOverrides?.[currentProductId];\n const placementsToRestore = imageOverride?.placements || productOverride?.placements || bulkData.default?.placements;\n\n if (placementsToRestore && placementsToRestore.length > 0) {\n // Use setTimeout to ensure canvas is fully ready\n setTimeout(() => {\n // Re-check canvas state to prevent duplicates from race condition\n const stillEmpty = !loadedCanvas.artPlacements || loadedCanvas.artPlacements.size === 0;\n if (stillEmpty) {\n console.log('[Phase 4.413] Restoring bulk placements from handleOrientationSelect:', {\n count: placementsToRestore.length,\n productId: currentProductId,\n source: imageOverride ? 'imageOverride' : (productOverride ? 'productOverride' : 'default'),\n });\n this.restoreBulkPlacements(placementsToRestore, loadedCanvas);\n }\n }, 100);\n }\n }\n } else {\n // Canvas already has placements in its Map (from a previous visit).\n // detachArtPlacements() removed them from the DOM when switching away.\n // Re-attach them so they are visible again.\n loadedCanvas.attachArtPlacements();\n }\n }\n return; // Skip normal loading in bulk mode\n }\n\n // Phase 4.379: Load placements for this orientation (non-bulk mode)\n // The initial modal render sets existingDecoration to empty { art_placements: [] }\n // to let the frontend load dynamically per image via AJAX.\n // Check if existingDecoration has data for this image first;\n // if not, fall back to AJAX like loadInitialImage() does.\n if (!canvas.artPlacements || canvas.artPlacements.size === 0) {\n const imageId = canvas.imageId;\n const hasImagePlacements = this.existingDecoration?.art_placements_by_image_id?.[imageId]?.length > 0;\n\n if (hasImagePlacements) {\n // Data attribute has placements for this image - use fast path\n this.loadExistingDecorationData(this.existingDecoration);\n } else {\n // No data in attribute - fetch via AJAX (same as loadInitialImage)\n this.loadExistingArtPlacements(canvas);\n }\n }\n\n // Re-attach art placements after switching orientation\n if (canvas.artPlacements.size > 0) {\n canvas.attachArtPlacements();\n }\n });\n\n // Clear loading timeout and state when orientation is selected\n if (this.loadingTimeout) {\n clearTimeout(this.loadingTimeout);\n this.loadingTimeout = null;\n }\n this.element.classList.remove('is-loading');\n }\n\n handleArtworkDrop(event) {\n // Phase 4.190: Extract clientX/clientY for container-relative calculation\n const { artworkId, artworkUrl, dropX, dropY, clientX, clientY, dropzoneRect } = event.detail;\n\n // Phase 4.416: Drop lock - prevent duplicate processing by multiple controller instances\n // During modal animation or DOM re-rendering, two artboard controller instances may briefly\n // coexist, both listening on `document` for `decorator:artwork:drop`. Each would generate a\n // different random `new_*` ID. The lock ensures only the first instance processes the drop.\n // Scoped to bulk mode only \u2014 the race conditions (drop+restore overlap, dual instances during\n // modal animation) only occur in bulk decoration mode.\n if (this.skipExistingDecorations) {\n const dropLockNow = Date.now();\n if (activeDropLock && (dropLockNow - activeDropLock.timestamp) < DROP_LOCK_TIMEOUT_MS\n && activeDropLock.artworkId == artworkId) {\n console.log('[Phase 4.416] Drop lock active, skipping duplicate:', {\n artworkId, lockInstanceId: activeDropLock.instanceId, thisInstanceId: this.instanceId,\n });\n return;\n }\n activeDropLock = { artworkId, timestamp: dropLockNow, instanceId: this.instanceId };\n }\n\n // Phase 4.322: Diagnostic logging to trace layout-dependent coordinate shift\n // Compare boardDimensions at drop time with values from connect()/loadInitialImage()\n console.log('[Phase 4.322] handleArtworkDrop() boardDimensions:', {\n width: this.boardDimensions?.width,\n height: this.boardDimensions?.height,\n elementWidth: this.element?.clientWidth,\n elementHeight: this.element?.clientHeight,\n timestamp: Date.now(),\n saleId: this.saleId,\n note: 'Compare with connect() and loadInitialImage() dimensions',\n });\n\n // Phase 4.372: Drop-time dimension comparison\n // Check if dimensions have changed since connect() - any change indicates instability\n const dropTimeDimensions = {\n width: this.element.clientWidth,\n height: this.element.clientHeight,\n timestamp: Date.now(),\n computed: window.getComputedStyle(this.element).width,\n };\n\n console.log('[PHASE 4.372] DROP TIME boardDimensions:', dropTimeDimensions);\n console.log('[PHASE 4.372] Dimension changed since connect?', {\n widthDiff: dropTimeDimensions.width - this.connectTimeDimensions.width,\n heightDiff: dropTimeDimensions.height - this.connectTimeDimensions.height,\n timeDiff: dropTimeDimensions.timestamp - this.connectTimeDimensions.timestamp,\n });\n\n // Phase 4.378: Warn if dimensions changed significantly (> 5px threshold)\n // This indicates potential coordinate drift from modal animation or layout shift\n const widthDelta = Math.abs(dropTimeDimensions.width - this.connectTimeDimensions.width);\n const heightDelta = Math.abs(dropTimeDimensions.height - this.connectTimeDimensions.height);\n if (widthDelta > 5 || heightDelta > 5) {\n console.warn('[Phase 4.378] DIMENSION INSTABILITY DETECTED (artwork drop):', {\n connect: this.connectTimeDimensions,\n dropTime: dropTimeDimensions,\n widthDelta,\n heightDelta,\n note: 'Coordinate calculations may use stale dimensions - potential shift source',\n });\n }\n\n if (!this.currentCanvas || !this.currentCanvas.isLoaded) {\n console.error('[Artboard] Canvas not ready for artwork drop:', {\n currentCanvas: this.currentCanvas,\n isLoaded: this.currentCanvas?.isLoaded,\n saleId: this.saleId,\n imageDataLength: this.imageData?.length,\n });\n this.showError('Please select a product image first');\n return;\n }\n\n // Phase 4.267: Load artwork image to get natural dimensions for aspect-ratio-correct container\n // This eliminates letterboxing gaps that cause coordinate shift between canvas and baked image.\n // The gap occurs because: container uses background-size:contain, server bakes without letterbox.\n // eslint-disable-next-line no-undef\n const artworkImg = new Image();\n\n artworkImg.onload = () => {\n const aspectRatio = artworkImg.naturalWidth / artworkImg.naturalHeight;\n\n // Target: 100px on the larger dimension (preserves current visual scale)\n const targetSize = 100;\n let defaultWidth, defaultHeight;\n\n if (aspectRatio >= 1) {\n // Wide or square artwork: constrain by width\n defaultWidth = targetSize;\n defaultHeight = targetSize / aspectRatio;\n } else {\n // Tall artwork: constrain by height\n defaultHeight = targetSize;\n defaultWidth = targetSize * aspectRatio;\n }\n\n console.log('[Phase 4.267] Artwork aspect ratio calculated:', {\n naturalWidth: artworkImg.naturalWidth,\n naturalHeight: artworkImg.naturalHeight,\n aspectRatio: aspectRatio.toFixed(4),\n calculatedWidth: defaultWidth.toFixed(2),\n calculatedHeight: defaultHeight.toFixed(2),\n note: 'Container now matches artwork aspect ratio - no letterboxing',\n });\n\n // Continue with the drop using calculated dimensions\n this._processArtworkDrop(event, defaultWidth, defaultHeight, artworkImg);\n };\n\n artworkImg.onerror = () => {\n console.warn('[Phase 4.267] Failed to load artwork image, using square fallback:', artworkUrl);\n // Fallback to square if image fails to load (preserves original behavior)\n this._processArtworkDrop(event, 100, 100, null);\n };\n\n artworkImg.src = artworkUrl;\n }\n\n /**\n * Phase 4.357: Get the actual image height for coordinate calculations.\n * Centralizes the calculation to ensure consistency across all code paths.\n *\n * CRITICAL: imageEl is an in-memory JavaScript Image object created with new Image()\n * in ImageCanvas.load(). It is NEVER attached to the DOM, so getBoundingClientRect()\n * always returns 0 for all properties. Therefore, we use naturalHeight directly.\n *\n * @param {ImageCanvas} canvas - The canvas to get height from (defaults to this.currentCanvas)\n * @returns {number} The actual image height in pixels\n */\n getActualImageHeight(canvas = this.currentCanvas) {\n if (!canvas) {\n return this.boardDimensions?.height || 0;\n }\n\n const imageEl = canvas.imageEl;\n if (!imageEl) {\n return canvas.boardDimensions?.height || this.boardDimensions?.height || 0;\n }\n\n // Use naturalHeight for in-memory Image objects\n // (getBoundingClientRect returns 0 for non-DOM elements)\n return imageEl.naturalHeight || imageEl.height || 0;\n }\n\n // Phase 4.267: Extracted artwork drop processing to support async dimension loading\n _processArtworkDrop(event, defaultWidth, defaultHeight, artworkImg) {\n const { artworkId, artworkUrl, dropX, dropY, clientX, clientY, dropzoneRect } = event.detail;\n\n // Generate a unique placement ID\n const placementId = `new_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;\n\n // Phase 4.373: Removed Phase 4.182 sync code\n // ImageCanvas.boardDimensions is now a getter that always reads live from DOM\n // No sync needed - getter eliminates stale cache issues entirely\n\n // Get the centered image position within the board\n const imagePosition = this.currentCanvas.getBackgroundImageStart();\n\n // Account for drag image hotspot (50, 50 from setDragImage in decorator_artwork_library_controller.js)\n // The drag preview is centered at 50,50 relative to the cursor during drag\n // We need to subtract this offset so the final element position matches where the user saw the preview\n const dragImageHotspotX = 50;\n const dragImageHotspotY = 50;\n\n // Phase 4.226: Enhanced diagnostic logging for coordinate shift investigation\n // This captures all raw inputs BEFORE any calculations to identify if bgStart.y\n // differs between bulk mode (modal) and single mode (full page)\n const boardRect = this.currentCanvas.board ? this.currentCanvas.board.getBoundingClientRect() : null;\n console.log('[Phase 4.226] handleArtworkDrop raw inputs:', {\n // Event coordinates\n clientX: event.clientX,\n clientY: event.clientY,\n // Board reference\n boardRectTop: boardRect?.top,\n boardRectLeft: boardRect?.left,\n boardRectWidth: boardRect?.width,\n boardRectHeight: boardRect?.height,\n // Image offset (bgStart) - KEY VALUE TO COMPARE\n imagePositionX: imagePosition.x,\n imagePositionY: imagePosition.y,\n // Drag offset\n dragImageHotspotX,\n dragImageHotspotY,\n // Context\n isBulkMode: this.skipExistingDecorations,\n canvasBoardWidth: this.currentCanvas.board?.clientWidth,\n canvasBoardHeight: this.currentCanvas.board?.clientHeight,\n // Image dimensions\n imageNaturalHeight: this.currentCanvas.imageEl?.naturalHeight,\n imageNaturalWidth: this.currentCanvas.imageEl?.naturalWidth,\n scalingFactor: this.currentCanvas.getScalingFactor(),\n });\n\n // Phase 4.238: Critical isVerticalFit diagnostic to detect fit direction flipping\n // If isVerticalFit differs between single and bulk mode, bgStart is applied on wrong axis\n const isVertical = this.currentCanvas.isVerticalFit ? this.currentCanvas.isVerticalFit() : null;\n const imageRatio = this.currentCanvas.imageEl\n ? (this.currentCanvas.imageEl.naturalHeight / this.currentCanvas.imageEl.naturalWidth)\n : null;\n const boardRatio = this.currentCanvas.boardDimensions\n ? (this.currentCanvas.boardDimensions.height / this.currentCanvas.boardDimensions.width)\n : null;\n\n console.log('[Phase 4.238] CRITICAL fit direction check:', {\n mode: this.skipExistingDecorations ? 'BULK' : 'SINGLE',\n isVerticalFit: isVertical,\n imageRatio: imageRatio?.toFixed(6),\n boardRatio: boardRatio?.toFixed(6),\n comparison: imageRatio && boardRatio ? `${imageRatio.toFixed(6)} > ${boardRatio.toFixed(6)} = ${imageRatio > boardRatio}` : 'N/A',\n boardDimensions: this.currentCanvas.boardDimensions,\n // If these are close (within 1%), fit direction could flip with minor board changes\n ratioDifference: imageRatio && boardRatio ? Math.abs(imageRatio - boardRatio).toFixed(6) : 'N/A',\n isEdgeCase: imageRatio && boardRatio ? Math.abs(imageRatio - boardRatio) < 0.01 : false,\n });\n\n // Phase 4.227: Additional diagnostic for drop coordinates and fallback path calculation\n console.log('[Phase 4.227] Drop coordinates:', {\n dropX,\n dropY,\n dropzoneRect,\n // Calculate what the fallback path produces (when clientX/clientY undefined)\n fallbackAdjustedX: dropX - dragImageHotspotX - imagePosition.x,\n fallbackAdjustedY: dropY - dragImageHotspotY - imagePosition.y,\n });\n\n // Phase 4.190: Use CONTAINER-relative coordinates instead of DROPZONE-relative\n // The bug: dropX/dropY are relative to the dropzone element, but artwork is\n // placed in the previewContainer (dropzone-previews). If these elements have\n // different positions, coordinates are incorrect.\n // The fix: Calculate position relative to the actual container.\n const container = this.currentCanvas.container;\n const containerRect = container ? container.getBoundingClientRect() : null;\n // boardRect already declared above in Phase 4.226 logging\n\n // Phase 4.237: Calculate position relative to BOARD (not container)\n // imagePosition (bgStart) is calculated relative to board, so we must use\n // boardRect for consistency. Phase 4.190 used containerRect which caused\n // ~20px shift in bulk mode where container has different position than board.\n let adjustedDropX, adjustedDropY;\n\n // Phase 4.237: Log container vs board offset for debugging\n if (containerRect && boardRect) {\n const containerToBoardOffsetY = boardRect.top - containerRect.top;\n if (containerToBoardOffsetY !== 0) {\n console.log('[Phase 4.237] Container-to-board offset detected:', {\n containerToBoardOffsetY,\n containerRectTop: containerRect.top,\n boardRectTop: boardRect.top,\n });\n }\n }\n\n if (clientX !== undefined && clientY !== undefined && boardRect) {\n // Phase 4.237: BOARD-relative calculation\n // imagePosition (bgStart) is relative to board, so we must use boardRect\n adjustedDropX = clientX - boardRect.left - dragImageHotspotX;\n adjustedDropY = clientY - boardRect.top - dragImageHotspotY;\n } else {\n // Fallback: Dropzone-relative calculation (legacy)\n // Phase 4.236: Account for potential offset between dropzone and board elements\n // The dropY is relative to DROPZONE, but imagePosition is relative to BOARD.\n // If these elements have different positions, we must compensate.\n const dropzoneToBoardOffsetX = (dropzoneRect && boardRect)\n ? (boardRect.left - dropzoneRect.left)\n : 0;\n const dropzoneToBoardOffsetY = (dropzoneRect && boardRect)\n ? (boardRect.top - dropzoneRect.top)\n : 0;\n\n // Phase 4.236: Log offset compensation when non-zero (diagnostic)\n if (dropzoneToBoardOffsetX !== 0 || dropzoneToBoardOffsetY !== 0) {\n console.log('[Phase 4.236] Dropzone-to-board offset compensation:', {\n dropzoneToBoardOffsetX,\n dropzoneToBoardOffsetY,\n dropzoneRect: dropzoneRect ? { top: dropzoneRect.top, left: dropzoneRect.left } : null,\n boardRect: boardRect ? { top: boardRect.top, left: boardRect.left } : null,\n });\n }\n\n adjustedDropX = dropX + dropzoneToBoardOffsetX - dragImageHotspotX;\n adjustedDropY = dropY + dropzoneToBoardOffsetY - dragImageHotspotY;\n }\n\n // Subtract BOTH bgStart components - one will be ~0 based on actual fit\n adjustedDropX = adjustedDropX - imagePosition.x;\n adjustedDropY = adjustedDropY - imagePosition.y;\n\n // Phase 4.372: Detailed drop coordinate calculation logging\n // Show EXACTLY how coordinates are calculated to identify any differences\n console.log('[PHASE 4.372] Drop calculation details:', {\n // Event coordinates\n eventClientX: clientX,\n eventClientY: clientY,\n\n // Canvas position\n canvasRect: boardRect ? {\n top: boardRect.top,\n left: boardRect.left,\n width: boardRect.width,\n height: boardRect.height,\n } : null,\n canvasTop: boardRect?.top,\n canvasLeft: boardRect?.left,\n\n // Calculated artwork position (AFTER all adjustments)\n calculatedTop: adjustedDropY,\n calculatedLeft: adjustedDropX,\n\n // Image position offset (bgStart)\n imagePositionX: imagePosition.x,\n imagePositionY: imagePosition.y,\n\n // Drag hotspot offset\n dragImageHotspotX,\n dragImageHotspotY,\n\n // Context\n context: this.skipExistingDecorations ? 'bulk' : 'single_product',\n skipExistingDecorations: this.skipExistingDecorations,\n });\n\n // Calculate dimensions for percentage calculations\n // The backend expects percentages relative to the ACTUAL image height\n\n // Phase 4.241: Log boardDimensions BEFORE inverse transform to detect stale cached dimensions\n // The forward transform (in convertCoordinates) syncs fresh dimensions,\n // so if cached != live here, inverse and forward transforms will use different params\n console.log('[Phase 4.241] PRE-INVERSE boardDimensions:', {\n mode: this.skipExistingDecorations ? 'BULK' : 'SINGLE',\n cached: this.currentCanvas.boardDimensions,\n live: {\n width: this.currentCanvas.board?.clientWidth,\n height: this.currentCanvas.board?.clientHeight,\n },\n mismatch: this.currentCanvas.boardDimensions?.width !== this.currentCanvas.board?.clientWidth ||\n this.currentCanvas.boardDimensions?.height !== this.currentCanvas.board?.clientHeight,\n });\n\n // Phase 4.373: Removed Phase 4.293 sync code\n // ImageCanvas.boardDimensions is now a getter that always reads live from DOM\n // No sync needed - getter eliminates stale cache issues entirely\n\n const scalingFactor = this.currentCanvas.getScalingFactor();\n const imageEl = this.currentCanvas.imageEl;\n\n // Phase 4.357: Use consolidated helper method for actualImageHeight calculation\n // NOTE: imageEl is an in-memory Image object (new Image()) that is NEVER attached\n // to the DOM, so getBoundingClientRect() always returns 0. We use naturalHeight directly.\n const actualImageHeight = this.getActualImageHeight(this.currentCanvas);\n\n console.log('[Phase 4.357] actualImageHeight (_processArtworkDrop):', {\n actualImageHeight: actualImageHeight,\n naturalHeight: imageEl?.naturalHeight,\n boardHeight: this.currentCanvas.boardDimensions?.height,\n scalingFactor: scalingFactor,\n });\n\n // For visual display, we need scaled coordinates\n // But for saving, we need percentages of actual image dimensions\n // Convert the adjusted drop coordinates (which are in scaled space) to actual image space\n const scaleAdjustedDropX = adjustedDropX / scalingFactor;\n const scaleAdjustedDropY = adjustedDropY / scalingFactor;\n const scaleAdjustedWidth = defaultWidth / scalingFactor;\n const scaleAdjustedHeight = defaultHeight / scalingFactor;\n\n // Phase 4.272: Detailed diagnostic for LEFT coordinate shift bug\n // The bug: stored left% doesn't match visual position, causing ~80px LEFT shift\n // This traces EVERY step to find where the extra offset comes from\n console.log('[Phase 4.272] LEFT COORDINATE TRACE:', {\n // Step 1: Raw client position\n clientX: clientX,\n boardRectLeft: boardRect?.left,\n step1_boardRelative: clientX - (boardRect?.left || 0),\n // Step 2: After hotspot subtraction\n dragImageHotspotX: dragImageHotspotX,\n step2_afterHotspot: (clientX - (boardRect?.left || 0)) - dragImageHotspotX,\n // Step 3: After bgStart subtraction\n bgStartX: imagePosition.x,\n step3_afterBgStart: adjustedDropX, // This is the final value used\n // Step 4: After scaling\n scalingFactor: scalingFactor,\n step4_scaleAdjusted: scaleAdjustedDropX,\n // Step 5: Final percentage\n actualImageHeight: actualImageHeight,\n step5_rawLeftPct: (scaleAdjustedDropX / actualImageHeight) * 100,\n // Container comparison\n containerRectLeft: containerRect?.left,\n containerBoardOffset: (containerRect?.left || 0) - (boardRect?.left || 0),\n // Expected vs actual (for verification)\n // If visual position is X, expected left% should be:\n // ((X - bgStart.x) / scalingFactor / naturalHeight) * 100\n });\n\n // Phase 4.275: Detailed diagnostic for TOP coordinate shift bug (isVerticalFit=false)\n // The bug: When isVerticalFit=false (bgStart.y > 0), artwork shifts UPWARD after save\n // This traces EVERY step to find where the Y coordinate discrepancy comes from\n console.log('[Phase 4.275] TOP COORDINATE TRACE:', {\n // Step 1: Raw client position\n clientY: clientY,\n boardRectTop: boardRect?.top,\n step1_boardRelative: clientY - (boardRect?.top || 0),\n // Step 2: After hotspot subtraction\n dragImageHotspotY: dragImageHotspotY,\n step2_afterHotspot: (clientY - (boardRect?.top || 0)) - dragImageHotspotY,\n // Step 3: After bgStart subtraction\n bgStartY: imagePosition.y,\n step3_afterBgStart: adjustedDropY, // This is the final value used\n // Step 4: After scaling\n scalingFactor: scalingFactor,\n step4_scaleAdjusted: scaleAdjustedDropY,\n // Step 5: Final percentage\n actualImageHeight: actualImageHeight,\n step5_rawTopPct: (scaleAdjustedDropY / actualImageHeight) * 100,\n // Orientation check (isVerticalFit=false means bgStart.y > 0 is expected)\n isVerticalFit: this.currentCanvas.isVerticalFit?.() ?? 'unknown',\n // Container comparison for Y\n containerRectTop: containerRect?.top,\n containerBoardOffsetY: (containerRect?.top || 0) - (boardRect?.top || 0),\n });\n\n // Calculate raw percentages\n // Phase 4.66: Fix coordinate drift bug - remove double center offset subtraction\n // The hotspot subtraction (lines 617-625) already converts cursor position to left edge\n // of artwork. Since drag preview (100x100) = default artwork (100x100), no additional\n // centering adjustment is needed. The previous -width/2 caused artwork to shift\n // UP and LEFT after save due to double subtraction.\n const rawLeft = (scaleAdjustedDropX / actualImageHeight) * 100;\n const rawTop = (scaleAdjustedDropY / actualImageHeight) * 100;\n const rawWidth = (scaleAdjustedWidth / actualImageHeight) * 100;\n const rawHeight = (scaleAdjustedHeight / actualImageHeight) * 100;\n\n // Phase 0.99.56: Clamp at DROP TIME to prevent whitespace placements\n // Artwork dropped in whitespace areas (above/below product image on landscape images)\n // gets coordinates outside 0-100% range, rendering off-canvas in composite.\n // Clamp ONLY at drop time so artwork snaps to image edge immediately.\n // This does NOT affect handlePlacementUpdate or reconstructMapFromDOM -\n // subsequent moves/resizes and modal reopens work normally.\n const clampedLeft = Math.max(0, Math.min(rawLeft, 100 - rawWidth));\n const clampedTop = Math.max(0, Math.min(rawTop, 100 - rawHeight));\n\n // Phase 4.240: Log actual drop position vs calculated percentage\n // To find where the ~2% error is introduced in bulk mode\n // Compare SINGLE vs BULK mode to identify which input value differs\n console.log('[Phase 4.240] DROP POSITION TRACE:', {\n mode: this.skipExistingDecorations ? 'BULK' : 'SINGLE',\n // Raw inputs from dropzone\n rawDropX: dropX,\n rawDropY: dropY,\n clientX: clientX,\n clientY: clientY,\n // Hotspot adjustment\n dragImageHotspotY: dragImageHotspotY,\n // Rect values\n dropzoneRect: dropzoneRect ? { top: dropzoneRect.top, left: dropzoneRect.left } : null,\n boardRect: boardRect ? { top: boardRect.top, left: boardRect.left } : null,\n // bgStart (imagePosition)\n imagePosition: imagePosition,\n // Adjusted values (after bgStart subtraction)\n adjustedDropY: adjustedDropY,\n // Scale factors\n scalingFactor: scalingFactor,\n actualImageHeight: actualImageHeight,\n // Final calculation\n scaleAdjustedDropY: scaleAdjustedDropY,\n rawTop: rawTop,\n clampedTop: clampedTop,\n // Verify: percentage * imageHeight should give back scaleAdjustedDropY\n reverseCheck: (rawTop / 100) * actualImageHeight,\n });\n\n // Phase 4.298: Round-trip verification - forward transform the percentage back to pixels\n // This reveals if inverse/forward transforms are using different parameters.\n // The forward transform should produce the same pixel position as the original drop.\n // Formula: forwardPixel = (percentage / 100 * imageHeight * scalingFactor) + bgStart.y\n const forwardTransformY = ((clampedTop / 100) * actualImageHeight * scalingFactor) + imagePosition.y;\n const originalDropY = clientY - (boardRect?.top || 0) - dragImageHotspotY;\n const roundTripErrorY = Math.abs(forwardTransformY - originalDropY);\n const roundTripErrorPct = (roundTripErrorY / (actualImageHeight * scalingFactor)) * 100;\n\n console.log('[Phase 4.298] ROUND-TRIP VERIFICATION:', {\n mode: this.skipExistingDecorations ? 'BULK' : 'SINGLE',\n // Original cursor position (where user dropped)\n originalDropY: originalDropY.toFixed(2),\n // Percentage calculated from inverse transform\n calculatedPct: clampedTop.toFixed(4),\n // Forward transform back to pixels\n forwardTransformY: forwardTransformY.toFixed(2),\n // Error analysis\n roundTripErrorY: roundTripErrorY.toFixed(2),\n roundTripErrorPct: roundTripErrorPct.toFixed(4),\n // Parameters used\n bgStartY: imagePosition.y.toFixed(2),\n scalingFactor: scalingFactor.toFixed(4),\n actualImageHeight: actualImageHeight,\n // ALERT if error exceeds 1px\n MISMATCH: roundTripErrorY > 1 ? `\u26A0\uFE0F ERROR ${roundTripErrorY.toFixed(2)}px - transforms inconsistent!` : '\u2713 OK',\n });\n\n // Phase 4.172: Check bulk mode BEFORE creating placement object\n // This allows us to set coordinatesLocked in the placement data,\n // which ImageCanvas.addArtPlacement copies BEFORE DOM events fire\n const modalElement = document.querySelector('#decorator-modal');\n const productIds = modalElement?.dataset.productIds ? JSON.parse(modalElement.dataset.productIds) : [];\n const isBulkMode = productIds.length > 1;\n\n // Phase 4.268: Build dropTimeCoordinates BEFORE addArtPlacement call\n // Previously, dropTimeCoordinates was set AFTER addArtPlacement (line 1257+),\n // but if addArtPlacement queues the placement (image not loaded or conversion fails),\n // artPlacements.get() returns undefined and dropTimeCoordinates was NEVER SET.\n // This caused the coordinate shift bug where debug panel showed \"dropTime top: -\".\n // Fix: Include dropTimeCoordinates IN the placement object so addArtPlacement\n // copies it properly (lines 437-439 in image_canvas.js) even when queued.\n const dropTimeCoordinates = {\n left: clampedLeft,\n top: clampedTop,\n width: rawWidth,\n height: rawHeight,\n artworkId: artworkId,\n artworkUrl: artworkUrl,\n // Phase 4.384: Include default tool values for intentional change detection\n rotation: 0,\n flipX: false,\n flipY: false,\n // Phase 4.246: Store transform parameters used for inverse calculation\n boardDimensions: this.currentCanvas.boardDimensions ? {\n width: this.currentCanvas.boardDimensions.width,\n height: this.currentCanvas.boardDimensions.height,\n } : null,\n bgStart: imagePosition ? {\n x: imagePosition.x,\n y: imagePosition.y,\n } : null,\n scalingFactor: scalingFactor || null,\n // Phase 4.278: Store isVerticalFit for server-side coordinate transformation\n isVerticalFit: this.currentCanvas?.isVerticalFit?.() ?? null,\n // Phase 4.400: Track if drop happened in modal context for context-aware bgStart handling\n isModalContext: !!this.element.closest('#decorator-modal'),\n timestamp: Date.now(),\n // Phase 4.267: Store artwork natural dimensions for letterbox offset calculation\n artworkNaturalWidth: artworkImg?.naturalWidth || null,\n artworkNaturalHeight: artworkImg?.naturalHeight || null,\n // Phase 4.295: Store actualImageHeight used for inverse transform\n // This is CRITICAL because if the image isn't fully loaded at drop time,\n // naturalHeight may be 0 and we fall back to CSS height (e.g., 600px).\n // But at load time, naturalHeight is available (e.g., 768px), causing\n // percentage \u2192 pixel conversion to produce wrong position.\n // By storing the exact height used for percentage calculation, the forward\n // transform can use the same height for accurate round-trip conversion.\n actualImageHeight: actualImageHeight,\n };\n\n console.log('[Phase 4.268] dropTimeCoordinates built BEFORE addArtPlacement:', {\n placementId,\n top: dropTimeCoordinates.top,\n left: dropTimeCoordinates.left,\n boardDimensions: dropTimeCoordinates.boardDimensions,\n });\n\n // Phase 4.309: DROP TIME CONSISTENCY CHECK\n // Verify that bgStart values are consistent between capture and storage.\n // If these don't match, the bug is a dimension change between capture and storage.\n console.log('[Phase 4.309] DROP TIME CONSISTENCY CHECK:', {\n placementId: placementId,\n // bgStart captured at line 915\n imagePositionCaptured: { x: imagePosition.x, y: imagePosition.y },\n // bgStart stored in dropTimeCoordinates\n storedInDropTimeCoords: dropTimeCoordinates.bgStart,\n // Current bgStart (may differ if board changed)\n currentBgStart: this.currentCanvas.getBackgroundImageStart(),\n // Match check\n capturedMatchesStored:\n Math.abs(imagePosition.y - (dropTimeCoordinates.bgStart?.y || 0)) < 0.01,\n capturedMatchesCurrent:\n Math.abs(imagePosition.y - this.currentCanvas.getBackgroundImageStart().y) < 0.01,\n });\n\n const placement = {\n id: placementId,\n artworkId: artworkId,\n artworkUrl: artworkUrl,\n // Store as percentages (0-100 range) - baseline uses % of actual image height for ALL dimensions\n left: clampedLeft,\n top: clampedTop,\n width: rawWidth,\n height: rawHeight,\n rotation: 0,\n flipX: false,\n flipY: false,\n // Phase 4.218: Lock coordinates IMMEDIATELY at drop time\n // Previous approach (Phase 4.179/4.193) used delayed locking via needsLockAfterStabilization,\n // but this created a timing window where handlePlacementUpdate could fire before the lock\n // was set, corrupting originalData. This was especially problematic in Safari bulk mode\n // due to extra layout events during modal animation.\n // The lock is cleared by handlePlacementDragStart when user explicitly drags/resizes.\n coordinatesLocked: true,\n // Phase 4.268: Include dropTimeCoordinates so addArtPlacement copies it\n // even if the placement is queued for retry\n dropTimeCoordinates: dropTimeCoordinates,\n };\n\n // Add the placement to the canvas\n this.currentCanvas.addArtPlacement(placement);\n\n // Phase 4.381: Track addition in change tracking system\n // This ensures newly added placements are tracked for context switches\n // Phase 4.389: Include colorVariantId for state isolation per color\n const changes = getCanvasChanges(this.saleId, this.currentColorVariantId, this.currentCanvas.imageId);\n changes.added.add(placementId);\n console.log('[Phase 4.381] Tracked new placement addition:', { placementId });\n\n // Phase 4.153: Initialize originalData immediately after drop\n // This ensures getAllCurrentPlacements() uses stored percentages instead of\n // falling back to DOM reading, which produces incorrect coordinates (20.42% vs 22.21%)\n // Root cause: Without originalData, the guard condition at lines 2108-2151 fails\n const artPlacement = this.currentCanvas.artPlacements.get(placementId);\n if (artPlacement) {\n // Phase 4.178: Removed authoritative pixel storage (Phase 4.177 reverted)\n // Now using threshold-based filtering in handlePlacementUpdate instead\n artPlacement.originalData = {\n left: clampedLeft,\n top: clampedTop,\n width: rawWidth,\n height: rawHeight,\n artworkId: artworkId,\n artworkUrl: artworkUrl,\n rotation: 0,\n flipX: false,\n flipY: false,\n // Phase 4.218: Removed needsLockAfterStabilization - now using immediate locking\n // coordinatesLocked is set on placement object directly (line 954)\n };\n\n // Phase 4.195: Diagnostic logging to identify coordinate mismatch\n console.log('[Phase 4.195] handleArtworkDrop originalData:', {\n placementId,\n rawTop,\n clampedTop,\n rawLeft,\n clampedLeft,\n rawWidth,\n rawHeight,\n actualImageHeight,\n scalingFactor,\n boardDimensions: this.currentCanvas.boardDimensions,\n naturalDimensions: {\n width: imageEl?.naturalWidth,\n height: imageEl?.naturalHeight,\n },\n cssDimensions: {\n width: imageEl?.width,\n height: imageEl?.height,\n },\n bgStart: imagePosition,\n adjustedDropX,\n adjustedDropY,\n scaleAdjustedDropX,\n scaleAdjustedDropY,\n });\n\n // Phase 4.196: Log actual CSS position of artwork element after DOM insertion\n const element = typeof artPlacement.getElement === 'function' ? artPlacement.getElement() : null;\n if (element && typeof element.getBoundingClientRect === 'function') {\n const elementRect = element.getBoundingClientRect();\n const containerRect = this.currentCanvas.container?.getBoundingClientRect();\n console.log('[Phase 4.196] Artwork element CSS position:', {\n placementId,\n styleTop: element.style.top,\n styleLeft: element.style.left,\n styleWidth: element.style.width,\n styleHeight: element.style.height,\n boundingRect: {\n top: elementRect.top,\n left: elementRect.left,\n width: elementRect.width,\n height: elementRect.height,\n },\n containerRect: containerRect ? {\n top: containerRect.top,\n left: containerRect.left,\n } : null,\n relativeToContainer: containerRect ? {\n top: elementRect.top - containerRect.top,\n left: elementRect.left - containerRect.left,\n } : null,\n storedPercentages: {\n top: clampedTop,\n left: clampedLeft,\n width: rawWidth,\n height: rawHeight,\n },\n });\n }\n\n // Phase 4.229: Final confirmation of what was stored - compare with save-time log\n console.log('[Phase 4.229] handleArtworkDrop STORED:', {\n placementId,\n storedTop: artPlacement.originalData?.top,\n storedLeft: artPlacement.originalData?.left,\n coordinatesLocked: artPlacement.coordinatesLocked,\n });\n\n // Phase 4.268: dropTimeCoordinates is now set in the placement object BEFORE\n // addArtPlacement (lines 1135-1170), so addArtPlacement copies it automatically.\n // This fallback only sets it if somehow not already present (defensive).\n if (!artPlacement.dropTimeCoordinates) {\n console.warn('[Phase 4.268] dropTimeCoordinates not copied by addArtPlacement, setting fallback');\n artPlacement.dropTimeCoordinates = dropTimeCoordinates;\n } else {\n console.log('[Phase 4.268] dropTimeCoordinates already set by addArtPlacement:', {\n top: artPlacement.dropTimeCoordinates.top,\n left: artPlacement.dropTimeCoordinates.left,\n });\n }\n\n // Phase 4.247: Log image dimensions for server comparison\n // Server uses base_image.height (from MiniMagick) for baking.\n // If this differs from JavaScript's naturalHeight, the baked image will be shifted.\n // Compare this log with Heroku log: [Phase 4.195] ImageComposer base_image height=XXXXpx\n console.log('[Phase 4.247] Image dimensions for server comparison:', {\n naturalHeight: imageEl?.naturalHeight,\n naturalWidth: imageEl?.naturalWidth,\n cssHeight: imageEl?.height,\n cssWidth: imageEl?.width,\n actualImageHeightUsed: actualImageHeight,\n imageUrl: this.currentCanvas?.imageUrl || 'unknown',\n mode: this.skipExistingDecorations ? 'BULK' : 'SINGLE',\n // IMPORTANT: Server must see the same naturalHeight for baked position to match\n });\n\n // Phase 4.249: Log DROP-TIME context for comparison with FORWARD-TRANSFORM context\n // The key hypothesis: bulk mode and single mode use different boardDimensions/bgStart,\n // which causes the percentage to be correct for bulk mode but wrong for display.\n // Compare these values with [Phase 4.249] FORWARD-TRANSFORM logs to identify mismatch.\n console.log('[Phase 4.249] DROP-TIME context:', {\n mode: this.skipExistingDecorations ? 'BULK' : 'SINGLE',\n boardDimensions: this.currentCanvas?.boardDimensions,\n bgStart: imagePosition,\n scalingFactor: scalingFactor,\n isVerticalFit: this.currentCanvas?.isVerticalFit?.(),\n calculatedTop: clampedTop,\n calculatedLeft: clampedLeft,\n imageNaturalHeight: imageEl?.naturalHeight,\n imageNaturalWidth: imageEl?.naturalWidth,\n // COMPARE: If these differ from FORWARD-TRANSFORM logs, that's the shift cause\n });\n\n // Phase 4.323: Log COMPLETE inverse transform values for comparison with forward transform\n // After save, compare these values with [Phase 4.323] FORWARD TRANSFORM COMPLETE log.\n // If any parameter differs (bgStart, scalingFactor, imageHeight), that's the root cause.\n const expectedReloadTopPx = ((clampedTop / 100) * actualImageHeight * scalingFactor) + imagePosition.y;\n const expectedReloadLeftPx = ((clampedLeft / 100) * actualImageHeight * scalingFactor) + imagePosition.x;\n console.log('[Phase 4.323] INVERSE TRANSFORM COMPLETE:', {\n placementId: placementId,\n // Stored percentages (input to forward transform after reload)\n storedTopPct: clampedTop,\n storedLeftPct: clampedLeft,\n // Values used for inverse transform (should match forward transform values)\n bgStartUsed: { x: imagePosition.x, y: imagePosition.y },\n scalingFactorUsed: scalingFactor,\n actualImageHeightUsed: actualImageHeight,\n // Board dimensions\n boardWidth: this.currentCanvas.boardDimensions?.width,\n boardHeight: this.currentCanvas.boardDimensions?.height,\n // For verification: Reverse the calculation to get expected pixel position on reload\n // Forward formula: pixel = (pct / 100 * imageHeight * scalingFactor) + bgStart\n expectedReloadTopPx: expectedReloadTopPx.toFixed(2),\n expectedReloadLeftPx: expectedReloadLeftPx.toFixed(2),\n // Mode context\n mode: this.skipExistingDecorations ? 'BULK' : 'SINGLE',\n });\n\n // Phase 4.251: Dispatch debug event for visual debug panel\n document.dispatchEvent(new CustomEvent('decorator:debug:placement-drop', {\n detail: {\n placementId: placementId,\n mode: this.skipExistingDecorations ? 'BULK' : 'SINGLE',\n boardDimensions: this.currentCanvas?.boardDimensions,\n bgStart: imagePosition,\n scalingFactor: scalingFactor,\n isVerticalFit: this.currentCanvas?.isVerticalFit?.(),\n top: clampedTop,\n left: clampedLeft,\n width: rawWidth,\n height: rawHeight,\n imageNaturalHeight: imageEl?.naturalHeight,\n imageNaturalWidth: imageEl?.naturalWidth,\n dropTimeCoordinates: artPlacement.dropTimeCoordinates,\n },\n }));\n\n // Phase 4.241: Compare calculated percentage vs CSS position after DOM update\n // Uses setTimeout to ensure forward transform (convertCoordinates) has completed\n // If these differ, the inverse and forward transforms used different parameters\n setTimeout(() => {\n const el = this.currentCanvas?.artPlacements.get(placementId)?.getElement?.();\n if (el) {\n const cssTopValue = parseFloat(el.style.top) || 0;\n const topMismatch = Math.abs(cssTopValue - clampedTop) > 0.1;\n console.log('[Phase 4.241] DROP vs CSS POSITION:', {\n mode: this.skipExistingDecorations ? 'BULK' : 'SINGLE',\n calculatedTop: clampedTop.toFixed(6) + '%',\n cssTop: el.style.top,\n cssTopParsed: cssTopValue.toFixed(6),\n topDifference: Math.abs(cssTopValue - clampedTop).toFixed(6),\n mismatch: topMismatch,\n // If mismatch is true, the forward transform used different params than inverse\n });\n }\n }, 100);\n }\n\n // Emit state change for history\n this.emitStateChange();\n\n // Phase 4.11: Mark canvas as dirty for unsaved changes tracking (November 2025)\n this.markAsDirty();\n\n // Phase 4.254: Dispatch placements-changed event for debug panel\n this.dispatchPlacementsChanged();\n\n // Phase 4.409: Direct bulk storage on drop\n // Phase 4.407 used captureCurrentPlacementsToGlobal() which reads from artPlacements Map.\n // BUG: If convertCoordinates() fails (canvas not ready), placement goes to pendingPlacements\n // queue, NOT artPlacements Map. So captureCurrentPlacementsToGlobal() captures nothing.\n // FIX: Directly store the placement data we already have - no Map lookup needed.\n if (this.skipExistingDecorations) {\n // Construct placement data in the format expected by globalBulkDecorationPlacements\n // (matches getPlacementData() output format from lines 3343-3362)\n // Phase 4.412: Include artworkUrl - restoreBulkPlacements() expects it at line 4166\n // Without this, the element is created but has no image source (invisible)\n const placementForStorage = {\n artworkId: parseInt(artworkId),\n artworkUrl: artworkUrl,\n left: clampedLeft,\n top: clampedTop,\n width: rawWidth,\n height: rawHeight,\n rotate: 0, // radians - 0 at drop time\n mirrorHorizontally: false,\n mirrorVertically: false,\n // Include dropTimeCoordinates fields for server-side geometry\n dropTimeBgStartX: dropTimeCoordinates.bgStart?.x ?? null,\n dropTimeBgStartY: dropTimeCoordinates.bgStart?.y ?? null,\n dropTimeIsVerticalFit: dropTimeCoordinates.isVerticalFit ?? null,\n dropTimeActualImageHeight: dropTimeCoordinates.actualImageHeight ?? null,\n // Phase 4.417: Store image fractions for cross-aspect-ratio bulk positioning\n // left% uses imageHeight as denominator (not imageWidth), so when aspect ratio differs\n // between products, the same left% maps to a different fraction of image width.\n // imageFractionX = X position as fraction of source image WIDTH (0-1)\n // imageFractionY = Y position as fraction of source image HEIGHT (0-1)\n imageFractionX: (imageEl?.naturalWidth > 0) ? scaleAdjustedDropX / imageEl.naturalWidth : null,\n imageFractionY: (imageEl?.naturalHeight > 0) ? scaleAdjustedDropY / imageEl.naturalHeight : null,\n };\n\n console.log('[Phase 4.409] Bulk drop - storing placement directly', {\n saleId: this.saleId,\n placementId: placementId,\n placement: placementForStorage,\n });\n\n // Store directly to global - bypass artPlacements Map lookup\n let bulkData = globalBulkDecorationPlacements.get(this.saleId);\n if (!bulkData) {\n bulkData = { default: null, productOverrides: {}, imageOverrides: {} };\n globalBulkDecorationPlacements.set(this.saleId, bulkData);\n }\n\n const currentImageId = this.currentCanvas?.imageId;\n\n if (!bulkData.default) {\n // First placement - set as default\n bulkData.default = {\n placements: [placementForStorage],\n sourceImageId: currentImageId,\n timestamp: Date.now(),\n };\n } else {\n // Add to existing default placements\n bulkData.default.placements.push(placementForStorage);\n bulkData.default.timestamp = Date.now();\n }\n\n console.log('[Phase 4.409] After direct storage:', {\n hasDefault: !!bulkData.default,\n defaultCount: bulkData.default?.placements?.length,\n });\n }\n\n // Phase 4.416: Release drop lock after retry window (0+16+100ms margin)\n // The addArtPlacement retry cycle uses delays of 0ms, 16ms, 100ms.\n // 500ms covers the full retry window plus margin.\n if (activeDropLock && activeDropLock.instanceId === this.instanceId) {\n const dropLockTimestamp = activeDropLock.timestamp;\n setTimeout(() => {\n if (activeDropLock && activeDropLock.timestamp === dropLockTimestamp) {\n activeDropLock = null;\n }\n }, 500);\n }\n\n // Autosave disabled - user must click Save button\n }\n\n // Autosave: Schedule debounced autosave\n scheduleAutosave() {\n // Clear any pending autosave\n if (this.autosaveTimeoutId) {\n clearTimeout(this.autosaveTimeoutId);\n }\n\n // Schedule new autosave after delay\n this.autosaveTimeoutId = setTimeout(async () => {\n this.autosaveTimeoutId = null;\n\n // Only autosave if there are unsaved changes\n if (!this.hasDirtyState()) {\n return;\n }\n\n const saved = await this.triggerAutosave();\n\n if (!saved) {\n // Show error dialog with retry/discard/cancel options\n const choice = await this.showSaveErrorDialog();\n\n if (choice === 'retry') {\n this.scheduleAutosave();\n } else if (choice === 'discard') {\n this.resetDirtyState();\n }\n // 'cancel' - do nothing, leave dirty state\n }\n }, this.autosaveDelay);\n }\n\n findOrCreateCanvas(imageData) {\n const imageId = imageData.id;\n\n // Phase 4.389: Include color variant ID for state isolation per color\n // Different color variants may share the same imageId but need separate canvas states\n // to prevent wrong product image and stale artworks from appearing when switching colors\n const colorVariantId = this.currentColorVariantId || 'default';\n const canvasKey = `${this.saleId}_${colorVariantId}_${imageId}`;\n const localCacheKey = `${colorVariantId}_${imageId}`;\n\n // Phase 0.99.16: Check global canvas state FIRST before creating new canvas\n // This allows canvas objects (with artPlacements Map) to persist across controller instances\n if (globalCanvasState.has(canvasKey)) {\n // Retrieve persisted canvas from previous controller instance\n const persistedCanvas = globalCanvasState.get(canvasKey);\n\n // Phase 4.319 + 4.402: Context-aware bgStart nullification\n // Only null bgStart when drop context DIFFERS from current context\n // (e.g., dropped outside modal but now viewing in modal, or vice versa)\n // Preserve bgStart when contexts match (modal\u2192modal or non-modal\u2192non-modal)\n // This fixes coordinate shift in bulk mode where artwork was dropped in modal\n // and should maintain position when modal reopens.\n const currentIsModal = !!this.element.closest('#decorator-modal');\n\n persistedCanvas.artPlacements?.forEach((artPlacement) => {\n if (artPlacement.dropTimeCoordinates) {\n const dropWasInModal = artPlacement.dropTimeCoordinates.isModalContext;\n\n // Only null if contexts differ (modal\u2192non-modal or non-modal\u2192modal)\n // Preserve bgStart when contexts match (modal\u2192modal or non-modal\u2192non-modal)\n // Also preserve for legacy placements without isModalContext flag\n const contextsDiffer = dropWasInModal !== undefined && dropWasInModal !== currentIsModal;\n\n if (contextsDiffer) {\n artPlacement.dropTimeCoordinates.bgStart = null;\n }\n }\n // Also check originalData if present (some placements store coords there)\n if (artPlacement.originalData?.dropTimeCoordinates) {\n const dropWasInModal = artPlacement.originalData.dropTimeCoordinates.isModalContext;\n const contextsDiffer = dropWasInModal !== undefined && dropWasInModal !== currentIsModal;\n\n if (contextsDiffer) {\n artPlacement.originalData.dropTimeCoordinates.bgStart = null;\n }\n }\n });\n\n this.canvases[localCacheKey] = persistedCanvas;\n return persistedCanvas;\n }\n\n if (!this.canvases[localCacheKey]) {\n this.canvases[localCacheKey] = new ImageCanvas(imageData);\n\n // Phase 0.99.33: CRITICAL FIX - Store in globalCanvasState IMMEDIATELY after creation\n //\n // Root Cause: Race condition when multiple controller instances reconnect rapidly.\n // Timeline of Bug (verified from logs):\n // 1. Instance 1: findOrCreateCanvas() creates Canvas A, starts async canvas.load()\n // 2. Instance 2: findOrCreateCanvas() checks globalCanvasState (not yet stored!) \u2192 creates Canvas B\n // 3. Instance 3: findOrCreateCanvas() checks globalCanvasState (still not stored!) \u2192 creates Canvas C\n // 4. Canvas A load completes \u2192 onCanvasLoad() stores Canvas A in globalCanvasState (too late!)\n // 5. Result: 3 canvas instances for same image, placements go to wrong instance\n //\n // Previous implementation: globalCanvasState.set() happened in onCanvasLoad() (async callback)\n // This created race window of ~4-11ms where multiple canvases could be created\n //\n // Fix: Store in globalCanvasState IMMEDIATELY after creation (synchronous)\n // This ensures subsequent findOrCreateCanvas() calls retrieve the SAME canvas instance\n globalCanvasState.set(canvasKey, this.canvases[localCacheKey]);\n\n }\n\n // Phase 0.99.31: Track instance ID when creating new canvas\n const canvas = this.canvases[localCacheKey];\n\n return canvas;\n }\n\n clearAllCanvases() {\n // Destroy all cached canvas instances to prevent old artwork from persisting\n Object.values(this.canvases).forEach(canvas => {\n if (canvas && canvas.destroyArtPlacements) {\n canvas.destroyArtPlacements();\n }\n });\n\n // Phase 4.389: Clear global state for all color variants\n // The local cache keys are now `${colorVariantId}_${imageId}`, so construct\n // global keys using the saleId prefix\n if (this.saleId && this.canvases) {\n Object.keys(this.canvases).forEach(localCacheKey => {\n const globalKey = `${this.saleId}_${localCacheKey}`;\n globalLoadedPlacements.delete(globalKey);\n // Phase 0.99.16: Also clear global canvas state when switching products\n globalCanvasState.delete(globalKey);\n // Phase 4.389: Also clear canvas changes tracking\n globalCanvasChanges.delete(globalKey);\n // Phase 4.414: Clear debounce timestamp to allow fresh restores on new product\n lastBulkRestoreTimestamp.delete(globalKey);\n });\n }\n\n // Phase 4.414: Also clear debounce timestamps keyed by saleId_imageId pattern\n // when product switches (covers cases where canvases may have been cleared differently)\n for (const key of lastBulkRestoreTimestamp.keys()) {\n if (key.startsWith(`${this.saleId}_`)) {\n lastBulkRestoreTimestamp.delete(key);\n }\n }\n\n // Clear the canvases cache completely\n this.canvases = {};\n this.currentCanvas = null;\n // Phase 4.389: Reset color variant tracking\n this.currentColorVariantId = null;\n\n // Clear the dropzone background image immediately to prevent flickering\n const dropzone = this.element.querySelector('#decorator-dropzone');\n if (dropzone) {\n dropzone.style.backgroundImage = '';\n dropzone.dataset.imageId = '';\n }\n\n // Clear the preview container DOM to remove old artwork elements\n const previewContainer = this.element.querySelector('.dropzone-previews');\n if (previewContainer) {\n previewContainer.innerHTML = '';\n }\n }\n\n // Autosave: Mark canvas as having unsaved changes\n // Phase 4.89: Also persist to globalDirtyState for reconnection resilience\n markAsDirty() {\n // Don't mark dirty if restoring from history (undo/redo)\n if (this.isRestoringFromHistory) return;\n\n this.hasUnsavedChanges = true;\n globalDirtyState.set(this.saleId, true);\n }\n\n // Autosave: Reset dirty state after successful save\n // Phase 4.89: Also clear globalDirtyState\n // Phase 4.381: Also clear globalCanvasChanges for this sale\n resetDirtyState() {\n this.hasUnsavedChanges = false;\n globalDirtyState.delete(this.saleId);\n\n // Phase 4.381: Clear all change tracking for this sale\n // Server now reflects the changes, so tracking is no longer needed\n for (const key of globalCanvasChanges.keys()) {\n if (key.startsWith(`${this.saleId}_`)) {\n globalCanvasChanges.delete(key);\n }\n }\n console.log('[Phase 4.381] Cleared change tracking for sale:', this.saleId);\n }\n\n // Autosave: Check if canvas has unsaved changes\n hasDirtyState() {\n return this.hasUnsavedChanges;\n }\n\n // Autosave: Trigger save directly (no cross-controller dependency)\n // Phase 4.9 (November 2025): Moved autosave logic from save controller to canvas controller\n // to eliminate spurious disconnection issues during product switch operations\n async triggerAutosave() {\n // Check if canvas has any placements before saving (skip autosave on empty canvas)\n const placements = this.getPlacementData();\n if (!placements || Object.keys(placements).length === 0) {\n return true; // Consider empty canvas as \"saved\"\n }\n\n // Verify we have required data\n const currentImageId = this.currentImageId;\n if (!currentImageId) {\n return false;\n }\n\n // Phase 4.159: In bulk mode, DO NOT make HTTP requests during autosave.\n // Instead, just capture placements to global storage. The actual save\n // happens when user clicks Save (bulk save with proper bulk_options).\n // This prevents SingleProductDecorationJob from being triggered during\n // product switches in bulk mode.\n if (this.skipExistingDecorations) {\n // Capture current placements to global storage\n this.captureCurrentPlacementsToGlobal();\n // Reset dirty state since we've captured the changes\n this.resetDirtyState();\n return true;\n }\n\n try {\n const result = await this.performAutosave(placements, currentImageId);\n\n if (result && result.success) {\n // Reset dirty state on successful save\n this.resetDirtyState();\n return true;\n }\n\n return false;\n\n } catch (error) {\n return false;\n }\n }\n\n // Autosave: Perform the actual save operation (extracted from save controller)\n // Phase 4.9 (November 2025): Direct API call from canvas controller to avoid\n // cross-controller timing issues during product switches\n async performAutosave(placements, currentImageId) {\n try {\n // Get sale ID from element data attribute\n const saleId = this.saleId;\n if (!saleId) {\n return { success: false, error: 'No sale ID' };\n }\n\n // Build save params (context is always 'single_product' for autosave)\n const params = this.buildAutosaveParams(placements, currentImageId);\n\n // Make API call\n const response = await fetch(`/teamsites/${saleId}/decorator`, {\n method: 'POST',\n credentials: 'same-origin',\n headers: {\n 'Content-Type': 'application/json',\n 'X-CSRF-Token': document.querySelector('[name=\"csrf-token\"]').content,\n 'Accept': 'application/json',\n },\n body: JSON.stringify(params),\n });\n\n const data = await response.json();\n\n // Handle different response types\n if (response.status === 202 && data.status === 'processing') {\n // Async processing - job submitted successfully\n return { success: true, async: true, jobId: data.job_id };\n } else if (response.status === 201 && data.status === 'completed') {\n // Sync processing - immediate success\n return { success: true };\n } else if (response.ok) {\n // Other successful responses\n return { success: true };\n } else {\n return { success: false, error: data.error || 'Save failed' };\n }\n\n } catch (error) {\n return { success: false, error: error.message };\n }\n }\n\n // Autosave: Build request params for autosave (simplified from save controller's buildParams)\n // Phase 4.9 (November 2025): Autosave always uses 'single_product' context\n // Multi-image support: Collect placements from ALL decorated images\n buildAutosaveParams(placements, currentImageId) {\n // Collect placements from ALL decorated images (multi-image support)\n // This ensures front/back/side images are all saved, not just the current one\n const allImagePlacements = this.getAllImagePlacements();\n\n // If no placements found in any canvas, fall back to passed placements\n const finalPlacements = Object.keys(allImagePlacements).length > 0\n ? allImagePlacements\n : { [currentImageId]: placements };\n\n // Merge artwork IDs from all images\n const allArtworkIds = new Set();\n Object.values(finalPlacements).forEach(imagePlacements => {\n Object.values(imagePlacements).forEach(placement => {\n if (placement.artworkId) {\n allArtworkIds.add(placement.artworkId);\n }\n });\n });\n\n const params = {\n artwork_ids: Array.from(allArtworkIds),\n board_width: this.boardDimensions.width,\n board_height: this.boardDimensions.height,\n art_placements_by_image_id: finalPlacements,\n };\n\n // For autosave, get product context from modal data attributes\n const modalElement = document.querySelector('#decorator-modal');\n if (modalElement) {\n params.product_id = modalElement.dataset.productId;\n params.color_variant_id = modalElement.dataset.colorVariantId;\n }\n\n // Fallback: try to get product ID from selected product\n if (!params.product_id) {\n const selectedProduct = document.querySelector('.product-list-item.ae-selected');\n if (selectedProduct) {\n const productData = JSON.parse(selectedProduct.dataset.product || '{}');\n params.product_id = productData.id;\n }\n }\n\n params.image_id = currentImageId;\n\n // Phase 4.257: Dispatch save payload for debug panel\n document.dispatchEvent(new CustomEvent('decorator:debug:save-payload', {\n bubbles: false,\n detail: {\n timestamp: Date.now(),\n params: params,\n placements: this.getAllCurrentPlacements(),\n context: 'autosave',\n },\n }));\n\n return params;\n }\n\n // Autosave: Get reference to save controller\n getSaveController() {\n // DEFENSIVE: Check if controller is still connected to application\n // Prevents \"Cannot read properties of undefined\" during disconnect/reconnect cycles\n // This can occur during modal close or Stimulus reinit operations\n if (!this.application) {\n return null;\n }\n\n // Phase 4.16: Save functionality now in decorator-modal controller\n // (Previously decorator-save, consolidated in commit f6f5132deb)\n // Use word match operator (~=) to handle multiple controllers on same element\n const saveElement = document.querySelector('[data-controller~=\"decorator-modal\"]');\n if (!saveElement) return null;\n\n // Use proper Stimulus 1.1.0 API (fixes timing bug with _controller property)\n return this.application.getControllerForElementAndIdentifier(\n saveElement,\n 'decorator-modal',\n );\n }\n\n // Autosave: Show error dialog with retry/discard/cancel options\n async showSaveErrorDialog() {\n return new Promise((resolve) => {\n // Create dialog overlay\n const overlay = document.createElement('div');\n overlay.style.cssText = `\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba(0, 0, 0, 0.5);\n display: flex;\n align-items: center;\n justify-content: center;\n z-index: 10000;\n `;\n\n // Create dialog box\n const dialog = document.createElement('div');\n dialog.style.cssText = `\n background: white;\n padding: 24px;\n border-radius: 8px;\n max-width: 400px;\n box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);\n `;\n\n dialog.innerHTML = `\n

    \n Failed to Save Decoration\n

    \n

    \n Your decoration changes could not be saved. Would you like to retry, discard your changes, or cancel?\n

    \n
    \n \n \n \n
    \n `;\n\n overlay.appendChild(dialog);\n document.body.appendChild(overlay);\n\n // Handle button clicks\n dialog.addEventListener('click', (e) => {\n const action = e.target.dataset.choice;\n if (action) {\n overlay.remove();\n resolve(action);\n }\n });\n });\n }\n\n onCanvasLoad(canvas) {\n const imageId = canvas.imageId;\n const imageUrl = canvas.imageUrl;\n\n // Phase 0.99.31: Track canvas instance at entry to onCanvasLoad\n\n // Phase 0.99.32: CRITICAL FIX - Guard against duplicate onCanvasLoad() calls\n //\n // Root Cause: Multiple controller reconnections during modal open cause canvas.load()\n // to invoke its callback synchronously when image is already loaded (ImageCanvas line 85-87).\n // This results in multiple onCanvasLoad() calls for the SAME canvas instance, which\n // overwrites currentCanvas/globalCanvasState with potentially different references.\n //\n // Timeline of Bug:\n // 1. Instance 1: findOrCreateCanvas() \u2192 Canvas A (new)\n // 2. Instance 1: canvas.load(callback) \u2192 Image loads asynchronously\n // 3. Instance 2: Reconnects \u2192 findOrCreateCanvas() \u2192 Canvas A (from global state)\n // 4. Image finishes loading \u2192 Canvas A callback \u2192 onCanvasLoad(Canvas A) stores in global\n // 5. Canvas A.isLoaded = true\n // 6. Instance 3: Reconnects \u2192 findOrCreateCanvas() \u2192 Canvas A (from global state)\n // 7. Instance 3: canvas.load(callback) \u2192 ALREADY LOADED \u2192 callback fires SYNCHRONOUSLY\n // 8. onCanvasLoad(Canvas A) runs AGAIN \u2192 overwrites currentCanvas with different ref\n // 9. Instance 2's loadExistingArtPlacements() completes \u2192 adds to orphaned canvas\n // 10. Save retrieves registered canvas (different instance) \u2192 0 placements\n //\n // Fix: Skip duplicate onCanvasLoad() calls by checking canvas instance identity.\n // If this canvas is already currentCanvas, it's already been processed - no-op.\n if (this.currentCanvas === canvas) {\n return;\n }\n\n // Phase 0.99.16: Store canvas in global state for persistence across controller instances\n // Phase 4.420: Use 3-part key matching findOrCreateCanvas to prevent ghost entries\n const colorVariantId = this.currentColorVariantId || 'default';\n const canvasKey = `${this.saleId}_${colorVariantId}_${imageId}`;\n globalCanvasState.set(canvasKey, canvas);\n\n this.currentCanvas = canvas;\n\n // Phase 4.151: Track product -> image mapping for per-product placements\n // This allows us to know which ColorVariantImage ID belongs to each product\n // so we can send per-product placements keyed by TARGET image ID (not source)\n const currentProductId = this.getCurrentProductId();\n if (currentProductId && this.saleId) {\n const mappingKey = `${this.saleId}_${currentProductId}`;\n productImageMapping.set(mappingKey, imageId);\n }\n\n // Phase 0.99.31: Confirm canvas stored\n\n // Update the dropzone background image - fix selector to use ID\n const dropzone = this.element.querySelector('#decorator-dropzone');\n\n if (dropzone && imageUrl) {\n dropzone.style.backgroundImage = `url('${imageUrl}')`;\n dropzone.dataset.imageId = imageId;\n }\n\n // Find the preview container for artwork placements\n // It's now a sibling of the dropzone, not inside it\n let previewContainer = this.element.querySelector('.dropzone-previews');\n\n // If not found, try searching in the parent artboard container\n if (!previewContainer) {\n const artboardContainer = this.element.closest('[data-decorator-board]') || this.element;\n previewContainer = artboardContainer.querySelector('.dropzone-previews');\n }\n\n if (previewContainer) {\n canvas.setContainer(previewContainer);\n // Phase 4.185: Removed stale boardDimensions assignment\n // setContainer() already syncs boardDimensions from the actual container\n } else {\n // Create the preview container if it doesn't exist\n previewContainer = document.createElement('div');\n previewContainer.className = 'dropzone-previews';\n previewContainer.style.cssText = 'width: 100%; height: 100%; position: absolute; top: 0; left: 0; pointer-events: none;';\n this.element.appendChild(previewContainer);\n canvas.setContainer(previewContainer);\n // Phase 4.185: Removed stale boardDimensions assignment\n }\n\n // Check if we've already loaded placements for this image (using global tracking)\n // BUT also verify that THIS canvas instance actually has placements\n // This prevents blank canvas when controller instances are recreated\n const trackingKey = `${this.saleId}_${imageId}`;\n if (globalLoadedPlacements.has(trackingKey)) {\n // Tracking flag exists - check if canvas actually has the placements\n if (canvas.artPlacements?.size > 0) {\n // Canvas has placements in memory - use them\n // Get current placements from canvas to maintain existing artwork in history\n const currentPlacements = canvas.getArtPlacements().filter(placement =>\n placement.id.startsWith('existing_'),\n );\n\n // Trigger event that placements are ready\n document.dispatchEvent(new CustomEvent('decorator:board:canvasLoaded', {\n bubbles: true,\n detail: {\n canvas: canvas,\n existingPlacements: currentPlacements,\n },\n }));\n } else {\n // Phase 0.99.3: Check if placements already exist in DOM\n // This prevents inappropriate reloading during spurious controller reconnections\n // (e.g., during drag operations) vs legitimate modal reopen scenarios\n // Phase 4.68: Use container-level query to prevent cross-contamination between images\n // Phase 0.99.51: Use [data-art-placement-id] selector for reliable DOM guard\n const onCanvasContainer = this.element.querySelector('.dropzone-previews');\n const existingPlacementsInDOM = onCanvasContainer ?\n onCanvasContainer.querySelectorAll('[data-art-placement-id]').length : 0;\n\n // Phase 0.99.7: Enhanced debug logging to diagnose DOM selector issues\n const allDataArtwork = document.querySelectorAll('[data-artwork]');\n const allDataArtPlacementId = document.querySelectorAll('[data-art-placement-id]');\n const dropzonePreviews = document.querySelectorAll('.dropzone-previews');\n\n\n if (existingPlacementsInDOM > 0) {\n // Placements are already rendered in the DOM\n // This is likely a spurious controller reconnection (e.g., during drag)\n // NOT a legitimate modal reopen - skip reload to prevent duplicates\n\n // Phase 0.99.19: If Map is empty but DOM has placements, reconstruct Map\n // This synchronizes Map with DOM state after controller reconnection\n if (!canvas.artPlacements || canvas.artPlacements.size === 0) {\n const reconstructed = this.reconstructMapFromDOM(canvas);\n\n if (reconstructed > 0) {\n // Phase 0.99.20: Update controller state after successful reconstruction\n // This ensures getPlacementData() fallback chain can find the canvas when save is clicked\n // Without this, save controller gets empty placements even though canvas.artPlacements has data\n const canvasKey = `${this.saleId}_${canvas.imageId}`;\n globalCanvasState.set(canvasKey, canvas);\n this.currentCanvas = canvas;\n\n // Get reconstructed placements for event\n const currentPlacements = canvas.getArtPlacements().filter(placement =>\n placement.id.startsWith('existing_'),\n );\n\n // Dispatch event with reconstructed placements\n document.dispatchEvent(new CustomEvent('decorator:board:canvasLoaded', {\n bubbles: true,\n detail: {\n canvas: canvas,\n existingPlacements: currentPlacements,\n },\n }));\n\n // Phase 4.345: Update debug panel after reconstruction\n // Without this, debug panel shows \"ALL PLACEMENTS (0)\" even though Map was populated.\n // This is critical for the fallback path (controller reconnection scenario).\n this.dispatchPlacementsChanged();\n console.log('[Phase 4.345] Fallback path reconstruction complete - dispatched placementsChanged:', {\n reconstructed,\n canvasImageId: canvas.imageId,\n mapSize: canvas.artPlacements?.size || 0,\n });\n\n return; // Exit early after successful reconstruction\n }\n // If reconstruction failed, fall through to dispatch empty placements event\n }\n\n // Dispatch event that canvas is ready with empty placements\n // (actual placements are already in DOM)\n document.dispatchEvent(new CustomEvent('decorator:board:canvasLoaded', {\n bubbles: true,\n detail: {\n canvas: canvas,\n existingPlacements: [],\n },\n }));\n } else {\n // Phase 0.99.9: Controller recreation with empty canvas and DOM\n // Tracking exists but canvas Map is empty AND DOM is empty\n // This can happen during:\n // 1. Spurious reconnection before DOM updates (timing race)\n // 2. Legitimate modal reopen (modal closed and reopened)\n //\n // Phase 0.99.9 FIX: DON'T delete tracking here!\n // Reason: Deleting tracking allows Phase 0.99.4 fallback path to load via AJAX\n // during spurious reconnections, causing duplicates.\n //\n // Let Phase 0.99.8's control flow handle this:\n // - If hasDataPlacements=true + DOM populated later \u2192 Phase 0.99.8 skips both paths\n // - If hasDataPlacements=false (modal reopen) \u2192 Phase 0.99.4 fallback loads correctly\n // Phase 0.99.9: Removed globalLoadedPlacements.delete(trackingKey);\n\n // Dispatch event that canvas is ready (placements will be loaded by loadInitialImage)\n document.dispatchEvent(new CustomEvent('decorator:board:canvasLoaded', {\n bubbles: true,\n detail: {\n canvas: canvas,\n existingPlacements: [],\n },\n }));\n }\n }\n } else {\n // Existing placements will be loaded from existingDecoration data attribute\n // in loadInitialImage() callback (line 195) - no need to fetch from server\n // This prevents duplicate loading: AJAX fetch + data attribute load = 2x placements\n //\n // Note: Previously called loadExistingArtPlacements() here, which caused duplicates\n // because loadExistingDecorationData() in loadInitialImage() loads the same data\n // from the data attribute that was already fetched during Rails template rendering.\n\n // Dispatch event that canvas is ready (without loading placements)\n document.dispatchEvent(new CustomEvent('decorator:board:canvasLoaded', {\n bubbles: true,\n detail: {\n canvas: canvas,\n existingPlacements: [],\n },\n }));\n }\n\n // Phase 4.403: Dispatch debug event for modal layout comparison\n // This allows the debug panel to capture canvas state for bulk vs single mode comparison\n this.dispatchDebugCanvasReady(canvas);\n }\n\n // Phase 4.403: Dispatch debug canvas-ready event with full canvas state\n dispatchDebugCanvasReady(canvas) {\n if (!canvas) return;\n\n const mode = this.skipExistingDecorations ? 'bulk' : 'single';\n const boardDimensions = canvas.boardDimensions || { width: 0, height: 0 };\n const bgStart = typeof canvas.getBackgroundImageStart === 'function'\n ? canvas.getBackgroundImageStart()\n : { x: 0, y: 0 };\n const scalingFactor = typeof canvas.getScalingFactor === 'function'\n ? canvas.getScalingFactor()\n : 1;\n const isVerticalFit = typeof canvas.isVerticalFit === 'function'\n ? canvas.isVerticalFit()\n : false;\n const imageNaturalWidth = canvas.imageEl?.naturalWidth || 0;\n const imageNaturalHeight = canvas.imageEl?.naturalHeight || 0;\n\n console.log('[Phase 4.403] dispatchDebugCanvasReady:', {\n mode,\n boardDimensions,\n bgStart,\n scalingFactor,\n isVerticalFit,\n imageNaturalWidth,\n imageNaturalHeight,\n });\n\n document.dispatchEvent(new CustomEvent('decorator:debug:canvas-ready', {\n bubbles: false,\n detail: {\n mode,\n boardDimensions,\n bgStart,\n scalingFactor,\n isVerticalFit,\n imageNaturalWidth,\n imageNaturalHeight,\n saleId: this.saleId,\n imageId: canvas.imageId,\n },\n }));\n }\n\n loadExistingDecorationData(decoration) {\n // Phase 4.212: Data attribute path entry diagnostic\n console.log('[Phase 4.212] loadExistingDecorationData ENTRY:', {\n skipExistingDecorations: this.skipExistingDecorations,\n hasCurrentCanvas: !!this.currentCanvas,\n decorationKeys: Object.keys(decoration || {}),\n artPlacementsCount: decoration?.art_placements?.length,\n artPlacementsByImageIdKeys: decoration?.art_placements_by_image_id\n ? Object.keys(decoration.art_placements_by_image_id)\n : null,\n });\n\n // Bulk mode: Skip loading existing decorations to start with blank canvas\n if (this.skipExistingDecorations) {\n return;\n }\n\n if (!this.currentCanvas) {\n return;\n }\n\n // Phase 4.67: Support both formats for backward compatibility\n // New format: art_placements_by_image_id (keyed by image ID)\n // Legacy format: art_placements (array, applies to first image only)\n let placements;\n\n if (decoration.art_placements_by_image_id) {\n // New format: placements keyed by image ID - prevents cross-contamination\n placements = decoration.art_placements_by_image_id[this.currentCanvas.imageId];\n } else if (decoration.art_placements) {\n // Legacy format: only use if we're on the first image\n // This prevents cross-contamination with old API responses\n const firstImageId = this.imageData?.[0]?.id;\n if (this.currentCanvas.imageId === firstImageId || String(this.currentCanvas.imageId) === String(firstImageId)) {\n placements = decoration.art_placements;\n } else {\n return;\n }\n } else {\n // No placements data at all\n return;\n }\n\n if (!placements || !Array.isArray(placements) || placements.length === 0) {\n return;\n }\n\n // Clear existing placements to prevent duplicates\n this.currentCanvas.destroyArtPlacements();\n\n // Load placements for this image\n placements.forEach(placement => {\n this.addExistingPlacement(this.currentCanvas, placement);\n });\n\n // Remove duplicate placements after loading\n this.currentCanvas.removeDuplicatePlacements();\n\n // Emit initial state after loading existing decorations\n // This ensures history has the correct initial state\n this.emitStateChange();\n\n // Mark placements as loaded in global tracking to prevent reload\n // This prevents duplicate loading across multiple controller instances\n // Phase 4.389: Include colorVariantId for state isolation per color\n if (this.currentCanvas) {\n const key = `${this.saleId}_${this.currentColorVariantId || 'default'}_${this.currentCanvas.imageId}`;\n globalLoadedPlacements.set(key, true);\n }\n }\n\n async loadExistingArtPlacements(canvas) {\n // Phase 4.212: AJAX path entry diagnostic\n console.log('[Phase 4.212] loadExistingArtPlacements ENTRY:', {\n skipExistingDecorations: this.skipExistingDecorations,\n saleId: this.saleId,\n canvasImageId: canvas?.imageId,\n });\n\n // Bulk mode: Skip loading existing decorations to start with blank canvas\n if (this.skipExistingDecorations) {\n return;\n }\n\n // Phase 0.99.31: Track canvas instance at entry\n\n // Phase 4.389: Include colorVariantId for state isolation per color\n const trackingKey = `${this.saleId}_${this.currentColorVariantId || 'default'}_${canvas.imageId}`;\n\n // Phase 0.99.34: Comprehensive entry diagnostics for modal reopen debugging\n // Phase 4.68: Use container-level query to prevent cross-contamination between images\n // Phase 0.99.51: Use [data-art-placement-id] selector for reliable DOM counting\n const ajaxContainer = this.element.querySelector('.dropzone-previews');\n const domPlacementCount = ajaxContainer ?\n ajaxContainer.querySelectorAll('[data-art-placement-id]').length : 0;\n\n // DEBUG: Log AJAX placement loading attempt\n\n // Guard: Check BOTH global tracking AND canvas state to prevent duplicates\n // Global tracking prevents duplicate AJAX calls across controller instances\n // Canvas state check prevents blank canvas when instances are recreated\n // Only skip if BOTH conditions are true (tracking says loaded AND canvas has data)\n if (globalLoadedPlacements.has(trackingKey) && canvas.artPlacements?.size > 0) {\n return;\n }\n\n // CRITICAL: Set tracking flag BEFORE fetch to prevent race condition\n // When multiple controller instances exist (e.g., modal reopen), they may all call\n // this method simultaneously. Without setting the flag before the fetch:\n // Instance 1: Check guard \u2192 NOT in map \u2192 start fetch\n // Instance 2: Check guard \u2192 NOT in map \u2192 start fetch (DUPLICATE!)\n // By setting the flag immediately after guard check (before async fetch),\n // subsequent instances will see the flag and skip, preventing duplicate AJAX calls.\n // This is the \"test-and-set\" atomic operation pattern for async race conditions.\n //\n // Phase 0.99.35: This works correctly now that globalLoadedPlacements is cleared\n // when modal closes (decorator_modal_controller.js disconnect())\n globalLoadedPlacements.set(trackingKey, true);\n\n // Phase 0.99.35: Log which controller instance won the race to load placements\n\n // DEBUG: Log tracking set before fetch\n\n try {\n // Use sale ID from controller data (Stimulus 1.1.0 style)\n let saleId = this.saleId;\n\n // Fallback: try to find sale ID from data attributes\n if (!saleId) {\n // Look for data-sale-id on the modal element (Stimulus 1.1.0 style)\n const modalElement = this.element.closest('#decorator-modal');\n saleId = modalElement?.dataset.saleId;\n\n // Secondary fallback: look on the canvas element itself\n if (!saleId) {\n saleId = this.element.dataset.saleId;\n }\n }\n\n // If still no sale ID, show error and return\n if (!saleId) {\n this.showError('Unable to load existing decorations: Sale ID missing');\n return;\n }\n\n // Phase 4.340: Include board dimensions for server-side CSS position calculation\n // This eliminates client-side forward transforms and makes server the single source of truth\n const boardWidth = canvas.board?.clientWidth || 800;\n const boardHeight = canvas.board?.clientHeight || 600;\n const url = `/teamsites/${saleId}/decorator/art_placements?img=${canvas.imageId}` +\n `&board_width=${boardWidth}&board_height=${boardHeight}`;\n\n console.log('[Phase 4.340] loadExistingArtPlacements with board dimensions:', {\n imageId: canvas.imageId,\n boardWidth,\n boardHeight,\n saleId,\n });\n\n const response = await fetch(url, {\n headers: this.getHeadersWithCSRF({\n 'Accept': 'application/json',\n }),\n });\n\n if (!response.ok) {\n return;\n }\n\n const artPlacements = await response.json();\n\n // Phase 4.257: Dispatch load payload for debug panel\n document.dispatchEvent(new CustomEvent('decorator:debug:load-payload', {\n bubbles: false,\n detail: {\n timestamp: Date.now(),\n placements: artPlacements,\n imageId: canvas.imageId,\n },\n }));\n\n // Phase 0.99.34: Detailed AJAX response diagnostics\n\n // Phase 0.99.26: Log initial placements from AJAX backend\n\n // Phase 0.99.34: Track placements attempted vs added\n let placementsAttempted = 0;\n let placementsAddedSuccessfully = 0;\n\n // Parse and add existing placements to canvas\n if (Array.isArray(artPlacements) && artPlacements.length > 0) {\n // Phase 4.165: Clear existing placements before adding from AJAX to prevent duplicates\n // This mirrors restoreBulkPlacements() which also calls destroyArtPlacements() first\n canvas.destroyArtPlacements();\n\n artPlacements.forEach((placement) => {\n placementsAttempted++;\n const beforeSize = canvas.artPlacements?.size || 0;\n\n this.addExistingPlacement(canvas, placement);\n\n const afterSize = canvas.artPlacements?.size || 0;\n if (afterSize > beforeSize) {\n placementsAddedSuccessfully++;\n } else {\n // Phase 0.99.34: Log why placement wasn't added\n }\n });\n\n // Phase 0.99.27: Track which canvas instance was populated\n\n // Phase 0.99.31: Compare with stored canvas instances\n const currentCanvasId = this.currentCanvas?.__instanceId || 'NO_ID';\n const globalCanvasId = globalCanvasState.get(`${this.saleId}_${canvas.imageId}`)?.__instanceId || 'NO_ID';\n } else {\n // Phase 0.99.26: Log when backend returns no placements\n }\n\n // NOTE: Tracking flag already set BEFORE fetch (line 671) to prevent race condition\n // Do NOT set it here - that would allow race conditions where multiple instances\n // pass the guard check before any fetch completes\n\n // Remove duplicate placements after loading\n canvas.removeDuplicatePlacements();\n\n // Phase 0.99.34: Comprehensive completion diagnostics\n const placementsFiltered = placementsAttempted - placementsAddedSuccessfully;\n\n // DEBUG: Log final state after loading\n\n // Notify that canvas is loaded with placements\n document.dispatchEvent(new CustomEvent('decorator:board:canvasLoaded', {\n bubbles: true,\n detail: {\n canvas: canvas,\n existingPlacements: artPlacements,\n },\n }));\n\n // Emit initial state after loading existing placements\n this.emitStateChange();\n\n // Phase 4.255: Update debug panel with loaded placements\n this.dispatchPlacementsChanged();\n\n } catch (error) {\n // Log errors instead of silently swallowing them\n console.error('[Decorator] Error loading existing placements:', {\n error: error.message,\n stack: error.stack,\n imageId: canvas?.imageId,\n saleId: this.saleId,\n });\n }\n }\n\n addExistingPlacement(canvas, placementData) {\n const placementId = `existing_${placementData.id}`;\n\n // Check if this placement already exists to prevent duplicates\n if (canvas.artPlacements && canvas.artPlacements.has(placementId)) {\n return;\n }\n\n // Phase 4.381: Skip if this placement was deleted in current session\n // This ensures deleted placements don't reappear when switching orientations/colors/products\n // Phase 4.389: Include colorVariantId for state isolation per color\n const changes = getCanvasChanges(this.saleId, this.currentColorVariantId, canvas.imageId);\n if (changes.deleted.has(placementId)) {\n console.log('[Phase 4.381] Skipping deleted placement:', { placementId, imageId: canvas.imageId });\n return;\n }\n\n // Phase 4.248: Log dimensions at modal reopen for comparison with drop time\n // If these differ from drop-time dimensions (Phase 4.247 log), that explains the shift.\n // Compare with drop-time values: boardDimensions {860, 1600}, naturalHeight 2000\n console.log('[Phase 4.248] MODAL REOPEN dimensions (addExistingPlacement):', {\n placementId,\n placementTop: placementData.top,\n placementLeft: placementData.left,\n boardDimensions: canvas.boardDimensions,\n liveBoardDimensions: {\n width: canvas.board?.clientWidth,\n height: canvas.board?.clientHeight,\n },\n imageNaturalDimensions: {\n width: canvas.imageEl?.naturalWidth,\n height: canvas.imageEl?.naturalHeight,\n },\n scalingFactor: canvas.getScalingFactor?.(),\n bgStart: canvas.getBackgroundImageStart?.(),\n isLoaded: canvas.isLoaded,\n // COMPARE: If these differ from Phase 4.247 log values, that's the shift cause\n });\n\n // Phase 4.352: Per-field format detection\n // Backend may return decimals (0-1) or percentages (0-100), and these can be MIXED:\n // { top: 23.15, left: 35.39, width: 0.17, height: 0.30 }\n // The old ALL-or-nothing check failed on mixed format because top/left > 2.0\n // meant is_decimal_format = FALSE, leaving width/height as tiny decimals\n // \u2192 caused ~56px Y coordinate shift in baked images\n //\n // Fix: Convert each field INDEPENDENTLY if it looks like decimal format (< 2.0)\n const normalizeField = (value) => {\n const numValue = parseFloat(value);\n // If value > 0 and < 2.0, likely a decimal that should be percentage\n if (numValue > 0 && numValue < 2.0) {\n console.log(`[Phase 4.352] Converting field from decimal to percentage: ${numValue} \u2192 ${numValue * 100}`);\n return numValue * 100;\n }\n return numValue;\n };\n\n // Phase 4.299: Store database percentages as immutable dropTimeCoordinates\n // Problem: Existing placements from DB had no dropTimeCoordinates, so getAllCurrentPlacements\n // fell back to originalData, which can be corrupted by handlePlacementUpdate running\n // with different parameters (bgStart, scalingFactor) than when originally saved.\n //\n // Fix: Set dropTimeCoordinates for existing placements using the database percentages.\n // These are the source of truth and should be immutable until user explicitly drags.\n //\n // Why this fixes the shift:\n // 1. User saves placement at 28% \u2192 stored in DB\n // 2. Modal reopens \u2192 placement loaded with dropTimeCoordinates.top = 28%\n // 3. Even if handlePlacementUpdate runs with different params and corrupts originalData\n // 4. getAllCurrentPlacements uses dropTimeCoordinates (28%) \u2192 correct coordinates sent to server\n const normalizedLeft = normalizeField(placementData.left);\n const normalizedTop = normalizeField(placementData.top);\n const normalizedWidth = normalizeField(placementData.width);\n const normalizedHeight = normalizeField(placementData.height);\n\n // Phase 4.318: Don't store bgStart for DB placements\n // Root cause: Canvas dimensions at RELOAD time may differ from original DROP time.\n // When dimensions change, isVerticalFit flips, swapping bgStart.x and bgStart.y roles.\n // Evidence: Drop time bgStart = {x: 12.66, y: 0} (vertical fit)\n // Reload time bgStart = {x: 0, y: 12.66} (horizontal fit - WRONG swap!)\n // Fix: Don't capture bgStart at all. DB percentages are the source of truth.\n // The parser will calculate bgStart from CURRENT canvas, which is correct for display.\n //\n // Note: This differs from restoreBulkPlacements which DOES preserve bgStart because\n // bulk placements cycle through product switches with the SAME modal dimensions.\n // Phase 4.357: Use consolidated helper method, but preserve null if canvas not ready\n const canvasReady = canvas.imageEl?.naturalHeight > 0;\n const actualImageHeight = canvasReady ? this.getActualImageHeight(canvas) : null;\n // bgStart intentionally NOT captured - let parser use current canvas (Phase 4.318)\n\n // Phase 4.340: Check for server-provided CSS position\n // When board dimensions are sent with the request, server returns pre-computed cssPosition\n const serverCssPosition = placementData.cssPosition || null;\n const serverComputed = placementData.computed || null;\n\n if (serverCssPosition) {\n console.log('[Phase 4.340] Server CSS position received:', {\n placementId,\n serverCssPosition,\n serverComputed,\n dbTop: placementData.top,\n dbLeft: placementData.left,\n });\n }\n\n const placement = {\n id: placementId,\n artworkId: placementData.artwork_id,\n // Check multiple possible locations for the artwork URL\n artworkUrl: placementData.artworkUrl || placementData.artwork_url || placementData.imageUrl || placementData.image_url,\n // Apply format-specific multiplier to normalize to percentages\n left: normalizedLeft,\n top: normalizedTop,\n width: normalizedWidth,\n height: normalizedHeight,\n // Convert radians to degrees for display\n rotation: placementData.rotate * (180 / Math.PI),\n flipX: placementData.mirrorHorizontally,\n flipY: placementData.mirrorVertically,\n // Store original backend ID for updates\n backendId: placementData.id,\n // Flag to skip bgStart offset during coordinate conversion\n // Existing placements from backend have pure image-relative coords (no offset)\n isExistingPlacement: true,\n // Phase 4.175: Prevent handlePlacementUpdate from recalculating server percentages\n // Server stores correct percentages - no recalculation needed until user explicitly drags\n coordinatesLocked: true,\n // Phase 4.340: Server-provided CSS position (eliminates client-side forward transform)\n // When present, ImageCanvas.convertCoordinates() uses this directly\n serverCssPosition: serverCssPosition,\n serverComputed: serverComputed,\n // Phase 4.299: Set immutable dropTimeCoordinates from database percentages\n // This ensures getAllCurrentPlacements returns the correct DB percentages,\n // even if originalData gets corrupted by handlePlacementUpdate.\n dropTimeCoordinates: {\n left: normalizedLeft,\n top: normalizedTop,\n width: normalizedWidth,\n height: normalizedHeight,\n // Phase 4.384: Include tool values for intentional change detection\n // Database stores rotate in radians - convert to degrees for consistency\n rotation: (placementData.rotate || 0) * (180 / Math.PI),\n flipX: placementData.mirrorHorizontally || false,\n flipY: placementData.mirrorVertically || false,\n // Phase 4.342: Include artworkId and artworkUrl to match fresh drop structure\n // Without these, getArtPlacements() returns dropTimeCoordinates without artworkId,\n // causing getPlacementData() to get undefined, which becomes 0 on the server.\n artworkId: placementData.artwork_id,\n artworkUrl: placementData.artworkUrl || placementData.artwork_url || placementData.imageUrl || placementData.image_url,\n // Mark as loaded from DB (not a fresh drop) for diagnostic purposes\n loadedFromDatabase: true,\n // Phase 4.318: Don't store bgStart for DB placements\n // DB percentages are the source of truth - parser calculates bgStart from current canvas\n actualImageHeight: actualImageHeight,\n bgStart: null, // Phase 4.318: Let parser use current canvas bgStart\n },\n };\n\n // Phase 4.318: Diagnostic log to confirm bgStart is null for DB placements\n // bgStart intentionally null - parser will use current canvas which is correct for display\n console.log('[Phase 4.318] addExistingPlacement with dropTimeCoordinates:', {\n placementId,\n dbTop: placementData.top,\n dbLeft: placementData.left,\n normalizedTop,\n normalizedLeft,\n dropTimeCoordinatesTop: placement.dropTimeCoordinates.top,\n dropTimeCoordinatesLeft: placement.dropTimeCoordinates.left,\n loadedFromDatabase: placement.dropTimeCoordinates.loadedFromDatabase,\n // Phase 4.318: bgStart always null for DB placements\n bgStartIsNull: placement.dropTimeCoordinates.bgStart === null,\n canvasReady,\n actualImageHeight: placement.dropTimeCoordinates.actualImageHeight,\n });\n\n // Phase 0.99.18: Add to pending queue if canvas not ready\n // Problem: Silent failure when canvas.isLoaded = false or canvas.container = null\n // Result: Placements render in DOM but not in canvas.artPlacements Map\n // When save reads empty Map \u2192 \"No artwork placed on canvas\" error\n // Solution: Use existing pending queue infrastructure (ImageCanvas line 66)\n\n // Phase 4.345: Diagnostic logging to trace canvas instance and placement flow\n // This helps identify if placements go to Map vs pendingPlacements\n const mapSizeBefore = canvas.artPlacements?.size || 0;\n const pendingSizeBefore = canvas.pendingPlacements?.length || 0;\n\n if (canvas.isLoaded && canvas.container) {\n canvas.addArtPlacement(placement);\n // Phase 4.178: Removed authoritative pixel storage (Phase 4.177 reverted)\n // Now using threshold-based filtering in handlePlacementUpdate instead\n\n // Phase 4.345: Log successful Map addition\n console.log('[Phase 4.345] addExistingPlacement \u2192 Map:', {\n placementId,\n canvasImageId: canvas.imageId,\n isLoaded: canvas.isLoaded,\n hasContainer: !!canvas.container,\n mapSizeBefore,\n mapSizeAfter: canvas.artPlacements?.size || 0,\n });\n } else {\n // Canvas not ready - add to pending queue for processing when load completes\n canvas.pendingPlacements.push(placement);\n\n // Phase 4.345: Log pendingPlacements queuing\n console.log('[Phase 4.345] addExistingPlacement \u2192 pendingPlacements:', {\n placementId,\n canvasImageId: canvas.imageId,\n isLoaded: canvas.isLoaded,\n hasContainer: !!canvas.container,\n pendingSizeBefore,\n pendingSizeAfter: canvas.pendingPlacements?.length || 0,\n });\n }\n }\n\n // Phase 0.99.19: Reconstruct canvas.artPlacements Map from DOM elements\n // Problem: After controller reconnection, DOM has artwork elements but Map is empty\n // Skip-reload logic prevents AJAX reload (correct - prevents duplicates)\n // But doesn't synchronize Map with DOM state (incorrect - breaks save)\n // Solution: Extract placement data from DOM and rebuild Map\n reconstructMapFromDOM(canvas) {\n // Phase 4.212: DOM reconstruction path entry diagnostic\n console.log('[Phase 4.212] reconstructMapFromDOM ENTRY:', {\n canvasExists: !!canvas,\n canvasImageId: canvas?.imageId,\n containerExists: !!canvas?.container,\n });\n\n if (!canvas || !canvas.container) {\n return 0;\n }\n\n // Find all artwork placement elements in the container\n // Phase 0.99.51: Use [data-art-placement-id] as primary selector (more reliable)\n const placementElements = canvas.container.querySelectorAll('[data-art-placement-id]');\n\n if (placementElements.length === 0) {\n return 0;\n }\n\n // Get board dimensions for pixel-to-percentage conversion\n const board = canvas.board || canvas.container.closest('[data-decorator-board]');\n if (!board || board.clientWidth === 0 || board.clientHeight === 0) {\n return 0;\n }\n\n // Phase 4.373: Removed Phase 4.183 sync code\n // ImageCanvas.boardDimensions is now a getter that always reads live from DOM\n // No sync needed - getter eliminates stale cache issues entirely\n\n // Phase 0.99.45/0.99.49: Get image dimensions and scaling ratio for coordinate conversion\n // CRITICAL: Must use imageHeight + ratio to match coordinate system from parser.js (lines 68-79)\n // Forward: pixels = (percentage / 100) * imageHeight * ratio [+ bgStart for appropriate axis]\n // Inverse: percentage = ((pixels [- bgStart for appropriate axis]) / ratio / imageHeight) * 100\n // Phase 4.183: Use naturalHeight to match forward transform (was using CSS height causing shift)\n const imageHeight = canvas.imageEl\n ? (canvas.imageEl.naturalHeight || canvas.imageEl.height)\n : null;\n const ratio = canvas.getScalingFactor ? canvas.getScalingFactor() : 1;\n\n // Validate image loaded before attempting conversion\n if (!imageHeight || imageHeight === 0) {\n return 0;\n }\n\n // Phase 0.99.49: Get background offset for orientation-aware coordinate conversion\n // The parser adds bgStart offset during forward transform (parser.js lines 73-79)\n // We must subtract it during inverse transform to avoid double-offset bug\n const bgStart = canvas.getBackgroundImageStart ? canvas.getBackgroundImageStart() : { x: 0, y: 0 };\n const isVertical = canvas.isVerticalFit ? canvas.isVerticalFit() : false;\n\n\n let reconstructedCount = 0;\n\n placementElements.forEach(element => {\n try {\n const placementId = element.dataset.artPlacementId;\n const artworkId = parseInt(element.dataset.artworkId);\n\n // Skip if already in Map (shouldn't happen, but defensive)\n if (canvas.artPlacements.has(placementId)) {\n return;\n }\n\n // Phase 4.314: Skip if already in pendingPlacements (from addExistingPlacement)\n // addExistingPlacement creates placements with dropTimeCoordinates and queues them\n // in pendingPlacements when canvas is not yet loaded. If we create a duplicate here\n // without dropTimeCoordinates, it could overwrite the correct one during queue processing.\n const existingInPending = canvas.pendingPlacements?.find(p => p.id === placementId);\n if (existingInPending) {\n console.log('[Phase 4.314] Skipping reconstructMapFromDOM - placement already in pendingPlacements:', {\n placementId,\n hasDropTimeCoordinates: !!existingInPending.dropTimeCoordinates,\n dropTimeTop: existingInPending.dropTimeCoordinates?.top,\n dropTimeLeft: existingInPending.dropTimeCoordinates?.left,\n });\n return;\n }\n\n // Phase 4.375: Try to read percentage coordinates from data attributes first\n // This fixes the bulk/single mode coordinate shift bug (ping-pong effect).\n // Root cause: CSS positions include bgStart offset from the mode they were set in.\n // If we recalculate percentages using current bgStart (which may differ), we get wrong values.\n // Fix: Store percentage coordinates as data attributes when artwork is added,\n // and read them here instead of recalculating.\n const hasPercentageData = element.dataset.percentTop !== undefined &&\n element.dataset.percentLeft !== undefined;\n\n let left, top, width, height;\n\n if (hasPercentageData) {\n // Use stored percentage coordinates (immune to bgStart changes)\n left = parseFloat(element.dataset.percentLeft) || 0;\n top = parseFloat(element.dataset.percentTop) || 0;\n width = parseFloat(element.dataset.percentWidth) || 0;\n height = parseFloat(element.dataset.percentHeight) || 0;\n\n console.log('[Phase 4.375] reconstructMapFromDOM using stored data attributes:', {\n placementId,\n left,\n top,\n width,\n height,\n note: 'Using stored percentages - immune to bgStart changes',\n });\n } else {\n // Fallback: Extract pixel coordinates from inline styles and recalculate\n // This path is used for legacy DOM elements without data attributes\n const leftPx = parseFloat(element.style.left) || 0;\n const topPx = parseFloat(element.style.top) || 0;\n const widthPx = parseFloat(element.style.width) || 0;\n const heightPx = parseFloat(element.style.height) || 0;\n\n // Phase 0.99.49 FIX: Complete inverse transform with background offset subtraction\n // Phase 0.99.64: ALWAYS subtract bgStart for inverse transform\n // Forward transform (parser.js): ALWAYS adds bgStart for visual positioning\n // Inverse transform (here): ALWAYS subtracts bgStart to get pure image-relative percentages\n // This ensures round-trip consistency: load \u2192 display \u2192 save preserves coordinates\n //\n // Phase 4.198: Use orientation-independent bgStart subtraction (matches handlePlacementUpdate)\n // Previous code was orientation-dependent (only subtracted x OR y based on isVertical).\n // This caused Safari-specific coordinate shift when modal was reopened because\n // isVerticalFit() could return different values due to Safari's layout timing.\n // Fix: Always subtract BOTH bgStart.x AND bgStart.y (one is ~0 based on fit direction).\n const rawLeft = ((leftPx - bgStart.x) / ratio / imageHeight) * 100;\n const rawTop = ((topPx - bgStart.y) / ratio / imageHeight) * 100;\n width = (widthPx / ratio / imageHeight) * 100;\n height = (heightPx / ratio / imageHeight) * 100;\n\n // Phase 0.99.62: Clamp coordinates to valid 0-100% range\n // Same logic as Phase 0.99.56 (handleArtworkDrop) and Phase 0.99.60 (handlePlacementUpdate)\n // Prevents whitespace placements from rendering off-canvas in the composed image\n // This fixes Issue 11: artworks placed in whitespace area were being composited\n // at positions outside the visible product image (e.g., basketball whitespace placements)\n left = Math.max(0, Math.min(rawLeft, 100 - width));\n top = Math.max(0, Math.min(rawTop, 100 - height));\n\n console.log('[Phase 4.375] reconstructMapFromDOM recalculating from CSS (legacy fallback):', {\n placementId,\n leftPx,\n topPx,\n bgStart: { x: bgStart.x, y: bgStart.y },\n calculatedLeft: left,\n calculatedTop: top,\n warning: 'No data attributes - recalculated percentages may shift if bgStart changed',\n });\n }\n\n // Phase 0.99.61: Extract rotation from INTERNAL .resizable element\n // Rotation is applied to .resizable and .rotable-container, NOT the main element\n // (see decorator_canvas_art_placement.js applyTransformations lines 163-177)\n let rotation = 0;\n const resizable = element.querySelector('.resizable');\n const rotationSource = resizable || element;\n const rotationTransform = rotationSource.style.transform;\n if (rotationTransform) {\n const rotateMatch = rotationTransform.match(/rotate\\((-?\\d+\\.?\\d*)deg\\)/);\n if (rotateMatch) {\n rotation = parseFloat(rotateMatch[1]);\n }\n }\n\n // Phase 0.99.61: Extract flip states from INTERNAL .dz-artwork element\n // Flips are applied to .dz-artwork element, NOT the main element\n // (see decorator_canvas_art_placement.js applyTransformations lines 179-193)\n let flipX = false;\n let flipY = false;\n const artworkElement = element.querySelector('.dz-artwork');\n if (artworkElement) {\n const artworkTransform = artworkElement.style.transform;\n if (artworkTransform) {\n if (artworkTransform.includes('scaleX(-1)')) flipX = true;\n if (artworkTransform.includes('scaleY(-1)')) flipY = true;\n }\n }\n\n // Create placement object matching expected format\n const placement = {\n id: placementId,\n artworkId: artworkId,\n left: left,\n top: top,\n width: width,\n height: height,\n rotation: rotation,\n flipX: flipX,\n flipY: flipY,\n // Note: We don't have artworkUrl from DOM, but it's not needed for save\n // The backend already has the artwork association via artworkId\n // Flag existing placements for coordinate conversion\n // Existing placements have IDs starting with 'existing_' (see addExistingPlacement line 1384)\n // This ensures bgStart is NOT subtracted in handlePlacementUpdate for reconstructed placements\n isExistingPlacement: placementId.startsWith('existing_'),\n // Phase 4.85: Pass existing DOM element to prevent creating duplicate via template cloning\n // This is the key fix for the \"two drag controls\" bug - wrap existing element instead of cloning\n existingElement: element,\n // Phase 4.215: Lock coordinates to prevent handlePlacementUpdate from corrupting percentages\n // DOM reconstruction already calculated correct image-relative percentages above.\n // Without this lock, layout events trigger handlePlacementUpdate which recalculates\n // percentages using potentially different bgStart/scaling values, causing coordinate shift.\n coordinatesLocked: true,\n // Phase 4.314: Add dropTimeCoordinates for reconstructed placements\n // This ensures getAllCurrentPlacements has correct coordinates even if handlePlacementUpdate\n // runs with different parameters later. Uses current canvas dimensions as the reference.\n dropTimeCoordinates: {\n left: left,\n top: top,\n width: width,\n height: height,\n loadedFromDatabase: placementId.startsWith('existing_'),\n actualImageHeight: imageHeight,\n bgStart: bgStart,\n reconstructedFromDOM: true,\n },\n };\n\n // Add to canvas pending queue (will be processed through normal flow)\n // This ensures duplicate prevention and proper ArtPlacement object creation\n canvas.pendingPlacements.push(placement);\n reconstructedCount++;\n\n } catch (error) {\n // Failed to reconstruct placement from DOM\n }\n });\n\n // Trigger pending queue processing\n if (reconstructedCount > 0 && canvas.isLoaded) {\n // Process pending placements immediately\n // Phase 4.80: Pass fromDomReconstruction flag to skip DOM check and DOM append\n // (elements already exist in DOM, we're just syncing the Map with them)\n const pending = [...canvas.pendingPlacements];\n canvas.pendingPlacements = [];\n pending.forEach(p => canvas.addArtPlacement(p, { fromDomReconstruction: true }));\n }\n\n return reconstructedCount;\n }\n\n handleHistoryReload(event) {\n const history = event.detail;\n\n if (!history || !this.currentCanvas) return;\n\n // Set flag to prevent state change events during restore\n this.isRestoringFromHistory = true;\n\n // Get the current state from history\n const currentState = history.getCurrentState();\n\n // Clear all existing placements\n this.currentCanvas.destroyArtPlacements();\n\n // Recreate placements from history state\n // Data is already normalized by the history controller\n Object.entries(currentState).forEach(([placementId, placementData]) => {\n // Ensure the placement has its ID set\n const placement = {\n ...placementData,\n id: placementId,\n };\n\n // Add the placement back to canvas\n this.currentCanvas.addArtPlacement(placement);\n });\n\n // Reset flag\n this.isRestoringFromHistory = false;\n }\n\n handlePublish(event) {\n const publishController = event.detail.controller;\n\n // Phase 4.373: Removed Phase 4.186 sync code\n // Use currentCanvas.boardDimensions getter which always reads live from DOM\n // This eliminates the need for manual sync before save\n\n // Collect data in the EXACT format expected by the backend\n // Phase 4.373: Use currentCanvas.boardDimensions (live getter) with fallback to this.boardDimensions\n // Fallback handles cases where currentCanvas or its board is not available\n const canvasDims = this.currentCanvas?.boardDimensions;\n const boardDims = (canvasDims && canvasDims.width > 0 && canvasDims.height > 0)\n ? canvasDims\n : (this.boardDimensions || { width: 0, height: 0 });\n const data = {\n board_width: boardDims.width,\n board_height: boardDims.height,\n art_placement_border_width: 1,\n art_placements_by_image_id: {},\n };\n\n // Gather placements from all canvases\n Object.entries(this.canvases).forEach(([imageId, canvas]) => {\n const placements = this.getPlacementData(canvas);\n if (Object.keys(placements).length > 0) {\n data.art_placements_by_image_id[imageId] = placements;\n }\n });\n\n // Pass data to publish controller\n publishController.submitDecoration(data);\n }\n\n // Convert canvas placements to backend format\n getPlacementData(canvas) {\n const data = {};\n\n // Phase 0.99.17: Robust fallback chain to handle instance-level vs module-level state\n // Phase 0.99.20: Added Fallback 0 using this.canvases for additional resilience\n // Problem: this.currentCanvas is instance-level, can be null in stale controller instances\n // Solution: Multi-layer fallback chain with currentCanvas, this.canvases, and globalCanvasState\n // Phase 4.383 FIX: Reordered fallbacks - currentCanvas FIRST, then canvases cache\n // Bug: Previously searched this.canvases first, which returns FIRST canvas with placements,\n // not the canvas user is currently viewing. When user switches to orientation B and adds\n // artwork, but orientation A already has placements, the wrong canvas was returned.\n let targetCanvas = canvas;\n\n // Fallback 0: Try instance-level currentCanvas FIRST (Phase 4.383 fix)\n // This is the canvas the user is currently viewing and working on\n // Only accept if it has placements, otherwise continue to search canvases cache\n if (!targetCanvas && this.currentCanvas) {\n const placementCount = this.currentCanvas.artPlacements?.size || 0;\n if (placementCount > 0) {\n targetCanvas = this.currentCanvas;\n }\n }\n\n // Fallback 1: Try instance-level canvases cache (Phase 0.99.20 defense in depth)\n // This works even when this.currentCanvas is null after reconnection\n // because this.canvases is populated by findOrCreateCanvas() and persists\n // Phase 4.72 FIX: Only accept canvas if it has placements > 0, otherwise continue to Fallback 2\n if (!targetCanvas && this.canvases && Object.keys(this.canvases).length > 0) {\n // Search ALL canvases for one with placements (not just first)\n for (const imageId of Object.keys(this.canvases)) {\n const canvas = this.canvases[imageId];\n if (canvas) {\n const placementCount = canvas.artPlacements?.size || 0;\n if (placementCount > 0) {\n targetCanvas = canvas;\n break;\n }\n }\n }\n }\n\n // Fallback 2: Try globalCanvasState using current image ID (Phase 0.99.17 fix)\n // BUG FIX: this.currentImageId getter returns null when this.currentCanvas is null,\n // making this fallback unreachable. Search ALL entries in globalCanvasState instead.\n if (!targetCanvas) {\n // Try to find any canvas with this sale's ID prefix\n const salePrefix = `${this.saleId}_`;\n for (const [key, savedCanvas] of globalCanvasState.entries()) {\n if (key.startsWith(salePrefix) && savedCanvas) {\n const placementCount = savedCanvas.artPlacements?.size || 0;\n if (placementCount > 0) {\n targetCanvas = savedCanvas;\n break;\n }\n }\n }\n }\n\n if (!targetCanvas) {\n return data;\n }\n\n // Phase 0.99.21: Diagnostic logging to debug Fallback 0 issue\n\n // Phase 0.99.22: Add error boundary to capture getArtPlacements() failures\n let placements;\n try {\n placements = targetCanvas.getArtPlacements();\n } catch (error) {\n return data; // Return empty data on error\n }\n\n // Phase 0.99.22: Validate return value before processing\n if (!placements) {\n return data;\n }\n\n if (!Array.isArray(placements)) {\n return data;\n }\n\n // Phase 0.99.21B: Log what getArtPlacements returned with FULL details\n\n let index = 1;\n\n placements.forEach((placement) => {\n data[index] = {\n artworkId: parseInt(placement.artworkId),\n left: placement.left, // Already % of height from canvas\n top: placement.top, // Already % of height from canvas\n width: placement.width, // Already % of height from canvas\n height: placement.height, // Already % of height from canvas\n rotate: placement.rotation * (Math.PI / 180), // Convert to radians\n mirrorHorizontally: placement.flipX,\n mirrorVertically: placement.flipY,\n // Phase 4.281: Include drop-time bgStart for correct server-side scaling\n // MagickGeometry uses these to calculate displayed_image_height for letterbox offset\n // Without these fields, server falls back to board_height \u2192 wrong scale factor\n dropTimeBgStartX: placement.bgStart?.x ?? null,\n dropTimeBgStartY: placement.bgStart?.y ?? null,\n dropTimeIsVerticalFit: placement.isVerticalFit ?? null,\n // Phase 4.324: Include actualImageHeight for correct server-side percentage\u2192pixel conversion\n // This was previously included via getAllCurrentPlacements (Phase 4.301) but lost in Phase 4.321\n // when bulk mode switched to getPlacementDataAsArray(). Without this, server falls back to\n // base_image.height which may differ from client's value, causing coordinate shift.\n dropTimeActualImageHeight: placement.actualImageHeight ?? null,\n };\n\n // Phase 4.347: Diagnostic to trace artworkId values through the save flow\n // Root cause: Existing placements lost because artworkId is missing/undefined\n // Backend: artwork_instances.find { |ai| ai.artwork_id == 0 } returns nil \u2192 placement skipped\n console.log('[Phase 4.347] getPlacementData artworkId TRACE:', {\n placementIndex: index,\n rawArtworkId: placement.artworkId,\n parsedArtworkId: parseInt(placement.artworkId),\n hasDropTimeCoords: !!placement.dropTimeCoordinates,\n dropTimeCoordsArtworkId: placement.dropTimeCoordinates?.artworkId,\n hasOriginalData: !!placement.originalData,\n originalDataArtworkId: placement.originalData?.artworkId,\n });\n\n // Phase 4.325: Diagnostic to verify field is being sent\n console.log('[Phase 4.325] getPlacementData sending:', {\n placementIndex: index,\n actualImageHeight: placement.actualImageHeight,\n dropTimeActualImageHeight: placement.actualImageHeight ?? null,\n storedDropTimeCoords: placement.dropTimeCoordinates ? {\n top: placement.dropTimeCoordinates.top,\n actualImageHeight: placement.dropTimeCoordinates.actualImageHeight,\n } : 'NOT_SET',\n });\n\n // Phase 4.329: Add IMAGE_ID to enable client-server log correlation\n // Previous Phase 4.327 couldn't correlate because image ID was missing\n // This enables matching client logs with server logs for the SAME image\n console.log('[Phase 4.329] getPlacementData VALUES:', {\n imageId: targetCanvas.imageId, // CRITICAL: Must match server logs\n placementIndex: index,\n topValue: placement.top, // THIS is what gets sent to server\n leftValue: placement.left, // THIS is what gets sent to server\n widthValue: placement.width,\n heightValue: placement.height,\n placementId: placement.id,\n });\n\n index++;\n });\n\n // Phase 0.99.21: Log final data object\n\n return data;\n }\n\n /**\n * Phase 4.321: Get placement data as array (for bulk mode compatibility)\n * Uses same simple path as getPlacementData() - reads directly from\n * dropTimeCoordinates without complex transforms or threshold logic.\n *\n * This fixes the Safari bulk mode coordinate shift where getAllCurrentPlacements()\n * used complex inverse transform logic (Phase 4.298, 4.310) that could produce\n * different percentages than what was stored at drop time.\n *\n * @param {Object} canvas - The canvas to get placements from\n * @returns {Array} Array of placement objects with stored percentages\n */\n getPlacementDataAsArray(canvas) {\n const data = this.getPlacementData(canvas);\n // Convert object { 1: {...}, 2: {...} } to array [{...}, {...}]\n return Object.values(data);\n }\n\n // Get placements for ALL decorated images (multi-image support)\n // Used by save operations to collect placements across front/back/sides\n // Returns: { imageId: placementsObject, ... }\n getAllImagePlacements() {\n const placementsByImage = {};\n\n // Phase 4.142: Add fallback to globalCanvasState similar to getPlacementData()\n // When controller is stale/reconnected, this.canvases may be empty while\n // globalCanvasState still has the active canvas with placements\n\n // Try 1: Instance-level canvases cache\n if (this.canvases && Object.keys(this.canvases).length > 0) {\n Object.entries(this.canvases).forEach(([cacheKey, canvas]) => {\n // Phase 4.393: Extract imageId from localCacheKey format ${colorVariantId}_${imageId}\n // cacheKey is e.g., \"default_779188194\" or \"456_779188194\"\n const underscoreIndex = cacheKey.indexOf('_');\n const imageId = underscoreIndex !== -1 ? cacheKey.substring(underscoreIndex + 1) : cacheKey;\n const placements = this.getPlacementData(canvas);\n if (Object.keys(placements).length > 0) {\n placementsByImage[imageId] = placements;\n }\n });\n }\n\n // Phase 4.142: Fallback to globalCanvasState if no placements found\n if (Object.keys(placementsByImage).length === 0) {\n const salePrefix = `${this.saleId}_`;\n for (const [key, savedCanvas] of globalCanvasState.entries()) {\n if (key.startsWith(salePrefix) && savedCanvas) {\n // Phase 4.393: Extract imageId from key format ${saleId}_${colorVariantId}_${imageId}\n // After removing salePrefix, we have ${colorVariantId}_${imageId}\n const keyWithoutSale = key.replace(salePrefix, '');\n const underscoreIndex = keyWithoutSale.indexOf('_');\n const imageId = underscoreIndex !== -1 ? keyWithoutSale.substring(underscoreIndex + 1) : keyWithoutSale;\n const placements = this.getPlacementData(savedCanvas);\n if (Object.keys(placements).length > 0) {\n placementsByImage[imageId] = placements;\n }\n }\n }\n }\n\n return placementsByImage;\n }\n\n /**\n * Phase 4.124: Get all current placements in percentage format for bulk mode persistence\n * Uses getBoundingClientRect() to read ACTUAL rendered positions instead of inline styles.\n * This captures positions correctly even when:\n * - Element uses CSS transforms (during drag)\n * - Inline styles aren't set yet (newly created elements)\n * - Element is being animated\n * @param {Object} providedCanvas - Phase 4.142: Optional canvas parameter\n * @returns {Array} Array of placement data with percentage coordinates\n */\n getAllCurrentPlacements(providedCanvas = null) {\n // Phase 4.142: Accept optional canvas parameter, fallback to this.currentCanvas\n let targetCanvas = providedCanvas || this.currentCanvas;\n\n // Phase 4.142: Fallback to globalCanvasState if still null\n if (!targetCanvas) {\n const salePrefix = `${this.saleId}_`;\n for (const [key, savedCanvas] of globalCanvasState.entries()) {\n if (key.startsWith(salePrefix) && savedCanvas) {\n const placementCount = savedCanvas.artPlacements?.size || 0;\n if (placementCount > 0) {\n targetCanvas = savedCanvas;\n break;\n }\n }\n }\n }\n\n if (!targetCanvas || !targetCanvas.artPlacements) {\n return [];\n }\n\n // Phase 4.373: Removed Phase 4.200 sync code\n // ImageCanvas.boardDimensions is now a getter that always reads live from DOM\n // No sync needed - getter eliminates stale cache issues entirely\n\n // Get conversion factors (same approach as handlePlacementUpdate)\n // Use defensive checks since these methods may not exist on test mocks\n const scalingFactor = typeof targetCanvas.getScalingFactor === 'function'\n ? targetCanvas.getScalingFactor()\n : null;\n const imageEl = targetCanvas.imageEl;\n // Phase 4.357: Use consolidated helper method for actualImageHeight calculation\n // Phase 4.222: Bulk mode passes targetCanvas explicitly to get correct dimensions\n const actualImageHeight = this.getActualImageHeight(targetCanvas);\n const bgStart = typeof targetCanvas.getBackgroundImageStart === 'function'\n ? targetCanvas.getBackgroundImageStart()\n : { x: 0, y: 0 };\n const isVertical = typeof targetCanvas.isVerticalFit === 'function'\n ? targetCanvas.isVerticalFit()\n : false;\n\n // Phase 4.124: Get container rect for relative position calculation\n const container = targetCanvas.container;\n const containerRect = container && typeof container.getBoundingClientRect === 'function'\n ? container.getBoundingClientRect()\n : null;\n\n // If we don't have valid conversion factors OR container rect, fall back to originalData\n if (!scalingFactor || scalingFactor === 0 || !actualImageHeight || actualImageHeight === 0 || !containerRect) {\n const placements = [];\n targetCanvas.artPlacements.forEach((artPlacement, placementId) => {\n const data = artPlacement.originalData || artPlacement.data;\n if (data) {\n placements.push({\n placementId: placementId,\n artworkId: data.artworkId,\n artworkUrl: data.artworkUrl,\n left: data.left,\n top: data.top,\n width: data.width,\n height: data.height,\n rotation: data.rotation || 0,\n flipX: data.flipX || false,\n flipY: data.flipY || false,\n // Phase 4.147: Include containerPixel values from originalData when available\n containerPixelLeft: data.containerPixelLeft,\n containerPixelTop: data.containerPixelTop,\n containerPixelWidth: data.containerPixelWidth,\n containerPixelHeight: data.containerPixelHeight,\n });\n }\n });\n return placements;\n }\n\n const placements = [];\n targetCanvas.artPlacements.forEach((artPlacement, placementId) => {\n const element = artPlacement.getElement();\n const data = artPlacement.originalData || artPlacement.data;\n\n if (element && data) {\n // Phase 4.136: Use originalData coordinates when available\n // originalData contains correct percentage coordinates from handlePlacementUpdate()\n // which uses element.parentElement.getBoundingClientRect() for accurate positioning.\n // This fixes bulk decoration coordinate regression where DOM reading with\n // container.getBoundingClientRect() returned different values.\n // Phase 4.169: ALWAYS use originalData if it exists - never fall through to DOM path\n // The DOM path recalculates with CSS dimensions causing coordinate shift.\n // Use Number() coercion to handle edge cases (strings, undefined).\n //\n // Phase 4.242: Prefer dropTimeCoordinates over originalData\n // dropTimeCoordinates is immutable (only cleared on explicit user drag), while\n // originalData can be corrupted by handlePlacementUpdate when coordinatesLocked\n // is unexpectedly cleared (Safari bulk mode bug). This ensures we always use\n // the correct drop-time coordinates for saving.\n //\n // Phase 4.298: ROBUST coordinate selection - detect ACTUAL user drag vs phantom event\n // Instead of relying solely on coordinatesLocked (which Safari phantom events can clear),\n // we compare dropTimeCoordinates with originalData. If they differ significantly (>2%),\n // the user has intentionally moved the artwork. Otherwise, dropTimeCoordinates is the\n // source of truth and should be used regardless of coordinatesLocked state.\n //\n // This fixes the Safari bulk mode bug where:\n // 1. Artwork dropped \u2192 coordinatesLocked=true, dropTimeCoordinates correct\n // 2. Safari phantom drag \u2192 coordinatesLocked=false\n // 3. handlePlacementUpdate runs \u2192 originalData potentially corrupted\n // 4. Save \u2192 wrong coordinates sent to server (originalData preferred when unlocked)\n //\n // The fix: Only use originalData when it ACTUALLY differs from dropTimeCoordinates.\n let coords;\n const dropCoords = artPlacement.dropTimeCoordinates;\n const origCoords = artPlacement.originalData;\n // Phase 4.368: Increase threshold from 2% to 5% to prevent false positives\n // Safari phantom events and animation effects can cause 2-3% coordinate corruption\n // in originalData. The previous 2% threshold falsely triggered \"intentional drag\"\n // detection when diff was 2.67%, causing bulk mode to use corrupted originalData\n // instead of correct dropTimeCoordinates. 5% threshold (~63px on 1250px image)\n // is large enough to filter out animation artifacts while still detecting real drags.\n const INTENTIONAL_DRAG_THRESHOLD = 5.0; // 5% = ~63px on 1250px image\n\n if (dropCoords) {\n // Phase 4.310: Time-based protection for freshly dropped artwork\n // Safari phantom events can corrupt originalData within ~1 second of drop.\n // If artwork was dropped < 2 seconds ago, always trust dropTimeCoordinates.\n const FRESH_DROP_WINDOW_MS = 2000;\n const dropAge = dropCoords.timestamp ? Date.now() - dropCoords.timestamp : Infinity;\n const isFreshDrop = dropAge < FRESH_DROP_WINDOW_MS;\n\n // Check if originalData differs significantly from dropTimeCoordinates\n const topDiff = Math.abs((Number(origCoords?.top) || 0) - Number(dropCoords.top));\n const leftDiff = Math.abs((Number(origCoords?.left) || 0) - Number(dropCoords.left));\n const widthDiff = Math.abs((Number(origCoords?.width) || 0) - (Number(dropCoords?.width) || 0));\n const heightDiff = Math.abs((Number(origCoords?.height) || 0) - (Number(dropCoords?.height) || 0));\n\n // Phase 4.384: Detect tool changes (rotation, flip) in addition to position changes\n // Tool operations update originalData but NOT dropTimeCoordinates (immutable).\n // Without this check, tool changes are lost when saving without position movement.\n const hasRotationChange = (Number(origCoords?.rotation) || 0) !== (Number(dropCoords?.rotation) || 0);\n const hasFlipXChange = Boolean(origCoords?.flipX) !== Boolean(dropCoords?.flipX);\n const hasFlipYChange = Boolean(origCoords?.flipY) !== Boolean(dropCoords?.flipY);\n\n const hasIntentionalChange =\n topDiff > INTENTIONAL_DRAG_THRESHOLD ||\n leftDiff > INTENTIONAL_DRAG_THRESHOLD ||\n widthDiff > INTENTIONAL_DRAG_THRESHOLD ||\n heightDiff > INTENTIONAL_DRAG_THRESHOLD ||\n hasRotationChange ||\n hasFlipXChange ||\n hasFlipYChange;\n\n // Phase 4.368: Log threshold comparison for debugging coordinate shift\n // Phase 4.384: Extended to include tool change tracking\n // Phase 4.385: Use debugLog for hot-path diagnostic\n debugLog('[Phase 4.384] INTENTIONAL CHANGE DETECTION:', {\n placementId,\n dropCoordsTop: dropCoords.top,\n origCoordsTop: origCoords?.top,\n topDiff: topDiff.toFixed(2),\n leftDiff: leftDiff.toFixed(2),\n widthDiff: widthDiff.toFixed(2),\n heightDiff: heightDiff.toFixed(2),\n threshold: INTENTIONAL_DRAG_THRESHOLD,\n // Phase 4.384: Tool change tracking\n origRotation: origCoords?.rotation,\n dropRotation: dropCoords?.rotation,\n hasRotationChange,\n hasFlipXChange,\n hasFlipYChange,\n hasIntentionalChange,\n isFreshDrop,\n dropAgeMs: dropAge,\n coordinatesLocked: artPlacement.coordinatesLocked,\n });\n\n // Phase 4.310: Fresh drops always use dropTimeCoordinates\n // Phase 4.385: Use debugLog for hot-path coordinate decision logs\n if (isFreshDrop) {\n coords = dropCoords;\n debugLog('[Phase 4.310] Using dropTimeCoordinates (fresh drop protection):', {\n placementId,\n dropAgeMs: dropAge,\n threshold: FRESH_DROP_WINDOW_MS,\n topDiff: topDiff.toFixed(2),\n leftDiff: leftDiff.toFixed(2),\n coordsTop: coords.top,\n coordsLeft: coords.left,\n });\n } else if (hasIntentionalChange && origCoords) {\n // User has intentionally changed the artwork - use their updated values\n coords = origCoords;\n debugLog('[Phase 4.384] Using originalData (intentional change detected):', {\n placementId,\n topDiff: topDiff.toFixed(2),\n leftDiff: leftDiff.toFixed(2),\n threshold: INTENTIONAL_DRAG_THRESHOLD,\n hasRotationChange,\n hasFlipXChange,\n hasFlipYChange,\n coordsTop: coords.top,\n coordsLeft: coords.left,\n coordsRotation: coords.rotation,\n coordsFlipX: coords.flipX,\n coordsFlipY: coords.flipY,\n });\n } else {\n // No significant change - use immutable drop-time coordinates\n // This protects against Safari phantom events corrupting originalData\n coords = dropCoords;\n debugLog('[Phase 4.298] Using dropTimeCoordinates (no significant change):', {\n placementId,\n topDiff: topDiff.toFixed(2),\n leftDiff: leftDiff.toFixed(2),\n threshold: INTENTIONAL_DRAG_THRESHOLD,\n coordinatesLocked: artPlacement.coordinatesLocked,\n coordsTop: coords.top,\n coordsLeft: coords.left,\n });\n }\n } else {\n // No dropTimeCoordinates available - fall back to originalData\n coords = origCoords;\n debugLog('[Phase 4.298] No dropTimeCoordinates, using originalData:', {\n placementId,\n coordsTop: coords?.top,\n coordsLeft: coords?.left,\n });\n }\n if (coords) {\n // Phase 4.144 Part A: Also read container pixel positions from DOM for bulk restoration\n // Even though we use originalData percentages for saving, we need container pixels\n // for consistent visual positioning when restoring on different products\n let containerPixelLeft, containerPixelTop, containerPixelWidth, containerPixelHeight;\n const elementRect = typeof element.getBoundingClientRect === 'function'\n ? element.getBoundingClientRect()\n : null;\n if (elementRect && elementRect.width > 0 && containerRect) {\n // Phase 4.194: Compensate for .resizable -8px offset\n // elementRect includes the resize handle overflow, add RESIZABLE_HANDLE_OFFSET to get actual artwork position\n containerPixelLeft = elementRect.left - containerRect.left + RESIZABLE_HANDLE_OFFSET;\n containerPixelTop = elementRect.top - containerRect.top + RESIZABLE_HANDLE_OFFSET;\n containerPixelWidth = elementRect.width - (RESIZABLE_HANDLE_OFFSET * 2);\n containerPixelHeight = elementRect.height - (RESIZABLE_HANDLE_OFFSET * 2);\n\n // Phase 4.273: Log containerPixelLeft calculation to find the 629 vs 438 discrepancy\n // Expected: elementRect.left - containerRect.left \u2248 CSS style.left \u2248 438px\n // Actual: containerPixelLeft \u2248 629px\n console.log('[Phase 4.273] containerPixelLeft CALC:', {\n placementId,\n elementRectLeft: elementRect.left,\n containerRectLeft: containerRect.left,\n diff: elementRect.left - containerRect.left,\n RESIZABLE_HANDLE_OFFSET,\n result: containerPixelLeft,\n // Check the actual CSS value\n cssStyleLeft: element.style.left,\n cssStyleLeftParsed: parseFloat(element.style.left),\n });\n } else {\n // Fallback for test environment\n containerPixelLeft = parseFloat(element.style.left) || 0;\n containerPixelTop = parseFloat(element.style.top) || 0;\n containerPixelWidth = parseFloat(element.style.width) || 0;\n containerPixelHeight = parseFloat(element.style.height) || 0;\n }\n\n // Phase 4.208: Compute imagePixels directly from percentage coordinates\n // This avoids bgStart timing issues where the current bgStart might differ\n // from the bgStart used when the element was originally positioned.\n // Formula: percentage -> image pixels (same as ArtPlacementParser.parse without bgStart)\n // Phase 4.242: Use coords (dropTimeCoordinates || originalData)\n const imagePixelLeft = (Number(coords.left) / 100) * actualImageHeight * scalingFactor;\n const imagePixelTop = (Number(coords.top) / 100) * actualImageHeight * scalingFactor;\n const imagePixelWidth = (Number(coords.width) / 100) * actualImageHeight * scalingFactor;\n const imagePixelHeight = (Number(coords.height) / 100) * actualImageHeight * scalingFactor;\n\n // Phase 4.229: Show which path is being used - compare with drop-time log\n // Phase 4.242: Updated to show dropTimeCoordinates vs originalData\n console.log('[Phase 4.229] getAllCurrentPlacements PATH:', {\n placementId,\n hasDropTimeCoordinates: !!artPlacement.dropTimeCoordinates,\n hasOriginalData: !!artPlacement.originalData,\n usingCoords: artPlacement.dropTimeCoordinates ? 'dropTimeCoordinates' : 'originalData',\n coordsTop: coords?.top,\n coordsLeft: coords?.left,\n coordinatesLocked: artPlacement.coordinatesLocked,\n });\n\n // Phase 4.246: Compare drop-time dimensions with current dimensions\n // If these differ, it explains why same percentage appears at different visual positions\n const dropTimeDims = artPlacement.dropTimeCoordinates?.boardDimensions;\n if (dropTimeDims) {\n const currentDims = targetCanvas.boardDimensions;\n const heightDiff = currentDims.height - dropTimeDims.height;\n const widthDiff = currentDims.width - dropTimeDims.width;\n console.log('[Phase 4.246] SAVE-TIME DIMENSION COMPARISON:', {\n placementId,\n dropTimeBoardDimensions: dropTimeDims,\n currentBoardDimensions: currentDims,\n heightDiff,\n widthDiff,\n hasMismatch: heightDiff !== 0 || widthDiff !== 0,\n dropTimeBgStart: artPlacement.dropTimeCoordinates?.bgStart,\n currentBgStart: bgStart,\n dropTimeScalingFactor: artPlacement.dropTimeCoordinates?.scalingFactor,\n currentScalingFactor: scalingFactor,\n });\n }\n\n // Phase 4.195: Diagnostic logging to identify coordinate mismatch\n // Phase 4.242: Use coords instead of originalData\n console.log('[Phase 4.195] getAllCurrentPlacements sending (from coords):', {\n placementId,\n top: Number(coords.top),\n left: Number(coords.left),\n width: Number(coords.width),\n height: Number(coords.height),\n containerPixelTop,\n containerPixelLeft,\n imagePixelTop,\n imagePixelLeft,\n });\n\n // Phase 4.263: Compare stored percentage with current visual position\n // This diagnostic helps identify if there's a mismatch between:\n // - What's stored (coords.top) vs what's visually rendered\n const cssTop = parseFloat(element.style.top) || 0;\n const diagElementRect = element.getBoundingClientRect();\n const boardRect = targetCanvas.board ? targetCanvas.board.getBoundingClientRect() : null;\n const visualTopFromBoard = boardRect ? (diagElementRect.top - boardRect.top) : null;\n // Calculate what percentage the visual position represents\n const visualTopImageRelative = visualTopFromBoard !== null ? (visualTopFromBoard - bgStart.y) : null;\n const visualPercentage = visualTopImageRelative !== null && scalingFactor && actualImageHeight\n ? ((visualTopImageRelative / scalingFactor) / actualImageHeight) * 100\n : null;\n console.log('[Phase 4.263] STORED vs VISUAL POSITION:');\n console.log(' Stored percentage:', Number(coords.top).toFixed(2) + '%');\n console.log(' CSS style.top:', cssTop.toFixed(2) + 'px');\n console.log(' Visual position (from board):', visualTopFromBoard !== null ? visualTopFromBoard.toFixed(2) + 'px' : 'N/A');\n console.log(' Visual position (image-relative):', visualTopImageRelative !== null ? visualTopImageRelative.toFixed(2) + 'px' : 'N/A');\n console.log(' Visual percentage (recalculated):', visualPercentage !== null ? visualPercentage.toFixed(2) + '%' : 'N/A');\n console.log(' bgStart.y:', bgStart.y.toFixed(2) + 'px');\n console.log(' scalingFactor:', scalingFactor);\n console.log(' actualImageHeight:', actualImageHeight + 'px');\n if (visualPercentage !== null) {\n const percentageDiff = Math.abs(visualPercentage - Number(coords.top));\n console.log(' MISMATCH:', percentageDiff.toFixed(2) + '% difference');\n if (percentageDiff > 5) {\n console.warn('[Phase 4.263] \u26A0\uFE0F SIGNIFICANT MISMATCH DETECTED!');\n console.warn(' This explains why baked image differs from canvas preview.');\n console.warn(' The stored percentage (' + Number(coords.top).toFixed(2) + '%) does not match visual position (' + visualPercentage.toFixed(2) + '%).');\n }\n }\n\n // Phase 4.264: Compare CENTER position to check for size mismatch\n // Even if top-left matches, if artwork SIZE differs, the CENTER appears shifted\n const storedTopPct = Number(coords.top) || 0;\n const storedHeightPct = Number(coords.height) || 0;\n const storedCenterPct = storedTopPct + (storedHeightPct / 2);\n\n // Calculate visual center from DOM\n const visualHeightPx = diagElementRect.height || 0;\n const visualHeightImagePx = visualHeightPx / scalingFactor;\n const visualHeightPct = (visualHeightImagePx / actualImageHeight) * 100;\n const visualCenterPct = visualPercentage !== null ? visualPercentage + (visualHeightPct / 2) : null;\n\n console.log('[Phase 4.264] CENTER POSITION COMPARISON:');\n console.log(' Stored: top=' + storedTopPct.toFixed(2) + '% height=' + storedHeightPct.toFixed(2) + '% center=' + storedCenterPct.toFixed(2) + '%');\n console.log(' Visual: height(px)=' + visualHeightPx.toFixed(1) + ' height(%)=' + visualHeightPct.toFixed(2) + '% center=' + (visualCenterPct !== null ? visualCenterPct.toFixed(2) + '%' : 'N/A'));\n if (visualCenterPct !== null) {\n const centerDiff = Math.abs(storedCenterPct - visualCenterPct);\n console.log(' CENTER MISMATCH:', centerDiff.toFixed(2) + '% (' + (centerDiff * actualImageHeight / 100).toFixed(1) + 'px on image)');\n }\n // Also log what server will calculate: center = top% + height%/2\n // Compare with visual center to see if they match\n console.log(' SERVER will place center at:', (storedTopPct + storedHeightPct/2).toFixed(2) + '% (' + ((storedTopPct + storedHeightPct/2) * actualImageHeight / 100).toFixed(1) + 'px)');\n\n placements.push({\n placementId: placementId,\n artworkId: data.artworkId,\n artworkUrl: data.artworkUrl,\n // Phase 4.169: Use Number() coercion to handle strings/undefined edge cases\n // Phase 4.242: Use coords (dropTimeCoordinates || originalData)\n left: Number(coords.left) || 0,\n top: Number(coords.top) || 0,\n width: Number(coords.width) || 10,\n height: Number(coords.height) || 10,\n rotation: data.rotation || 0,\n flipX: data.flipX || false,\n flipY: data.flipY || false,\n // Phase 4.144 Part A: Container-relative pixel positions for bulk mode\n containerPixelLeft: containerPixelLeft,\n containerPixelTop: containerPixelTop,\n containerPixelWidth: containerPixelWidth,\n containerPixelHeight: containerPixelHeight,\n // Phase 4.208: Image-relative pixels computed from percentages for bulk restore\n imagePixelLeft: imagePixelLeft,\n imagePixelTop: imagePixelTop,\n imagePixelWidth: imagePixelWidth,\n imagePixelHeight: imagePixelHeight,\n // Phase 4.202: Store bgStart for adjustment during restore\n // Safari timing differences can cause different bgStart values on modal reopen\n savedBgStartX: bgStart.x,\n savedBgStartY: bgStart.y,\n // Phase 4.225: Include coordinatesLocked so Phase 4.224's hasLockedPlacements check works\n // Without this, getAllProductPlacements() can't detect locked placements\n coordinatesLocked: artPlacement.coordinatesLocked || false,\n // Phase 4.243: Include dropTimeCoordinates so it survives bulk mode cycle\n // When placements are captured to globalBulkDecorationPlacements and restored\n // via restoreBulkPlacements, the dropTimeCoordinates must be preserved.\n // Without this, restoreBulkPlacements has no dropTimeCoordinates to pass to\n // addArtPlacement, causing getAllCurrentPlacements to fall back to corrupted originalData.\n dropTimeCoordinates: artPlacement.dropTimeCoordinates\n ? { ...artPlacement.dropTimeCoordinates }\n : null,\n // Phase 4.278: Include drop-time transform parameters for server-side baking\n // Server should use these instead of recalculating bgStart, as the coordinates\n // were calculated with these specific values. Without this, cross-product\n // bulk decoration fails when products have different aspect ratios.\n dropTimeBgStartX: artPlacement.dropTimeCoordinates?.bgStart?.x ?? null,\n dropTimeBgStartY: artPlacement.dropTimeCoordinates?.bgStart?.y ?? null,\n dropTimeIsVerticalFit: artPlacement.dropTimeCoordinates?.isVerticalFit ?? null,\n dropTimeScalingFactor: artPlacement.dropTimeCoordinates?.scalingFactor ?? null,\n // Phase 4.301: Include actualImageHeight for server-side dimension matching\n // The percentage was calculated using this height; server must use same height\n // to convert percentage back to pixels correctly. Without this, dimension\n // mismatch between client display image and server base_image causes shift.\n dropTimeActualImageHeight: artPlacement.dropTimeCoordinates?.actualImageHeight ?? actualImageHeight,\n });\n\n // Phase 4.359: Field presence check for bulk mode investigation\n // Verify all expected fields are present after building placement\n const lastPlacement = placements[placements.length - 1];\n console.log('[Phase 4.359] getAllCurrentPlacements FIELD PRESENCE (originalData path):', {\n placementId,\n hasTop: 'top' in lastPlacement,\n hasLeft: 'left' in lastPlacement,\n hasWidth: 'width' in lastPlacement,\n hasHeight: 'height' in lastPlacement,\n hasDropTimeActualImageHeight: 'dropTimeActualImageHeight' in lastPlacement,\n dropTimeActualImageHeightValue: lastPlacement.dropTimeActualImageHeight,\n hasDropTimeBgStartX: 'dropTimeBgStartX' in lastPlacement,\n dropTimeBgStartXValue: lastPlacement.dropTimeBgStartX,\n hasDropTimeBgStartY: 'dropTimeBgStartY' in lastPlacement,\n dropTimeBgStartYValue: lastPlacement.dropTimeBgStartY,\n hasDropTimeIsVerticalFit: 'dropTimeIsVerticalFit' in lastPlacement,\n dropTimeIsVerticalFitValue: lastPlacement.dropTimeIsVerticalFit,\n hasDropTimeScalingFactor: 'dropTimeScalingFactor' in lastPlacement,\n dropTimeScalingFactorValue: lastPlacement.dropTimeScalingFactor,\n hasDropTimeCoordinates: 'dropTimeCoordinates' in lastPlacement,\n dropTimeCoordinatesKeys: lastPlacement.dropTimeCoordinates ? Object.keys(lastPlacement.dropTimeCoordinates) : null,\n hasCoordinatesLocked: 'coordinatesLocked' in lastPlacement,\n coordinatesLockedValue: lastPlacement.coordinatesLocked,\n });\n\n return; // Skip DOM reading for this placement\n }\n\n // Phase 4.229: DOM fallback path - this should NOT happen for fresh drops!\n console.log('[Phase 4.229] getAllCurrentPlacements PATH:', {\n placementId,\n hasOriginalData: false, // originalData doesn't exist - THIS IS THE PROBLEM\n coordinatesLocked: artPlacement?.coordinatesLocked,\n usingOriginalDataPath: false, // false = DOM fallback (bad - recalculates coordinates)\n });\n\n // Fallback: Read from DOM when originalData doesn't have coordinates\n // Phase 4.124: Use getBoundingClientRect() for ACTUAL rendered position\n // This works even when element uses CSS transforms or inline styles aren't set\n const elementRect = typeof element.getBoundingClientRect === 'function'\n ? element.getBoundingClientRect()\n : null;\n\n // Fall back to inline styles if getBoundingClientRect not available (tests)\n let pixelLeft, pixelTop, pixelWidth, pixelHeight;\n if (elementRect && elementRect.width > 0) {\n // Phase 4.194: Compensate for .resizable -8px offset\n // elementRect includes the resize handle overflow, add RESIZABLE_HANDLE_OFFSET to get actual artwork position\n pixelLeft = elementRect.left - containerRect.left + RESIZABLE_HANDLE_OFFSET;\n pixelTop = elementRect.top - containerRect.top + RESIZABLE_HANDLE_OFFSET;\n pixelWidth = elementRect.width - (RESIZABLE_HANDLE_OFFSET * 2);\n pixelHeight = elementRect.height - (RESIZABLE_HANDLE_OFFSET * 2);\n } else {\n // Fallback for test environment (inline styles don't include resize handles)\n pixelLeft = parseFloat(element.style.left) || 0;\n pixelTop = parseFloat(element.style.top) || 0;\n pixelWidth = parseFloat(element.style.width) || 0;\n pixelHeight = parseFloat(element.style.height) || 0;\n }\n\n // Convert pixel to percentage (same logic as handlePlacementUpdate)\n // Phase 4.199: Always subtract BOTH bgStart components (orientation-independent)\n // The forward transform (ArtPlacementParser.parse) always adds BOTH bgStart.x AND bgStart.y,\n // so the inverse transform must always subtract BOTH (one component is ~0 based on orientation)\n // This matches the Phase 4.198 fix for reconstructMapFromDOM and the handlePlacementUpdate pattern\n const adjustedLeft = pixelLeft - bgStart.x;\n const adjustedTop = pixelTop - bgStart.y;\n\n // Convert to percentages of actual image height\n const rawLeft = ((adjustedLeft / scalingFactor) / actualImageHeight) * 100;\n const rawTop = ((adjustedTop / scalingFactor) / actualImageHeight) * 100;\n const rawWidth = ((pixelWidth / scalingFactor) / actualImageHeight) * 100;\n const rawHeight = ((pixelHeight / scalingFactor) / actualImageHeight) * 100;\n\n // Clamp to valid 0-100% range (same as handlePlacementUpdate)\n const clampedLeft = Math.max(0, Math.min(rawLeft, 100 - rawWidth));\n const clampedTop = Math.max(0, Math.min(rawTop, 100 - rawHeight));\n\n // Note: Phase 4.126 originalData sync removed in Phase 4.136\n // Syncing DOM-read values back to originalData was causing coordinate corruption\n // when container.getBoundingClientRect() differed from element.parentElement\n\n // Phase 4.208: Compute imagePixels directly from percentage coordinates\n // This avoids bgStart timing issues where the current bgStart might differ\n // from the bgStart used when the element was originally positioned.\n const imagePixelLeft = (clampedLeft / 100) * actualImageHeight * scalingFactor;\n const imagePixelTop = (clampedTop / 100) * actualImageHeight * scalingFactor;\n const imagePixelWidth = (rawWidth / 100) * actualImageHeight * scalingFactor;\n const imagePixelHeight = (rawHeight / 100) * actualImageHeight * scalingFactor;\n\n // Phase 4.205: Diagnostic logging when storing placement data\n console.log('[Phase 4.205] getAllCurrentPlacements storing:', {\n containerPixelLeft: pixelLeft,\n containerPixelTop: pixelTop,\n imagePixelLeft: imagePixelLeft,\n imagePixelTop: imagePixelTop,\n bgStart: { x: bgStart.x, y: bgStart.y },\n });\n\n placements.push({\n placementId: placementId,\n artworkId: data.artworkId,\n artworkUrl: data.artworkUrl,\n left: clampedLeft,\n top: clampedTop,\n width: rawWidth,\n height: rawHeight,\n rotation: data.rotation || 0,\n flipX: data.flipX || false,\n flipY: data.flipY || false,\n // Phase 4.208: Image-relative pixels computed from percentages for bulk restore\n imagePixelLeft: imagePixelLeft,\n imagePixelTop: imagePixelTop,\n imagePixelWidth: imagePixelWidth,\n imagePixelHeight: imagePixelHeight,\n // Phase 4.144 Part A: Keep container pixels for backward compatibility\n containerPixelLeft: pixelLeft,\n containerPixelTop: pixelTop,\n containerPixelWidth: pixelWidth,\n containerPixelHeight: pixelHeight,\n // Phase 4.202: Store bgStart for backward compatibility\n savedBgStartX: bgStart.x,\n savedBgStartY: bgStart.y,\n // Phase 4.225: Include coordinatesLocked so Phase 4.224's hasLockedPlacements check works\n // Without this, getAllProductPlacements() can't detect locked placements\n coordinatesLocked: artPlacement.coordinatesLocked || false,\n // Phase 4.278: Include drop-time transform parameters for server-side baking\n dropTimeBgStartX: artPlacement.dropTimeCoordinates?.bgStart?.x ?? bgStart.x,\n dropTimeBgStartY: artPlacement.dropTimeCoordinates?.bgStart?.y ?? bgStart.y,\n dropTimeIsVerticalFit: artPlacement.dropTimeCoordinates?.isVerticalFit ?? null,\n dropTimeScalingFactor: artPlacement.dropTimeCoordinates?.scalingFactor ?? null,\n // Phase 4.301: Include actualImageHeight for server-side dimension matching\n dropTimeActualImageHeight: artPlacement.dropTimeCoordinates?.actualImageHeight ?? actualImageHeight,\n });\n\n // Phase 4.359: Field presence check for bulk mode investigation (DOM fallback path)\n const lastPlacement = placements[placements.length - 1];\n console.log('[Phase 4.359] getAllCurrentPlacements FIELD PRESENCE (DOM fallback path):', {\n placementId,\n hasTop: 'top' in lastPlacement,\n hasLeft: 'left' in lastPlacement,\n hasWidth: 'width' in lastPlacement,\n hasHeight: 'height' in lastPlacement,\n hasDropTimeActualImageHeight: 'dropTimeActualImageHeight' in lastPlacement,\n dropTimeActualImageHeightValue: lastPlacement.dropTimeActualImageHeight,\n hasDropTimeBgStartX: 'dropTimeBgStartX' in lastPlacement,\n dropTimeBgStartXValue: lastPlacement.dropTimeBgStartX,\n hasDropTimeBgStartY: 'dropTimeBgStartY' in lastPlacement,\n dropTimeBgStartYValue: lastPlacement.dropTimeBgStartY,\n hasDropTimeIsVerticalFit: 'dropTimeIsVerticalFit' in lastPlacement,\n dropTimeIsVerticalFitValue: lastPlacement.dropTimeIsVerticalFit,\n hasDropTimeScalingFactor: 'dropTimeScalingFactor' in lastPlacement,\n dropTimeScalingFactorValue: lastPlacement.dropTimeScalingFactor,\n hasDropTimeCoordinates: 'dropTimeCoordinates' in lastPlacement,\n hasCoordinatesLocked: 'coordinatesLocked' in lastPlacement,\n coordinatesLockedValue: lastPlacement.coordinatesLocked,\n warning: 'DOM fallback path - dropTimeCoordinates should NOT be used!',\n });\n }\n });\n\n return placements;\n }\n\n /**\n * Phase 4.118: Restore bulk placements to current canvas\n * Called after new product image loads to restore previously placed artwork\n * Placements use percentage coordinates so they scale to any image size\n * @param {Array} placements - Array of placement data from getAllCurrentPlacements\n * @param {Object} providedCanvas - Phase 4.144: Optional canvas parameter to avoid race condition\n */\n restoreBulkPlacements(placements, providedCanvas = null) {\n // Phase 4.144: Accept provided canvas or fall back to this.currentCanvas\n // This fixes race condition where setTimeout fires after clearAllCanvases() has nulled this.currentCanvas\n const targetCanvas = providedCanvas || this.currentCanvas;\n\n if (!targetCanvas) {\n return;\n }\n\n // Phase 4.414: Debounce restoration to prevent race condition between dual restore paths\n // Both loadInitialImage and handleOrientationSelect can trigger restoration via setTimeout.\n // If both fire within RESTORE_DEBOUNCE_MS (200ms), the second call is skipped.\n // This prevents: first restore adds placements \u2192 second destroys them \u2192 artwork disappears.\n const cacheKey = `${this.saleId}_${targetCanvas.imageId}`;\n const now = Date.now();\n const lastRestore = lastBulkRestoreTimestamp.get(cacheKey) || 0;\n const timeSinceLastRestore = now - lastRestore;\n\n console.log('[DEBUG-BUG1] restoreBulkPlacements called', {\n timestamp: now,\n cacheKey,\n timeSinceLastRestore,\n debounceWindow: RESTORE_DEBOUNCE_MS,\n willSkip: timeSinceLastRestore < RESTORE_DEBOUNCE_MS,\n placementsCount: placements.length,\n canvasMapSizeBefore: targetCanvas.artPlacements?.size || 0,\n currentProductId: this.getCurrentProductId(),\n caller: new Error().stack?.split('\\n')[2]?.trim() || 'unknown',\n });\n\n if (timeSinceLastRestore < RESTORE_DEBOUNCE_MS) {\n console.log('[Phase 4.414] Skipping duplicate restoreBulkPlacements call (debounce)', {\n timeSinceLastRestore,\n threshold: RESTORE_DEBOUNCE_MS,\n cacheKey,\n });\n return;\n }\n\n // Phase 4.416: Suppress restoration during active drop\n // When a drop is being processed, restoreBulkPlacements can fire from loadInitialImage or\n // handleOrientationSelect callbacks. If restore runs before the drop's addArtPlacement retry\n // completes, two placements appear: one from restore (bulk_*) and one from retry (new_*).\n if (activeDropLock && (Date.now() - activeDropLock.timestamp) < DROP_LOCK_TIMEOUT_MS) {\n console.log('[Phase 4.416] Skipping restoreBulkPlacements - drop lock active');\n return;\n }\n\n // Update timestamp for debounce tracking\n lastBulkRestoreTimestamp.set(cacheKey, now);\n\n // Phase 4.378: Dimension stability check during bulk restore\n // Compare current dimensions with connect-time dimensions to detect layout shift\n if (this.connectTimeDimensions) {\n const currentDims = { width: this.element.clientWidth, height: this.element.clientHeight };\n const widthDelta = Math.abs(currentDims.width - this.connectTimeDimensions.width);\n const heightDelta = Math.abs(currentDims.height - this.connectTimeDimensions.height);\n if (widthDelta > 5 || heightDelta > 5) {\n console.warn('[Phase 4.378] DIMENSION INSTABILITY DETECTED (bulk restore):', {\n connect: this.connectTimeDimensions,\n restore: currentDims,\n widthDelta,\n heightDelta,\n placementsCount: placements.length,\n note: 'Restored placements may appear shifted from original drop position',\n });\n }\n }\n\n // Phase 4.174: Clear existing placements before restoring to prevent duplicates\n // This matches loadExistingArtPlacements() pattern (Phase 4.165)\n // Fixes race condition where multiple callbacks schedule restoration\n targetCanvas.destroyArtPlacements();\n\n placements.forEach((placementData, index) => {\n // Generate unique ID for restored placement\n const placementId = `bulk_${Date.now()}_${index}`;\n\n // Phase 4.417: Adjust left for target product's aspect ratio\n // left% uses imageHeight as denominator, so when aspect ratio differs between products,\n // the same left% maps to a different fraction of image width.\n // Use imageFractionX (X as fraction of source image WIDTH) to recompute left% for target.\n let effectiveLeft = placementData.left;\n if (typeof placementData.imageFractionX === 'number' && placementData.imageFractionX >= 0) {\n const targetWidth = targetCanvas.imageEl?.naturalWidth || 0;\n const targetHeight = this.getActualImageHeight(targetCanvas);\n if (targetWidth > 0 && targetHeight > 0) {\n effectiveLeft = (placementData.imageFractionX * targetWidth / targetHeight) * 100;\n console.log('[Phase 4.417] Adjusted left for aspect ratio:', {\n index,\n imageFractionX: placementData.imageFractionX,\n targetWidth,\n targetHeight,\n originalLeft: placementData.left,\n effectiveLeft,\n aspectRatioDiff: Math.abs(effectiveLeft - placementData.left) > 0.01,\n });\n }\n }\n\n // Phase 4.203: Diagnostic logging to identify why Phase 4.202 log doesn't appear\n console.log('[Phase 4.203] restoreBulkPlacements processing:', {\n index,\n hasContainerPixelLeft: typeof placementData.containerPixelLeft === 'number',\n hasContainerPixelTop: typeof placementData.containerPixelTop === 'number',\n containerPixelLeft: placementData.containerPixelLeft,\n containerPixelTop: placementData.containerPixelTop,\n savedBgStartX: placementData.savedBgStartX,\n savedBgStartY: placementData.savedBgStartY,\n });\n\n const placement = {\n id: placementId,\n artworkId: placementData.artworkId,\n artworkUrl: placementData.artworkUrl,\n left: effectiveLeft,\n top: placementData.top,\n width: placementData.width,\n height: placementData.height,\n rotation: placementData.rotation || 0,\n flipX: placementData.flipX || false,\n flipY: placementData.flipY || false,\n // Flag for bulk-restored placements\n isBulkRestored: true,\n // Phase 4.170: Set coordinatesLocked BEFORE addArtPlacement\n // This prevents handlePlacementUpdate from recalculating percentages when events fire\n coordinatesLocked: true,\n // Null out dropTimeCoordinates for bulk-restored placements so each product\n // falls back to its own image dimensions (naturalHeight) during coordinate\n // conversion. Without this, Product 1's actualImageHeight carries over to\n // Products 2/3, causing wrong position and size on different-dimension images.\n dropTimeCoordinates: null,\n // Phase 4.417: Preserve image fractions for subsequent product switches\n imageFractionX: placementData.imageFractionX ?? null,\n imageFractionY: placementData.imageFractionY ?? null,\n };\n\n // Phase 4.246: Compare drop-time board dimensions with restore-time dimensions\n // If these differ, the forward transform will position artwork differently than intended\n const dropTimeDims = placementData.dropTimeCoordinates?.boardDimensions;\n const currentDims = targetCanvas.boardDimensions;\n if (dropTimeDims) {\n const heightDiff = currentDims.height - dropTimeDims.height;\n const widthDiff = currentDims.width - dropTimeDims.width;\n console.log('[Phase 4.246] RESTORE-TIME DIMENSION COMPARISON:', {\n placementIndex: index,\n dropTimeBoardDimensions: dropTimeDims,\n currentBoardDimensions: currentDims,\n heightDiff,\n widthDiff,\n hasMismatch: heightDiff !== 0 || widthDiff !== 0,\n // Also compare bgStart and scalingFactor\n dropTimeBgStart: placementData.dropTimeCoordinates?.bgStart,\n dropTimeScalingFactor: placementData.dropTimeCoordinates?.scalingFactor,\n currentBgStart: targetCanvas.getBackgroundImageStart?.() || 'N/A',\n currentScalingFactor: targetCanvas.getScalingFactor?.() || 'N/A',\n });\n } else {\n console.log('[Phase 4.246] RESTORE-TIME: No dropTimeBoardDimensions available', {\n placementIndex: index,\n currentBoardDimensions: currentDims,\n });\n }\n\n // Add to canvas using existing addArtPlacement method\n // Phase 4.144: Use targetCanvas instead of this.currentCanvas\n if (targetCanvas.isLoaded && targetCanvas.container) {\n targetCanvas.addArtPlacement(placement);\n\n // Phase 4.410: Apply CSS positioning with retry mechanism\n // addArtPlacement() may queue the placement instead of adding it to the Map immediately\n // (e.g., if convertCoordinates() returns null due to dimensions not ready).\n // The retry mechanism ensures CSS positioning is applied even when the placement\n // gets added to Map asynchronously via addArtPlacement's internal retry timers.\n const applyPositioningWithRetry = (attempt = 0) => {\n const artPlacement = targetCanvas.artPlacements.get(placementId);\n\n // Phase 4.211: Diagnostic log after addArtPlacement to verify originalData was set correctly\n console.log('[Phase 4.211] restoreBulkPlacements after addArtPlacement:', {\n placementId,\n artPlacementExists: !!artPlacement,\n originalData: artPlacement?.originalData ? {\n left: artPlacement.originalData.left,\n top: artPlacement.originalData.top,\n } : null,\n coordinatesLocked: artPlacement?.coordinatesLocked,\n inputPlacementData: {\n left: placementData.left,\n top: placementData.top,\n },\n phase4410Attempt: attempt,\n });\n\n // Phase 4.203: Diagnostic logging for artPlacement retrieval\n console.log('[Phase 4.203] artPlacement retrieved:', {\n exists: !!artPlacement,\n placementId,\n artPlacementsSize: targetCanvas.artPlacements.size,\n phase4410Attempt: attempt,\n });\n\n if (artPlacement) {\n artPlacement.coordinatesLocked = true;\n\n // Phase 4.205: Use IMAGE-relative pixels for truly layout-independent positioning\n // Image pixels have bgStart factored out at save time, so we just add current bgStart\n // This works correctly regardless of timing - if bgStart is 0 at restore, we get\n // the position relative to container top; if bgStart is correct, we get container position\n const hasImagePixels = typeof placementData.imagePixelLeft === 'number' &&\n typeof placementData.imagePixelTop === 'number';\n const hasContainerPixels = typeof placementData.containerPixelLeft === 'number' &&\n typeof placementData.containerPixelTop === 'number';\n // Phase 4.408: Check percentages OUTSIDE the pixel block\n // getPlacementData() captures percentages but NOT pixel values, so bulk restoration\n // was failing to apply CSS positioning because this block was never entered.\n const hasPercentages = typeof placementData.left === 'number' &&\n typeof placementData.top === 'number';\n\n if (hasImagePixels || hasContainerPixels || hasPercentages) {\n const element = artPlacement.getElement();\n\n // Phase 4.203: Diagnostic logging for element check\n console.log('[Phase 4.203] element check:', {\n elementExists: !!element,\n elementTagName: element?.tagName,\n phase4410Attempt: attempt,\n });\n\n if (element) {\n // Phase 4.209: Fix - use targetCanvas.getBackgroundImageStart() not this.backgroundImageStartXY\n // The method backgroundImageStartXY doesn't exist on the controller, so it was always returning {0, 0}\n const currentBgStart = typeof targetCanvas.getBackgroundImageStart === 'function'\n ? targetCanvas.getBackgroundImageStart()\n : { x: 0, y: 0 };\n\n let adjustedLeft, adjustedTop, pixelWidth, pixelHeight;\n\n // Phase 4.210: Always recompute from percentages using CURRENT dimensions\n // The saved imagePixels were computed with a different scalingFactor (different board size),\n // so we can't just add currentBgStart to them. Instead, do the full forward transform\n // using current scalingFactor and currentBgStart.\n // Phase 4.408: hasPercentages now checked before entering this block (line ~4218)\n\n if (hasPercentages) {\n // Phase 4.210: Recompute from percentages using current dimensions (like ArtPlacementParser.parse)\n const currentScalingFactor = typeof targetCanvas.getScalingFactor === 'function'\n ? targetCanvas.getScalingFactor()\n : 1;\n // Phase 4.357: Use consolidated helper method for actualImageHeight calculation\n // Falls back to boardDimensions.height if imageEl not available\n const currentActualImageHeight = this.getActualImageHeight(targetCanvas);\n\n // Phase 4.348: Use drop-time dimensions for fresh drops in bulk mode\n // The scalingFactor and bgStart captured at drop time can differ from current values\n // due to modal animation, layout shifts, or scrollbars changing board dimensions.\n // Using current values causes percentage\u2192pixel conversion drift.\n // For fresh drops, use stored drop-time values to ensure accurate round-trip conversion.\n const dropTimeCoords = placementData.dropTimeCoordinates;\n const dropTimeScalingFactor = dropTimeCoords?.scalingFactor;\n const dropTimeBgStart = dropTimeCoords?.bgStart;\n const dropTimeActualImageHeight = dropTimeCoords?.actualImageHeight;\n\n // Phase 4.348: Diagnostic logging to verify dimension mismatch hypothesis\n const hasScalingMismatch = dropTimeScalingFactor != null && dropTimeScalingFactor !== currentScalingFactor;\n const hasBgStartMismatch = dropTimeBgStart != null && (dropTimeBgStart.x !== currentBgStart.x || dropTimeBgStart.y !== currentBgStart.y);\n const hasHeightMismatch = dropTimeActualImageHeight != null && dropTimeActualImageHeight !== currentActualImageHeight;\n\n if (hasScalingMismatch || hasBgStartMismatch || hasHeightMismatch) {\n console.log('[Phase 4.348] DIMENSION MISMATCH DETECTED:', {\n placementId: placementData.artworkId || placementData.id,\n hasScalingMismatch,\n hasBgStartMismatch,\n hasHeightMismatch,\n dropTimeScalingFactor,\n currentScalingFactor,\n dropTimeBgStart,\n currentBgStart,\n dropTimeActualImageHeight,\n currentActualImageHeight,\n });\n }\n\n // Phase 4.376: ALWAYS use current dimensions for display\n // Percentages are image-relative (the source of truth), but bgStart and\n // scalingFactor are board-relative and must be calculated for the CURRENT\n // board context. Using drop-time values from a different board size\n // (e.g., single mode's full page vs bulk mode's modal) causes coordinate shift.\n // The forward transform should always use current board dimensions.\n const scalingFactorToUse = currentScalingFactor;\n const bgStartToUse = currentBgStart;\n const actualImageHeight = currentActualImageHeight;\n\n // Forward transform: percentage -> image pixels -> container pixels\n // Phase 4.417: Use effectiveLeft (aspect-ratio-adjusted) instead of placementData.left\n const imagePixelLeft = (effectiveLeft / 100) * actualImageHeight * scalingFactorToUse;\n const imagePixelTop = (placementData.top / 100) * actualImageHeight * scalingFactorToUse;\n const imagePixelWidth = (placementData.width / 100) * actualImageHeight * scalingFactorToUse;\n const imagePixelHeight = (placementData.height / 100) * actualImageHeight * scalingFactorToUse;\n\n // Add bgStart to get container position\n adjustedLeft = imagePixelLeft + bgStartToUse.x;\n adjustedTop = imagePixelTop + bgStartToUse.y;\n pixelWidth = imagePixelWidth;\n pixelHeight = imagePixelHeight;\n\n console.log('[Phase 4.376] restoreBulkPlacements using CURRENT dimensions:', {\n percentages: { left: effectiveLeft, originalLeft: placementData.left, top: placementData.top },\n actualImageHeight,\n scalingFactorToUse,\n bgStartToUse,\n // Show drop-time values for comparison (not used anymore)\n dropTimeValuesForComparison: {\n scalingFactor: dropTimeScalingFactor,\n bgStart: dropTimeBgStart,\n actualImageHeight: dropTimeActualImageHeight,\n },\n imagePixels: { left: imagePixelLeft, top: imagePixelTop },\n computed: { left: adjustedLeft, top: adjustedTop },\n phase4410Attempt: attempt,\n });\n } else if (hasImagePixels) {\n // Phase 4.205 legacy: Image-relative pixels + current bgStart (fallback)\n adjustedLeft = placementData.imagePixelLeft + currentBgStart.x;\n adjustedTop = placementData.imagePixelTop + currentBgStart.y;\n pixelWidth = placementData.imagePixelWidth;\n pixelHeight = placementData.imagePixelHeight;\n\n console.log('[Phase 4.205] restoreBulkPlacements using saved image pixels:', {\n imagePixels: { left: placementData.imagePixelLeft, top: placementData.imagePixelTop },\n currentBgStart,\n computed: { left: adjustedLeft, top: adjustedTop },\n phase4410Attempt: attempt,\n });\n } else {\n // Legacy path: container pixels with bgStart adjustment (Phase 4.202/4.204)\n const savedBgStartX = placementData.savedBgStartX || 0;\n const savedBgStartY = placementData.savedBgStartY || 0;\n\n // Use saved bgStart to compute image-relative position, then add current bgStart\n // This is equivalent to: containerPixel - savedBgStart + currentBgStart\n adjustedLeft = placementData.containerPixelLeft + (currentBgStart.x - savedBgStartX);\n adjustedTop = placementData.containerPixelTop + (currentBgStart.y - savedBgStartY);\n pixelWidth = placementData.containerPixelWidth;\n pixelHeight = placementData.containerPixelHeight;\n\n console.log('[Phase 4.202] restoreBulkPlacements legacy path:', {\n containerPixels: { left: placementData.containerPixelLeft, top: placementData.containerPixelTop },\n savedBgStart: { x: savedBgStartX, y: savedBgStartY },\n currentBgStart,\n computed: { left: adjustedLeft, top: adjustedTop },\n phase4410Attempt: attempt,\n });\n }\n\n // Phase 4.411: Clamp position to keep artwork visible in container\n // When products have different bgStart values, percentage-based position\n // can produce off-screen coordinates (e.g., 903px top in a 600px container).\n // Clamp to keep artwork within visible bounds while preserving the coordinate\n // system for products with compatible dimensions.\n const containerHeight = targetCanvas.boardDimensions?.height || targetCanvas.container?.clientHeight || 600;\n const containerWidth = targetCanvas.boardDimensions?.width || targetCanvas.container?.clientWidth || 800;\n const maxTop = Math.max(0, containerHeight - pixelHeight - 10);\n const maxLeft = Math.max(0, containerWidth - pixelWidth - 10);\n\n const originalTop = adjustedTop;\n const originalLeft = adjustedLeft;\n const clampedTop = Math.max(0, Math.min(adjustedTop, maxTop));\n const clampedLeft = Math.max(0, Math.min(adjustedLeft, maxLeft));\n\n // Log if clamping was applied\n if (clampedTop !== originalTop || clampedLeft !== originalLeft) {\n console.warn('[Phase 4.411] Position clamped to keep artwork visible:', {\n originalTop,\n clampedTop,\n originalLeft,\n clampedLeft,\n containerHeight,\n containerWidth,\n artworkHeight: pixelHeight,\n artworkWidth: pixelWidth,\n });\n }\n\n element.style.left = `${clampedLeft}px`;\n element.style.top = `${clampedTop}px`;\n element.style.width = `${pixelWidth}px`;\n element.style.height = `${pixelHeight}px`;\n\n // Phase 4.410: Log success (Phase 4.411: use clamped values)\n console.log('[Phase 4.410] CSS positioning applied successfully:', {\n placementId,\n attempt,\n position: { left: clampedLeft, top: clampedTop },\n wasClamped: clampedTop !== originalTop || clampedLeft !== originalLeft,\n });\n }\n // Phase 4.178: Removed authoritative pixel storage (Phase 4.177 reverted)\n // Now using threshold-based filtering in handlePlacementUpdate instead\n }\n } else if (attempt < 10) {\n // Phase 4.410: Retry if placement not yet in Map\n // addArtPlacement's internal retry uses delays of 0ms, 16ms, 100ms\n // We use similar timing: first 3 attempts at 16ms, then 100ms\n const delay = attempt < 3 ? 16 : 100;\n console.log('[Phase 4.410] artPlacement not in Map yet, scheduling retry:', {\n placementId,\n attempt,\n nextDelay: delay,\n });\n setTimeout(() => applyPositioningWithRetry(attempt + 1), delay);\n } else {\n // Phase 4.410: Max retries reached\n console.warn('[Phase 4.410] CSS positioning failed after max retries:', {\n placementId,\n attempts: attempt,\n artPlacementsSize: targetCanvas.artPlacements.size,\n });\n }\n };\n\n // Start the positioning attempt\n applyPositioningWithRetry(0);\n } else {\n // Canvas not ready - add to pending queue\n // Phase 4.144: Use targetCanvas instead of this.currentCanvas\n // Phase 4.203: Diagnostic logging for canvas not ready\n console.log('[Phase 4.203] canvas not ready:', {\n isLoaded: targetCanvas.isLoaded,\n hasContainer: !!targetCanvas.container,\n });\n targetCanvas.pendingPlacements.push(placement);\n }\n });\n\n // Mark as dirty since we have placements\n if (placements.length > 0) {\n this.markAsDirty();\n }\n }\n\n /**\n * Phase 4.119: Get current product ID from decorator modal\n * @returns {string|null} Product ID or null if not found\n */\n getCurrentProductId() {\n const modal = this.element.closest('#decorator-modal');\n return modal?.dataset.productId || null;\n }\n\n /**\n * Phase 4.119: Check if placements differ from default\n * Returns true if any placement has different position/size/rotation\n * @param {Array} currentPlacements - Current placements to compare\n * @param {Array} defaultPlacements - Default placements to compare against\n * @returns {boolean} True if placements differ\n */\n placementsDifferFromDefault(currentPlacements, defaultPlacements) {\n if (!defaultPlacements || currentPlacements.length !== defaultPlacements.length) {\n return true;\n }\n\n // Compare each placement by artworkId and coordinates\n for (let i = 0; i < currentPlacements.length; i++) {\n const current = currentPlacements[i];\n const original = defaultPlacements.find(p => p.artworkId === current.artworkId);\n\n if (!original) return true;\n\n // Check if position/size/rotation changed (with small tolerance for floating point)\n const tolerance = 0.1;\n if (Math.abs(current.left - original.left) > tolerance ||\n Math.abs(current.top - original.top) > tolerance ||\n Math.abs(current.width - original.width) > tolerance ||\n Math.abs(current.height - original.height) > tolerance ||\n Math.abs((current.rotation || 0) - (original.rotation || 0)) > tolerance) {\n return true;\n }\n }\n\n return false;\n }\n\n /**\n * Phase 4.420: Store a cleared sentinel in imageOverrides.\n * Indicates the user intentionally removed all artwork from this image.\n * Called from handleProductSelected, updateGlobalBulkStorage, and captureCurrentPlacementsToGlobal.\n * @param {Object} bulkData - The globalBulkDecorationPlacements entry for this sale\n * @param {number|string} imageId - The ColorVariantImage ID\n * @param {string} productId - The product ID\n */\n storeClearedSentinel(bulkData, imageId, productId) {\n if (!bulkData || !imageId || !productId) return;\n if (!bulkData.imageOverrides) bulkData.imageOverrides = {};\n bulkData.imageOverrides[imageId] = {\n placements: [],\n sourceImageId: imageId,\n productId: productId,\n timestamp: Date.now(),\n cleared: true,\n };\n }\n\n /**\n * Phase 4.147: Update global bulk storage with current placements\n * Called during coordinate adjustments to preserve positions across product switches.\n * This method updates the imageOverrides without recalculating percentages.\n */\n updateGlobalBulkStorage() {\n if (!this.skipExistingDecorations) return;\n\n const currentProductId = this.getCurrentProductId();\n const bulkData = globalBulkDecorationPlacements.get(this.saleId);\n\n if (bulkData && currentProductId) {\n const currentPlacements = this.getAllCurrentPlacements();\n if (currentPlacements.length > 0) {\n // Phase 4.415: Store per-image override for color-level isolation.\n // Only imageOverrides are written \u2014 productOverrides are intentionally\n // left empty to prevent edits on one color cascading to other colors\n // of the same product (which share the same productId).\n const currentImageId = this.currentCanvas?.imageId;\n if (currentImageId) {\n if (!bulkData.imageOverrides) bulkData.imageOverrides = {};\n bulkData.imageOverrides[currentImageId] = {\n placements: currentPlacements,\n sourceImageId: currentImageId,\n productId: currentProductId,\n timestamp: Date.now(),\n };\n }\n } else if (bulkData.default) {\n // Phase 4.420: Empty canvas + default exists = user intentionally cleared\n this.storeClearedSentinel(bulkData, this.currentCanvas?.imageId, currentProductId);\n }\n }\n }\n\n /**\n * Phase 4.120: Capture current canvas placements to global storage\n * Called before save to ensure current product's state is captured\n */\n captureCurrentPlacementsToGlobal() {\n if (!this.skipExistingDecorations) {\n return;\n }\n\n // Phase 4.142: Add fallback for this.currentCanvas using same pattern as getPlacementData()\n let targetCanvas = this.currentCanvas;\n let canvasSource = 'this.currentCanvas';\n\n // Fallback to globalCanvasState if this.currentCanvas is null\n if (!targetCanvas) {\n const salePrefix = `${this.saleId}_`;\n for (const [key, savedCanvas] of globalCanvasState.entries()) {\n if (key.startsWith(salePrefix) && savedCanvas) {\n const placementCount = savedCanvas.artPlacements?.size || 0;\n if (placementCount > 0) {\n targetCanvas = savedCanvas;\n canvasSource = `globalCanvasState[${key}]`;\n break;\n }\n }\n }\n }\n\n if (!targetCanvas) {\n return;\n }\n\n // Phase 4.322: Diagnostic logging to trace layout-dependent coordinate shift\n // Compare boardDimensions at save time with earlier values\n console.log('[Phase 4.322] captureCurrentPlacementsToGlobal() boardDimensions:', {\n width: this.boardDimensions?.width,\n height: this.boardDimensions?.height,\n elementWidth: this.element?.clientWidth,\n elementHeight: this.element?.clientHeight,\n timestamp: Date.now(),\n saleId: this.saleId,\n note: 'Compare with connect(), loadInitialImage(), and handleArtworkDrop() dimensions',\n });\n\n // Use targetCanvas instead of this.currentCanvas throughout\n // Phase 4.321: Use getPlacementDataAsArray() instead of getAllCurrentPlacements()\n // This reads stored percentages directly from dropTimeCoordinates (like single mode),\n // rather than using complex inverse transform logic that can produce different percentages.\n // Fixes Safari bulk mode coordinate shift where first save had 14px vertical offset.\n const placements = this.getPlacementDataAsArray(targetCanvas);\n if (placements.length === 0) {\n // Phase 4.420: Store cleared state if default exists (user intentionally removed artwork)\n const bulkData = globalBulkDecorationPlacements.get(this.saleId);\n const currentProductId = this.getCurrentProductId();\n if (bulkData?.default && currentProductId) {\n this.storeClearedSentinel(bulkData, targetCanvas.imageId, currentProductId);\n }\n return;\n }\n\n let bulkData = globalBulkDecorationPlacements.get(this.saleId);\n if (!bulkData) {\n bulkData = { default: null, productOverrides: {}, imageOverrides: {} };\n globalBulkDecorationPlacements.set(this.saleId, bulkData);\n }\n\n const currentProductId = this.getCurrentProductId();\n const currentImageId = targetCanvas.imageId;\n\n // Always update with current state\n if (!bulkData.default) {\n bulkData.default = { placements, sourceImageId: currentImageId, timestamp: Date.now() };\n } else if (currentProductId) {\n // Phase 4.415: Store per-image override for color-level isolation.\n // Only imageOverrides are written \u2014 productOverrides are intentionally\n // left empty to prevent edits on one color cascading to other colors\n // of the same product (which share the same productId).\n const differs = this.placementsDifferFromDefault(placements, bulkData.default.placements);\n if (currentImageId) {\n if (!bulkData.imageOverrides) bulkData.imageOverrides = {};\n if (differs) {\n bulkData.imageOverrides[currentImageId] = {\n placements, sourceImageId: currentImageId, productId: currentProductId, timestamp: Date.now(),\n };\n } else {\n delete bulkData.imageOverrides[currentImageId];\n }\n }\n }\n\n // Phase 4.230: Log what was stored - trace bulk mode data flow\n console.log('[Phase 4.230] captureCurrentPlacementsToGlobal STORED:', {\n hasDefault: !!bulkData.default,\n defaultTop: bulkData.default?.placements?.[0]?.top,\n defaultLeft: bulkData.default?.placements?.[0]?.left,\n placementsCount: bulkData.default?.placements?.length,\n productOverrides: Object.keys(bulkData.productOverrides || {}),\n });\n }\n\n /**\n * Phase 4.120: Get all per-product placements for bulk save\n * Phase 4.151: Enhanced to include targetImageId and hasCustomAdjustment\n * @param {Array} selectedProductIds - Array of product IDs to get placements for\n * @returns {Object|null} Map of productId -> { placements: [...], imageId: string, targetImageId: string, hasCustomAdjustment: boolean }\n */\n getAllProductPlacements(selectedProductIds) {\n const bulkData = globalBulkDecorationPlacements.get(this.saleId);\n\n if (!bulkData || !bulkData.default) {\n return null;\n }\n\n const result = {};\n\n for (const productId of selectedProductIds) {\n // Phase 4.151: Get the TARGET image ID for this product (the actual ColorVariantImage)\n const mappingKey = `${this.saleId}_${productId}`;\n const targetImageId = productImageMapping.get(mappingKey);\n\n // Phase 4.415: 3-tier lookup: imageOverride > productOverride > default\n const imageOverride = targetImageId ? bulkData.imageOverrides?.[targetImageId] : null;\n\n // Phase 4.420: Handle intentionally cleared images\n if (imageOverride?.cleared) {\n result[productId] = {\n placements: [],\n imageId: imageOverride.sourceImageId,\n targetImageId: targetImageId,\n hasCustomAdjustment: false,\n cleared: true,\n };\n continue;\n }\n\n const productOverride = bulkData.productOverrides[productId];\n const override = imageOverride || productOverride;\n\n // Phase 4.224: Products with locked placements are considered \"custom\" even without an override\n // This fixes the issue where Phase 4.223 would delete overrides for locked placements,\n // causing them to not be added to loaded_image_ids and thus skipped by the backend.\n // A product is \"custom\" if it has:\n // 1. Different placements from default (user adjusted), OR\n // 2. Locked placements (user explicitly positioned via drop)\n const defaultPlacements = bulkData.default?.placements || [];\n const effectivePlacements = override?.placements || defaultPlacements;\n const hasLockedPlacements = effectivePlacements.some(p => p.coordinatesLocked);\n const hasDifferentPlacements = override &&\n this.placementsDifferFromDefault(override.placements, defaultPlacements);\n const hasCustomAdjustment = hasDifferentPlacements || hasLockedPlacements;\n\n result[productId] = {\n placements: override?.placements || bulkData.default.placements,\n imageId: override?.sourceImageId || bulkData.default.sourceImageId,\n targetImageId: targetImageId, // Phase 4.151: The product's own ColorVariantImage ID\n hasCustomAdjustment: hasCustomAdjustment || false, // Phase 4.151: Flag for buildSaveParams\n };\n\n // Phase 4.230: Log what's being returned for each product\n console.log('[Phase 4.230] getAllProductPlacements RETURNING:', {\n productId,\n hasOverride: !!override,\n placementsCount: result[productId].placements?.length,\n firstPlacementTop: result[productId].placements?.[0]?.top,\n firstPlacementLeft: result[productId].placements?.[0]?.left,\n targetImageId,\n });\n\n // Phase 4.359: Field presence check for each placement in getAllProductPlacements\n // This shows what fields are available when buildSaveParams calls this function\n result[productId].placements?.forEach((p, idx) => {\n console.log(`[Phase 4.359] getAllProductPlacements FIELD PRESENCE [${productId}][${idx}]:`, {\n pKeys: Object.keys(p),\n hasDropTimeActualImageHeight: 'dropTimeActualImageHeight' in p,\n dropTimeActualImageHeightValue: p.dropTimeActualImageHeight,\n hasActualImageHeight: 'actualImageHeight' in p,\n actualImageHeightValue: p.actualImageHeight,\n hasDropTimeBgStartX: 'dropTimeBgStartX' in p,\n dropTimeBgStartXValue: p.dropTimeBgStartX,\n hasDropTimeBgStartY: 'dropTimeBgStartY' in p,\n dropTimeBgStartYValue: p.dropTimeBgStartY,\n hasBgStart: 'bgStart' in p,\n bgStartValue: p.bgStart,\n hasDropTimeIsVerticalFit: 'dropTimeIsVerticalFit' in p,\n dropTimeIsVerticalFitValue: p.dropTimeIsVerticalFit,\n hasIsVerticalFit: 'isVerticalFit' in p,\n isVerticalFitValue: p.isVerticalFit,\n hasDropTimeCoordinates: 'dropTimeCoordinates' in p,\n dropTimeCoordinatesKeys: p.dropTimeCoordinates ? Object.keys(p.dropTimeCoordinates) : null,\n });\n });\n }\n\n // Phase 4.415: Include non-primary color overrides from imageOverrides\n // When a product has multiple colors, only the last-loaded color is mapped in productImageMapping.\n // Other colors with per-image overrides would be missed without this loop.\n if (bulkData.imageOverrides) {\n const selectedIds = new Set(selectedProductIds.map(String));\n for (const [overrideImageId, imageData] of Object.entries(bulkData.imageOverrides)) {\n // Skip if already covered as a product's primary targetImageId\n const alreadyCovered = Object.values(result).some(\n r => String(r.targetImageId) === String(overrideImageId)\n );\n if (alreadyCovered) continue;\n\n // Only include if the override's product is selected\n if (!selectedIds.has(String(imageData.productId))) continue;\n\n // Phase 4.420: Skip cleared entries from non-primary color loop\n if (imageData.cleared) continue;\n\n result[`${imageData.productId}_color_${overrideImageId}`] = {\n placements: imageData.placements,\n imageId: imageData.sourceImageId,\n targetImageId: parseInt(overrideImageId, 10) || overrideImageId,\n hasCustomAdjustment: true,\n };\n }\n }\n\n return result;\n }\n\n emitStateChange() {\n if (!this.currentCanvas || this.isRestoringFromHistory) return;\n\n // Ensure canvas is fully loaded before emitting state\n if (!this.currentCanvas.isLoaded || !this.currentCanvas.imageEl) {\n return;\n }\n\n const placements = this.currentCanvas.getArtPlacements();\n\n // Only filter out placements with clearly corrupted coordinates\n const validPlacements = placements.filter(placement => {\n const hasCorruptedCoords = typeof placement.left !== 'number' ||\n typeof placement.top !== 'number' ||\n typeof placement.width !== 'number' ||\n typeof placement.height !== 'number' ||\n isNaN(placement.left) || isNaN(placement.top) ||\n isNaN(placement.width) || isNaN(placement.height) ||\n !isFinite(placement.left) || !isFinite(placement.top) ||\n !isFinite(placement.width) || !isFinite(placement.height) ||\n Math.abs(placement.left) > 100000000 ||\n Math.abs(placement.top) > 100000000 ||\n Math.abs(placement.width) > 100000000 ||\n Math.abs(placement.height) > 100000000;\n\n // Filter out placements with corrupted coordinates\n\n return !hasCorruptedCoords;\n });\n\n\n const state = {\n artPlacements: validPlacements,\n currentImageId: this.currentCanvas.imageId,\n timestamp: Date.now(),\n };\n\n this.element.dispatchEvent(new CustomEvent('decorator:state:changed', {\n bubbles: true,\n detail: state,\n }));\n }\n\n // Phase 4.254: Dispatch placements-changed event for debug panel\n dispatchPlacementsChanged() {\n if (!this.currentCanvas) return;\n\n // Get all current placements with their percentage coordinates\n const placements = this.getAllCurrentPlacements();\n\n document.dispatchEvent(new CustomEvent('decorator:debug:placements-changed', {\n bubbles: false,\n detail: {\n placements: placements,\n mode: this.skipExistingDecorations ? 'BULK' : 'SINGLE',\n canvasImageId: this.currentCanvas?.imageId,\n },\n }));\n\n // Phase 4.277: Also dispatch DOM state for the first placement\n this.dispatchDomStateForFirstPlacement();\n }\n\n // Phase 4.277: Dispatch DOM state for debugging visual position vs stored position\n dispatchDomStateForFirstPlacement() {\n if (!this.currentCanvas || !this.currentCanvas.artPlacements) return;\n\n // Get the first placement element\n const container = this.element;\n const placementEl = container.querySelector('.art-placement-wrapper');\n if (!placementEl) return;\n\n const placementId = placementEl.dataset.placementId;\n\n // Get computed style\n const computedStyle = window.getComputedStyle(placementEl);\n const transform = computedStyle.transform;\n\n // Parse translate values from matrix(a, b, c, d, tx, ty)\n let translateX = 0;\n let translateY = 0;\n if (transform && transform !== 'none') {\n const matrixMatch = transform.match(/matrix\\(([^)]+)\\)/);\n if (matrixMatch) {\n const values = matrixMatch[1].split(',').map(v => parseFloat(v.trim()));\n translateX = values[4] || 0;\n translateY = values[5] || 0;\n }\n }\n\n // Get bounding rects\n const placementRect = placementEl.getBoundingClientRect();\n const containerRect = container.getBoundingClientRect();\n\n // Position relative to container\n const bcrX = placementRect.left - containerRect.left;\n const bcrY = placementRect.top - containerRect.top;\n\n // Get stored percentages from the artPlacement object\n let storedLeft = 0;\n let storedTop = 0;\n\n // Find the placement in the Map by checking all entries\n for (const [id, artPlacement] of this.currentCanvas.artPlacements) {\n if (id === placementId) {\n // Use dropTimeCoordinates if available, otherwise originalData\n const coords = artPlacement.dropTimeCoordinates || artPlacement.originalData || {};\n storedLeft = coords.left || 0;\n storedTop = coords.top || 0;\n break;\n }\n }\n\n // Calculate expected position from percentages\n // This replicates the forward transform: (pct / 100) * imageHeight * ratio + bgStart\n const bgStart = this.currentCanvas.backgroundImageStartXY?.() || { x: 0, y: 0 };\n const ratio = this.currentCanvas.productImageBoardRatio?.() || 1;\n const imageHeight = this.currentCanvas.imageEl?.naturalHeight || 0;\n\n // Forward transform formula (from ArtPlacementParser.parse)\n const expectedX = ((storedLeft / 100) * imageHeight * ratio) + bgStart.x;\n const expectedY = ((storedTop / 100) * imageHeight * ratio) + bgStart.y;\n\n document.dispatchEvent(new CustomEvent('decorator:debug:dom-state', {\n bubbles: false,\n detail: {\n placementId,\n cssTransform: transform,\n translateX,\n translateY,\n offsetLeft: placementEl.offsetLeft,\n offsetTop: placementEl.offsetTop,\n bcrX,\n bcrY,\n expectedX,\n expectedY,\n storedPct: { left: storedLeft, top: storedTop },\n bgStart,\n ratio,\n imageHeight,\n },\n }));\n }\n\n showError(message) {\n // Could integrate with flash messages or notification system\n // For now, use alert as per baseline behavior\n window.alert(message);\n }\n\n /**\n * Phase 4.167: Handle placement drag start - clear coordinatesLocked\n * This allows handlePlacementUpdate to recalculate percentages when user drags/resizes\n *\n * The coordinatesLocked flag prevents percentage recalculation in handlePlacementUpdate.\n * This is needed because layout reflows can trigger handlePlacementUpdate with changed\n * pixel positions, even though the user hasn't moved the artwork.\n *\n * When the user EXPLICITLY starts a drag or resize, we clear the lock so that\n * handlePlacementUpdate can recalculate the correct percentages at dragEnd.\n */\n handlePlacementDragStart(event) {\n const { placementId } = event.detail || {};\n\n // Phase 4.232: Diagnostic logging to trace what triggers this handler\n console.log('[Phase 4.232] handlePlacementDragStart RECEIVED:', {\n placementId,\n eventType: event.type,\n // Capture stack trace to see what code path triggered this\n stackTrace: new Error().stack?.split('\\n').slice(1, 6).join('\\n'),\n });\n\n if (!placementId) return;\n\n // Find the artPlacement in the current canvas\n // Use same fallback chain as handlePlacementUpdate for robustness\n let targetCanvas = this.currentCanvas;\n\n if (!targetCanvas && this.canvases && Object.keys(this.canvases).length > 0) {\n const firstImageId = Object.keys(this.canvases)[0];\n targetCanvas = this.canvases[firstImageId];\n }\n\n if (!targetCanvas) return;\n\n const artPlacement = targetCanvas.artPlacements.get(placementId);\n if (artPlacement && artPlacement.coordinatesLocked) {\n // Phase 4.232: Log when coordinatesLocked is being cleared\n console.log('[Phase 4.232] CLEARING coordinatesLocked:', {\n placementId,\n previousValue: artPlacement.coordinatesLocked,\n });\n artPlacement.coordinatesLocked = false;\n // Phase 4.244: Do NOT clear dropTimeCoordinates here.\n // Safari modal animation causes phantom \"drag\" events that would clear our\n // immutable protection. dropTimeCoordinates should only be replaced when\n // a NEW artwork is dropped (fresh placement replaces old one).\n }\n }\n\n // Handle placement update from template controller\n handlePlacementUpdate(event) {\n const placementData = event.detail;\n\n // Phase 0.99.23: Apply same fallback chain as getPlacementData to handle null this.currentCanvas\n let targetCanvas = this.currentCanvas;\n\n // Fallback 0: Try instance-level canvases cache\n if (!targetCanvas && this.canvases && Object.keys(this.canvases).length > 0) {\n const firstImageId = Object.keys(this.canvases)[0];\n targetCanvas = this.canvases[firstImageId];\n }\n\n // Fallback 1: Try globalCanvasState\n // Phase 4.420: Use 3-part key matching findOrCreateCanvas/onCanvasLoad\n if (!targetCanvas && this.currentImageId) {\n const colorVariantId = this.currentColorVariantId || 'default';\n const canvasKey = `${this.saleId}_${colorVariantId}_${this.currentImageId}`;\n targetCanvas = globalCanvasState.get(canvasKey);\n }\n\n if (!targetCanvas) {\n return;\n }\n\n // Phase 0.99.27: Track which canvas instance is being queried\n const whichFallback = !this.currentCanvas ?\n (this.canvases && Object.keys(this.canvases).length > 0 ? 'canvases' : 'globalCanvasState') :\n 'currentCanvas';\n\n // The template controller sends pixel values, but we need to convert them back to percentages\n // for storage since the canvas system uses percentages internally\n // Calculate dimensions for percentage calculations\n const scalingFactor = targetCanvas.getScalingFactor();\n const imageEl = targetCanvas.imageEl;\n\n // Phase 4.357: Use consolidated helper method for actualImageHeight calculation\n // NOTE: imageEl is an in-memory Image object (new Image()) that is NEVER attached\n // to the DOM, so getBoundingClientRect() always returns 0. We use naturalHeight directly.\n const actualImageHeight = this.getActualImageHeight(targetCanvas);\n\n console.log('[Phase 4.357] actualImageHeight (handlePlacementUpdate):', {\n actualImageHeight: actualImageHeight,\n naturalHeight: imageEl?.naturalHeight,\n scalingFactor: scalingFactor,\n });\n\n // Phase 0.99.23: Log conversion inputs to diagnose > 200% filtering\n\n // Validate that we have valid values for calculations\n if (!scalingFactor || scalingFactor === 0 || !actualImageHeight || actualImageHeight === 0) {\n return;\n }\n\n\n // Validate input pixel coordinates are reasonable for the board size\n // Phase 4.373: Use targetCanvas.boardDimensions (live getter) instead of cached this.boardDimensions\n // Defensive fallback to element dimensions if canvas doesn't have board\n const dims = targetCanvas.boardDimensions || this.boardDimensions || { width: 0, height: 0 };\n const boardSize = Math.max(dims.width, dims.height);\n const isInputReasonable = (value) => {\n return typeof value === 'number' && isFinite(value) && !isNaN(value) &&\n Math.abs(value) < (boardSize * 10); // Allow up to 10x board size for safety\n };\n\n // Phase 0.99.25: Input validation\n if (!isInputReasonable(placementData.left) || !isInputReasonable(placementData.top) ||\n !isInputReasonable(placementData.width) || !isInputReasonable(placementData.height)) {\n return;\n }\n\n // Phase 4.373: Removed Phase 4.184 sync code\n // ImageCanvas.boardDimensions is now a getter that always reads live from DOM\n // No sync needed - getter eliminates stale cache issues entirely\n\n // Phase 0.99.50: Get background offset for inverse transformation\n // Phase 4.189: Now orientation-independent - both bgStart.x and bgStart.y are subtracted\n // Forward transform (parser.js): adds both bgStart.x and bgStart.y\n // Inverse transform (this code): subtracts both bgStart.x and bgStart.y\n const bgStart = targetCanvas.getBackgroundImageStart();\n\n // Phase 0.99.64: ALWAYS subtract bgStart for inverse transform\n // Forward transform (parser.js): ALWAYS adds bgStart for visual positioning\n // Inverse transform (here): ALWAYS subtracts bgStart to get pure image-relative percentages\n // This ensures round-trip consistency: load \u2192 display \u2192 save preserves coordinates\n //\n // Note: DOM coordinates are always board-relative, so we must always subtract\n // the bgStart offset to convert back to pure image-relative percentages.\n const artPlacement = targetCanvas.artPlacements.get(placementData.placementId);\n\n // Phase 4.211: Diagnostic log at entry to trace coordinatesLocked state\n console.log('[Phase 4.211] handlePlacementUpdate ENTRY:', {\n placementId: placementData?.placementId,\n artPlacementExists: !!artPlacement,\n coordinatesLocked: artPlacement?.coordinatesLocked,\n needsLockAfterStabilization: artPlacement?.originalData?.needsLockAfterStabilization,\n currentOriginalDataLeft: artPlacement?.originalData?.left,\n currentOriginalDataTop: artPlacement?.originalData?.top,\n inputLeft: placementData?.left,\n inputTop: placementData?.top,\n });\n\n let pixelLeft = placementData.left;\n let pixelTop = placementData.top;\n\n // Phase 4.189: Subtract BOTH bgStart components (orientation-independent)\n // DOM coordinates are board-relative. Parser.parse() adds bgStart.x AND bgStart.y\n // (one is ~0 based on fit direction), so we must subtract BOTH here.\n // This makes the inverse transform match the forward transform regardless of\n // isVerticalFit() value at any point in time.\n pixelLeft = pixelLeft - bgStart.x;\n pixelTop = pixelTop - bgStart.y;\n\n // The placement data is in scaled pixel space, need to convert to actual image space\n // then to percentages of actual image height\n // Phase 0.99.50: Now using adjusted pixel values (with bgStart subtracted)\n const rawLeft = ((pixelLeft / scalingFactor) / actualImageHeight) * 100;\n const rawTop = ((pixelTop / scalingFactor) / actualImageHeight) * 100;\n const rawWidth = ((placementData.width / scalingFactor) / actualImageHeight) * 100;\n const rawHeight = ((placementData.height / scalingFactor) / actualImageHeight) * 100;\n\n // Phase 0.99.60: Clamp coordinates to valid 0-100% range\n // Same logic as Phase 0.99.56 in handleArtworkDrop - prevents whitespace placements\n // from rendering off-canvas in the composed image\n const clampedLeft = Math.max(0, Math.min(rawLeft, 100 - rawWidth));\n const clampedTop = Math.max(0, Math.min(rawTop, 100 - rawHeight));\n\n // Phase 4.308: INVERSE transform trace for comparison with forward transform\n // This traces EVERY parameter to identify asymmetry between forward/inverse transforms.\n // The forward transform converts percentages \u2192 pixels (for canvas display).\n // The inverse transform converts pixels \u2192 percentages (for saving).\n // If these use different parameters, round-trip is asymmetric and coordinates shift.\n console.log('[Phase 4.308] INVERSE TRANSFORM TRACE:', {\n placementId: placementData.placementId,\n\n // Input CSS positions (board-relative pixels)\n inputTopPx: placementData.top,\n inputLeftPx: placementData.left,\n\n // After bgStart subtraction (image-relative pixels)\n afterBgStartTop: pixelTop,\n afterBgStartLeft: pixelLeft,\n bgStartX: bgStart.x,\n bgStartY: bgStart.y,\n\n // Scaling parameters - must match forward transform\n scalingFactor: scalingFactor,\n actualImageHeight: actualImageHeight,\n imageElNaturalHeight: imageEl?.naturalHeight,\n imageElHeight: imageEl?.height,\n\n // Output percentages\n rawTopPct: rawTop,\n rawLeftPct: rawLeft,\n clampedTopPct: clampedTop,\n clampedLeftPct: clampedLeft,\n\n // Board dimensions (for context)\n boardWidth: targetCanvas.boardDimensions?.width,\n boardHeight: targetCanvas.boardDimensions?.height,\n\n // Verification: forward transform of output percentage should equal input\n // Forward: ((pct / 100) * imageHeight * scalingFactor) + bgStart.y = inputTopPx\n verifyForwardTopPx: ((clampedTop / 100) * actualImageHeight * scalingFactor) + bgStart.y,\n verifyForwardLeftPx: ((clampedLeft / 100) * actualImageHeight * scalingFactor) + bgStart.x,\n inputVsVerifyTop: Math.abs(placementData.top - (((clampedTop / 100) * actualImageHeight * scalingFactor) + bgStart.y)),\n inputVsVerifyLeft: Math.abs(placementData.left - (((clampedLeft / 100) * actualImageHeight * scalingFactor) + bgStart.x)),\n topRoundTripsCorrectly: Math.abs(placementData.top - (((clampedTop / 100) * actualImageHeight * scalingFactor) + bgStart.y)) < 1,\n leftRoundTripsCorrectly: Math.abs(placementData.left - (((clampedLeft / 100) * actualImageHeight * scalingFactor) + bgStart.x)) < 1,\n });\n\n // Validate that resulting percentages are reasonable (should be roughly 0-200 for most cases)\n const isPercentageReasonable = (value) => {\n return typeof value === 'number' && isFinite(value) && !isNaN(value) &&\n Math.abs(value) < 1000; // Much stricter validation - percentages shouldn't exceed 1000%\n };\n\n // Phase 0.99.24: Validation check (using clamped values now)\n if (!isPercentageReasonable(clampedLeft) || !isPercentageReasonable(clampedTop) ||\n !isPercentageReasonable(rawWidth) || !isPercentageReasonable(rawHeight)) {\n return;\n }\n\n\n // Phase 4.386: Only include tool values if explicitly provided (not undefined)\n // This prevents spreading undefined values which would overwrite existing values\n const percentageData = {\n left: clampedLeft,\n top: clampedTop,\n width: rawWidth,\n height: rawHeight,\n // Conditionally include tool values only when defined\n ...(placementData.rotation !== undefined && { rotation: placementData.rotation }),\n ...(placementData.flipX !== undefined && { flipX: placementData.flipX }),\n ...(placementData.flipY !== undefined && { flipY: placementData.flipY }),\n };\n\n // Find the art placement in the canvas\n // Note: artPlacement was already retrieved earlier for isExistingPlacement check\n\n // Phase 0.99.24: Log artPlacement lookup result\n\n if (artPlacement) {\n // Phase 4.388: ALWAYS update tool values first, before any early returns\n // Tool operations (flip, rotation) are explicit user actions that should persist\n // regardless of coordinate lock state. Phase 4.386 fix was bypassed by early returns\n // at lines 4905 (needsLockAfterStabilization) and 4944 (coordinatesLocked).\n if (artPlacement.originalData) {\n if (placementData.flipX !== undefined) {\n artPlacement.originalData.flipX = placementData.flipX;\n }\n if (placementData.flipY !== undefined) {\n artPlacement.originalData.flipY = placementData.flipY;\n }\n if (placementData.rotation !== undefined) {\n artPlacement.originalData.rotation = placementData.rotation;\n }\n }\n\n // Phase 4.180: Handle needsLockAfterStabilization BEFORE any percentage recalculation\n // The Phase 4.179 bug was: lock was set AFTER updating originalData, so if the first\n // handlePlacementUpdate calculated different percentages, the damage was already done.\n // Fix: Set lock and skip recalculation on FIRST handlePlacementUpdate in bulk mode.\n if (artPlacement.originalData?.needsLockAfterStabilization) {\n artPlacement.coordinatesLocked = true;\n delete artPlacement.originalData.needsLockAfterStabilization;\n // Still update container pixel values for visual restoration\n if (artPlacement.originalData) {\n artPlacement.originalData.containerPixelLeft = placementData.left;\n artPlacement.originalData.containerPixelTop = placementData.top;\n artPlacement.originalData.containerPixelWidth = placementData.width;\n artPlacement.originalData.containerPixelHeight = placementData.height;\n }\n return; // Skip percentage recalculation - originalData from handleArtworkDrop is correct\n }\n\n // Phase 4.166: When coordinatesLocked is true, PRESERVE original percentages\n // The lock is set by handleArtworkDrop for fresh drops in bulk mode.\n // The drop event may trigger handlePlacementUpdate immediately, but we should\n // NOT recalculate percentages - the handleArtworkDrop percentages are correct.\n //\n // Phase 4.150 NOTE: The previous behavior of falling through to recalculate was\n // intended for user DRAGS, but it was also triggering on initial drops where\n // the user hasn't actually moved anything yet.\n // Phase 4.167: When coordinatesLocked is true, ALWAYS preserve original percentages\n // The lock means: \"these percentages are correct, do not recalculate\"\n //\n // Previous approach (Phase 4.166/4.166b) compared pixel positions to detect user movement,\n // but this was unreliable because:\n // 1. Layout reflows can change getBoundingClientRect() by >1px between calls\n // 2. This triggered false positive \"movement\" detection\n // 3. Which cleared the lock and recalculated percentages incorrectly\n //\n // The lock is now cleared ONLY by explicit user drag/resize start events\n // (decorator:placement:dragstart), not by position comparison in handlePlacementUpdate\n if (artPlacement.coordinatesLocked) {\n // Phase 4.211: Diagnostic log when coordinatesLocked check passes\n console.log('[Phase 4.211] handlePlacementUpdate LOCKED - returning early:', {\n placementId: placementData.placementId,\n originalDataPreserved: {\n left: artPlacement.originalData?.left,\n top: artPlacement.originalData?.top,\n },\n });\n // Update container pixel values for visual restoration on product switches\n // This allows bulk decoration restore to use these pixel positions\n if (artPlacement.originalData) {\n artPlacement.originalData.containerPixelLeft = placementData.left;\n artPlacement.originalData.containerPixelTop = placementData.top;\n artPlacement.originalData.containerPixelWidth = placementData.width;\n artPlacement.originalData.containerPixelHeight = placementData.height;\n }\n return; // ALWAYS return - no position comparison, no percentage recalculation\n }\n\n // Update the original percentage data for save functionality\n // Phase 4.178: Use threshold-based filtering instead of delta-based calculation\n // The percentageData (above) is calculated using legacy method which correctly\n // handles bgStart subtraction. Only update if change exceeds 0.1% threshold\n // to filter out browser rounding noise while allowing intentional moves.\n //\n // Phase 4.177 (reverted): Delta-based calculation had a bug where authoritative\n // pixels included bgStart offset, causing coordinate shift on different aspect ratios.\n\n if (artPlacement.originalData) {\n // Phase 4.178: Threshold-based filtering to prevent rounding error accumulation\n // Phase 4.292: Increased from 0.1% to 2% to filter Safari phantom event noise.\n // Phase 4.294: Lowered from 2% to 0.5% because 2% was too high - real user drags\n // can be smaller than 2% but still intentional. Safari phantom events cause <0.1%\n // drift, so 0.5% (about 4px on 768px image) filters noise while capturing small\n // intentional drags.\n const THRESHOLD = 0.5;\n\n const topDiff = Math.abs(percentageData.top - artPlacement.originalData.top);\n const leftDiff = Math.abs(percentageData.left - artPlacement.originalData.left);\n const widthDiff = Math.abs(percentageData.width - artPlacement.originalData.width);\n const heightDiff = Math.abs(percentageData.height - artPlacement.originalData.height);\n\n const topChanged = topDiff > THRESHOLD;\n const leftChanged = leftDiff > THRESHOLD;\n const widthChanged = widthDiff > THRESHOLD;\n const heightChanged = heightDiff > THRESHOLD;\n\n // Phase 4.292: Log threshold check for debugging coordinate drift\n console.log('[Phase 4.292] Threshold check:', {\n placementId: placementData.placementId,\n coordinatesLocked: artPlacement.coordinatesLocked,\n topDiff: topDiff.toFixed(4),\n leftDiff: leftDiff.toFixed(4),\n threshold: THRESHOLD,\n wouldUpdate: topChanged || leftChanged || widthChanged || heightChanged,\n dropTimeCoordinatesProtected: !!artPlacement.dropTimeCoordinates,\n });\n\n // Phase 4.388: Tool value updates moved to top of method (before early returns)\n // See Phase 4.386 comment above for rationale\n\n if (topChanged || leftChanged || widthChanged || heightChanged) {\n // Phase 4.211: Diagnostic log BEFORE modifying originalData (this should NOT run when locked!)\n console.log('[Phase 4.211] handlePlacementUpdate MODIFYING originalData:', {\n placementId: placementData.placementId,\n BEFORE: {\n left: artPlacement.originalData?.left,\n top: artPlacement.originalData?.top,\n },\n percentageData: {\n left: percentageData.left,\n top: percentageData.top,\n },\n diffs: {\n topDiff,\n leftDiff,\n },\n });\n // Significant change detected - this is an intentional user move\n artPlacement.originalData = {\n ...artPlacement.originalData,\n ...percentageData,\n // Update containerPixel values for visual restoration\n containerPixelLeft: placementData.left,\n containerPixelTop: placementData.top,\n containerPixelWidth: placementData.width,\n containerPixelHeight: placementData.height,\n // Phase 4.349: Include actualImageHeight for server-side dimension matching\n // This provides defense-in-depth alongside the fix in getArtPlacements()\n actualImageHeight: artPlacement.dropTimeCoordinates?.actualImageHeight,\n };\n\n // Phase 4.375: Update data attributes on DOM element after intentional drag\n // This ensures reconstructMapFromDOM gets the new coordinates on modal reopen\n const element = artPlacement.element || artPlacement.getElement?.();\n if (element && element.dataset) {\n // Explicitly convert to strings for consistency (real DOM does this automatically)\n element.dataset.percentLeft = String(percentageData.left);\n element.dataset.percentTop = String(percentageData.top);\n element.dataset.percentWidth = String(percentageData.width);\n element.dataset.percentHeight = String(percentageData.height);\n console.log('[Phase 4.375] Updated data attributes after drag:', {\n placementId: placementData.placementId,\n left: percentageData.left,\n top: percentageData.top,\n });\n }\n\n // Phase 4.292: REMOVED Phase 4.276/4.279 sync code\n // dropTimeCoordinates must remain IMMUTABLE to prevent Safari phantom\n // drag events from corrupting coordinates. The bug flow was:\n // 1. Artwork dropped \u2192 coordinatesLocked=true, dropTimeCoordinates.top=28%\n // 2. Safari modal animation \u2192 triggers phantom \"drag\" event\n // 3. handlePlacementDragStart \u2192 coordinatesLocked=false\n // 4. handlePlacementUpdate runs, recalculates percentage with different params\n // 5. Phase 4.279 sync \u2192 corrupts dropTimeCoordinates\n // 6. Save sends corrupted coordinates\n //\n // dropTimeCoordinates should ONLY be set during:\n // 1. Initial drop (_processArtworkDrop)\n // 2. Click-to-add (handleArtworkClick)\n // Never modified by handlePlacementUpdate.\n } else {\n // Below threshold - preserve original percentages (browser rounding noise)\n // Still update container pixels for visual restoration\n artPlacement.originalData.containerPixelLeft = placementData.left;\n artPlacement.originalData.containerPixelTop = placementData.top;\n artPlacement.originalData.containerPixelWidth = placementData.width;\n artPlacement.originalData.containerPixelHeight = placementData.height;\n }\n } else {\n // If originalData doesn't exist, create it with all necessary fields\n artPlacement.originalData = {\n id: artPlacement.data.id,\n artworkId: artPlacement.data.artworkId,\n artworkUrl: artPlacement.data.artworkUrl || artPlacement.data.imageUrl,\n ...percentageData,\n // Include containerPixel values for visual restoration\n containerPixelLeft: placementData.left,\n containerPixelTop: placementData.top,\n containerPixelWidth: placementData.width,\n containerPixelHeight: placementData.height,\n // Phase 4.349: Include actualImageHeight for server-side dimension matching\n actualImageHeight: artPlacement.dropTimeCoordinates?.actualImageHeight,\n };\n\n // Phase 4.375: Set data attributes when creating originalData for first time\n const element = artPlacement.element || artPlacement.getElement?.();\n if (element && element.dataset) {\n // Explicitly convert to strings for consistency (real DOM does this automatically)\n element.dataset.percentLeft = String(percentageData.left);\n element.dataset.percentTop = String(percentageData.top);\n element.dataset.percentWidth = String(percentageData.width);\n element.dataset.percentHeight = String(percentageData.height);\n }\n }\n\n // Phase 4.180: Removed old Phase 4.179 code here - lock is now set BEFORE recalculation (line 2924)\n\n // Also update the visual element with pixel values\n artPlacement.updateData({\n left: placementData.left,\n top: placementData.top,\n width: placementData.width,\n height: placementData.height,\n rotation: placementData.rotation,\n flipX: placementData.flipX,\n flipY: placementData.flipY,\n });\n\n // Emit state change for history\n this.emitStateChange();\n\n // Phase 4.254: Dispatch placements-changed event for debug panel\n this.dispatchPlacementsChanged();\n\n // Phase 4.128: Update global storage in real-time to prevent data loss\n // when canvas is cleared during async product switching\n // This ensures the moved position is persisted immediately, not just during product switch\n if (this.skipExistingDecorations) {\n const currentProductId = this.getCurrentProductId();\n const bulkData = globalBulkDecorationPlacements.get(this.saleId);\n\n if (bulkData && currentProductId) {\n // Get current placements (should include the just-updated position)\n const currentPlacements = this.getAllCurrentPlacements();\n if (currentPlacements.length > 0) {\n const differs = this.placementsDifferFromDefault(currentPlacements, bulkData.default?.placements || []);\n if (differs) {\n // Phase 4.415: Store per-image override for color-level isolation\n // Post-drop edits (move/resize) should be local to this color's canvas.\n // Only imageOverrides are written \u2014 productOverrides are intentionally\n // left empty to prevent edits on one color cascading to other colors\n // of the same product (which share the same productId).\n const currentImageId = this.currentCanvas?.imageId;\n if (currentImageId) {\n if (!bulkData.imageOverrides) bulkData.imageOverrides = {};\n bulkData.imageOverrides[currentImageId] = {\n placements: currentPlacements,\n sourceImageId: currentImageId,\n productId: currentProductId,\n timestamp: Date.now(),\n };\n }\n }\n }\n }\n }\n }\n }\n\n // Handle placement delete from template controller\n handlePlacementDelete(event) {\n const { placementId } = event.detail;\n\n if (!this.currentCanvas) return;\n\n // Phase 4.381: Track deletion in change tracking system\n // This ensures deleted placements are NOT re-added when switching orientations/colors/products\n // Phase 4.389: Include colorVariantId for state isolation per color\n const changes = getCanvasChanges(this.saleId, this.currentColorVariantId, this.currentCanvas.imageId);\n\n if (changes.added.has(placementId)) {\n // If this was a newly added placement, just remove from added set\n // (it was never saved to server, so no need to track as deleted)\n changes.added.delete(placementId);\n changes.modified.delete(placementId);\n console.log('[Phase 4.381] Removed newly-added placement from tracking:', { placementId });\n } else {\n // If this was an existing placement (from server), mark as deleted\n changes.deleted.add(placementId);\n changes.modified.delete(placementId);\n console.log('[Phase 4.381] Tracked deletion of existing placement:', { placementId });\n }\n\n // Remove the placement from canvas\n this.currentCanvas.removeArtPlacement(placementId);\n\n // Emit state change for history\n this.emitStateChange();\n\n // Phase 4.254: Dispatch placements-changed event for debug panel\n this.dispatchPlacementsChanged();\n\n // Phase 4.420: Persist cleared/updated state immediately on deletion.\n // Without this, the cleared sentinel is only stored at product-switch time,\n // so the deletion can be lost if canvas state is recreated before the switch.\n // Mirrors handlePlacementUpdate which also updates bulk storage in real-time.\n this.updateGlobalBulkStorage();\n }\n\n // Handle placement clone from template controller\n handlePlacementClone(event) {\n const cloneData = event.detail;\n\n if (!this.currentCanvas) return;\n\n // The template controller sends pixel values and position\n const boardHeight = this.boardDimensions.height;\n\n // Create new placement with cloned data\n const placement = {\n id: `art_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,\n artworkId: cloneData.artworkId,\n imageUrl: cloneData.artworkUrl, // Use the URL from the cloned artwork\n // Convert pixel position to percentages (0-100 range) - baseline uses % of height for ALL dimensions\n left: (cloneData.position.left / boardHeight) * 100,\n top: (cloneData.position.top / boardHeight) * 100,\n width: (cloneData.width / boardHeight) * 100,\n height: (cloneData.height / boardHeight) * 100,\n rotation: cloneData.rotation,\n flipX: cloneData.flipX,\n flipY: cloneData.flipY,\n };\n\n // Add the cloned placement to canvas\n this.currentCanvas.addArtPlacement(placement);\n\n // Emit state change for history\n this.emitStateChange();\n }\n\n // Add artwork to canvas (used by artwork selector)\n addArtwork(artworkData) {\n if (!this.currentCanvas) return;\n\n // Phase 4.269: Get current canvas state for dropTimeCoordinates\n // This ensures click-to-add has the same immutable coordinate protection as drag-and-drop\n const boardDimensions = this.currentCanvas.boardDimensions || {\n width: this.currentCanvas.board?.clientWidth || 0,\n height: this.currentCanvas.board?.clientHeight || 0,\n };\n const bgStart = typeof this.currentCanvas.getBackgroundImageStart === 'function'\n ? this.currentCanvas.getBackgroundImageStart()\n : { x: 0, y: 0 };\n const scalingFactor = typeof this.currentCanvas.getScalingFactor === 'function'\n ? this.currentCanvas.getScalingFactor()\n : null;\n\n // Default position values (as percentage of height, 0-100 range)\n const left = 50; // 50% of height - center\n const top = 50; // 50% of height - center\n const width = 20; // 20% of height\n const height = 20; // 20% of height\n\n // Phase 4.269: Build dropTimeCoordinates for click-to-add path\n // This matches the Phase 4.268 pattern for drag-and-drop\n // Phase 4.357: Use consolidated helper method for actualImageHeight calculation\n const actualImageHeight = this.getActualImageHeight();\n\n const dropTimeCoordinates = {\n left: left,\n top: top,\n width: width,\n height: height,\n artworkId: artworkData.id,\n artworkUrl: artworkData.url,\n // Phase 4.384: Include default tool values for intentional change detection\n rotation: 0,\n flipX: false,\n flipY: false,\n boardDimensions: {\n width: boardDimensions.width,\n height: boardDimensions.height,\n },\n bgStart: {\n x: bgStart.x,\n y: bgStart.y,\n },\n scalingFactor: scalingFactor,\n // Phase 4.278: Store isVerticalFit for server-side coordinate transformation\n isVerticalFit: this.currentCanvas?.isVerticalFit?.() ?? null,\n // Phase 4.400: Track if drop happened in modal context for context-aware bgStart handling\n isModalContext: !!this.element.closest('#decorator-modal'),\n timestamp: Date.now(),\n // Phase 4.295: Store actualImageHeight for round-trip coordinate consistency\n actualImageHeight: actualImageHeight,\n };\n\n const placement = {\n id: `art_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,\n artworkId: artworkData.id,\n artworkUrl: artworkData.url,\n left: left,\n top: top,\n width: width,\n height: height,\n rotation: 0,\n flipX: false,\n flipY: false,\n // Phase 4.269: Lock coordinates to prevent handlePlacementUpdate corruption\n coordinatesLocked: true,\n // Phase 4.269: Include immutable drop-time coordinates\n dropTimeCoordinates: dropTimeCoordinates,\n };\n\n console.log('[Phase 4.269] addArtwork with dropTimeCoordinates:', {\n artworkId: artworkData.id,\n top: top,\n left: left,\n boardDimensions: boardDimensions,\n });\n\n this.currentCanvas.addArtPlacement(placement);\n this.emitStateChange();\n this.markAsDirty(); // Mark dirty for autosave tracking\n }\n\n // Handle controller request events (fallback communication method)\n handleControllerRequest(event) {\n if (event.detail && event.detail.callback) {\n // Provide this controller instance to the requesting code\n event.detail.callback(this);\n }\n }\n\n}\n", "import { Controller } from '../stimulus';\n\n/**\n * Decorator Canvas Color Controller\n *\n * Manages individual color variant buttons in the decorator modal.\n * Handles color selection, visual display, and event triggering.\n *\n * Maintains baseline fidelity with the original implementation.\n */\nexport default class extends Controller {\n static targets = ['link', 'total'];\n\n connect() {\n // Get data from element dataset\n this.colorData = this.getDataFromElement();\n\n // Only process if we have required data\n if (!this.colorData.id) return;\n\n this.initBackground();\n this.initTotal();\n this.initTooltip();\n\n // Auto-select if marked as selected\n // Phase 4.390: Use RAF instead of setTimeout for faster paint synchronization\n if (this.colorData.selected) {\n requestAnimationFrame(() => this.element.click());\n }\n\n // Trigger connect event\n const event = new CustomEvent('decorator:colors:connect', {\n detail: this.getData(),\n bubbles: true,\n });\n this.element.dispatchEvent(event);\n }\n\n initTotal() {\n if (this.hasTotalTarget) {\n this.totalTarget.textContent = this.colorData.total || 0;\n }\n }\n\n initTooltip() {\n // Using tippy if available, fallback to title attribute\n if (typeof tippy !== 'undefined') {\n this.element.setAttribute('title', this.colorData.name);\n tippy(this.element, {\n arrow: true,\n placement: 'top',\n popperOptions: {\n modifiers: {\n preventOverflow: { escapeWithReference: true },\n },\n },\n });\n } else {\n // Fallback to simple title\n this.element.setAttribute('title', this.colorData.name);\n }\n }\n\n initBackground() {\n if (this.hasLinkTarget && this.colorData.images && this.colorData.displayImageId) {\n const image = this.colorData.images[this.colorData.displayImageId];\n if (image && image.thumb_url) {\n this.linkTarget.style.backgroundImage = `url(${image.thumb_url})`;\n }\n }\n }\n\n onClick(e) {\n e.preventDefault();\n\n // Update active state\n const siblings = this.element.parentElement.querySelectorAll('li');\n siblings.forEach(sibling => sibling.classList.remove('active'));\n this.element.classList.add('active');\n\n // Get data to dispatch\n const colorData = this.getData();\n\n // Trigger color selection event\n const event = new CustomEvent('decorator:colors:select', {\n detail: colorData,\n bubbles: true,\n });\n document.dispatchEvent(event);\n }\n\n getDataFromElement() {\n const dataset = this.element.dataset;\n return {\n id: dataset.colorId ? parseInt(dataset.colorId) : null,\n name: dataset.colorName || '',\n selected: dataset.colorSelected === 'true',\n total: dataset.colorTotal ? parseInt(dataset.colorTotal) : 0,\n displayImageId: dataset.colorDisplayImageId ? parseInt(dataset.colorDisplayImageId) : null,\n orderedImageIds: dataset.colorOrderedImageIds ? JSON.parse(dataset.colorOrderedImageIds) : [],\n images: dataset.colorImages ? JSON.parse(dataset.colorImages) : {},\n isDefault: dataset.colorDefault === 'true',\n isAvailable: dataset.colorAvailable === 'true',\n };\n }\n\n getData() {\n return {\n id: this.colorData.id,\n name: this.colorData.name,\n orderedImageIds: this.colorData.orderedImageIds,\n images: this.colorData.images,\n selectedImageId: this.colorData.displayImageId,\n isDefault: this.colorData.isDefault,\n isAvailable: this.colorData.isAvailable,\n };\n }\n}\n", "import { Controller } from '../stimulus';\n\n/**\n * Decorator Canvas Colors Controller\n *\n * Manages the color variant gallery in the decorator modal.\n * Fetches and displays available color variants for selected products.\n *\n * Events Dispatched:\n * - startFetch: When beginning to fetch color data (for loading states)\n * - endFetch: When finishing fetch operation\n * - error: When fetch fails (detail contains message and status)\n *\n * Maintains baseline fidelity with the original implementation while\n * adapting to the new decorator modal structure.\n */\nexport default class extends Controller {\n static targets = ['template', 'content', 'expandIcon'];\n\n initialize() {\n this.template = this.templateTarget.innerHTML;\n\n this.colorElements = {};\n this.currentColorElements = [];\n this.loadedProductColors = {};\n\n // Listen for product selection events\n this.onProductSelect = this.handleProductSelect.bind(this);\n document.addEventListener('decorator:product:selected', this.onProductSelect);\n }\n\n disconnect() {\n document.removeEventListener('decorator:product:selected', this.onProductSelect);\n // Clean up cached color elements to prevent memory leaks\n this.colorElements = {};\n this.currentColorElements = [];\n this.loadedProductColors = {};\n }\n\n expand(e) {\n e.preventDefault();\n\n if (this.element.classList.contains('fully-expanded')) {\n this.collapse();\n } else if (this.element.classList.contains('expanded')) {\n this.fullyExpand();\n } else {\n this.element.classList.add('expanded');\n }\n }\n\n fullyExpand() {\n this.element.classList.add('fully-expanded');\n // Change direction of arrow\n this.expandIconTarget.classList.remove('fa-angle-left');\n this.expandIconTarget.classList.add('fa-angle-right');\n }\n\n collapse(e) {\n if (e) e.preventDefault();\n this.element.classList.remove('expanded', 'fully-expanded');\n // Restore expand icon\n this.expandIconTarget.classList.remove('fa-angle-right');\n this.expandIconTarget.classList.add('fa-angle-left');\n }\n\n handleProductSelect(e) {\n const productData = e.detail;\n const productId = productData.id;\n\n // Phase 4.387: Read modal's data-color-variant-id for URL-specified color\n // The modal's attribute is authoritative for the initially requested color\n const modalElement = document.getElementById('decorator-modal');\n const modalProductId = modalElement?.dataset.productId;\n const modalColorVariantId = modalElement?.dataset.colorVariantId;\n\n // Use modal's color variant ID when:\n // 1. This is the product specified in the URL (product IDs match)\n // 2. AND a color variant was specified in the URL\n // Otherwise fall back to productData.selected_color_id or display_color_id\n let selectedColorId;\n if (modalProductId && String(productId) === String(modalProductId) && modalColorVariantId) {\n selectedColorId = parseInt(modalColorVariantId, 10);\n } else {\n selectedColorId = productData.selected_color_id || productData.display_color_id;\n }\n\n // Don't abort old fetch - just let it complete and ignore stale responses\n // This avoids AbortError uncaught promise rejection issues\n\n if (productData.colors) {\n this.showColors(productData.colors, { productId, selectedColorId });\n } else {\n this.fetchColors(productId, selectedColorId);\n }\n }\n\n fetchColors(productId, selectedColorId) {\n const cachedColors = this.loadedProductColors[productId];\n\n // Check for cache first\n if (cachedColors) {\n this.showColors(cachedColors, { productId, selectedColorId });\n } else {\n // Fetch colors from server\n let url = this.element.dataset.url;\n if (!url) {\n const errorEvent = new CustomEvent('decorator:colors:error', {\n detail: { message: 'Configuration error: No URL specified' },\n bubbles: true,\n });\n this.element.dispatchEvent(errorEvent);\n return;\n }\n\n if (!url.includes('?')) {\n url += '?';\n } else {\n url += '&';\n }\n url += `product_id=${productId}`;\n\n if (selectedColorId) {\n url += `&selected_color_id=${selectedColorId}`;\n }\n\n // Create new request ID to track if response is still relevant\n const requestId = Date.now();\n this.currentRequestId = requestId;\n\n const startEvent = new CustomEvent('decorator:colors:startFetch', { bubbles: true });\n this.element.dispatchEvent(startEvent);\n\n fetch(url, {\n headers: {\n 'Accept': 'application/json',\n },\n })\n .then(response => {\n // Ignore stale response if a newer request has started\n if (this.currentRequestId !== requestId) {\n return null;\n }\n if (!response.ok) {\n const error = new Error(`HTTP ${response.status}: ${response.statusText}`);\n error.status = response.status;\n throw error;\n }\n return response.json();\n })\n .then(response => {\n // Ignore if response was already marked as stale\n if (!response) return;\n\n const colors = response.colors || response.color_variants;\n this.showColors(colors, { productId, selectedColorId: selectedColorId || response.selected_color_id });\n this.loadedProductColors[productId] = colors;\n })\n .catch(error => {\n // Ignore errors from stale requests\n if (this.currentRequestId !== requestId) return;\n\n let message = 'Failed to load color options. Please try again.';\n if (error.status === 404) {\n message = 'Product not found.';\n } else if (error.status === 422) {\n message = 'Invalid request parameters.';\n } else if (error.status >= 500) {\n message = 'Server error. Please try again later.';\n }\n\n const errorEvent = new CustomEvent('decorator:colors:error', {\n detail: { message, status: error.status },\n bubbles: true,\n });\n this.element.dispatchEvent(errorEvent);\n })\n .finally(() => {\n // Only dispatch end event if this is still the current request\n if (this.currentRequestId === requestId) {\n const endEvent = new CustomEvent('decorator:colors:endFetch', { bubbles: true });\n this.element.dispatchEvent(endEvent);\n }\n });\n }\n }\n\n showColors(colors, options = {}) {\n const productId = options.productId;\n const selectedColorId = options.selectedColorId;\n\n this.detachCurrentColorElements();\n\n this.currentColorElements = this.colorElements[productId];\n\n if (!this.currentColorElements) {\n this.currentColorElements = this.createColorElements(colors, selectedColorId);\n this.colorElements[productId] = this.currentColorElements;\n }\n\n this.attachCurrentColorElements();\n }\n\n attachCurrentColorElements() {\n this.currentColorElements.forEach((element) => {\n this.contentTarget.appendChild(element);\n });\n }\n\n detachCurrentColorElements() {\n this.currentColorElements.forEach((element) => {\n if (element.parentNode) {\n element.parentNode.removeChild(element);\n }\n });\n }\n\n createColorElements(colors, selectedId) {\n const elements = [];\n const colorsArray = Array.isArray(colors) ? colors : Object.values(colors);\n const displayedColors = colorsArray.filter((color) => color.is_displayed !== false);\n const totalColors = displayedColors.length;\n\n displayedColors.forEach((color) => {\n // Create element from template\n const temp = document.createElement('div');\n temp.innerHTML = this.template.trim();\n const colorElement = temp.firstChild;\n\n // Set data attributes for the color controller\n colorElement.setAttribute('data-controller', 'decorator-canvas-color');\n colorElement.setAttribute('data-init', 'true'); // This will trigger initialization - matches baseline\n colorElement.setAttribute('data-color-id', color.id);\n colorElement.setAttribute('data-color-name', color.name);\n colorElement.setAttribute('data-color-display-image-id', color.display_image_id);\n colorElement.setAttribute('data-color-selected', color.is_selected || (selectedId && selectedId.toString() === color.id.toString()));\n colorElement.setAttribute('data-color-total', totalColors);\n colorElement.setAttribute('data-color-images', JSON.stringify(color.images || {}));\n colorElement.setAttribute('data-color-ordered-image-ids', JSON.stringify(color.ordered_image_ids || []));\n colorElement.setAttribute('data-color-default', color.is_default || false);\n colorElement.setAttribute('data-color-available', color.is_available !== false);\n\n // Phase 4.390: Pre-set active class to prevent flash of unselected colors\n // CSS rule `.team-site-products-gallery-list li:not(.active) { display: none }` only hides\n // non-active colors AFTER one has `.active` class. Pre-setting avoids ~10ms flash.\n if (color.is_selected || (selectedId && selectedId.toString() === color.id.toString())) {\n colorElement.classList.add('active');\n }\n\n elements.push(colorElement);\n });\n\n return elements;\n }\n}\n", "/**\n * Decorator Canvas Dropzone Controller\n *\n * Custom drag-and-drop file upload implementation without Dropzone.js dependency.\n * Handles artwork uploads via drag-and-drop or click-to-browse.\n *\n * HTML Structure:\n * ```html\n *
    \n *\n * \n * \n *\n * \n *
    \n * Uploading...\n *
    \n *\n * \n *
    \n *

    Drag & drop here to upload

    \n *
    \n *
    \n * ```\n *\n * Data Attributes:\n * - sale-id: Current sale ID for upload URL construction\n *\n * Features:\n * - Click anywhere on dropzone to open file browser\n * - Drag and drop image files\n * - File type validation (images only)\n * - Visual feedback during drag over\n * - Progress indication during upload\n * - Automatic artwork placement on canvas after upload\n *\n * Events Emitted:\n * - dropzone:add - When file upload starts\n * - dropzone:upload - When upload completes with artwork data\n *\n * Upload Endpoint:\n * POST /teamsites/{saleId}/artworks\n *\n * CSS Classes:\n * - .drag-over - Applied during drag over for visual feedback\n *\n * Integration:\n * - Finds artboard controller to add uploaded artwork\n * - Uses CSRFTokenMixin for Rails CSRF protection\n *\n * Note: This is a custom implementation that does NOT use the Dropzone.js library\n */\nimport { Controller } from '../stimulus';\nimport CSRFTokenMixin from '../mixins/csrf_token_mixin';\n\nexport default class extends Controller {\n static targets = ['fileInput', 'progressBar'];\n\n connect() {\n Object.assign(this, CSRFTokenMixin);\n this.saleId = this.element.dataset.saleId;\n this.uploadUrl = `/teamsites/${this.saleId}/artworks`;\n\n\n this.setupDropzone();\n }\n\n disconnect() {\n // Clean up event listeners\n this.element.removeEventListener('click', this.handleClick);\n this.element.removeEventListener('dragenter', this.handleDragEnter);\n this.element.removeEventListener('dragover', this.handleDragOver);\n this.element.removeEventListener('dragleave', this.handleDragLeave);\n this.element.removeEventListener('drop', this.handleDrop);\n\n // Clean up file input listener\n if (this.fileInput && this.handleFileChange) {\n this.fileInput.removeEventListener('change', this.handleFileChange);\n }\n }\n\n setupDropzone() {\n // Make dropzone clickable\n this.handleClick = () => {\n const fileInput = this.element.querySelector('input[type=\"file\"]');\n if (fileInput) {\n fileInput.click();\n }\n };\n this.element.addEventListener('click', this.handleClick);\n\n // Setup drag and drop\n this.handleDragEnter = this.onDragEnter.bind(this);\n this.handleDragOver = this.onDragOver.bind(this);\n this.handleDragLeave = this.onDragLeave.bind(this);\n this.handleDrop = this.onDrop.bind(this);\n\n this.element.addEventListener('dragenter', this.handleDragEnter);\n this.element.addEventListener('dragover', this.handleDragOver);\n this.element.addEventListener('dragleave', this.handleDragLeave);\n this.element.addEventListener('drop', this.handleDrop);\n\n // Setup file input change handler - store bound reference for cleanup\n const fileInput = this.element.querySelector('input[type=\"file\"]');\n if (fileInput) {\n this.handleFileChange = (event) => {\n const files = event.target.files;\n if (files.length > 0) {\n this.uploadFile(files[0]);\n }\n };\n this.fileInput = fileInput; // Store reference for cleanup\n fileInput.addEventListener('change', this.handleFileChange);\n }\n }\n\n onDragEnter(event) {\n event.preventDefault();\n event.stopPropagation();\n this.element.classList.add('drag-over');\n }\n\n onDragOver(event) {\n event.preventDefault();\n event.stopPropagation();\n event.dataTransfer.dropEffect = 'copy';\n this.element.classList.add('drag-over');\n }\n\n onDragLeave(event) {\n event.preventDefault();\n event.stopPropagation();\n this.element.classList.remove('drag-over');\n }\n\n onDrop(event) {\n event.preventDefault();\n event.stopPropagation();\n this.element.classList.remove('drag-over');\n\n // Check if this is an artwork drop from the library\n const artworkId = event.dataTransfer.getData('artworkId');\n const artworkUrl = event.dataTransfer.getData('artworkUrl');\n\n\n if (artworkId && artworkUrl) {\n // This is an artwork from the library\n this.handleArtworkDrop(event, artworkId, artworkUrl);\n } else {\n // This is a file drop\n const files = event.dataTransfer.files;\n if (files.length > 0) {\n // Phase 4.114: Capture drop coordinates for direct canvas placement\n const rect = this.element.getBoundingClientRect();\n const dropX = event.clientX - rect.left;\n const dropY = event.clientY - rect.top;\n\n this.uploadFile(files[0], dropX, dropY);\n }\n }\n }\n\n async uploadFile(file, dropX = null, dropY = null) {\n // Validate file type\n if (!file.type.startsWith('image/')) {\n this.showError('Please upload an image file');\n return;\n }\n\n // Trigger loading event\n this.element.dispatchEvent(new CustomEvent('dropzone:add', { bubbles: true }));\n\n // Show progress if available\n if (this.hasProgressBarTarget) {\n this.progressBarTarget.style.display = 'block';\n }\n\n const formData = new FormData();\n formData.append('image', file);\n\n try {\n const response = await fetch(this.uploadUrl, {\n method: 'POST',\n headers: {\n 'X-CSRF-Token': this.getCSRFToken(),\n 'Accept': 'application/json',\n },\n body: formData,\n });\n\n if (!response.ok) {\n throw new Error('Upload failed');\n }\n\n const artwork = await response.json();\n\n // Phase 4.114.2: Dispatch dropzone:upload to clear loading state\n // Artboard controller listens for this event to hide spinner\n this.element.dispatchEvent(new CustomEvent('dropzone:upload', {\n bubbles: true,\n detail: { file: {}, response: artwork },\n }));\n\n // Dispatch uploadsuccess on element (for local listeners)\n this.element.dispatchEvent(new CustomEvent('dropzone:uploadsuccess', {\n bubbles: true,\n detail: { file: {}, response: artwork },\n }));\n\n // Phase 4.114.2: Also dispatch to document so artwork library can refresh\n // The artwork library is in a different DOM subtree, so element bubbling doesn't reach it\n // Uses dedicated event name to avoid collision with generic dropzone's bubbling queuecomplete\n document.dispatchEvent(new CustomEvent('decorator:canvas:upload:complete', {\n detail: { file: {}, response: artwork },\n }));\n\n // Phase 4.114: If coordinates provided, place artwork directly on canvas\n if (dropX !== null && dropY !== null) {\n const artworkDropEvent = new CustomEvent('decorator:artwork:drop', {\n detail: {\n artworkId: artwork.id,\n artworkUrl: artwork.url,\n dropX: dropX,\n dropY: dropY,\n },\n bubbles: true,\n });\n document.dispatchEvent(artworkDropEvent);\n }\n\n } catch {\n this.showError('Failed to upload artwork');\n } finally {\n // Hide progress\n if (this.hasProgressBarTarget) {\n this.progressBarTarget.style.display = 'none';\n }\n\n // Reset file input\n const fileInput = this.element.querySelector('input[type=\"file\"]');\n if (fileInput) {\n fileInput.value = '';\n }\n }\n }\n\n handleArtworkDrop(event, artworkId, artworkUrl) {\n // Get drop position relative to dropzone\n const rect = this.element.getBoundingClientRect();\n const dropX = event.clientX - rect.left;\n const dropY = event.clientY - rect.top;\n\n // Phase 4.190: Also pass raw clientX/clientY for container-relative calculation\n // The artboard controller will use these to calculate position relative to\n // the previewContainer (where artwork is actually placed) instead of the dropzone.\n // This fixes coordinate mismatch when dropzone and previewContainer have different positions.\n const artworkDropEvent = new CustomEvent('decorator:artwork:drop', {\n detail: {\n artworkId: artworkId,\n artworkUrl: artworkUrl,\n dropX: dropX,\n dropY: dropY,\n // Phase 4.190: Raw screen coordinates for container-relative calculation\n clientX: event.clientX,\n clientY: event.clientY,\n // Phase 4.190: Dropzone rect for diagnostic comparison\n dropzoneRect: { top: rect.top, left: rect.left, width: rect.width, height: rect.height },\n },\n bubbles: true,\n });\n\n document.dispatchEvent(artworkDropEvent);\n }\n\n showError(message) {\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash(message);\n } else {\n console.error('[decorator-canvas-dropzone]', message);\n }\n }\n}\n", "/**\n * ImageHistory - Manages state history for decorator canvas\n *\n * Tracks art placement states and provides undo/redo functionality.\n * Each state is a snapshot of all art placements at a moment in time.\n */\nexport default class ImageHistory {\n /**\n * Creates a new ImageHistory instance\n * @param {string} imageId - The unique ID of the image\n * @param {Object} initialState - The initial state of art placements\n */\n constructor(imageId, initialState = {}) {\n this.imageId = imageId;\n this.index = 0;\n this.hasUserActions = false; // Track if user has made any changes\n\n // States array stores snapshots of art placements\n // Each state is an object mapping placementId to placement data\n this.states = [this.deepClone(initialState)];\n }\n\n /**\n * Gets the current state\n * @returns {Object} The current state object\n */\n getCurrentState() {\n return this.states[this.index];\n }\n\n /**\n * Checks if we can undo\n * @returns {boolean} True if undo is possible\n */\n canUndo() {\n // Only allow undo if user has made changes\n return this.hasUserActions && this.index > 0;\n }\n\n /**\n * Performs undo operation\n * @returns {boolean} True if undo was performed\n */\n undo() {\n if (this.canUndo()) {\n this.index--;\n return true;\n }\n return false;\n }\n\n /**\n * Checks if we can redo\n * @returns {boolean} True if redo is possible\n */\n canRedo() {\n return this.index < (this.states.length - 1);\n }\n\n /**\n * Performs redo operation\n * @returns {boolean} True if redo was performed\n */\n redo() {\n if (this.canRedo()) {\n this.index++;\n return true;\n }\n return false;\n }\n\n /**\n * Pushes a new state to history\n * @param {Object} state - The new state to push\n */\n push(state) {\n // Mark that user has made actions\n this.hasUserActions = true;\n\n // Remove any states after current index (linear history)\n this.states = this.states.slice(0, this.index + 1);\n\n // Add new state\n this.states.push(this.deepClone(state));\n this.index++;\n\n // Limit history size to prevent memory issues\n const maxStates = 50;\n if (this.states.length > maxStates) {\n this.states.shift();\n this.index--;\n }\n }\n\n /**\n * Pushes changes for a specific art placement\n * @param {string} artPlacementId - The placement ID\n * @param {Object} changes - The changes to apply\n */\n pushChanges(artPlacementId, changes) {\n const currentState = this.getCurrentState();\n const newState = this.deepClone(currentState);\n\n // Get existing placement data or create new one\n const existingPlacement = newState[artPlacementId] || {};\n\n // Merge changes into the placement, ensuring artworkUrl is preserved\n newState[artPlacementId] = {\n ...existingPlacement,\n ...changes,\n // Ensure artworkUrl is never lost during updates\n artworkUrl: changes.artworkUrl || existingPlacement.artworkUrl ||\n existingPlacement.artwork_url || existingPlacement.imageUrl ||\n existingPlacement.image_url,\n };\n\n this.push(newState);\n }\n\n /**\n * Pushes removal of an art placement\n * @param {string} artPlacementId - The placement ID to remove\n */\n pushRemoval(artPlacementId) {\n // Only allow removal of user-added artworks (not existing ones)\n if (artPlacementId.startsWith('existing_')) {\n return; // Don't allow removal of existing artworks\n }\n\n const currentState = this.getCurrentState();\n const newState = this.deepClone(currentState);\n\n delete newState[artPlacementId];\n this.push(newState);\n }\n\n /**\n * Resets to initial state\n */\n reset() {\n const initialState = this.deepClone(this.states[0]);\n this.push(initialState);\n }\n\n /**\n * Checks if there are changes from initial state\n * @returns {boolean} True if there are changes\n */\n hasChanges() {\n return this.index > 0;\n }\n\n /**\n * Removes artworks from all states\n * @param {Array} artworkIds - Array of artwork IDs to remove\n */\n removeArtworks(artworkIds = []) {\n this.states.forEach((state) => {\n Object.keys(state).forEach((placementId) => {\n const placement = state[placementId];\n if (placement && artworkIds.includes(parseInt(placement.artworkId))) {\n delete state[placementId];\n }\n });\n });\n }\n\n /**\n * Deep clones an object\n * @param {Object} obj - Object to clone\n * @returns {Object} Cloned object\n */\n deepClone(obj) {\n return JSON.parse(JSON.stringify(obj));\n }\n}\n", "/**\n * Decorator Canvas History Controller\n *\n * Manages undo/redo functionality for the decorator canvas. Maintains a history\n * of canvas states and provides navigation through them.\n *\n * HTML Structure:\n * ```html\n *
    \n * \n * \n *\n * \n * \n *\n * \n * \n *
    \n * ```\n *\n * Targets:\n * - undo: Undo button element\n * - redo: Redo button element\n *\n * Actions:\n * - undo: Navigate to previous state\n * - redo: Navigate to next state\n * - publish: Trigger save (emits event)\n *\n * Events Listened:\n * - decorator:state:changed - Records new canvas state\n *\n * Events Emitted:\n * - decorator:history:reload - Requests artboard to restore a state\n * - decorator:history:publish - Triggers save process\n *\n * Configuration:\n * - maxHistorySize: 50 states (configurable in connect())\n *\n * State Management:\n * - Linear history (no branching)\n * - New changes clear forward history\n * - Automatic button enable/disable based on position\n * - Old states removed when limit exceeded (FIFO)\n *\n * CSS Classes:\n * - .ae-btn-disabled - Applied to disabled buttons\n *\n * Example State Object:\n * ```javascript\n * {\n * artPlacements: [...],\n * currentImageId: 123,\n * timestamp: Date.now()\n * }\n * ```\n */\nimport { Controller } from '../stimulus';\n\nimport ImageHistory from './decorator_canvas_image_history';\n\nexport default class extends Controller {\n static targets = ['undo', 'redo'];\n\n connect() {\n\n this.histories = {};\n this.currentCanvas = null;\n\n // Bind event handlers\n this.onNewArtwork = this.handleNewArtwork.bind(this);\n this.onRemoveArtwork = this.handleRemoveArtwork.bind(this);\n this.onCanvasLoaded = this.handleCanvasLoaded.bind(this);\n this.onStateChanged = this.handleStateChanged.bind(this);\n\n // Listen for canvas events\n document.addEventListener('decorator:board:canvasLoaded', this.onCanvasLoaded);\n\n // Listen for artwork lifecycle events only (not manipulations)\n document.addEventListener('decorator:artwork:remove', this.onRemoveArtwork);\n document.addEventListener('decorator:artwork:clone', this.onNewArtwork);\n\n // Listen for dropzone events\n document.addEventListener('dropzone:drop', this.onNewArtwork);\n\n // Listen for state changes from artboard (this provides all coordinate updates with proper percentages)\n document.addEventListener('decorator:state:changed', this.onStateChanged);\n }\n\n disconnect() {\n document.removeEventListener('decorator:board:canvasLoaded', this.onCanvasLoaded);\n document.removeEventListener('decorator:artwork:remove', this.onRemoveArtwork);\n document.removeEventListener('decorator:artwork:clone', this.onNewArtwork);\n document.removeEventListener('dropzone:drop', this.onNewArtwork);\n document.removeEventListener('decorator:state:changed', this.onStateChanged);\n }\n\n handleCanvasLoaded(event) {\n const { canvas, existingPlacements } = event.detail;\n\n // Initialize history for this canvas\n const imageId = canvas.imageId;\n this.currentCanvas = canvas;\n\n // Only create new history if one doesn't exist for this image\n // This prevents overwriting existing history when switching orientations\n if (!this.histories[imageId]) {\n // Convert array of placements to object format for history\n const initialState = {};\n if (existingPlacements && Array.isArray(existingPlacements)) {\n existingPlacements.forEach(placement => {\n const placementId = `existing_${placement.id}`;\n // Normalize the initial placement data\n initialState[placementId] = this.normalizePlacementData(placement);\n });\n }\n\n this.histories[imageId] = new ImageHistory(imageId, initialState);\n }\n\n this.updateButtons();\n }\n\n handleStateChanged(event) {\n // This is a full state update from artboard\n // Add defensive check for null event.detail\n if (!event.detail) {\n // Silently return if no detail data\n return;\n }\n\n const { artPlacements, currentImageId } = event.detail;\n\n if (!currentImageId || !this.histories[currentImageId]) {\n return;\n }\n\n // Convert array of placements to object format with normalized data\n const state = {};\n if (artPlacements && Array.isArray(artPlacements)) {\n artPlacements.forEach(placement => {\n // Validate coordinates before storing in history\n if (this.isPlacementDataValid(placement)) {\n // Normalize the placement data to ensure consistency\n state[placement.id] = this.normalizePlacementData(placement);\n } else {\n // Skip placement with invalid coordinates\n }\n });\n }\n\n // Push the full state\n this.histories[currentImageId].push(state);\n this.updateButtons();\n }\n\n handleNewArtwork(event) {\n // For dropzone:drop events, we need to wait for the actual placement\n // The artboard will emit a state change event after adding the artwork\n // For decorator:artwork:clone, the event contains placement data\n if (event.type === 'decorator:artwork:clone') {\n const placementId = Date.now().toString();\n const changes = event.detail.changes || event.detail;\n\n if (this.getCurrentHistory() && changes) {\n // Normalize the changes data before storing\n const normalizedChanges = this.normalizePlacementData(changes);\n this.getCurrentHistory().pushChanges(placementId, normalizedChanges);\n this.updateButtons();\n }\n }\n // For dropzone:drop, we'll rely on the state change event\n }\n\n\n handleRemoveArtwork(event) {\n const { placementId } = event.detail;\n\n if (this.getCurrentHistory() && placementId) {\n this.getCurrentHistory().pushRemoval(placementId);\n this.updateButtons();\n }\n }\n\n getCurrentHistory() {\n if (this.currentCanvas && this.histories[this.currentCanvas.imageId]) {\n return this.histories[this.currentCanvas.imageId];\n }\n return null;\n }\n\n\n undo(event) {\n event.preventDefault();\n\n const history = this.getCurrentHistory();\n if (!history) return;\n\n if (history.undo()) {\n this.reload();\n }\n }\n\n redo(event) {\n event.preventDefault();\n\n const history = this.getCurrentHistory();\n if (!history) return;\n\n if (history.redo()) {\n this.reload();\n }\n }\n\n reload() {\n this.updateButtons();\n\n const history = this.getCurrentHistory();\n if (!history) return;\n\n // Emit reload event with current history state\n document.dispatchEvent(new CustomEvent('decorator:history:reload', {\n bubbles: true,\n detail: history,\n }));\n }\n\n updateButtons() {\n const history = this.getCurrentHistory();\n\n // Update undo button\n if (this.hasUndoTarget) {\n if (!history || !history.canUndo()) {\n this.undoTarget.disabled = true;\n this.undoTarget.classList.add('ae-btn-disabled');\n } else {\n this.undoTarget.disabled = false;\n this.undoTarget.classList.remove('ae-btn-disabled');\n }\n }\n\n // Update redo button\n if (this.hasRedoTarget) {\n if (!history || !history.canRedo()) {\n this.redoTarget.disabled = true;\n this.redoTarget.classList.add('ae-btn-disabled');\n } else {\n this.redoTarget.disabled = false;\n this.redoTarget.classList.remove('ae-btn-disabled');\n }\n }\n }\n\n publish(event) {\n event.preventDefault();\n\n // Trigger publish event with all histories\n this.element.dispatchEvent(new CustomEvent('decorator:history:publish', {\n bubbles: true,\n detail: { histories: this.histories },\n }));\n }\n\n /**\n * Validates that placement data has reasonable coordinate values\n * @param {Object} placement - The placement data to validate\n * @returns {Boolean} True if placement data is valid\n */\n isPlacementDataValid(placement) {\n if (!placement) return false;\n\n // Check that essential coordinate properties exist and are numbers\n const coords = ['left', 'top', 'width', 'height'];\n for (const coord of coords) {\n const value = placement[coord];\n if (typeof value !== 'number' || isNaN(value) || !isFinite(value)) {\n return false;\n }\n }\n\n // For percentage coordinates, values should typically be 0-100, but allow flexibility\n // Reject obviously corrupted values (> 1000% is clearly wrong)\n const maxReasonablePercentage = 1000;\n const isReasonable = (value) => Math.abs(value) < maxReasonablePercentage;\n\n if (!isReasonable(placement.left) || !isReasonable(placement.top) ||\n !isReasonable(placement.width) || !isReasonable(placement.height)) {\n return false;\n }\n\n // Reject negative width/height (doesn't make sense)\n if (placement.width < 0 || placement.height < 0) {\n return false;\n }\n\n return true;\n }\n\n /**\n * Normalizes placement data to ensure consistent format across different sources\n * @param {Object} placement - The placement data to normalize\n * @returns {Object} Normalized placement data\n */\n normalizePlacementData(placement) {\n if (!placement) return placement;\n\n // Ensure we have the artwork URL from any possible property\n const artworkUrl = placement.artworkUrl ||\n placement.artwork_url ||\n placement.imageUrl ||\n placement.image_url ||\n placement.url;\n\n return {\n // Core identifiers\n id: placement.id,\n artworkId: placement.artworkId || placement.artwork_id,\n artworkUrl: artworkUrl, // Critical for displaying artwork\n\n // Position and dimensions (preserve as-is, could be pixels or percentages)\n left: placement.left,\n top: placement.top,\n width: placement.width,\n height: placement.height,\n\n // Rotation (preserve as-is, could be degrees or radians)\n rotation: placement.rotation || placement.rotate || 0,\n\n // Mirror flags (normalize to consistent naming)\n flipX: placement.flipX !== undefined ? placement.flipX :\n (placement.mirrorHorizontally || placement.mirror_horizontally || false),\n flipY: placement.flipY !== undefined ? placement.flipY :\n (placement.mirrorVertically || placement.mirror_vertically || false),\n\n // Preserve any additional properties\n ...(placement.backendId && { backendId: placement.backendId }),\n ...(placement.translate_x !== undefined && { translate_x: placement.translate_x }),\n ...(placement.translate_y !== undefined && { translate_y: placement.translate_y }),\n };\n }\n}\n", "import { Controller } from '../stimulus';\n\n/**\n * Decorator Canvas Orientation Image Controller\n *\n * Manages individual orientation/view images in the decorator modal.\n * Handles image selection and triggers canvas updates.\n *\n * Maintains baseline fidelity with the original implementation.\n */\nexport default class extends Controller {\n static targets = ['link'];\n\n initialize() {\n this.setupComplete = false;\n }\n\n connect() {\n // Template has no `data-init` attribute - matches baseline behavior\n if (!this.element.dataset.init) return false;\n\n if (!this.setupComplete) {\n // Get data from element dataset\n this.imageData = this.getDataFromElement();\n\n // Only process if we have required data\n if (!this.imageData.id) return;\n\n this.initBackground();\n this.setupComplete = true;\n }\n\n // Auto-select if marked as selected\n // Phase 4.385: Use RAF instead of 10ms setTimeout for faster orientation switch\n if (this.imageData.selected) {\n requestAnimationFrame(() => this.element.click());\n }\n }\n\n getDataFromElement() {\n const dataset = this.element.dataset;\n return {\n id: dataset.orientationId ? parseInt(dataset.orientationId) : null,\n largeUrl: dataset.orientationLargeUrl || '',\n thumbUrl: dataset.orientationThumbUrl || '',\n selected: dataset.orientationSelected === 'true',\n // Phase 4.302: Add original_url for decorator canvas coordinate consistency\n // The decorator canvas must use :original variant to match server baking dimensions.\n originalUrl: dataset.orientationOriginalUrl || '',\n };\n }\n\n initBackground() {\n if (this.hasLinkTarget && this.imageData.thumbUrl) {\n this.linkTarget.style.backgroundImage = `url(${this.imageData.thumbUrl})`;\n }\n }\n\n onClick(e) {\n e.preventDefault();\n\n // Update active state\n const siblings = this.element.parentElement.querySelectorAll('li');\n siblings.forEach(sibling => sibling.classList.remove('active'));\n this.element.classList.add('active');\n\n // Prepare image data for artboard\n // Phase 4.302: Use originalUrl for decorator canvas coordinate consistency\n // The decorator canvas must use :original variant to match server baking dimensions.\n // Using :large variant (688px) vs :original (768px) caused ~80px coordinate shift.\n // Fallback to largeUrl for backwards compatibility with existing data.\n const imageData = {\n id: this.imageData.id,\n url: this.imageData.originalUrl || this.imageData.largeUrl,\n index: Array.from(this.element.parentElement.children).indexOf(this.element),\n };\n\n // Trigger orientation selection event on document to ensure it reaches artboard\n const event = new CustomEvent('decorator:orientations:select', {\n detail: imageData,\n bubbles: true,\n });\n document.dispatchEvent(event);\n }\n}\n", "import { Controller } from '../stimulus';\n\n/**\n * Decorator Canvas Orientations Controller\n *\n * Manages the orientation/view gallery in the decorator modal.\n * Updates available orientations when a color variant is selected.\n *\n * Maintains baseline fidelity with the original implementation.\n */\nexport default class extends Controller {\n static targets = ['template', 'content'];\n\n initialize() {\n this.template = this.templateTarget.innerHTML;\n\n this.imageElements = {};\n this.currentImageElements = [];\n\n // Listen for color selection events\n this.onColorSelect = this.showImages.bind(this);\n document.addEventListener('decorator:colors:select', this.onColorSelect);\n }\n\n disconnect() {\n document.removeEventListener('decorator:colors:select', this.onColorSelect);\n }\n\n showImages(e) {\n const colorData = e.detail;\n const colorId = colorData.id;\n const orderedImageIds = colorData.orderedImageIds;\n const images = colorData.images;\n const selectedImageId = colorData.selectedImageId;\n\n\n // Clear any active states from previous orientations\n const activeOrientations = this.contentTarget.querySelectorAll('.active');\n activeOrientations.forEach(el => el.classList.remove('active'));\n\n this.detachCurrentImageElements();\n\n // Check cache first\n if (!this.imageElements[colorId]) {\n this.imageElements[colorId] = this.createImageElements(images, orderedImageIds, selectedImageId);\n }\n\n this.currentImageElements = this.imageElements[colorId];\n this.attachCurrentImageElements();\n\n // Phase 4.385: Preload adjacent orientation images for faster switching\n this.preloadOrientationImages(colorData);\n }\n\n // Phase 4.385: Preload first 2 orientation images for faster switching\n // This reduces perceived lag when user switches orientations by having\n // the next likely images already in browser cache.\n preloadOrientationImages(colorData) {\n const { images, orderedImageIds } = colorData;\n if (!images || !orderedImageIds) return;\n\n const preloadLimit = 2;\n\n orderedImageIds.slice(0, preloadLimit).forEach(id => {\n const image = images[String(id)];\n const url = image?.original_url || image?.large_url;\n if (url && !this.preloadedUrls?.has(url)) {\n this.preloadedUrls = this.preloadedUrls || new Set();\n this.preloadedUrls.add(url);\n // eslint-disable-next-line no-undef\n const preloader = new Image();\n preloader.src = url;\n }\n });\n }\n\n attachCurrentImageElements() {\n this.currentImageElements.forEach((element) => {\n this.contentTarget.appendChild(element);\n });\n\n // Auto-select first orientation image after attaching (baseline behavior)\n // This ensures the canvas updates when a color is selected\n if (this.currentImageElements.length > 0) {\n // Phase 4.385: Use RAF instead of 100ms setTimeout for faster orientation switch\n // RAF provides at least one frame (16ms) which is sufficient for Stimulus readiness\n requestAnimationFrame(() => {\n const firstImage = this.currentImageElements[0];\n if (firstImage) {\n // Find the click target (the anchor tag with click handler)\n const clickTarget = firstImage.querySelector('a') || firstImage;\n // Trigger click on the first orientation image\n clickTarget.click();\n }\n });\n }\n }\n\n detachCurrentImageElements() {\n this.currentImageElements.forEach((element) => {\n if (element.parentNode) {\n element.parentNode.removeChild(element);\n }\n });\n }\n\n createImageElements(images, orderedImageIds, selectedId) {\n const elements = [];\n\n // Handle missing orderedImageIds - default to empty array\n if (!orderedImageIds) {\n return elements;\n }\n\n orderedImageIds.forEach((id) => {\n // Convert to string for lookup: JSON serializes object keys as strings,\n // but array values (orderedImageIds) remain integers. Integer 123 \u2260 string \"123\".\n const image = images[String(id)];\n if (!image) {\n return;\n }\n\n // Create element from template\n const temp = document.createElement('div');\n temp.innerHTML = this.template.trim();\n const imgElement = temp.firstChild;\n\n // Set data attributes for the orientation image controller\n imgElement.setAttribute('data-controller', 'decorator-canvas-orientation-image');\n imgElement.setAttribute('data-init', 'true'); // This will trigger initialization - matches baseline\n imgElement.setAttribute('data-orientation-id', image.id);\n imgElement.setAttribute('data-orientation-large-url', image.large_url);\n imgElement.setAttribute('data-orientation-thumb-url', image.thumb_url);\n imgElement.setAttribute('data-orientation-selected', (parseInt(selectedId) === parseInt(image.id)));\n // Phase 4.302: Add original_url for decorator canvas coordinate consistency\n // The decorator canvas must use :original variant to match server baking dimensions.\n // Using :large variant (688px) vs :original (768px) causes ~80px coordinate shift.\n if (image.original_url) {\n imgElement.setAttribute('data-orientation-original-url', image.original_url);\n }\n\n elements.push(imgElement);\n });\n\n return elements;\n }\n}\n", "/**\n * Decorator Canvas Publish Controller\n *\n * Handles saving decoration data to the server. Works with the artboard controller\n * to collect canvas state and submit it to the backend.\n *\n * HTML Structure:\n * ```html\n *
    \n * \n *
    \n * ```\n *\n * Data Attributes:\n * - url: POST endpoint for saving decorations\n *\n * Actions:\n * - publish: Triggered by save button click\n *\n * Events Listened:\n * - (none directly - uses event to request data from artboard)\n *\n * Events Emitted:\n * - decorator:publish - Requests decoration data from artboard\n * - decorator:close - Closes modal after successful save\n *\n * Context Handling:\n * - Single Product: Saves decoration for one product/color variant\n * - Bulk Mode: Saves decoration for multiple products\n *\n * Integration:\n * - Reads context from decorator-modal data attributes\n * - Calls submitDecoration() on artboard controller response\n *\n * Example Response Handling:\n * ```javascript\n * // Success response with redirect\n * { redirect_url: \"/teamsites/123/products\" }\n *\n * // Error response\n * 400/500 with error message in response body\n * ```\n */\nimport { Controller } from '../stimulus';\nimport CSRFTokenMixin from '../mixins/csrf_token_mixin';\n\nexport default class extends Controller {\n connect() {\n Object.assign(this, CSRFTokenMixin);\n this.url = this.element.dataset.url;\n }\n\n publish(event) {\n event.preventDefault();\n\n // Trigger event to collect data from artboard\n const customEvent = new CustomEvent('decorator:publish', {\n bubbles: true,\n detail: { controller: this },\n });\n this.element.dispatchEvent(customEvent);\n }\n\n async submitDecoration(data) {\n try {\n // Add context-specific parameters\n const params = this.buildParams(data);\n\n const response = await fetch(this.url, {\n method: 'POST',\n headers: this.getHeadersWithCSRF({\n 'Content-Type': 'application/json',\n 'Accept': 'application/json',\n }),\n body: JSON.stringify(params),\n });\n\n if (response.ok) {\n // The controller returns a redirect, so follow it\n const result = await response.json();\n if (result.redirect_url) {\n window.location.href = result.redirect_url;\n } else {\n // If no redirect URL, try to parse the response for a flash message\n this.showSuccess('Decoration saved successfully');\n // Close modal after success\n this.closeModal();\n }\n } else {\n const error = await response.text();\n this.showError(error || 'Failed to save decoration');\n }\n } catch {\n this.showError('An error occurred while saving');\n }\n }\n\n buildParams(decorationData) {\n // Get context from modal controller\n const modalElement = this.element.closest('[data-decorator-modal-sale-id-value]');\n const context = modalElement?.dataset.decoratorModalContextValue || 'single_product';\n\n const params = { ...decorationData };\n\n // Add context-specific parameters\n if (context === 'single_product') {\n params.product_id = modalElement.dataset.decoratorModalProductIdValue;\n params.color_variant_id = modalElement.dataset.decoratorModalColorVariantIdValue;\n\n // Find the specific image ID for single product\n const imageId = this.findCurrentImageId();\n if (imageId) {\n params.image_id = imageId;\n }\n } else if (context === 'bulk') {\n params.bulk_options = {\n product_ids: JSON.parse(modalElement.dataset.decoratorModalProductIdsValue || '[]'),\n };\n params.is_bulk = true;\n }\n\n return params;\n }\n\n findCurrentImageId() {\n // Look for the current image ID in the artboard\n const artboard = document.querySelector('[data-decorator-board]');\n const contentElement = artboard?.querySelector('[data-image-id]');\n return contentElement?.dataset.imageId;\n }\n\n showSuccess(_message) {\n // Could integrate with flash messages or notification system\n }\n\n showError(message) {\n // Could integrate with flash messages or notification system\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash(message);\n } else {\n window.alert(message);\n }\n }\n\n closeModal() {\n // Find the close button or trigger close event\n const closeButton = this.element.closest('.ae-popup')?.querySelector('.mfp-close');\n if (closeButton) {\n closeButton.click();\n } else {\n // Dispatch close event\n const closeEvent = new CustomEvent('decorator:close', { bubbles: true });\n this.element.dispatchEvent(closeEvent);\n }\n }\n}\n", "/**\n * Decorator Canvas Template Controller\n *\n * Handles interactive controls for individual artwork placements on the decorator canvas.\n * This includes dragging, resizing, rotating, and other manipulation controls.\n *\n * HTML Structure (from template):\n * ```html\n *
    \n *
    \n * \n *
    \n *
    \n *
    \n *
    \n *
    \n *
    \n * \n *
    \n *
    \n *
    \n * ```\n *\n * Features:\n * - Drag artwork around the canvas\n * - Resize from 8 different handles (corners and sides)\n * - Rotate using the rotate control\n * - Delete artwork\n * - Mirror/flip horizontally and vertically\n *\n * Events Emitted:\n * - decorator:placement:updated - When placement position/size/rotation changes\n * - decorator:placement:deleted - When placement is deleted\n * - decorator:state:changed - For history tracking\n */\nimport { Controller } from '../stimulus';\nimport { throttleRAF, PerformanceMonitor } from '../utils/performance';\n\nexport default class extends Controller {\n connect() {\n this.isDragging = false;\n this.isResizing = false;\n this.isRotating = false;\n\n // Phase 4.234: Grace period to ignore phantom pointer events after element creation\n // In bulk mode, modal animation causes layout jitter (up to ~14px) that triggers\n // false drag detection and clears coordinatesLocked. This jitter varies depending\n // on timing, screen size, and animation state - too unpredictable to filter by threshold.\n // Solution: Ignore all pointer events for 500ms after controller connects.\n // Skip grace period in test environment to avoid interfering with event-based tests.\n // eslint-disable-next-line no-undef\n const isTestEnv = typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'test';\n if (isTestEnv) {\n this.inGracePeriod = false;\n } else {\n this.inGracePeriod = true;\n this.gracePeriodTimer = setTimeout(() => {\n this.inGracePeriod = false;\n }, 500);\n }\n\n // Performance monitoring\n this.perfMonitor = new PerformanceMonitor('DecoratorTemplate');\n\n // Get placement data from element\n this.placementId = this.element.dataset.artPlacementId;\n this.artworkId = this.element.dataset.artworkId;\n\n // Cache element references\n this.resizableElement = this.element.querySelector('.resizable');\n this.rotableContainer = this.element.querySelector('.rotable-container');\n this.rotateControl = this.element.querySelector('.rotable-indicator');\n\n // Setup event listeners\n this.setupDragging();\n this.setupResizing();\n this.setupRotating();\n\n // Store initial transform state\n this.rotation = 0;\n this.flipX = false;\n this.flipY = false;\n }\n\n disconnect() {\n // Phase 4.234: Clear grace period timer\n if (this.gracePeriodTimer) {\n clearTimeout(this.gracePeriodTimer);\n }\n\n // Clean up event listeners\n this.element.removeEventListener('pointerdown', this.handleDragStart);\n\n if (this.resizeHandlers) {\n this.resizeHandlers.forEach((handler, handle) => {\n handle.removeEventListener('pointerdown', handler);\n });\n }\n\n if (this.rotateControl) {\n this.rotateControl.removeEventListener('pointerdown', this.handleRotateStart);\n }\n\n // Phase 2 P0: Clean up document-level rotation event listeners (memory leak fix)\n if (this.throttledRotateMove) {\n document.removeEventListener('pointermove', this.throttledRotateMove);\n this.throttledRotateMove.cancel();\n }\n if (this.handleRotateEnd) {\n document.removeEventListener('pointerup', this.handleRotateEnd);\n document.removeEventListener('pointercancel', this.handleRotateEnd);\n }\n\n // Clean up throttled functions\n if (this.throttledDragMove) {\n this.throttledDragMove.cancel();\n }\n if (this.throttledResizeMove) {\n this.throttledResizeMove.cancel();\n }\n\n // Clear performance monitor\n this.perfMonitor.clear();\n }\n\n setupDragging() {\n // Make the element draggable\n this.handleDragStart = (e) => {\n // Phase 4.234: Ignore pointer events during grace period after element creation\n // This prevents phantom drags caused by modal animation jitter\n if (this.inGracePeriod) {\n return;\n }\n\n // Don't start drag if clicking on controls\n if (e.target.closest('.resizable-indicator') ||\n e.target.closest('.rotable') ||\n e.target.closest('.dz-trash') ||\n e.target.closest('.dz-more-options')) {\n return;\n }\n\n e.preventDefault();\n e.stopPropagation();\n this.startDrag(e);\n };\n\n this.element.addEventListener('pointerdown', this.handleDragStart);\n }\n\n setupResizing() {\n // Setup resize handles\n const resizeHandles = this.element.querySelectorAll('.resizable-indicator');\n this.resizeHandlers = new Map();\n\n resizeHandles.forEach(handle => {\n const direction = handle.dataset.direction;\n const handler = (e) => {\n // Phase 4.234: Ignore pointer events during grace period after element creation\n if (this.inGracePeriod) {\n return;\n }\n\n e.preventDefault();\n e.stopPropagation();\n this.startResize(e, direction);\n };\n\n this.resizeHandlers.set(handle, handler);\n handle.addEventListener('pointerdown', handler);\n });\n }\n\n setupRotating() {\n // Setup rotate control\n if (this.rotateControl) {\n this.handleRotateStart = (e) => {\n // Phase 4.234: Ignore pointer events during grace period after element creation\n if (this.inGracePeriod) {\n return;\n }\n\n e.preventDefault();\n e.stopPropagation();\n this.startRotate(e);\n };\n\n this.rotateControl.addEventListener('pointerdown', this.handleRotateStart);\n }\n }\n\n\n // Dragging implementation\n startDrag(e) {\n this.isDragging = true;\n this.element.classList.add('dragging');\n\n // Phase 4.254: Dispatch selection event for debug panel\n this.dispatchSelectionEvent();\n\n // Phase 4.13: Disable CSS transitions during drag for instant position updates\n // Prevents 400ms transition lag that makes artwork appear \"left behind\" during drag\n // Phase 4.17: Use !important to override CSS specificity and timing races on first render\n this.element.style.setProperty('transition', 'none', 'important');\n\n // Phase 4.14: Also disable transitions on control elements to prevent positioning issues\n // When artwork has rotation applied, child elements with transitions can become offset\n // during drag due to browser rendering race conditions between transform and position\n if (this.resizableElement) {\n this.resizableElement.style.setProperty('transition', 'none', 'important');\n const indicators = this.resizableElement.querySelectorAll('.resizable-indicator');\n indicators.forEach(indicator => {\n indicator.style.setProperty('transition', 'none', 'important');\n });\n }\n\n // Phase 4.15: Disable transitions on rotation control elements\n // The rotable container and its children also have 0.4s transitions that cause\n // offset artifacts during drag, especially on rotated artwork\n if (this.rotableContainer) {\n this.rotableContainer.style.setProperty('transition', 'none', 'important');\n\n // Disable transitions on all rotation control children\n const rotableElements = this.rotableContainer.querySelectorAll('.rotable, .rotable-indicator, .rotable-dashed-line');\n rotableElements.forEach(element => {\n element.style.setProperty('transition', 'none', 'important');\n });\n }\n\n // Phase 4.15: Disable transitions on .dz-details wrapper\n // This element has a 0.4s transition for border animation that causes positioning issues\n const dzDetails = this.element.querySelector('.dz-details');\n if (dzDetails) {\n dzDetails.style.setProperty('transition', 'none', 'important');\n\n // Phase 4.16: Disable transitions on .dz-artwork child element\n // The .dz-artwork displays actual image via background-image and inherits\n // transition from parent. Without disabling, image lags behind during drag\n // (especially noticeable on first modal render when CSS not fully cached)\n const dzArtwork = dzDetails.querySelector('.dz-artwork');\n if (dzArtwork) {\n dzArtwork.style.setProperty('transition', 'none', 'important');\n }\n }\n\n // Phase 4.77: Force CSS readiness for .dz-artwork on first drag\n // Problem: On first modal open, .dz-artwork is created after connect() runs\n // by ArtPlacement.createElement(), so CSS may not be fully parsed yet\n // Solution: Force layout calculation here, right before drag starts\n const dzArtworkForCss = this.element.querySelector('.dz-artwork');\n if (dzArtworkForCss) {\n void window.getComputedStyle(dzArtworkForCss).transform;\n }\n\n // Phase 4.83: Force compositing layer establishment on ALL transformed children\n // Problem: Phase 4.77 only forced layout on .dz-artwork, but .resizable and\n // .rotable-container have rotation transforms that create separate compositing layers.\n // On first modal open, browser hasn't established layer relationships yet, causing\n // artwork image to lag behind controls during drag.\n // Solution: Force browser to compute/cache transform styles for ALL transformed elements\n // before drag starts. This mimics the \"reopen\" state where browser has already cached\n // layer optimizations.\n if (this.resizableElement) {\n void window.getComputedStyle(this.resizableElement).transform;\n }\n if (this.rotableContainer) {\n void window.getComputedStyle(this.rotableContainer).transform;\n }\n\n // Phase 4.84: Save and clear child transforms to unify into single compositing layer\n // Problem: Phase 4.83 didn't fix lag because forcing layout doesn't change layer structure\n // Root cause: Parent has translate3d(), children have rotate() - separate layers don't\n // update atomically during RAF, causing artwork image to lag behind controls\n // Solution: Apply rotation to parent during drag so ALL descendants are in ONE layer\n if (this.resizableElement) {\n this.savedResizableTransform = this.resizableElement.style.transform;\n this.resizableElement.style.transform = '';\n }\n if (this.rotableContainer) {\n this.savedRotableTransform = this.rotableContainer.style.transform;\n this.rotableContainer.style.transform = '';\n }\n\n // Start performance monitoring\n this.perfMonitor.start('drag');\n\n // Phase 4.231: MOVED event dispatch to dragEnd() - only dispatch if actual movement occurred\n // This prevents phantom drags (pointerdown/pointerup with no movement) from corrupting coordinates\n // See Phase 4.231 in plan for root cause analysis\n\n // Get initial positions\n const rect = this.element.getBoundingClientRect();\n const parentRect = this.element.parentElement.getBoundingClientRect();\n\n this.dragStartX = e.clientX;\n this.dragStartY = e.clientY;\n\n // Use computed styles as fallback if getBoundingClientRect returns 0 (e.g., in tests)\n if (rect.left === 0 && rect.top === 0 && rect.width === 0 && rect.height === 0) {\n this.elementStartX = parseFloat(this.element.style.left) || 0;\n this.elementStartY = parseFloat(this.element.style.top) || 0;\n } else {\n this.elementStartX = rect.left - parentRect.left;\n this.elementStartY = rect.top - parentRect.top;\n }\n\n // Cache parent dimensions for better performance\n this.parentWidth = parentRect.width || parseFloat(this.element.parentElement.style.width) || 800;\n this.parentHeight = parentRect.height || parseFloat(this.element.parentElement.style.height) || 600;\n\n // Phase 4.78: Switch to transform-based positioning during drag\n // Problem: left/top updates don't propagate to deeply nested elements without GPU layers\n // Solution: Use transform: translate() which updates entire compositing layer atomically\n this.initialLeft = parseFloat(this.element.style.left) || 0;\n this.initialTop = parseFloat(this.element.style.top) || 0;\n\n // Clear left/top and use transform for positioning during drag\n this.element.style.left = '0px';\n this.element.style.top = '0px';\n this.element.style.transform = `translate3d(${this.initialLeft}px, ${this.initialTop}px, 0)`;\n\n // Create throttled drag move handler\n this.throttledDragMove = throttleRAF(this.dragMove.bind(this));\n this.handleDragEnd = this.dragEnd.bind(this);\n\n // Add document-level event listeners\n document.addEventListener('pointermove', this.throttledDragMove);\n document.addEventListener('pointerup', this.handleDragEnd);\n document.addEventListener('pointercancel', this.handleDragEnd);\n\n // Prevent text selection\n document.body.style.userSelect = 'none';\n }\n\n dragMove(e) {\n if (!this.isDragging) return;\n\n const deltaX = e.clientX - this.dragStartX;\n const deltaY = e.clientY - this.dragStartY;\n\n // Phase 4.78: Calculate new position based on initial position + delta\n let newX = this.initialLeft + deltaX;\n let newY = this.initialTop + deltaY;\n\n // Use cached dimensions instead of querying DOM\n const elementWidth = parseFloat(this.element.style.width) || 0;\n const elementHeight = parseFloat(this.element.style.height) || 0;\n\n // Apply boundary constraints using cached parent dimensions\n newX = Math.max(0, Math.min(newX, this.parentWidth - elementWidth));\n newY = Math.max(0, Math.min(newY, this.parentHeight - elementHeight));\n\n // Phase 4.78: Use transform for atomic GPU updates instead of left/top\n // Transform updates propagate to all nested elements atomically\n // Phase 4.84: Include rotation in parent transform so all descendants are in ONE layer\n const rotateStr = this.rotation !== 0 ? ` rotate(${this.rotation}deg)` : '';\n this.element.style.transform = `translate3d(${newX}px, ${newY}px, 0)${rotateStr}`;\n\n // Phase 4.81: Track last position for fallback in dragEnd\n this.lastDragX = newX;\n this.lastDragY = newY;\n }\n\n dragEnd() {\n if (!this.isDragging) return;\n\n this.isDragging = false;\n this.element.classList.remove('dragging');\n\n // Phase 4.78: Convert transform back to left/top for persistence\n // Parse the translate values from the transform string\n // Phase 4.82: Updated regex to handle browser-normalized \"0px\" (not just \"0\")\n const transform = this.element.style.transform;\n const match = transform.match(/translate3d\\(([^,]+)px,\\s*([^,]+)px,\\s*0(?:px)?\\)/);\n if (match) {\n this.element.style.left = `${parseFloat(match[1])}px`;\n this.element.style.top = `${parseFloat(match[2])}px`;\n } else {\n // Phase 4.81: Fallback when transform parsing fails (browser normalization, race condition)\n // Use last known position from dragMove, or initial position as last resort\n console.warn('[Decorator] Transform parse failed, using fallback position', {\n transform,\n lastDragX: this.lastDragX,\n lastDragY: this.lastDragY,\n initialLeft: this.initialLeft,\n initialTop: this.initialTop,\n });\n const fallbackX = this.lastDragX !== undefined ? this.lastDragX : this.initialLeft;\n const fallbackY = this.lastDragY !== undefined ? this.lastDragY : this.initialTop;\n this.element.style.left = `${fallbackX}px`;\n this.element.style.top = `${fallbackY}px`;\n }\n this.element.style.transform = '';\n\n // Phase 4.84: Restore child transforms after drag ends\n // During drag, rotation was applied to parent for unified compositing layer\n // Now restore rotation to children for normal rendering\n if (this.resizableElement) {\n this.resizableElement.style.transform = this.savedResizableTransform || '';\n }\n if (this.rotableContainer) {\n this.rotableContainer.style.transform = this.savedRotableTransform || '';\n }\n this.savedResizableTransform = undefined;\n this.savedRotableTransform = undefined;\n\n // Phase 4.231: Check for actual movement BEFORE clearing tracking variables\n // This prevents phantom drags (pointerdown/pointerup with no movement) from corrupting coordinates\n // Uses existing tracking: initialLeft/initialTop (set in startDrag) vs lastDragX/lastDragY (set in dragMove)\n // For phantom drags (no movement), lastDragX/lastDragY remain undefined because dragMove never fires\n //\n // Phase 4.233: Increase threshold from 1px to 5px to filter browser coordinate jitter\n // In bulk mode, modal animation causes ~2-3px of apparent movement during element creation.\n // This jitter triggers false-positive movement detection and clears coordinatesLocked.\n // 5px threshold filters jitter while still detecting intentional user drags.\n const MOVEMENT_THRESHOLD = 5;\n const hasMoved = this.lastDragX !== undefined && this.lastDragY !== undefined &&\n (Math.abs(this.lastDragX - this.initialLeft) > MOVEMENT_THRESHOLD ||\n Math.abs(this.lastDragY - this.initialTop) > MOVEMENT_THRESHOLD);\n\n // Phase 4.232: Diagnostic logging to understand why coordinatesLocked is being cleared\n\n // Phase 4.81: Clear tracking variables\n this.lastDragX = undefined;\n this.lastDragY = undefined;\n\n // End performance monitoring\n this.perfMonitor.end('drag');\n\n // Cancel any pending throttled calls\n if (this.throttledDragMove) {\n this.throttledDragMove.cancel();\n }\n\n // Remove document-level event listeners\n document.removeEventListener('pointermove', this.throttledDragMove);\n document.removeEventListener('pointerup', this.handleDragEnd);\n document.removeEventListener('pointercancel', this.handleDragEnd);\n\n // Restore text selection\n document.body.style.userSelect = '';\n\n // Phase 4.13: Re-enable CSS transitions after drag completes\n // Restores hover effects and border animations\n // Phase 4.17: Use removeProperty() to properly clean up !important inline styles\n this.element.style.removeProperty('transition');\n\n // Phase 4.14: Re-enable transitions on control elements\n if (this.resizableElement) {\n this.resizableElement.style.removeProperty('transition');\n const indicators = this.resizableElement.querySelectorAll('.resizable-indicator');\n indicators.forEach(indicator => {\n indicator.style.removeProperty('transition');\n });\n }\n\n // Phase 4.15: Re-enable transitions on rotation control elements\n if (this.rotableContainer) {\n this.rotableContainer.style.removeProperty('transition');\n\n const rotableElements = this.rotableContainer.querySelectorAll('.rotable, .rotable-indicator, .rotable-dashed-line');\n rotableElements.forEach(element => {\n element.style.removeProperty('transition');\n });\n }\n\n // Phase 4.15: Re-enable transitions on .dz-details wrapper\n const dzDetails = this.element.querySelector('.dz-details');\n if (dzDetails) {\n dzDetails.style.removeProperty('transition');\n\n // Phase 4.16: Re-enable transitions on .dz-artwork child element\n const dzArtwork = dzDetails.querySelector('.dz-artwork');\n if (dzArtwork) {\n dzArtwork.style.removeProperty('transition');\n }\n }\n\n // Phase 4.231: Only clear coordinatesLocked if user actually moved the artwork\n // hasMoved was calculated earlier (before tracking variables were cleared)\n if (hasMoved) {\n // Phase 4.232: Log when event is dispatched\n document.dispatchEvent(new CustomEvent('decorator:placement:dragstart', {\n bubbles: false,\n detail: { placementId: this.placementId },\n }));\n }\n\n // Emit move event for history\n this.emitHistoryEvent('decorator:artwork:move');\n\n // Emit update event\n this.emitUpdate();\n }\n\n // Resizing implementation\n startResize(e, direction) {\n this.isResizing = true;\n this.resizeDirection = direction;\n this.element.classList.add('resizing');\n\n // Start performance monitoring\n this.perfMonitor.start('resize');\n\n // Phase 4.231: MOVED event dispatch to resizeEnd() - only dispatch if actual resize occurred\n // This prevents phantom resizes from corrupting coordinates (same fix as startDrag)\n\n // Get initial dimensions\n const rect = this.element.getBoundingClientRect();\n this.resizeStartX = e.clientX;\n this.resizeStartY = e.clientY;\n\n // Use style dimensions as fallback if getBoundingClientRect returns 0\n this.startWidth = rect.width || parseFloat(this.element.style.width) || 0;\n this.startHeight = rect.height || parseFloat(this.element.style.height) || 0;\n this.startLeft = parseFloat(this.element.style.left) || 0;\n this.startTop = parseFloat(this.element.style.top) || 0;\n\n // Calculate aspect ratio\n this.aspectRatio = this.startWidth / this.startHeight;\n\n // Cache parent dimensions for constraint checking\n const parentRect = this.element.parentElement.getBoundingClientRect();\n this.parentWidth = parentRect.width || parseFloat(this.element.parentElement.style.width) || 800;\n this.parentHeight = parentRect.height || parseFloat(this.element.parentElement.style.height) || 600;\n\n // Create throttled resize move handler\n this.throttledResizeMove = throttleRAF(this.resizeMove.bind(this));\n this.handleResizeEnd = this.resizeEnd.bind(this);\n\n // Add document-level event listeners\n document.addEventListener('pointermove', this.throttledResizeMove);\n document.addEventListener('pointerup', this.handleResizeEnd);\n document.addEventListener('pointercancel', this.handleResizeEnd);\n\n // Prevent text selection\n document.body.style.userSelect = 'none';\n }\n\n resizeMove(e) {\n if (!this.isResizing) return;\n\n const deltaX = e.clientX - this.resizeStartX;\n const deltaY = e.clientY - this.resizeStartY;\n\n let newWidth = this.startWidth;\n let newHeight = this.startHeight;\n let newLeft = this.startLeft;\n let newTop = this.startTop;\n\n // Handle resize based on direction\n switch (this.resizeDirection) {\n case 'e': // East\n newWidth = this.startWidth + deltaX;\n break;\n case 'w': // West\n newWidth = this.startWidth - deltaX;\n newLeft = this.startLeft + deltaX;\n break;\n case 's': // South\n newHeight = this.startHeight + deltaY;\n break;\n case 'n': // North\n newHeight = this.startHeight - deltaY;\n newTop = this.startTop + deltaY;\n break;\n case 'se': // Southeast\n newWidth = this.startWidth + deltaX;\n newHeight = this.startHeight + deltaY;\n break;\n case 'sw': // Southwest\n newWidth = this.startWidth - deltaX;\n newHeight = this.startHeight + deltaY;\n newLeft = this.startLeft + deltaX;\n break;\n case 'ne': // Northeast\n newWidth = this.startWidth + deltaX;\n newHeight = this.startHeight - deltaY;\n newTop = this.startTop + deltaY;\n break;\n case 'nw': // Northwest\n newWidth = this.startWidth - deltaX;\n newHeight = this.startHeight - deltaY;\n newLeft = this.startLeft + deltaX;\n newTop = this.startTop + deltaY;\n break;\n }\n\n // Maintain aspect ratio for corner handles if shift is held\n if (e.shiftKey && ['se', 'sw', 'ne', 'nw'].includes(this.resizeDirection)) {\n if (Math.abs(deltaX) > Math.abs(deltaY)) {\n newHeight = newWidth / this.aspectRatio;\n } else {\n newWidth = newHeight * this.aspectRatio;\n }\n }\n\n // Apply minimum size constraints\n newWidth = Math.max(50, newWidth);\n newHeight = Math.max(50, newHeight);\n\n // Apply boundary constraints using cached dimensions\n\n if (newLeft < 0) {\n newWidth += newLeft;\n newLeft = 0;\n }\n if (newTop < 0) {\n newHeight += newTop;\n newTop = 0;\n }\n if (newLeft + newWidth > this.parentWidth) {\n newWidth = this.parentWidth - newLeft;\n }\n if (newTop + newHeight > this.parentHeight) {\n newHeight = this.parentHeight - newTop;\n }\n\n // Update dimensions and position\n this.element.style.width = `${newWidth}px`;\n this.element.style.height = `${newHeight}px`;\n this.element.style.left = `${newLeft}px`;\n this.element.style.top = `${newTop}px`;\n\n // Phase 4.231: Track last resize dimensions for movement detection in resizeEnd\n this.lastResizeWidth = newWidth;\n this.lastResizeHeight = newHeight;\n }\n\n resizeEnd() {\n if (!this.isResizing) return;\n\n this.isResizing = false;\n this.element.classList.remove('resizing');\n\n // End performance monitoring\n this.perfMonitor.end('resize');\n\n // Cancel any pending throttled calls\n if (this.throttledResizeMove) {\n this.throttledResizeMove.cancel();\n }\n\n // Remove document-level event listeners\n document.removeEventListener('pointermove', this.throttledResizeMove);\n document.removeEventListener('pointerup', this.handleResizeEnd);\n document.removeEventListener('pointercancel', this.handleResizeEnd);\n\n // Restore text selection\n document.body.style.userSelect = '';\n\n // Phase 4.231: Only clear coordinatesLocked if user actually resized\n // This prevents phantom resizes (pointerdown/pointerup with no movement) from corrupting coordinates\n // Uses tracking: startWidth/startHeight (set in startResize) vs lastResizeWidth/lastResizeHeight (set in resizeMove)\n // For phantom resizes (no movement), lastResizeWidth/lastResizeHeight remain undefined\n //\n // Phase 4.233: Increase threshold from 1px to 5px to filter browser coordinate jitter\n // Same reasoning as drag threshold - modal animation can cause apparent size changes.\n const RESIZE_THRESHOLD = 5;\n const hasResized = this.lastResizeWidth !== undefined && this.lastResizeHeight !== undefined &&\n (Math.abs(this.lastResizeWidth - this.startWidth) > RESIZE_THRESHOLD ||\n Math.abs(this.lastResizeHeight - this.startHeight) > RESIZE_THRESHOLD);\n\n // Phase 4.232: Diagnostic logging for resize\n\n if (hasResized) {\n // Phase 4.232: Log when event is dispatched\n document.dispatchEvent(new CustomEvent('decorator:placement:dragstart', {\n bubbles: false,\n detail: { placementId: this.placementId },\n }));\n }\n\n // Emit resize event for history\n this.emitHistoryEvent('decorator:artwork:resize');\n\n // Emit update event\n this.emitUpdate();\n }\n\n // Rotating implementation\n startRotate(e) {\n this.isRotating = true;\n this.element.classList.add('rotating');\n\n // Start performance monitoring\n this.perfMonitor.start('rotate');\n\n // Get center of element\n const rect = this.element.getBoundingClientRect();\n this.centerX = rect.left + rect.width / 2;\n this.centerY = rect.top + rect.height / 2;\n\n // Get initial angle\n this.startAngle = this.getAngle(e.clientX, e.clientY);\n this.startRotation = this.rotation;\n\n // Create throttled rotate move handler\n this.throttledRotateMove = throttleRAF(this.rotateMove.bind(this));\n this.handleRotateEnd = this.rotateEnd.bind(this);\n\n // Add document-level event listeners\n document.addEventListener('pointermove', this.throttledRotateMove);\n document.addEventListener('pointerup', this.handleRotateEnd);\n document.addEventListener('pointercancel', this.handleRotateEnd);\n\n // Prevent text selection\n document.body.style.userSelect = 'none';\n }\n\n rotateMove(e) {\n if (!this.isRotating) return;\n\n const currentAngle = this.getAngle(e.clientX, e.clientY);\n let deltaAngle = currentAngle - this.startAngle;\n\n // Normalize angle difference\n if (deltaAngle > 180) deltaAngle -= 360;\n if (deltaAngle < -180) deltaAngle += 360;\n\n this.rotation = this.startRotation + deltaAngle;\n\n // Snap to 15-degree increments if shift is held\n if (e.shiftKey) {\n this.rotation = Math.round(this.rotation / 15) * 15;\n }\n\n // Apply rotation\n this.updateTransform();\n }\n\n rotateEnd() {\n if (!this.isRotating) return;\n\n this.isRotating = false;\n this.element.classList.remove('rotating');\n\n // End performance monitoring\n this.perfMonitor.end('rotate');\n\n // Cancel any pending throttled calls\n if (this.throttledRotateMove) {\n this.throttledRotateMove.cancel();\n }\n\n // Remove document-level event listeners\n document.removeEventListener('pointermove', this.throttledRotateMove);\n document.removeEventListener('pointerup', this.handleRotateEnd);\n document.removeEventListener('pointercancel', this.handleRotateEnd);\n\n // Restore text selection\n document.body.style.userSelect = '';\n\n // Emit rotate event for history\n this.emitHistoryEvent('decorator:artwork:rotate');\n\n // Emit update event\n this.emitUpdate();\n }\n\n getAngle(x, y) {\n const dx = x - this.centerX;\n const dy = y - this.centerY;\n return Math.atan2(dy, dx) * (180 / Math.PI);\n }\n\n // Mirror/flip functionality\n mirrorHorizontal() {\n this.flipX = !this.flipX;\n this.updateTransform();\n\n // Emit mirror event for history\n this.emitHistoryEvent('decorator:artwork:mirror');\n\n this.emitUpdate();\n }\n\n mirrorVertical() {\n this.flipY = !this.flipY;\n this.updateTransform();\n\n // Emit mirror event for history\n this.emitHistoryEvent('decorator:artwork:mirror');\n\n this.emitUpdate();\n }\n\n // Update element transform\n updateTransform() {\n // Apply rotation only to internal elements, matching baseline behavior\n // Main element should NOT have transform applied\n\n if (this.resizableElement) {\n this.resizableElement.style.transform = this.rotation !== 0 ? `rotate(${this.rotation}deg)` : '';\n }\n if (this.rotableContainer) {\n this.rotableContainer.style.transform = this.rotation !== 0 ? `rotate(${this.rotation}deg)` : '';\n }\n\n // Handle mirroring on the artwork element itself\n const artworkElement = this.element.querySelector('.dz-artwork');\n if (artworkElement) {\n const transforms = [];\n if (this.flipX) {\n transforms.push('scaleX(-1)');\n }\n if (this.flipY) {\n transforms.push('scaleY(-1)');\n }\n artworkElement.style.transform = transforms.join(' ');\n }\n }\n\n // Delete placement\n delete() {\n if (confirm('Remove this artwork from the canvas?')) {\n // Emit remove event for history\n this.emitHistoryEvent('decorator:artwork:remove');\n\n // Phase 4.89: Dispatch to document for reliable dirty state tracking across reconnections\n document.dispatchEvent(new CustomEvent('decorator:placement:deleted', {\n bubbles: false, // No bubbling needed when targeting document\n detail: {\n placementId: this.placementId,\n artworkId: this.artworkId,\n },\n }));\n\n // Remove element\n this.element.remove();\n }\n }\n\n // More info - placeholder for baseline compatibility\n moreInfo() {\n // TODO: Implement more info functionality\n // More info functionality - placeholder for baseline compatibility\n }\n\n // Clone artwork - matches baseline implementation\n clone() {\n const currentLeft = parseFloat(this.element.style.left) || 0;\n const currentTop = parseFloat(this.element.style.top) || 0;\n const currentWidth = parseFloat(this.element.style.width) || 0;\n const currentHeight = parseFloat(this.element.style.height) || 0;\n\n // Get the artwork image URL from the current element\n const artworkDiv = this.element.querySelector('.dz-artwork');\n let imageUrl = '';\n if (artworkDiv) {\n const bgImage = artworkDiv.style.backgroundImage;\n // Extract URL from background-image: url('...')\n const match = bgImage.match(/url\\(['\"]?(.+?)['\"]?\\)/);\n if (match) {\n imageUrl = match[1];\n }\n }\n\n // Emit clone event for history\n this.emitHistoryEvent('decorator:artwork:clone');\n\n // Phase 4.89: Dispatch to document for reliable dirty state tracking across reconnections\n // Position clone to the right (+width) and halfway down (+height/2) per baseline\n document.dispatchEvent(new CustomEvent('decorator:placement:clone', {\n bubbles: false, // No bubbling needed when targeting document\n detail: {\n placementId: null, // null per baseline\n artworkId: this.artworkId,\n artworkUrl: imageUrl,\n position: {\n left: currentLeft + currentWidth,\n top: currentTop + (currentHeight / 2),\n },\n width: currentWidth,\n height: currentHeight,\n rotation: this.rotation,\n flipX: this.flipX,\n flipY: this.flipY,\n },\n }));\n }\n\n // Emit update event with current placement data\n emitUpdate() {\n // Phase 4.157: ALWAYS use style values for position to ensure round-trip consistency\n // getBoundingClientRect() returns position from border edge, which differs from\n // style.top/left that set position from content edge. This 3-4px discrepancy\n // causes coordinate drift on save (vertical shift bug).\n // Width/height still use getBoundingClientRect for accurate rendered dimensions.\n const rect = this.element.getBoundingClientRect();\n\n const placementData = {\n placementId: this.placementId,\n artworkId: this.artworkId,\n left: parseFloat(this.element.style.left) || 0,\n top: parseFloat(this.element.style.top) || 0,\n width: rect.width || parseFloat(this.element.style.width) || 0,\n height: rect.height || parseFloat(this.element.style.height) || 0,\n rotation: this.rotation,\n flipX: this.flipX,\n flipY: this.flipY,\n };\n\n // Phase 4.89: Dispatch to document for reliable dirty state tracking across reconnections\n document.dispatchEvent(new CustomEvent('decorator:placement:updated', {\n bubbles: false, // No bubbling needed when targeting document\n detail: placementData,\n }));\n }\n\n // Phase 4.254: Dispatch selection event for debug panel\n dispatchSelectionEvent() {\n const rect = this.element.getBoundingClientRect();\n\n document.dispatchEvent(new CustomEvent('decorator:debug:placement-selected', {\n bubbles: false,\n detail: {\n placementId: this.placementId,\n artworkId: this.artworkId,\n // Pixel values (for reference)\n leftPx: parseFloat(this.element.style.left) || 0,\n topPx: parseFloat(this.element.style.top) || 0,\n widthPx: rect.width || parseFloat(this.element.style.width) || 0,\n heightPx: rect.height || parseFloat(this.element.style.height) || 0,\n rotation: this.rotation,\n flipX: this.flipX,\n flipY: this.flipY,\n },\n }));\n }\n\n // Emit history event with placement data\n emitHistoryEvent(eventName) {\n // Phase 4.157: ALWAYS use style values for position (same fix as emitUpdate)\n const rect = this.element.getBoundingClientRect();\n\n const placementData = {\n id: this.placementId,\n artworkId: this.artworkId,\n left: parseFloat(this.element.style.left) || 0,\n top: parseFloat(this.element.style.top) || 0,\n width: rect.width || parseFloat(this.element.style.width) || 0,\n height: rect.height || parseFloat(this.element.style.height) || 0,\n rotation: this.rotation,\n flipX: this.flipX,\n flipY: this.flipY,\n };\n\n // Emit the specific history event\n document.dispatchEvent(new CustomEvent(eventName, {\n bubbles: true,\n detail: {\n placementId: this.placementId,\n changes: placementData,\n },\n }));\n }\n\n // Phase 4.73: Helper to calculate DOM depth of an element relative to an ancestor\n // Used for debugging GPU layer compositing issues\n getElementDepth(element, ancestor) {\n let depth = 0;\n let current = element;\n while (current && current !== ancestor) {\n depth++;\n current = current.parentElement;\n }\n return current === ancestor ? depth : -1;\n }\n}\n", "/* eslint-disable no-console */\nimport { Controller } from '../stimulus';\n\n/**\n * DecoratorDebugPanelController - Visual debugging tool for coordinate transforms\n *\n * Phase 4.251: Provides real-time display of coordinate transform values to help\n * diagnose coordinate shifts in bulk decoration mode.\n *\n * Toggle: Ctrl+Shift+D or click the toggle button\n * Shows: boardDimensions, bgStart, scalingFactor, isVerticalFit, coordinates\n *\n * Usage:\n * Include the _decorator_debug_panel partial in the decorator canvas template.\n * The panel listens for custom events dispatched by the artboard controller.\n */\nexport default class extends Controller {\n static targets = [\n 'panel',\n 'mode',\n 'boardWidth',\n 'boardHeight',\n 'bgStartX',\n 'bgStartY',\n 'scalingFactor',\n 'isVerticalFit',\n 'dropTimeIsVerticalFit',\n 'isVerticalFitMatch',\n 'imageWidth',\n 'imageHeight',\n 'placementId',\n 'placementTop',\n 'placementLeft',\n 'placementXPx',\n 'placementYPx',\n 'placementWidth',\n 'placementHeight',\n 'dropTimeTop',\n 'dropTimeLeft',\n 'coordMatch',\n 'lastEvent',\n // Phase 4.254: All placements tracking\n 'placementCount',\n 'placementsList',\n // Phase 4.257: Snapshot comparison\n 'snapshotButton',\n 'snapshotTimestamp',\n 'comparisonList',\n // Phase 4.257: Server round-trip\n 'sentData',\n 'receivedData',\n 'roundTripMatch',\n // Phase 4.271: Drop-time dimension comparison\n 'dropTimeBoardWidth',\n 'dropTimeBoardHeight',\n 'boardDimsMatch',\n 'dropTimeBgStartX',\n 'dropTimeBgStartY',\n 'bgStartMatch',\n 'dropTimeScalingFactor',\n 'scalingFactorMatch',\n // Phase 4.277: DOM State tracking\n 'cssTransform',\n 'translateX',\n 'translateY',\n 'offsetLeft',\n 'offsetTop',\n 'bcrX',\n 'bcrY',\n 'expectedX',\n 'expectedY',\n 'visualDelta',\n // Phase 4.403: Modal Layout tracking\n 'artboardContainerTop',\n 'artboardContainerHeight',\n 'artboardTop',\n 'artboardHeight',\n 'artboardOffset',\n 'popupContentWidth',\n 'popupContentHeight',\n 'leftPaneWidth',\n 'leftPaneHeight',\n 'dropzoneTop',\n 'dropzoneLeft',\n // Phase 4.404: Extended Layout tracking\n 'modalTop',\n 'modalHeight',\n 'headingHeight',\n 'dropzoneFormTop',\n 'dropzoneFormHeight',\n ];\n\n connect() {\n // Bind event handlers for cleanup\n this.boundUpdateCanvasState = this.updateCanvasState.bind(this);\n this.boundUpdateTransform = this.updateTransform.bind(this);\n this.boundUpdatePlacement = this.updatePlacement.bind(this);\n this.boundHandleDrop = this.handleDrop.bind(this);\n this.boundHandleKeydown = this.handleKeydown.bind(this);\n // Phase 4.254: All placements tracking\n this.boundUpdateAllPlacements = this.updateAllPlacements.bind(this);\n // Phase 4.257: Snapshot and round-trip tracking\n this.boundHandleSavePayload = this.handleSavePayload.bind(this);\n this.boundHandleLoadPayload = this.handleLoadPayload.bind(this);\n // Phase 4.277: DOM state tracking\n this.boundUpdateDomState = this.updateDomState.bind(this);\n\n // Phase 4.252: Track drop-time isVerticalFit for comparison\n this.dropTimeIsVerticalFitValue = null;\n\n // Phase 4.271: Track drop-time dimensions for comparison\n this.dropTimeBoardDimensions = null;\n this.dropTimeBgStart = null;\n this.dropTimeScalingFactor = null;\n\n // Phase 4.254: Track selected placement ID for highlighting\n this.selectedPlacementId = null;\n\n // Phase 4.257: Snapshot and round-trip data\n this.snapshotData = null;\n this.lastSentPayload = null;\n this.lastReceivedPayload = null;\n this.lastPlacementsData = null;\n\n // Listen for debug events from artboard controller\n document.addEventListener('decorator:debug:canvas-ready', this.boundUpdateCanvasState);\n document.addEventListener('decorator:debug:transform', this.boundUpdateTransform);\n document.addEventListener('decorator:debug:placement-selected', this.boundUpdatePlacement);\n document.addEventListener('decorator:debug:placement-drop', this.boundHandleDrop);\n // Phase 4.254: Listen for placements list updates\n document.addEventListener('decorator:debug:placements-changed', this.boundUpdateAllPlacements);\n // Phase 4.257: Listen for save/load payload events\n document.addEventListener('decorator:debug:save-payload', this.boundHandleSavePayload);\n document.addEventListener('decorator:debug:load-payload', this.boundHandleLoadPayload);\n // Phase 4.277: Listen for DOM state events\n document.addEventListener('decorator:debug:dom-state', this.boundUpdateDomState);\n\n // Keyboard shortcut: Ctrl+Shift+D\n document.addEventListener('keydown', this.boundHandleKeydown);\n\n // Check URL param for auto-show\n if (this.shouldAutoShow()) {\n this.show();\n }\n\n // Phase 4.257: Request initial placements after a short delay\n // This handles the case where the panel connects after placements are loaded\n setTimeout(() => {\n document.dispatchEvent(new CustomEvent('decorator:debug:request-placements', {\n bubbles: false,\n }));\n }, 300);\n\n console.log('[Phase 4.257] Debug panel connected. Press Ctrl+Shift+D to toggle.');\n }\n\n disconnect() {\n // Clean up event listeners\n document.removeEventListener('decorator:debug:canvas-ready', this.boundUpdateCanvasState);\n document.removeEventListener('decorator:debug:transform', this.boundUpdateTransform);\n document.removeEventListener('decorator:debug:placement-selected', this.boundUpdatePlacement);\n document.removeEventListener('decorator:debug:placement-drop', this.boundHandleDrop);\n document.removeEventListener('keydown', this.boundHandleKeydown);\n // Phase 4.254: Clean up placements listener\n document.removeEventListener('decorator:debug:placements-changed', this.boundUpdateAllPlacements);\n // Phase 4.257: Clean up save/load payload listeners\n document.removeEventListener('decorator:debug:save-payload', this.boundHandleSavePayload);\n document.removeEventListener('decorator:debug:load-payload', this.boundHandleLoadPayload);\n // Phase 4.277: Clean up DOM state listener\n document.removeEventListener('decorator:debug:dom-state', this.boundUpdateDomState);\n }\n\n // Check if panel should auto-show based on URL param\n shouldAutoShow() {\n const params = new URLSearchParams(window.location.search);\n return params.get('debug') === 'canvas' || params.get('debug') === 'decorator';\n }\n\n // Keyboard shortcut handler\n handleKeydown(event) {\n // Ctrl+Shift+D (or Cmd+Shift+D on Mac)\n if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key === 'D') {\n event.preventDefault();\n this.toggle();\n }\n }\n\n // Toggle panel visibility\n toggle() {\n if (this.hasPanelTarget) {\n this.panelTarget.classList.toggle('ae-hidden');\n const isVisible = !this.panelTarget.classList.contains('ae-hidden');\n console.log(`[Phase 4.251] Debug panel ${isVisible ? 'shown' : 'hidden'}`);\n }\n }\n\n // Show panel\n show() {\n if (this.hasPanelTarget) {\n this.panelTarget.classList.remove('ae-hidden');\n }\n }\n\n // Hide panel\n hide() {\n if (this.hasPanelTarget) {\n this.panelTarget.classList.add('ae-hidden');\n }\n }\n\n // Update canvas state section\n updateCanvasState(event) {\n const { mode, boardDimensions, isVerticalFit, scalingFactor, imageNaturalHeight, imageNaturalWidth } = event.detail;\n\n this.safeSetText('mode', mode || '-');\n this.safeSetText('boardWidth', boardDimensions?.width?.toFixed(0) || '-');\n this.safeSetText('boardHeight', boardDimensions?.height?.toFixed(0) || '-');\n this.safeSetText('isVerticalFit', isVerticalFit ? 'true' : 'false');\n this.safeSetText('scalingFactor', scalingFactor?.toFixed(4) || '-');\n this.safeSetText('imageWidth', imageNaturalWidth?.toFixed(0) || '-');\n this.safeSetText('imageHeight', imageNaturalHeight?.toFixed(0) || '-');\n\n // Phase 4.403: Auto-capture modal layout when canvas initializes\n // This provides automatic comparison data for bulk vs single mode\n setTimeout(() => {\n console.log('[Phase 4.403] Auto-capturing modal layout on canvas-ready...');\n this.captureLayout();\n }, 100); // Small delay to ensure layout is stable\n\n this.updateLastEvent('canvas-ready');\n }\n\n // Update transform context section\n updateTransform(event) {\n const {\n mode,\n boardDimensions,\n bgStart,\n scalingFactor,\n isVerticalFit,\n imageNaturalHeight,\n imageNaturalWidth,\n placementId,\n inputTop,\n inputLeft,\n } = event.detail;\n\n // Canvas state\n this.safeSetText('mode', mode || '-');\n this.safeSetText('boardWidth', boardDimensions?.width?.toFixed(0) || '-');\n this.safeSetText('boardHeight', boardDimensions?.height?.toFixed(0) || '-');\n this.safeSetText('isVerticalFit', isVerticalFit ? 'true' : 'false');\n this.safeSetText('scalingFactor', scalingFactor?.toFixed(4) || '-');\n\n // Transform context\n this.safeSetText('bgStartX', bgStart?.x?.toFixed(2) || '-');\n this.safeSetText('bgStartY', bgStart?.y?.toFixed(2) || '-');\n this.safeSetText('imageWidth', imageNaturalWidth?.toFixed(0) || '-');\n this.safeSetText('imageHeight', imageNaturalHeight?.toFixed(0) || '-');\n\n // If placement info included\n if (placementId) {\n this.safeSetText('placementId', placementId);\n this.safeSetText('placementTop', inputTop?.toFixed(6) || '-');\n this.safeSetText('placementLeft', inputLeft?.toFixed(6) || '-');\n }\n\n // Phase 4.271: Compare with drop-time dimensions\n this.updateDimensionComparison(boardDimensions, bgStart, scalingFactor);\n\n this.updateLastEvent('transform');\n }\n\n // Phase 4.252: Handle drop event - capture drop-time isVerticalFit\n // Phase 4.271: Also capture boardDimensions, bgStart, scalingFactor\n handleDrop(event) {\n const { isVerticalFit, boardDimensions, bgStart, scalingFactor } = event.detail;\n\n // Store drop-time isVerticalFit for comparison\n if (isVerticalFit !== undefined) {\n this.dropTimeIsVerticalFitValue = isVerticalFit;\n this.safeSetText('dropTimeIsVerticalFit', isVerticalFit ? 'true' : 'false');\n console.log('[Phase 4.252] Captured drop-time isVerticalFit:', isVerticalFit);\n }\n\n // Phase 4.271: Store drop-time dimensions for comparison\n if (boardDimensions) {\n this.dropTimeBoardDimensions = { ...boardDimensions };\n console.log('[Phase 4.271] Captured drop-time boardDimensions:', boardDimensions);\n }\n if (bgStart) {\n this.dropTimeBgStart = { ...bgStart };\n console.log('[Phase 4.271] Captured drop-time bgStart:', bgStart);\n }\n if (scalingFactor !== undefined) {\n this.dropTimeScalingFactor = scalingFactor;\n console.log('[Phase 4.271] Captured drop-time scalingFactor:', scalingFactor);\n }\n\n // Phase 4.271.1: Display drop-time values immediately and show initial comparison\n // At drop time, current values = drop-time values, so comparison should show MATCH\n this.updateDimensionComparison(boardDimensions, bgStart, scalingFactor);\n\n // Also update placement info\n this.updatePlacement(event);\n this.updateLastEvent('drop');\n }\n\n // Update placement section\n updatePlacement(event) {\n const {\n placementId,\n top,\n left,\n width,\n height,\n dropTimeCoordinates,\n mode,\n boardDimensions,\n bgStart,\n scalingFactor,\n isVerticalFit,\n } = event.detail;\n\n // Update placement info\n this.safeSetText('placementId', placementId || '-');\n this.safeSetText('placementTop', top?.toFixed(6) || '-');\n this.safeSetText('placementLeft', left?.toFixed(6) || '-');\n this.safeSetText('placementWidth', width?.toFixed(6) || '-');\n this.safeSetText('placementHeight', height?.toFixed(6) || '-');\n\n // Update drop-time coordinates if available\n if (dropTimeCoordinates) {\n this.safeSetText('dropTimeTop', dropTimeCoordinates.top?.toFixed(6) || '-');\n this.safeSetText('dropTimeLeft', dropTimeCoordinates.left?.toFixed(6) || '-');\n\n // Check if coordinates match\n const topMatch = Math.abs((top || 0) - (dropTimeCoordinates.top || 0)) < 0.0001;\n const leftMatch = Math.abs((left || 0) - (dropTimeCoordinates.left || 0)) < 0.0001;\n const match = topMatch && leftMatch;\n this.safeSetText('coordMatch', match ? 'MATCH' : 'MISMATCH');\n\n // Highlight mismatch\n if (this.hasCoordMatchTarget) {\n this.coordMatchTarget.classList.toggle('debug-mismatch', !match);\n this.coordMatchTarget.classList.toggle('debug-match', match);\n }\n } else {\n this.safeSetText('dropTimeTop', '-');\n this.safeSetText('dropTimeLeft', '-');\n this.safeSetText('coordMatch', '-');\n }\n\n // Update canvas state if included\n if (mode) this.safeSetText('mode', mode);\n if (boardDimensions) {\n this.safeSetText('boardWidth', boardDimensions.width?.toFixed(0) || '-');\n this.safeSetText('boardHeight', boardDimensions.height?.toFixed(0) || '-');\n }\n if (bgStart) {\n this.safeSetText('bgStartX', bgStart.x?.toFixed(2) || '-');\n this.safeSetText('bgStartY', bgStart.y?.toFixed(2) || '-');\n }\n if (scalingFactor !== undefined) {\n this.safeSetText('scalingFactor', scalingFactor?.toFixed(4) || '-');\n }\n if (isVerticalFit !== undefined) {\n this.safeSetText('isVerticalFit', isVerticalFit ? 'true' : 'false');\n\n // Phase 4.252: Compare current isVerticalFit with drop-time value\n this.updateIsVerticalFitComparison(isVerticalFit);\n }\n\n // Phase 4.271.1: Update dimension comparison when placement is selected/updated\n if (boardDimensions || bgStart || scalingFactor !== undefined) {\n this.updateDimensionComparison(boardDimensions, bgStart, scalingFactor);\n }\n\n this.updateLastEvent('placement');\n }\n\n // Phase 4.252: Compare and highlight isVerticalFit changes\n updateIsVerticalFitComparison(currentIsVerticalFit) {\n if (this.dropTimeIsVerticalFitValue === null) {\n // No drop-time value captured yet\n this.safeSetText('isVerticalFitMatch', '-');\n if (this.hasIsVerticalFitMatchTarget) {\n this.isVerticalFitMatchTarget.classList.remove('debug-mismatch', 'debug-match');\n }\n return;\n }\n\n const match = currentIsVerticalFit === this.dropTimeIsVerticalFitValue;\n this.safeSetText('isVerticalFitMatch', match ? 'MATCH' : 'FLIPPED!');\n\n if (this.hasIsVerticalFitMatchTarget) {\n this.isVerticalFitMatchTarget.classList.toggle('debug-mismatch', !match);\n this.isVerticalFitMatchTarget.classList.toggle('debug-match', match);\n }\n\n // Log warning if flipped - this is the suspected root cause\n if (!match) {\n console.warn('[Phase 4.252] isVerticalFit FLIPPED!', {\n dropTime: this.dropTimeIsVerticalFitValue,\n current: currentIsVerticalFit,\n message: 'This flip causes bgStart to be applied to the wrong axis!',\n });\n }\n }\n\n // Phase 4.271: Compare and highlight dimension changes\n updateDimensionComparison(currentBoardDimensions, currentBgStart, currentScalingFactor) {\n // Board dimensions comparison\n if (this.dropTimeBoardDimensions && currentBoardDimensions) {\n const widthDiff = Math.abs(currentBoardDimensions.width - this.dropTimeBoardDimensions.width);\n const heightDiff = Math.abs(currentBoardDimensions.height - this.dropTimeBoardDimensions.height);\n const boardMatch = widthDiff < 1 && heightDiff < 1;\n\n this.safeSetText('dropTimeBoardWidth', this.dropTimeBoardDimensions.width?.toFixed(0) || '-');\n this.safeSetText('dropTimeBoardHeight', this.dropTimeBoardDimensions.height?.toFixed(0) || '-');\n this.safeSetText('boardDimsMatch', boardMatch ? 'MATCH' : `\u0394${heightDiff.toFixed(0)}px`);\n\n if (this.hasBoardDimsMatchTarget) {\n this.boardDimsMatchTarget.classList.toggle('debug-mismatch', !boardMatch);\n this.boardDimsMatchTarget.classList.toggle('debug-match', boardMatch);\n }\n\n if (!boardMatch) {\n console.warn('[Phase 4.271] Board dimensions CHANGED!', {\n dropTime: this.dropTimeBoardDimensions,\n current: currentBoardDimensions,\n widthDiff,\n heightDiff,\n });\n }\n }\n\n // bgStart comparison\n if (this.dropTimeBgStart && currentBgStart) {\n const xDiff = Math.abs(currentBgStart.x - this.dropTimeBgStart.x);\n const yDiff = Math.abs(currentBgStart.y - this.dropTimeBgStart.y);\n const bgStartMatch = xDiff < 1 && yDiff < 1;\n\n this.safeSetText('dropTimeBgStartX', this.dropTimeBgStart.x?.toFixed(2) || '-');\n this.safeSetText('dropTimeBgStartY', this.dropTimeBgStart.y?.toFixed(2) || '-');\n this.safeSetText('bgStartMatch', bgStartMatch ? 'MATCH' : `\u0394${Math.max(xDiff, yDiff).toFixed(1)}px`);\n\n if (this.hasBgStartMatchTarget) {\n this.bgStartMatchTarget.classList.toggle('debug-mismatch', !bgStartMatch);\n this.bgStartMatchTarget.classList.toggle('debug-match', bgStartMatch);\n }\n\n if (!bgStartMatch) {\n console.warn('[Phase 4.271] bgStart CHANGED!', {\n dropTime: this.dropTimeBgStart,\n current: currentBgStart,\n xDiff,\n yDiff,\n message: 'This change causes stored percentage to render at different visual position!',\n });\n }\n }\n\n // Scaling factor comparison\n if (this.dropTimeScalingFactor !== null && currentScalingFactor !== undefined) {\n const scaleDiff = Math.abs(currentScalingFactor - this.dropTimeScalingFactor);\n const scaleMatch = scaleDiff < 0.001;\n\n this.safeSetText('dropTimeScalingFactor', this.dropTimeScalingFactor?.toFixed(4) || '-');\n this.safeSetText('scalingFactorMatch', scaleMatch ? 'MATCH' : `\u0394${scaleDiff.toFixed(4)}`);\n\n if (this.hasScalingFactorMatchTarget) {\n this.scalingFactorMatchTarget.classList.toggle('debug-mismatch', !scaleMatch);\n this.scalingFactorMatchTarget.classList.toggle('debug-match', scaleMatch);\n }\n }\n }\n\n // Safe helper to set text content\n safeSetText(targetName, value) {\n const hasTarget = this[`has${targetName.charAt(0).toUpperCase() + targetName.slice(1)}Target`];\n if (hasTarget) {\n this[`${targetName}Target`].textContent = value;\n }\n }\n\n // Update last event indicator\n updateLastEvent(eventType) {\n if (this.hasLastEventTarget) {\n const now = new Date().toLocaleTimeString();\n this.lastEventTarget.textContent = `${eventType} @ ${now}`;\n }\n }\n\n // Phase 4.254: Update all placements list\n updateAllPlacements(event) {\n const { placements } = event.detail;\n\n // Phase 4.257: Store placements for snapshot comparison\n this.lastPlacementsData = placements ? JSON.parse(JSON.stringify(placements)) : [];\n\n // If we have a snapshot, update the comparison\n if (this.snapshotData) {\n this.renderSnapshotComparison();\n }\n\n // Update count\n if (this.hasPlacementCountTarget) {\n this.placementCountTarget.textContent = placements?.length || 0;\n }\n\n // Update list using safe DOM methods\n if (this.hasPlacementsListTarget) {\n // Clear existing content\n while (this.placementsListTarget.firstChild) {\n this.placementsListTarget.removeChild(this.placementsListTarget.firstChild);\n }\n\n if (!placements || placements.length === 0) {\n const emptyDiv = document.createElement('div');\n emptyDiv.className = 'debug-value-muted';\n emptyDiv.textContent = 'No placements';\n this.placementsListTarget.appendChild(emptyDiv);\n } else {\n placements.forEach(p => {\n const item = document.createElement('div');\n item.className = 'debug-placement-item';\n if (p.id === this.selectedPlacementId) {\n item.classList.add('debug-selected');\n }\n item.dataset.placementId = p.id;\n\n const idSpan = document.createElement('span');\n idSpan.className = 'debug-placement-id';\n idSpan.textContent = p.id ? p.id.substring(0, 8) : '-';\n\n const coordsSpan = document.createElement('span');\n coordsSpan.className = 'debug-placement-coords';\n // Phase 4.256: Show both pixel and percentage coordinates\n const xPx = p.containerPixelLeft?.toFixed(0) || '-';\n const yPx = p.containerPixelTop?.toFixed(0) || '-';\n const topStr = p.top?.toFixed(2) || '-';\n coordsSpan.textContent = `(${xPx}, ${yPx}) T:${topStr}%`;\n\n item.appendChild(idSpan);\n item.appendChild(coordsSpan);\n\n // Add click handler to select placement\n item.addEventListener('click', () => {\n this.selectPlacement(p);\n });\n\n this.placementsListTarget.appendChild(item);\n });\n }\n }\n\n this.updateLastEvent('placements-changed');\n }\n\n // Phase 4.254: Select a placement and update the Selected Placement section\n selectPlacement(placement) {\n this.selectedPlacementId = placement.id;\n\n // Update Selected Placement section\n this.safeSetText('placementId', placement.id || '-');\n this.safeSetText('placementTop', placement.top?.toFixed(6) || '-');\n this.safeSetText('placementLeft', placement.left?.toFixed(6) || '-');\n\n // Phase 4.257: Calculate pixel coords from percentage if not provided\n let pixelX = placement.containerPixelLeft;\n let pixelY = placement.containerPixelTop;\n\n if (pixelX === undefined || pixelY === undefined) {\n // Calculate from percentage using current canvas dimensions\n const container = document.querySelector('.decorator-canvas-container');\n if (container && placement.left !== undefined && placement.top !== undefined) {\n const containerRect = container.getBoundingClientRect();\n pixelX = (placement.left / 100) * containerRect.width;\n pixelY = (placement.top / 100) * containerRect.height;\n }\n }\n\n this.safeSetText('placementXPx', pixelX !== undefined ? pixelX.toFixed(0) : '-');\n this.safeSetText('placementYPx', pixelY !== undefined ? pixelY.toFixed(0) : '-');\n this.safeSetText('placementWidth', placement.width?.toFixed(6) || '-');\n this.safeSetText('placementHeight', placement.height?.toFixed(6) || '-');\n\n // Update drop-time coords if available\n if (placement.dropTimeCoordinates) {\n this.safeSetText('dropTimeTop', placement.dropTimeCoordinates.top?.toFixed(6) || '-');\n this.safeSetText('dropTimeLeft', placement.dropTimeCoordinates.left?.toFixed(6) || '-');\n\n const topMatch = Math.abs((placement.top || 0) - (placement.dropTimeCoordinates.top || 0)) < 0.0001;\n const leftMatch = Math.abs((placement.left || 0) - (placement.dropTimeCoordinates.left || 0)) < 0.0001;\n const match = topMatch && leftMatch;\n this.safeSetText('coordMatch', match ? 'MATCH' : 'MISMATCH');\n\n if (this.hasCoordMatchTarget) {\n this.coordMatchTarget.classList.toggle('debug-mismatch', !match);\n this.coordMatchTarget.classList.toggle('debug-match', match);\n }\n }\n\n // Update highlighting in list\n if (this.hasPlacementsListTarget) {\n this.placementsListTarget.querySelectorAll('.debug-placement-item').forEach(item => {\n item.classList.toggle('debug-selected', item.dataset.placementId === placement.id);\n });\n }\n\n console.log('[Phase 4.254] Selected placement:', placement.id);\n }\n\n // Copy all values to clipboard\n copyValues() {\n const values = {\n mode: this.hasModeTarget ? this.modeTarget.textContent : null,\n boardDimensions: {\n width: this.hasBoardWidthTarget ? this.boardWidthTarget.textContent : null,\n height: this.hasBoardHeightTarget ? this.boardHeightTarget.textContent : null,\n },\n bgStart: {\n x: this.hasBgStartXTarget ? this.bgStartXTarget.textContent : null,\n y: this.hasBgStartYTarget ? this.bgStartYTarget.textContent : null,\n },\n scalingFactor: this.hasScalingFactorTarget ? this.scalingFactorTarget.textContent : null,\n isVerticalFit: {\n current: this.hasIsVerticalFitTarget ? this.isVerticalFitTarget.textContent : null,\n dropTime: this.hasDropTimeIsVerticalFitTarget ? this.dropTimeIsVerticalFitTarget.textContent : null,\n match: this.hasIsVerticalFitMatchTarget ? this.isVerticalFitMatchTarget.textContent : null,\n },\n imageDimensions: {\n width: this.hasImageWidthTarget ? this.imageWidthTarget.textContent : null,\n height: this.hasImageHeightTarget ? this.imageHeightTarget.textContent : null,\n },\n placement: {\n id: this.hasPlacementIdTarget ? this.placementIdTarget.textContent : null,\n top: this.hasPlacementTopTarget ? this.placementTopTarget.textContent : null,\n left: this.hasPlacementLeftTarget ? this.placementLeftTarget.textContent : null,\n width: this.hasPlacementWidthTarget ? this.placementWidthTarget.textContent : null,\n height: this.hasPlacementHeightTarget ? this.placementHeightTarget.textContent : null,\n },\n dropTime: {\n top: this.hasDropTimeTopTarget ? this.dropTimeTopTarget.textContent : null,\n left: this.hasDropTimeLeftTarget ? this.dropTimeLeftTarget.textContent : null,\n },\n coordMatch: this.hasCoordMatchTarget ? this.coordMatchTarget.textContent : null,\n };\n\n const text = JSON.stringify(values, null, 2);\n\n navigator.clipboard.writeText(text).then(() => {\n console.log('[Phase 4.252] Debug values copied to clipboard');\n window.App?.showSuccessFlash?.('Debug values copied to clipboard');\n }).catch(err => {\n console.error('[Phase 4.252] Failed to copy:', err);\n // Fallback: log to console\n console.log('[Phase 4.252] Debug values:', values);\n });\n }\n\n // Log current values to console\n logToConsole() {\n console.group('[Phase 4.257] Debug Panel Values');\n console.log('Mode:', this.hasModeTarget ? this.modeTarget.textContent : '-');\n console.log('Board Dimensions:', {\n width: this.hasBoardWidthTarget ? this.boardWidthTarget.textContent : '-',\n height: this.hasBoardHeightTarget ? this.boardHeightTarget.textContent : '-',\n });\n console.log('bgStart:', {\n x: this.hasBgStartXTarget ? this.bgStartXTarget.textContent : '-',\n y: this.hasBgStartYTarget ? this.bgStartYTarget.textContent : '-',\n });\n console.log('scalingFactor:', this.hasScalingFactorTarget ? this.scalingFactorTarget.textContent : '-');\n console.log('isVerticalFit:', {\n current: this.hasIsVerticalFitTarget ? this.isVerticalFitTarget.textContent : '-',\n dropTime: this.hasDropTimeIsVerticalFitTarget ? this.dropTimeIsVerticalFitTarget.textContent : '-',\n match: this.hasIsVerticalFitMatchTarget ? this.isVerticalFitMatchTarget.textContent : '-',\n });\n console.log('Image Dimensions:', {\n width: this.hasImageWidthTarget ? this.imageWidthTarget.textContent : '-',\n height: this.hasImageHeightTarget ? this.imageHeightTarget.textContent : '-',\n });\n console.log('Placement:', {\n id: this.hasPlacementIdTarget ? this.placementIdTarget.textContent : '-',\n top: this.hasPlacementTopTarget ? this.placementTopTarget.textContent : '-',\n left: this.hasPlacementLeftTarget ? this.placementLeftTarget.textContent : '-',\n });\n console.log('Drop-time:', {\n top: this.hasDropTimeTopTarget ? this.dropTimeTopTarget.textContent : '-',\n left: this.hasDropTimeLeftTarget ? this.dropTimeLeftTarget.textContent : '-',\n });\n console.log('Coord Match:', this.hasCoordMatchTarget ? this.coordMatchTarget.textContent : '-');\n // Phase 4.257: Log snapshot and round-trip data\n if (this.snapshotData) {\n console.log('Snapshot:', this.snapshotData);\n }\n if (this.lastSentPayload) {\n console.log('Last Sent:', this.lastSentPayload);\n }\n if (this.lastReceivedPayload) {\n console.log('Last Received:', this.lastReceivedPayload);\n }\n console.groupEnd();\n }\n\n // Phase 4.257: Capture current placements as snapshot\n takeSnapshot() {\n if (!this.lastPlacementsData || this.lastPlacementsData.length === 0) {\n console.warn('[Phase 4.257] No placements to snapshot');\n return;\n }\n\n const timestamp = new Date().toLocaleTimeString();\n this.snapshotData = {\n timestamp: timestamp,\n placements: JSON.parse(JSON.stringify(this.lastPlacementsData)), // Deep copy\n };\n\n // Update timestamp display\n this.safeSetText('snapshotTimestamp', timestamp);\n\n // Render snapshot in comparison list\n this.renderSnapshotComparison();\n\n console.log('[Phase 4.257] Snapshot captured:', this.snapshotData);\n }\n\n // Phase 4.257: Render snapshot comparison\n renderSnapshotComparison() {\n if (!this.hasComparisonListTarget) return;\n\n // Clear existing content\n while (this.comparisonListTarget.firstChild) {\n this.comparisonListTarget.removeChild(this.comparisonListTarget.firstChild);\n }\n\n if (!this.snapshotData || !this.snapshotData.placements) {\n const emptyDiv = document.createElement('div');\n emptyDiv.className = 'debug-value-muted';\n emptyDiv.textContent = 'No snapshot captured';\n this.comparisonListTarget.appendChild(emptyDiv);\n return;\n }\n\n // Compare snapshot with current placements\n const currentPlacements = this.lastPlacementsData || [];\n\n this.snapshotData.placements.forEach(snap => {\n const current = currentPlacements.find(p =>\n p.placementId === snap.placementId || p.id === snap.id,\n );\n\n const item = document.createElement('div');\n item.className = 'debug-comparison-item';\n\n if (!current) {\n item.classList.add('debug-comparison-removed');\n item.textContent = `${(snap.id || snap.placementId || '').substring(0, 8)}: REMOVED`;\n } else {\n const topDelta = (current.top || 0) - (snap.top || 0);\n const leftDelta = (current.left || 0) - (snap.left || 0);\n const hasShift = Math.abs(topDelta) > 0.01 || Math.abs(leftDelta) > 0.01;\n\n if (hasShift) {\n item.classList.add('debug-comparison-shifted');\n } else {\n item.classList.add('debug-comparison-match');\n }\n\n const idStr = (snap.id || snap.placementId || '').substring(0, 8);\n const topStr = `T:${snap.top?.toFixed(2)}%\u2192${current.top?.toFixed(2)}%`;\n const deltaStr = hasShift ? ` (\u0394${topDelta > 0 ? '+' : ''}${topDelta.toFixed(2)}%)` : '';\n\n item.textContent = `${idStr}: ${topStr}${deltaStr}`;\n }\n\n this.comparisonListTarget.appendChild(item);\n });\n }\n\n // Phase 4.257: Handle save payload event\n handleSavePayload(event) {\n const { placements, timestamp, context } = event.detail;\n\n this.lastSentPayload = {\n timestamp: timestamp || Date.now(),\n placements: placements,\n context: context,\n };\n\n // Display summary\n const count = placements?.length || 0;\n const firstPlacement = placements?.[0];\n const summary = firstPlacement\n ? `${count}p T:${firstPlacement.top?.toFixed(2)}%`\n : `${count} placements`;\n\n this.safeSetText('sentData', summary);\n this.updateLastEvent('save-payload');\n\n console.log('[Phase 4.257] Save payload captured:', this.lastSentPayload);\n }\n\n // Phase 4.257: Handle load payload event\n handleLoadPayload(event) {\n const { placements, timestamp, imageId } = event.detail;\n\n this.lastReceivedPayload = {\n timestamp: timestamp || Date.now(),\n placements: placements,\n imageId: imageId,\n };\n\n // Display summary\n const count = placements?.length || 0;\n const firstPlacement = placements?.[0];\n const summary = firstPlacement\n ? `${count}p T:${firstPlacement.top?.toFixed(2)}%`\n : `${count} placements`;\n\n this.safeSetText('receivedData', summary);\n\n // Compare round-trip\n this.compareRoundTrip();\n this.updateLastEvent('load-payload');\n\n console.log('[Phase 4.257] Load payload captured:', this.lastReceivedPayload);\n }\n\n // Phase 4.257: Compare sent vs received coordinates\n compareRoundTrip() {\n if (!this.lastSentPayload || !this.lastReceivedPayload) {\n this.safeSetText('roundTripMatch', '-');\n return;\n }\n\n const sentPlacements = this.lastSentPayload.placements || [];\n const receivedPlacements = this.lastReceivedPayload.placements || [];\n\n if (sentPlacements.length === 0 || receivedPlacements.length === 0) {\n this.safeSetText('roundTripMatch', 'NO DATA');\n return;\n }\n\n let allMatch = true;\n let shiftCount = 0;\n\n sentPlacements.forEach(sent => {\n // Find matching placement by artwork ID or backend ID\n const received = receivedPlacements.find(r =>\n r.artwork_id === sent.artworkId ||\n r.artworkId === sent.artworkId ||\n r.id === sent.backendId,\n );\n\n if (received) {\n const sentTop = sent.top || 0;\n const receivedTop = received.top || 0;\n const sentLeft = sent.left || 0;\n const receivedLeft = received.left || 0;\n\n const topMatch = Math.abs(sentTop - receivedTop) < 0.01;\n const leftMatch = Math.abs(sentLeft - receivedLeft) < 0.01;\n\n if (!topMatch || !leftMatch) {\n allMatch = false;\n shiftCount++;\n console.warn('[Phase 4.257] Round-trip SHIFT detected:', {\n artworkId: sent.artworkId,\n sent: { top: sentTop, left: sentLeft },\n received: { top: receivedTop, left: receivedLeft },\n delta: { top: receivedTop - sentTop, left: receivedLeft - sentLeft },\n });\n }\n }\n });\n\n const status = allMatch ? 'MATCH' : `SHIFT (${shiftCount})`;\n this.safeSetText('roundTripMatch', status);\n\n if (this.hasRoundTripMatchTarget) {\n this.roundTripMatchTarget.classList.toggle('debug-mismatch', !allMatch);\n this.roundTripMatchTarget.classList.toggle('debug-match', allMatch);\n }\n }\n\n // Phase 4.277: Update DOM state from event\n updateDomState(event) {\n const {\n cssTransform,\n translateX,\n translateY,\n offsetLeft,\n offsetTop,\n bcrX,\n bcrY,\n expectedX,\n expectedY,\n placementId,\n } = event.detail;\n\n // Display CSS transform (truncate if too long)\n const transformDisplay = cssTransform && cssTransform.length > 20\n ? `${cssTransform.substring(0, 20)}...`\n : (cssTransform || 'none');\n this.safeSetText('cssTransform', transformDisplay);\n\n // Translate values\n this.safeSetText('translateX', translateX?.toFixed(1) || '-');\n this.safeSetText('translateY', translateY?.toFixed(1) || '-');\n\n // Offset values\n this.safeSetText('offsetLeft', offsetLeft?.toFixed(0) || '-');\n this.safeSetText('offsetTop', offsetTop?.toFixed(0) || '-');\n\n // Bounding client rect (relative to container)\n this.safeSetText('bcrX', bcrX?.toFixed(0) || '-');\n this.safeSetText('bcrY', bcrY?.toFixed(0) || '-');\n\n // Expected position (calculated from percentages)\n this.safeSetText('expectedX', expectedX?.toFixed(0) || '-');\n this.safeSetText('expectedY', expectedY?.toFixed(0) || '-');\n\n // Calculate visual delta\n if (bcrX !== undefined && expectedX !== undefined &&\n bcrY !== undefined && expectedY !== undefined) {\n const deltaX = bcrX - expectedX;\n const deltaY = bcrY - expectedY;\n const totalDelta = Math.sqrt(deltaX * deltaX + deltaY * deltaY);\n const hasShift = totalDelta > 1;\n\n const deltaStr = hasShift\n ? `\u0394${deltaX.toFixed(0)},${deltaY.toFixed(0)} (${totalDelta.toFixed(0)}px)`\n : 'ALIGNED';\n this.safeSetText('visualDelta', deltaStr);\n\n if (this.hasVisualDeltaTarget) {\n this.visualDeltaTarget.classList.toggle('debug-mismatch', hasShift);\n this.visualDeltaTarget.classList.toggle('debug-match', !hasShift);\n }\n\n if (hasShift) {\n console.warn('[Phase 4.277] Visual position MISMATCH!', {\n placementId,\n expected: { x: expectedX, y: expectedY },\n actual: { x: bcrX, y: bcrY },\n delta: { x: deltaX, y: deltaY, total: totalDelta },\n });\n }\n } else {\n this.safeSetText('visualDelta', '-');\n }\n\n this.updateLastEvent('dom-state');\n }\n\n // Phase 4.277: Manually refresh DOM state by reading directly from DOM\n refreshDomState() {\n // Find the currently selected placement element\n const selectedPlacement = document.querySelector('.art-placement-wrapper[data-placement-id]');\n if (!selectedPlacement) {\n console.log('[Phase 4.277] No placement element found');\n this.safeSetText('cssTransform', 'no element');\n return;\n }\n\n const container = document.querySelector('.decorator-canvas-container');\n if (!container) {\n console.log('[Phase 4.277] No container found');\n return;\n }\n\n const placementId = selectedPlacement.dataset.placementId;\n const computedStyle = window.getComputedStyle(selectedPlacement);\n const transform = computedStyle.transform;\n\n // Parse translate values from matrix(a, b, c, d, tx, ty) or matrix3d\n let translateX = 0;\n let translateY = 0;\n if (transform && transform !== 'none') {\n const matrixMatch = transform.match(/matrix\\(([^)]+)\\)/);\n if (matrixMatch) {\n const values = matrixMatch[1].split(',').map(v => parseFloat(v.trim()));\n translateX = values[4] || 0;\n translateY = values[5] || 0;\n }\n }\n\n // Get bounding rects\n const placementRect = selectedPlacement.getBoundingClientRect();\n const containerRect = container.getBoundingClientRect();\n\n // Position relative to container\n const bcrX = placementRect.left - containerRect.left;\n const bcrY = placementRect.top - containerRect.top;\n\n // Get stored percentages from data attributes or cached data\n const storedLeft = parseFloat(selectedPlacement.dataset.leftPct) || 0;\n const storedTop = parseFloat(selectedPlacement.dataset.topPct) || 0;\n\n // Calculate expected position from percentages\n // This should match what the baked image will use\n const expectedX = (storedLeft / 100) * containerRect.width;\n const expectedY = (storedTop / 100) * containerRect.height;\n\n // Dispatch event to update display\n document.dispatchEvent(new CustomEvent('decorator:debug:dom-state', {\n bubbles: false,\n detail: {\n placementId,\n cssTransform: transform,\n translateX,\n translateY,\n offsetLeft: selectedPlacement.offsetLeft,\n offsetTop: selectedPlacement.offsetTop,\n bcrX,\n bcrY,\n expectedX,\n expectedY,\n },\n }));\n\n console.log('[Phase 4.277] DOM state refreshed:', {\n placementId,\n transform,\n translateX,\n translateY,\n bcrX,\n bcrY,\n expectedX,\n expectedY,\n storedPct: { left: storedLeft, top: storedTop },\n });\n }\n\n // Phase 4.403: Capture modal layout to compare bulk vs single mode\n // This helps identify if layout differs when modal opens in each mode\n captureLayout() {\n const results = {};\n\n // Get artboard container (right side of modal)\n const artboardContainer = document.querySelector('[id^=\"artboard-container-\"]');\n if (artboardContainer) {\n const acRect = artboardContainer.getBoundingClientRect();\n results.artboardContainer = {\n top: acRect.top,\n height: acRect.height,\n width: acRect.width,\n left: acRect.left,\n };\n this.safeSetText('artboardContainerTop', acRect.top.toFixed(0));\n this.safeSetText('artboardContainerHeight', acRect.height.toFixed(0));\n }\n\n // Get artboard element within container\n const artboard = document.querySelector('.artboard');\n if (artboard) {\n const aRect = artboard.getBoundingClientRect();\n results.artboard = {\n top: aRect.top,\n height: aRect.height,\n width: aRect.width,\n left: aRect.left,\n };\n this.safeSetText('artboardTop', aRect.top.toFixed(0));\n this.safeSetText('artboardHeight', aRect.height.toFixed(0));\n\n // Calculate offset from container\n if (artboardContainer) {\n const offset = aRect.top - artboardContainer.getBoundingClientRect().top;\n results.artboardOffsetFromContainer = offset;\n this.safeSetText('artboardOffset', `${offset.toFixed(0)}px`);\n\n // Highlight if offset is unexpected (should typically be padding ~20px)\n if (this.hasArtboardOffsetTarget) {\n const isUnexpected = offset < 10 || offset > 30;\n this.artboardOffsetTarget.classList.toggle('debug-mismatch', isUnexpected);\n this.artboardOffsetTarget.classList.toggle('debug-match', !isUnexpected);\n }\n }\n }\n\n // Get popup content area\n const popupContent = document.querySelector('.ae-popup-content');\n if (popupContent) {\n const pcRect = popupContent.getBoundingClientRect();\n results.popupContent = {\n width: pcRect.width,\n height: pcRect.height,\n top: pcRect.top,\n };\n this.safeSetText('popupContentWidth', pcRect.width.toFixed(0));\n this.safeSetText('popupContentHeight', pcRect.height.toFixed(0));\n }\n\n // Get left pane (artwork library)\n const leftPane = document.querySelector('.decorator-left-pane');\n if (leftPane) {\n const lpRect = leftPane.getBoundingClientRect();\n results.leftPane = {\n width: lpRect.width,\n height: lpRect.height,\n };\n this.safeSetText('leftPaneWidth', lpRect.width.toFixed(0));\n this.safeSetText('leftPaneHeight', lpRect.height.toFixed(0));\n }\n\n // Get dropzone previews container (where placements are positioned)\n const dropzonePreviews = document.querySelector('.dropzone-previews');\n if (dropzonePreviews) {\n const dzRect = dropzonePreviews.getBoundingClientRect();\n results.dropzonePreviews = {\n top: dzRect.top,\n left: dzRect.left,\n width: dzRect.width,\n height: dzRect.height,\n };\n this.safeSetText('dropzoneTop', dzRect.top.toFixed(0));\n this.safeSetText('dropzoneLeft', dzRect.left.toFixed(0));\n }\n\n // Phase 4.404: Extended measurements for unmeasured elements\n const modal = document.querySelector('#decorator-modal');\n if (modal) {\n const mRect = modal.getBoundingClientRect();\n results.modal = { top: mRect.top, height: mRect.height };\n this.safeSetText('modalTop', mRect.top.toFixed(0));\n this.safeSetText('modalHeight', mRect.height.toFixed(0));\n }\n\n const heading = document.querySelector('.ae-popup-heading');\n if (heading) {\n const hRect = heading.getBoundingClientRect();\n results.heading = { height: hRect.height };\n this.safeSetText('headingHeight', hRect.height.toFixed(0));\n }\n\n const dropzoneForm = document.querySelector('#decorator-dropzone');\n if (dropzoneForm) {\n const dfRect = dropzoneForm.getBoundingClientRect();\n results.dropzoneForm = { top: dfRect.top, height: dfRect.height };\n this.safeSetText('dropzoneFormTop', dfRect.top.toFixed(0));\n this.safeSetText('dropzoneFormHeight', dfRect.height.toFixed(0));\n }\n\n // Also capture computed styles that might differ\n if (artboardContainer) {\n const computedStyles = window.getComputedStyle(artboardContainer);\n results.artboardContainerStyles = {\n paddingTop: computedStyles.paddingTop,\n paddingBottom: computedStyles.paddingBottom,\n marginTop: computedStyles.marginTop,\n display: computedStyles.display,\n flex: computedStyles.flex,\n height: computedStyles.height,\n };\n }\n\n // Get current mode\n const mode = this.hasModeTarget ? this.modeTarget.textContent : 'unknown';\n results.mode = mode;\n results.timestamp = new Date().toISOString();\n\n // Log comprehensive output for comparison\n console.log('[Phase 4.403] MODAL LAYOUT CAPTURED:', results);\n console.log('[Phase 4.403] To compare: Open modal in OTHER mode, click Capture again, compare values');\n\n // Also log a diff-friendly summary including Phase 4.404 measurements\n console.log(`[Phase 4.404] Summary (mode=${mode}):\n artboard-container: top=${results.artboardContainer?.top}, height=${results.artboardContainer?.height}\n artboard: top=${results.artboard?.top}, height=${results.artboard?.height}\n offset: ${results.artboardOffsetFromContainer}px\n popup-content: ${results.popupContent?.width}x${results.popupContent?.height}\n left-pane: ${results.leftPane?.width}x${results.leftPane?.height}\n dropzone-previews: top=${results.dropzonePreviews?.top}, left=${results.dropzonePreviews?.left}\n #decorator-modal: top=${results.modal?.top}, height=${results.modal?.height}\n .ae-popup-heading: height=${results.heading?.height}\n #decorator-dropzone: top=${results.dropzoneForm?.top}, height=${results.dropzoneForm?.height}`);\n\n return results;\n }\n\n // Phase 4.404: Toggle visual reference line at dropzone top\n // Helps verify if visual position matches measured position\n toggleRefLine() {\n let refLine = document.querySelector('#debug-ref-line');\n if (refLine) {\n refLine.remove();\n console.log('[Phase 4.404] Reference line removed');\n return;\n }\n\n // Try #decorator-dropzone first, then .dropzone-previews\n const dropzoneForm = document.querySelector('#decorator-dropzone');\n const dropzonePreviews = document.querySelector('.dropzone-previews');\n const targetElement = dropzoneForm || dropzonePreviews;\n\n if (!targetElement) {\n console.log('[Phase 4.404] No dropzone element found');\n return;\n }\n\n const rect = targetElement.getBoundingClientRect();\n refLine = document.createElement('div');\n refLine.id = 'debug-ref-line';\n refLine.style.cssText = `\n position: fixed;\n top: ${rect.top}px;\n left: 0;\n right: 0;\n height: 2px;\n background: red;\n z-index: 99999;\n pointer-events: none;\n `;\n document.body.appendChild(refLine);\n console.log('[Phase 4.404] Reference line at dropzone top:', rect.top);\n }\n}\n", "import { Controller } from '../stimulus';\n\n// Track the last connected modal element to distinguish fresh open from spurious reconnect.\n// Fresh AJAX load creates a new DOM element; spurious reconnect reuses the same element.\nlet lastConnectedModalElement = null;\n\nexport default class extends Controller {\n // Stimulus 1.1.0 doesn't support static values\n // We'll use data attributes directly\n\n constructor() {\n super();\n // Phase 0.99.37: Track intentional modal close vs spurious disconnects\n // Problem: Phase 0.99.36 clears globalCanvasState on EVERY disconnect, including\n // spurious disconnects during drag operations. This causes duplicate placements\n // because decorator-canvas-artboard controller depends on globalCanvasState.\n // Solution: Only clear when user explicitly closes modal (clicks X button)\n this.isClosing = false;\n\n // Phase 0.99.40: Helper for timestamp logging (ESLint compatibility)\n this.getTimestamp = () => {\n return typeof window !== 'undefined' && window.performance ? window.performance.now() : Date.now();\n };\n\n // Save operation state\n this.isSaving = false;\n this.isConnected = true;\n }\n\n connect() {\n // Phase 4.107: Reset isConnected early, before scope guard\n // Spurious disconnect sets this to false at line 118. If connect() early-returns\n // due to invalid scope (line 68), isConnected stays false. This breaks async job\n // polling in handleAsyncProcessing() which checks if (!this.isConnected) return.\n this.isConnected = true;\n\n // Phase 4.106: Track pending confirmation dialog for cleanup\n // Initialize here so it's always available, even after spurious reconnects\n if (this.pendingDialogOverlay === undefined) {\n this.pendingDialogOverlay = null;\n }\n\n // Phase 0.99.42: Defensive guards against forced reconnection timing issues\n //\n // Root Cause Investigation:\n // 1. stimulus_reinit_mixin.js uses DUAL methods to force Stimulus reconnection:\n // Method 1 (lines 36-56): DOM manipulation (move to temp wrapper, move back)\n // Method 2 (lines 59-74): Attribute removal + restoration (10ms + 50ms delays)\n //\n // 2. When decorator_artwork_tabs_controller loads tab content via AJAX:\n // - showContent() calls reinitializeStimulusControllers()\n // - Both methods trigger on decorator-modal controller element\n // - Creates complex disconnect/reconnect timing scenarios\n //\n // 3. Screenshot evidence shows 53ms gap between disconnect/connect\n // - Disconnect: 04:01:27.033Z\n // - Connect: 04:01:27.086Z\n // - This matches Method 2's 50ms setTimeout window\n //\n // 4. Stimulus 1.1.0 Source Analysis (stimulus.umd.js):\n // - Context persists in contextsByScope Map (lines 1112-1119)\n // - disconnect() does NOT clear this.context (lines 944-950)\n // - Property getters should always work during normal lifecycle\n //\n // 5. [Speculation] Hypothesis: During forced reconnection via attribute manipulation,\n // a NEW controller instance might be created before old one fully disconnects,\n // or attribute restoration happens on partially initialized controller.\n //\n // Solution: Defensive guards using optional chaining on ALL property getter access\n // This prevents errors without breaking functionality if hypothesis is correct.\n\n // Phase 4.103: Add listener BEFORE scope guard\n // Even if scope is invalid, we need the listener attached so we can use\n // DOM fallback in handleBeforeClose() to find the wrapper when this.element fails.\n // Phase 4.102 added listener after guard, causing listener to never be added back\n // after spurious reconnect with invalid scope.\n if (!this._handleBeforeCloseBound) {\n this._handleBeforeCloseBound = this.handleBeforeClose.bind(this);\n }\n document.addEventListener('ajax-popup:beforeClose', this._handleBeforeCloseBound);\n\n // Phase 0.99.49: Guard against connect() called without valid context\n if (!this.context || !this.element) {\n return;\n }\n\n // Clear stale global state on fresh modal open (new DOM element from AJAX load).\n // Skip on spurious reconnect (same element reconnecting during drag operation).\n // This eliminates the race condition where disconnect() hasn't fired yet\n // but the new artboard controller reads stale globalBulkDecorationPlacements.\n if (this.element !== lastConnectedModalElement) {\n this.clearGlobalDecoratorState();\n lastConnectedModalElement = this.element;\n }\n\n // Phase 0.99.37: Reset closing flag on connect\n this.isClosing = false;\n // Initialize controller\n\n // Phase 3: Discover progress indicator targets (Phase 2.3 UI)\n // Stimulus 1.1.0 doesn't have static targets, so we use manual querySelector discovery\n this.progressIndicatorTarget = this.element.querySelector('[data-target=\"decorator-modal.progressIndicator\"]');\n this.progressFillTarget = this.element.querySelector('[data-target=\"decorator-modal.progressFill\"]');\n this.progressPercentageTarget = this.element.querySelector('[data-target=\"decorator-modal.progressPercentage\"]');\n this.progressCompletedTarget = this.element.querySelector('[data-target=\"decorator-modal.progressCompleted\"]');\n this.progressTotalTarget = this.element.querySelector('[data-target=\"decorator-modal.progressTotal\"]');\n this.progressBatchTarget = this.element.querySelector('[data-target=\"decorator-modal.progressBatch\"]');\n this.batchNumberTarget = this.element.querySelector('[data-target=\"decorator-modal.batchNumber\"]');\n this.progressErrorsTarget = this.element.querySelector('[data-target=\"decorator-modal.progressErrors\"]');\n this.errorMessageTarget = this.element.querySelector('[data-target=\"decorator-modal.errorMessage\"]');\n this.progressCompleteTarget = this.element.querySelector('[data-target=\"decorator-modal.progressComplete\"]');\n this.completeMessageTarget = this.element.querySelector('[data-target=\"decorator-modal.completeMessage\"]');\n this.errorNotificationTarget = this.element.querySelector('[data-target=\"decorator-modal.errorNotification\"]');\n\n // Phase 4.38: Listen for decorator:triggerSave event from ajax_popup_controller\n // This enables the Save button to trigger save via DOM events\n this.handleTriggerSave = this.handleTriggerSave.bind(this);\n document.addEventListener('decorator:triggerSave', this.handleTriggerSave);\n\n // Check if a product is already selected after controllers initialize\n // Use requestAnimationFrame to ensure DOM is ready\n // Phase 0.99.41B: Store rAF ID for cleanup (defense-in-depth level 1)\n this.initialSelectionRaf = requestAnimationFrame(() => {\n // Phase 0.99.47: Guard against callback firing after disconnect\n // Phase 0.99.41B cleanup cancels callbacks, but race conditions may still occur\n // Note: Use this.context instead of this.scope to avoid getter throwing\n if (!this || !this.context || !this.element) {\n return;\n }\n\n // Phase 0.99.41B: Store timeout ID for cleanup (defense-in-depth level 2)\n // Without dual tracking, spurious disconnects leave orphaned callbacks that fire\n // after this.scope is cleared, causing \"Cannot read properties of undefined (reading 'scope')\"\n this.initialSelectionTimeout = setTimeout(() => {\n this.checkInitialSelection();\n }, 300);\n });\n }\n\n disconnect() {\n // Stop any active polling to prevent orphaned setTimeout callbacks\n this.isConnected = false;\n\n // Phase 4.106: Cleanup any orphaned confirmation dialog\n if (this.pendingDialogOverlay) {\n this.pendingDialogOverlay.remove();\n this.pendingDialogOverlay = null;\n }\n\n // Phase 4.38: Clean up decorator:triggerSave event listener\n document.removeEventListener('decorator:triggerSave', this.handleTriggerSave);\n\n // Phase 4.103: Only remove listener on INTENTIONAL close (this.isClosing = true)\n // During spurious disconnects (drag operations), keep listener attached so\n // DOM fallback in handleBeforeClose() can still intercept the close event.\n // Phase 4.102 removed on every disconnect, losing listener after spurious disconnect.\n if (this.isClosing && this._handleBeforeCloseBound) {\n document.removeEventListener('ajax-popup:beforeClose', this._handleBeforeCloseBound);\n }\n\n // Auto-dismiss error notification on modal close\n this.hideInlineErrorNotification();\n\n // Phase 3: Hide progress indicator on modal close\n if (this.progressIndicatorTarget) {\n this.progressIndicatorTarget.style.display = 'none';\n }\n\n // Phase 0.99.41B: Cancel BOTH async operations to prevent orphaned callbacks\n // CRITICAL: This must happen BEFORE any other disconnect logic (including isClosing check)\n //\n // Root Cause: connect() uses nested async operations (rAF \u2192 setTimeout). Phase 0.99.38\n // only canceled setTimeout, but if disconnect fires after rAF is queued but before it\n // executes, the orphaned rAF callback later creates a setTimeout on a disconnected\n // controller instance \u2192 checkInitialSelection() tries to access this.scope (getter)\n // \u2192 ERROR: \"Cannot read properties of undefined (reading 'scope')\"\n //\n // Mechanism: Stimulus 1.1.0 implements this.element/this.scope as getters. When controller\n // disconnects, this.scope becomes undefined, but orphaned callbacks still hold reference\n // to the disconnected controller instance.\n //\n // Solution: Two-level cleanup (defense-in-depth):\n // 1. Cancel requestAnimationFrame to prevent orphaned setTimeout creation\n // 2. Cancel setTimeout if it was already created\n // This covers ALL timing scenarios in the race condition.\n\n // Level 1: Cancel requestAnimationFrame (prevents orphaned setTimeout)\n if (this.initialSelectionRaf) {\n // eslint-disable-next-line no-undef\n cancelAnimationFrame(this.initialSelectionRaf);\n this.initialSelectionRaf = null;\n }\n\n // Level 2: Cancel setTimeout if it exists (handles case where rAF already executed)\n if (this.initialSelectionTimeout) {\n clearTimeout(this.initialSelectionTimeout);\n this.initialSelectionTimeout = null;\n }\n\n // Phase 0.99.37: Only clear Maps on intentional close, not spurious disconnects\n //\n // Problem: Phase 0.99.36 cleared Maps on EVERY disconnect, including spurious\n // disconnects during drag operations. Since decorator-canvas-artboard controller\n // (child element) depends on globalCanvasState for canvas retrieval, clearing\n // during drag caused findOrCreateCanvas() to create NEW canvases \u2192 duplicates!\n //\n // Root Cause: Drag operations trigger spurious Stimulus disconnect/reconnect cycles\n // for both parent (decorator-modal) and child (decorator-canvas-artboard) controllers.\n // Phase 0.99.36's assumption that \"disconnect = modal close\" was incorrect.\n //\n // Solution: Use isClosing flag to differentiate:\n // - User clicked X button \u2192 close() sets isClosing = true \u2192 Clear Maps\n // - Spurious disconnect during drag \u2192 isClosing = false \u2192 Preserve Maps\n //\n if (!this.isClosing) {\n return;\n }\n\n this.clearGlobalDecoratorState();\n lastConnectedModalElement = null;\n\n this.logDebug('Decorator modal disconnected');\n }\n\n clearGlobalDecoratorState() {\n if (typeof window === 'undefined') return;\n if (window.globalLoadedPlacements) window.globalLoadedPlacements.clear();\n if (window.globalCanvasState) window.globalCanvasState.clear();\n if (window.globalBulkDecorationPlacements) window.globalBulkDecorationPlacements.clear();\n if (window.globalCanvasChanges) window.globalCanvasChanges.clear();\n if (window.globalDirtyState) window.globalDirtyState.clear();\n if (window.productImageMapping) window.productImageMapping.clear();\n }\n\n logDebug(_message, ..._args) {\n // Debug logging disabled for production\n }\n\n shouldBypassBulkMode() {\n // Phase 4.371: Use direct DOM access to avoid this.element getter throwing\n // when this.context is undefined. The getter accesses this.context.scope.element\n // which fails if controller is disconnected or not fully connected.\n const modalElement = document.getElementById('decorator-modal');\n return modalElement?.dataset.forceSingleMode === 'true';\n }\n\n determineContext() {\n // BULK MODE BYPASS: Always return 'single_product' when flag enabled\n if (this.shouldBypassBulkMode()) {\n console.warn('[BULK BYPASS] Force single mode enabled - bulk context overridden');\n return 'single_product';\n }\n\n // Original logic preserved for rollback\n const productId = this.element.dataset.productId;\n // const colorVariantId = this.element.dataset.colorVariantId;\n const bulk = this.element.dataset.bulk === 'true';\n const productIds = this.element.dataset.productIds;\n\n // Bulk mode takes priority when explicitly set\n if (bulk || (productIds && productIds !== '[]')) {\n return 'bulk';\n } else if (productId) {\n return 'single_product';\n } else {\n return 'all_products';\n }\n }\n\n\n buildParams(additionalParams = {}) {\n const params = {\n sale_id: this.element.dataset.saleId,\n ...additionalParams,\n };\n\n const context = this.element.dataset.context || this.determineContext();\n\n // Build URL and params based on context\n switch (context) {\n case 'single_product':\n params.product_id = this.element.dataset.productId;\n params.color_variant_id = this.element.dataset.colorVariantId;\n break;\n\n case 'bulk': {\n params.is_bulk = true;\n const productIds = this.element.dataset.productIds;\n params.product_ids = productIds ? JSON.parse(productIds) : this.getSelectedProductIds();\n break;\n }\n\n case 'all_products':\n // No specific product params needed\n break;\n }\n\n return params;\n }\n\n buildUrl() {\n const saleId = this.element.dataset.saleId;\n\n // Use unified teamsites decorator endpoint for all contexts\n return `/teamsites/${saleId}/decorator`;\n }\n\n getSelectedProductIds() {\n // Get selected product IDs from checkboxes\n const checkboxes = document.querySelectorAll('input[name=\"product_ids[]\"]:checked');\n return Array.from(checkboxes).map(cb => cb.value);\n }\n\n buildQueryString(params) {\n const searchParams = new URLSearchParams();\n\n for (const [key, value] of Object.entries(params)) {\n if (Array.isArray(value)) {\n // Handle arrays by adding multiple parameters with the same key\n value.forEach(item => {\n searchParams.append(`${key}[]`, item);\n });\n } else {\n searchParams.append(key, value);\n }\n }\n\n return searchParams.toString();\n }\n\n\n getEmptyMessage() {\n const context = this.element.dataset.context;\n\n switch (context) {\n case 'bulk':\n return 'No products selected. Please select at least one product.';\n case 'single_product':\n return 'This product has no images available for decoration.';\n default:\n return 'No products with images selected';\n }\n }\n\n\n autoSelectProduct(params) {\n let targetProductId;\n const context = this.element.dataset.context || this.determineContext();\n\n if (context === 'single_product') {\n targetProductId = params.product_id;\n } else if (context === 'bulk' && params.product_ids) {\n targetProductId = params.product_ids[0];\n }\n\n if (!targetProductId) return;\n\n const productElement = document.getElementById(`decorator-product-list-item-${targetProductId}`);\n if (productElement) {\n // Trigger click event with additional parameters\n const clickEvent = new CustomEvent('click', {\n bubbles: true,\n detail: {\n ...params,\n autoSelected: true,\n },\n });\n productElement.dispatchEvent(clickEvent);\n }\n }\n\n\n /**\n * Phase 4.99: Single Handler Architecture\n * close() simply triggers ajax-popup close - handleBeforeClose() handles ALL dirty state logic.\n * This eliminates duplicate dirty state checking which caused scope invalidation errors.\n * @param {Event} event - Click event from close button\n */\n async close(event) {\n if (event) {\n event.preventDefault();\n }\n\n // Phase 4.99: Just trigger ajax-popup close - handleBeforeClose will handle dirty state\n const wrapper = this.element?.closest('.mfp-wrap');\n if (wrapper && wrapper.ajaxPopupInstance) {\n wrapper.ajaxPopupInstance.close();\n return;\n }\n\n // Fallback: dispatch to document (will be caught by any listener on document)\n document.dispatchEvent(new CustomEvent('ajax-popup:close', { bubbles: true }));\n }\n\n /**\n * Phase 4.90: Handle ajax-popup:beforeClose event from ajax_popup_controller\n * This intercepts ALL close attempts (Escape, backdrop, X button) and checks dirty state\n * Phase 4.94: Added error handling to prevent modal getting stuck if dialog fails\n * Phase 4.99: Single Handler Architecture - this is the ONLY place that checks dirty state\n * Phase 4.101: Added try/catch guard for scope invalidation from spurious disconnects\n * @param {CustomEvent} event - The beforeClose event\n */\n async handleBeforeClose(event) {\n // Phase 4.103: Try this.element first, fall back to DOM query if scope invalid\n // The controller may have been spuriously disconnected during drag operations,\n // invalidating this.scope. When that happens, this.element throws an error.\n // Instead of returning early (Phase 4.101), we use DOM fallback to find wrapper.\n let ourWrapper;\n try {\n ourWrapper = this.element?.closest('.mfp-wrap');\n } catch (_error) {\n // Controller scope invalidated - fall back to DOM query\n const decoratorModal = document.querySelector('#decorator-modal');\n if (decoratorModal) {\n ourWrapper = decoratorModal.closest('.mfp-wrap');\n }\n if (!ourWrapper) {\n // Really can't find wrapper - let ajax-popup close normally\n return;\n }\n }\n\n // Phase 4.99: Only handle beforeClose events for THIS modal (backward compatibility)\n // Other ajax_popup modals that don't need dirty tracking should work normally\n if (!ourWrapper) {\n return; // Our modal isn't open via ajax-popup\n }\n\n // Phase 4.99: Verify the trigger element matches - ensures this event is for OUR modal\n // This prevents interference with other ajax_popup instances on the page\n if (ourWrapper.ajaxPopupInstance?.element !== event.target) {\n return; // Different ajax-popup, not ours - let it proceed normally\n }\n\n // Phase 4.99: Mark as intentional close (not spurious disconnect during drag)\n // This flag allows disconnect() to differentiate between:\n // - User clicking X button \u2192 isClosing = true \u2192 Clear Maps\n // - Spurious disconnect during drag \u2192 isClosing = false \u2192 Preserve Maps\n this.isClosing = true;\n\n const canvasController = this.getCanvasController();\n\n if (canvasController && canvasController.hasDirtyState()) {\n // Prevent the close - we need to show confirmation first\n event.preventDefault();\n\n // Phase 4.97: Cache wrapper reference BEFORE showing dialog (already cached as ourWrapper)\n // The controller scope may become invalid during async dialog interaction\n const cachedWrapper = ourWrapper;\n\n try {\n // Show confirmation dialog\n const userChoice = await this.showCloseConfirmationDialog();\n if (userChoice === 'discard') {\n // User chose to discard - close without the check\n this.closeWithoutSaveCheck(cachedWrapper);\n } else {\n // Phase 4.99: User cancelled - reset isClosing flag\n this.isClosing = false;\n }\n } catch (error) {\n // Phase 4.94: On error, allow close to proceed rather than leaving modal stuck\n console.error('[handleBeforeClose] Error showing confirmation dialog:', error);\n this.closeWithoutSaveCheck(cachedWrapper);\n }\n }\n // If not dirty, let the close proceed normally (isClosing already set)\n }\n\n /**\n * Close modal without checking for unsaved changes\n * Used after successful save or when user explicitly discards changes\n * Phase 4.90: Uses skipBeforeClose to prevent infinite loop\n */\n closeWithoutSaveCheck(cachedWrapper = null) {\n this.isClosing = true;\n\n // Phase 4.106: Cleanup any pending confirmation dialog\n // This handles the race condition where auto-close fires while dialog is showing\n if (this.pendingDialogOverlay) {\n this.pendingDialogOverlay.remove();\n this.pendingDialogOverlay = null;\n }\n\n // Phase 4.104: Try cached wrapper first, then this.element, then DOM fallback\n // The this.element getter throws when scope is invalid (after spurious disconnect)\n // This is the same pattern used in handleBeforeClose() (Phase 4.103)\n let wrapper = cachedWrapper;\n if (!wrapper) {\n try {\n wrapper = this.element?.closest('.mfp-wrap');\n } catch (_error) {\n // Controller scope invalidated - fall back to DOM query\n const decoratorModal = document.querySelector('#decorator-modal');\n if (decoratorModal) {\n wrapper = decoratorModal.closest('.mfp-wrap');\n }\n }\n }\n\n if (wrapper && wrapper.ajaxPopupInstance) {\n wrapper.ajaxPopupInstance.close({ skipBeforeClose: true });\n return;\n }\n\n // Phase 4.90 fallback: Try finding ajax-popup via data-controller attribute\n // This path is unlikely to work (ajax-popup is on trigger, not ancestor) but kept for safety\n // Phase 4.104: Also needs DOM fallback for this.element access\n let popupElement;\n try {\n popupElement = this.element?.closest('[data-controller*=\"ajax-popup\"]');\n } catch (_error) {\n // Scope invalid - this fallback path won't work anyway since ajax-popup is on trigger\n popupElement = null;\n }\n\n if (popupElement && window.Stimulus && window.Stimulus.application) {\n const popupController = window.Stimulus.application.getControllerForElementAndIdentifier(\n popupElement,\n 'ajax-popup',\n );\n if (popupController) {\n popupController.close({ skipBeforeClose: true });\n return;\n }\n }\n\n // Last resort fallback: dispatch close event to document\n // Phase 4.104: Can't use this.element when scope is invalid, so dispatch to document\n document.dispatchEvent(new CustomEvent('ajax-popup:close', { bubbles: true }));\n }\n\n /**\n * Show confirmation dialog when closing with unsaved changes\n * Uses safe DOM construction (no innerHTML with user content)\n * Phase 4.94: Added escape key handler, overlay click handler, increased z-index\n * @returns {Promise} 'discard' or 'cancel'\n */\n async showCloseConfirmationDialog() {\n return new Promise((resolve) => {\n // Phase 4.94: Create overlay with max z-index to ensure visibility above all modal layers\n const overlay = document.createElement('div');\n overlay.style.cssText = 'position: fixed; top: 0; left: 0; right: 0; bottom: 0; ' +\n 'background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; ' +\n 'justify-content: center; z-index: 2147483647;';\n\n // Create dialog container\n const dialog = document.createElement('div');\n dialog.style.cssText = 'background: white; padding: 24px; border-radius: 8px; ' +\n 'max-width: 400px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);';\n\n // Create heading\n const heading = document.createElement('h3');\n heading.style.cssText = 'margin: 0 0 12px 0; font-size: 18px; color: #333;';\n heading.textContent = 'Unsaved Changes';\n\n // Create message\n const message = document.createElement('p');\n message.style.cssText = 'margin: 0 0 20px 0; color: #666;';\n message.textContent = 'You have unsaved decoration changes. Are you sure you want to close without saving?';\n\n // Create button container\n const buttonContainer = document.createElement('div');\n buttonContainer.style.cssText = 'display: flex; gap: 8px; justify-content: flex-end;';\n\n // Create Cancel button - Phase 4.95: Use inline styles for proper sizing\n const cancelBtn = document.createElement('button');\n cancelBtn.type = 'button';\n cancelBtn.style.cssText = 'padding: 8px 16px; border: 1px solid #ccc; background: #f5f5f5; ' +\n 'color: #333; border-radius: 4px; cursor: pointer; font-size: 14px;';\n cancelBtn.textContent = 'Cancel';\n cancelBtn.dataset.choice = 'cancel';\n\n // Create Discard button - Phase 4.95: Use inline styles for proper sizing\n const discardBtn = document.createElement('button');\n discardBtn.type = 'button';\n discardBtn.style.cssText = 'padding: 8px 16px; border: none; background: #c0392b; ' +\n 'color: white; border-radius: 4px; cursor: pointer; font-size: 14px;';\n discardBtn.textContent = 'Discard & Close';\n discardBtn.dataset.choice = 'discard';\n\n // Assemble dialog\n buttonContainer.appendChild(cancelBtn);\n buttonContainer.appendChild(discardBtn);\n dialog.appendChild(heading);\n dialog.appendChild(message);\n dialog.appendChild(buttonContainer);\n overlay.appendChild(dialog);\n document.body.appendChild(overlay);\n\n // Phase 4.106: Track overlay for cleanup by closeWithoutSaveCheck()\n this.pendingDialogOverlay = overlay;\n\n // Phase 4.94: Cleanup function to remove listeners and resolve Promise\n const cleanup = (action) => {\n document.removeEventListener('keydown', escHandler);\n overlay.remove();\n this.pendingDialogOverlay = null; // Phase 4.106: Clear reference\n resolve(action);\n };\n\n // Phase 4.94: Escape key handler to cancel\n const escHandler = (e) => {\n if (e.key === 'Escape') {\n e.preventDefault();\n cleanup('cancel');\n }\n };\n document.addEventListener('keydown', escHandler);\n\n // Phase 4.94: Overlay click handler (click outside dialog to cancel)\n overlay.addEventListener('click', (e) => {\n if (e.target === overlay) {\n cleanup('cancel');\n }\n });\n\n // Handle button clicks\n dialog.addEventListener('click', (e) => {\n e.stopPropagation();\n const action = e.target.dataset.choice;\n if (action) {\n cleanup(action);\n }\n });\n });\n }\n\n checkInitialSelection() {\n // Phase 0.99.39: Fix guard evaluation order to prevent property getter access\n // Problem: Phase 0.99.38 guard `if (!this.scope || !this.element)` still threw errors\n // Root Cause Hypothesis: Property getter `this.element` executes during property ACCESS phase\n // - JavaScript evaluates `!this.element` by first accessing the property\n // - This triggers the getter which executes `this.scope.element`\n // - If `this.scope` is undefined, getter throws BEFORE OR short-circuit applies\n // - OR operator cannot prevent getter code execution during property access\n //\n // Solution: Check this.context directly (not this.scope which is a getter)\n // Reality Filter: this.scope getter accesses this.context.scope internally\n\n // Step 1: Check context existence (this.context is a plain property, not a getter)\n if (!this.context) {\n return;\n }\n\n // Step 2: Now safe to access property getters (scope verified to exist)\n if (!this.element) {\n return;\n }\n\n // Check if any product is already selected\n const selectedItem = this.element.querySelector('.product-list-item.ae-selected');\n if (selectedItem) {\n return;\n }\n\n // Check for products with data-is-selected=\"true\"\n const preSelectedItem = this.element.querySelector('.product-list-item[data-is-selected=\"true\"]');\n if (preSelectedItem) {\n return;\n }\n\n // No product is selected, try to select the first one\n const firstItem = this.element.querySelector('.product-list-item [data-action*=\"selectProduct\"]');\n if (firstItem) {\n firstItem.click();\n }\n }\n\n refreshProductGallery() {\n // Trigger gallery refresh by dispatching event to product-gallery-refresh controller\n const galleryElement = document.querySelector('[data-controller*=\"product-gallery-refresh\"]');\n\n if (galleryElement) {\n // Extract product ID from modal data attributes\n const productId = this.getProductIdForRefresh();\n\n // Dispatch the refresh event to the gallery\n const event = new CustomEvent('product-gallery-refresh', {\n detail: {\n productId: productId,\n source: 'decorator-modal-close',\n timestamp: Date.now(),\n },\n });\n galleryElement.dispatchEvent(event);\n }\n }\n\n /**\n * Phase 4.131: Capture product ID from currently selected product in modal\n * Must be called BEFORE modal close, while DOM elements still exist.\n * The .ae-selected element is inside the modal and will be removed on close.\n */\n getCapturedProductIdFromModal() {\n // Look for selected product INSIDE the modal (this.element)\n // Use this.element to scope the query to the modal, not document\n try {\n const selectedProduct = this.element?.querySelector('.product-list-item.ae-selected');\n if (selectedProduct) {\n try {\n const productData = JSON.parse(selectedProduct.dataset.product || '{}');\n if (productData.id) {\n return productData.id;\n }\n } catch {\n // Invalid JSON, continue to fallbacks\n }\n }\n } catch {\n // this.element may throw if scope is invalid\n }\n\n // Fallback: modal's data-product-id\n try {\n return this.element?.dataset.productId || null;\n } catch {\n return null;\n }\n }\n\n /**\n * Phase 4.133: Get the product ID for gallery refresh in bulk mode\n * Returns sidebar's active product ID only if it's in the bulk-selected products.\n * This ensures we only refresh the gallery for the product the user is currently viewing.\n * @param {Array} selectedProductIds - Array of product IDs selected for bulk decoration\n * @returns {string|null} The active product ID if in selection, null otherwise\n */\n getProductIdForBulkRefresh(selectedProductIds) {\n // Get sidebar's active product (what user is viewing in details pane)\n const sidebarActiveProduct = document.querySelector('.sidebar-product-list-item.active');\n if (!sidebarActiveProduct) {\n return null;\n }\n\n const activeProductId = sidebarActiveProduct.dataset.productId;\n if (!activeProductId) {\n return null;\n }\n\n // Only return if the active product is one of the bulk-selected products\n const selectedIdsSet = new Set(selectedProductIds.map(String));\n if (selectedIdsSet.has(String(activeProductId))) {\n return activeProductId;\n }\n\n return null;\n }\n\n getProductIdForRefresh() {\n // Phase 4.131: Use pre-captured product ID if available\n // This was captured BEFORE modal close, while .ae-selected element existed\n if (this.capturedProductIdForRefresh) {\n return this.capturedProductIdForRefresh;\n }\n\n // Phase 4.130: Check selected product in document (fallback)\n // This may not work if modal already removed from DOM\n const selectedProduct = document.querySelector('.product-list-item.ae-selected');\n if (selectedProduct) {\n try {\n const productData = JSON.parse(selectedProduct.dataset.product || '{}');\n if (productData.id) {\n return productData.id;\n }\n } catch {\n // Invalid JSON in product data, continue to next fallback\n }\n }\n\n // Fallback: Phase 4.105: Try this.element, fall back to DOM query if scope invalid\n // The this.element getter throws when scope is invalid (after spurious disconnect)\n let modalProductId;\n try {\n if (this.element && this.element.dataset.productId) {\n modalProductId = this.element.dataset.productId;\n }\n } catch (_error) {\n // Controller scope invalidated - fall back to DOM query\n const decoratorModal = document.querySelector('#decorator-modal');\n if (decoratorModal && decoratorModal.dataset.productId) {\n modalProductId = decoratorModal.dataset.productId;\n }\n }\n\n if (modalProductId) {\n return modalProductId;\n }\n\n // Last fallback: try to get product ID from URL parameters\n const urlParams = new URLSearchParams(window.location.search);\n const productIdFromUrl = urlParams.get('product_id');\n if (productIdFromUrl) {\n return productIdFromUrl;\n }\n\n // Could not determine product ID - gallery controller will use its own\n return null;\n }\n\n // Targeted refresh: Refresh only the decorated images (not the entire gallery)\n async refreshDecoratedImages(decoratedImageIds) {\n if (!decoratedImageIds || decoratedImageIds.length === 0) return;\n\n // Phase 4.105: DOM fallback for scope invalidation\n // The this.element getter throws when scope is invalid (after spurious disconnect)\n let saleId;\n try {\n saleId = this.element?.dataset.saleId;\n } catch (_error) {\n // Controller scope invalidated - fall back to DOM query\n const decoratorModal = document.querySelector('#decorator-modal');\n saleId = decoratorModal?.dataset.saleId;\n }\n\n const productId = this.getProductIdForRefresh();\n\n if (!saleId || !productId) return;\n\n for (const imageId of decoratedImageIds) {\n await this.refreshSingleImage(saleId, productId, imageId);\n }\n }\n\n // Targeted refresh: Fetch and replace a single image in the gallery\n async refreshSingleImage(saleId, productId, imageId) {\n try {\n const url = `/teamsites/${saleId}/products/${productId}/gallery_image/${imageId}`;\n\n const response = await fetch(url, {\n headers: {\n Accept: 'text/html',\n 'X-Requested-With': 'XMLHttpRequest',\n 'X-CSRF-Token': document.querySelector('[name=\"csrf-token\"]').content,\n },\n credentials: 'same-origin',\n });\n\n if (response.ok) {\n const html = await response.text();\n this.replaceImageInGallery(imageId, html);\n }\n } catch (error) {\n // Gracefully handle fetch errors - don't break the close flow\n console.error(`Error refreshing image ${imageId}:`, error);\n }\n }\n\n // Targeted refresh: Replace a single image element in the gallery DOM\n replaceImageInGallery(imageId, html) {\n const existingImage = document.querySelector(`li[data-image-id=\"${imageId}\"]`);\n if (!existingImage) return;\n\n const parser = new DOMParser();\n const doc = parser.parseFromString(html, 'text/html');\n const newImage = doc.querySelector('li[data-image-id]');\n\n if (newImage) {\n existingImage.replaceWith(newImage);\n }\n }\n\n // Targeted refresh: Refresh sidebar thumbnail in case decorated image is the display image\n refreshSidebarThumbnail(productId) {\n // Container ID pattern from product_sidebar_category_product partial\n const container = document.getElementById(\n `sidebar-product-display-image-container-${productId}`,\n );\n if (!container) return;\n\n const img = container.querySelector('img');\n if (!img) return;\n\n // Force cache-busting by appending timestamp to current src\n const currentSrc = img.src.split('?')[0];\n img.src = `${currentSrc}?t=${Date.now()}`;\n }\n\n // Autosave: Get reference to canvas controller\n getCanvasController() {\n // DEFENSIVE: Check if controller is still connected to application\n // Phase 4.40: Use window.Stimulus.application to avoid context dependency\n // this.application getter requires this.context which may be undefined\n if (!window.Stimulus || !window.Stimulus.application) {\n return null;\n }\n\n const canvasElement = document.querySelector('[data-controller=\"decorator-canvas-artboard\"]');\n if (!canvasElement) return null;\n\n // Use proper Stimulus 1.1.0 API (fixes timing bug with _controller property)\n return window.Stimulus.application.getControllerForElementAndIdentifier(\n canvasElement,\n 'decorator-canvas-artboard',\n );\n }\n\n // Autosave: Trigger save directly (refactored - no longer delegates to decorator-save controller)\n // @param {Object} buttonElements - Optional button elements for manual save (button, buttonText, buttonSpinner)\n async triggerAutosave(buttonElements = null) {\n // Phase 4.40: Simplified validation - let code fail gracefully if needed\n // Previous phases (4.21, 4.29, 4.31) added extensive context/scope checks that were\n // aborting save even when the controller could actually function. The root cause of\n // undefined context is a Stimulus initialization timing issue, but the actual save\n // logic doesn't require context - it just needs the canvas controller and product data.\n //\n // Keep only essential check: verify 'this' exists\n if (!this) {\n return false;\n }\n\n // Guard against double-click during save operation\n if (this.isSaving) {\n return false;\n }\n\n this.isSaving = true;\n\n try {\n const result = await this.performSave(buttonElements);\n // Phase 4.89: Return full result object to include async flag\n // This allows manualSave() to skip premature close for async jobs\n return result || { success: false };\n } catch {\n return { success: false };\n } finally {\n // CRITICAL: Always reset saving flag, even on error\n // Ensures Save button remains functional after any failure\n this.isSaving = false;\n }\n }\n\n // Autosave: Show error dialog with retry/discard/cancel options\n showInlineErrorNotification(errorMessage) {\n // Use initialized targets from connect()\n if (!this.errorNotificationTarget || !this.errorMessageTarget) {\n return;\n }\n\n this.errorMessageTarget.textContent = errorMessage || 'Your decoration changes could not be saved.';\n this.errorNotificationTarget.style.display = 'block';\n }\n\n hideInlineErrorNotification() {\n // Use initialized target from connect()\n if (this.errorNotificationTarget) {\n this.errorNotificationTarget.style.display = 'none';\n }\n }\n\n /**\n * Manual save triggered by user clicking Save button\n * Hooks into the autosave mechanism to avoid Stimulus initialization race conditions\n * @param {Event} event - Click event from Save button\n */\n async manualSave(event) {\n if (event) {\n event.preventDefault();\n }\n\n // Phase 4.131: Capture product ID NOW while modal DOM still exists\n // The .ae-selected element is inside the modal and will be removed on close\n this.capturedProductIdForRefresh = this.getCapturedProductIdFromModal();\n\n // Get button and text elements using querySelector (targets not set up)\n const button = event ? event.currentTarget : document.querySelector('[data-target=\"decorator-modal.saveButton\"]');\n const buttonText = document.querySelector('[data-target=\"decorator-modal.saveButtonText\"]');\n const buttonSpinner = document.querySelector('[data-target=\"decorator-modal.saveButtonSpinner\"]');\n\n if (!button) {\n return;\n }\n\n // Show loading state\n button.disabled = true;\n if (buttonText) buttonText.style.display = 'none';\n if (buttonSpinner) buttonSpinner.style.display = '';\n\n try {\n // Use existing autosave mechanism (proven working)\n // Pass button elements so decorator-save can manipulate the correct button\n // Phase 4.89: triggerAutosave now returns full result object\n const result = await this.triggerAutosave({\n button: button,\n buttonText: buttonText,\n buttonSpinner: buttonSpinner,\n });\n\n if (result && result.success) {\n // Show success feedback briefly\n if (buttonText) {\n buttonText.textContent = '\u2713 Saved';\n buttonText.style.display = '';\n }\n if (buttonSpinner) buttonSpinner.style.display = 'none';\n\n // Phase 4.89: Only close for sync saves\n // For async jobs, handleProgressComplete() will close the modal after job completes\n if (!result.async) {\n // Sync save - close after brief feedback\n setTimeout(() => {\n // Phase 4.105: Wrap in try/catch to ensure closeWithoutSaveCheck always runs\n // If scope is invalidated from drag operations, refreshProductGallery may throw\n try {\n this.refreshProductGallery();\n } catch (error) {\n // Log but don't block close - gallery can refresh on page reload\n console.warn('[manualSave] Gallery refresh failed:', error.message);\n }\n this.closeWithoutSaveCheck();\n }, 500);\n }\n // For async saves, handleProgressComplete() handles gallery refresh and close\n } else {\n // Show error feedback\n if (buttonText) {\n buttonText.textContent = 'Save Failed';\n buttonText.style.display = '';\n }\n if (buttonSpinner) buttonSpinner.style.display = 'none';\n\n // Reset button after 2 seconds\n setTimeout(() => {\n if (buttonText) buttonText.textContent = 'SAVE';\n button.disabled = false;\n }, 2000);\n\n // Also show inline error notification\n this.showInlineErrorNotification('Your decoration changes could not be saved.');\n }\n } catch {\n // Show error feedback - ALWAYS re-enable button regardless of context state\n if (buttonText) {\n buttonText.textContent = 'Error';\n buttonText.style.display = '';\n }\n if (buttonSpinner) buttonSpinner.style.display = 'none';\n\n // Reset button after 2 seconds\n setTimeout(() => {\n if (buttonText) buttonText.textContent = 'SAVE';\n button.disabled = false;\n }, 2000);\n\n // Show inline error notification\n this.showInlineErrorNotification('An error occurred while saving.');\n }\n }\n\n async retryFailedSave(event) {\n if (event) {\n event.preventDefault();\n }\n\n // Hide error notification\n this.hideInlineErrorNotification();\n\n // Attempt save again\n const saved = await this.triggerAutosave();\n\n if (!saved) {\n this.showInlineErrorNotification('Your decoration changes could not be saved.');\n }\n }\n\n // ========================================================================\n // SAVE FUNCTIONALITY (Refactored from decorator_save_controller)\n // ========================================================================\n\n // Phase 4.38: Handle decorator:triggerSave event from ajax_popup_controller\n async handleTriggerSave(event) {\n // Guard against orphaned callback after disconnect\n // Note: Use this.context instead of this.scope to avoid getter throwing\n if (!this.context || !this.element) {\n return;\n }\n\n // Get button elements from event detail if provided\n const buttonElements = event.detail?.buttons || null;\n\n try {\n const result = await this.performSave(buttonElements);\n\n if (result && result.success) {\n // Save succeeded - close modal\n this.close();\n } else {\n // Save failed - show error\n this.showInlineErrorNotification(result?.error || 'Failed to save decoration');\n }\n } catch (error) {\n console.error('[Phase 4.38] Save error:', error);\n this.showInlineErrorNotification('An unexpected error occurred');\n }\n }\n\n async performSave(buttonElements = null) {\n // Phase 4.131: Capture product ID NOW while modal DOM still exists\n // Only capture if not already captured by manualSave()\n if (!this.capturedProductIdForRefresh) {\n this.capturedProductIdForRefresh = this.getCapturedProductIdFromModal();\n }\n\n // Phase 4.40: Use direct DOM access instead of this.element getter\n const modalElement = document.getElementById('decorator-modal');\n\n try {\n // Get sale ID and context from modal data attributes\n const saleId = modalElement?.dataset.saleId;\n const context = modalElement?.dataset.context || this.determineContext();\n\n if (!saleId) {\n this.showError('Sale ID not found');\n this.setLoadingState(false, buttonElements);\n return { success: false, error: 'No sale ID' };\n }\n\n // Get canvas controller using Stimulus 1.1.0 compatible method\n const canvasElement = document.querySelector('[data-controller*=\"decorator-canvas-artboard\"]');\n\n if (!canvasElement) {\n this.showError('Canvas element not found');\n this.setLoadingState(false, buttonElements);\n return { success: false, error: 'Canvas element not found' };\n }\n\n // Phase 4.40: Check if Stimulus is available (not controller.application)\n if (!window.Stimulus || !window.Stimulus.application) {\n this.showError('Save controller disconnected. Please refresh the page.');\n this.setLoadingState(false, buttonElements);\n return { success: false, error: 'Controller disconnected' };\n }\n\n // Phase 4.40: Use window.Stimulus.application instead of this.application\n // this.application is a getter that requires this.context, which is undefined\n // window.Stimulus.application works regardless of controller state\n const canvasController = window.Stimulus.application.getControllerForElementAndIdentifier(\n canvasElement,\n 'decorator-canvas-artboard',\n );\n\n if (!canvasController) {\n this.showError('Canvas controller not found. Please refresh the page and try again.');\n this.setLoadingState(false, buttonElements);\n return { success: false, error: 'Canvas controller not found' };\n }\n\n // Validate canvas controller has required methods\n if (typeof canvasController.currentImageId === 'undefined' &&\n typeof canvasController.getCurrentImageId !== 'function') {\n this.showError('Canvas controller is not properly initialized. Please refresh the page.');\n this.setLoadingState(false, buttonElements);\n return { success: false, error: 'Canvas controller not initialized' };\n }\n\n // Check if there's an image loaded\n const currentImageId = canvasController.currentImageId ||\n (canvasController.getCurrentImageId && canvasController.getCurrentImageId());\n\n if (!currentImageId) {\n this.showError('Please select a product image first');\n this.setLoadingState(false, buttonElements);\n return { success: false, error: 'No image selected' };\n }\n\n // Validate canvas controller has getPlacementData method\n if (typeof canvasController.getPlacementData !== 'function') {\n this.showError('Canvas controller is not properly initialized. Please refresh the page.');\n this.setLoadingState(false, buttonElements);\n return { success: false, error: 'getPlacementData not available' };\n }\n\n // Get placement data\n const placements = canvasController.getPlacementData();\n\n const isEmpty = !placements || Object.keys(placements).length === 0;\n\n if (isEmpty) {\n // Check if user intentionally removed existing artwork (vs. fresh canvas)\n const isDirty = canvasController.hasDirtyState && canvasController.hasDirtyState();\n const hasTrackedDeletions = this.hasTrackedDeletions(saleId);\n\n if (!isDirty || !hasTrackedDeletions) {\n this.showError('No artwork placed on canvas');\n this.setLoadingState(false, buttonElements);\n return { success: false, error: 'No artwork on canvas' };\n }\n // Intentional removal of existing artwork \u2014 proceed with clear\n }\n\n // Build request params\n let params;\n try {\n params = this.buildSaveParams(canvasController, placements, context, currentImageId);\n\n // DEFENSIVE VALIDATION: Verify product ID matches current selection\n // Phase 4.41: Convert to strings for comparison (HTML data attributes are strings,\n // JSON parsed product data contains numbers). Avoids false positive mismatch errors.\n if (context === 'single_product' && params.product_id) {\n const selectedProduct = document.querySelector('.product-list-item.ae-selected');\n if (selectedProduct) {\n const actualProductData = JSON.parse(selectedProduct.dataset.product || '{}');\n if (actualProductData.id && String(params.product_id) !== String(actualProductData.id)) {\n // Use current product ID as fail-safe\n params.product_id = actualProductData.id;\n }\n }\n }\n\n // Inject clear_image_ids for intentional decoration removal\n const clearImageIds = this.getClearImageIds(saleId, canvasController);\n if (clearImageIds.length > 0) {\n params.clear_image_ids = clearImageIds;\n }\n } catch (error) {\n // JSON parsing or other data preparation errors\n this.showError('Failed to prepare decoration data: ' + error.message);\n this.setLoadingState(false, buttonElements);\n return { success: false, error: 'Preparation failed: ' + error.message };\n }\n\n // Show loading state\n this.setLoadingState(true, buttonElements);\n\n try {\n const response = await fetch(`/teamsites/${saleId}/decorator`, {\n method: 'POST',\n credentials: 'same-origin',\n headers: {\n 'Content-Type': 'application/json',\n 'X-CSRF-Token': document.querySelector('[name=\"csrf-token\"]').content,\n 'Accept': 'application/json',\n },\n body: JSON.stringify(params),\n });\n\n // Handle server errors (503, 500, etc.) before trying to parse JSON\n if (response.status >= 500) {\n const errorMessage = response.status === 503\n ? 'Server is temporarily unavailable. Please try again in a moment.'\n : `Server error (${response.status}). Please try again.`;\n this.showError(errorMessage);\n this.setLoadingState(false, buttonElements);\n return { success: false, error: errorMessage };\n }\n\n const data = await response.json();\n\n if (response.status === 202 && data.status === 'processing') {\n // Phase 4.109: Close immediately, let page-level tracker handle polling\n if (!data.job_id) {\n throw new Error('No job ID received from server');\n }\n\n // Get current product/image info for the event\n const modalElement = document.getElementById('decorator-modal');\n const productId = modalElement?.dataset.productId;\n // Phase 4.129: Also capture all selected product IDs for bulk refresh\n let productIds = [];\n try {\n productIds = modalElement?.dataset.productIds\n ? JSON.parse(modalElement.dataset.productIds)\n : [];\n } catch (_e) {\n // Ignore JSON parse errors - fall back to empty array\n }\n const canvasElement = document.querySelector('[data-controller*=\"decorator-canvas-artboard\"]');\n const canvasController = canvasElement && window.Stimulus?.application\n ? window.Stimulus.application.getControllerForElementAndIdentifier(canvasElement, 'decorator-canvas-artboard')\n : null;\n const currentImageId = canvasController?.currentImageId ||\n (canvasController?.getCurrentImageId && canvasController.getCurrentImageId());\n\n // Phase 4.133: Determine correct product ID for gallery refresh\n // For bulk mode: only refresh if sidebar's active product is in the selection\n // For single mode: use the captured product ID\n let refreshProductId = null;\n if (productIds.length > 0) {\n // Bulk mode - use sidebar's active product if it's in the selection\n refreshProductId = this.getProductIdForBulkRefresh(productIds);\n } else {\n // Single mode - use captured product ID\n refreshProductId = this.capturedProductIdForRefresh || productId;\n }\n\n // Dispatch job to page-level tracker\n document.dispatchEvent(new CustomEvent('decorator:job:queued', {\n bubbles: true,\n detail: {\n jobId: data.job_id,\n saleId: saleId,\n productId: refreshProductId, // Phase 4.133: May be null if active product not in selection\n productIds: productIds, // Phase 4.129: Include all selected products for bulk refresh\n imageIds: currentImageId ? [currentImageId] : [],\n timestamp: Date.now(),\n },\n }));\n\n // Show \"in progress\" flash message\n if (window.App && window.App.showSuccessFlash) {\n window.App.showSuccessFlash('Decoration in progress...');\n }\n\n // Reset dirty state and close immediately\n if (canvasController && canvasController.resetDirtyState) {\n canvasController.resetDirtyState();\n }\n this.setLoadingState(false, buttonElements);\n this.closeWithoutSaveCheck();\n\n return { success: true, async: true, jobId: data.job_id };\n } else if (response.status === 201 && data.status === 'completed') {\n // Sync processing - immediate success\n return this.handleSuccess(data.notice || 'Decoration saved successfully', buttonElements);\n } else if (response.ok) {\n // Handle any other successful responses\n return this.handleSuccess(data.notice || 'Decoration saved successfully', buttonElements);\n } else {\n this.showError(data.error || 'Failed to save decorations');\n this.setLoadingState(false, buttonElements);\n return { success: false, error: data.error || 'Save failed' };\n }\n } catch (error) {\n this.showError('Network error: ' + error.message);\n this.setLoadingState(false, buttonElements);\n return { success: false, error: error.message };\n }\n } catch (error) {\n // Catch any unexpected errors in the entire method\n this.showError('An unexpected error occurred. Please try again.');\n this.setLoadingState(false, buttonElements);\n return { success: false, error: error.message || 'Unexpected error' };\n }\n }\n\n async handleAsyncProcessing(data, saleId = null, buttonElements = null) {\n // Support both calling conventions:\n // 1. New format: handleAsyncProcessing({ job_id: 'xxx' })\n // 2. Old format: handleAsyncProcessing('job-id-string', saleId, buttonElements)\n let jobId;\n\n if (typeof data === 'object' && data !== null) {\n // New format from tests - extract job_id from object\n jobId = data.job_id;\n // saleId fallback: use from modal element if not provided\n if (!saleId) {\n const modalElement = document.getElementById('decorator-modal');\n saleId = modalElement?.dataset.saleId;\n }\n } else {\n // Old format - data IS the jobId string\n jobId = data;\n }\n\n if (!jobId) {\n this.showError('No job ID provided');\n return;\n }\n\n // Phase 3: Use enhanced progress UI instead of simple text\n this.showEnhancedProgressUI();\n\n const pollInterval = 500; // Poll every 500ms\n const maxAttempts = 60; // Max 30 seconds\n let attempts = 0;\n\n const checkProgress = async () => {\n // Guard against disconnect - stop polling if controller is disconnected\n if (!this.isConnected) {\n return;\n }\n\n attempts++;\n\n try {\n const csrfToken = document.querySelector('[name=\"csrf-token\"]')?.content || '';\n const response = await fetch(`/teamsites/${saleId}/decorator/job_progress/${jobId}`, {\n method: 'GET',\n credentials: 'same-origin',\n headers: {\n 'Accept': 'application/json',\n 'X-CSRF-Token': csrfToken,\n },\n });\n\n if (response.status === 404) {\n // Not found - job data not in cache\n if (attempts <= 3) {\n // Early attempts: might be a race condition, retry with longer delay\n setTimeout(checkProgress, pollInterval * 2);\n return;\n } else if (attempts < maxAttempts) {\n // Continue polling but with normal interval\n setTimeout(checkProgress, pollInterval);\n return;\n } else {\n // Max attempts reached\n this.handleProgressTimeout(buttonElements);\n return;\n }\n }\n\n if (!response.ok) {\n throw new Error(`Failed to check progress (${response.status})`);\n }\n\n const progress = await response.json();\n\n if (progress.status === 'completed') {\n // Phase 3: Use new completion handler with progress data\n this.handleProgressComplete(progress, buttonElements);\n } else if (progress.status === 'failed') {\n // Phase 3: Use new error handler with progress data\n this.handleProgressError(progress, buttonElements);\n } else if (attempts < maxAttempts) {\n // Phase 3: Update enhanced UI with progress data\n this.updateProgressDisplay(progress);\n setTimeout(checkProgress, pollInterval);\n } else {\n // Phase 3: Use new timeout handler\n this.handleProgressTimeout(buttonElements);\n }\n } catch (error) {\n // Show error immediately on first failure (for user feedback)\n // but continue retrying in background\n this.showInlineErrorNotification('Failed to check decoration progress: ' + error.message);\n\n if (attempts < maxAttempts) {\n // Retry on network errors\n setTimeout(checkProgress, pollInterval);\n } else {\n // Max retries reached - hide progress indicator and re-enable buttons\n if (this.progressIndicatorTarget) {\n this.progressIndicatorTarget.style.display = 'none';\n }\n\n this.setLoadingState(false, buttonElements);\n }\n }\n };\n\n // Start polling immediately, then continue with interval\n checkProgress();\n }\n\n buildSaveParams(canvasController, placements, context, currentImageId) {\n // BULK MODE BYPASS: Treat bulk as single-product\n const effectiveContext = this.shouldBypassBulkMode() ? 'single_product' : context;\n\n if (this.shouldBypassBulkMode() && context === 'bulk') {\n console.warn('[BULK BYPASS] Force single mode enabled - using single-product save flow');\n }\n\n // Phase 4.372: Save-time dimension check\n // Compare canvas dimensions at save time with earlier measurements\n const saveTimeDimensions = {\n width: canvasController.element.clientWidth,\n height: canvasController.element.clientHeight,\n timestamp: Date.now(),\n };\n\n // Phase 4.259.2: Get board dimensions from canvasController.boardDimensions\n // The boardDimensions are stored as a JS property on the controller, NOT as a DOM data attribute.\n // Previous code read from canvasElement.dataset.boardDimensions which doesn't exist,\n // causing fallback to { width: 800, height: 600 } and incorrect coordinate scaling.\n const boardDims = canvasController && canvasController.boardDimensions\n ? canvasController.boardDimensions\n : { width: 800, height: 600 };\n\n // Phase 4.259: Get client image height (naturalHeight) for dimension mismatch detection\n // This is the reference dimension used for percentage calculations in JavaScript.\n // If server's base_image.height differs from this, coordinates will be shifted.\n let clientImageHeight = null;\n if (canvasController && canvasController.currentCanvas && canvasController.currentCanvas.imageEl) {\n const imageEl = canvasController.currentCanvas.imageEl;\n clientImageHeight = imageEl.naturalHeight || imageEl.height;\n }\n\n // Collect placements from ALL decorated images (multi-image support)\n // This ensures front/back/side images are all saved, not just the current one\n const allImagePlacements = canvasController && canvasController.getAllImagePlacements\n ? canvasController.getAllImagePlacements()\n : { [currentImageId]: placements }; // Fallback to current image only\n\n // Merge artwork IDs from all images\n const allArtworkIds = new Set();\n Object.values(allImagePlacements).forEach(imagePlacements => {\n Object.values(imagePlacements).forEach(placement => {\n if (placement.artworkId) {\n allArtworkIds.add(placement.artworkId);\n }\n });\n });\n\n const params = {\n artwork_ids: Array.from(allArtworkIds),\n board_width: boardDims.width,\n board_height: boardDims.height,\n client_image_height: clientImageHeight, // Phase 4.259: For dimension mismatch detection\n art_placements_by_image_id: allImagePlacements,\n };\n\n // Add context-specific params\n if (effectiveContext === 'single_product') {\n const modalElement = document.getElementById('decorator-modal');\n\n // Phase 4.370: When bypass is active and original context was bulk,\n // extract first product ID from bulk data structure\n if (this.shouldBypassBulkMode() && context === 'bulk') {\n // Extract first product from data-product-ids array\n if (modalElement?.dataset.productIds) {\n try {\n const productIds = JSON.parse(modalElement.dataset.productIds);\n if (productIds && productIds.length > 0) {\n params.product_id = productIds[0]; // First product in bulk selection\n }\n } catch (e) {\n console.error('[BULK BYPASS] Failed to parse product IDs:', e);\n }\n }\n\n // For color_variant_id, we'll need to infer from the first product\n // Leave it undefined - backend will select first variant\n params.color_variant_id = undefined;\n } else {\n // Normal single_product mode - use standard extraction\n params.product_id = modalElement?.dataset.productId;\n params.color_variant_id = modalElement?.dataset.colorVariantId;\n }\n\n // Fallback: try to get product ID from the selected product in the gallery\n if (!params.product_id) {\n const selectedProduct = document.querySelector('.product-list-item.ae-selected');\n if (selectedProduct) {\n const productData = JSON.parse(selectedProduct.dataset.product || '{}');\n params.product_id = productData.id;\n }\n }\n\n params.image_id = currentImageId;\n\n } else if (effectiveContext === 'bulk') {\n // Get selected products - first from modal data attribute (authoritative), then checkboxes\n let selectedIds = [];\n\n // Primary source: modal data-product-ids attribute (set by Rails controller)\n const modalElement = document.getElementById('decorator-modal');\n if (modalElement?.dataset.productIds) {\n try {\n selectedIds = JSON.parse(modalElement.dataset.productIds);\n } catch (e) {\n // Fall through to checkbox approach\n }\n }\n\n // Fallback: query checkboxes if data attribute not available\n if (selectedIds.length === 0) {\n const checkboxes = document.querySelectorAll('input[name=\"product_ids[]\"]:checked');\n selectedIds = Array.from(checkboxes).map(cb => cb.value);\n }\n\n if (selectedIds.length === 0) {\n throw new Error('No products selected');\n }\n\n // Phase 4.120: Capture current canvas state and get per-product placements\n if (canvasController && canvasController.captureCurrentPlacementsToGlobal) {\n canvasController.captureCurrentPlacementsToGlobal();\n }\n\n // Phase 4.228: Get per-product placements - each product has independent coordinates\n // NO template concept - bulk decoration = collection of independent single-product decorations\n const perProductPlacements = canvasController && canvasController.getAllProductPlacements\n ? canvasController.getAllProductPlacements(selectedIds)\n : null;\n\n if (perProductPlacements) {\n // Phase 4.228: Send ALL products' placements individually\n // Each product that was decorated gets its own coordinates\n // Products not decorated = not in artPlacementsByImageId = won't be decorated (backend Phase 4.219)\n\n const artPlacementsByImageId = {};\n const loadedImageIds = [];\n\n for (const [productId, data] of Object.entries(perProductPlacements)) {\n if (!data.placements || data.placements.length === 0) continue;\n\n const targetId = data.targetImageId;\n if (!targetId) continue;\n\n // Send this product's placements\n // Phase 4.355: Apply same transformation as getPlacementData() to ensure\n // bulk mode sends complete coordinate data. Without this, critical fields\n // like dropTimeActualImageHeight are missing, causing coordinate shift.\n const placementsObj = {};\n data.placements.forEach((p, idx) => {\n placementsObj[`placement_${idx}`] = {\n artworkId: parseInt(p.artworkId),\n left: p.left,\n top: p.top,\n width: p.width,\n height: p.height,\n rotate: p.rotate ?? (p.rotation ? p.rotation * (Math.PI / 180) : 0),\n mirrorHorizontally: p.mirrorHorizontally ?? p.flipX ?? false,\n mirrorVertically: p.mirrorVertically ?? p.flipY ?? false,\n // Phase 4.324/4.281: Critical fields for server-side dimension matching\n // Fallback chain handles both raw fields and pre-transformed names\n dropTimeActualImageHeight: p.dropTimeActualImageHeight ?? p.actualImageHeight ?? null,\n dropTimeBgStartX: p.dropTimeBgStartX ?? p.bgStart?.x ?? null,\n dropTimeBgStartY: p.dropTimeBgStartY ?? p.bgStart?.y ?? null,\n dropTimeIsVerticalFit: p.dropTimeIsVerticalFit ?? p.isVerticalFit ?? null,\n };\n\n });\n artPlacementsByImageId[String(targetId)] = placementsObj;\n loadedImageIds.push(targetId);\n }\n\n if (Object.keys(artPlacementsByImageId).length > 0) {\n params.art_placements_by_image_id = artPlacementsByImageId;\n }\n\n // Phase 4.418: Send default (unedited) placement alongside per-image overrides.\n // The backend uses this for cross-color propagation instead of copying the\n // resized override from one color to sibling colors.\n const bulkData = window.globalBulkDecorationPlacements\n ? window.globalBulkDecorationPlacements.get(canvasController.saleId)\n : null;\n if (bulkData && bulkData.default && bulkData.default.placements) {\n const defaultPlacementsObj = {};\n bulkData.default.placements.forEach((p, idx) => {\n defaultPlacementsObj[`placement_${idx}`] = {\n artworkId: parseInt(p.artworkId),\n left: p.left,\n top: p.top,\n width: p.width,\n height: p.height,\n rotate: p.rotate ?? (p.rotation ? p.rotation * (Math.PI / 180) : 0),\n mirrorHorizontally: p.mirrorHorizontally ?? p.flipX ?? false,\n mirrorVertically: p.mirrorVertically ?? p.flipY ?? false,\n dropTimeActualImageHeight: p.dropTimeActualImageHeight ?? p.actualImageHeight ?? null,\n dropTimeBgStartX: p.dropTimeBgStartX ?? p.bgStart?.x ?? null,\n dropTimeBgStartY: p.dropTimeBgStartY ?? p.bgStart?.y ?? null,\n dropTimeIsVerticalFit: p.dropTimeIsVerticalFit ?? p.isVerticalFit ?? null,\n };\n });\n params.default_placement = defaultPlacementsObj;\n }\n\n // Phase 4.420: Collect clear_image_ids from intentionally cleared products\n const bulkClearImageIds = [];\n for (const [productId, data] of Object.entries(perProductPlacements)) {\n if (data.cleared && data.targetImageId) {\n bulkClearImageIds.push(data.targetImageId);\n }\n }\n if (bulkClearImageIds.length > 0) {\n params.clear_image_ids = bulkClearImageIds;\n }\n\n params.bulk_options = {\n product_ids: selectedIds,\n loaded_image_ids: loadedImageIds,\n };\n } else {\n // No placements captured - just send product IDs\n params.bulk_options = {\n product_ids: selectedIds,\n };\n }\n } else if (effectiveContext === 'all_products') {\n // All products mode\n // Phase 4.398: Removed template_image_id - each product has independent canvas state\n // Position filtering is now handled by which images have explicit placements\n params.bulk_options = {\n apply_to_all: true,\n };\n }\n\n return params;\n }\n\n // Check if globalCanvasChanges has tracked server-side deletions for this sale\n // Key format: saleId_colorVariantId_imageId (three-part, per Phase 4.389)\n hasTrackedDeletions(saleId) {\n if (!window.globalCanvasChanges || !(window.globalCanvasChanges instanceof Map)) {\n return false;\n }\n\n for (const entry of window.globalCanvasChanges) {\n const key = entry[0];\n const changes = entry[1];\n if (key.startsWith(saleId + '_') && changes.deleted && changes.deleted.size > 0) {\n return true;\n }\n }\n return false;\n }\n\n // Get image IDs that had server-side deletions and now have zero placements\n getClearImageIds(saleId, canvasController) {\n if (!window.globalCanvasChanges || !(window.globalCanvasChanges instanceof Map)) {\n return [];\n }\n\n let allImagePlacements = {};\n if (typeof canvasController.getAllImagePlacements === 'function') {\n allImagePlacements = canvasController.getAllImagePlacements() || {};\n }\n\n const clearIds = [];\n for (const entry of window.globalCanvasChanges) {\n const key = entry[0];\n const changes = entry[1];\n if (!key.startsWith(saleId + '_')) {\n continue;\n }\n\n if (!changes.deleted || changes.deleted.size === 0) {\n continue;\n }\n\n // Extract imageId from key format: saleId_colorVariantId_imageId\n const afterSale = key.substring((saleId + '_').length);\n const underscoreIndex = afterSale.indexOf('_');\n const imageId = underscoreIndex !== -1 ? afterSale.substring(underscoreIndex + 1) : afterSale;\n // Only clear if the image now has zero placements\n const currentPlacements = allImagePlacements[imageId];\n if (!currentPlacements || Object.keys(currentPlacements).length === 0) {\n clearIds.push(parseInt(imageId, 10));\n }\n }\n return clearIds.filter(function(id) { return !isNaN(id) && id > 0; });\n }\n\n showProgressUI(buttonElements = null) {\n const saveButton = buttonElements?.button || document.querySelector('[data-target=\"decorator-modal.saveButton\"]');\n const buttonText = buttonElements?.buttonText || document.querySelector('[data-target=\"decorator-modal.saveButtonText\"]');\n const spinner = buttonElements?.buttonSpinner || document.querySelector('[data-target=\"decorator-modal.saveButtonSpinner\"]');\n\n if (buttonText) {\n buttonText.style.display = 'none';\n }\n\n if (spinner) {\n spinner.style.display = '';\n }\n\n // Create or update progress text element\n let progressText = document.querySelector('[data-target=\"decorator-modal.progressText\"]');\n if (!progressText && saveButton) {\n progressText = document.createElement('span');\n progressText.setAttribute('data-target', 'decorator-modal.progressText');\n progressText.style.marginLeft = '10px';\n saveButton.appendChild(progressText);\n }\n\n if (progressText) {\n progressText.style.display = '';\n progressText.textContent = 'Processing...';\n }\n\n if (saveButton) {\n saveButton.disabled = true;\n }\n }\n\n updateProgressText(text, buttonElements = null) {\n const progressText = buttonElements?.progressText || document.querySelector('[data-target=\"decorator-modal.progressText\"]');\n if (progressText) {\n progressText.textContent = text;\n }\n }\n\n handleSuccess(message, buttonElements = null) {\n this.setLoadingState(false, buttonElements);\n this.showInModalSuccess(message, buttonElements);\n\n // Reset canvas dirty state after successful save\n const canvasController = this.getCanvasController();\n if (canvasController && canvasController.resetDirtyState) {\n canvasController.resetDirtyState();\n }\n\n // Modal stays open - gallery refresh happens on modal close\n return { success: true };\n }\n\n handleError(message, buttonElements = null) {\n this.setLoadingState(false, buttonElements);\n this.showError(message);\n }\n\n setLoadingState(loading, buttonElements = null) {\n // Use passed button elements if provided, otherwise fall back to querySelector for modal targets\n const saveButton = buttonElements?.button || document.querySelector('[data-target=\"decorator-modal.saveButton\"]');\n const buttonText = buttonElements?.buttonText || document.querySelector('[data-target=\"decorator-modal.saveButtonText\"]');\n const spinner = buttonElements?.buttonSpinner || document.querySelector('[data-target=\"decorator-modal.saveButtonSpinner\"]');\n const progressText = document.querySelector('[data-target=\"decorator-modal.progressText\"]');\n\n if (saveButton) {\n saveButton.disabled = loading;\n }\n\n if (buttonText) {\n buttonText.style.display = loading ? 'none' : '';\n }\n\n if (spinner) {\n spinner.style.display = loading ? '' : 'none';\n }\n\n if (progressText) {\n progressText.style.display = 'none';\n }\n }\n\n showError(message) {\n // Use proper user messaging instead of alert()\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash(message);\n } else {\n // Fallback to proper flash message\n this.createFlashMessage(message, 'error');\n }\n }\n\n showSuccess(message) {\n // Check if the app has a flash message system\n if (window.App && window.App.showSuccessFlash) {\n window.App.showSuccessFlash(message);\n } else {\n // Fallback to simple notification\n this.createFlashMessage(message, 'success');\n }\n }\n\n showInfo(message) {\n if (window.App && window.App.showInfoFlash) {\n window.App.showInfoFlash(message);\n } else {\n this.createFlashMessage(message, 'info');\n }\n }\n\n showInModalSuccess(message, buttonElements = null) {\n // Display success indicator in the modal footer (to the left of save button)\n // FIX: Use modal button targets, not decorator-save targets\n const saveButton = buttonElements?.button || document.querySelector('[data-target=\"decorator-modal.saveButton\"]');\n if (!saveButton) {\n return;\n }\n\n // Create or find success indicator element\n let successIndicator = document.querySelector('[data-target=\"decorator-modal.successIndicator\"]');\n if (!successIndicator) {\n successIndicator = document.createElement('span');\n successIndicator.setAttribute('data-target', 'decorator-modal.successIndicator');\n successIndicator.style.cssText = `\n display: inline-flex;\n align-items: center;\n margin-right: 12px;\n color: #4CAF50;\n font-weight: 500;\n opacity: 0;\n transition: opacity 0.3s ease-in-out;\n `;\n\n // Insert before the save button (to the left)\n saveButton.parentNode.insertBefore(successIndicator, saveButton);\n }\n\n // Set message with checkmark icon\n successIndicator.innerHTML = `\n \n ${message}\n `;\n\n // Fade in\n successIndicator.style.display = 'inline-flex';\n setTimeout(() => {\n successIndicator.style.opacity = '1';\n }, 10);\n\n // Auto-hide after 3 seconds with fade out\n setTimeout(() => {\n successIndicator.style.opacity = '0';\n setTimeout(() => {\n successIndicator.style.display = 'none';\n }, 300);\n }, 3000);\n }\n\n createFlashMessage(message, type = 'success') {\n // Create a simple flash message element\n const flash = document.createElement('div');\n flash.className = `flash-message flash-${type}`;\n flash.style.cssText = `\n position: fixed;\n top: 20px;\n left: 50%;\n transform: translateX(-50%);\n background: ${type === 'success' ? '#4CAF50' : type === 'error' ? '#f44336' : '#2196F3'};\n color: white;\n padding: 15px 20px;\n border-radius: 4px;\n z-index: 10000;\n box-shadow: 0 2px 5px rgba(0,0,0,0.3);\n `;\n flash.textContent = message;\n\n document.body.appendChild(flash);\n\n // Auto-remove after 5 seconds\n setTimeout(() => {\n flash.remove();\n }, 5000);\n }\n\n // ==================== Phase 3: Progress Indicator Integration ====================\n\n // Phase 3: Show enhanced progress UI using Phase 2.3 progress indicator component\n showEnhancedProgressUI() {\n // Hide save button loading state\n this.setLoadingState(false);\n\n // Show progress indicator container\n if (this.progressIndicatorTarget) {\n this.progressIndicatorTarget.style.display = '';\n }\n\n // Initialize progress at 0%\n this.updateProgressDisplay({\n completed: 0,\n total: 0,\n status: 'processing',\n });\n }\n\n // Phase 3: Update progress display with backend data\n updateProgressDisplay(progressData) {\n const { completed, total, batch, failed_batches } = progressData;\n\n // Calculate percentage (capped at 100%)\n const percentage = total > 0 ? Math.min(Math.round((completed / total) * 100), 100) : 0;\n\n // Update progress bar fill width\n if (this.progressFillTarget) {\n this.progressFillTarget.style.width = `${percentage}%`;\n }\n\n // Update percentage text\n if (this.progressPercentageTarget) {\n const strongElement = this.progressPercentageTarget.querySelector('strong');\n if (strongElement) {\n strongElement.textContent = `${percentage}%`;\n }\n }\n\n // Update completed/total counts\n if (this.progressCompletedTarget) {\n this.progressCompletedTarget.textContent = completed || 0;\n }\n\n if (this.progressTotalTarget) {\n this.progressTotalTarget.textContent = total || 0;\n }\n\n // Show/update batch number if present\n if (batch && this.progressBatchTarget && this.batchNumberTarget) {\n this.progressBatchTarget.style.display = '';\n this.batchNumberTarget.textContent = batch;\n }\n\n // Show errors if any failed batches\n if (failed_batches && failed_batches.length > 0 && this.progressErrorsTarget) {\n this.progressErrorsTarget.style.display = '';\n if (this.errorMessageTarget) {\n const failedCount = failed_batches.reduce((sum, b) => sum + (b.failed_count || 0), 0);\n this.errorMessageTarget.textContent =\n `${failedCount} image${failedCount !== 1 ? 's' : ''} failed to decorate`;\n }\n }\n }\n\n // Phase 3: Handle progress completion\n handleProgressComplete(progressData, buttonElements) {\n // Hide any error notifications from previous failures\n this.hideInlineErrorNotification();\n\n // Update progress to 100%\n this.updateProgressDisplay({\n completed: progressData.total || 0,\n total: progressData.total || 0,\n status: 'completed',\n });\n\n // Hide progress indicator, show success message\n if (this.progressCompleteTarget && this.completeMessageTarget) {\n this.progressCompleteTarget.style.display = '';\n this.completeMessageTarget.textContent =\n 'All images have been decorated successfully.';\n }\n\n // Wait 2 seconds to show success, then refresh gallery and close modal\n setTimeout(async () => {\n if (this.progressIndicatorTarget) {\n this.progressIndicatorTarget.style.display = 'none';\n }\n\n // Reset canvas dirty state\n const canvasController = this.getCanvasController();\n if (canvasController && canvasController.resetDirtyState) {\n canvasController.resetDirtyState();\n }\n\n // Phase 4.105: Wrap refresh operations in try/catch to ensure closeWithoutSaveCheck runs\n // If scope is invalidated from drag operations, refresh methods may throw\n try {\n // Targeted refresh of decorated images only (instead of wholesale gallery refresh)\n if (progressData.decorated_image_ids?.length > 0) {\n await this.refreshDecoratedImages(progressData.decorated_image_ids);\n }\n\n // Also refresh sidebar thumbnail in case decorated image is the display image\n const productId = this.getProductIdForRefresh();\n if (productId) {\n this.refreshSidebarThumbnail(productId);\n }\n } catch (error) {\n // Log but don't block close - gallery can refresh on page reload\n console.warn('[handleProgressComplete] Refresh failed:', error.message);\n }\n\n this.closeWithoutSaveCheck();\n }, 2000);\n }\n\n // Phase 3: Handle progress error\n handleProgressError(progressData, buttonElements) {\n // Hide progress indicator\n if (this.progressIndicatorTarget) {\n this.progressIndicatorTarget.style.display = 'none';\n }\n\n // Show inline error notification\n const errorMessage = progressData.error || 'Decoration failed';\n this.showInlineErrorNotification(errorMessage);\n\n // Re-enable buttons\n this.setLoadingState(false, buttonElements);\n }\n\n // Phase 3: Handle progress timeout\n handleProgressTimeout(buttonElements) {\n // Hide progress indicator\n if (this.progressIndicatorTarget) {\n this.progressIndicatorTarget.style.display = 'none';\n }\n\n // Show inline error notification for timeout\n this.showInlineErrorNotification(\n 'Decoration timeout: taking longer than expected. Please refresh to check status.',\n );\n\n // Re-enable buttons\n this.setLoadingState(false, buttonElements);\n }\n}\n", "import { Controller } from '../stimulus';\nimport CSRFTokenMixin from '../mixins/csrf_token_mixin';\n\nexport default class extends Controller {\n static targets = ['item'];\n\n connect() {\n Object.assign(this, CSRFTokenMixin);\n this.currentPage = 0;\n this.currentTranslate = 0;\n this.maxTranslate = 0;\n\n this.calculateVisibleItems();\n this.updateNavButtons();\n\n // Find and mark the initially selected product\n const selectedItem = this.element.querySelector('.ae-selected');\n if (selectedItem) {\n const items = Array.from(this.itemTargets);\n const selectedIndex = items.indexOf(selectedItem);\n this.currentPage = Math.floor(selectedIndex / this.itemsPerPage);\n this.moveToPage(this.currentPage);\n } else {\n // Initialize position even if no selected item\n this.moveToPage(0);\n }\n }\n\n selectProduct(event) {\n event.preventDefault();\n\n const item = event.currentTarget;\n const productId = item.dataset.productId;\n\n // Remove previous selection\n this.itemTargets.forEach(target => {\n target.classList.remove('ae-selected');\n });\n\n // Mark as selected\n item.classList.add('ae-selected');\n\n // Load product colors\n this.loadProductColors(productId);\n\n // Dispatch event for other controllers\n const customEvent = new CustomEvent('product:selected', {\n bubbles: true,\n detail: { productId: productId },\n });\n this.element.dispatchEvent(customEvent);\n }\n\n async loadProductColors(productId) {\n try {\n const saleId = this.element.closest('[data-sale-id]')?.dataset.saleId;\n if (!saleId) return;\n\n const response = await fetch(`/teamsites/${saleId}/decorator/product_colors?product_id=${productId}`, {\n headers: this.getHeadersWithCSRF({\n 'Accept': 'text/html',\n }),\n });\n if (!response.ok) throw new Error('Failed to load colors');\n\n const html = await response.text();\n\n // Update color selector area if it exists\n const colorSelector = document.querySelector('[data-decorator-color-selector]');\n if (colorSelector) {\n colorSelector.innerHTML = html;\n }\n } catch {\n // Error loading product colors\n }\n }\n\n calculateVisibleItems() {\n const firstItem = this.itemTargets[0];\n\n if (!firstItem) return;\n\n // Use conservative estimate - only move a few items at a time\n // This prevents over-scrolling and clipping\n this.itemsPerPage = 3;\n this.totalPages = Math.ceil(this.itemTargets.length / this.itemsPerPage);\n }\n\n prev(event) {\n event.preventDefault();\n\n if (this.currentPage > 0) {\n this.currentPage--;\n this.moveToPage(this.currentPage);\n }\n }\n\n next(event) {\n event.preventDefault();\n\n if (this.currentPage < this.totalPages - 1) {\n this.currentPage++;\n this.moveToPage(this.currentPage);\n }\n }\n\n moveToPage(page) {\n const list = this.element.querySelector('.team-site-products-gallery-list');\n const container = this.element.querySelector('.team-site-products-gallery-scroll-container');\n const firstItem = this.itemTargets[0];\n if (!firstItem || !container) return;\n\n const itemWidth = firstItem.offsetWidth + 2; // Include margin\n const containerWidth = container.clientWidth;\n const totalWidth = list.scrollWidth;\n\n // Calculate max translate accounting for container padding\n // No buffer needed since CSS padding handles the spacing\n const maxTranslate = Math.max(0, totalWidth - containerWidth);\n\n let requestedTranslate;\n if (page >= this.totalPages - 1) {\n // On the last page, always scroll to maximum to show all content\n requestedTranslate = maxTranslate;\n } else {\n // For other pages, use page-based calculation\n requestedTranslate = page * this.itemsPerPage * itemWidth;\n }\n\n // Use the smaller of requested translate or max allowed\n const translateX = -Math.min(requestedTranslate, maxTranslate);\n\n // Store current transform for button visibility logic\n this.currentTranslate = Math.min(requestedTranslate, maxTranslate);\n this.maxTranslate = maxTranslate;\n\n list.style.transform = `translateX(${translateX}px)`;\n this.updateNavButtons();\n }\n\n updateNavButtons() {\n const prevButton = this.element.querySelector('.prev');\n const nextButton = this.element.querySelector('.next');\n\n // FORCE BOTH BUTTONS ALWAYS VISIBLE FOR DEBUGGING\n if (prevButton) {\n prevButton.style.display = 'flex';\n prevButton.style.visibility = 'visible';\n }\n\n if (nextButton) {\n nextButton.style.display = 'flex';\n nextButton.style.visibility = 'visible';\n }\n }\n\n selectImage(event) {\n event.preventDefault();\n\n const item = event.currentTarget;\n\n // Update active/selected state\n const siblings = this.element.querySelectorAll('.color-variant-item');\n siblings.forEach(sibling => sibling.classList.remove('selected'));\n item.classList.add('selected');\n\n // Get image data from element dataset\n const imageId = item.dataset.imageId;\n const imageUrl = item.dataset.imageUrl;\n\n // Dispatch orientation select event to trigger canvas switch\n const customEvent = new CustomEvent('decorator:orientations:select', {\n detail: {\n id: parseInt(imageId, 10),\n url: imageUrl,\n },\n bubbles: true,\n });\n document.dispatchEvent(customEvent);\n }\n}\n\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n static targets = ['link'];\n\n initialize() {\n this.productData = JSON.parse(this.element.dataset.product);\n this.initBackground();\n\n // Check if selected on load\n const isSelected = this.element.dataset.isSelected === 'true';\n\n if (isSelected) {\n // Add timeout so that listeners will have the chance to initialize\n setTimeout(() => {\n this.element.classList.add('ae-selected');\n // Also trigger the selection event to load the product on canvas\n this.triggerProductSelection();\n }, 100); // Increased timeout to ensure other controllers are ready\n }\n }\n\n initBackground() {\n const thumbUrl = this.productData.thumb_url;\n if (this.hasLinkTarget && thumbUrl) {\n this.linkTarget.style.backgroundImage = `url('${thumbUrl}')`;\n }\n }\n\n selectProduct(event) {\n event.preventDefault();\n this.performSelection();\n }\n\n performSelection() {\n // Remove selection from all items\n const gallery = this.element.closest('.team-site-products-gallery');\n gallery.querySelectorAll('.product-list-item').forEach(item => {\n item.classList.remove('ae-selected');\n item.dataset.isSelected = 'false';\n });\n\n // Mark this item as selected\n this.element.classList.add('ae-selected');\n this.element.dataset.isSelected = 'true';\n\n // Dispatch event for other controllers\n this.triggerProductSelection();\n }\n\n triggerProductSelection() {\n const customEvent = new CustomEvent('decorator:product:selected', {\n bubbles: false,\n detail: this.productData,\n });\n document.dispatchEvent(customEvent);\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n initialize() {\n this.categoryId = this.element.dataset.categoryId;\n this.saleId = this.element.dataset.saleId;\n this.productId = this.element.dataset.productId;\n }\n\n delete(event) {\n event.preventDefault();\n\n const categoryName = this.element.dataset.name;\n const willProceed = confirm('Are you sure you wish to delete this category?');\n\n if (!willProceed) {\n return;\n }\n\n // Add loading state to the parent container if available\n const categoryContainer = this.element.closest('.category-container');\n if (categoryContainer) {\n categoryContainer.classList.add('is-loading');\n }\n\n const _sidebarCategoryRow = this.element.closest('.sidebar-category-row');\n\n // Construct the URL for the teamsites route\n let url = `/teamsites/${this.saleId}/categories/${this.categoryId}`;\n\n // Add product_id as a query parameter if it's present\n if (this.productId) {\n url = `${url}?product_id=${this.productId}`;\n }\n\n fetch(url, {\n method: 'DELETE',\n headers: {\n 'X-CSRF-Token': document.querySelector('meta[name=\"csrf-token\"]').content,\n 'Content-Type': 'application/json',\n 'Accept': 'application/json',\n },\n })\n .then(response => {\n if (!response.ok) {\n throw new Error('Failed to delete category');\n }\n return response.json();\n })\n .then(_data => {\n // Show success message\n if (window.App && window.App.showSuccessFlash) {\n window.App.showSuccessFlash(`Category (${categoryName}) successfully deleted`);\n }\n\n // Dispatch category deleted event for sidebar refresh\n const categoryDeletedEvent = new CustomEvent('category:deleted', {\n detail: {\n categoryId: this.categoryId,\n categoryName: categoryName,\n saleId: this.saleId,\n },\n });\n document.dispatchEvent(categoryDeletedEvent);\n\n // Add refresh parameter for index page navigation\n // For edit page, the category:deleted event will trigger sidebar refresh\n const currentUrl = new URL(window.location.href);\n const isEditPage = window.location.pathname.match(/\\/products\\/\\d+\\/edit/);\n\n if (!isEditPage) {\n // On index page, use Turbolinks navigation to refresh\n currentUrl.searchParams.set('refresh', Date.now().toString());\n if (window.Turbolinks && window.Turbolinks.visit) {\n Turbolinks.visit(currentUrl.toString());\n } else {\n window.location.href = currentUrl.toString();\n }\n }\n // On edit page, the event will trigger sidebar refresh (no navigation needed)\n })\n .catch(_error => {\n // Error occurred\n\n // Remove loading state\n if (categoryContainer) {\n categoryContainer.classList.remove('is-loading');\n }\n\n // Show error message\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash(`Failed to delete category (${categoryName})`);\n }\n });\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n initialize() {\n this.productId = this.element.dataset.productId;\n this.saleId = this.element.dataset.saleId;\n }\n\n // Named \"confirm\" to comply with button debouncing conventions\n // (custom_controller pattern recognizes #confirm, #process, #submit, #debounce)\n async confirm(e) {\n e.preventDefault();\n\n const button = e.currentTarget;\n const originalText = button.textContent.trim();\n button.disabled = true;\n button.textContent = 'Deleting...';\n\n try {\n const csrfToken = document.querySelector('meta[name=\"csrf-token\"]')?.content;\n const url = `/teamsites/${this.saleId}/products/${this.productId}`;\n\n const response = await fetch(url, {\n method: 'DELETE',\n headers: {\n 'X-CSRF-Token': csrfToken,\n 'Accept': 'application/json',\n 'Content-Type': 'application/json',\n 'X-Requested-With': 'XMLHttpRequest',\n },\n credentials: 'same-origin',\n });\n\n const data = await response.json();\n\n if (response.ok) {\n if (window.App?.showSuccessFlash) {\n window.App.showSuccessFlash(data.message || 'Product deleted successfully');\n }\n\n // Update product count if provided\n if (data.updated_product_count !== undefined) {\n const countElement = document.getElementById('tab-products-count');\n if (countElement) {\n countElement.textContent = data.updated_product_count;\n }\n }\n\n // Dispatch event before navigation\n document.dispatchEvent(new CustomEvent('product:deleted', {\n detail: {\n productId: this.productId,\n saleId: this.saleId,\n redirectUrl: data.redirect_url,\n },\n bubbles: true,\n }));\n\n // Navigate after short delay using Turbolinks\n setTimeout(() => {\n if (data.redirect_url) {\n if (typeof Turbolinks !== 'undefined' && Turbolinks.visit) {\n Turbolinks.visit(data.redirect_url);\n } else {\n window.location.href = data.redirect_url;\n }\n } else {\n // Fallback to products index\n const fallbackUrl = `/teamsites/${this.saleId}/products`;\n if (typeof Turbolinks !== 'undefined' && Turbolinks.visit) {\n Turbolinks.visit(fallbackUrl);\n } else {\n window.location.href = fallbackUrl;\n }\n }\n }, 500);\n } else {\n if (window.App?.showErrorFlash) {\n window.App.showErrorFlash(data.message || 'Unable to delete product');\n }\n button.disabled = false;\n button.textContent = originalText;\n }\n } catch {\n if (window.App?.showErrorFlash) {\n window.App.showErrorFlash('Network error. Please try again.');\n }\n button.disabled = false;\n button.textContent = originalText;\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n initialize() {\n this.saleId = this.element.dataset.saleId;\n }\n\n // Named \"confirm\" to comply with button debouncing conventions\n // (custom_controller pattern recognizes #confirm, #process, #submit, #debounce)\n async confirm(e) {\n e.preventDefault();\n\n const button = e.currentTarget;\n const originalText = button.textContent.trim();\n button.disabled = true;\n button.textContent = 'Deleting...';\n\n try {\n const csrfToken = document.querySelector('meta[name=\"csrf-token\"]')?.content;\n\n const response = await fetch(`/teamsites/${this.saleId}`, {\n method: 'DELETE',\n headers: {\n 'X-CSRF-Token': csrfToken,\n 'Content-Type': 'application/json',\n },\n });\n\n const data = await response.json();\n\n if (response.ok) {\n if (window.App?.showSuccessFlash) {\n window.App.showSuccessFlash(data.message || 'TeamSite deleted successfully');\n }\n\n setTimeout(() => {\n window.location.href = data.redirect || '/teamsites';\n }, 1000);\n } else {\n if (window.App?.showErrorFlash) {\n window.App.showErrorFlash(data.error || 'Unable to delete TeamSite');\n }\n button.disabled = false;\n button.textContent = originalText;\n }\n } catch {\n if (window.App?.showErrorFlash) {\n window.App.showErrorFlash('Network error. Please try again.');\n }\n button.disabled = false;\n button.textContent = originalText;\n }\n }\n}\n", "import { Controller } from '../stimulus';\nimport CSRFTokenMixin from '../mixins/csrf_token_mixin';\n\n/**\n * Dollar Report Auto Refresh Controller\n *\n * Automatically refreshes the accounting dollar report in the background\n * when the page first loads, without requiring user interaction.\n *\n * Features:\n * - SessionStorage rate limiting (5-minute cooldown)\n * - DOM update without page reload\n * - Turbolinks cache prevention\n * - Smart refresh (only when needed)\n *\n * Usage:\n *
    \"\n * data-has-report=\"<%= current_dollar_report.present? %>\"\n * data-turbolinks-cache=\"false\">\n * \n *
    \n */\nexport default class extends Controller {\n initialize() {\n // Apply CSRF token mixin for secure AJAX requests\n Object.assign(this, CSRFTokenMixin);\n }\n\n connect() {\n // Get sale ID from data attribute (Stimulus 1.1.0 pattern)\n this.saleId = this.element.dataset.saleId;\n\n // Check if report already exists\n this.hasReport = this.element.dataset.hasReport === 'true';\n\n // Track if refresh has been triggered to prevent duplicates\n this.hasRefreshed = false;\n\n // Store timeout ID for cleanup\n this.autoRefreshTimeout = null;\n\n // Automatically trigger refresh after a short delay\n // Delay allows the page to fully render before making the request\n this.autoRefreshTimeout = setTimeout(() => {\n this.triggerAutoRefresh();\n }, 500);\n }\n\n disconnect() {\n // Clean up timeout if component is disconnected before it fires\n if (this.autoRefreshTimeout) {\n clearTimeout(this.autoRefreshTimeout);\n this.autoRefreshTimeout = null;\n }\n\n // Reset refresh flag\n this.hasRefreshed = false;\n }\n\n /**\n * Check if a refresh was recently performed (within cooldown period)\n * @returns {boolean} True if refresh is on cooldown\n */\n isOnCooldown() {\n const COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes\n const storageKey = `dollar_report_refresh_${this.saleId}`;\n\n try {\n const lastRefreshStr = sessionStorage.getItem(storageKey);\n if (!lastRefreshStr) {\n return false;\n }\n\n const lastRefresh = parseInt(lastRefreshStr, 10);\n const now = Date.now();\n const timeSinceRefresh = now - lastRefresh;\n\n return timeSinceRefresh < COOLDOWN_MS;\n } catch (error) {\n // SessionStorage might not be available (private browsing, etc.)\n // In this case, allow the refresh\n return false;\n }\n }\n\n /**\n * Record that a refresh was performed\n */\n recordRefresh() {\n const storageKey = `dollar_report_refresh_${this.saleId}`;\n\n try {\n sessionStorage.setItem(storageKey, Date.now().toString());\n } catch (error) {\n // Silently ignore storage errors\n // Only log to console in development\n if (window.location.hostname === 'localhost' || window.location.hostname.includes('lvh.me')) {\n console.warn('Failed to record refresh timestamp:', error.message);\n }\n }\n }\n\n /**\n * Trigger automatic background refresh\n */\n async triggerAutoRefresh() {\n // Prevent multiple refreshes within the same page session\n if (this.hasRefreshed) {\n return;\n }\n\n // Validate sale ID is present\n if (!this.saleId) {\n return;\n }\n\n // Skip refresh if report already exists and was recently refreshed\n if (this.hasReport && this.isOnCooldown()) {\n return;\n }\n\n // Mark as refreshed to prevent duplicates in this session\n this.hasRefreshed = true;\n\n try {\n // Build the refresh endpoint URL\n const url = `/teamsites/${this.saleId}/accounting/refresh`;\n\n // Make AJAX request to trigger dollar report generation\n const response = await fetch(url, {\n method: 'GET',\n headers: this.getHeadersWithCSRF({\n 'Accept': 'text/html',\n 'X-Requested-With': 'XMLHttpRequest',\n }),\n credentials: 'same-origin',\n });\n\n // Check if request was successful\n if (!response.ok) {\n throw new Error(`Failed to refresh dollar report: ${response.statusText}`);\n }\n\n // Parse HTML response\n const html = await response.text();\n\n // Update the container with the new HTML\n this.element.innerHTML = html;\n\n // Record the successful refresh\n this.recordRefresh();\n\n // Update the has-report flag since we now have fresh data\n this.hasReport = true;\n this.element.dataset.hasReport = 'true';\n\n } catch (error) {\n // Silently handle errors - don't break the page for the user\n // The user can still manually refresh if auto-refresh fails\n\n // Only log to console in development, not production\n if (window.location.hostname === 'localhost' || window.location.hostname.includes('lvh.me')) {\n console.warn('Auto-refresh failed:', error.message);\n }\n\n // Optionally show a subtle notification (only if App is available)\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n // Don't show error for auto-refresh failures - it would be jarring\n // User can manually click refresh if they need updated data\n }\n }\n }\n}\n", "import { Controller } from '../stimulus';\nimport CSRFTokenMixin from '../mixins/csrf_token_mixin';\n\n/**\n * Dollar Report Refresh Controller\n *\n * Refreshes the accounting dollar report via AJAX by triggering the\n * GenerateDollarReport job and reloading the page to show updated data.\n *\n * Uses Pattern 11 (button-debounce controller) for double-click protection.\n *\n * Usage:\n * \n */\nexport default class extends Controller {\n initialize() {\n // Apply CSRF token mixin for secure AJAX requests\n Object.assign(this, CSRFTokenMixin);\n }\n\n connect() {\n // Get sale ID from data attribute (Stimulus 1.1.0 pattern)\n this.saleId = this.element.dataset.saleId;\n }\n\n /**\n * Refresh the dollar report by calling the backend endpoint\n * @param {Event} event - The click event\n */\n async refresh(event) {\n // Prevent default button behavior\n event.preventDefault();\n\n // Validate sale ID is present\n if (!this.saleId) {\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash('Sale ID is missing. Unable to refresh dollar report.');\n }\n return;\n }\n\n try {\n // Build the refresh endpoint URL\n const url = `/teamsites/${this.saleId}/accounting/refresh`;\n\n // Make AJAX request to trigger dollar report generation\n const response = await fetch(url, {\n method: 'GET',\n headers: this.getHeadersWithCSRF({\n 'Accept': 'application/json',\n 'X-Requested-With': 'XMLHttpRequest',\n }),\n credentials: 'same-origin',\n });\n\n // Check if request was successful\n if (!response.ok) {\n throw new Error(`Failed to refresh dollar report: ${response.statusText}`);\n }\n\n // Parse JSON response\n const data = await response.json();\n\n // Check for success in response\n if (data.success) {\n // Show success message\n if (window.App && typeof window.App.showSuccessFlash === 'function') {\n window.App.showSuccessFlash('Dollar report refreshed successfully.');\n }\n\n // Reload the page to show updated dollar report data\n // Using a small delay to ensure flash message is visible\n setTimeout(() => {\n window.location.reload();\n }, 500);\n } else {\n // Handle error from backend\n const errorMessage = data.error || 'Failed to refresh dollar report.';\n throw new Error(errorMessage);\n }\n\n } catch (error) {\n // Show error message to user\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash(error.message || 'Failed to refresh dollar report. Please try again.');\n }\n }\n }\n}\n", "// Shared event coordination module for drag and navigation controllers\n// Prevents race conditions by managing event handling state\n\nclass EventCoordinator {\n constructor() {\n // Debug flag - set to true to enable debug logging\n this.debug = false;\n\n // Track which controller is currently handling an event\n this.activeHandler = null;\n this.eventInProgress = false;\n this.pendingNavigation = null;\n\n // Track drag state globally\n this.dragState = {\n isDragging: false,\n dragElement: null,\n startTime: null,\n };\n\n // Navigation state\n this.navigationState = {\n isNavigating: false,\n targetUrl: null,\n startTime: null,\n };\n }\n\n /**\n * Logs debug messages when debug mode is enabled\n * @param {string} message - The message to log\n */\n logDebug(_message) {\n if (this.debug) {\n // Debug logging disabled for production\n }\n }\n\n // Request to handle an event - returns true if granted, false if another handler is active\n requestEventHandling(handlerType, _element) {\n try {\n if (this.eventInProgress && this.activeHandler !== handlerType) {\n this.logDebug(`${handlerType} denied - ${this.activeHandler} is active`);\n return false;\n }\n\n this.eventInProgress = true;\n this.activeHandler = handlerType;\n this.logDebug(`${handlerType} granted for element`);\n return true;\n } catch {\n this.recoverFromError();\n return false;\n }\n }\n\n // Release event handling\n releaseEventHandling(handlerType) {\n try {\n if (this.activeHandler === handlerType) {\n this.eventInProgress = false;\n this.activeHandler = null;\n this.logDebug(`${handlerType} released`);\n\n // Process any pending navigation after drag completes\n if (handlerType === 'drag' && this.pendingNavigation) {\n const pending = this.pendingNavigation;\n this.pendingNavigation = null;\n this.logDebug('Processing pending navigation');\n try {\n pending.callback();\n } catch {\n // Ignore callback errors\n }\n }\n }\n } catch {\n this.recoverFromError();\n }\n }\n\n // Check if a specific handler is active\n isHandlerActive(handlerType) {\n return this.activeHandler === handlerType;\n }\n\n // Check if any event is in progress\n isEventInProgress() {\n return this.eventInProgress;\n }\n\n // Drag-specific methods\n startDrag(element) {\n try {\n if (this.navigationState.isNavigating) {\n this.logDebug('Drag prevented - navigation in progress');\n return false;\n }\n\n this.dragState.isDragging = true;\n this.dragState.dragElement = element;\n this.dragState.startTime = Date.now();\n return true;\n } catch {\n this.recoverFromError();\n return false;\n }\n }\n\n endDrag() {\n try {\n this.dragState.isDragging = false;\n this.dragState.dragElement = null;\n this.dragState.startTime = null;\n } catch {\n this.recoverFromError();\n }\n }\n\n isDragging() {\n return this.dragState.isDragging;\n }\n\n // Navigation-specific methods\n startNavigation(url) {\n try {\n if (this.dragState.isDragging) {\n this.logDebug('Navigation deferred - drag in progress');\n return false;\n }\n\n this.navigationState.isNavigating = true;\n this.navigationState.targetUrl = url;\n this.navigationState.startTime = Date.now();\n return true;\n } catch {\n this.recoverFromError();\n return false;\n }\n }\n\n endNavigation() {\n try {\n this.navigationState.isNavigating = false;\n this.navigationState.targetUrl = null;\n this.navigationState.startTime = null;\n } catch {\n this.recoverFromError();\n }\n }\n\n isNavigating() {\n return this.navigationState.isNavigating;\n }\n\n // Queue navigation to occur after drag completes\n queueNavigationAfterDrag(callback) {\n try {\n if (this.dragState.isDragging) {\n this.pendingNavigation = { callback, timestamp: Date.now() };\n this.logDebug('Navigation queued for after drag');\n return true;\n }\n return false;\n } catch {\n this.recoverFromError();\n return false;\n }\n }\n\n // Get time since event started (for debugging)\n getEventDuration(handlerType) {\n if (handlerType === 'drag' && this.dragState.startTime) {\n return Date.now() - this.dragState.startTime;\n }\n if (handlerType === 'navigation' && this.navigationState.startTime) {\n return Date.now() - this.navigationState.startTime;\n }\n return 0;\n }\n\n // Reset all state (useful for testing or error recovery)\n reset() {\n this.activeHandler = null;\n this.eventInProgress = false;\n this.pendingNavigation = null;\n this.dragState = {\n isDragging: false,\n dragElement: null,\n startTime: null,\n };\n this.navigationState = {\n isNavigating: false,\n targetUrl: null,\n startTime: null,\n };\n this.logDebug('State reset');\n }\n\n // Error recovery - safely reset state when an error occurs\n recoverFromError() {\n try {\n this.reset();\n } catch {\n // Force reset by directly setting properties\n this.activeHandler = null;\n this.eventInProgress = false;\n this.pendingNavigation = null;\n this.dragState = { isDragging: false, dragElement: null, startTime: null };\n this.navigationState = { isNavigating: false, targetUrl: null, startTime: null };\n }\n }\n}\n\n// Create singleton instance\nconst eventCoordinator = new EventCoordinator();\n\n// Don't freeze the object since we need to be able to reset state\n// Object.freeze(eventCoordinator);\n\nexport default eventCoordinator;\n", "/**\n * Drag Category Controller\n *\n * Handles drag-and-drop functionality for reordering categories in the sidebar.\n * Integrates with the Event Coordinator to prevent conflicts with navigation operations.\n *\n * Key Features:\n * - Visual drag feedback with clone element\n * - Placeholder showing drop position\n * - Accordion collapse/expand during drag\n * - Server synchronization via PATCH requests\n * - Event coordination to prevent navigation conflicts\n *\n * @requires stimulus\n * @requires event_coordinator\n */\nimport { Controller } from '../stimulus';\n\nimport eventCoordinator from './event_coordinator';\n\nexport default class extends Controller {\n static get targets() {\n return ['draggableCategory'];\n }\n\n connect() {\n // Store bound functions as instance properties to ensure proper removal\n this.boundDragStart = this.dragStart.bind(this);\n this.boundDragMove = this.dragMove.bind(this);\n this.boundDragEnd = this.dragEnd.bind(this);\n this.boundRefresh = this.refresh.bind(this);\n\n this.initDraggable();\n // Use vanilla JS event listener instead of jQuery\n this.element.addEventListener('createCategory', this.boundRefresh);\n }\n\n disconnect() {\n this.removeDragListeners();\n // Remove event listener using vanilla JS\n this.element.removeEventListener('createCategory', this.boundRefresh);\n\n // Clean up any active drag state and release event coordinator\n if (this.draggedElement) {\n eventCoordinator.endDrag();\n eventCoordinator.releaseEventHandling('drag');\n }\n }\n\n refresh() {\n this.removeDragListeners();\n this.initDraggable();\n }\n\n initDraggable() {\n if (!this.hasDraggableCategoryTarget) return;\n\n this.draggableCategoryTarget.querySelectorAll('.draggable-category').forEach(handle => {\n handle.addEventListener('mousedown', this.boundDragStart);\n });\n\n // Add event listeners for dragging\n document.addEventListener('mousemove', this.boundDragMove);\n document.addEventListener('mouseup', this.boundDragEnd);\n }\n\n removeDragListeners() {\n if (!this.hasDraggableCategoryTarget) return;\n\n this.draggableCategoryTarget.querySelectorAll('.draggable-category').forEach(handle => {\n handle.removeEventListener('mousedown', this.boundDragStart);\n });\n\n document.removeEventListener('mousemove', this.boundDragMove);\n document.removeEventListener('mouseup', this.boundDragEnd);\n }\n\n dragStart(event) {\n if (!event.target.classList.contains('draggable-category')) return;\n\n // Find the draggable category element (parent of the handle)\n const categoryElement = event.target.closest('.sidebar-category-row, .categories-list');\n if (!categoryElement) return;\n\n // Request event handling from coordinator\n if (!eventCoordinator.requestEventHandling('drag', categoryElement)) {\n // Event coordinator denied drag request - navigation might be in progress\n return;\n }\n\n // Start drag with coordinator\n if (!eventCoordinator.startDrag(categoryElement)) {\n eventCoordinator.releaseEventHandling('drag');\n return;\n }\n\n this.draggedElement = categoryElement;\n this.initialY = event.clientY;\n this.offsetY = 0;\n\n // Create a clone for visual dragging\n this.dragClone = categoryElement.cloneNode(true);\n this.dragClone.classList.add('dragging');\n this.dragClone.style.position = 'absolute';\n this.dragClone.style.width = `${categoryElement.offsetWidth}px`;\n this.dragClone.style.height = `${categoryElement.offsetHeight}px`;\n this.dragClone.style.zIndex = '1000';\n this.dragClone.style.opacity = '0.8';\n\n // Get initial position\n const rect = categoryElement.getBoundingClientRect();\n this.dragClone.style.top = `${rect.top}px`;\n this.dragClone.style.left = `${rect.left}px`;\n\n document.body.appendChild(this.dragClone);\n\n // Add placeholder\n this.placeholder = document.createElement('div');\n this.placeholder.classList.add('category-placeholder');\n this.placeholder.style.height = `${categoryElement.offsetHeight}px`;\n this.placeholder.style.backgroundColor = 'rgba(128, 128, 128, 0.3)';\n this.placeholder.style.border = '2px dashed #888888';\n categoryElement.parentNode.insertBefore(this.placeholder, categoryElement);\n\n // Hide original element\n categoryElement.style.display = 'none';\n\n // Collapse all categories using vanilla JS\n const accordions = this.element.querySelectorAll('[data-js-accordion]');\n accordions.forEach(accordion => {\n // Dispatch custom event to trigger collapse\n accordion.dispatchEvent(new Event('collapse', { bubbles: true }));\n });\n\n event.preventDefault();\n }\n\n dragMove(event) {\n if (!this.draggedElement || !this.dragClone) return;\n\n this.offsetY = event.clientY - this.initialY;\n\n // Add a highlight effect to the area being dragged over\n const dragArea = this.draggableCategoryTarget;\n if (dragArea) {\n dragArea.style.backgroundColor = 'rgba(128, 128, 128, 0.08)';\n dragArea.style.transition = 'background-color 0.3s ease';\n }\n this.dragClone.style.transform = `translateY(${this.offsetY}px)`;\n\n // Find position to insert placeholder\n const categories = Array.from(this.draggableCategoryTarget.querySelectorAll('.sidebar-category-row, .categories-list'));\n const filtered = categories.filter(cat => cat !== this.draggedElement);\n\n // Reset all category highlights first\n filtered.forEach(cat => {\n cat.style.boxShadow = '';\n cat.style.transition = 'box-shadow 0.2s ease';\n });\n\n for (let i = 0; i < filtered.length; i++) {\n const cat = filtered[i];\n const rect = cat.getBoundingClientRect();\n const catMiddle = rect.top + rect.height / 2;\n\n if (event.clientY < catMiddle) {\n // Add highlight to the category being hovered over\n cat.style.boxShadow = '0 0 0 2px #888888';\n\n if (this.placeholder.nextSibling !== cat) {\n this.placeholder.parentNode.insertBefore(this.placeholder, cat);\n }\n break;\n } else if (i === filtered.length - 1) {\n // Highlight the last item when hovering below it\n cat.style.boxShadow = '0 0 0 2px #888888';\n\n if (cat.nextSibling !== this.placeholder) {\n cat.parentNode.insertBefore(this.placeholder, cat.nextSibling);\n }\n }\n }\n }\n\n dragEnd(_event) {\n if (!this.draggedElement || !this.dragClone) return;\n\n // End drag with coordinator\n eventCoordinator.endDrag();\n eventCoordinator.releaseEventHandling('drag');\n\n // Get the new position\n const categories = Array.from(this.draggableCategoryTarget.querySelectorAll('.sidebar-category-row, .categories-list'));\n const placeholderIndex = categories.indexOf(this.placeholder);\n const droppedPosition = (placeholderIndex === -1) ? 0 : placeholderIndex;\n\n // Remove background highlighting and box shadows\n if (this.draggableCategoryTarget) {\n this.draggableCategoryTarget.style.backgroundColor = '';\n\n // Clear all box shadows\n const categories = Array.from(this.draggableCategoryTarget.querySelectorAll('.sidebar-category-row, .categories-list'));\n categories.forEach(cat => {\n cat.style.boxShadow = '';\n });\n }\n\n // Clean up dragging elements\n document.body.removeChild(this.dragClone);\n this.placeholder.parentNode.insertBefore(this.draggedElement, this.placeholder);\n this.placeholder.parentNode.removeChild(this.placeholder);\n this.draggedElement.style.display = '';\n\n // Expand all categories using vanilla JS\n const accordions = this.element.querySelectorAll('[data-js-accordion]');\n accordions.forEach(accordion => {\n // Dispatch custom event to trigger expand\n accordion.dispatchEvent(new Event('expand', { bubbles: true }));\n });\n\n // Call the server to update the position\n this.move(this.draggedElement, droppedPosition);\n\n // Reset drag state\n this.draggedElement = null;\n this.dragClone = null;\n this.placeholder = null;\n this.initialY = null;\n this.offsetY = null;\n }\n\n move(element, droppedPosition) {\n const saleId = element.dataset.saleId;\n const categoryId = element.dataset.categoryId;\n const url = `/teamsites/${saleId}/categories/${categoryId}/move/${droppedPosition}`;\n\n // Use fetch instead of jQuery.ajax\n fetch(url, {\n method: 'PATCH',\n headers: {\n 'X-CSRF-Token': document.querySelector('meta[name=\"csrf-token\"]')?.content || '',\n 'Accept': 'application/json',\n 'Content-Type': 'application/json',\n },\n credentials: 'same-origin',\n })\n .then(response => {\n if (!response.ok) {\n throw new Error('Network response was not ok');\n }\n return response.json();\n })\n .then(() => {\n // Success - no action needed\n })\n .catch(error => {\n // Failed to move category\n if (typeof App !== 'undefined' && App.showErrorFlash) {\n App.showErrorFlash('Failed to move category');\n }\n });\n }\n}\n", "/**\n * @file Drag Product Controller\n * @description A Stimulus controller for handling drag-and-drop functionality of products.\n * Allows reordering products within categories and moving products between categories.\n * Implements intelligent click vs drag detection to support both navigation and drag operations.\n * Uses only vanilla JavaScript with no external dependencies other than Stimulus.\n *\n * Key Features:\n * - Movement-based drag detection (10px threshold)\n * - Timeout-based click detection (200ms)\n * - Support for both main grid and sidebar products\n * - Visual feedback during drag operations\n * - Server synchronization via PATCH requests\n *\n * @requires stimulus\n *\n * @example\n * \n *
    \n *
    \n * \n * \n * \n *
    \n * Product 2\n *
    \n *
    \n *
    \n *\n * \n *
    \n * No products in this category\n *
    \n */\n\nimport { Controller } from '../stimulus';\n\nimport eventCoordinator from './event_coordinator';\n\n// Constants for timeouts and thresholds\nconst DRAG_SAFETY_TIMEOUT_MS = 2000; // Safety timeout to clean up stuck drag states\nconst PRELOAD_CLEANUP_INTERVAL_MS = 5000; // Interval for cleaning up completed preloads\nconst PRELOAD_MAX_AGE_MS = 30000; // Maximum age before removing stale preloads\nconst DRAG_THRESHOLD_PX = 10; // Minimum movement in pixels to consider it a drag (increased from 8 for less sensitive drag detection)\n\n/**\n * Drag Product Controller class\n *\n * @class DragProductController\n * @extends Controller\n */\nexport default class extends Controller {\n /**\n * Defines targets for the controller\n * @static\n * @property {string[]} targets - Array of target definitions\n */\n static get targets() {\n return ['draggableProduct'];\n }\n\n /**\n * Initialize controller\n * Set up event handlers, state, and safety mechanisms\n *\n * @method initialize\n * @returns {void}\n */\n initialize() {\n // Bind methods to preserve 'this' context\n // Store these as permanent properties that won't be recreated on reconnect\n if (!this._boundHandlers) {\n this._boundHandlers = {\n onDragStart: this.dragStart.bind(this),\n onDragMove: this.dragMove.bind(this),\n onDragEnd: this.dragEnd.bind(this),\n cleanupDrag: this.cleanupDrag.bind(this),\n throttledFindNearestGridPosition: this.throttle(this.findNearestGridPosition.bind(this), 50),\n errorHandler: (_event) => {\n if (this.isDragging) {\n this.logDebug('Error detected during drag operation, performing cleanup');\n this.cleanupDrag();\n }\n },\n };\n }\n\n // Create references to bound handlers for easier access\n this.onDragStart = this._boundHandlers.onDragStart;\n this.onDragMove = this._boundHandlers.onDragMove;\n this.onDragEnd = this._boundHandlers.onDragEnd;\n this.cleanupDrag = this._boundHandlers.cleanupDrag;\n this.throttledFindNearestGridPosition = this._boundHandlers.throttledFindNearestGridPosition;\n this.errorHandler = this._boundHandlers.errorHandler;\n\n // Initialize state\n this.isDragging = false;\n this.isDragInitiated = false;\n this.shouldHandleClick = true; // Default to handling clicks unless element has navigation\n this.passiveDragDetection = false; // Track if using passive detection for navigation elements\n this.debug = false; // Set to true to enable debug logging\n this.cachedRowPositions = null;\n this.lastContainerWidth = null;\n this.lastContainerHeight = null;\n this.lastProductCount = 0;\n this.lastCacheTime = 0;\n\n // Track spacer's current DOM position to avoid redundant moves (live shuffle)\n this.spacerCurrentContainer = null;\n this.spacerCurrentNextSibling = null;\n\n // Initialize error handler attachment state\n this._errorHandlerAttached = false;\n\n // Listen for errors at the window level\n // Remove any existing handler first to prevent duplicates in case initialize is called multiple times\n window.removeEventListener('error', this.errorHandler);\n window.addEventListener('error', this.errorHandler);\n this._errorHandlerAttached = true;\n }\n\n /**\n * Creates a throttled function that only invokes the provided function at most once per specified interval\n *\n * @method throttle\n * @param {Function} func - The function to throttle\n * @param {number} limit - The time limit in milliseconds\n * @returns {Function} - The throttled function\n */\n throttle(func, limit) {\n let lastCall = 0;\n return function(...args) {\n const now = Date.now();\n if (now - lastCall >= limit) {\n lastCall = now;\n return func.apply(this, args);\n }\n };\n }\n\n /**\n * Connect controller when DOM element is attached\n * Sets up CSS selectors and adds event listeners\n *\n * @method connect\n * @returns {void}\n */\n connect() {\n // CSS selectors for draggable product elements\n this.markupClasses = '.sidebar-product-list-item, .product-list-item';\n\n if (this.debug) {\n // DragProduct controller connected\n }\n\n // Mark that we're connected\n this._disconnected = false;\n\n // Re-add error handler if reconnecting\n if (this.errorHandler && !this._errorHandlerAttached) {\n // Additional safety: remove any existing handler first to prevent duplicates\n window.removeEventListener('error', this.errorHandler);\n window.addEventListener('error', this.errorHandler);\n this._errorHandlerAttached = true;\n }\n\n // Reset event coordinator on connect (helps with Turbolinks)\n if (eventCoordinator.isDragging() || eventCoordinator.isNavigating()) {\n this.logDebug('DragProduct: Resetting event coordinator on connect');\n eventCoordinator.reset();\n }\n\n // Initialize styles and event listeners\n this.addStyles();\n this.addEventListeners();\n }\n\n /**\n * Disconnect controller when DOM element is detached\n * Cleans up listeners and any active drag operation\n *\n * @method disconnect\n * @returns {void}\n */\n disconnect() {\n // Ensure any active drag operation is cleaned up\n if (this.isDragging || this.hasPotentialDrag) {\n this.cleanupDrag();\n }\n\n // Remove all event listeners\n this.removeEventListeners();\n\n // Remove the global error handler\n try {\n if (this.errorHandler) {\n window.removeEventListener('error', this.errorHandler);\n }\n } catch (error) {\n this.logDebug(`Error removing error handler: ${error.message}`);\n } finally {\n // Always reset the flag, even if removeEventListener fails\n this._errorHandlerAttached = false;\n }\n\n // Clear any remaining timeouts\n if (this.dragSafetyTimeout) {\n clearTimeout(this.dragSafetyTimeout);\n this.dragSafetyTimeout = null;\n }\n if (this.clickTimeout) {\n clearTimeout(this.clickTimeout);\n this.clickTimeout = null;\n }\n\n // Mark that we've disconnected (for debugging)\n this._disconnected = true;\n }\n\n /**\n * Logs debug messages when debug mode is enabled\n *\n * @method logDebug\n * @param {string} message - The message to log\n * @returns {void}\n */\n logDebug(_message) {\n if (this.debug) {\n // Debug logging removed for production compliance\n }\n }\n\n /**\n * Sets up styles for the drag interface\n * Note: CSS is now managed through the asset pipeline in app/assets/stylesheets/scripts/_drag_product.sass\n * This method is kept for compatibility but no longer injects styles\n *\n * @method addStyles\n * @returns {void}\n */\n addStyles() {\n this.logDebug('Using stylesheet from asset pipeline');\n // The method is kept empty for backward compatibility\n }\n\n /**\n * Adds event listeners to draggable product elements\n * Sets up mousedown handlers for starting drag operations\n *\n * @method addEventListeners\n * @returns {void}\n */\n addEventListeners() {\n try {\n if (!this.hasDraggableProductTarget) {\n this.logDebug('No draggableProductTargets found');\n return;\n }\n\n // Initialize tracking if not exists\n if (!this._attachedListeners) {\n this._attachedListeners = new WeakSet();\n }\n\n this.draggableProductTargets.forEach(container => {\n try {\n if (!container || !(container instanceof Element)) {\n this.logDebug('Invalid container element found');\n return;\n }\n\n const products = container.querySelectorAll(this.markupClasses);\n this.logDebug(`Adding listeners to ${products.length} products in ${container.id || 'container'}`);\n\n if (products.length === 0) {\n this.logDebug(`No matching products found in container with selector: ${this.markupClasses}`);\n }\n\n products.forEach(product => {\n try {\n // Only add listener if not already attached\n if (!this._attachedListeners.has(product)) {\n product.addEventListener('mousedown', this.onDragStart);\n this._attachedListeners.add(product);\n }\n } catch (productError) {\n this.logDebug(`Failed to add event listener to product: ${productError.message}`);\n }\n });\n } catch (containerError) {\n this.logDebug(`Error processing container: ${containerError.message}`);\n }\n });\n } catch (error) {\n this.logDebug(`Error in addEventListeners: ${error.message}`);\n }\n }\n\n /**\n * Removes all event listeners added by the controller\n * Cleans up both product-specific and document-level listeners\n *\n * @method removeEventListeners\n * @returns {void}\n */\n removeEventListeners() {\n try {\n if (this.hasDraggableProductTarget && this._attachedListeners) {\n this.draggableProductTargets.forEach(container => {\n try {\n if (!container || !(container instanceof Element)) {\n return;\n }\n\n const products = container.querySelectorAll(this.markupClasses);\n\n products.forEach(product => {\n try {\n // Only remove if we tracked it\n if (this._attachedListeners.has(product)) {\n product.removeEventListener('mousedown', this.onDragStart);\n this._attachedListeners.delete(product);\n }\n } catch (productError) {\n this.logDebug(`Failed to remove event listener from product: ${productError.message}`);\n }\n });\n } catch (containerError) {\n this.logDebug(`Error removing listeners from container: ${containerError.message}`);\n }\n });\n }\n\n try {\n document.removeEventListener('mousemove', this.onDragMove);\n document.removeEventListener('mouseup', this.onDragEnd);\n } catch (docError) {\n this.logDebug(`Error removing document event listeners: ${docError.message}`);\n }\n } catch (error) {\n this.logDebug(`Error in removeEventListeners: ${error.message}`);\n }\n }\n\n /**\n * Checks if a container is a sidebar container by examining its contents\n *\n * @method isSidebarContainer\n * @param {Element} container - The container element to check\n * @returns {boolean} - True if the container contains sidebar products\n */\n isSidebarContainer(container) {\n if (!container || !(container instanceof Element)) {\n return false;\n }\n\n // First check if the container has sidebar products\n const sidebarProducts = container.querySelectorAll('.sidebar-product-list-item');\n if (sidebarProducts.length > 0) {\n this.logDebug(`Container ${container.id || 'unnamed'} has ${sidebarProducts.length} sidebar products, isSidebar: true`);\n return true;\n }\n\n // If no sidebar products, check if this is a sidebar container by structure\n // Sidebar containers have IDs like \"sidebar-products-list-{categoryId}\"\n const isSidebarById = container.id && container.id.startsWith('sidebar-products-list-');\n\n // Also check if container is within the products sidebar\n const isInSidebar = container.closest('#products-sidebar') !== null;\n\n const isSidebar = isSidebarById || isInSidebar;\n\n this.logDebug(`Container ${container.id || 'unnamed'} has ${sidebarProducts.length} sidebar products, isSidebar: ${isSidebar} (by structure)`);\n\n return isSidebar;\n }\n\n /**\n * Checks if the cursor is within the bounds of any sidebar container\n *\n * @method isCursorInSidebarBounds\n * @param {number} cursorX - The X position of the cursor\n * @param {number} cursorY - The Y position of the cursor\n * @returns {boolean} - True if the cursor is within sidebar bounds\n */\n isCursorInSidebarBounds(cursorX, cursorY) {\n if (!this.hasDraggableProductTarget) {\n return false;\n }\n\n // Check all containers to find sidebar containers\n let foundSidebarContainers = 0;\n for (const container of this.draggableProductTargets) {\n if (this.isSidebarContainer(container)) {\n foundSidebarContainers++;\n const rect = container.getBoundingClientRect();\n\n // Add some padding to make dragging more forgiving\n const padding = 10; // Reduced from 20px for more predictable boundary detection\n const isWithinBounds = (\n cursorX >= rect.left - padding &&\n cursorX <= rect.right + padding &&\n cursorY >= rect.top - padding &&\n cursorY <= rect.bottom + padding\n );\n\n if (isWithinBounds) {\n return true;\n }\n }\n }\n\n if (foundSidebarContainers === 0 && this.debug) {\n this.logDebug('\u26A0\uFE0F No sidebar containers found for bounds checking!');\n }\n\n return false;\n }\n\n /**\n * Checks if an element has navigation capabilities\n * This includes checking both data-action and data-controller attributes\n *\n * @method isNavigationEnabled\n * @param {Element} element - The element to check\n * @returns {boolean} - True if the element has navigation capabilities\n */\n isNavigationEnabled(element) {\n if (!element) return false;\n\n // Check for navigation action\n const hasNavigationAction = element.dataset.action &&\n element.dataset.action.includes('product-navigation');\n\n // Check for navigation controller\n const hasNavigationController = element.dataset.controller &&\n element.dataset.controller.includes('product-navigation');\n\n // Check parent elements for navigation controller (bubbling)\n let parent = element.parentElement;\n let depth = 0;\n const maxDepth = 5; // Limit traversal depth\n let hasParentNavigationController = false;\n\n while (parent && depth < maxDepth && !hasParentNavigationController) {\n if (parent.dataset && parent.dataset.controller &&\n parent.dataset.controller.includes('product-navigation')) {\n this.logDebug('Found navigation controller on parent element');\n hasParentNavigationController = true;\n break;\n }\n parent = parent.parentElement;\n depth++;\n }\n\n const isNavigationEnabled = hasNavigationAction || hasNavigationController || hasParentNavigationController;\n\n if (isNavigationEnabled) {\n this.logDebug(`Navigation enabled: action=${hasNavigationAction}, controller=${hasNavigationController}, parent=${hasParentNavigationController}`);\n }\n\n return isNavigationEnabled;\n }\n\n /**\n * Finds the container element for a draggable product\n * Traverses up the DOM tree to find the target container\n * Also handles empty drop zones with data-empty-drop-zone attribute\n *\n * @method findContainer\n * @param {Element} element - The element to start searching from\n * @returns {Element|null} - The container element or null if not found\n */\n findContainer(element) {\n try {\n if (!element || !(element instanceof Element)) {\n this.logDebug('Invalid element passed to findContainer');\n return null;\n }\n\n let container = null;\n\n // First check if we're hovering over an empty drop zone\n const emptyDropZone = element.closest('[data-empty-drop-zone=\"true\"]');\n if (emptyDropZone) {\n this.logDebug('Found empty drop zone, looking for parent container');\n // Find the parent container that has the drag target\n try {\n container = emptyDropZone.closest('[data-target=\"drag-product.draggableProduct\"]');\n if (container) {\n this.logDebug(`Found container for empty drop zone: ${container.id || 'unnamed'}`);\n return container;\n }\n } catch (emptyZoneError) {\n this.logDebug(`Error finding container for empty drop zone: ${emptyZoneError.message}`);\n }\n }\n\n try {\n container = element.closest('[data-target=\"drag-product.draggableProduct\"]');\n } catch (closestError) {\n this.logDebug(`Error using closest selector: ${closestError.message}`);\n }\n\n if (!container && this.hasDraggableProductTarget) {\n try {\n const draggableTargets = Array.from(this.draggableProductTargets || []);\n\n if (draggableTargets.includes(element)) {\n container = element;\n } else {\n let parent = element.parentNode;\n let safety = 0; // Safety counter to prevent infinite loops\n\n while (parent && parent !== document.body && safety < 50) {\n if (draggableTargets.includes(parent)) {\n container = parent;\n break;\n }\n parent = parent.parentNode;\n safety++;\n }\n\n if (safety >= 50) {\n this.logDebug('Possible infinite loop detected in findContainer');\n }\n }\n } catch (traversalError) {\n this.logDebug(`Error during DOM traversal: ${traversalError.message}`);\n }\n }\n\n return container;\n } catch (error) {\n this.logDebug(`Error in findContainer: ${error.message}`);\n return null;\n }\n }\n\n /**\n * Handles the start of a potential drag operation or click\n * Sets up click detection timeout and minimal drag state\n * Visual drag elements are only created when movement is detected\n *\n * @method dragStart\n * @param {MouseEvent} event - The mousedown event that triggered the interaction\n * @returns {void}\n */\n dragStart(event) {\n try {\n this.logDebug('dragStart called');\n\n // If we're already in a drag state, clean up first\n if (this.isDragging) {\n this.logDebug('Already in drag state, cleaning up previous drag');\n this.cleanupDrag();\n }\n\n if (!event || !event.target || !(event.target instanceof Element)) {\n this.logDebug('Invalid event or target in dragStart');\n return;\n }\n\n this.logDebug('Event target class: ' + event.target.className);\n this.logDebug('Event target tagName: ' + event.target.tagName);\n this.logDebug('Markup classes: ' + this.markupClasses);\n\n // Check if we're clicking within a draggable product first\n let productElement = null;\n try {\n productElement = event.target.closest(this.markupClasses);\n } catch (closestError) {\n this.logDebug(`Error finding closest product: ${closestError.message}`);\n }\n\n this.logDebug('Found product element class: ' + (productElement ? productElement.className : 'null'));\n this.logDebug('Found product element id: ' + (productElement ? productElement.id : 'null'));\n\n // We now handle all products uniformly with timeout-based click detection\n\n // At this point productElement should already be set from above\n\n if (!productElement) {\n this.logDebug('No matching product element found');\n return;\n }\n\n // Skip drag handling for clicks within product action buttons (Add to Library, etc.)\n // These have their own Stimulus controllers that handle click events\n if (event.target.closest('.product-actions, .product-actions-left')) {\n return;\n }\n\n // All products use the same drag logic now\n // The event coordinator handles conflicts between drag and navigation\n // Request event handling from coordinator\n if (!eventCoordinator.requestEventHandling('drag', productElement)) {\n this.logDebug('Event coordinator denied drag request');\n return;\n }\n\n // Store initial position to determine if this is a click or drag\n this.initialClickX = event.clientX;\n this.initialClickY = event.clientY;\n this.isDragInitiated = false; // Will be set to true once drag movement exceeds threshold\n\n // Prevent default to stop link navigation and allow drag detection\n event.preventDefault(); // Enables drag on all card areas (image, title, price)\n\n // Check if this element has navigation capabilities (via action or controller)\n const hasNavigationCapability = this.isNavigationEnabled(productElement);\n\n // Important: shouldHandleClick and passiveDragDetection work together:\n // - Navigation elements: shouldHandleClick=false, passiveDragDetection=true, NO click timeout\n // - Regular elements: shouldHandleClick=true, passiveDragDetection=false, HAS click timeout\n // This ensures proper separation of concerns between controllers\n\n // For navigation-enabled elements, we need to be more careful about event handling\n if (hasNavigationCapability) {\n this.logDebug('Element has navigation capability - using passive drag detection');\n // For navigation-enabled elements, we'll use a different approach:\n // 1. Don't set up click timeout\n // 2. Set up a temporary mousemove listener to detect drag intent\n // 3. Only fully engage if movement exceeds threshold\n\n this.shouldHandleClick = false; // Navigation controller handles clicks\n this.passiveDragDetection = true;\n\n // Set up a one-time mousemove check\n const checkForDragIntent = (moveEvent) => {\n const moveDistanceX = Math.abs(moveEvent.clientX - this.initialClickX);\n const moveDistanceY = Math.abs(moveEvent.clientY - this.initialClickY);\n\n if (moveDistanceX > DRAG_THRESHOLD_PX || moveDistanceY > DRAG_THRESHOLD_PX) {\n // User is dragging - take over from navigation controller\n this.logDebug('Drag intent detected on navigation element - taking over');\n document.removeEventListener('mousemove', checkForDragIntent);\n if (this.passiveMouseUpHandler) {\n document.removeEventListener('mouseup', this.passiveMouseUpHandler);\n this.passiveMouseUpHandler = null;\n }\n this.passiveDragDetection = false;\n\n // Mark that drag is initiated\n this.isDragInitiated = true;\n\n // For non-navigation controller elements, verify we can start drag\n if (!eventCoordinator.startDrag(this.draggedElement)) {\n this.logDebug('Event coordinator denied drag start - navigation in progress');\n this.cleanupDrag();\n return;\n }\n\n // Initialize the full drag state\n this.initializeFullDragState();\n\n // Set up full drag listeners\n document.addEventListener('mousemove', this.onDragMove);\n document.addEventListener('mouseup', this.onDragEnd);\n\n // Continue with normal drag handling\n this.dragMove(moveEvent);\n }\n };\n\n // Add temporary listener\n document.addEventListener('mousemove', checkForDragIntent);\n\n // Store reference for cleanup\n this.dragIntentListener = checkForDragIntent;\n } else {\n // Only set up click detection for products without navigation controller\n this.shouldHandleClick = true; // Drag controller handles clicks for non-navigation elements\n this.passiveDragDetection = false; // Use standard drag detection\n\n // Store pending navigation URL - navigation will only occur on mouseup (not on timeout)\n // This fixes the click-and-hold auto-navigation bug\n this.pendingNavigationUrl = null;\n const productLink = productElement.querySelector('a.product-list-item-link');\n if (productLink && productLink.href) {\n this.pendingNavigationUrl = productLink.href;\n this.logDebug(`Stored pending navigation URL: ${this.pendingNavigationUrl}`);\n } else if (productElement.classList.contains('sidebar-product-list-item')) {\n // Handle sidebar products\n const saleId = productElement.dataset.saleId;\n const productId = productElement.dataset.productId;\n if (saleId && productId) {\n this.pendingNavigationUrl = `/teamsites/${saleId}/products/${productId}/edit`;\n this.logDebug(`Stored pending sidebar navigation URL: ${this.pendingNavigationUrl}`);\n }\n }\n\n this.logDebug('Setting up click detection for product element class: ' + productElement.className);\n // Note: We no longer auto-navigate on timeout. Navigation only happens on mouseup in dragEnd.\n }\n\n // Store minimal drag state for potential drag\n this.draggedElement = productElement;\n this.initialX = event.clientX;\n this.initialY = event.clientY;\n this.originalParent = productElement.parentNode;\n this.originalNextSibling = productElement.nextElementSibling;\n\n // Set up safety mechanism for all cases\n if (this.dragSafetyTimeout) {\n clearTimeout(this.dragSafetyTimeout);\n }\n\n this.dragSafetyTimeout = setTimeout(() => {\n if (this.isDragging) {\n this.logDebug('Drag safety timeout triggered - cleaning up stale drag state');\n this.cleanupDrag();\n }\n }, PRELOAD_MAX_AGE_MS); // Maximum drag duration\n\n // We'll initialize the full drag state only when movement is detected for all products\n\n // For navigation-enabled elements, we already set up passive detection\n // For other elements, add the standard listeners\n if (!this.passiveDragDetection) {\n document.addEventListener('mousemove', this.onDragMove);\n document.addEventListener('mouseup', this.onDragEnd);\n } else {\n // For passive detection, only add mouseup to clean up if no drag occurs\n const passiveMouseUp = (_upEvent) => {\n this.logDebug('Passive mouseup - no drag occurred, allowing navigation click');\n document.removeEventListener('mouseup', passiveMouseUp);\n if (this.dragIntentListener) {\n document.removeEventListener('mousemove', this.dragIntentListener);\n this.dragIntentListener = null;\n }\n // Clean up minimal state\n this.passiveDragDetection = false;\n this.draggedElement = null;\n this.passiveMouseUpHandler = null;\n this.shouldHandleClick = true; // Reset to default\n\n // Clear the safety timeout since no drag occurred\n if (this.dragSafetyTimeout) {\n clearTimeout(this.dragSafetyTimeout);\n this.dragSafetyTimeout = null;\n }\n\n // Release event handling\n eventCoordinator.releaseEventHandling('drag');\n\n // The click event will now propagate to the navigation controller\n this.logDebug('Passive detection cleanup complete - navigation controller can handle the click');\n };\n document.addEventListener('mouseup', passiveMouseUp);\n this.passiveMouseUpHandler = passiveMouseUp;\n }\n\n this.logDebug('Drag setup completed');\n } catch (error) {\n this.logDebug(`Error in dragStart: ${error.message}`);\n this.cleanupDrag();\n }\n }\n\n /**\n * Initializes the full drag state including visual elements\n * Called either immediately for sidebar products or on first movement for main grid products\n *\n * @method initializeFullDragState\n * @returns {void}\n */\n initializeFullDragState() {\n try {\n if (!this.draggedElement) {\n this.logDebug('No dragged element to initialize drag state');\n return;\n }\n\n // Set the full drag state\n this.isDragging = true;\n document.body.classList.add('dragging-product');\n\n // Start drag with coordinator (if not already started)\n if (!eventCoordinator.isDragging()) {\n eventCoordinator.startDrag(this.draggedElement);\n }\n\n this.sourceContainer = this.findContainer(this.draggedElement);\n if (!this.sourceContainer) {\n this.logDebug('No valid source container found - using parent as fallback');\n this.sourceContainer = this.draggedElement.parentNode;\n } else {\n this.logDebug(`Found source container: ${this.sourceContainer.id || 'unnamed'}`);\n }\n\n // Determine if this is a sidebar product\n this.isDraggedElementFromSidebar = this.draggedElement.classList.contains('sidebar-product-list-item');\n if (this.isDraggedElementFromSidebar && this.debug) {\n this.logDebug(`\uD83C\uDFAF SIDEBAR DRAG DETECTED: Product with classes \"${this.draggedElement.className}\" detected as sidebar product`);\n }\n\n this.targetContainer = this.sourceContainer;\n this.targetPosition = 'append';\n\n const rect = this.draggedElement.getBoundingClientRect();\n this.originalWidth = rect.width;\n this.originalHeight = rect.height;\n\n // SIDEBAR vs GRID: Different approaches for each\n if (this.isDraggedElementFromSidebar) {\n // SIDEBAR: In-place transform approach (no clone, no spacer)\n // The actual element moves with CSS transform, other items shift via transforms\n this.dragClone = null; // Explicitly no clone for sidebar\n this.spacer = null; // No spacer - we use transforms instead\n\n // Mark element as being dragged (for CSS styling)\n this.draggedElement.classList.add('dragging-sidebar-item');\n\n // Get all sidebar products in this container for transform-based shuffle\n this.sidebarProducts = Array.from(\n this.sourceContainer.querySelectorAll('.sidebar-product-list-item'),\n );\n\n // Store original index and current logical position\n this.draggedIndex = this.sidebarProducts.indexOf(this.draggedElement);\n this.currentLogicalIndex = this.draggedIndex;\n\n // Store item height for shift calculations (assume uniform height)\n this.itemHeight = this.originalHeight;\n\n // Collapse the dragged element's space using negative margin\n // This prevents a gap from appearing at the original position\n this.draggedElement.style.marginBottom = `-${this.originalHeight}px`;\n\n // DON'T hide the element - it stays visible and moves with transform\n // DON'T create a clone - element stays in sidebar\n\n // Create landing area (may still be used for cross-category drops)\n this.landingArea = document.createElement('div');\n this.landingArea.classList.add('product-landing-area');\n this.landingArea.style.width = `${this.originalWidth}px`;\n this.landingArea.style.height = `${this.originalHeight}px`;\n this.landingArea.style.display = 'none';\n document.body.appendChild(this.landingArea);\n\n this.logDebug(`Sidebar drag initialized: index=${this.draggedIndex}, products=${this.sidebarProducts.length}`);\n return; // Exit early - sidebar setup complete\n }\n\n // GRID: Floating clone approach (existing behavior for grid products)\n // Create a clone that follows the cursor\n this.dragClone = this.draggedElement.cloneNode(true);\n\n // Save original HTML before adding class to preserve exact layout\n const originalHtml = this.draggedElement.innerHTML;\n const originalClasses = this.draggedElement.className;\n\n // Preserve computed styles from the original element\n const computedStyle = window.getComputedStyle(this.draggedElement);\n\n // Apply original classes first, then add dragging class\n this.dragClone.className = originalClasses + ' product-dragging';\n\n // Apply dimensions based on source element type\n if (this.isDraggedElementFromSidebar) {\n // Sidebar products: preserve original compact dimensions\n this.dragClone.style.width = `${this.originalWidth}px`;\n this.dragClone.style.maxWidth = 'none';\n this.dragClone.style.minWidth = 'none';\n this.dragClone.style.height = 'auto';\n this.dragClone.style.minHeight = 'auto';\n this.dragClone.style.maxHeight = 'none';\n this.logDebug(`Sidebar drag clone using original dimensions: ${this.originalWidth}px width`);\n } else {\n // Grid products: large card format\n this.dragClone.style.width = '280px';\n this.dragClone.style.maxWidth = '280px';\n this.dragClone.style.minWidth = '260px';\n this.dragClone.style.height = 'auto';\n this.dragClone.style.minHeight = '380px';\n this.dragClone.style.maxHeight = '400px';\n }\n this.dragClone.style.top = `${rect.top}px`;\n this.dragClone.style.left = `${rect.left}px`;\n\n // Make sure inner HTML stays exactly the same\n this.dragClone.innerHTML = originalHtml;\n\n // Special handling for background images BEFORE we modify any other styles\n // This captures the original inline styles before we start changing them\n this.preserveBackgroundImages(this.draggedElement, this.dragClone);\n\n // Preserve important CSS properties (except padding which is set based on element type)\n if (this.isDraggedElementFromSidebar) {\n this.dragClone.style.padding = '8px 12px';\n } else {\n this.dragClone.style.padding = '15px';\n }\n this.dragClone.style.margin = '0'; // Reset margins for absolute positioning\n this.dragClone.style.backgroundColor = computedStyle.backgroundColor || 'white';\n this.dragClone.style.color = computedStyle.color;\n this.dragClone.style.fontSize = computedStyle.fontSize;\n this.dragClone.style.fontFamily = computedStyle.fontFamily;\n this.dragClone.style.lineHeight = computedStyle.lineHeight;\n this.dragClone.style.textAlign = computedStyle.textAlign;\n this.dragClone.style.borderRadius = computedStyle.borderRadius || '4px';\n\n // Set layout properties to ensure content is displayed properly\n this.dragClone.style.overflow = 'visible';\n this.dragClone.style.display = computedStyle.display;\n\n // Apply flex properties if the original used flex\n if (computedStyle.display.includes('flex')) {\n this.dragClone.style.flexDirection = computedStyle.flexDirection;\n this.dragClone.style.justifyContent = computedStyle.justifyContent;\n this.dragClone.style.alignItems = computedStyle.alignItems;\n }\n\n // For grid products only: process images and reorganize layout\n // Sidebar products retain their original compact structure\n if (!this.isDraggedElementFromSidebar) {\n // Ensure images and content is preserved\n const images = this.dragClone.querySelectorAll('img');\n if (images.length > 0) {\n images.forEach(img => {\n img.style.maxWidth = '100%';\n img.style.height = 'auto';\n img.style.display = 'block';\n img.style.objectFit = 'contain';\n });\n }\n\n // Handle background-image elements (this is crucial for product images)\n const backgroundImageElements = this.dragClone.querySelectorAll('.product-photo-current');\n if (backgroundImageElements.length > 0) {\n this.logDebug(`Found ${backgroundImageElements.length} background image elements`);\n backgroundImageElements.forEach(el => {\n // Get the inline style from the original element (contains the background-image URL)\n const inlineStyle = el.getAttribute('style') || '';\n const bgMatch = inlineStyle.match(/background-image:\\s*url\\(['\"]?([^'\"]+)['\"]?\\)/);\n\n if (bgMatch && bgMatch[1]) {\n // Directly apply the extracted URL to ensure the background image is preserved\n el.style.backgroundImage = `url('${bgMatch[1]}')`;\n this.logDebug(`Applied background image from inline style: ${bgMatch[1].substring(0, 50)}...`);\n } else {\n // Fallback to computed style if inline style doesn't have the URL\n try {\n const originalElement = this.draggedElement.querySelector('.product-photo-current');\n if (originalElement) {\n const computedBgStyle = window.getComputedStyle(originalElement).backgroundImage;\n el.style.backgroundImage = computedBgStyle;\n this.logDebug(`Applied background image from computed style: ${computedBgStyle.substring(0, 50)}...`);\n }\n } catch (bgError) {\n this.logDebug(`Error getting background image: ${bgError.message}`);\n }\n }\n\n // Set other background properties\n el.style.backgroundSize = 'contain';\n el.style.backgroundPosition = 'center center';\n el.style.backgroundRepeat = 'no-repeat';\n el.style.display = 'block';\n el.style.minHeight = '180px'; // Increased from 100px to 180px for larger image display\n el.style.width = '100%';\n el.style.maxHeight = 'none'; // Remove any height restrictions\n el.style.flexGrow = '1'; // Allow the image to take up available space\n });\n } else {\n this.logDebug('No .product-photo-current elements found in clone');\n\n // Fallback: Look for any element with background-image style\n try {\n const originalPhotoDivs = this.draggedElement.querySelectorAll('div[style*=\"background-image\"]');\n if (originalPhotoDivs.length > 0) {\n this.logDebug(`Found ${originalPhotoDivs.length} original elements with background-image styles`);\n\n originalPhotoDivs.forEach(originalDiv => {\n // Extract the style\n const style = originalDiv.getAttribute('style') || '';\n const bgMatch = style.match(/background-image:\\s*url\\(['\"]?([^'\"]+)['\"]?\\)/);\n\n if (bgMatch && bgMatch[1]) {\n // Find a corresponding element in the clone\n // Use a similar structure/class if possible\n const className = originalDiv.className;\n let targetElement;\n\n if (className) {\n targetElement = this.dragClone.querySelector(`.${className.split(' ')[0]}`);\n }\n\n // If no matching element found, create one\n if (!targetElement) {\n targetElement = document.createElement('div');\n targetElement.className = className || 'product-photo-current';\n targetElement.style.backgroundSize = 'contain';\n targetElement.style.backgroundPosition = 'center center';\n targetElement.style.backgroundRepeat = 'no-repeat';\n targetElement.style.minHeight = '180px'; // Increased from 100px for larger image\n targetElement.style.width = '100%';\n targetElement.style.maxHeight = 'none';\n targetElement.style.flexGrow = '1';\n targetElement.style.display = 'block';\n\n // Add at the top of the clone for visibility\n if (this.dragClone.firstChild) {\n this.dragClone.insertBefore(targetElement, this.dragClone.firstChild);\n } else {\n this.dragClone.appendChild(targetElement);\n }\n }\n\n // Apply the background image\n targetElement.style.backgroundImage = `url('${bgMatch[1]}')`;\n this.logDebug(`Created/updated element with background image: ${bgMatch[1].substring(0, 50)}...`);\n }\n });\n }\n } catch (fallbackError) {\n this.logDebug(`Error in background image fallback: ${fallbackError.message}`);\n }\n }\n\n // Apply specific styles for product elements\n const productTitle = this.dragClone.querySelector('.product-title, .sidebar-product-title');\n if (productTitle) {\n productTitle.style.display = 'block';\n productTitle.style.whiteSpace = 'normal';\n productTitle.style.overflow = 'visible';\n productTitle.style.textOverflow = 'clip';\n productTitle.style.textAlign = 'center';\n productTitle.style.width = '100%';\n productTitle.style.margin = '5px 0';\n }\n\n // Special handling for product SKU/style number\n const productSku = this.dragClone.querySelector('.product-sku, .sidebar-product-sku');\n if (productSku) {\n productSku.style.display = 'block';\n productSku.style.visibility = 'visible';\n productSku.style.textAlign = 'center';\n productSku.style.width = '100%';\n productSku.style.margin = '5px auto';\n productSku.style.fontWeight = 'bold';\n }\n\n // Ensure price elements remain visible and centered\n const priceElements = this.dragClone.querySelectorAll('.product-price, .sidebar-product-price');\n priceElements.forEach(el => {\n el.style.display = 'block';\n el.style.visibility = 'visible';\n el.style.textAlign = 'center';\n el.style.width = '100%';\n el.style.margin = '5px 0';\n });\n\n // Rearrange elements for proper layout if needed\n try {\n // Create a flex layout structure\n const container = document.createElement('div');\n container.style.display = 'flex';\n container.style.flexDirection = 'column';\n container.style.alignItems = 'center';\n container.style.justifyContent = 'space-between';\n container.style.height = '100%';\n container.style.width = '100%';\n\n // Organize the product card correctly\n const photoElement = this.dragClone.querySelector('.product-photo-current');\n if (photoElement) {\n // Create a wrapper div for the photo for proper centering\n const photoWrapper = document.createElement('div');\n photoWrapper.style.width = '240px'; // Fixed width for image container\n photoWrapper.style.maxWidth = '240px';\n photoWrapper.style.margin = '0 auto 15px auto';\n photoWrapper.style.display = 'flex';\n photoWrapper.style.justifyContent = 'center';\n photoWrapper.style.alignItems = 'center';\n\n // Configure the photo element\n photoElement.style.width = '100%';\n photoElement.style.minHeight = '180px';\n photoElement.style.maxHeight = '200px';\n photoElement.style.backgroundSize = 'contain';\n photoElement.style.backgroundPosition = 'center center';\n photoElement.style.backgroundRepeat = 'no-repeat';\n photoElement.style.margin = '0 auto';\n\n // Move the photo to the top\n if (photoElement.parentNode) {\n photoElement.parentNode.removeChild(photoElement);\n }\n\n photoWrapper.appendChild(photoElement);\n container.appendChild(photoWrapper);\n }\n\n // Create the info section\n const infoSection = document.createElement('div');\n infoSection.style.textAlign = 'center';\n infoSection.style.width = '100%';\n infoSection.style.display = 'flex';\n infoSection.style.flexDirection = 'column';\n infoSection.style.alignItems = 'center';\n infoSection.style.justifyContent = 'center';\n\n // Append title, sku, price in order if they exist\n if (productTitle && productTitle.parentNode) {\n productTitle.parentNode.removeChild(productTitle);\n infoSection.appendChild(productTitle);\n }\n\n if (productSku && productSku.parentNode) {\n productSku.parentNode.removeChild(productSku);\n infoSection.appendChild(productSku);\n }\n\n priceElements.forEach(el => {\n if (el.parentNode) {\n el.parentNode.removeChild(el);\n infoSection.appendChild(el);\n }\n });\n\n container.appendChild(infoSection);\n\n // Only replace content if we have a valid structure\n if (container.children.length > 0) {\n this.dragClone.innerHTML = '';\n this.dragClone.appendChild(container);\n }\n } catch (layoutError) {\n this.logDebug(`Error organizing layout: ${layoutError.message}`);\n // If layout reorganization fails, at least center the text\n const allTextElements = this.dragClone.querySelectorAll('div, span, p');\n allTextElements.forEach(el => {\n el.style.textAlign = 'center';\n });\n }\n } // End of grid-only image/layout processing\n\n document.body.appendChild(this.dragClone);\n\n // Create the green target area (initially hidden)\n this.landingArea = document.createElement('div');\n this.landingArea.classList.add('product-landing-area');\n this.landingArea.style.width = `${this.originalWidth}px`;\n this.landingArea.style.height = `${this.originalHeight}px`;\n this.landingArea.style.display = 'none';\n document.body.appendChild(this.landingArea);\n\n // Create a spacer element to take the place of the product being dragged\n // Note: Sidebar products return early and don't use spacers (they use transforms)\n this.spacer = document.createElement('div');\n this.spacer.classList.add('product-spacer');\n this.spacer.style.width = `${this.originalWidth}px`;\n this.spacer.style.height = `${this.originalHeight}px`;\n\n // Hide the original element (grid only - sidebar returns early and stays visible)\n this.draggedElement.style.display = 'none';\n\n this.logDebug(`Full drag state initialized: ${this.originalWidth}x${this.originalHeight}`);\n } catch (error) {\n this.logDebug(`Error in initializeFullDragState: ${error.message}`);\n this.cleanupDrag();\n }\n }\n\n /**\n * Handles mouse movement during interaction\n * Detects if movement exceeds threshold to initiate full drag mode\n * Updates visual state and calculates potential drop locations during active drag\n *\n * @method dragMove\n * @param {MouseEvent} event - The mousemove event\n * @returns {void}\n */\n dragMove(event) {\n // If we're using passive detection, this shouldn't be called until drag is confirmed\n if (this.passiveDragDetection) {\n this.logDebug('dragMove called while in passive detection mode - this should not happen');\n return;\n }\n\n // Don't process moves if a drag hasn't been fully initiated yet\n if (!this.isDragInitiated) {\n // If no dragged element was set up (e.g., for navigation controller elements), ignore\n if (!this.draggedElement) {\n return;\n }\n\n // Check if movement exceeds drag threshold\n const moveDistanceX = Math.abs(event.clientX - this.initialClickX);\n const moveDistanceY = Math.abs(event.clientY - this.initialClickY);\n\n this.logDebug(`Mouse move distance: X=${moveDistanceX}, Y=${moveDistanceY}`);\n\n // Only start actual dragging if mouse has moved more than threshold in any direction\n if (moveDistanceX > DRAG_THRESHOLD_PX || moveDistanceY > DRAG_THRESHOLD_PX) {\n this.logDebug('Movement threshold exceeded, starting drag');\n\n // For non-navigation controller elements, verify we can start drag\n if (!eventCoordinator.startDrag(this.draggedElement)) {\n this.logDebug('Event coordinator denied drag start - navigation in progress');\n this.cleanupDrag();\n return;\n }\n\n this.isDragInitiated = true;\n // Now that we've confirmed it's a drag, prevent default to stop text selection etc.\n if (event) {\n event.preventDefault();\n }\n // Clear the click timeout since this is now confirmed to be a drag\n if (this.clickTimeout) {\n this.logDebug('Clearing click timeout');\n clearTimeout(this.clickTimeout);\n this.clickTimeout = null;\n }\n // Initialize the full drag state now that we've confirmed it's a drag\n if (!this.isDragging) {\n this.initializeFullDragState();\n }\n } else {\n return; // Don't process the drag yet\n }\n }\n try {\n if (!event) {\n this.logDebug('No event object in dragMove');\n return;\n }\n\n // For grid products, dragClone is required. For sidebar products, dragClone is null by design.\n if (!this.isDragging || !this.draggedElement || (!this.dragClone && !this.isDraggedElementFromSidebar)) {\n // If we're supposed to be dragging but don't have the necessary elements,\n // something went wrong - clean up\n if (this.isDragging) {\n this.logDebug('Drag state inconsistency detected, cleaning up');\n this.cleanupDrag();\n }\n return;\n }\n\n try {\n event.preventDefault();\n } catch (preventError) {\n this.logDebug(`Could not prevent default event: ${preventError.message}`);\n }\n\n // Move the clone with the cursor\n const offsetX = event.clientX - this.initialX;\n const offsetY = event.clientY - this.initialY;\n\n // Constrain sidebar drag to Y-axis only (horizontal movement unnecessary in vertical list)\n if (this.isDraggedElementFromSidebar) {\n // SIDEBAR: Apply transform to ACTUAL element (no clone)\n this.draggedElement.style.transform = `translateY(${offsetY}px)`;\n\n // Update shuffle transforms on other items (gapless animation)\n this.updateSidebarShuffleTransforms();\n\n // Throttle cross-category detection (every 150ms, not every frame)\n // elementFromPoint() is expensive - triggers layout calculation\n const crossCategoryCheckNow = Date.now();\n if (!this.lastCrossCategoryCheck || crossCategoryCheckNow - this.lastCrossCategoryCheck > 150) {\n this.lastCrossCategoryCheck = crossCategoryCheckNow;\n\n // Check if cursor is over a DIFFERENT category (cross-category drop)\n // Temporarily disable pointer-events on dragged element so elementFromPoint\n // can \"see through\" to the category underneath (the dragged element has z-index: 1000)\n const originalPointerEvents = this.draggedElement.style.pointerEvents;\n this.draggedElement.style.pointerEvents = 'none';\n const crossCategoryElemBelow = document.elementFromPoint(event.clientX, event.clientY);\n this.draggedElement.style.pointerEvents = originalPointerEvents || '';\n\n let crossCategoryTargetList = null;\n if (crossCategoryElemBelow) {\n // Find the product list container using data-target (only on ul, not on li items)\n // This works in both production (ul[data-target]) and tests (div[data-target])\n crossCategoryTargetList = crossCategoryElemBelow.closest('[data-target=\"drag-product.draggableProduct\"]');\n\n // Fallback: if hovering over category heading (outside the list), find the list within the category row\n if (!crossCategoryTargetList) {\n const crossCategoryCategoryRow = crossCategoryElemBelow.closest('.sidebar-category-row');\n if (crossCategoryCategoryRow) {\n crossCategoryTargetList = crossCategoryCategoryRow.querySelector('[data-target=\"drag-product.draggableProduct\"]');\n }\n }\n }\n let crossCategoryCurrentId = this.draggedElement.closest('[data-category-id]');\n crossCategoryCurrentId = crossCategoryCurrentId ? crossCategoryCurrentId.dataset.categoryId : null;\n\n if (crossCategoryTargetList && crossCategoryTargetList.dataset.categoryId !== crossCategoryCurrentId) {\n // Cursor is over a different category - track it for drop\n // Clear previous highlight\n var prevHighlight = document.querySelector('.drag-target-active');\n if (prevHighlight) prevHighlight.classList.remove('drag-target-active');\n\n // Add highlight to new target category\n const categoryRow = crossCategoryTargetList.closest('.sidebar-category-row');\n if (categoryRow) categoryRow.classList.add('drag-target-active');\n\n this.crossCategoryTarget = crossCategoryTargetList;\n this.crossCategoryTargetId = crossCategoryTargetList.dataset.categoryId;\n } else {\n // Still in same category - clear cross-category target and highlight\n var prevHighlight = document.querySelector('.drag-target-active');\n if (prevHighlight) prevHighlight.classList.remove('drag-target-active');\n\n this.crossCategoryTarget = null;\n this.crossCategoryTargetId = null;\n }\n }\n\n // For within-category reordering, continue with shuffle transforms only\n return;\n } else {\n // GRID: Apply transform to clone\n this.dragClone.style.transform = `translate(${offsetX}px, ${offsetY}px)`;\n }\n\n // --- GRID ONLY CODE BELOW (sidebar returns early above) ---\n\n // Ensure the clone remains visible and properly styled during dragging\n if (this.dragClone.style.display === 'none') {\n // Don't force block display - use original display property\n const origDisplay = window.getComputedStyle(this.draggedElement).display;\n this.dragClone.style.display = origDisplay === 'none' ? 'block' : origDisplay;\n }\n\n // Remember original display state\n const originalCloneDisplay = this.dragClone.style.display;\n const originalLandingDisplay = this.landingArea.style.display;\n\n // Hide elements to detect what's underneath\n try {\n this.dragClone.style.display = 'none';\n this.landingArea.style.display = 'none';\n } catch (styleError) {\n this.logDebug(`Error hiding elements: ${styleError.message}`);\n }\n\n let elemBelow = null;\n try {\n elemBelow = document.elementFromPoint(event.clientX, event.clientY);\n } catch (pointError) {\n this.logDebug(`Error getting element from point: ${pointError.message}`);\n }\n\n // Restore visibility - preserve original styling\n try {\n // Restore original display states rather than hardcoding\n this.dragClone.style.display = originalCloneDisplay;\n this.landingArea.style.display = originalLandingDisplay;\n\n // Ensure the clone remains visible for users\n if (this.dragClone.style.display === 'none') {\n this.dragClone.style.display = 'block';\n }\n\n // Re-apply background image styles that might have been lost during hide/show\n try {\n const photoElement = this.dragClone.querySelector('.product-photo-current');\n if (photoElement) {\n // Ensure the background image is still present after toggling display\n const style = photoElement.getAttribute('style') || '';\n if (!style.includes('background-image') && this.draggedElement) {\n // Try to get the style from original element\n const originalPhoto = this.draggedElement.querySelector('.product-photo-current');\n if (originalPhoto) {\n const originalStyle = originalPhoto.getAttribute('style') || '';\n photoElement.setAttribute('style', originalStyle);\n\n // Ensure display properties\n photoElement.style.display = 'block';\n photoElement.style.visibility = 'visible';\n // Maintain larger image size\n photoElement.style.minHeight = '180px';\n photoElement.style.maxHeight = 'none';\n photoElement.style.width = '100%';\n }\n }\n }\n } catch (bgRestoreError) {\n this.logDebug(`Error restoring background image: ${bgRestoreError.message}`);\n }\n } catch (restoreError) {\n this.logDebug(`Error restoring visibility: ${restoreError.message}`);\n // Fallback - ensure at least clone is visible\n try {\n this.dragClone.style.display = 'block';\n } catch {\n // Silently ignore display errors\n }\n }\n\n if (!elemBelow) {\n this.logDebug('No element found under cursor');\n return;\n }\n\n // Find container and products\n this.logDebug(`Element below cursor: ${elemBelow?.tagName} ${elemBelow?.className} id=${elemBelow?.id}`);\n const container = this.findContainer(elemBelow);\n if (!container) {\n this.logDebug('No valid container found for drop');\n return;\n }\n this.logDebug(`Found container: ${container.tagName} ${container.className} id=${container.id} categoryId=${container.dataset.categoryId}`);\n\n // For sidebar products, add additional spatial constraint\n if (this.isDraggedElementFromSidebar) {\n // Check if cursor is within sidebar bounds\n if (!this.isCursorInSidebarBounds(event.clientX, event.clientY)) {\n this.landingArea.style.display = 'none';\n if (this.dragClone) {\n this.dragClone.classList.add('invalid-drop');\n }\n return;\n } else {\n // Remove invalid-drop class when back in valid bounds\n if (this.dragClone) {\n this.dragClone.classList.remove('invalid-drop');\n }\n }\n\n // Also check container type\n if (!this.isSidebarContainer(container)) {\n this.landingArea.style.display = 'none';\n if (this.dragClone) {\n this.dragClone.classList.add('invalid-drop');\n }\n return;\n } else {\n // Remove invalid-drop class when back in valid container\n if (this.dragClone) {\n this.dragClone.classList.remove('invalid-drop');\n }\n }\n }\n\n // Similarly, prevent main grid products from being dropped in sidebar\n if (!this.isDraggedElementFromSidebar && this.isSidebarContainer(container)) {\n this.logDebug(`Main grid product cannot be dropped in sidebar container (${container.id || 'unnamed'})`);\n this.landingArea.style.display = 'none';\n if (this.dragClone) {\n this.dragClone.classList.add('invalid-drop');\n }\n return;\n } else if (!this.isDraggedElementFromSidebar) {\n // Remove invalid-drop class when back in valid main grid container\n if (this.dragClone) {\n this.dragClone.classList.remove('invalid-drop');\n }\n }\n\n this.logDebug(`Container ${container.id || 'unnamed'} is valid for current drag operation`);\n\n this.targetContainer = container;\n\n // Get all products in this container\n let allProducts = [];\n let visibleProducts = [];\n\n try {\n allProducts = Array.from(container.querySelectorAll(this.markupClasses) || []);\n\n visibleProducts = allProducts.filter(p => {\n try {\n return p !== this.draggedElement && (!p.style || p.style.display !== 'none');\n } catch (filterError) {\n this.logDebug(`Error filtering product: ${filterError.message}`);\n return false;\n }\n });\n\n this.logDebug(`Found ${allProducts.length} total products, ${visibleProducts.length} visible products in container`);\n if (allProducts.length > 0) {\n this.logDebug(`Product classes found: ${allProducts.map(p => p.className).join(', ')}`);\n }\n } catch (productsError) {\n this.logDebug(`Error getting products: ${productsError.message}`);\n visibleProducts = [];\n }\n\n // Find the nearest row and column position using throttled function to improve performance\n try {\n this.throttledFindNearestGridPosition(container, visibleProducts, event.clientX, event.clientY);\n } catch (gridError) {\n this.logDebug(`Error in grid position calculation: ${gridError.message}`);\n }\n } catch (error) {\n this.logDebug(`Error in dragMove: ${error.message}`);\n this.cleanupDrag();\n }\n }\n\n /**\n * Sets the landing area position with sidebar-specific constraints\n * For sidebar containers, constrains positioning to container bounds\n *\n * @method setSidebarConstrainedPosition\n * @param {Element} container - The container element\n * @param {number} top - The top position\n * @param {number} left - The left position\n * @param {number} width - The width\n * @param {number} height - The height\n * @returns {void}\n */\n setSidebarConstrainedPosition(container, top, left, width, height) {\n if (this.isDraggedElementFromSidebar && this.isSidebarContainer(container)) {\n const containerRect = container.getBoundingClientRect();\n\n // For sidebar products, force vertical layout by using container left position\n // This ensures the landing area is always within the sidebar bounds\n const sidebarLeft = containerRect.left + 10; // 10px padding from left edge\n const sidebarWidth = containerRect.width - 20; // Account for padding on both sides\n\n this.landingArea.style.top = `${top}px`;\n this.landingArea.style.left = `${sidebarLeft}px`;\n this.landingArea.style.width = `${sidebarWidth}px`;\n this.landingArea.style.height = `${height}px`;\n } else {\n // For main grid products, use original positioning\n this.landingArea.style.top = `${top}px`;\n this.landingArea.style.left = `${left}px`;\n this.landingArea.style.width = `${width}px`;\n this.landingArea.style.height = `${height}px`;\n }\n }\n\n /**\n * Moves the spacer to a new position in the sidebar for live shuffle effect\n * Only moves if position actually changed to avoid layout thrashing\n *\n * @method moveSpacerToPosition\n * @param {Element} container - The target container\n * @param {Element|null} targetElement - Element to insert before, or null for append\n * @param {string} position - 'before', 'after', or 'empty'\n * @returns {void}\n */\n moveSpacerToPosition(container, targetElement, position) {\n if (!this.spacer || !this.isDraggedElementFromSidebar) {\n return;\n }\n\n let newNextSibling = null;\n\n if (position === 'before' && targetElement) {\n newNextSibling = targetElement;\n } else if (position === 'after' && targetElement) {\n newNextSibling = targetElement.nextSibling;\n }\n // 'empty' case: newNextSibling stays null (append)\n\n // Skip if spacer is already at this position\n if (this.spacerCurrentContainer === container &&\n this.spacerCurrentNextSibling === newNextSibling) {\n return;\n }\n\n // Move spacer to new position\n if (newNextSibling) {\n container.insertBefore(this.spacer, newNextSibling);\n } else {\n container.appendChild(this.spacer);\n }\n\n // Update tracking\n this.spacerCurrentContainer = container;\n this.spacerCurrentNextSibling = newNextSibling;\n\n this.logDebug(`Moved spacer: position=${position}, container=${container.id || 'unnamed'}`);\n }\n\n /**\n * Checks if the dragged sidebar element should swap with another element\n * Based on center-point comparison (like color variants modal)\n * This replaces cursor-based detection with element overlap detection\n *\n * @method checkForSidebarReorder\n * @returns {void}\n */\n checkForSidebarReorder() {\n if (!this.isDraggedElementFromSidebar || !this.dragClone || !this.spacer) {\n return;\n }\n\n const container = this.sourceContainer;\n if (!container) return;\n\n // Get all sidebar products except the hidden dragged element and the spacer\n const products = Array.from(container.querySelectorAll('.sidebar-product-list-item'))\n .filter(el => el !== this.draggedElement && el !== this.spacer && el.style.display !== 'none');\n\n if (products.length === 0) return;\n\n // Get the dragged clone's bounding rect (this follows the cursor)\n const draggedRect = this.dragClone.getBoundingClientRect();\n const draggedCenterY = draggedRect.top + draggedRect.height / 2;\n\n for (const product of products) {\n const rect = product.getBoundingClientRect();\n const centerY = rect.top + rect.height / 2;\n\n // Check if we should swap (vertical comparison for sidebar)\n if (draggedCenterY < centerY && this.isAfterInDOM(this.spacer, product)) {\n // Dragged element moved UP past this product - move spacer before it\n product.parentNode.insertBefore(this.spacer, product);\n this.logDebug('Shuffled: spacer moved BEFORE product');\n break; // Only swap once per check\n } else if (draggedCenterY > centerY && this.isBeforeInDOM(this.spacer, product)) {\n // Dragged element moved DOWN past this product - move spacer after it\n product.parentNode.insertBefore(this.spacer, product.nextSibling);\n this.logDebug('Shuffled: spacer moved AFTER product');\n break; // Only swap once per check\n }\n }\n }\n\n /**\n * Check if el1 comes after el2 in DOM order\n *\n * @method isAfterInDOM\n * @param {Element} el1 - First element\n * @param {Element} el2 - Second element\n * @returns {boolean} - True if el1 comes after el2\n */\n isAfterInDOM(el1, el2) {\n return !!(el1.compareDocumentPosition(el2) & Node.DOCUMENT_POSITION_PRECEDING);\n }\n\n /**\n * Check if el1 comes before el2 in DOM order\n *\n * @method isBeforeInDOM\n * @param {Element} el1 - First element\n * @param {Element} el2 - Second element\n * @returns {boolean} - True if el1 comes before el2\n */\n isBeforeInDOM(el1, el2) {\n return !!(el1.compareDocumentPosition(el2) & Node.DOCUMENT_POSITION_FOLLOWING);\n }\n\n /**\n * Updates transforms on all sidebar items to create shuffle effect\n * Items shift up/down based on where the dragged item currently is\n * This creates a gapless shuffle animation without using spacers\n *\n * @method updateSidebarShuffleTransforms\n * @returns {void}\n */\n updateSidebarShuffleTransforms() {\n if (!this.isDraggedElementFromSidebar || !this.sidebarProducts) {\n return;\n }\n\n // Get the visual center of the dragged element (it's transformed, so getBoundingClientRect reflects visual position)\n const draggedRect = this.draggedElement.getBoundingClientRect();\n const draggedCenterY = draggedRect.top + draggedRect.height / 2;\n\n // Determine where the dragged item would logically be in the list\n let newLogicalIndex = this.draggedIndex; // Default to original position\n\n // Compare dragged item's center with other items to find new logical position\n for (let i = 0; i < this.sidebarProducts.length; i++) {\n if (this.sidebarProducts[i] === this.draggedElement) continue;\n\n const item = this.sidebarProducts[i];\n // Get the item's \"original\" position (without its current transform)\n // We need to account for any transform already applied\n const currentTransform = item.style.transform;\n item.style.transform = '';\n const itemRect = item.getBoundingClientRect();\n item.style.transform = currentTransform;\n\n const itemCenterY = itemRect.top + itemRect.height / 2;\n\n // If dragged item's center is above this item's center, it should be before it\n if (draggedCenterY < itemCenterY) {\n newLogicalIndex = i;\n // Adjust if moving from below (original index is higher)\n if (i > this.draggedIndex) newLogicalIndex = i - 1;\n break;\n } else {\n // Dragged item is below this item, so it goes after\n newLogicalIndex = i;\n // Adjust if moving from above (original index is lower)\n if (i < this.draggedIndex) newLogicalIndex = i + 1;\n }\n }\n\n // Clamp to valid range\n newLogicalIndex = Math.max(0, Math.min(newLogicalIndex, this.sidebarProducts.length - 1));\n\n // Only log if position changed\n if (newLogicalIndex !== this.currentLogicalIndex) {\n this.currentLogicalIndex = newLogicalIndex;\n this.logDebug(`Shuffle: logical index changed to ${newLogicalIndex}`);\n }\n\n // Apply transforms to create visual shuffle effect\n for (let i = 0; i < this.sidebarProducts.length; i++) {\n const item = this.sidebarProducts[i];\n if (item === this.draggedElement) continue; // Dragged item handles its own transform\n\n let shiftDirection = 0;\n\n // Items between original position and current logical position need to shift\n if (this.draggedIndex < this.currentLogicalIndex) {\n // Dragging DOWN: items between original and current shift UP\n if (i > this.draggedIndex && i <= this.currentLogicalIndex) {\n shiftDirection = -1; // Shift up\n }\n } else if (this.draggedIndex > this.currentLogicalIndex) {\n // Dragging UP: items between current and original shift DOWN\n if (i >= this.currentLogicalIndex && i < this.draggedIndex) {\n shiftDirection = 1; // Shift down\n }\n }\n\n // Apply transform\n if (shiftDirection !== 0) {\n item.style.transform = `translateY(${shiftDirection * this.itemHeight}px)`;\n } else {\n item.style.transform = '';\n }\n }\n }\n\n /**\n * Updates transforms on grid items to create shuffle effect during drag\n * Items shift left/right based on where the dragged item would be placed\n * Similar pattern to updateSidebarShuffleTransforms but for horizontal grid\n *\n * @method updateGridShuffleTransforms\n * @param {Element} container - The container element\n * @param {Array} products - Array of visible product elements\n * @param {number} targetIndex - The index where the dragged item would be placed\n * @returns {void}\n */\n updateGridShuffleTransforms(container, products, targetIndex) {\n if (!products || products.length === 0) return;\n\n // Skip if dragging sidebar product (sidebar uses vertical transforms)\n if (this.isDraggedElementFromSidebar) return;\n\n const allProducts = Array.from(container.querySelectorAll(this.markupClasses));\n const draggedIndex = allProducts.indexOf(this.draggedElement);\n\n if (draggedIndex === -1) return;\n\n // Calculate item dimensions for transform (use first visible product)\n const sampleProduct = products[0];\n if (!sampleProduct) return;\n const rect = sampleProduct.getBoundingClientRect();\n const itemWidth = rect.width;\n const horizontalGap = 20; // Approximate gap between items\n const shiftAmount = itemWidth + horizontalGap;\n\n // Apply transforms to create visual shuffle effect\n for (let i = 0; i < allProducts.length; i++) {\n const item = allProducts[i];\n if (item === this.draggedElement) continue; // Skip the dragged item (it's hidden)\n\n let shiftDirection = 0;\n\n // Items between original position and target position need to shift\n if (draggedIndex < targetIndex) {\n // Dragging RIGHT: items between original and target shift LEFT\n if (i > draggedIndex && i <= targetIndex) {\n shiftDirection = -1; // Shift left\n }\n } else if (draggedIndex > targetIndex) {\n // Dragging LEFT: items between target and original shift RIGHT\n if (i >= targetIndex && i < draggedIndex) {\n shiftDirection = 1; // Shift right\n }\n }\n\n // Apply transform\n if (shiftDirection !== 0) {\n item.style.transform = `translateX(${shiftDirection * shiftAmount}px)`;\n item.classList.add('will-shift');\n } else {\n item.style.transform = '';\n item.classList.remove('will-shift');\n }\n }\n\n // Store current target index for drop handling\n this.currentGridTargetIndex = targetIndex;\n }\n\n /**\n * Calculates the target index based on target element and position\n *\n * @method calculateGridTargetIndex\n * @param {Element} container - The container element\n * @param {Element} targetElement - The element to insert relative to\n * @param {string} targetPosition - 'before', 'after', or 'empty'\n * @returns {number} - The target index\n */\n calculateGridTargetIndex(container, targetElement, targetPosition) {\n if (targetPosition === 'empty') return 0;\n\n const allProducts = Array.from(container.querySelectorAll(this.markupClasses));\n const targetIdx = allProducts.indexOf(targetElement);\n\n if (targetPosition === 'after') {\n return targetIdx + 1;\n }\n return targetIdx;\n }\n\n /**\n * Finds the nearest valid grid position for dropping a product\n * Uses a grid-based approach to determine where a product should be placed\n * Implements position caching to improve performance\n *\n * @method findNearestGridPosition\n * @param {Element} container - The container element\n * @param {Array} products - Array of product elements in the container\n * @param {number} cursorX - The X position of the cursor\n * @param {number} cursorY - The Y position of the cursor\n * @returns {void}\n */\n findNearestGridPosition(container, products, cursorX, cursorY) {\n // Ensure products is a valid array early\n let validProducts = products;\n if (!Array.isArray(products)) {\n validProducts = [];\n }\n\n try {\n if (!container || !(container instanceof Element)) {\n this.logDebug('Invalid container passed to findNearestGridPosition');\n return;\n }\n\n if (!Array.isArray(products)) {\n this.logDebug('Products must be an array');\n }\n\n if (typeof cursorX !== 'number' || typeof cursorY !== 'number') {\n this.logDebug(`Invalid cursor coordinates: X:${cursorX}, Y:${cursorY}`);\n return;\n }\n\n // Safety check - if we're no longer in dragging state, clean up\n if (!this.isDragging) {\n this.logDebug('No longer in dragging state, canceling grid position calculation');\n return;\n }\n\n // If DOM was modified during drag and some critical elements are missing, clean up\n if (!this.dragClone || !this.landingArea) {\n this.logDebug('Critical drag elements missing, cleaning up');\n this.cleanupDrag();\n return;\n }\n\n // Double-check container constraints before showing landing area\n if (this.isDraggedElementFromSidebar) {\n // Check spatial bounds first\n if (!this.isCursorInSidebarBounds(cursorX, cursorY)) {\n this.landingArea.style.display = 'none';\n return;\n }\n\n // Then check container type\n if (!this.isSidebarContainer(container)) {\n this.landingArea.style.display = 'none';\n return;\n }\n }\n\n if (!this.isDraggedElementFromSidebar && this.isSidebarContainer(container)) {\n this.landingArea.style.display = 'none';\n return;\n }\n\n // If no products, handle empty container (after constraint validation)\n if (validProducts.length === 0) {\n this.logDebug(`Empty container detected with ${validProducts.length} products`);\n // Check if we're hovering over an empty drop zone for better positioning\n const emptyDropZone = document.elementFromPoint(cursorX, cursorY)?.closest('[data-empty-drop-zone=\"true\"]');\n this.logDebug(`Empty drop zone found: ${emptyDropZone ? 'YES' : 'NO'}`);\n\n let targetRect;\n if (emptyDropZone) {\n // Position the landing area over the empty drop zone for better visual feedback\n targetRect = emptyDropZone.getBoundingClientRect();\n this.logDebug(`Positioning landing area over empty drop zone: ${targetRect.width}x${targetRect.height} at ${targetRect.left},${targetRect.top}`);\n } else {\n // Fallback to container positioning\n targetRect = container.getBoundingClientRect();\n this.logDebug(`Fallback to container positioning: ${targetRect.width}x${targetRect.height} at ${targetRect.left},${targetRect.top}`);\n }\n\n // For sidebar containers, constrain positioning to container bounds\n if (this.isDraggedElementFromSidebar && this.isSidebarContainer(container)) {\n this.landingArea.style.top = `${targetRect.top + 10}px`;\n this.landingArea.style.left = `${targetRect.left + 10}px`;\n this.landingArea.style.width = `${Math.min(this.originalWidth, targetRect.width - 20)}px`;\n this.landingArea.style.height = `${Math.max(this.originalHeight, targetRect.height - 20)}px`;\n } else {\n this.landingArea.style.top = `${targetRect.top + 10}px`;\n this.landingArea.style.left = `${targetRect.left + 10}px`;\n this.landingArea.style.width = `${this.originalWidth}px`;\n this.landingArea.style.height = `${this.originalHeight}px`;\n }\n\n // Landing area hidden - shuffle animations provide visual feedback\n this.targetPosition = 'empty';\n this.targetElement = null;\n return;\n }\n } catch (error) {\n this.logDebug(`Error in findNearestGridPosition validation: ${error.message}`);\n this.cleanupDrag();\n return;\n }\n\n // Check if we need to recalculate the grid\n const shouldRecalculate = this.shouldRecalculateGrid(container, validProducts);\n\n // Get product coordinates and row structure (either from cache or by calculating)\n let rows = [];\n\n if (shouldRecalculate) {\n // Get all coordinates of products to understand the grid\n const productCoords = validProducts.map(product => {\n const rect = product.getBoundingClientRect();\n return {\n product: product,\n left: rect.left,\n right: rect.right,\n top: rect.top,\n bottom: rect.bottom,\n centerX: rect.left + rect.width / 2,\n centerY: rect.top + rect.height / 2,\n width: rect.width,\n height: rect.height,\n };\n });\n\n // Find rows in the grid based on top position\n rows = [];\n const rowThreshold = 10; // Tolerance for row detection (reduced from 20px for more precise positioning)\n\n productCoords.forEach(coord => {\n // Find if this product belongs to an existing row\n let foundRow = false;\n for (const row of rows) {\n if (Math.abs(coord.top - row.top) < rowThreshold) {\n row.products.push(coord);\n foundRow = true;\n break;\n }\n }\n\n // If no matching row, create a new one\n if (!foundRow) {\n rows.push({\n top: coord.top,\n products: [coord],\n });\n }\n });\n\n // Sort rows by top position\n rows.sort((a, b) => a.top - b.top);\n\n // Sort products in each row by left position\n rows.forEach(row => {\n row.products.sort((a, b) => a.left - b.left);\n });\n\n // Cache the calculated rows\n this.cachedRowPositions = rows;\n this.lastContainerWidth = container.offsetWidth;\n this.lastContainerHeight = container.offsetHeight;\n this.lastProductCount = validProducts.length;\n this.lastCacheTime = Date.now();\n\n this.logDebug(`Recalculated grid with ${rows.length} rows and ${validProducts.length} products`);\n } else {\n // Use cached rows\n rows = this.cachedRowPositions;\n this.logDebug(`Using cached grid with ${rows.length} rows`);\n }\n\n // SIDEBAR-SPECIFIC: Handle vertical layout before/after boundaries\n // For sidebar containers (vertical list), detect when cursor is above first product or below last\n if (this.isDraggedElementFromSidebar && this.isSidebarContainer(container) && rows.length > 0) {\n const firstRow = rows[0];\n const lastRow = rows[rows.length - 1];\n\n // Check if cursor is ABOVE the first product (drop to position 0)\n if (cursorY < firstRow.top) {\n const firstProduct = firstRow.products[0].product;\n this.targetElement = firstProduct;\n this.targetPosition = 'before';\n this.targetContainer = container;\n\n this.setSidebarConstrainedPosition(\n container,\n firstRow.top,\n firstRow.products[0].left,\n firstRow.products[0].width,\n firstRow.products[0].height,\n );\n // Landing area hidden - shuffle animations provide visual feedback\n this.logDebug('Target: BEFORE FIRST (above first row in sidebar)');\n return;\n }\n\n // Check if cursor is BELOW the last product (drop to last position)\n const lastProduct = lastRow.products[lastRow.products.length - 1];\n if (cursorY > lastProduct.bottom) {\n this.targetElement = lastProduct.product;\n this.targetPosition = 'after';\n this.targetContainer = container;\n\n this.setSidebarConstrainedPosition(\n container,\n lastProduct.top + lastProduct.height + 5,\n lastProduct.left,\n lastProduct.width,\n lastProduct.height,\n );\n // Landing area hidden - shuffle animations provide visual feedback\n this.logDebug('Target: AFTER LAST (below last row in sidebar)');\n return;\n }\n }\n\n // Find the nearest row based on cursor Y position\n let targetRow = null;\n let minDistanceY = Infinity;\n\n rows.forEach(row => {\n // Use first product in row to get a reference height\n const productHeight = row.products[0] ? row.products[0].height : 0;\n const distance = Math.abs(cursorY - row.top - productHeight / 2);\n if (distance < minDistanceY) {\n minDistanceY = distance;\n targetRow = row;\n }\n });\n\n if (!targetRow) return; // Should never happen if we have products\n\n // Find insertion position within the row\n const rowProducts = targetRow.products;\n\n // Handle first position in row\n if (cursorX < rowProducts[0].left) {\n const firstProduct = rowProducts[0].product;\n this.targetElement = firstProduct;\n this.targetPosition = 'before';\n\n // Position the landing area\n this.setSidebarConstrainedPosition(container, rowProducts[0].top, rowProducts[0].left, rowProducts[0].width, rowProducts[0].height);\n // Landing area hidden - shuffle animations provide visual feedback\n\n // Trigger grid shuffle animation\n const targetIdx = this.calculateGridTargetIndex(container, this.targetElement, this.targetPosition);\n this.updateGridShuffleTransforms(container, validProducts, targetIdx);\n\n this.logDebug(`Target: BEFORE FIRST in row ${targetRow.top}`);\n return;\n }\n\n // Handle position after last product in row\n if (cursorX > rowProducts[rowProducts.length - 1].right) {\n const lastProduct = rowProducts[rowProducts.length - 1].product;\n this.targetElement = lastProduct;\n this.targetPosition = 'after';\n\n // Position the landing area after the last product\n const lastRect = rowProducts[rowProducts.length - 1];\n const newLeft = lastRect.right + 10; // 10px gap\n\n this.setSidebarConstrainedPosition(container, lastRect.top, newLeft, lastRect.width, lastRect.height);\n // Landing area hidden - shuffle animations provide visual feedback\n\n // Trigger grid shuffle animation\n const targetIdx = this.calculateGridTargetIndex(container, this.targetElement, this.targetPosition);\n this.updateGridShuffleTransforms(container, validProducts, targetIdx);\n\n this.logDebug(`Target: AFTER LAST in row ${targetRow.top}`);\n return;\n }\n\n // Handle position between products in the row\n for (let i = 0; i < rowProducts.length - 1; i++) {\n const current = rowProducts[i];\n const next = rowProducts[i + 1];\n\n // Check if cursor is between these two products\n if (cursorX > current.right && cursorX < next.left) {\n this.targetElement = next.product;\n this.targetPosition = 'before';\n\n // Position the landing area between the products\n const gapCenter = (current.right + next.left) / 2;\n const newLeft = gapCenter - (this.originalWidth / 2);\n\n this.setSidebarConstrainedPosition(container, current.top, newLeft, this.originalWidth, current.height);\n // Landing area hidden - shuffle animations provide visual feedback\n\n // Trigger grid shuffle animation\n const targetIdx = this.calculateGridTargetIndex(container, this.targetElement, this.targetPosition);\n this.updateGridShuffleTransforms(container, validProducts, targetIdx);\n\n this.logDebug(`Target: BETWEEN at position ${i}`);\n return;\n }\n }\n\n // If we get here, the cursor is directly over a product\n // Find which product and position based on which half of the product the cursor is on\n for (let i = 0; i < rowProducts.length; i++) {\n const prod = rowProducts[i];\n\n if (cursorX >= prod.left && cursorX <= prod.right) {\n // Check if cursor is in left or right half\n if (cursorX < prod.centerX) {\n // Left half - position before this product\n this.targetElement = prod.product;\n this.targetPosition = 'before';\n\n this.setSidebarConstrainedPosition(container, prod.top, prod.left, prod.width, prod.height);\n // Landing area hidden - shuffle animations provide visual feedback\n\n this.logDebug(`Target: BEFORE (left half) product ${i}`);\n } else {\n // Right half - position after this product\n this.targetElement = prod.product;\n this.targetPosition = 'after';\n\n // Calculate position for \"after\" - may need to go to next row\n if (i === rowProducts.length - 1) {\n // Last in row - position to the right\n const newLeft = prod.right + 10;\n\n this.setSidebarConstrainedPosition(container, prod.top, newLeft, prod.width, prod.height);\n // Landing area hidden - shuffle animations provide visual feedback\n\n this.logDebug(`Target: AFTER (right half, last in row) product ${i}`);\n } else {\n // Not last in row - position between this and next\n const next = rowProducts[i + 1];\n const gapCenter = (prod.right + next.left) / 2;\n const newLeft = gapCenter - (this.originalWidth / 2);\n\n this.setSidebarConstrainedPosition(container, prod.top, newLeft, this.originalWidth, prod.height);\n // Landing area hidden - shuffle animations provide visual feedback\n\n this.logDebug(`Target: AFTER (right half) product ${i}`);\n }\n }\n\n // Trigger grid shuffle animation\n const targetIdx = this.calculateGridTargetIndex(container, this.targetElement, this.targetPosition);\n this.updateGridShuffleTransforms(container, validProducts, targetIdx);\n\n return;\n }\n }\n }\n\n /**\n * Determines if the grid positions need to be recalculated\n * Based on changes in container size, number of products, or time elapsed\n *\n * @method shouldRecalculateGrid\n * @param {Element} container - The container element\n * @param {Array} products - Array of product elements\n * @returns {boolean} - Whether recalculation is needed\n */\n shouldRecalculateGrid(container, products) {\n // Always calculate on first call\n if (!this.cachedRowPositions) {\n return true;\n }\n\n // Check if container dimensions have changed\n const currentContainerWidth = container.offsetWidth;\n const currentContainerHeight = container.offsetHeight;\n\n if (\n this.lastContainerWidth !== currentContainerWidth ||\n this.lastContainerHeight !== currentContainerHeight\n ) {\n this.logDebug('Container dimensions changed, recalculating grid');\n return true;\n }\n\n // Check if number of products has changed\n if (this.lastProductCount !== products.length) {\n this.logDebug('Product count changed, recalculating grid');\n return true;\n }\n\n // Force recalculation periodically as a safety measure\n // This ensures that even if dimensions/counts haven't changed\n // but something else has (like element positions), we still update\n const timeSinceLastCalculation = Date.now() - this.lastCacheTime;\n if (timeSinceLastCalculation > DRAG_SAFETY_TIMEOUT_MS) {\n this.logDebug('Cache timeout reached, recalculating grid');\n return true;\n }\n\n return false;\n }\n\n /**\n * Centralizes cleanup of drag operations\n * Removes visual elements, event listeners, and resets state\n * Called when drag ends normally or when errors occur\n *\n * @method cleanupDrag\n * @returns {void}\n */\n cleanupDrag() {\n try {\n // Clear safety timeout\n if (this.dragSafetyTimeout) {\n clearTimeout(this.dragSafetyTimeout);\n this.dragSafetyTimeout = null;\n }\n\n // Clear click timeout\n if (this.clickTimeout) {\n this.logDebug('Clearing click timeout in cleanupDrag');\n clearTimeout(this.clickTimeout);\n this.clickTimeout = null;\n }\n\n // Reset dragging states\n this.isDragging = false;\n this.isDragInitiated = false;\n\n // Release event coordinator resources\n if (eventCoordinator.isDragging()) {\n eventCoordinator.endDrag();\n }\n eventCoordinator.releaseEventHandling('drag');\n\n // Reset body class\n try {\n document.body.classList.remove('dragging-product');\n } catch (classError) {\n this.logDebug(`Error removing body class: ${classError.message}`);\n }\n\n // Remove cross-category highlight\n try {\n const dragTargetHighlight = document.querySelector('.drag-target-active');\n if (dragTargetHighlight) dragTargetHighlight.classList.remove('drag-target-active');\n } catch (highlightError) {\n this.logDebug(`Error removing highlight: ${highlightError.message}`);\n }\n\n // Remove visual elements\n try {\n if (this.dragClone && this.dragClone.parentNode) {\n // Remove invalid-drop class before removing from DOM\n this.dragClone.classList.remove('invalid-drop');\n this.dragClone.parentNode.removeChild(this.dragClone);\n }\n } catch (cloneError) {\n this.logDebug(`Error removing clone: ${cloneError.message}`);\n }\n\n try {\n if (this.landingArea && this.landingArea.parentNode) {\n this.landingArea.parentNode.removeChild(this.landingArea);\n }\n } catch (landingError) {\n this.logDebug(`Error removing landing area: ${landingError.message}`);\n }\n\n try {\n if (this.spacer && this.spacer.parentNode) {\n this.spacer.parentNode.removeChild(this.spacer);\n }\n } catch (spacerError) {\n this.logDebug(`Error removing spacer: ${spacerError.message}`);\n }\n\n // Make sure the original element is visible and remove dragging class\n try {\n if (this.draggedElement) {\n // Reset transform and margin for sidebar products (grid products use clone, not transform)\n if (this.isDraggedElementFromSidebar) {\n this.draggedElement.style.transform = '';\n this.draggedElement.style.marginBottom = ''; // Reset margin that collapsed space\n } else {\n // Grid: restore display (was hidden)\n this.draggedElement.style.display = '';\n }\n // Remove dragging class for CSS transitions\n this.draggedElement.classList.remove('dragging-sidebar-item');\n }\n } catch (elementError) {\n this.logDebug(`Error restoring dragged element: ${elementError.message}`);\n }\n\n // Reset transforms on ALL sidebar products (sidebar shuffle cleanup)\n try {\n if (this.sidebarProducts) {\n this.sidebarProducts.forEach(item => {\n item.style.transform = '';\n });\n }\n } catch (transformError) {\n this.logDebug(`Error resetting sidebar transforms: ${transformError.message}`);\n }\n\n // Reset transforms on ALL grid products (grid shuffle cleanup)\n try {\n if (this.targetContainer && !this.isDraggedElementFromSidebar) {\n const gridProducts = this.targetContainer.querySelectorAll(this.markupClasses);\n gridProducts.forEach(item => {\n item.style.transform = '';\n item.classList.remove('will-shift');\n });\n }\n // Also reset source container if different\n if (this.sourceContainer && this.sourceContainer !== this.targetContainer && !this.isDraggedElementFromSidebar) {\n const sourceProducts = this.sourceContainer.querySelectorAll(this.markupClasses);\n sourceProducts.forEach(item => {\n item.style.transform = '';\n item.classList.remove('will-shift');\n });\n }\n } catch (gridTransformError) {\n this.logDebug(`Error resetting grid transforms: ${gridTransformError.message}`);\n }\n\n // Clear sidebar-specific state\n this.sidebarProducts = null;\n this.draggedIndex = null;\n this.currentLogicalIndex = null;\n this.itemHeight = null;\n\n // Remove event listeners\n try {\n document.removeEventListener('mousemove', this.onDragMove);\n document.removeEventListener('mouseup', this.onDragEnd);\n\n // Remove passive detection listeners if they exist\n if (this.dragIntentListener) {\n document.removeEventListener('mousemove', this.dragIntentListener);\n this.dragIntentListener = null;\n }\n if (this.passiveMouseUpHandler) {\n document.removeEventListener('mouseup', this.passiveMouseUpHandler);\n this.passiveMouseUpHandler = null;\n }\n\n // Also remove temporary drag detection listeners if they exist\n // These are created in dragStart for elements with navigation controllers\n if (this.detectDrag) {\n document.removeEventListener('mousemove', this.detectDrag);\n this.detectDrag = null;\n }\n if (this.cancelDetection) {\n document.removeEventListener('mouseup', this.cancelDetection);\n this.cancelDetection = null;\n }\n } catch (listenerError) {\n this.logDebug(`Error removing event listeners: ${listenerError.message}`);\n }\n\n // Reset references\n this.dragClone = null;\n this.landingArea = null;\n this.spacer = null;\n this.draggedElement = null;\n this.sourceContainer = null;\n this.targetContainer = null;\n this.targetElement = null;\n this.targetPosition = null;\n this.originalParent = null;\n this.originalNextSibling = null;\n this.isDraggedElementFromSidebar = false;\n\n // Reset potential drag state\n this.hasPotentialDrag = false;\n this.passiveDragDetection = false;\n this.potentialDragElement = null;\n this.potentialDragStartX = null;\n this.potentialDragStartY = null;\n\n // Reset click handling flag to default\n this.shouldHandleClick = true;\n\n // Reset pending navigation URL\n this.pendingNavigationUrl = null;\n\n // Reset grid cache to ensure fresh calculation on next drag\n this.cachedRowPositions = null;\n this.lastContainerWidth = null;\n this.lastContainerHeight = null;\n this.lastProductCount = 0;\n this.lastCacheTime = 0;\n\n // Reset spacer position tracking (live shuffle)\n this.spacerCurrentContainer = null;\n this.spacerCurrentNextSibling = null;\n } catch (error) {\n this.logDebug(`Error in cleanupDrag: ${error.message}`);\n // As a last resort, try to remove the event listeners\n try {\n document.removeEventListener('mousemove', this.onDragMove);\n document.removeEventListener('mouseup', this.onDragEnd);\n } catch {\n // Silently ignore event removal errors\n }\n }\n }\n\n /**\n * Handles the end of a potential drag operation or click\n * For actual drags: places element in final position and updates server\n * For clicks: navigates to product page if movement was below threshold\n *\n * @method dragEnd\n * @param {MouseEvent} event - The mouseup event\n * @returns {void}\n */\n dragEnd(event) {\n try {\n this.logDebug('dragEnd called, isDragging: ' + this.isDragging + ', isDragInitiated: ' + this.isDragInitiated + ', passiveDragDetection: ' + this.passiveDragDetection);\n\n // If we're in passive detection mode, this shouldn't be called\n if (this.passiveDragDetection) {\n this.logDebug('dragEnd called while in passive detection mode - this should not happen');\n return;\n }\n\n // If we never actually started dragging (just a click), handle click-based navigation\n if (!this.isDragging || !this.isDragInitiated) {\n this.logDebug('No drag was initiated - checking for click-based navigation');\n\n // For elements with navigation actions, we need to allow the click event to propagate\n if (!this.shouldHandleClick && this.draggedElement) {\n this.logDebug('Element has navigation action, allowing click to propagate');\n // Just clean up our drag state without preventing the click\n eventCoordinator.releaseEventHandling('drag');\n eventCoordinator.endDrag();\n // Remove event listeners\n document.removeEventListener('mousemove', this.onDragMove);\n document.removeEventListener('mouseup', this.onDragEnd);\n // Clear the safety timeout\n if (this.dragSafetyTimeout) {\n clearTimeout(this.dragSafetyTimeout);\n this.dragSafetyTimeout = null;\n }\n // Reset state\n this.isDragging = false;\n this.isDragInitiated = false;\n this.draggedElement = null;\n this.pendingNavigationUrl = null;\n this.shouldHandleClick = true; // Reset to default\n return; // Don't call full cleanupDrag which might interfere\n }\n\n // Handle click-based navigation for non-navigation elements\n // Only navigate if movement was below threshold (not a drag intent)\n if (this.shouldHandleClick && this.pendingNavigationUrl && event) {\n const moveDistanceX = Math.abs(event.clientX - this.initialClickX);\n const moveDistanceY = Math.abs(event.clientY - this.initialClickY);\n\n this.logDebug(`Click movement: X=${moveDistanceX}px, Y=${moveDistanceY}px, threshold=${DRAG_THRESHOLD_PX}px`);\n\n // Only navigate if movement was minimal (below drag threshold)\n if (moveDistanceX <= DRAG_THRESHOLD_PX && moveDistanceY <= DRAG_THRESHOLD_PX) {\n // Check with event coordinator before proceeding with navigation\n if (!eventCoordinator.isNavigating()) {\n this.logDebug(`Navigating on mouseup to: ${this.pendingNavigationUrl}`);\n\n // Mark sidebar product as active if applicable\n if (this.draggedElement && this.draggedElement.classList.contains('sidebar-product-list-item')) {\n this.draggedElement.classList.add('active');\n if (this.draggedElement.parentNode) {\n const siblings = this.draggedElement.parentNode.querySelectorAll('.sidebar-product-list-item');\n siblings.forEach(sibling => {\n if (sibling !== this.draggedElement) {\n sibling.classList.remove('active');\n }\n });\n }\n }\n\n // Store URL before cleanup\n const navUrl = this.pendingNavigationUrl;\n this.cleanupDrag();\n window.location.href = navUrl;\n return;\n } else {\n this.logDebug('Navigation already in progress by another controller, skipping');\n }\n } else {\n this.logDebug('Movement exceeded threshold - not treating as click');\n }\n }\n\n this.cleanupDrag();\n return;\n }\n\n if (event) {\n try {\n event.preventDefault();\n } catch (preventError) {\n this.logDebug(`Could not prevent default event: ${preventError.message}`);\n }\n }\n\n this.logDebug(`Drag ended. Target position: ${this.targetPosition || 'none'}`);\n\n // SIDEBAR: Handle transform-based shuffle drop (no clone, uses logical index)\n if (this.isDraggedElementFromSidebar) {\n this.logDebug(`Sidebar drag end: original=${this.draggedIndex}, target=${this.currentLogicalIndex}`);\n\n // Check for cross-category drop FIRST\n if (this.crossCategoryTarget && this.crossCategoryTargetId) {\n this.logDebug(`Cross-category drop: moving to category ${this.crossCategoryTargetId}`);\n\n const crossCategoryDraggedElement = this.draggedElement;\n const crossCategoryTargetContainer = this.crossCategoryTarget;\n\n // Move to new category FIRST (before cleanup resets state/transforms)\n crossCategoryTargetContainer.appendChild(crossCategoryDraggedElement);\n\n // Get position in new category (last position)\n const crossCategoryProductsInTarget = crossCategoryTargetContainer.querySelectorAll('.sidebar-product-list-item');\n const crossCategoryNewPosition = crossCategoryProductsInTarget.length - 1;\n\n // Clean up after DOM move\n this.cleanupDrag();\n\n // Notify server of category change\n this.updateServerPosition(crossCategoryNewPosition, crossCategoryDraggedElement, crossCategoryTargetContainer);\n return;\n }\n\n // Save references before cleanup (within-category reorder)\n const draggedElement = this.draggedElement;\n const sidebarProducts = this.sidebarProducts;\n const draggedIndex = this.draggedIndex;\n const targetIndex = this.currentLogicalIndex;\n\n // Clean up (resets transforms on all items)\n this.cleanupDrag();\n\n // If position changed, reorder DOM\n if (draggedElement && sidebarProducts && targetIndex !== null && targetIndex !== draggedIndex) {\n try {\n const parent = draggedElement.parentNode;\n // Get the item at the target position (from original order)\n const referenceItem = sidebarProducts[targetIndex];\n\n if (parent && referenceItem) {\n if (targetIndex > draggedIndex) {\n // Moving down: insert after the reference item\n parent.insertBefore(draggedElement, referenceItem.nextSibling);\n } else {\n // Moving up: insert before the reference item\n parent.insertBefore(draggedElement, referenceItem);\n }\n this.logDebug(`Sidebar DOM reordered: moved from ${draggedIndex} to ${targetIndex}`);\n\n // Notify server of position change\n this.updateServerPosition(targetIndex, draggedElement, parent);\n }\n } catch (reorderError) {\n this.logDebug(`Error reordering sidebar DOM: ${reorderError.message}`);\n // On error, element is already in DOM (just at original position)\n }\n } else {\n this.logDebug('Sidebar position unchanged - no DOM reorder needed');\n }\n return;\n }\n\n // GRID: Check if drag was properly initialized (grid requires clone)\n if (!this.dragClone) {\n this.logDebug('Drag not properly initialized - no dragClone. Rolling back to original position.');\n this.cleanupDrag();\n return;\n }\n\n // Save references before cleanup\n const draggedElement = this.draggedElement;\n const targetContainer = this.targetContainer;\n const targetPosition = this.targetPosition;\n const targetElement = this.targetElement;\n const originalParent = this.originalParent;\n const originalNextSibling = this.originalNextSibling;\n const isSidebarDrag = this.isDraggedElementFromSidebar;\n const spacerElement = this.spacer;\n\n // For sidebar shuffle, save spacer position before cleanup removes it from DOM\n const spacerParent = spacerElement ? spacerElement.parentNode : null;\n const spacerNextSibling = spacerElement ? spacerElement.nextSibling : null;\n\n // Clean up the drag operation (this removes spacer from DOM)\n this.cleanupDrag();\n\n try {\n // Handle placement using saved references\n if (!targetContainer || !targetPosition) {\n this.logDebug('No valid target - returning to original position');\n if (originalParent) {\n if (originalNextSibling) {\n originalParent.insertBefore(draggedElement, originalNextSibling);\n } else {\n originalParent.appendChild(draggedElement);\n }\n }\n return;\n }\n\n // Place the element\n this.logDebug(`Placing element in ${targetContainer.id || 'container'} at position ${targetPosition}`);\n\n // For sidebar with live shuffle, use the saved spacer position\n // (spacer was removed from DOM by cleanupDrag, but we saved its position)\n if (isSidebarDrag && spacerParent) {\n if (spacerNextSibling) {\n spacerParent.insertBefore(draggedElement, spacerNextSibling);\n } else {\n spacerParent.appendChild(draggedElement);\n }\n this.logDebug('Placed element at spacer position (sidebar shuffle)');\n } else {\n // GRID: Record positions BEFORE DOM change (FLIP - First)\n const gridProductsForFlip = targetContainer.querySelectorAll(this.markupClasses);\n const firstPositions = new Map();\n gridProductsForFlip.forEach(product => {\n if (product !== draggedElement) {\n firstPositions.set(product, product.getBoundingClientRect());\n }\n });\n\n // Perform grid DOM insertion\n if (targetPosition === 'before' && targetElement) {\n targetContainer.insertBefore(draggedElement, targetElement);\n this.logDebug(`Inserted before ${targetElement.dataset ? (targetElement.dataset.productId || 'unknown') : 'unknown'}`);\n } else if (targetPosition === 'after' && targetElement) {\n targetContainer.insertBefore(draggedElement, targetElement.nextSibling);\n this.logDebug(`Inserted after ${targetElement.dataset ? (targetElement.dataset.productId || 'unknown') : 'unknown'}`);\n } else if (targetPosition === 'empty') {\n targetContainer.appendChild(draggedElement);\n this.logDebug('Inserted into empty container');\n } else {\n // Fallback - append to target container\n targetContainer.appendChild(draggedElement);\n this.logDebug('Inserted at end of container (append)');\n }\n\n // Make dragged element visible again\n draggedElement.style.display = '';\n\n // FLIP - Last, Invert, Play (animate transition)\n requestAnimationFrame(() => {\n // Add settle animation to the dropped element\n draggedElement.classList.add('drop-settling');\n\n // Remove animation class after it completes\n setTimeout(() => {\n draggedElement.classList.remove('drop-settling');\n }, 250);\n\n // Animate other items from their old position to new position\n gridProductsForFlip.forEach(product => {\n if (product !== draggedElement && firstPositions.has(product)) {\n const first = firstPositions.get(product);\n const last = product.getBoundingClientRect();\n\n // Calculate the delta (Invert)\n const deltaX = first.left - last.left;\n const deltaY = first.top - last.top;\n\n if (Math.abs(deltaX) > 1 || Math.abs(deltaY) > 1) {\n // Apply inverse transform to keep element at old position\n product.style.transform = `translate(${deltaX}px, ${deltaY}px)`;\n product.style.transition = 'none';\n\n // Force reflow to apply the transform\n product.offsetHeight;\n\n // Play - animate to final position\n product.style.transition = 'transform 0.2s ease';\n product.style.transform = '';\n\n // Clean up transition after animation\n setTimeout(() => {\n product.style.transition = '';\n }, 200);\n }\n }\n });\n });\n }\n\n // Get final position for server update\n try {\n const products = Array.from(targetContainer.querySelectorAll(this.markupClasses));\n const finalPosition = products.indexOf(draggedElement);\n\n this.logDebug(`Final position index: ${finalPosition}`);\n\n // Call server to update\n if (finalPosition !== -1) {\n this.updateServerPosition(finalPosition, draggedElement, targetContainer);\n } else {\n this.logDebug('Error: Could not determine final position');\n }\n\n // Update placeholders - show if container is empty, hide if it has products\n const targetHasProducts = targetContainer.querySelectorAll(this.markupClasses).length > 0;\n this.updatePlaceholder(targetContainer, targetHasProducts);\n\n // Force immediate visibility update for target container (it now has products)\n if (targetHasProducts) {\n this.forceHidePlaceholder(targetContainer);\n }\n\n if (this.sourceContainer !== targetContainer) {\n const sourceHasProducts = this.sourceContainer.querySelectorAll(this.markupClasses).length > 0;\n this.updatePlaceholder(this.sourceContainer, sourceHasProducts);\n\n // Force immediate visibility update for source container (may be empty now)\n if (!sourceHasProducts) {\n this.forceShowPlaceholder(this.sourceContainer);\n }\n }\n } catch (updateError) {\n this.logDebug(`Error during position update: ${updateError.message}`);\n }\n } catch (error) {\n this.logDebug(`Error during drop: ${error.message}`);\n try {\n if (draggedElement && originalParent) {\n originalParent.appendChild(draggedElement);\n }\n } catch (e) {\n this.logDebug(`Error restoring element position: ${e.message}`);\n }\n }\n } catch (outerError) {\n this.logDebug(`Error in dragEnd: ${outerError.message}`);\n this.cleanupDrag();\n }\n }\n\n /**\n * Updates the server with the new position of the dragged product\n * Makes an AJAX request to persist the changes\n *\n * @method updateServerPosition\n * @param {number} finalPosition - The index position of the product in its container\n * @param {Element} draggedElement - The product element that was dragged\n * @param {Element} targetContainer - The container where the product was dropped\n * @returns {void}\n */\n updateServerPosition(finalPosition, draggedElement, targetContainer) {\n try {\n // Use provided elements or fall back to instance properties\n const validDraggedElement = draggedElement || this.draggedElement;\n const validTargetContainer = targetContainer || this.targetContainer;\n\n if (!validDraggedElement || !validTargetContainer) {\n this.logDebug('Missing required elements for server update');\n return;\n }\n\n if (!validDraggedElement.dataset) {\n this.logDebug('No dataset on draggedElement');\n return;\n }\n\n // Store references to necessary data before AJAX call\n const toCategoryId = validTargetContainer.dataset ?\n (validTargetContainer.dataset.categoryId || '') : '';\n const saleId = validDraggedElement.dataset.saleId || '';\n const productId = validDraggedElement.dataset.productId || '';\n const fromCategoryId = validDraggedElement.dataset.categoryId || '';\n\n // Store a reference to the element for the callback\n const elementRef = validDraggedElement;\n\n if (!saleId || !productId) {\n this.logDebug('Missing required data attributes for server update');\n return;\n }\n\n // Check if we're changing categories\n const isChangingCategory = fromCategoryId !== toCategoryId;\n\n this.logDebug(`Updating server: category ${fromCategoryId} -> ${toCategoryId}, position: ${finalPosition}`);\n\n const url = `/teamsites/${saleId}/products/${productId}/move/${finalPosition}?from=${fromCategoryId}&to=${toCategoryId}`;\n\n // Use fetch API with proper CSRF token\n fetch(url, {\n method: 'PATCH',\n headers: {\n 'Content-Type': 'application/json',\n 'X-Requested-With': 'XMLHttpRequest',\n 'X-CSRF-Token': this.getCSRFToken(),\n },\n })\n .then(response => {\n if (!response.ok) {\n // Handle non-200 responses\n this.showError('Failed to move product');\n this.logDebug(`Server error: Status code: ${response.status}`);\n return response.text().then(text => {\n this.logDebug(`Server response: ${text.substring(0, 200)}`);\n throw new Error(`HTTP error ${response.status}`);\n });\n }\n return response.json().catch(_e => {\n // Handle case where response is not JSON\n return {}; // Empty object if not JSON\n });\n })\n .then(_data => {\n // Update element on success\n if (elementRef && elementRef.dataset) {\n elementRef.dataset.categoryId = toCategoryId;\n }\n\n // Handle visibility of empty placeholders after server confirms the move\n if (isChangingCategory) {\n try {\n // Get the source and target container elements by ID\n const sourceContainerId = `sidebar-products-list-${fromCategoryId}`;\n const targetContainerId = `sidebar-products-list-${toCategoryId}`;\n\n const sourceContainer = document.getElementById(sourceContainerId);\n const targetContainer = document.getElementById(targetContainerId);\n\n if (sourceContainer) {\n // Check if source category is now empty\n const sourceProducts = sourceContainer.querySelectorAll(this.markupClasses);\n this.updatePlaceholder(sourceContainer, sourceProducts.length > 0);\n\n // Force immediate visibility update for empty categories\n if (sourceProducts.length === 0) {\n this.logDebug('Source category is now empty, showing placeholder');\n const sourcePlaceholderId = `empty-product-placeholder-${fromCategoryId}`;\n const sourcePlaceholder = document.getElementById(sourcePlaceholderId);\n if (sourcePlaceholder) {\n sourcePlaceholder.style.display = 'block';\n sourcePlaceholder.style.visibility = 'visible';\n sourcePlaceholder.style.height = '';\n sourcePlaceholder.style.minHeight = '40px';\n\n // Also update the accordion content\n const accordionContent = sourceContainer.closest('.ae-accordion-content');\n if (accordionContent) {\n accordionContent.style.paddingTop = '10px';\n accordionContent.style.minHeight = '50px';\n }\n }\n }\n }\n\n if (targetContainer) {\n // Make sure target category placeholder is hidden\n const targetProducts = targetContainer.querySelectorAll(this.markupClasses);\n this.updatePlaceholder(targetContainer, targetProducts.length > 0);\n\n // Force immediate update for categories that now have products\n if (targetProducts.length > 0) {\n this.logDebug('Target category now has products, hiding placeholder');\n const targetPlaceholderId = `empty-product-placeholder-${toCategoryId}`;\n const targetPlaceholder = document.getElementById(targetPlaceholderId);\n if (targetPlaceholder) {\n targetPlaceholder.style.display = 'none';\n targetPlaceholder.style.visibility = 'hidden';\n targetPlaceholder.style.height = '0';\n\n // Also update the accordion content\n const accordionContent = targetContainer.closest('.ae-accordion-content');\n if (accordionContent) {\n accordionContent.style.padding = '0';\n accordionContent.style.minHeight = '0';\n }\n }\n }\n }\n } catch (placeholderError) {\n this.logDebug(`Error updating placeholders after move: ${placeholderError.message}`);\n }\n\n // Show success message only when moving between categories\n this.showSuccess('Product moved to new category');\n }\n\n this.logDebug('Server update successful');\n })\n .catch(error => {\n this.showError('Failed to move product');\n this.logDebug(`Error in fetch: ${error.message}`);\n });\n } catch (error) {\n this.logDebug(`Error updating server: ${error.message}`);\n }\n }\n\n /**\n * Retrieves the CSRF token for secure AJAX requests\n * Looks for the meta tag containing the token\n *\n * @method getCSRFToken\n * @returns {string} - The CSRF token, or empty string if not found\n */\n getCSRFToken() {\n const tokenElement = document.querySelector('meta[name=\"csrf-token\"]');\n return tokenElement ? tokenElement.getAttribute('content') : '';\n }\n\n /**\n * Safely displays error messages to the user\n * Uses a fallback mechanism if App.showErrorFlash is unavailable\n *\n * @method showError\n * @param {string} message - The error message to display\n * @returns {void}\n */\n showError(message) {\n try {\n // First try using App.showErrorFlash if available\n if (typeof App === 'object' && App !== null && typeof App.showErrorFlash === 'function') {\n App.showErrorFlash(message);\n return;\n }\n\n // Create a simple toast notification with vanilla JS\n const toast = document.createElement('div');\n toast.textContent = message;\n toast.style.position = 'fixed';\n toast.style.top = '20px';\n toast.style.right = '20px';\n toast.style.padding = '10px 20px';\n toast.style.background = '#f44336';\n toast.style.color = 'white';\n toast.style.borderRadius = '4px';\n toast.style.zIndex = '10000';\n toast.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';\n toast.style.fontFamily = 'sans-serif';\n toast.style.fontSize = '14px';\n\n document.body.appendChild(toast);\n\n // Add a simple fade-in animation\n toast.style.opacity = '0';\n toast.style.transition = 'opacity 0.3s ease';\n\n // Trigger the animation after a small delay\n setTimeout(() => {\n toast.style.opacity = '1';\n }, 10);\n\n // Remove after 5 seconds with fade-out\n setTimeout(() => {\n toast.style.opacity = '0';\n setTimeout(() => {\n if (toast.parentNode) {\n toast.parentNode.removeChild(toast);\n }\n }, 300); // Wait for the fade-out animation to complete\n }, PRELOAD_CLEANUP_INTERVAL_MS);\n } catch {\n // If all else fails, log to console\n // Error occurred;\n }\n }\n\n /**\n * Shows a success message to the user\n * Uses App.showSuccessFlash or falls back to a custom toast notification\n *\n * @method showSuccess\n * @param {string} message - The success message to display\n * @returns {void}\n */\n showSuccess(message) {\n try {\n // First try using App.showSuccessFlash if available\n if (typeof App === 'object' && App !== null && typeof App.showSuccessFlash === 'function') {\n App.showSuccessFlash(message);\n return;\n }\n\n // Create a simple toast notification with vanilla JS\n const toast = document.createElement('div');\n toast.textContent = message;\n toast.style.position = 'fixed';\n toast.style.top = '20px';\n toast.style.right = '20px';\n toast.style.padding = '10px 20px';\n toast.style.background = '#4CAF50'; // Green for success\n toast.style.color = 'white';\n toast.style.borderRadius = '4px';\n toast.style.zIndex = '10000';\n toast.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';\n toast.style.fontFamily = 'sans-serif';\n toast.style.fontSize = '14px';\n\n document.body.appendChild(toast);\n\n // Add a simple fade-in animation\n toast.style.opacity = '0';\n toast.style.transition = 'opacity 0.3s ease';\n\n // Trigger the animation after a small delay\n setTimeout(() => {\n toast.style.opacity = '1';\n }, 10);\n\n // Remove after 3 seconds with fade-out (shorter duration for success messages)\n setTimeout(() => {\n toast.style.opacity = '0';\n setTimeout(() => {\n if (toast.parentNode) {\n toast.parentNode.removeChild(toast);\n }\n }, 300); // Wait for the fade-out animation to complete\n }, 3000);\n } catch {\n // If all else fails, log to console\n // Product operation completed successfully\n }\n }\n\n /**\n * Helper method to specifically preserve background images in the clone\n * This addresses cases where product images are implemented as background images\n *\n * @method preserveBackgroundImages\n * @param {Element} sourceElement - The original element\n * @param {Element} cloneElement - The cloned element\n * @returns {void}\n */\n preserveBackgroundImages(sourceElement, cloneElement) {\n try {\n // First, identify all elements with background images in the source\n const sourceBgElements = Array.from(sourceElement.querySelectorAll('*')).filter(el => {\n try {\n // Check both inline style and computed style\n const inlineStyle = el.getAttribute('style') || '';\n if (inlineStyle.includes('background-image')) return true;\n\n const computedStyle = window.getComputedStyle(el).backgroundImage;\n return computedStyle && computedStyle !== 'none';\n } catch {\n return false;\n }\n });\n\n if (sourceBgElements.length === 0) {\n // Also check the source element itself\n const sourceStyle = sourceElement.getAttribute('style') || '';\n if (sourceStyle.includes('background-image')) {\n sourceBgElements.push(sourceElement);\n }\n }\n\n this.logDebug(`Found ${sourceBgElements.length} elements with background images in source`);\n\n // For each source element with a background image, find its equivalent in the clone\n sourceBgElements.forEach(sourceEl => {\n try {\n // First try to find the element by class\n const className = sourceEl.className;\n let path = '';\n\n // Build a CSS path to this element using classes and tags\n let currentEl = sourceEl;\n const pathElements = [];\n while (currentEl && currentEl !== sourceElement && pathElements.length < 10) {\n const elClass = currentEl.className.split(' ')[0];\n if (elClass) {\n pathElements.unshift(`.${elClass}`);\n } else {\n pathElements.unshift(currentEl.tagName.toLowerCase());\n }\n currentEl = currentEl.parentNode;\n }\n\n path = pathElements.join(' > ');\n\n // First try to find by class\n let targetEl = null;\n if (className) {\n const classSelector = `.${className.split(' ')[0]}`;\n targetEl = cloneElement.querySelector(classSelector);\n }\n\n // Then try by path\n if (!targetEl && path) {\n targetEl = cloneElement.querySelector(path);\n }\n\n // If we found a match, apply the background image\n if (targetEl) {\n const inlineStyle = sourceEl.getAttribute('style') || '';\n const computedStyle = window.getComputedStyle(sourceEl);\n\n // Try to get background image from inline style first\n const bgMatch = inlineStyle.match(/background-image:\\s*url\\(['\"]?([^'\"]+)['\"]?\\)/);\n\n if (bgMatch && bgMatch[1]) {\n targetEl.style.backgroundImage = `url('${bgMatch[1]}')`;\n this.logDebug(`Applied background image from inline style: ${bgMatch[1].substring(0, 50)}...`);\n } else if (computedStyle.backgroundImage && computedStyle.backgroundImage !== 'none') {\n // Fall back to computed style\n targetEl.style.backgroundImage = computedStyle.backgroundImage;\n this.logDebug(`Applied background image from computed style: ${computedStyle.backgroundImage.substring(0, 50)}...`);\n }\n\n // Apply background image properties\n targetEl.style.backgroundSize = 'contain';\n targetEl.style.backgroundPosition = 'center center';\n targetEl.style.backgroundRepeat = 'no-repeat';\n targetEl.style.minHeight = '180px'; // Increased from 100px\n targetEl.style.maxHeight = 'none';\n targetEl.style.flexGrow = '1';\n\n // Ensure the element is visible\n targetEl.style.display = 'block';\n targetEl.style.visibility = 'visible';\n } else {\n this.logDebug('Could not find matching element for background image in clone');\n }\n } catch (elementError) {\n this.logDebug(`Error processing background image element: ${elementError.message}`);\n }\n });\n\n // Special case for .product-photo-current which is known to contain the main product image\n try {\n const sourceProductPhoto = sourceElement.querySelector('.product-photo-current');\n const cloneProductPhoto = cloneElement.querySelector('.product-photo-current');\n\n if (sourceProductPhoto && cloneProductPhoto) {\n // Direct clone of styles\n const sourceStyle = sourceProductPhoto.getAttribute('style') || '';\n cloneProductPhoto.setAttribute('style', sourceStyle);\n\n // Additional style properties to ensure visibility\n cloneProductPhoto.style.display = 'block';\n cloneProductPhoto.style.visibility = 'visible';\n cloneProductPhoto.style.backgroundSize = 'contain';\n cloneProductPhoto.style.backgroundPosition = 'center center';\n cloneProductPhoto.style.backgroundRepeat = 'no-repeat';\n cloneProductPhoto.style.minHeight = '180px'; // Increased height\n cloneProductPhoto.style.maxHeight = 'none';\n cloneProductPhoto.style.flexGrow = '1';\n\n this.logDebug('Applied direct style copy to .product-photo-current');\n }\n } catch (photoError) {\n this.logDebug(`Error handling product-photo-current: ${photoError.message}`);\n }\n } catch (error) {\n this.logDebug(`Error in preserveBackgroundImages: ${error.message}`);\n }\n }\n\n /**\n * Updates the visibility of placeholder elements in containers\n * Controls the display of empty state indicators\n *\n * @method updatePlaceholder\n * @param {Element} container - The container element\n * @param {boolean} isHidden - Whether the placeholder should be hidden\n * @returns {void}\n */\n /**\n * Forcibly hides the placeholder for a container regardless of cache\n * Used when we know a container has products but need immediate visual update\n *\n * @method forceHidePlaceholder\n * @param {Element} container - The container element\n * @returns {void}\n */\n forceHidePlaceholder(container) {\n try {\n if (!container) return;\n\n const categoryId = (container.dataset.categoryId === undefined) ? 'unavailable' : container.dataset.categoryId;\n const placeholderId = `empty-product-placeholder-${categoryId}`;\n const placeholderEl = document.getElementById(placeholderId);\n\n if (placeholderEl) {\n // Apply aggressive hiding\n this.logDebug(`Forcing hide placeholder for category ${categoryId}`);\n placeholderEl.style.height = '0 !important';\n placeholderEl.style.padding = '0 !important';\n placeholderEl.style.margin = '0 !important';\n placeholderEl.style.minHeight = '0 !important';\n placeholderEl.style.overflow = 'hidden !important';\n placeholderEl.style.visibility = 'hidden !important';\n placeholderEl.style.display = 'none !important';\n\n // Apply !important styles using setAttribute for greater specificity\n placeholderEl.setAttribute('style',\n 'height: 0 !important; padding: 0 !important; margin: 0 !important; ' +\n 'overflow: hidden !important; visibility: hidden !important; display: none !important;');\n\n // Also hide child elements\n const messageDiv = placeholderEl.querySelector('.ae-empty-message');\n if (messageDiv) {\n messageDiv.style.display = 'none';\n messageDiv.style.visibility = 'hidden';\n messageDiv.setAttribute('style', 'display: none !important; visibility: hidden !important;');\n }\n\n // Update accordion content styles\n const accordionContent = container.closest('.ae-accordion-content');\n if (accordionContent) {\n accordionContent.style.padding = '0';\n accordionContent.style.minHeight = '0';\n }\n\n // Store has-products state\n container.dataset.hasProducts = 'true';\n }\n } catch (error) {\n this.logDebug(`Error in forceHidePlaceholder: ${error.message}`);\n }\n }\n\n /**\n * Forcibly shows the placeholder for a container regardless of cache\n * Used when we know a container is empty but need immediate visual update\n *\n * @method forceShowPlaceholder\n * @param {Element} container - The container element\n * @returns {void}\n */\n forceShowPlaceholder(container) {\n try {\n if (!container) return;\n\n const categoryId = (container.dataset.categoryId === undefined) ? 'unavailable' : container.dataset.categoryId;\n const placeholderId = `empty-product-placeholder-${categoryId}`;\n const placeholderEl = document.getElementById(placeholderId);\n\n if (placeholderEl) {\n // Apply aggressive showing\n this.logDebug(`Forcing show placeholder for category ${categoryId}`);\n placeholderEl.style.height = '';\n placeholderEl.style.padding = '';\n placeholderEl.style.margin = '';\n placeholderEl.style.overflow = '';\n placeholderEl.style.minHeight = '40px';\n placeholderEl.style.visibility = 'visible';\n placeholderEl.style.display = 'block';\n\n // Apply !important styles using setAttribute for greater specificity\n placeholderEl.setAttribute('style',\n 'height: auto !important; min-height: 40px !important; padding: inherit !important; ' +\n 'margin: inherit !important; visibility: visible !important; display: block !important;');\n\n // Also show child elements\n const messageDiv = placeholderEl.querySelector('.ae-empty-message');\n if (messageDiv) {\n messageDiv.style.display = 'block';\n messageDiv.style.visibility = 'visible';\n messageDiv.setAttribute('style', 'display: block !important; visibility: visible !important;');\n }\n\n // Update accordion content styles\n const accordionContent = container.closest('.ae-accordion-content');\n if (accordionContent) {\n accordionContent.style.paddingTop = '10px';\n accordionContent.style.minHeight = '50px';\n }\n\n // Store has-products state\n container.dataset.hasProducts = 'false';\n }\n } catch (error) {\n this.logDebug(`Error in forceShowPlaceholder: ${error.message}`);\n }\n }\n\n /**\n * Updates the visibility of placeholder elements based on whether products exist\n * Regular method called during normal drag operations, less aggressive\n *\n * @method updatePlaceholder\n * @param {Element} container - The container element\n * @param {boolean} isHidden - Whether the placeholder should be hidden\n * @returns {void}\n */\n updatePlaceholder(container, isHidden) {\n // Guard against errors during drag cleanup\n if (!container) {\n this.logDebug('Cannot update placeholder: missing container');\n return;\n }\n\n try {\n const categoryId = (container.dataset.categoryId === undefined) ? 'unavailable' : container.dataset.categoryId;\n const placeholderId = `empty-product-placeholder-${categoryId}`;\n\n // Use native DOM methods\n const placeholderEl = document.getElementById(placeholderId);\n if (placeholderEl) {\n // Update container styles based on product presence\n const accordionContent = container.closest('.ae-accordion-content');\n if (accordionContent) {\n if (isHidden) {\n // Category has products - remove space\n accordionContent.style.padding = '0';\n accordionContent.style.minHeight = '0';\n } else {\n // Empty category - add space for message\n accordionContent.style.paddingTop = '10px';\n accordionContent.style.minHeight = '50px';\n }\n }\n\n if (isHidden) {\n // Products exist - hide the placeholder to eliminate any gap\n placeholderEl.style.height = '0';\n placeholderEl.style.padding = '0';\n placeholderEl.style.margin = '0';\n placeholderEl.style.overflow = 'hidden';\n placeholderEl.style.minHeight = '0';\n placeholderEl.style.visibility = 'hidden';\n placeholderEl.style.display = 'none';\n\n // Also hide child elements to prevent any rendering\n const messageDiv = placeholderEl.querySelector('.ae-empty-message');\n if (messageDiv) {\n messageDiv.style.display = 'none';\n messageDiv.style.visibility = 'hidden';\n }\n } else {\n // No products - show the empty placeholder message\n placeholderEl.style.height = '';\n placeholderEl.style.padding = '';\n placeholderEl.style.margin = '';\n placeholderEl.style.overflow = '';\n placeholderEl.style.minHeight = '40px';\n placeholderEl.style.visibility = 'visible';\n placeholderEl.style.display = 'block';\n\n // Show child elements as well\n const messageDiv = placeholderEl.querySelector('.ae-empty-message');\n if (messageDiv) {\n messageDiv.style.display = 'block';\n messageDiv.style.visibility = 'visible';\n }\n }\n\n // Set data attribute on container to track whether it has products\n container.dataset.hasProducts = isHidden.toString();\n }\n } catch (error) {\n this.logDebug(`Error updating placeholder: ${error.message}`);\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\n/**\n * Dropdown Controller\n *\n * This controller manages dropdown menus with click functionality.\n * It handles showing/hiding dropdown content and includes click-away behavior.\n *\n * Features:\n * - Toggle dropdown on click\n * - Close all other dropdowns when opening a new one\n * - Close on click outside the dropdown\n * - Special handling for thumbnail action dropdowns\n *\n * Usage:\n *
    \n * \n *
    \n * \n *
    \n *
    \n */\nexport default class extends Controller {\n static targets = ['content'];\n\n // Track the dropdown state\n isOpen = false;\n\n\n connect() {\n // Force set initial state\n if (this.hasContentTarget && this.contentTarget) {\n this.contentTarget.style.display = 'none';\n }\n this.isOpen = false;\n\n // Bind event handlers\n this.documentClickHandler = this.handleDocumentClick.bind(this);\n this.boundCloseAllHandler = this.handleCloseAll.bind(this);\n\n // Add document click handler (for click-away behavior)\n document.addEventListener('click', this.documentClickHandler);\n\n // Add listener for close-all events\n document.addEventListener('dropdown:close-all', this.boundCloseAllHandler);\n }\n\n disconnect() {\n // Remove document event listener\n document.removeEventListener('click', this.documentClickHandler);\n\n // Remove close-all listener\n if (this.boundCloseAllHandler) {\n document.removeEventListener('dropdown:close-all', this.boundCloseAllHandler);\n }\n }\n\n // Toggle dropdown on click\n click(event) {\n // Prevent default to avoid navigation if inside a link\n event.preventDefault();\n event.stopPropagation();\n\n if (this.isOpen) {\n this.closeDropdown();\n } else {\n // Dispatch event to close all other dropdowns\n const closeAllEvent = new CustomEvent('dropdown:close-all', {\n bubbles: true,\n detail: { originator: this.element },\n });\n document.dispatchEvent(closeAllEvent);\n\n this.openDropdown();\n }\n }\n\n // Open the dropdown\n openDropdown() {\n // Skip if already open or no content target\n if (this.isOpen || !this.hasContentTarget || !this.contentTarget) return;\n\n // Show dropdown\n this.contentTarget.style.display = 'block';\n this.isOpen = true;\n\n // Apply z-index for thumbnail actions if needed\n if (this.contentTarget.classList.contains('thumbnail-actions')) {\n const listItem = this.contentTarget.closest('li');\n if (listItem) listItem.classList.add('ae-z-index-2');\n }\n }\n\n // Close the dropdown\n closeDropdown() {\n // Skip if already closed or no content target\n if (!this.isOpen || !this.hasContentTarget || !this.contentTarget) return;\n\n // Hide dropdown\n this.contentTarget.style.display = 'none';\n this.isOpen = false;\n\n // Handle z-index for thumbnail actions if needed\n if (this.contentTarget.classList.contains('thumbnail-actions')) {\n const listItem = this.contentTarget.closest('li');\n if (listItem) listItem.classList.remove('ae-z-index-2');\n }\n }\n\n // Handle close-all event from other dropdowns\n handleCloseAll(event) {\n // Close this dropdown if it's not the originator and is currently open\n if (event.detail.originator !== this.element && this.isOpen) {\n this.closeDropdown();\n }\n }\n\n // Handle clicks outside the dropdown\n handleDocumentClick(event) {\n // If dropdown is open and the click is outside, close it\n if (this.isOpen && !this.element.contains(event.target)) {\n this.closeDropdown();\n }\n\n // Don't stop propagation for clicks inside dropdown content -\n // this allows other controllers (like rate selector) to handle their events\n }\n}\n", "import { Controller } from '../stimulus';\n// Import StimulusReinitMixin - build will fail if file doesn't exist\n// But we'll handle runtime errors gracefully\nimport StimulusReinitMixin from '../mixins/stimulus_reinit_mixin';\n\n// Provide fallback implementation that matches the actual mixin behavior\nconst fallbackReinitMixin = {\n /**\n * Reinitialize Stimulus controllers for dynamically added content\n * This is a fallback that replicates the mixin's behavior\n * @param {Element} container - The container element with new content\n */\n reinitializeStimulusControllers(container) {\n if (!window.Stimulus || !window.Stimulus.application) {\n // Use this.logDebug if available (when bound to controller instance)\n if (this.logDebug) {\n this.logDebug('[Dropzone Fallback] Stimulus application not found');\n }\n return;\n }\n\n // Match the actual mixin implementation:\n // For Stimulus 1.1.0, trigger the DOM mutation observer\n // by adding and removing a dummy element with a data-controller attribute\n const dummyDiv = document.createElement('div');\n dummyDiv.style.display = 'none';\n dummyDiv.setAttribute('data-controller', 'dummy-reinit');\n\n try {\n container.appendChild(dummyDiv);\n\n // Remove on next frame to trigger mutation observer\n requestAnimationFrame(() => {\n if (dummyDiv.parentNode) {\n dummyDiv.remove();\n }\n });\n } catch (error) {\n // Use this.logDebug if available (when bound to controller instance)\n if (this.logDebug) {\n this.logDebug('[Dropzone Fallback] Failed to reinitialize controllers:', error);\n }\n }\n },\n};\n\n/**\n * A pure JavaScript dropzone controller without external dependencies\n *\n * Usage (Stimulus 1.1.0 compatible):\n *
    \n * <%# Hidden fields are automatically included in uploads %>\n * <%# <%= hidden_field_tag :product_id, \"123\" %> %>\n * \n *
    \n *
    \n *
    \n */\nexport default class extends Controller {\n static targets = ['input', 'preview', 'progressIndicator'];\n\n initialize() {\n // Debug flag - set to true to enable debug logging\n this.debug = false; // Set via data-dropzone-debug=\"true\" on the element\n\n // Mix in the Stimulus reinit functionality with error handling\n try {\n // Check if StimulusReinitMixin is valid before using it\n if (StimulusReinitMixin && typeof StimulusReinitMixin === 'object' &&\n typeof StimulusReinitMixin.reinitializeStimulusControllers === 'function') {\n Object.assign(this, StimulusReinitMixin);\n // Mixin applied successfully\n } else {\n throw new Error('StimulusReinitMixin is not properly loaded or has incorrect format');\n }\n } catch (error) {\n // Use fallback if mixin application fails for any reason\n this.logDebug('[Dropzone] Using fallback reinitialize implementation:', error.message);\n this.reinitializeStimulusControllers = fallbackReinitMixin.reinitializeStimulusControllers.bind(this);\n }\n }\n\n // Helper methods to get data attributes with defaults (Stimulus 1.1.0 compatible)\n get urlValue() {\n return this.element.dataset.url || null;\n }\n\n get paramNameValue() {\n return this.element.dataset.paramName || 'file';\n }\n\n get maxFileSizeValue() {\n return parseInt(this.element.dataset.maxFileSize) || 5;\n }\n\n get acceptedTypesValue() {\n return this.element.dataset.acceptedTypes || '.jpg,.jpeg,.png,.gif';\n }\n\n get maxFilesValue() {\n return parseInt(this.element.dataset.maxFiles) || 10;\n }\n\n connect() {\n // Check for debug flag from data attribute\n this.debug = this.element.dataset.dropzoneDebug === 'true' ||\n this.element.dataset.debug === 'true';\n\n // Initialize the controller\n this.uploadQueue = [];\n this.isUploading = false;\n this.fileCounter = 0;\n this.successfulUploads = 0; // Track successful uploads separately\n this.warnings = true; // Enable warning messages for security issues\n\n // Overall progress tracking\n this.totalFiles = 0;\n this.completedFiles = 0;\n this.currentFileProgress = 0;\n this.currentUploadingFile = null;\n\n // Initialize tracking arrays for cleanup\n this.messageTimeouts = [];\n this.activeFileReaders = [];\n this.activeXhr = null;\n this.fileInputChangeHandler = null;\n\n // Minimum display time for progress bar (in milliseconds)\n // Ensures users can see progress even for fast uploads\n this.minimumUploadDisplayTime = 1500;\n\n // Upload result tracking system\n this.uploadResults = {\n successful: [], // Array of successfully uploaded files\n failed: [], // Array of failed files\n skipped: [], // Array of files skipped due to validation\n };\n\n this.failureReasons = new Map(); // Map\n\n this.errorCategories = {\n VALIDATION: 'validation',\n NETWORK: 'network',\n SERVER: 'server',\n SECURITY: 'security',\n QUOTA: 'quota',\n UNKNOWN: 'unknown',\n };\n\n // Cache the values directly from data attributes for reliability\n // This avoids potential issues with Stimulus value binding in different environments\n this.uploadUrl = this.element.getAttribute('data-url');\n this.paramName = this.element.getAttribute('data-param-name') || 'attachment';\n\n // Validate that we have a valid upload URL - critical for preventing routing errors\n if (!this.uploadUrl ||\n this.uploadUrl === 'undefined' ||\n this.uploadUrl.includes('undefined') ||\n this.uploadUrl.includes('null') ||\n typeof this.uploadUrl !== 'string' ||\n this.uploadUrl.trim() === '') {\n this.logDebug('[Dropzone] Warning: No valid upload URL found in data-url attribute');\n // Try to construct URL from current page if we're in a teamsites context\n const pathMatch = window.location.pathname.match(/\\/teamsites\\/(\\d+)/);\n if (pathMatch && pathMatch[1]) {\n this.uploadUrl = `/teamsites/${pathMatch[1]}/branding_slides`;\n this.logDebug('[Dropzone] Constructed fallback URL:', this.uploadUrl);\n } else {\n this.uploadUrl = null; // Ensure it's null rather than undefined\n }\n }\n\n // Set up the event listeners\n this.setupEventListeners();\n\n // Give the Stimulus values time to initialize properly\n // This ensures acceptedTypesValue is available when creating the file input\n setTimeout(() => {\n // Create a hidden file input for \"browse\" functionality\n this.setupFileInput();\n }, 0);\n\n // Set up navigation protection\n this.setupNavigationProtection();\n }\n\n logDebug(_message, ..._args) {\n if (this.debug) {\n // Debug logging disabled for production\n }\n }\n\n disconnect() {\n // Remove event listeners\n this.removeEventListeners();\n\n // Remove navigation protection\n this.removeNavigationProtection();\n\n // Cancel any in-progress uploads\n if (this.activeXhr) {\n try {\n this.activeXhr.abort();\n } catch {\n // Ignore errors during abort\n }\n this.activeXhr = null;\n }\n\n // Remove file input explicitly\n if (this.fileInput && this.fileInput.parentNode) {\n if (this.fileInputChangeHandler) {\n this.fileInput.removeEventListener('change', this.fileInputChangeHandler);\n }\n this.fileInput.parentNode.removeChild(this.fileInput);\n this.fileInput = null;\n }\n\n // Clear any active timeouts\n if (this.messageTimeouts && this.messageTimeouts.length) {\n this.messageTimeouts.forEach(timeoutId => {\n clearTimeout(timeoutId);\n });\n this.messageTimeouts = [];\n }\n\n // Clear any file readers\n if (this.activeFileReaders && this.activeFileReaders.length) {\n this.activeFileReaders.forEach(reader => {\n try {\n reader.abort();\n } catch {\n // Ignore errors during abort\n }\n });\n this.activeFileReaders = [];\n }\n\n // Clear upload queue to release file references\n this.uploadQueue = [];\n\n // Remove any dropzone messages that might be lingering\n const messages = this.element.querySelectorAll('.dropzone-global-message');\n if (messages.length) {\n messages.forEach(message => message.remove());\n }\n\n // Clear other references\n this.fileCounter = 0;\n this.isUploading = false;\n this.successfulUploads = 0;\n this.totalFiles = 0;\n this.completedFiles = 0;\n this.currentFileProgress = 0;\n\n // Hide progress indicator\n this.hideOverallProgress();\n }\n\n setupEventListeners() {\n this.dragEnterHandler = this.onDragEnter.bind(this);\n this.dragOverHandler = this.onDragOver.bind(this);\n this.dragLeaveHandler = this.onDragLeave.bind(this);\n this.dropHandler = this.onDrop.bind(this);\n this.clickHandler = this.onClick.bind(this);\n\n this.element.addEventListener('dragenter', this.dragEnterHandler);\n this.element.addEventListener('dragover', this.dragOverHandler);\n this.element.addEventListener('dragleave', this.dragLeaveHandler);\n this.element.addEventListener('drop', this.dropHandler);\n this.element.addEventListener('click', this.clickHandler);\n }\n\n removeEventListeners() {\n this.element.removeEventListener('dragenter', this.dragEnterHandler);\n this.element.removeEventListener('dragover', this.dragOverHandler);\n this.element.removeEventListener('dragleave', this.dragLeaveHandler);\n this.element.removeEventListener('drop', this.dropHandler);\n this.element.removeEventListener('click', this.clickHandler);\n }\n\n setupFileInput() {\n this.fileInput = document.createElement('input');\n this.fileInput.type = 'file';\n\n // Get max files from data attribute directly for reliability\n const maxFiles = parseInt(this.element.getAttribute('data-max-files')) || 10;\n this.fileInput.multiple = maxFiles > 1;\n\n // Fix for undefined acceptedTypesValue\n const acceptedTypes = this.acceptedTypesValue || '.jpg,.jpeg,.png,.gif';\n this.fileInput.accept = acceptedTypes;\n\n this.fileInput.style.display = 'none';\n this.fileInputChangeHandler = this.onFileInputChange.bind(this);\n this.fileInput.addEventListener('change', this.fileInputChangeHandler);\n this.element.appendChild(this.fileInput);\n }\n\n onDragEnter(event) {\n event.preventDefault();\n event.stopPropagation();\n\n // Add both classes to ensure compatibility with both CSS files\n this.element.classList.add('dropzone-active');\n this.element.classList.add('dz-drag-hover');\n\n // Force the style directly to ensure it works (JavaScript doesn't support !important)\n this.element.style.setProperty('background-color', '#d4edda', 'important');\n this.element.style.setProperty('border-color', '#28a745', 'important');\n this.element.style.setProperty('box-shadow', '0 0 10px rgba(40, 167, 69, 0.3)', 'important');\n\n // Add additional visual feedback classes\n this.element.classList.add('dropzone-hover-green');\n\n // Dispatch dragenter event that can be listened to\n this.dispatch('dragenter');\n }\n\n onDragOver(event) {\n event.preventDefault();\n event.stopPropagation();\n\n // Keep the dropzone active with both class names for CSS compatibility\n this.element.classList.add('dropzone-active');\n this.element.classList.add('dz-drag-hover');\n }\n\n onDragLeave(event) {\n event.preventDefault();\n event.stopPropagation();\n\n // Only remove the active class if we're leaving the dropzone itself\n // and not just moving between its children\n const rect = this.element.getBoundingClientRect();\n const dx = event.clientX - rect.left;\n const dy = event.clientY - rect.top;\n\n if (dx < 0 || dx >= rect.width || dy < 0 || dy >= rect.height) {\n // Remove all classes\n this.element.classList.remove('dropzone-active');\n this.element.classList.remove('dz-drag-hover');\n this.element.classList.remove('dropzone-hover-green');\n\n // Remove inline styles properly\n this.element.style.removeProperty('background-color');\n this.element.style.removeProperty('border-color');\n this.element.style.removeProperty('box-shadow');\n\n // Dispatch dragleave event\n this.dispatch('dragleave');\n }\n }\n\n onDrop(event) {\n event.preventDefault();\n event.stopPropagation();\n\n // Remove all active classes\n this.element.classList.remove('dropzone-active');\n this.element.classList.remove('dz-drag-hover');\n this.element.classList.remove('dropzone-hover-green');\n\n // Remove inline styles properly\n this.element.style.removeProperty('background-color');\n this.element.style.removeProperty('border-color');\n this.element.style.removeProperty('box-shadow');\n\n // Process the dropped files\n const files = event.dataTransfer.files;\n this.processFiles(files);\n\n // Dispatch drop event\n this.dispatch('drop');\n }\n\n onClick(event) {\n // Only trigger the file browser if clicking on the dropzone itself\n // (not a button or other interactive element within it)\n if (event.target === this.element ||\n (this.hasInputTarget && event.target === this.inputTarget)) {\n this.fileInput.click();\n }\n }\n\n onFileInputChange(event) {\n const files = event.target.files;\n this.processFiles(files);\n\n // Reset the input value so the same file can be selected again\n this.fileInput.value = '';\n }\n\n processFiles(files) {\n if (!files || files.length === 0) {\n return;\n }\n\n // Check if we have too many files\n if (this.uploadQueue.length + files.length > this.maxFilesValue) {\n this.showError(`Too many files. Maximum ${this.maxFilesValue} files allowed.`);\n return;\n }\n\n // Clear previous batch results if starting fresh\n if (this.uploadQueue.length === 0) {\n this.uploadResults = {\n successful: [],\n failed: [],\n skipped: [],\n };\n this.failureReasons.clear();\n\n // Clear any existing summary\n this.element.querySelector('.dropzone-batch-summary')?.remove();\n }\n\n // Track new files added in this batch\n let validFilesCount = 0;\n let skippedFilesCount = 0;\n\n // Add valid files to the upload queue\n for (let i = 0; i < files.length; i++) {\n const file = files[i];\n // Assign ID early for tracking\n file.id = `file-${this.fileCounter++}`;\n\n // Check file type\n if (!this.isValidFileType(file)) {\n this.uploadResults.skipped.push(file);\n this.failureReasons.set(file.id, {\n category: this.errorCategories.VALIDATION,\n message: `Invalid file type. Accepted types: ${this.acceptedTypesValue}`,\n retryable: false,\n });\n this.showError(`Invalid file type: ${file.name}. Accepted types: ${this.acceptedTypesValue}`);\n skippedFilesCount++;\n continue;\n }\n\n // Check file size\n if (!this.isValidFileSize(file)) {\n const maxSizeMB = this.maxFileSizeValue || 5; // Default to 5MB if undefined\n this.uploadResults.skipped.push(file);\n this.failureReasons.set(file.id, {\n category: this.errorCategories.VALIDATION,\n message: `File too large. Maximum size: ${maxSizeMB}MB`,\n retryable: false,\n });\n this.showError(`File too large: ${file.name}. Maximum size: ${maxSizeMB}MB`);\n skippedFilesCount++;\n continue;\n }\n\n // Add to queue\n this.uploadQueue.push(file);\n validFilesCount++;\n\n // Create preview if we have a preview target\n if (this.hasPreviewTarget) {\n this.createPreview(file);\n\n // Hide drag-and-drop prompt when first thumbnail appears (simplified UI)\n if (this.uploadQueue.length === 1 && this.hasInputTarget) {\n this.inputTarget.style.display = 'none';\n }\n }\n }\n\n // Only proceed if we have valid files\n if (validFilesCount === 0) {\n return;\n }\n\n // Set up overall progress tracking\n this.totalFiles = this.uploadQueue.length;\n this.completedFiles = 0;\n this.currentFileProgress = 0;\n\n // Simplified UI: No overall progress card - progress shown on thumbnails only\n // For constrained spaces, minimal progress bar is still shown via thumbnail updates\n // this.showOverallProgress(); // Disabled for simplified UI (Pattern 1)\n\n // Start uploading if we're not already\n if (!this.isUploading) {\n this.uploadNextFile();\n }\n\n // Dispatch fileadded event\n this.dispatch('fileadded', { fileCount: validFilesCount, skippedCount: skippedFilesCount });\n }\n\n isValidFileType(file) {\n if (!this.acceptedTypesValue) {\n return true;\n }\n\n // Define MIME type mapping for common file extensions\n const mimeTypeMap = {\n '.jpg': ['image/jpeg'],\n '.jpeg': ['image/jpeg'],\n '.png': ['image/png'],\n '.gif': ['image/gif'],\n '.pdf': ['application/pdf'],\n '.svg': ['image/svg+xml'],\n '.webp': ['image/webp'],\n // Add more as needed\n };\n\n const acceptedTypes = this.acceptedTypesValue.split(',');\n const fileName = file.name.toLowerCase();\n const fileType = file.type;\n\n return acceptedTypes.some(type => {\n const extension = type.trim().toLowerCase();\n\n // Ensure proper extension format with leading dot\n const formattedExtension = extension.startsWith('.') ? extension : `.${extension}`;\n\n // Check if filename has the right extension (exact ending match)\n // Use regex to ensure it's the actual extension at the end\n const extensionRegex = new RegExp(`\\\\${formattedExtension}$`);\n if (!extensionRegex.test(fileName)) {\n return false;\n }\n\n // If we have MIME types defined for this extension, verify the file's MIME type\n const validMimeTypes = mimeTypeMap[formattedExtension];\n if (validMimeTypes && validMimeTypes.length > 0) {\n return validMimeTypes.includes(fileType);\n }\n\n // If no MIME types are defined for this extension, just accept the extension match\n // This is a fallback for custom or less common extensions\n return true;\n });\n }\n\n isValidFileSize(file) {\n // Ensure maxFileSizeValue has a valid value (default to 5MB if undefined)\n const maxSizeMB = this.maxFileSizeValue || 5;\n const maxSizeBytes = maxSizeMB * 1024 * 1024;\n\n return file.size <= maxSizeBytes;\n }\n\n createPreview(file) {\n const fileId = file.id || `file-${this.fileCounter++}`;\n\n // Create minimal progress item (replaces full preview card)\n const progressItem = document.createElement('div');\n progressItem.className = 'dropzone-progress-item';\n progressItem.dataset.fileId = fileId;\n\n // Progress bar container\n const progressContainer = document.createElement('div');\n progressContainer.className = 'dropzone-progress-bar-container';\n\n // Progress bar\n const progressBar = document.createElement('div');\n progressBar.className = 'dropzone-progress-bar';\n progressBar.style.width = '0%';\n progressContainer.appendChild(progressBar);\n\n // Minimal remove button for queued files\n const removeBtn = document.createElement('button');\n removeBtn.className = 'dropzone-progress-remove';\n removeBtn.type = 'button';\n removeBtn.textContent = '\\u00d7';\n removeBtn.setAttribute('aria-label', 'Remove file from queue');\n removeBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n this.removeFile(fileId);\n });\n\n // Assemble elements\n progressItem.appendChild(progressContainer);\n progressItem.appendChild(removeBtn);\n\n // Add to preview container\n if (this.hasPreviewTarget) {\n this.previewTarget.appendChild(progressItem);\n }\n\n // Store references (same pattern for compatibility)\n file.id = fileId;\n file.previewElement = progressItem;\n file.progressElement = progressBar;\n }\n\n removeFile(fileId) {\n // Find the file in the queue\n const index = this.uploadQueue.findIndex(file => file.id === fileId);\n\n if (index !== -1) {\n // Remove from queue\n this.uploadQueue.splice(index, 1);\n\n // Remove preview element\n const previewItem = this.previewTarget.querySelector(`[data-file-id=\"${fileId}\"]`);\n if (previewItem) {\n previewItem.remove();\n }\n\n // Dispatch event\n this.dispatch('fileremoved');\n }\n }\n\n uploadNextFile() {\n if (this.uploadQueue.length === 0) {\n this.isUploading = false;\n\n // Hide progress indicator (cleanup even though simplified UI doesn't show it)\n this.hideOverallProgress();\n\n // Show batch summary instead of individual success message\n this.showBatchSummary();\n\n // Reset counters for next batch\n this.successfulUploads = 0;\n this.totalFiles = 0;\n this.completedFiles = 0;\n this.currentFileProgress = 0;\n this.currentUploadingFile = null;\n\n // Note: Input visibility is now restored in showBatchSummary() timeout callback\n // This ensures the check icon is prominently visible without the input text\n\n // Dispatch queue complete event\n this.dispatch('queuecomplete');\n\n // Also dispatch to attachments container (sibling element) for reload\n // since bubbling events only go UP the DOM tree, not to siblings\n const attachmentsContainer = document.getElementById('attachments-container');\n if (attachmentsContainer) {\n attachmentsContainer.dispatchEvent(new CustomEvent('dropzone:queuecomplete', { bubbles: true }));\n }\n return;\n }\n\n this.isUploading = true;\n this.currentFileProgress = 0; // Reset for new file\n const file = this.uploadQueue.shift();\n this.uploadFile(file);\n }\n\n uploadFile(file) {\n // Track the currently uploading file\n this.currentUploadingFile = file;\n\n // Track upload start time for minimum display duration\n file.uploadStartTime = Date.now();\n\n const formData = new FormData();\n // Use cached parameter name for reliability\n const paramName = this.paramName || this.paramNameValue || 'attachment';\n formData.append(paramName, file);\n\n // Automatically include all form fields within the dropzone element\n // This includes hidden inputs, selects, and textareas that might be needed\n const formFields = this.element.querySelectorAll('input[type=\"hidden\"], input[type=\"text\"]:not([data-dropzone-target]), select:not([data-dropzone-target]), textarea:not([data-dropzone-target])');\n formFields.forEach(field => {\n if (field.name && field.value) {\n // Handle checkboxes and radio buttons if they're checked\n if ((field.type === 'checkbox' || field.type === 'radio') && !field.checked) {\n return;\n }\n formData.append(field.name, field.value);\n }\n });\n\n // Set up progress tracking\n const xhr = new XMLHttpRequest();\n\n // Store reference to active XHR for cleanup\n this.activeXhr = xhr;\n\n // Track upload progress\n xhr.upload.addEventListener('progress', (event) => {\n if (event.lengthComputable) {\n const percentComplete = (event.loaded / event.total) * 100;\n this.updateProgress(file, percentComplete);\n }\n });\n\n // Handle response\n xhr.addEventListener('load', () => {\n // Upload response received\n\n // Clear reference to active XHR\n this.activeXhr = null;\n\n if (xhr.status >= 200 && xhr.status < 300) {\n // Upload successful\n this.handleSuccess(file, xhr.responseText);\n } else {\n // Upload failed\n // Classify the error\n const errorClassification = this.classifyError(xhr, file);\n this.handleError(file, errorClassification.message, errorClassification);\n }\n\n // Note: uploadNextFile() is now called from handleSuccess/handleError\n // after the minimum display delay to ensure progress bar is visible\n });\n\n // Handle network errors\n xhr.addEventListener('error', () => {\n this.activeXhr = null;\n const errorClassification = {\n category: this.errorCategories.NETWORK,\n message: 'Network error occurred',\n retryable: true,\n };\n this.handleError(file, errorClassification.message, errorClassification);\n // Note: uploadNextFile() called from handleError after minimum display delay\n });\n\n // Handle timeouts\n xhr.addEventListener('timeout', () => {\n this.activeXhr = null;\n const errorClassification = {\n category: this.errorCategories.NETWORK,\n message: 'Request timed out',\n retryable: true,\n };\n this.handleError(file, errorClassification.message, errorClassification);\n // Note: uploadNextFile() called from handleError after minimum display delay\n });\n\n // Start the upload - use cached URL for reliability, falling back to Stimulus value if needed\n const uploadUrl = this.uploadUrl || this.urlValue;\n\n // Validate upload URL before making request - prevent routing errors\n if (!uploadUrl ||\n uploadUrl === 'undefined' ||\n uploadUrl.includes('undefined') ||\n uploadUrl.includes('null') ||\n typeof uploadUrl !== 'string' ||\n uploadUrl.trim() === '') {\n const errorMessage = 'Upload failed: No valid upload URL configured';\n this.handleError(file, errorMessage, {\n category: this.errorCategories.VALIDATION,\n message: errorMessage,\n retryable: false,\n });\n // Note: uploadNextFile() called from handleError after minimum display delay\n return;\n }\n\n xhr.open('POST', uploadUrl);\n\n // Get a fresh CSRF token for each request\n const csrfToken = this.getCsrfToken();\n\n // Add CSRF token if available\n if (csrfToken) {\n xhr.setRequestHeader('X-CSRF-Token', csrfToken);\n } else if (this.warnings) {\n // Show warning to the user about the missing token\n this.showWarningMessage('Security token missing. Upload may fail.');\n }\n\n xhr.send(formData);\n\n // Dispatch uploadstart event\n this.dispatch('uploadstart', { file });\n }\n\n\n updateProgress(file, percent) {\n if (file.progressElement) {\n file.progressElement.style.width = `${percent}%`;\n }\n\n // Update overall progress\n this.currentFileProgress = percent;\n // this.showOverallProgress(); // Disabled for simplified UI (Pattern 1)\n\n // Dispatch progress event\n this.dispatch('uploadprogress', { file, progress: percent });\n }\n\n // Helper method to show success messages with fallback\n showSuccessMessage(message) {\n // Try to use App.showSuccessFlash if available\n if (window.App && typeof window.App.showSuccessFlash === 'function') {\n window.App.showSuccessFlash(message);\n return;\n }\n\n // Fallback: Create a temporary success message\n const messageElement = document.createElement('div');\n messageElement.className = 'dropzone-success-message dropzone-global-message';\n messageElement.style.backgroundColor = '#d4edda';\n messageElement.style.color = '#155724';\n messageElement.style.padding = '10px 15px';\n messageElement.style.margin = '10px 0';\n messageElement.style.borderRadius = '4px';\n messageElement.style.border = '1px solid #c3e6cb';\n messageElement.style.position = 'relative';\n messageElement.style.zIndex = '1000';\n messageElement.textContent = message;\n\n // Add a small check icon\n const icon = document.createElement('span');\n icon.innerHTML = '\u2713 ';\n icon.style.marginRight = '5px';\n icon.style.fontWeight = 'bold';\n messageElement.prepend(icon);\n\n // Add to the beginning of the dropzone\n this.element.insertBefore(messageElement, this.element.firstChild);\n\n // Remove after a delay\n const timeoutId = setTimeout(() => {\n if (messageElement.parentNode) {\n messageElement.remove();\n }\n\n // Remove from tracking array\n const index = this.messageTimeouts.indexOf(timeoutId);\n if (index !== -1) {\n this.messageTimeouts.splice(index, 1);\n }\n }, 5000);\n\n // Add to tracking array\n this.messageTimeouts.push(timeoutId);\n }\n\n // Helper method to show error messages with fallback\n showErrorMessage(message) {\n // Use app-wide error handling system\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash(message);\n } else {\n // Fallback to proper flash message\n this.createFlashMessage(message, 'error');\n }\n }\n\n createFlashMessage(message, type = 'error') {\n // Create a simple flash message element\n const flash = document.createElement('div');\n flash.className = `flash-message flash-${type}`;\n\n // Choose background color based on type\n const backgroundColors = {\n error: '#f44336',\n warning: '#ff9800',\n success: '#4CAF50',\n info: '#2196F3',\n };\n\n flash.style.cssText = `\n position: fixed;\n top: 20px;\n left: 50%;\n transform: translateX(-50%);\n background: ${backgroundColors[type] || backgroundColors.error};\n color: white;\n padding: 15px 20px;\n border-radius: 4px;\n z-index: 10000;\n box-shadow: 0 2px 5px rgba(0,0,0,0.3);\n `;\n flash.textContent = message;\n\n document.body.appendChild(flash);\n\n // Auto-remove after 5 seconds\n setTimeout(() => {\n flash.remove();\n }, 5000);\n }\n\n // Helper method to show warning messages\n showWarningMessage(message) {\n // Use app-wide warning handling system\n if (window.App && window.App.showWarningFlash) {\n window.App.showWarningFlash(message);\n } else {\n // Fallback to proper flash message\n this.createFlashMessage(message, 'warning');\n }\n }\n\n // Enhanced CSRF token handling - tries multiple sources\n getCsrfToken() {\n // Primary source: meta tag (Rails default)\n const metaToken = document.querySelector('meta[name=\"csrf-token\"]')?.content;\n\n if (metaToken) {\n return metaToken;\n }\n\n // Fallback 1: Look for it in a cookie (commonly used in Rails apps)\n const cookies = document.cookie.split(';');\n const csrfCookie = cookies.find(cookie =>\n cookie.trim().startsWith('CSRF-TOKEN=') ||\n cookie.trim().startsWith('X-CSRF-Token=') ||\n cookie.trim().startsWith('csrf_token='),\n );\n\n if (csrfCookie) {\n return csrfCookie.split('=')[1];\n }\n\n // Fallback 2: Check for it in localStorage if the app uses that approach\n const localStorageToken = localStorage.getItem('csrf-token') ||\n localStorage.getItem('X-CSRF-Token') ||\n localStorage.getItem('csrfToken');\n\n if (localStorageToken) {\n return localStorageToken;\n }\n\n return null;\n }\n\n // Error classification system\n classifyError(xhr, _file) {\n // Network errors\n if (!xhr.status || xhr.status === 0) {\n return {\n category: this.errorCategories.NETWORK,\n message: 'Network connection failed',\n retryable: true,\n };\n }\n\n // Security errors\n if (xhr.status === 401 || xhr.status === 403) {\n return {\n category: this.errorCategories.SECURITY,\n message: 'Authentication required',\n retryable: false,\n };\n }\n\n // CSRF errors\n if (xhr.status === 422 && this.isCsrfError(xhr)) {\n return {\n category: this.errorCategories.SECURITY,\n message: 'Security token expired',\n retryable: true,\n };\n }\n\n // Server errors\n if (xhr.status >= 500) {\n return {\n category: this.errorCategories.SERVER,\n message: 'Server error occurred',\n retryable: true,\n };\n }\n\n // Quota errors\n if (xhr.status === 413 || (xhr.responseText && xhr.responseText.includes('quota'))) {\n return {\n category: this.errorCategories.QUOTA,\n message: 'Storage quota exceeded',\n retryable: false,\n };\n }\n\n return {\n category: this.errorCategories.UNKNOWN,\n message: xhr.statusText || 'Upload failed',\n retryable: true,\n };\n }\n\n // Helper method to check if an error is CSRF related\n isCsrfError(xhr) {\n try {\n const response = JSON.parse(xhr.responseText);\n return response.error && (\n response.error.includes('CSRF') ||\n response.error.includes('token') ||\n response.error.includes('authenticity')\n );\n } catch {\n // If it's not JSON or we can't parse it, check the text\n return xhr.responseText.includes('CSRF') ||\n xhr.responseText.includes('token') ||\n xhr.responseText.includes('authenticity');\n }\n }\n\n handleSuccess(file, response) {\n let parsedResponse;\n try {\n parsedResponse = JSON.parse(response);\n } catch {\n parsedResponse = response;\n }\n\n // Calculate remaining time to meet minimum display duration\n const elapsedTime = Date.now() - (file.uploadStartTime || Date.now());\n const remainingTime = Math.max(0, this.minimumUploadDisplayTime - elapsedTime);\n\n // Immediately show progress bar at 100%\n if (file.progressElement) {\n file.progressElement.style.width = '100%';\n }\n\n // Delay completion handling to ensure minimum display time\n const completionTimeoutId = setTimeout(() => {\n // Track successful upload\n this.uploadResults.successful.push(file);\n\n // Increment successful uploads counter\n this.successfulUploads++;\n this.completedFiles++;\n this.currentFileProgress = 100;\n\n // Update UI - add success class\n if (file.previewElement) {\n file.previewElement.classList.add('dropzone-success');\n\n // Auto-remove the preview after a delay\n const removeTimeoutId = setTimeout(() => {\n if (file.previewElement && file.previewElement.parentNode) {\n file.previewElement.remove();\n }\n\n // Remove from tracking array\n const index = this.messageTimeouts.indexOf(removeTimeoutId);\n if (index !== -1) {\n this.messageTimeouts.splice(index, 1);\n }\n }, 3000);\n\n // Add to tracking array\n this.messageTimeouts.push(removeTimeoutId);\n }\n\n // Dispatch success event with bubbles enabled so parent controllers can listen\n this.dispatch('uploadsuccess', { file, response: parsedResponse });\n\n // Show success flash for single-file uploads (logo, slide, etc.)\n // This provides immediate feedback without relying on event listener chains\n if (this.element.dataset.maxFiles === '1') {\n if (window.App && typeof window.App.showSuccessFlash === 'function') {\n window.App.showSuccessFlash('File uploaded successfully');\n }\n }\n\n // Proceed to next file in queue\n this.uploadNextFile();\n\n // Remove completion timeout from tracking\n const index = this.messageTimeouts.indexOf(completionTimeoutId);\n if (index !== -1) {\n this.messageTimeouts.splice(index, 1);\n }\n }, remainingTime);\n\n // Track the completion timeout for cleanup\n this.messageTimeouts.push(completionTimeoutId);\n }\n\n handleError(file, errorMessage, errorClassification = null) {\n // Calculate remaining time to meet minimum display duration\n const elapsedTime = Date.now() - (file.uploadStartTime || Date.now());\n const remainingTime = Math.max(0, this.minimumUploadDisplayTime - elapsedTime);\n\n // Delay error handling to ensure minimum display time for progress bar\n const completionTimeoutId = setTimeout(() => {\n // Track failed upload\n this.uploadResults.failed.push(file);\n\n // Store the failure reason if classification is provided\n if (errorClassification) {\n this.failureReasons.set(file.id, errorClassification);\n }\n\n // Update progress tracking for failed files\n this.completedFiles++;\n this.currentFileProgress = 0;\n\n // Show error message\n this.showErrorMessage(`Failed to upload ${file.name}: ${errorMessage}`);\n\n // Update UI\n if (file.previewElement) {\n file.previewElement.classList.add('dropzone-error');\n\n // Add error message\n const errorElement = document.createElement('div');\n errorElement.className = 'dropzone-error-message';\n errorElement.textContent = errorMessage;\n file.previewElement.appendChild(errorElement);\n }\n\n // Dispatch error event\n this.dispatch('uploaderror', { file, error: errorMessage });\n\n // Proceed to next file in queue\n this.uploadNextFile();\n\n // Remove completion timeout from tracking\n const index = this.messageTimeouts.indexOf(completionTimeoutId);\n if (index !== -1) {\n this.messageTimeouts.splice(index, 1);\n }\n }, remainingTime);\n\n // Track the completion timeout for cleanup\n this.messageTimeouts.push(completionTimeoutId);\n }\n\n showError(message) {\n // Use the helper method\n this.showErrorMessage(message);\n\n // Dispatch error event\n this.dispatch('error', { message });\n }\n\n dispatch(name, detail = {}) {\n const event = new CustomEvent(`dropzone:${name}`, {\n detail,\n bubbles: true,\n cancelable: true,\n });\n\n this.element.dispatchEvent(event);\n }\n\n // Check if dropzone is in a constrained space\n isConstrainedSpace() {\n // Check if parent has small container class\n const hasSmallContainerClass = this.element.closest('.uploaded-img-container, .slideshow');\n\n // Check if dropzone itself is small\n const rect = this.element.getBoundingClientRect();\n const isSmallSize = rect.width < 150 || rect.height < 150;\n\n return !!(hasSmallContainerClass || isSmallSize);\n }\n\n // Show overall progress indicator\n showOverallProgress() {\n if (this.totalFiles === 0) return;\n\n let progressContainer = this.hasProgressIndicatorTarget ?\n this.progressIndicatorTarget :\n this.element.querySelector('.dropzone-overall-progress');\n\n if (!progressContainer) {\n // Create progress container if it doesn't exist\n progressContainer = document.createElement('div');\n progressContainer.className = 'dropzone-overall-progress';\n\n // Insert after input target if it exists, otherwise at the beginning\n if (this.hasInputTarget) {\n this.inputTarget.insertAdjacentElement('afterend', progressContainer);\n } else {\n this.element.insertBefore(progressContainer, this.element.firstChild);\n }\n }\n\n const successCount = this.uploadResults.successful.length;\n const failedCount = this.uploadResults.failed.length;\n const currentFileNum = successCount + failedCount + 1;\n\n // Calculate overall progress percentage\n const overallProgress = this.totalFiles > 0 ?\n ((this.completedFiles + (this.currentFileProgress / 100)) / this.totalFiles) * 100 : 0;\n\n // Check if we're in a constrained space\n const isConstrained = this.isConstrainedSpace();\n\n // Add constrained class to dropzone if needed\n if (isConstrained) {\n this.element.classList.add('dropzone-constrained');\n }\n\n // Create or update progress content\n if (isConstrained) {\n // Minimal progress bar only for constrained spaces\n progressContainer.innerHTML = `\n
    \n
    \n
    \n `;\n } else {\n // Full progress display for normal spaces\n progressContainer.innerHTML = `\n
    \n \n Uploading file ${currentFileNum} of ${this.totalFiles}\n \n
    \n \u2713 ${successCount} completed\n ${failedCount > 0 ? `\u2717 ${failedCount} failed` : ''}\n
    \n
    \n
    \n
    \n
    \n
    \n ${this.currentUploadingFile?.name || ''}\n
    \n `;\n }\n\n // Make sure it's visible\n progressContainer.style.display = 'block';\n }\n\n // Hide overall progress indicator\n hideOverallProgress() {\n const progressContainer = this.hasProgressIndicatorTarget ?\n this.progressIndicatorTarget :\n this.element.querySelector('.dropzone-overall-progress');\n\n if (progressContainer) {\n progressContainer.style.display = 'none';\n }\n }\n\n // Show batch completion summary\n showBatchSummary() {\n const { successful, failed, skipped } = this.uploadResults;\n const total = successful.length + failed.length + skipped.length;\n\n if (total === 0) return;\n\n // Check if using progress-only UI (individual items already show checkmarks)\n const progressItems = this.element.querySelectorAll('.dropzone-progress-item');\n const hasProgressOnlyUI = progressItems.length > 0;\n\n // For successful uploads with progress-only UI, skip the large batch summary icon\n // The individual item checkmarks are sufficient feedback\n if (hasProgressOnlyUI && failed.length === 0 && skipped.length === 0) {\n const timeoutId = setTimeout(() => {\n // Clean up progress items\n progressItems.forEach(item => item.remove());\n\n // Restore input visibility\n if (this.hasInputTarget) {\n this.inputTarget.style.display = '';\n }\n\n // Remove from tracking array\n const index = this.messageTimeouts.indexOf(timeoutId);\n if (index !== -1) {\n this.messageTimeouts.splice(index, 1);\n }\n }, 2000);\n\n this.messageTimeouts.push(timeoutId);\n return;\n }\n\n // Hide input text while showing summary (prevents clutter)\n if (this.hasInputTarget && !this.isConstrainedSpace()) {\n this.inputTarget.style.display = 'none';\n }\n\n let summaryHtml = '
    ';\n\n // Icon-only display for both success and error states\n if (failed.length === 0 && skipped.length === 0) {\n // Success: large green check icon\n summaryHtml += `\n
    \n \n
    \n `;\n } else {\n // Error: large red X icon\n summaryHtml += `\n
    \n \n
    \n `;\n }\n\n summaryHtml += '
    ';\n\n // Insert summary at the top of the dropzone\n this.element.insertAdjacentHTML('afterbegin', summaryHtml);\n\n // Auto-remove summary after a delay\n const timeoutId = setTimeout(() => {\n const summary = this.element.querySelector('.dropzone-batch-summary');\n if (summary) {\n summary.remove();\n }\n\n // Clean up any remaining progress items\n const remainingProgressItems = this.element.querySelectorAll('.dropzone-progress-item');\n remainingProgressItems.forEach(item => item.remove());\n\n // Restore input visibility AFTER summary is removed\n if (this.hasInputTarget && !this.isConstrainedSpace()) {\n this.inputTarget.style.display = '';\n }\n\n // Remove from tracking array\n const index = this.messageTimeouts.indexOf(timeoutId);\n if (index !== -1) {\n this.messageTimeouts.splice(index, 1);\n }\n }, 3000);\n\n // Add to tracking array\n this.messageTimeouts.push(timeoutId);\n }\n\n // Group failures by reason for display\n groupFailuresByReason(failedFiles) {\n const groups = new Map();\n\n for (const file of failedFiles) {\n const reason = this.failureReasons.get(file.id);\n const message = reason ? reason.message : 'Unknown error';\n\n if (!groups.has(message)) {\n groups.set(message, []);\n }\n groups.get(message).push(file);\n }\n\n return groups;\n }\n\n // Count retryable failures\n getRetryableFailuresCount() {\n return this.uploadResults.failed.filter(file => {\n const reason = this.failureReasons.get(file.id);\n return reason && reason.retryable;\n }).length;\n }\n\n // Retry failed uploads\n retryFailedUploads() {\n // Get retryable failures only\n const retryableFiles = this.uploadResults.failed.filter(file => {\n const reason = this.failureReasons.get(file.id);\n return reason && reason.retryable;\n });\n\n if (retryableFiles.length === 0) {\n this.showWarningMessage('No files available for retry');\n return;\n }\n\n // Clear the failed files from results\n this.uploadResults.failed = this.uploadResults.failed.filter(\n file => !retryableFiles.includes(file),\n );\n\n // Clear any existing summary\n this.element.querySelector('.dropzone-batch-summary')?.remove();\n\n // Restore input target visibility if we're in constrained space\n if (this.isConstrainedSpace() && this.hasInputTarget) {\n this.inputTarget.style.display = '';\n }\n\n // Remove constrained class when retrying\n this.element.classList.remove('dropzone-constrained');\n\n // Reset counters for retry batch\n this.totalFiles = retryableFiles.length;\n this.completedFiles = 0;\n\n // Add files back to queue\n this.uploadQueue.push(...retryableFiles);\n\n // Show progress and start uploading (simplified UI: progress shown on thumbnails only)\n // this.showOverallProgress(); // Disabled for simplified UI (Pattern 1)\n if (!this.isUploading) {\n this.uploadNextFile();\n }\n }\n\n // Navigation protection methods\n setupNavigationProtection() {\n this.beforeUnloadHandler = (e) => {\n if (this.isUploading && this.uploadQueue.length > 0) {\n const message = 'Files are still uploading. Are you sure you want to leave?';\n e.preventDefault();\n e.returnValue = message;\n return message;\n }\n };\n\n window.addEventListener('beforeunload', this.beforeUnloadHandler);\n }\n\n removeNavigationProtection() {\n if (this.beforeUnloadHandler) {\n window.removeEventListener('beforeunload', this.beforeUnloadHandler);\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n click(event) {\n event.preventDefault();\n event.stopPropagation();\n\n const categoryId = this.element.dataset.categoryId;\n const categoryName = this.element.dataset.categoryName;\n\n // Hide the category heading\n const categoryHeading = document.getElementById(`category-heading-${categoryId}`);\n if (categoryHeading) {\n categoryHeading.style.display = 'none';\n }\n\n // Show the rename form\n const renameForm = document.getElementById(`rename-category-form-${categoryId}`);\n if (renameForm) {\n renameForm.classList.remove('ae-hidden');\n\n // Set the input field value and focus it\n const inputField = document.getElementById(`edit-category-form-input-${categoryId}`);\n if (inputField) {\n inputField.value = categoryName;\n inputField.focus();\n inputField.select();\n }\n\n // Hide any previous error messages\n const errorMsg = document.getElementById(`edit-category-form-error-${categoryId}`);\n if (errorMsg) {\n errorMsg.style.display = 'none';\n }\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\n/**\n * Event-Triggered Popup Controller\n *\n * This controller bridges the gap between the old event-based modal system\n * and the new ajax-popup controller. It listens for custom events and\n * programmatically triggers the associated popup.\n *\n * ## Usage\n *\n * ```html\n *
    \n *\n * \n * ajax-popup#click\"\n * style=\"display: none;\">\n * \n *
    \n * ```\n *\n * ## Data Attributes\n *\n * - data-event-triggered-popup-event-value: The event to listen for\n * - data-event-triggered-popup-target-value: CSS selector for the element to click\n * - data-event-triggered-popup-bubble-value: Whether to listen on document (default: true)\n */\nexport default class extends Controller {\n\n connect() {\n if (!this.element.dataset.event) {\n // No event specified - skip initialization\n return;\n }\n\n // Store bound handler for cleanup\n this.boundHandler = this.handleEvent.bind(this);\n\n // Listen on document if bubble is true, otherwise on the element\n const listenTarget = (this.element.dataset.bubble !== 'false') ? document : this.element;\n listenTarget.addEventListener(this.element.dataset.event, this.boundHandler);\n }\n\n disconnect() {\n if (this.boundHandler && this.element.dataset.event) {\n const listenTarget = (this.element.dataset.bubble !== 'false') ? document : this.element;\n listenTarget.removeEventListener(this.element.dataset.event, this.boundHandler);\n }\n }\n\n handleEvent(event) {\n // Find the target element to click\n const targetSelector = this.element.dataset.target || '[data-controller=\"ajax-popup\"]';\n const targetElement = this.element.querySelector(targetSelector);\n\n if (!targetElement) {\n // Target not found - skip action\n return;\n }\n\n // Store event data if needed by the popup\n if (event.detail) {\n targetElement.dataset.eventDetail = JSON.stringify(event.detail);\n }\n\n // Trigger click on the target\n targetElement.click();\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n connect() {\n // Store bound function reference for proper cleanup\n this.boundTriggerFileInput = this.triggerFileInput.bind(this);\n this.element.addEventListener('click', this.boundTriggerFileInput);\n }\n\n disconnect() {\n // Clean up event listener to prevent memory leaks\n if (this.boundTriggerFileInput) {\n this.element.removeEventListener('click', this.boundTriggerFileInput);\n }\n }\n\n triggerFileInput(event) {\n event.preventDefault();\n\n // Find the nearest dropzone form\n const dropzoneForm = this.element.closest('.ae-hidden')?.querySelector('form[data-controller=\"dropzone\"]') ||\n document.querySelector('form[data-controller=\"dropzone\"]');\n\n if (dropzoneForm) {\n const fileInput = dropzoneForm.querySelector('input[type=\"file\"]');\n if (fileInput) {\n fileInput.click();\n }\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\n/**\n * Fixed Footer Controller\n *\n * This controller handles the fixed positioning of footers inside scrollable containers.\n * It replaces the legacy jQuery.onmount implementation in legacy/behaviors/footer.js.\n *\n * Usage:\n *
    \n *
    \n * \n *
    \n *
    ...
    \n *
    \n */\nexport default class extends Controller {\n static targets = ['scrollContainer', 'footer'];\n\n connect() {\n // Add scroll and resize event listeners\n this.handleScroll = this.handleScrollAndResize.bind(this);\n this.handleResize = this.handleScrollAndResize.bind(this);\n\n if (this.hasScrollContainerTarget) {\n this.scrollContainerTarget.addEventListener('scroll', this.handleScroll);\n window.addEventListener('resize', this.handleResize);\n\n // Initialize on connect\n this.updateFooterPosition();\n }\n }\n\n disconnect() {\n // Clean up event listeners when controller disconnects\n if (this.hasScrollContainerTarget) {\n this.scrollContainerTarget.removeEventListener('scroll', this.handleScroll);\n window.removeEventListener('resize', this.handleResize);\n }\n }\n\n handleScrollAndResize() {\n this.updateFooterPosition();\n }\n\n updateFooterPosition() {\n if (this.hasFooterTarget) {\n // Add the bottom-fixed class to the footer\n this.footerTarget.classList.add('bottom-fixed');\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\n/**\n * Flash Controller\n *\n * This controller manages flash message behavior when the messages\n * are already rendered in the DOM via the shared/_flash.html.erb partial.\n *\n * It handles:\n * - Auto-hiding success messages after a delay\n * - Maintaining a simple API for JavaScript flash interactions\n * - Ensuring flash messages work with Turbolinks page transitions\n */\nexport default class extends Controller {\n connect() {\n // Initialize the App object if needed\n window.App = window.App || {};\n\n // Cache DOM elements for performance\n this.cacheElements();\n\n // Setup the API for flash message interactions\n this.setupAppInterface();\n\n // Show any flash messages that are in the DOM\n this.showFlashMessages();\n\n // Auto-hide success messages\n this.autoHideSuccessMessages();\n }\n\n disconnect() {\n // Clear cached elements on disconnect\n this.clearElementCache();\n }\n\n /**\n * Cache frequently accessed DOM elements\n */\n cacheElements() {\n this._flashContainer = document.getElementById('flash');\n this._cachedFlashMessages = null; // Will be cached on first access\n }\n\n /**\n * Clear cached elements\n */\n clearElementCache() {\n this._flashContainer = null;\n this._cachedFlashMessages = null;\n }\n\n /**\n * Get cached flash container or null if it doesn't exist\n */\n get flashContainer() {\n if (this._flashContainer === undefined) {\n this._flashContainer = document.getElementById('flash');\n }\n // Check if the cached element is still in the DOM\n if (this._flashContainer && !document.contains(this._flashContainer)) {\n this._flashContainer = document.getElementById('flash');\n }\n return this._flashContainer;\n }\n\n /**\n * Get all flash messages, with caching for performance\n * @param {boolean} forceRefresh - Force refresh of cached messages\n */\n getFlashMessages(forceRefresh = false) {\n if (this._cachedFlashMessages === null || forceRefresh) {\n // Query from document for global messages, not just controller element\n this._cachedFlashMessages = document.querySelectorAll('.ae-alert-fixed');\n }\n return this._cachedFlashMessages;\n }\n\n showFlashMessages() {\n // Look for any flash messages and make sure they're visible\n const flashMessages = this.getFlashMessages(true); // Force refresh on show\n\n if (flashMessages.length > 0) {\n // Reset any flash messages that might have been hidden\n flashMessages.forEach(message => {\n message.style.display = 'block';\n message.style.opacity = '1';\n\n // Initialize the closeable-container controller if needed\n if (!message.hasAttribute('data-closeable-container-connected')) {\n const event = new CustomEvent('stimulus:connect', { bubbles: true });\n message.dispatchEvent(event);\n message.setAttribute('data-closeable-container-connected', 'true');\n }\n });\n }\n }\n\n setupAppInterface() {\n // This method is called by AJAX responses and needs to accept HTML for backward compatibility\n window.App.showFlashMessage = (html) => {\n // Check if there are flash messages in the DOM\n const flashContainer = this.flashContainer;\n\n if (!flashContainer) {\n return;\n }\n\n // Get all flash messages from Rails (force refresh since AJAX may have updated DOM)\n const flashMessages = this.getFlashMessages(true);\n\n if (flashMessages.length > 0) {\n // There are flash messages, make sure they're visible\n flashMessages.forEach(message => {\n message.style.display = 'block';\n message.style.opacity = '1';\n\n // Make sure closeable-container controller is initialized\n if (!message.hasAttribute('data-closeable-container-connected')) {\n const event = new CustomEvent('stimulus:connect', { bubbles: true });\n message.dispatchEvent(event);\n message.setAttribute('data-closeable-container-connected', 'true');\n }\n });\n } else if (html) {\n // Safely insert HTML to prevent XSS attacks\n // Parse HTML using DOMParser which doesn't execute scripts\n const parser = new DOMParser();\n const doc = parser.parseFromString(html, 'text/html');\n\n // Clear existing content first\n while (flashContainer.firstChild) {\n flashContainer.removeChild(flashContainer.firstChild);\n }\n\n // Move parsed nodes from parsed document to flash container\n // This approach prevents script execution during parsing\n const parsedBody = doc.body;\n while (parsedBody.firstChild) {\n flashContainer.appendChild(parsedBody.firstChild);\n }\n\n // Invalidate cache since DOM changed\n this._cachedFlashMessages = null;\n }\n\n // Ensure auto-hiding works after Ajax updates the page\n this.autoHideSuccessMessages();\n };\n\n // Legacy methods for backward compatibility\n window.App.showSuccessFlash = (message) => {\n this.createFlashMessage(message, 'success');\n };\n\n window.App.showErrorFlash = (message) => {\n this.createFlashMessage(message, 'error');\n };\n\n window.App.showWarningFlash = (message) => {\n this.createFlashMessage(message, 'info');\n };\n\n // Method to close all flash messages\n window.App.closeFlash = () => {\n // Use cached flash messages and find close buttons within them\n const flashMessages = this.getFlashMessages();\n flashMessages.forEach(message => {\n const closeButton = message.querySelector('[data-action*=\"closeable-container#close\"]');\n if (closeButton) {\n closeButton.click();\n }\n });\n };\n }\n\n autoHideSuccessMessages() {\n const flashMessages = this.getFlashMessages();\n\n if (flashMessages.length > 0) {\n setTimeout(() => {\n // Re-query in case messages were removed during the timeout\n const currentMessages = this.getFlashMessages(true);\n currentMessages.forEach(message => {\n const closeButton = message.querySelector('[data-action*=\"closeable-container#close\"]');\n if (closeButton) {\n closeButton.click();\n }\n });\n }, 5000);\n }\n }\n\n /**\n * Creates a flash message and adds it to the flash container\n * @param {string} message - The message text to display\n * @param {string} type - The type of message (success, error, info)\n */\n createFlashMessage(message, type = 'info') {\n const flashContainer = this.flashContainer;\n if (!flashContainer) {\n return;\n }\n\n // Create the message element\n const messageEl = document.createElement('div');\n messageEl.setAttribute('data-controller', 'closeable-container');\n\n // Add appropriate classes based on type\n messageEl.className = 'ae-alert ae-alert-fixed';\n if (type === 'success') {\n messageEl.classList.add('ae-alert-success');\n } else if (type === 'error') {\n messageEl.classList.add('ae-alert-error');\n } else {\n messageEl.classList.add('ae-alert-info');\n }\n\n // Add icon based on type\n let icon = 'info-circle';\n if (type === 'success') {\n icon = 'check-circle';\n } else if (type === 'error') {\n icon = 'exclamation-circle';\n }\n\n // Create the message structure safely\n const p = document.createElement('p');\n const iconEl = document.createElement('i');\n iconEl.className = `fa fa-${icon} fa-fw`;\n p.appendChild(iconEl);\n\n // Use textContent to safely set the message\n const textNode = document.createTextNode(' ' + message);\n p.appendChild(textNode);\n\n const closeButtonEl = document.createElement('span');\n closeButtonEl.className = 'ae-alert-action ae-alert-close';\n closeButtonEl.title = 'Close';\n closeButtonEl.setAttribute('data-target', 'closeable-container.closeButton');\n closeButtonEl.setAttribute('data-action', 'click->closeable-container#close');\n closeButtonEl.textContent = '\u00D7';\n\n messageEl.appendChild(p);\n messageEl.appendChild(closeButtonEl);\n\n // Add to the flash container\n flashContainer.appendChild(messageEl);\n\n // Invalidate cached messages since we added a new one\n this._cachedFlashMessages = null;\n\n // Initialize the closeable container controller\n const event = new CustomEvent('stimulus:connect', { bubbles: true });\n messageEl.dispatchEvent(event);\n messageEl.setAttribute('data-closeable-container-connected', 'true');\n\n // Auto-hide all flash messages (use the element we just created)\n setTimeout(() => {\n if (closeButtonEl) {\n closeButtonEl.click();\n }\n }, 5000);\n\n return messageEl;\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n static targets = ['submit'];\n\n connect() {\n this.isSubmitting = false;\n }\n\n submit(event) {\n\n // Prevent submission if already submitting\n if (this.isSubmitting) {\n event.preventDefault();\n return;\n }\n\n // Mark as submitting\n this.isSubmitting = true;\n\n // Get the submit button\n const submitButton = this.hasSubmitTarget ? this.submitTarget : event.submitter;\n\n if (submitButton) {\n\n // CRITICAL: Defer button disable to allow form submission to proceed\n // Disabling synchronously prevents the form submit event from completing\n setTimeout(() => {\n // Disable the button (functional only, no text changes)\n submitButton.disabled = true;\n }, 0);\n\n // Re-enable after a delay (in case the form doesn't redirect)\n setTimeout(() => {\n this.reset(submitButton);\n }, 5000);\n }\n\n }\n\n reset(button) {\n if (button) {\n button.disabled = false;\n }\n this.isSubmitting = false;\n }\n}\n\n", "import { Controller } from '../stimulus';\n\n/**\n * Image Zoom Modal Controller\n *\n * Displays a product image in a contained Express UI modal with zoom and pan capabilities.\n *\n * Features:\n * - Mouse wheel zoom (1x to 3x)\n * - Click and drag to pan when zoomed\n * - Zoom control buttons (+/-)\n * - Reset button to return to 1x\n * - Close via ESC key, backdrop click, or close button\n *\n * Usage (programmatic):\n * ```javascript\n * import ImageZoomModalController from './image_zoom_modal_controller';\n * ImageZoomModalController.open('/path/to/image.jpg', 'Product Name');\n * ```\n *\n * Note: This controller follows Stimulus 1.1.0 patterns and uses vanilla JavaScript.\n */\nexport default class extends Controller {\n static get targets() { return ['image', 'zoomLevel']; }\n\n // Zoom constraints\n static get MIN_ZOOM() { return 1; }\n static get MAX_ZOOM() { return 3; }\n static get ZOOM_STEP() { return 0.25; }\n static get LOAD_TIMEOUT_MS() { return 15000; } // 15 seconds timeout for image loading\n\n /**\n * Static method to programmatically open the image zoom modal\n * @param {string} imageUrl - The URL of the image to display\n * @param {string} imageAlt - The alt text for the image\n */\n static open(imageUrl, imageAlt) {\n const instance = new this();\n\n // Create a minimal element to satisfy controller requirements\n const element = document.createElement('div');\n Object.defineProperty(instance, 'element', {\n value: element,\n writable: true,\n configurable: true,\n });\n\n // Initialize state\n instance.scale = 1;\n instance.panX = 0;\n instance.panY = 0;\n instance.isPanning = false;\n instance.startX = 0;\n instance.startY = 0;\n\n // Bind event handlers for cleanup\n instance.boundHandleWheel = instance.handleWheel.bind(instance);\n instance.boundHandleKeyDown = instance.handleKeyDown.bind(instance);\n instance.boundHandleMouseDown = instance.handleMouseDown.bind(instance);\n instance.boundHandleMouseMove = instance.handleMouseMove.bind(instance);\n instance.boundHandleMouseUp = instance.handleMouseUp.bind(instance);\n instance.boundHandleWrapperClick = instance.handleWrapperClick.bind(instance);\n\n // Show the modal\n instance.show(imageUrl, imageAlt);\n\n return instance;\n }\n\n connect() {\n // Initialize state\n this.scale = 1;\n this.panX = 0;\n this.panY = 0;\n this.isPanning = false;\n\n // Bind event handlers\n this.boundHandleWheel = this.handleWheel.bind(this);\n this.boundHandleKeyDown = this.handleKeyDown.bind(this);\n this.boundHandleMouseDown = this.handleMouseDown.bind(this);\n this.boundHandleMouseMove = this.handleMouseMove.bind(this);\n this.boundHandleMouseUp = this.handleMouseUp.bind(this);\n this.boundHandleWrapperClick = this.handleWrapperClick.bind(this);\n }\n\n disconnect() {\n this.cleanup();\n }\n\n /**\n * Show the modal with the given image\n * @param {string} imageUrl - The image URL\n * @param {string} imageAlt - The image alt text\n */\n show(imageUrl, imageAlt) {\n // Reset zoom state\n this.scale = 1;\n this.panX = 0;\n this.panY = 0;\n\n // Create backdrop\n this.backdrop = document.createElement('div');\n this.backdrop.className = 'mfp-bg mfp-ready image-zoom-modal-backdrop';\n this.backdrop.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:1040;opacity:0.8;background-color:#0b0b0b;';\n document.body.appendChild(this.backdrop);\n\n // Create wrapper\n this.wrapper = document.createElement('div');\n this.wrapper.className = 'mfp-wrap mfp-ready image-zoom-modal-wrapper';\n this.wrapper.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:1050;overflow:auto;display:flex;align-items:center;justify-content:center;';\n\n // Build modal content using safe DOM methods\n const modalContent = this.buildModalContent(imageUrl, imageAlt);\n this.wrapper.appendChild(modalContent);\n\n document.body.appendChild(this.wrapper);\n\n // Get references to elements\n this.imageElement = this.wrapper.querySelector('[data-target=\"image-zoom-modal.image\"]');\n this.zoomLevelElement = this.wrapper.querySelector('[data-target=\"image-zoom-modal.zoomLevel\"]');\n this.containerElement = this.wrapper.querySelector('.image-zoom-modal-content');\n\n // Setup event listeners\n this.setupEventListeners();\n\n // Add body class to prevent scrolling\n document.body.classList.add('mfp-open');\n\n // Add keyframe animation for spinner if not already present\n if (!document.getElementById('image-zoom-modal-styles')) {\n const style = document.createElement('style');\n style.id = 'image-zoom-modal-styles';\n style.textContent = '@keyframes image-zoom-spin { to { transform: translate(-50%,-50%) rotate(360deg); } }';\n document.head.appendChild(style);\n }\n\n // Get spinner reference\n this.spinnerElement = this.wrapper.querySelector('[data-target=\"image-zoom-modal.spinner\"]');\n\n // Show loading state until image loads\n this.imageElement.style.opacity = '0';\n\n // Set up timeout for slow loading images\n this.loadTimeout = setTimeout(() => {\n this.handleImageError('Image load timeout - please try again');\n }, this.constructor.LOAD_TIMEOUT_MS);\n\n // Success handler - hide spinner, show image\n this.imageElement.onload = () => {\n if (this.loadTimeout) {\n clearTimeout(this.loadTimeout);\n this.loadTimeout = null;\n }\n if (this.spinnerElement) {\n this.spinnerElement.style.display = 'none';\n }\n this.imageElement.style.opacity = '1';\n };\n\n // Error handler - show error message\n this.imageElement.onerror = () => {\n if (this.loadTimeout) {\n clearTimeout(this.loadTimeout);\n this.loadTimeout = null;\n }\n this.handleImageError('Failed to load image');\n };\n }\n\n /**\n * Build modal content using safe DOM methods (no innerHTML)\n * @param {string} imageUrl - The image URL\n * @param {string} imageAlt - The image alt text\n * @returns {HTMLElement} - The modal container element\n */\n buildModalContent(imageUrl, imageAlt) {\n // Modal container \u2014 Express UI popup\n const modal = document.createElement('div');\n modal.className = 'ae-popup ae-popup-no-padding image-zoom-modal';\n\n // Heading with title and close button\n const heading = document.createElement('div');\n heading.className = 'ae-popup-heading';\n\n const title = document.createElement('h4');\n title.textContent = imageAlt || 'Product Image';\n heading.appendChild(title);\n\n const closeBtn = document.createElement('button');\n closeBtn.className = 'mfp-close';\n closeBtn.type = 'button';\n closeBtn.setAttribute('aria-label', 'Close modal');\n closeBtn.textContent = '\\u00D7'; // \u00D7 character\n heading.appendChild(closeBtn);\n\n modal.appendChild(heading);\n\n // Zoom controls bar\n const controls = document.createElement('div');\n controls.className = 'image-zoom-modal-controls';\n\n // Zoom out button\n const zoomOutBtn = document.createElement('button');\n zoomOutBtn.className = 'ae-btn ae-btn-sm image-zoom-modal-btn';\n zoomOutBtn.setAttribute('data-zoom-btn', 'zoom-out');\n zoomOutBtn.type = 'button';\n zoomOutBtn.setAttribute('aria-label', 'Zoom out');\n const zoomOutSpan = document.createElement('span');\n zoomOutSpan.setAttribute('aria-hidden', 'true');\n zoomOutSpan.textContent = '\\u2212'; // \u2212 character\n zoomOutBtn.appendChild(zoomOutSpan);\n controls.appendChild(zoomOutBtn);\n\n // Zoom level display\n const zoomLevel = document.createElement('span');\n zoomLevel.className = 'image-zoom-modal-level';\n zoomLevel.setAttribute('data-target', 'image-zoom-modal.zoomLevel');\n zoomLevel.textContent = '100%';\n controls.appendChild(zoomLevel);\n\n // Zoom in button\n const zoomInBtn = document.createElement('button');\n zoomInBtn.className = 'ae-btn ae-btn-sm image-zoom-modal-btn';\n zoomInBtn.setAttribute('data-zoom-btn', 'zoom-in');\n zoomInBtn.type = 'button';\n zoomInBtn.setAttribute('aria-label', 'Zoom in');\n const zoomInSpan = document.createElement('span');\n zoomInSpan.setAttribute('aria-hidden', 'true');\n zoomInSpan.textContent = '+';\n zoomInBtn.appendChild(zoomInSpan);\n controls.appendChild(zoomInBtn);\n\n // Reset button\n const resetBtn = document.createElement('button');\n resetBtn.className = 'ae-btn ae-btn-sm image-zoom-modal-btn';\n resetBtn.setAttribute('data-zoom-btn', 'reset');\n resetBtn.type = 'button';\n resetBtn.setAttribute('aria-label', 'Reset zoom');\n resetBtn.textContent = 'Reset';\n controls.appendChild(resetBtn);\n\n modal.appendChild(controls);\n\n // Content area \u2014 zoom container\n const content = document.createElement('div');\n content.className = 'ae-popup-content image-zoom-modal-content';\n\n // Loading spinner\n const spinner = document.createElement('div');\n spinner.className = 'image-zoom-modal-spinner';\n spinner.setAttribute('data-target', 'image-zoom-modal.spinner');\n spinner.style.cssText =\n 'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);' +\n 'width:40px;height:40px;border:4px solid rgba(0,0,0,0.15);' +\n 'border-top-color:#333;border-radius:50%;animation:image-zoom-spin 1s linear infinite;';\n content.appendChild(spinner);\n\n // Image element\n const img = document.createElement('img');\n img.src = imageUrl;\n img.alt = imageAlt || 'Product image';\n img.className = 'image-zoom-modal-image';\n img.setAttribute('data-target', 'image-zoom-modal.image');\n content.appendChild(img);\n\n modal.appendChild(content);\n\n return modal;\n }\n\n /**\n * Setup all event listeners\n */\n setupEventListeners() {\n // Close button\n const closeButton = this.wrapper.querySelector('.mfp-close');\n if (closeButton) {\n closeButton.addEventListener('click', (e) => {\n e.preventDefault();\n this.close();\n });\n }\n\n // Zoom control buttons\n const zoomInBtn = this.wrapper.querySelector('[data-zoom-btn=\"zoom-in\"]');\n const zoomOutBtn = this.wrapper.querySelector('[data-zoom-btn=\"zoom-out\"]');\n const resetBtn = this.wrapper.querySelector('[data-zoom-btn=\"reset\"]');\n\n if (zoomInBtn) {\n zoomInBtn.addEventListener('click', (e) => {\n e.preventDefault();\n this.zoomIn();\n });\n }\n\n if (zoomOutBtn) {\n zoomOutBtn.addEventListener('click', (e) => {\n e.preventDefault();\n this.zoomOut();\n });\n }\n\n if (resetBtn) {\n resetBtn.addEventListener('click', (e) => {\n e.preventDefault();\n this.resetZoom();\n });\n }\n\n // Wheel zoom on image container\n if (this.containerElement) {\n this.containerElement.addEventListener('wheel', this.boundHandleWheel, { passive: false });\n }\n\n // Pan via mouse drag\n if (this.imageElement) {\n this.imageElement.addEventListener('mousedown', this.boundHandleMouseDown);\n }\n document.addEventListener('mousemove', this.boundHandleMouseMove);\n document.addEventListener('mouseup', this.boundHandleMouseUp);\n\n // Keyboard handler (ESC to close)\n document.addEventListener('keydown', this.boundHandleKeyDown);\n\n // Wrapper click to close (wrapper sits above backdrop in z-index)\n this.wrapper.addEventListener('click', this.boundHandleWrapperClick);\n }\n\n /**\n * Handle mouse wheel for zooming\n * @param {WheelEvent} event\n */\n handleWheel(event) {\n event.preventDefault();\n\n const delta = event.deltaY > 0 ? -1 : 1;\n const newScale = this.scale + delta * this.constructor.ZOOM_STEP;\n\n this.setZoom(newScale);\n }\n\n /**\n * Handle keyboard events\n * @param {KeyboardEvent} event\n */\n handleKeyDown(event) {\n if (event.key === 'Escape') {\n this.close();\n } else if (event.key === '+' || event.key === '=') {\n this.zoomIn();\n } else if (event.key === '-') {\n this.zoomOut();\n } else if (event.key === '0') {\n this.resetZoom();\n }\n }\n\n /**\n * Handle mouse down for pan start\n * @param {MouseEvent} event\n */\n handleMouseDown(event) {\n if (this.scale <= 1) return; // Only pan when zoomed\n\n event.preventDefault();\n this.isPanning = true;\n this.startX = event.clientX - this.panX;\n this.startY = event.clientY - this.panY;\n\n if (this.imageElement) {\n this.imageElement.style.cursor = 'grabbing';\n }\n }\n\n /**\n * Handle mouse move for panning\n * @param {MouseEvent} event\n */\n handleMouseMove(event) {\n if (!this.isPanning) return;\n\n this.panX = event.clientX - this.startX;\n this.panY = event.clientY - this.startY;\n\n this.updateTransform();\n }\n\n /**\n * Handle mouse up for pan end\n */\n handleMouseUp() {\n if (!this.isPanning) return;\n\n this.isPanning = false;\n\n if (this.imageElement) {\n this.imageElement.style.cursor = this.scale > 1 ? 'grab' : 'default';\n }\n }\n\n /**\n * Handle wrapper click \u2014 closes modal when clicking empty space around content.\n * Ignores clicks on modal content and scrollbar clicks.\n * @param {MouseEvent} event\n */\n handleWrapperClick(event) {\n if (event.target !== this.wrapper) return;\n const rect = this.wrapper.getBoundingClientRect();\n if (event.clientX > rect.left + this.wrapper.clientWidth) return;\n if (event.clientY > rect.top + this.wrapper.clientHeight) return;\n this.close();\n }\n\n /**\n * Zoom in by one step\n */\n zoomIn() {\n this.setZoom(this.scale + this.constructor.ZOOM_STEP);\n }\n\n /**\n * Zoom out by one step\n */\n zoomOut() {\n this.setZoom(this.scale - this.constructor.ZOOM_STEP);\n }\n\n /**\n * Reset zoom to 1x\n */\n resetZoom() {\n this.scale = 1;\n this.panX = 0;\n this.panY = 0;\n this.updateTransform();\n this.updateZoomLevel();\n }\n\n /**\n * Set zoom level with constraints\n * @param {number} newScale - The new zoom level\n */\n setZoom(newScale) {\n this.scale = Math.min(\n Math.max(this.constructor.MIN_ZOOM, newScale),\n this.constructor.MAX_ZOOM,\n );\n\n // Reset pan if zoomed out to 1x\n if (this.scale === 1) {\n this.panX = 0;\n this.panY = 0;\n }\n\n this.updateTransform();\n this.updateZoomLevel();\n }\n\n /**\n * Update the CSS transform on the image\n */\n updateTransform() {\n if (!this.imageElement) return;\n\n this.imageElement.style.transform = `scale(${this.scale}) translate(${this.panX / this.scale}px, ${this.panY / this.scale}px)`;\n this.imageElement.style.cursor = this.scale > 1 ? (this.isPanning ? 'grabbing' : 'grab') : 'default';\n }\n\n /**\n * Update the zoom level display\n */\n updateZoomLevel() {\n if (!this.zoomLevelElement) return;\n\n this.zoomLevelElement.textContent = `${Math.round(this.scale * 100)}%`;\n }\n\n /**\n * Handle image load errors (timeout or network failure)\n * @param {string} message - The error message to display\n */\n handleImageError(message) {\n // Hide spinner\n if (this.spinnerElement) {\n this.spinnerElement.style.display = 'none';\n }\n\n // Hide the broken image\n if (this.imageElement) {\n this.imageElement.style.display = 'none';\n }\n\n // Show error message in modal\n const errorDiv = document.createElement('div');\n errorDiv.className = 'image-zoom-modal-error';\n errorDiv.style.cssText = 'color:#333;text-align:center;padding:40px 20px;font-size:16px;';\n errorDiv.textContent = message;\n\n if (this.containerElement) {\n this.containerElement.appendChild(errorDiv);\n }\n }\n\n /**\n * Close the modal\n */\n close() {\n this.cleanup();\n\n // Remove DOM elements\n if (this.backdrop && this.backdrop.parentNode) {\n this.backdrop.parentNode.removeChild(this.backdrop);\n }\n if (this.wrapper && this.wrapper.parentNode) {\n this.wrapper.parentNode.removeChild(this.wrapper);\n }\n\n // Remove body class\n document.body.classList.remove('mfp-open');\n\n // Dispatch close event\n const closeEvent = new CustomEvent('image-zoom-modal:closed', {\n bubbles: true,\n cancelable: false,\n });\n document.dispatchEvent(closeEvent);\n\n // Clear references\n this.backdrop = null;\n this.wrapper = null;\n this.imageElement = null;\n this.zoomLevelElement = null;\n this.containerElement = null;\n this.spinnerElement = null;\n }\n\n /**\n * Clean up event listeners and timers\n */\n cleanup() {\n // Clear load timeout if still pending\n if (this.loadTimeout) {\n clearTimeout(this.loadTimeout);\n this.loadTimeout = null;\n }\n\n if (this.containerElement) {\n this.containerElement.removeEventListener('wheel', this.boundHandleWheel);\n }\n\n if (this.imageElement) {\n this.imageElement.removeEventListener('mousedown', this.boundHandleMouseDown);\n }\n\n document.removeEventListener('mousemove', this.boundHandleMouseMove);\n document.removeEventListener('mouseup', this.boundHandleMouseUp);\n document.removeEventListener('keydown', this.boundHandleKeyDown);\n\n if (this.wrapper) {\n this.wrapper.removeEventListener('click', this.boundHandleWrapperClick);\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\nimport ImageZoomModalController from './image_zoom_modal_controller';\n\n/**\n * Image Zoom Controller\n *\n * This controller provides two types of zoom functionality for product images:\n * 1. A magnifying glass effect that follows the cursor when hovering over an image\n * 2. A modal popup view when clicking on the image with zoom/pan capabilities\n *\n * Features:\n * - Circular magnifying glass zoom that follows the mouse\n * - High zoom level (10x) for detailed product inspection\n * - Modal popup with zoom (1x-5x) and pan for full-screen viewing\n * - Smooth animations for both zoom and modal\n * - Mobile-friendly interactions\n *\n * Usage:\n *
    image-zoom#handleZoom\n * mouseenter->image-zoom#mouseEnter\n * mouseleave->image-zoom#mouseLeave\n * click->image-zoom#openLightbox\">\n * \"Product\n *
    \n *
    \n */\nexport default class extends Controller {\n static targets = ['image', 'zoomContainer'];\n\n connect() {\n // Initialize state flags\n this._popupInitialized = false;\n this._isOpening = false; // Prevent multiple rapid clicks\n this._imageObserver = null;\n\n // Check if zoom container exists (it should from the template)\n if (!this.hasZoomContainerTarget) {\n // Zoom container not found\n }\n\n // Set up based on current image state\n this.setupZoomFunctionality();\n\n // Watch for image changes to handle transitions\n this.observeImageChanges();\n }\n\n /**\n * Set up zoom functionality based on whether we have a real image\n */\n setupZoomFunctionality() {\n // Use Stimulus 1.1.0 compatible data access - check data-has-image attribute\n const hasImageValue = this.data.get('has-image') || this.element.getAttribute('data-has-image');\n const hasRealImage = hasImageValue === 'true' &&\n this.hasImageTarget &&\n !this.imageTarget.src.includes('/image_not_available.jpg');\n\n if (hasRealImage) {\n // Enable zoom\n if (this.hasZoomContainerTarget) {\n this.zoomContainerTarget.style.display = 'none'; // Hidden by default\n }\n this._zoomEnabled = true;\n } else {\n // Disable zoom\n if (this.hasZoomContainerTarget) {\n this.zoomContainerTarget.style.display = 'none';\n }\n this._zoomEnabled = false;\n }\n }\n\n /**\n * Observe image src changes to handle transitions from placeholder to real image\n */\n observeImageChanges() {\n if (!this.hasImageTarget) return;\n\n // Create observer to watch for src changes if MutationObserver is available\n const MutationObserverClass = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver;\n\n if (MutationObserverClass) {\n this._imageObserver = new MutationObserverClass((mutations) => {\n mutations.forEach((mutation) => {\n if (mutation.type === 'attributes' && mutation.attributeName === 'src') {\n this.setupZoomFunctionality();\n }\n });\n });\n } else {\n // Fallback: periodically check for src changes\n this._imageObserver = {\n observe: () => {\n if (this.hasImageTarget) {\n this._lastImageSrc = this.imageTarget.src;\n this._imageCheckInterval = setInterval(() => {\n if (this.hasImageTarget && this.imageTarget.src !== this._lastImageSrc) {\n this._lastImageSrc = this.imageTarget.src;\n this.setupZoomFunctionality();\n }\n }, 1000);\n }\n },\n disconnect: () => {\n if (this._imageCheckInterval) {\n clearInterval(this._imageCheckInterval);\n this._imageCheckInterval = null;\n }\n },\n };\n }\n\n // Start observing\n this._imageObserver.observe(this.imageTarget, {\n attributes: true,\n attributeFilter: ['src'],\n });\n }\n\n /**\n * Handle data attribute changes (for when the whole gallery is refreshed)\n * Note: Stimulus 1.1.0 doesn't support automatic value change callbacks\n * Call this manually when data attributes change\n */\n hasImageChanged() {\n this.setupZoomFunctionality();\n }\n\n disconnect() {\n // Clean up observer\n if (this._imageObserver) {\n this._imageObserver.disconnect();\n this._imageObserver = null;\n }\n\n // Close any open popup by triggering close event\n if (this._popupInitialized) {\n this.closePopup();\n }\n\n // Reset state\n this._popupInitialized = false;\n this._isOpening = false;\n this._zoomEnabled = false;\n }\n\n\n // Handle magnifying glass effect\n handleZoom(event) {\n // Check if zoom is enabled\n if (!this._zoomEnabled || !this.hasZoomContainerTarget) {\n return;\n }\n\n const { left: imgX, top: imgY, width: imgWidth, height: imgHeight } =\n this.imageTarget.getBoundingClientRect();\n\n // Calculate mouse position relative to the image\n const x = event.clientX - imgX;\n const y = event.clientY - imgY;\n\n // Only show magnifying effect when mouse is over the image\n if (x < 0 || x > imgWidth || y < 0 || y > imgHeight) {\n this.zoomContainerTarget.style.display = 'none';\n return;\n }\n\n // Calculate zoom position\n const xPercent = (x / imgWidth) * 100;\n const yPercent = (y / imgHeight) * 100;\n\n // Position zoom container near cursor\n this.zoomContainerTarget.style.display = 'block';\n this.zoomContainerTarget.style.backgroundImage = `url('${this.imageTarget.src}')`;\n this.zoomContainerTarget.style.backgroundPosition = `${xPercent}% ${yPercent}%`;\n this.zoomContainerTarget.style.left = `${event.clientX + 20}px`;\n this.zoomContainerTarget.style.top = `${event.clientY - 80}px`;\n }\n\n // Mouse entered the image area\n mouseEnter() {\n if (!this._zoomEnabled) {\n return;\n }\n this.imageTarget.style.cursor = 'zoom-in';\n }\n\n // Mouse left the image area\n mouseLeave() {\n if (this.hasZoomContainerTarget) {\n this.zoomContainerTarget.style.display = 'none';\n }\n }\n\n // Open large image in modal when clicked\n openLightbox(event) {\n event.preventDefault();\n\n // Check if zoom is enabled\n if (!this._zoomEnabled) {\n return false;\n }\n\n // Prevent multiple rapid clicks\n if (this._isOpening) {\n return false;\n }\n\n // Set opening flag\n this._isOpening = true;\n\n // Open the zoom modal with the original (larger) image if available\n const originalUrl = this.imageTarget.dataset.originalUrl || this.imageTarget.src;\n this._modalInstance = ImageZoomModalController.open(\n originalUrl,\n this.imageTarget.alt,\n );\n\n // Listen for modal close event to cleanup\n const closeHandler = () => {\n this._popupInitialized = false;\n this._modalInstance = null;\n document.removeEventListener('image-zoom-modal:closed', closeHandler);\n };\n document.addEventListener('image-zoom-modal:closed', closeHandler);\n\n this._popupInitialized = true;\n this._isOpening = false;\n\n return false;\n }\n\n /**\n * Close the modal if it's open\n */\n closePopup() {\n if (this._modalInstance) {\n this._modalInstance.close();\n this._modalInstance = null;\n }\n this._popupInitialized = false;\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n static targets = ['input'];\n\n increment(event) {\n event.preventDefault();\n if (this.hasInputTarget) {\n const currentValue = parseInt(this.inputTarget.value) || 0;\n this.inputTarget.value = currentValue + 1;\n\n // Trigger change event for any listeners\n const changeEvent = new Event('change', { bubbles: true });\n this.inputTarget.dispatchEvent(changeEvent);\n }\n }\n\n decrement(event) {\n event.preventDefault();\n if (this.hasInputTarget) {\n const currentValue = parseInt(this.inputTarget.value) || 1;\n const minValue = parseInt(this.inputTarget.min) || 1;\n\n if (currentValue > minValue) {\n this.inputTarget.value = currentValue - 1;\n\n // Trigger change event for any listeners\n const changeEvent = new Event('change', { bubbles: true });\n this.inputTarget.dispatchEvent(changeEvent);\n }\n }\n }\n}\n\n", "import { Controller } from '../stimulus';\n\n/**\n * Infinite Scroll Controller\n *\n * Adds infinite scrolling functionality to list views by automatically\n * loading more content when the user scrolls to the bottom of the page.\n *\n * Required data attributes:\n * - data-url: Base URL for pagination (e.g., \"/teamsites/page/\")\n * - data-container: CSS selector for the container to append items to\n * - data-total-pages: Total number of pages available\n *\n * Optional data attributes:\n * - data-page: Initial page number (default: 1)\n * - data-throttle: Throttle delay in milliseconds (default: 200)\n *\n * Required target:\n * - data-target=\"infinite-scroll.nextPage\": Element that triggers loading when scrolled into view\n *\n * @example\n *
    \n *
    \n * \n *
    \n *
    \n *
    \n *\n * @see /doc/infinite_scroll_controller.md for detailed documentation\n */\nexport default class extends Controller {\n static targets = ['nextPage'];\n\n initialize() {\n this.loadMore = this.loadMore.bind(this);\n this.isLoading = false;\n this.throttleTimer = null;\n\n this.observer = new IntersectionObserver(entries => {\n entries.forEach(entry => {\n const page = parseInt(this.element.dataset.page, 10);\n const totalPages = parseInt(this.element.dataset.totalPages, 10);\n\n if (page >= totalPages) {\n this.observer.unobserve(this.nextPageTarget);\n return;\n }\n\n if (entry.isIntersecting && !this.isLoading) {\n // Throttle the loadMore call\n clearTimeout(this.throttleTimer);\n this.throttleTimer = setTimeout(() => {\n this.loadMore();\n }, parseInt(this.element.dataset.throttle || 200, 10));\n }\n });\n });\n }\n\n connect() {\n if (this.hasNextPageTarget) {\n this.observer.observe(this.nextPageTarget);\n } else {\n console.warn('InfiniteScroll: nextPageTarget not found');\n }\n }\n\n disconnect() {\n if (this.hasNextPageTarget) {\n this.observer.unobserve(this.nextPageTarget);\n }\n clearTimeout(this.throttleTimer);\n }\n\n loadMore() {\n const nextPage = parseInt(this.element.dataset.page, 10) + 1;\n const totalPages = parseInt(this.element.dataset.totalPages, 10);\n\n if (nextPage > totalPages || this.isLoading) {\n return;\n }\n\n this.isLoading = true;\n // Fix the URL format to properly handle both base URLs with and without query parameters\n const baseUrl = this.element.dataset.url;\n const url = baseUrl.includes('?')\n ? `${baseUrl}&page=${nextPage}`\n : `${baseUrl}/${nextPage}`;\n\n fetch(url)\n .then(response => {\n if (!response.ok) {\n throw new Error(`HTTP error! status: ${response.status}`);\n }\n return response.text();\n })\n .then(html => {\n const container = this.element.querySelector(this.element.dataset.container);\n if (container) {\n container.insertAdjacentHTML('beforeend', html);\n this.element.dataset.page = nextPage;\n\n // If we've reached the last page, disconnect the observer\n if (nextPage >= totalPages) {\n this.observer.unobserve(this.nextPageTarget);\n }\n } else {\n // InfiniteScroll: Container selector not found\n }\n })\n .catch(error => {\n // InfiniteScroll: Error loading more items\n })\n .finally(() => {\n this.isLoading = false;\n });\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n static targets = ['input'];\n\n blur(event) {\n // Remove focus highlight when input loses focus\n const input = event.target;\n input.parentElement.classList.remove('ae-field-focus');\n\n // Remove the data-selected-all attribute when input loses focus\n if (input.hasAttribute('data-selected-all')) {\n input.removeAttribute('data-selected-all');\n }\n }\n\n click(event) {\n // Add focus highlight when input field is clicked\n const input = event.target;\n input.parentElement.classList.add('ae-field-focus');\n\n // Select all text in the input if not already selected\n if (!input.hasAttribute('data-selected-all')) {\n try {\n // Modern browsers\n input.setSelectionRange(0, input.value.length);\n input.setAttribute('data-selected-all', 'true');\n } catch {\n // Fallback for older browsers\n input.select();\n input.setAttribute('data-selected-all', 'true');\n }\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n static targets = ['type', 'btnText', 'input'];\n\n connect() {\n // Initialize button text based on current type value\n this.updateButtonText();\n }\n\n toggle(event) {\n event.preventDefault();\n event.stopPropagation();\n\n const currentType = this.typeTarget.value;\n const newType = currentType === 'percentage' ? 'fixed' : 'percentage';\n\n // Update the hidden field\n this.typeTarget.value = newType;\n\n // Update the button text\n this.updateButtonText();\n }\n\n updateButtonText() {\n if (this.hasBtnTextTarget && this.hasTypeTarget) {\n const type = this.typeTarget.value;\n this.btnTextTarget.textContent = type === 'percentage' ? '%' : '$';\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\n/**\n * Lazy Image Controller\n *\n * Defers image loading until the element is near the viewport.\n * Uses IntersectionObserver for efficient scroll detection.\n *\n * Data attributes:\n * data-lazy-image-placeholder-url: URL for placeholder image (optional)\n * data-lazy-image-actual-url: The actual image URL to load when visible\n *\n * Works in conjunction with product-image-fallback controller:\n * When element becomes visible, triggers the fallback controller to start loading\n *\n * Stimulus 1.1.0 compatible - uses data attributes (NOT values API)\n */\nexport default class extends Controller {\n connect() {\n // Check if IntersectionObserver is supported\n if ('IntersectionObserver' in window) {\n this.setupIntersectionObserver();\n } else {\n // Fallback for older browsers: load immediately\n this.loadImage();\n }\n }\n\n disconnect() {\n // Clean up IntersectionObserver\n if (this.observer) {\n this.observer.disconnect();\n this.observer = null;\n }\n }\n\n setupIntersectionObserver() {\n // Create observer with rootMargin to start loading before element is visible\n // This provides a smoother user experience\n this.observer = new IntersectionObserver(\n this.handleIntersection.bind(this),\n {\n rootMargin: '100px 0px', // Start loading when within 100px of viewport\n threshold: 0.01, // Trigger when at least 1% visible\n },\n );\n\n this.observer.observe(this.element);\n }\n\n handleIntersection(entries) {\n entries.forEach(entry => {\n if (entry.isIntersecting) {\n this.loadImage();\n // Stop observing after loading starts\n this.observer.unobserve(this.element);\n }\n });\n }\n\n loadImage() {\n // Get the actual image URL from data attribute\n const actualUrl = this.element.getAttribute('data-lazy-image-actual-url');\n\n if (actualUrl) {\n // Update the background-image to the actual URL\n this.element.style.backgroundImage = `url(${actualUrl})`;\n }\n\n // Try to get the product-image-fallback controller on the same element\n // and trigger its image loading/verification\n const fallbackController = this.application.getControllerForElementAndIdentifier(\n this.element,\n 'product-image-fallback',\n );\n\n if (fallbackController && typeof fallbackController.tryCurrentUrl === 'function') {\n fallbackController.tryCurrentUrl();\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n connect() {\n this.isCloning = false;\n this.setupFilterHandler();\n }\n\n setupFilterHandler() {\n // Direct DOM wiring for AJAX modal timing (same pattern as ajax_popup_controller.js:490)\n const filterInput = this.element.querySelector('[data-target=\"library-product-clone.input\"]');\n if (!filterInput) return;\n\n if (filterInput.dataset.libraryFilterHandler === 'true') return;\n filterInput.dataset.libraryFilterHandler = 'true';\n\n const productItems = this.element.querySelectorAll('[data-target=\"library-product-clone.productItem\"]');\n const emptyState = this.element.querySelector('[data-target=\"library-product-clone.emptyState\"]');\n\n let debounceTimeout = null;\n let lastSearchTerm = '';\n\n filterInput.addEventListener('input', () => {\n if (debounceTimeout) clearTimeout(debounceTimeout);\n debounceTimeout = setTimeout(() => {\n const searchTerm = filterInput.value.toLowerCase().trim();\n if (searchTerm === lastSearchTerm) return;\n lastSearchTerm = searchTerm;\n let visibleCount = 0;\n\n productItems.forEach(item => {\n const productName = item.dataset.productName || '';\n const matches = productName.includes(searchTerm) || searchTerm === '';\n if (matches) {\n item.style.removeProperty('display');\n visibleCount++;\n } else {\n item.style.setProperty('display', 'none', 'important');\n }\n });\n\n if (emptyState) {\n emptyState.style.display = visibleCount === 0 ? 'block' : 'none';\n }\n }, 150);\n });\n }\n\n cloneProduct(event) {\n event.preventDefault();\n event.stopPropagation();\n\n if (this.isCloning) return;\n\n const button = event.currentTarget || event.target.closest('button');\n const productId = button.dataset.productId;\n const saleId = this.element.dataset.saleId;\n\n if (!productId || !saleId) return;\n\n this.isCloning = true;\n\n const url = `/teamsites/${saleId}/products/${productId}/clone`;\n const csrfToken = document.querySelector('meta[name=\"csrf-token\"]');\n\n fetch(url, {\n method: 'POST',\n headers: {\n 'X-CSRF-Token': csrfToken ? csrfToken.content : '',\n 'Accept': 'application/json',\n 'Content-Type': 'application/json',\n },\n credentials: 'same-origin',\n })\n .then(response => {\n if (!response.ok) {\n throw new Error('Clone failed');\n }\n return response.json();\n })\n .then(data => {\n if (data.message && window.App && typeof window.App.showSuccessFlash === 'function') {\n window.App.showSuccessFlash(data.message);\n }\n\n if (data.sale_id && data.product_id) {\n window.location.href = `/teamsites/${data.sale_id}/products/${data.product_id}/edit?refresh=${Date.now()}`;\n }\n })\n .catch(() => {\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash('Failed to add product from library. Please try again.');\n }\n })\n .finally(() => {\n this.isCloning = false;\n });\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n remove(event) {\n event.preventDefault();\n event.stopPropagation();\n\n const productId = this.element.dataset.productId;\n if (!productId) return;\n\n const url = `/library/products/${productId}`;\n const csrfToken = document.querySelector('meta[name=\"csrf-token\"]');\n\n fetch(url, {\n method: 'DELETE',\n headers: {\n 'X-CSRF-Token': csrfToken ? csrfToken.content : '',\n 'Accept': 'application/json',\n },\n credentials: 'same-origin',\n })\n .then(response => {\n if (!response.ok) {\n throw new Error('Remove failed');\n }\n return response.json();\n })\n .then(data => {\n if (data.message && window.App && typeof window.App.showSuccessFlash === 'function') {\n window.App.showSuccessFlash(data.message);\n }\n\n // Remove the product card from the DOM\n const productCard = this.element.closest('.product-list-item');\n if (productCard) {\n productCard.remove();\n }\n })\n .catch(() => {\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash('Failed to remove product from library. Please try again.');\n }\n });\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n connect() {\n this.highlightRecentlyAddedItem();\n }\n\n highlightRecentlyAddedItem() {\n // Check if there's a recently added line item to highlight\n const lineItemId = sessionStorage.getItem('recently_added_line_item_id');\n\n if (!lineItemId) {\n return;\n }\n\n // Find the line item row\n const rowId = `line-item-row-${lineItemId}`;\n const row = document.getElementById(rowId);\n\n if (!row) {\n console.warn('[LineItemHighlight] Line item row not found:', rowId);\n sessionStorage.removeItem('recently_added_line_item_id');\n return;\n }\n\n console.log('[LineItemHighlight] Highlighting newly added line item:', lineItemId);\n\n // Add highlight animation class\n row.classList.add('highlight-and-fade');\n\n // Scroll item into view with smooth animation\n row.scrollIntoView({\n behavior: 'smooth',\n block: 'center',\n });\n\n // Clear the sessionStorage key\n sessionStorage.removeItem('recently_added_line_item_id');\n\n // Optional: Remove the animation class after it completes (2.5s)\n // This allows the animation to be triggered again if needed\n setTimeout(() => {\n row.classList.remove('highlight-and-fade');\n }, 2500);\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n static get targets() {\n return ['quantityInput', 'sizeSelect', 'subtotal'];\n }\n\n initialize() {\n this.originalPrice = this.getUnitPrice();\n }\n\n disconnect() {\n // Clean up any references or event listeners\n }\n\n incrementQuantity(event) {\n event.preventDefault();\n if (!this.hasQuantityInputTarget) {\n return;\n }\n const input = this.quantityInputTarget;\n const currentValue = parseInt(input.value) || 0;\n const newValue = Math.min(currentValue + 1, 999);\n input.value = newValue;\n\n // Manually dispatch change event for form-level change detection\n // Programmatic value changes don't fire events automatically\n input.dispatchEvent(new Event('change', { bubbles: true }));\n\n this.updateSubtotal();\n }\n\n decrementQuantity(event) {\n event.preventDefault();\n if (!this.hasQuantityInputTarget) {\n return;\n }\n const input = this.quantityInputTarget;\n const currentValue = parseInt(input.value) || 0;\n const newValue = Math.max(currentValue - 1, 1);\n input.value = newValue;\n\n // Manually dispatch change event for form-level change detection\n // Programmatic value changes don't fire events automatically\n input.dispatchEvent(new Event('change', { bubbles: true }));\n\n this.updateSubtotal();\n }\n\n updateQuantity(event) {\n const input = event.target;\n const originalValue = parseInt(input.value) || 1;\n let value = originalValue;\n\n // Enforce bounds\n if (value < 1) value = 1;\n if (value > 999) value = 999;\n\n input.value = value;\n\n // Only dispatch change event if value was adjusted by bounds enforcement\n // Otherwise the natural change event from user input is sufficient\n if (value !== originalValue) {\n input.dispatchEvent(new Event('change', { bubbles: true }));\n }\n\n this.updateSubtotal();\n }\n\n updateSize(event) {\n const newSize = event.target.value;\n if (!newSize) return;\n\n // Navigate to the product container for color-sizes JSON\n const container = this.element.closest('.product-container');\n if (!container) {\n this.updateSubtotal();\n return;\n }\n\n const jsonScript = container.querySelector('script.color-sizes-data');\n if (!jsonScript) {\n this.updateSubtotal();\n return;\n }\n\n let colorData;\n try {\n colorData = JSON.parse(jsonScript.textContent);\n } catch (err) { // eslint-disable-line no-unused-vars\n this.updateSubtotal();\n return;\n }\n\n // Empty JSON means no variant mapping available\n if (!colorData || Object.keys(colorData).length === 0) {\n this.updateSubtotal();\n return;\n }\n\n // Determine current color: read from color-change select, or use sole key\n let currentColor;\n const colorSelect = container.querySelector('.color-change-select');\n if (colorSelect) {\n currentColor = colorSelect.value;\n } else {\n const colorKeys = Object.keys(colorData);\n if (colorKeys.length === 1) {\n currentColor = colorKeys[0];\n } else {\n this.updateSubtotal();\n return;\n }\n }\n\n const targetColorData = colorData[currentColor];\n if (!targetColorData || !targetColorData.sizes) {\n this.updateSubtotal();\n return;\n }\n\n // Find the variant matching the new size\n let sizeData = null;\n for (let i = 0; i < targetColorData.sizes.length; i++) {\n if (targetColorData.sizes[i].size === newSize) {\n sizeData = targetColorData.sizes[i];\n break;\n }\n }\n\n if (!sizeData) {\n this.updateSubtotal();\n return;\n }\n\n // Update the hidden variant_id input\n const variantIdInput = this.element.querySelector('input[name*=\"[variant_id]\"]');\n if (variantIdInput) {\n variantIdInput.value = sizeData.variant_id;\n }\n\n // Update price (dollars from JSON, converted to cents) and subtotal display\n this.originalPrice = Math.round(sizeData.price * 100);\n this.updateSubtotal();\n\n // Also update the total column (separate from subtotalTarget which is the per-unit column).\n // updateSubtotal() handles subtotalTarget; this handles .total-column (qty \u00D7 price).\n if (this.hasQuantityInputTarget && this.hasSubtotalTarget) {\n const currency = (container && container.getAttribute('data-currency')) || 'USD';\n const totalEl = this.element.querySelector('.total-column .product-price');\n if (totalEl) {\n const quantity = parseInt(this.quantityInputTarget.value) || 1;\n const total = sizeData.price * quantity;\n const formatter = new Intl.NumberFormat('en-US', {\n style: 'currency',\n currency: currency,\n });\n totalEl.textContent = formatter.format(total);\n }\n }\n\n // Trigger form-level change event so Save button enables\n const form = container.closest('form');\n if (form) {\n form.dispatchEvent(new Event('input', { bubbles: true }));\n }\n }\n\n updateSubtotal() {\n if (!this.hasSubtotalTarget || !this.hasQuantityInputTarget) {\n return;\n }\n\n const container = this.element.closest('.product-container');\n const currency = (container && container.getAttribute('data-currency')) || 'USD';\n const quantity = parseInt(this.quantityInputTarget.value) || 1;\n const newTotal = this.originalPrice * quantity;\n\n const formatter = new Intl.NumberFormat('en-US', {\n style: 'currency',\n currency: currency,\n });\n\n this.subtotalTarget.textContent = formatter.format(newTotal / 100); // Convert cents to dollars\n }\n\n getUnitPrice() {\n // Extract the original unit price from the subtotal\n // This assumes the subtotal shows the current total amount\n if (!this.hasSubtotalTarget || !this.hasQuantityInputTarget) {\n return 0;\n }\n\n const subtotalText = this.subtotalTarget.textContent.replace(/[$,]/g, '');\n const currentTotal = parseFloat(subtotalText) * 100; // Convert to cents\n const currentQuantity = parseInt(this.quantityInputTarget.value) || 1;\n\n return Math.round(currentTotal / currentQuantity);\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n static get targets() {\n return ['variantSelect', 'colorSelect', 'quantityInput', 'subtotalDisplay', 'errorContainer', 'errorList', 'submitButton', 'variantData'];\n }\n\n connect() {\n // Parse variant data if available\n if (this.hasVariantDataTarget) {\n try {\n this.variantsByColor = JSON.parse(this.variantDataTarget.textContent);\n } catch (e) {\n this.variantsByColor = {};\n }\n }\n\n this.updateSubtotal();\n this.validateForm();\n }\n\n disconnect() {\n }\n\n selectColor() {\n const colorSelect = this.colorSelectTarget;\n const selectedColor = colorSelect.value || colorSelect.textContent;\n\n if (!selectedColor || !this.variantsByColor) {\n this.variantSelectTarget.innerHTML = '';\n this.variantSelectTarget.disabled = true;\n this.updateSubtotal();\n return;\n }\n\n const variants = this.variantsByColor[selectedColor] || [];\n\n // Build size options\n let optionsHtml = '';\n variants.forEach(variant => {\n const priceFormatted = new Intl.NumberFormat('en-US', {\n style: 'currency',\n currency: 'USD',\n }).format(variant.price);\n\n optionsHtml += ``;\n });\n\n this.variantSelectTarget.innerHTML = optionsHtml;\n this.variantSelectTarget.disabled = false;\n this.updateSubtotal();\n }\n\n updateSubtotal() {\n const variant = this.variantSelectTarget.selectedOptions[0];\n const price = parseFloat(variant ? variant.dataset.price : 0) || 0;\n const quantity = parseInt(this.quantityInputTarget.value, 10) || 0;\n const subtotal = price * quantity;\n\n this.subtotalDisplayTarget.textContent = new Intl.NumberFormat('en-US', {\n style: 'currency',\n currency: 'USD',\n }).format(subtotal);\n\n this.validateForm();\n }\n\n validateForm() {\n const variantId = this.variantSelectTarget.value;\n const quantity = parseInt(this.quantityInputTarget.value, 10);\n const isValid = variantId && quantity > 0;\n\n this.submitButtonTarget.disabled = !isValid;\n\n if (isValid) {\n this.submitButtonTarget.classList.remove('ae-btn-disabled');\n this.hideErrors();\n } else {\n this.submitButtonTarget.classList.add('ae-btn-disabled');\n }\n }\n\n async submit(event) {\n event.preventDefault();\n\n const variantId = this.variantSelectTarget.value;\n const quantity = this.quantityInputTarget.value;\n\n // Validate inputs\n if (!variantId) {\n this.showErrors(['Please select a size']);\n return;\n }\n\n if (!quantity || parseInt(quantity, 10) <= 0) {\n this.showErrors(['Please enter a quantity greater than 0']);\n return;\n }\n\n // Disable submit button during submission\n this.submitButtonTarget.disabled = true;\n this.submitButtonTarget.textContent = 'Adding...';\n\n const orderNumber = this.element.dataset.lineItemsFormOrderNumber;\n const saleId = this.element.dataset.lineItemsFormSaleId;\n const url = `/teamsites/${saleId}/orders/${orderNumber}/line_items`;\n const csrfToken = document.querySelector('[name=\"csrf-token\"]').content;\n\n const formData = {\n variant_id: variantId,\n quantity: quantity,\n };\n\n try {\n const response = await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'X-CSRF-Token': csrfToken,\n 'X-Requested-With': 'XMLHttpRequest',\n 'Accept': 'application/json',\n },\n body: JSON.stringify(formData),\n });\n\n const data = await response.json();\n\n if (response.ok && data.success) {\n this.showSuccessToast(data.message || 'Product added to order successfully');\n // Store newly added line item ID for highlighting after reload\n if (data.line_item_id) {\n sessionStorage.setItem('recently_added_line_item_id', data.line_item_id);\n }\n this.closeModal();\n this.reloadProductsSection();\n } else {\n console.error('[LineItemsForm] Server returned error:', data);\n this.showErrors(data.errors || ['Failed to add product to order']);\n this.submitButtonTarget.disabled = false;\n this.submitButtonTarget.textContent = 'Add to Order';\n }\n } catch (error) {\n console.error('[LineItemsForm] Failed to submit form:', error);\n this.showErrors(['Network error. Please try again.']);\n this.submitButtonTarget.disabled = false;\n this.submitButtonTarget.textContent = 'Add to Order';\n }\n }\n\n cancel(event) {\n event.preventDefault();\n this.closeModal();\n }\n\n closeModal() {\n const container = document.getElementById('new-line-item-container');\n if (container) {\n container.innerHTML = '';\n }\n }\n\n reloadProductsSection() {\n // Simple page reload to refresh products section\n // In the future, this could be optimized to only reload the products container\n window.location.reload();\n }\n\n showSuccessToast(message) {\n // TODO: Integrate with existing toast/flash system\n // For now, use a simple alert as placeholder\n // alert(message); // Uncomment if needed for testing\n }\n\n showErrors(errors) {\n console.error('[LineItemsForm] Showing errors:', errors);\n\n if (!this.hasErrorContainerTarget || !this.hasErrorListTarget) {\n console.error('[LineItemsForm] Error container not found');\n return;\n }\n\n // Clear existing errors\n this.errorListTarget.innerHTML = '';\n\n // Add new errors\n errors.forEach(error => {\n const li = document.createElement('li');\n li.textContent = error;\n this.errorListTarget.appendChild(li);\n });\n\n // Show error container\n this.errorContainerTarget.style.display = 'block';\n\n // Scroll to top of form to show errors\n this.element.scrollIntoView({ behavior: 'smooth', block: 'start' });\n }\n\n hideErrors() {\n if (this.hasErrorContainerTarget) {\n this.errorContainerTarget.style.display = 'none';\n if (this.hasErrorListTarget) {\n this.errorListTarget.innerHTML = '';\n }\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n click() {\n const orderNumber = this.data.get('order-number');\n if (orderNumber) {\n window.location.href = `/customers/orders/${orderNumber}`;\n }\n }\n}\n", "/* global rgbaster, MutationObserver */\nimport { Controller } from '../stimulus';\nimport NotificationMixin from '../mixins/notification_mixin';\n\nexport default class extends Controller {\n\n connect() {\n // Apply notification mixin\n Object.assign(this, NotificationMixin);\n // Initialize MutationObserver reference\n this.mutationObserver = null;\n\n // Bind methods to maintain the same reference for add/remove\n this.boundHandleLogoUpload = this.handleLogoUpload.bind(this);\n this.boundHandleUploadSuccess = this.handleUploadSuccess.bind(this);\n this.boundHandleLogoChange = this.handleLogoChange.bind(this);\n\n // Listen for dropzone upload success events at document level\n document.addEventListener('dropzone:uploadsuccess', this.boundHandleUploadSuccess);\n\n // Keep the thumbnail listener as fallback (even though it's not currently fired)\n document.addEventListener('dropzone:thumbnail', this.boundHandleLogoUpload);\n\n // Also listen for custom logo change events if needed\n document.addEventListener('logo:uploaded', this.boundHandleLogoChange);\n\n\n // Add backup mechanism - watch for logo images appearing in the DOM\n this.setupLogoWatcher();\n\n // Scan for existing logos on page load\n this.scanForExistingLogos();\n }\n\n setupLogoWatcher() {\n // Watch for uploaded logo images that might appear without proper events\n this.mutationObserver = new MutationObserver((mutations) => {\n mutations.forEach((mutation) => {\n mutation.addedNodes.forEach((node) => {\n if (node.nodeType === Node.ELEMENT_NODE) {\n // Get branding form context to scope our search to TeamSite only\n const brandingForm = this.element.closest('form') || document.querySelector('[data-controller*=\"logo-color-extractor\"]')?.closest('form');\n if (!brandingForm) return;\n\n // Look specifically for TeamSite branding logo images with zoom-thumbnail class\n const logoImages = node.querySelectorAll ?\n node.querySelectorAll('.uploaded-img-container img.zoom-thumbnail[src*=\"branding_logo\"], .uploaded-img-container img.zoom-thumbnail[src*=\"dealers/logos\"]') :\n [];\n\n if (logoImages.length > 0) {\n // Detected TeamSite branding logo via MutationObserver\n // Extract colors from the first found TeamSite branding logo\n const logoSrc = logoImages[0].src;\n if (logoSrc && !logoSrc.includes('placeholder')) {\n // Triggering color extraction for TeamSite branding logo\n this.waitForImageLoad(logoSrc)\n .then(() => this.extractColors(logoSrc))\n .catch(() => {\n // Failed to load image, try anyway\n this.extractColors(logoSrc);\n });\n }\n }\n\n // Also check if the node itself is a TeamSite branding logo image\n if (node.tagName === 'IMG' &&\n node.classList.contains('zoom-thumbnail') &&\n node.closest('.uploaded-img-container') &&\n brandingForm.contains(node) &&\n (node.src.includes('branding_logo') || node.src.includes('dealers/logos')) &&\n !node.src.includes('placeholder')) {\n // Direct TeamSite branding logo image detected\n this.waitForImageLoad(node.src)\n .then(() => this.extractColors(node.src))\n .catch(() => {\n // Failed to load image, try anyway\n this.extractColors(node.src);\n });\n }\n }\n });\n });\n });\n\n // Start observing\n this.mutationObserver.observe(document.body, {\n childList: true,\n subtree: true,\n });\n\n // Logo watcher (MutationObserver) set up\n }\n\n scanForExistingLogos() {\n // Scanning for existing TeamSite branding logos...\n\n // Get branding form context to scope search to TeamSite only\n const brandingForm = this.element.closest('form') || document.querySelector('[data-controller*=\"logo-color-extractor\"]')?.closest('form');\n if (!brandingForm) {\n // No branding form found, skipping scan\n return;\n }\n\n // Specific selector for TeamSite branding logos with zoom-thumbnail class\n const logoSelector = '.uploaded-img-container img.zoom-thumbnail[src*=\"branding_logo\"], .uploaded-img-container img.zoom-thumbnail[src*=\"dealers/logos\"]';\n\n const logos = brandingForm.querySelectorAll(logoSelector);\n logos.forEach(logo => {\n // Only process if it's a TeamSite branding logo URL and not a placeholder\n if (logo.src &&\n (logo.src.includes('branding_logo') || logo.src.includes('dealers/logos')) &&\n !logo.src.includes('placeholder') &&\n !logo.src.includes('data:image')) {\n // Found TeamSite branding logo\n // Ensure image is loaded before extracting colors\n this.waitForImageLoad(logo.src)\n .then(() => this.extractColors(logo.src))\n .catch(() => {\n // Failed to load image, try anyway\n this.extractColors(logo.src);\n });\n }\n });\n }\n\n disconnect() {\n document.removeEventListener('dropzone:uploadsuccess', this.boundHandleUploadSuccess);\n document.removeEventListener('dropzone:thumbnail', this.boundHandleLogoUpload);\n document.removeEventListener('logo:uploaded', this.boundHandleLogoChange);\n\n // Disconnect the MutationObserver to prevent memory leaks\n if (this.mutationObserver) {\n this.mutationObserver.disconnect();\n this.mutationObserver = null;\n }\n }\n\n // Helper method to ensure image is loaded before processing\n waitForImageLoad(imageUrl) {\n return new Promise((resolve, reject) => {\n const img = new window.Image();\n img.onload = () => resolve(imageUrl);\n img.onerror = () => reject(new Error('Failed to load image'));\n img.src = imageUrl;\n });\n }\n\n handleLogoUpload(event) {\n // Logo thumbnail event received\n\n const { file, dataUrl } = event.detail || {};\n if (file && file.accepted !== false && dataUrl) {\n // Valid thumbnail with dataUrl, extracting colors...\n this.extractColors(dataUrl);\n } else {\n // Invalid thumbnail event structure\n }\n }\n\n handleLogoChange(event) {\n const { url } = event.detail;\n if (url) {\n this.extractColors(url);\n }\n }\n\n handleUploadSuccess(event) {\n // Upload success event received\n\n const response = event.detail?.response;\n if (!response) {\n // No response in upload success event\n return;\n }\n\n // Check various possible URL fields in the response\n const imageUrl = response.original_url || response.thumb_url || response.logo_url || response.url;\n\n if (imageUrl) {\n // Found image URL in response\n // Ensure the image is accessible before extracting colors\n this.waitForImageLoad(imageUrl)\n .then(() => this.extractColors(imageUrl))\n .catch(() => {\n // Failed to load image, try anyway\n this.extractColors(imageUrl);\n });\n } else {\n // No image URL found in upload response\n }\n }\n\n extractColors(imageUrl) {\n // Starting color extraction\n\n // Check if rgbaster is available\n if (typeof rgbaster === 'undefined') {\n console.error('[LogoColorExtractor] rgbaster library is not loaded');\n return Promise.resolve();\n }\n\n if (!imageUrl) {\n console.error('[LogoColorExtractor] No image URL provided for color extraction');\n return Promise.resolve();\n }\n\n // Starting rgbaster analysis...\n try {\n // Create a timeout promise\n const timeoutPromise = new Promise((_, reject) => {\n setTimeout(() => reject(new Error('Color extraction timeout')), 10000); // 10 second timeout\n });\n\n // Create the rgbaster promise\n const rgbasterPromise = rgbaster(imageUrl, { ignore: ['rgb(255,255,255)'] });\n\n // Race between timeout and rgbaster\n return Promise.race([rgbasterPromise, timeoutPromise]).then(result => {\n // Rgbaster analysis complete\n\n if (!result || !Array.isArray(result) || result.length === 0) {\n console.warn('[LogoColorExtractor] No colors found in result');\n return;\n }\n\n let primaryColor = null;\n let secondaryColor = null;\n\n // Set colors using direct jQuery selectors (like baseline)\n if (result[0] && result[0].color) {\n secondaryColor = result[0].color;\n this.setSecondaryColorHex(secondaryColor);\n }\n if (result[1] && result[1].color) {\n primaryColor = result[1].color;\n this.setPrimaryColorHex(primaryColor);\n }\n\n // Auto-save the extracted colors\n if (primaryColor || secondaryColor) {\n this.saveExtractedColors(primaryColor, secondaryColor);\n }\n }).catch(error => {\n console.error('[LogoColorExtractor] Error extracting colors from logo:', error);\n if (error.message === 'Color extraction timeout') {\n this.showErrorNotification('Color extraction is taking too long. Please try again with a smaller image.');\n }\n });\n } catch {\n // Error calling rgbaster\n return Promise.resolve();\n }\n }\n\n // Simple color setting methods like baseline\n setPrimaryColorHex(hex) {\n $('#dealers_sites_branding_primary_color').spectrum('set', hex);\n }\n\n setSecondaryColorHex(hex) {\n $('#dealers_sites_branding_secondary_color').spectrum('set', hex);\n }\n\n saveExtractedColors(primaryColor, secondaryColor) {\n // Auto-saving extracted colors\n\n // Get the form element\n const form = this.element.closest('form');\n if (!form) {\n // Could not find branding form\n return Promise.resolve();\n }\n\n // Get sale ID from form action\n const formAction = form.getAttribute('action');\n const saleIdMatch = formAction.match(/\\/teamsites\\/(\\d+)\\/branding/);\n if (!saleIdMatch) {\n // Could not extract sale ID from form action\n return Promise.resolve();\n }\n const saleId = saleIdMatch[1];\n\n // Validate color values\n const validatedPrimaryColor = this.validateColor(primaryColor);\n const validatedSecondaryColor = this.validateColor(secondaryColor);\n\n if (!validatedPrimaryColor && !validatedSecondaryColor) {\n // No valid colors to save\n return Promise.resolve();\n }\n\n // Prepare form data\n const formData = new FormData();\n\n // Get CSRF token\n const csrfToken = document.querySelector('meta[name=\"csrf-token\"]')?.getAttribute('content');\n if (csrfToken) {\n formData.append('authenticity_token', csrfToken);\n }\n\n // Add validated color values if they exist\n if (validatedPrimaryColor) {\n formData.append('dealers_sites_branding[primary_color]', validatedPrimaryColor);\n }\n if (validatedSecondaryColor) {\n formData.append('dealers_sites_branding[secondary_color]', validatedSecondaryColor);\n }\n\n // Send AJAX request to save colors with retry logic\n const saveColors = (retryCount = 0) => {\n return fetch(`/teamsites/${saleId}/branding`, {\n method: 'POST',\n headers: {\n 'X-CSRF-Token': csrfToken,\n 'X-Requested-With': 'XMLHttpRequest',\n 'Accept': 'application/javascript',\n },\n body: formData,\n })\n .then(response => {\n if (response.ok) {\n // Colors saved successfully\n // Optionally show a success message to user\n this.showSaveNotification('Colors extracted and saved successfully');\n } else if (response.status === 422) {\n // Handle validation errors\n return response.json().then(data => {\n const errorMessage = data.error || 'Invalid color values';\n // Validation error\n this.showErrorNotification(`Failed to save colors: ${errorMessage}`);\n });\n } else if (response.status >= 500 && retryCount < 2) {\n // Retry server errors up to 2 times\n // Server error, retrying...\n return new Promise(resolve => {\n setTimeout(() => resolve(saveColors(retryCount + 1)), 1000 * (retryCount + 1));\n });\n } else {\n // Failed to save colors\n this.showErrorNotification('Failed to save colors. Please try again.');\n }\n })\n .catch(_error => {\n // Network error saving colors\n if (retryCount < 2) {\n // Network error, retrying...\n return new Promise(resolve => {\n setTimeout(() => resolve(saveColors(retryCount + 1)), 1000 * (retryCount + 1));\n });\n } else {\n this.showErrorNotification('Network error: Unable to save colors. Please check your connection.');\n }\n });\n };\n\n return saveColors();\n }\n\n\n validateColor(color) {\n if (!color) return null;\n\n // Trim whitespace\n const trimmedColor = color.trim();\n\n // Validate hex color format (#RGB or #RRGGBB)\n const hexPattern = /^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/;\n if (hexPattern.test(trimmedColor)) {\n return trimmedColor;\n }\n\n // Validate rgb/rgba format\n const rgbPattern = /^rgba?\\(\\s*(\\d{1,3})\\s*,\\s*(\\d{1,3})\\s*,\\s*(\\d{1,3})\\s*(?:,\\s*([01]?\\.?\\d*))?\\s*\\)$/;\n const rgbMatch = trimmedColor.match(rgbPattern);\n if (rgbMatch) {\n const r = parseInt(rgbMatch[1]);\n const g = parseInt(rgbMatch[2]);\n const b = parseInt(rgbMatch[3]);\n\n // Validate RGB values are in range\n if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255) {\n // Convert to hex format for consistency\n const toHex = (n) => n.toString(16).padStart(2, '0');\n return `#${toHex(r)}${toHex(g)}${toHex(b)}`;\n }\n }\n\n // Invalid color format\n return null;\n }\n\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n connect() {\n // Store bound methods to properly remove event listeners later\n this.boundHandleSuccess = this.handleSuccess.bind(this);\n this.boundHandleError = this.handleError.bind(this);\n\n this.element.addEventListener('dropzone:uploadsuccess', this.boundHandleSuccess);\n this.element.addEventListener('dropzone:uploaderror', this.boundHandleError);\n }\n\n disconnect() {\n this.element.removeEventListener('dropzone:uploadsuccess', this.boundHandleSuccess);\n this.element.removeEventListener('dropzone:uploaderror', this.boundHandleError);\n }\n\n handleSuccess(event) {\n // Flash message is now handled by dropzone_controller.js for single-file uploads\n // This controller only handles DOM updates\n if (!event.detail) return;\n\n const { response } = event.detail;\n\n if (response && response.thumb_url) {\n // Update the logo display dynamically without reloading\n this.updateLogoDisplay(response);\n }\n }\n\n updateLogoDisplay(response) {\n // Extract sale_id from response or current URL as fallback\n let saleId = response.sale_id;\n if (!saleId) {\n const pathMatch = window.location.pathname.match(/\\/teamsites\\/(\\d+)/);\n saleId = pathMatch ? pathMatch[1] : '';\n }\n\n // Find the logo field container\n const logoField = this.element.closest('.ae-form-field') || this.element.closest('[data-controller*=\"logo-color-extractor\"]')?.closest('.ae-form-field');\n if (!logoField) return;\n\n // Look for existing logo display container\n // Note: Using JavaScript loop instead of CSS :has() for browser compatibility\n // (:has() not supported in Firefox < 121, throws SyntaxError in older browsers)\n let logoDisplay = null;\n const containers = logoField.querySelectorAll('.uploaded-img-container');\n for (const container of containers) {\n if (container.querySelector('img.zoom-thumbnail')) {\n logoDisplay = container;\n break;\n }\n }\n\n if (logoDisplay) {\n // Update existing logo\n const img = logoDisplay.querySelector('img.zoom-thumbnail');\n if (img) {\n img.src = response.thumb_url;\n }\n } else {\n // Create new logo display if it doesn't exist\n const logoDropzone = this.element.closest('.uploaded-img-container');\n if (logoDropzone) {\n // Hide the dropzone\n logoDropzone.style.display = 'none';\n\n // Create new logo display element\n const newLogoDisplay = document.createElement('li');\n newLogoDisplay.className = 'uploaded-img-container';\n newLogoDisplay.tabIndex = '0';\n\n // Create img element safely\n const img = document.createElement('img');\n img.src = response.thumb_url;\n img.className = 'zoom-thumbnail';\n img.tabIndex = '0';\n\n // Create remove link safely\n const removeLink = document.createElement('a');\n removeLink.href = `/teamsites/${saleId}/branding_logo.js`;\n removeLink.className = 'remove-icon ae-clickable';\n removeLink.setAttribute('data-method', 'delete');\n removeLink.setAttribute('data-remote', 'true');\n removeLink.setAttribute('data-confirm', 'Are you sure?');\n removeLink.textContent = '\u00D7';\n\n // Append elements\n newLogoDisplay.appendChild(img);\n newLogoDisplay.appendChild(removeLink);\n\n // Insert after dropzone\n logoDropzone.parentNode.insertBefore(newLogoDisplay, logoDropzone.nextSibling);\n }\n }\n }\n\n handleError(event) {\n if (!event.detail) return;\n\n // Show error message if App.showErrorFlash is available\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash('Failed to upload logo. Please try again.');\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n static targets = ['drawer', 'link'];\n\n connect() {\n this.handleOutsideClick = this.handleOutsideClick.bind(this);\n document.addEventListener('click', this.handleOutsideClick);\n }\n\n disconnect() {\n document.removeEventListener('click', this.handleOutsideClick);\n }\n\n toggle() {\n document.body.classList.toggle('main-menu-drawer-is-active');\n }\n\n close() {\n document.body.classList.remove('main-menu-drawer-is-active');\n }\n\n handleOutsideClick(event) {\n const target = event.target;\n\n // Skip if clicking the drawer toggle button or its children\n if (this.linkTargets.some(link => link.contains(target))) {\n return;\n }\n\n // Close if click is outside drawer\n if (this.hasDrawerTarget && !this.drawerTarget.contains(target)) {\n this.close();\n }\n }\n}\n\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n initialize() {\n this.drawerCssClass = this.element.dataset.drawerCssClass;\n this.handleClick = this.handleClick.bind(this);\n }\n\n connect() {\n this.element.addEventListener('click', this.handleClick);\n }\n\n disconnect() {\n this.element.removeEventListener('click', this.handleClick);\n }\n\n handleClick(e) {\n e.preventDefault();\n e.stopPropagation();\n\n const url = this.element.dataset.url;\n const drawer = document.querySelector('[data-messages-drawer]');\n\n if (drawer) {\n const event = new CustomEvent('drawer:open', {\n detail: { url: url, cssClass: this.drawerCssClass },\n });\n drawer.dispatchEvent(event);\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n static targets = ['badge'];\n\n initialize() {\n this.pollInterval = null;\n }\n\n connect() {\n this.loadCount();\n this.startPolling();\n }\n\n disconnect() {\n this.stopPolling();\n }\n\n startPolling() {\n // Poll every 60 seconds for message count updates\n this.pollInterval = setInterval(() => {\n this.loadCount();\n }, 60000);\n }\n\n stopPolling() {\n if (this.pollInterval) {\n clearInterval(this.pollInterval);\n this.pollInterval = null;\n }\n }\n\n loadCount() {\n const countUrl = this.element.dataset.countUrl;\n if (!countUrl) return;\n\n fetch(countUrl, {\n headers: {\n 'X-Requested-With': 'XMLHttpRequest',\n 'Accept': 'application/json',\n },\n })\n .then(response => response.json())\n .then(data => {\n if (this.hasBadgeTarget) {\n this.badgeTarget.textContent = data.count;\n }\n })\n .catch(_error => {\n // Silently fail - message count is not critical\n });\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n static targets = ['toggleExpandIcon', 'mainContent'];\n\n initialize() {\n this.url = null;\n this.handleOpen = this.handleOpen.bind(this);\n this.handleReload = this.handleReload.bind(this);\n }\n\n connect() {\n this.element.addEventListener('drawer:open', this.handleOpen);\n this.element.addEventListener('drawer:reload', this.handleReload);\n }\n\n disconnect() {\n this.element.removeEventListener('drawer:open', this.handleOpen);\n this.element.removeEventListener('drawer:reload', this.handleReload);\n }\n\n handleOpen(e) {\n const detail = e.detail || {};\n this.url = detail.url;\n\n const sidebar = document.querySelector('.js-ae-sidebar-right');\n if (sidebar) {\n sidebar.classList.add('ae-show-sidebar-right');\n }\n\n this.reloadContent();\n }\n\n handleReload() {\n this.reloadContent();\n }\n\n close(e) {\n e.preventDefault();\n\n const sidebar = document.querySelector('.js-ae-sidebar-right');\n if (sidebar) {\n sidebar.classList.remove('ae-show-sidebar-right');\n }\n\n if (this.hasMainContentTarget) {\n this.mainContentTarget.classList.remove('is-loading');\n }\n\n this.clearContent();\n }\n\n reloadContent() {\n this.clearContent();\n\n if (this.hasMainContentTarget) {\n this.mainContentTarget.classList.add('is-loading');\n }\n\n this.fetchContent();\n }\n\n clearContent() {\n if (this.hasMainContentTarget) {\n this.mainContentTarget.textContent = '';\n const emptyDiv = document.createElement('div');\n emptyDiv.className = 'ae-empty';\n this.mainContentTarget.appendChild(emptyDiv);\n }\n }\n\n toggleExpand(e) {\n e.preventDefault();\n\n const sidebar = document.querySelector('.js-ae-sidebar-right');\n if (!sidebar) return;\n\n const isExpanded = sidebar.classList.contains('ae-expanded-detail');\n sidebar.classList.toggle('ae-expanded-detail');\n\n if (this.hasToggleExpandIconTarget) {\n if (isExpanded) {\n this.toggleExpandIconTarget.classList.remove('fa-chevron-right');\n this.toggleExpandIconTarget.classList.add('fa-chevron-left');\n } else {\n this.toggleExpandIconTarget.classList.remove('fa-chevron-left');\n this.toggleExpandIconTarget.classList.add('fa-chevron-right');\n }\n }\n }\n\n fetchContent() {\n if (!this.url) return;\n\n const csrfToken = document.querySelector('meta[name=\"csrf-token\"]');\n\n fetch(this.url, {\n headers: {\n 'X-Requested-With': 'XMLHttpRequest',\n 'X-CSRF-Token': csrfToken ? csrfToken.content : '',\n },\n })\n .then(response => response.text())\n .then(html => {\n if (this.hasMainContentTarget) {\n // Clear existing content\n this.mainContentTarget.textContent = '';\n // Use insertAdjacentHTML for trusted server-rendered content\n this.mainContentTarget.insertAdjacentHTML('beforeend', html);\n this.mainContentTarget.classList.remove('is-loading');\n }\n })\n .catch(error => {\n console.error('Error fetching drawer content:', error);\n if (this.hasMainContentTarget) {\n this.mainContentTarget.classList.remove('is-loading');\n }\n });\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n static targets = ['recipientsField', 'messageField'];\n\n initialize() {\n this.handleSubmit = this.handleSubmit.bind(this);\n this.isSubmitting = false;\n }\n\n connect() {\n this.element.addEventListener('submit', this.handleSubmit);\n }\n\n disconnect() {\n this.element.removeEventListener('submit', this.handleSubmit);\n }\n\n handleSubmit(e) {\n e.preventDefault();\n\n if (this.isSubmitting) return;\n\n if (!this.validateForm()) {\n return;\n }\n\n this.isSubmitting = true;\n\n const formData = new FormData(this.element);\n const url = this.element.action;\n const csrfToken = document.querySelector('meta[name=\"csrf-token\"]');\n\n fetch(url, {\n method: 'POST',\n body: formData,\n headers: {\n 'X-Requested-With': 'XMLHttpRequest',\n 'X-CSRF-Token': csrfToken ? csrfToken.content : '',\n },\n })\n .then(response => {\n this.isSubmitting = false;\n if (response.ok) {\n this.triggerSuccess();\n this.clearForm();\n this.hideForm();\n } else {\n window.App.showErrorFlash('Failed to send reply');\n }\n })\n .catch(_error => {\n this.isSubmitting = false;\n window.App.showErrorFlash('Failed to send reply');\n });\n }\n\n validateForm() {\n let isValid = true;\n\n if (this.hasRecipientsFieldTarget && !this.recipientsFieldTarget.value) {\n isValid = false;\n }\n\n if (this.hasMessageFieldTarget && !this.messageFieldTarget.value.trim()) {\n isValid = false;\n }\n\n return isValid;\n }\n\n clearForm() {\n if (this.hasMessageFieldTarget) {\n this.messageFieldTarget.value = '';\n }\n }\n\n hideForm() {\n this.element.classList.add('ae-hidden');\n }\n\n triggerSuccess() {\n const drawer = document.querySelector('[data-messages-drawer]');\n if (drawer) {\n const event = new CustomEvent('drawer:reload');\n drawer.dispatchEvent(event);\n }\n\n window.App.showSuccessFlash('Reply sent successfully');\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n static targets = ['recipientsField', 'subjectField', 'messageField'];\n\n initialize() {\n this.handleSubmit = this.handleSubmit.bind(this);\n this.isSubmitting = false;\n }\n\n connect() {\n this.element.addEventListener('submit', this.handleSubmit);\n }\n\n disconnect() {\n this.element.removeEventListener('submit', this.handleSubmit);\n }\n\n handleSubmit(e) {\n e.preventDefault();\n\n if (this.isSubmitting) return;\n\n if (!this.validateForm()) {\n return;\n }\n\n this.isSubmitting = true;\n\n const formData = new FormData(this.element);\n const url = this.element.action;\n const csrfToken = document.querySelector('meta[name=\"csrf-token\"]');\n\n fetch(url, {\n method: 'POST',\n body: formData,\n headers: {\n 'X-Requested-With': 'XMLHttpRequest',\n 'X-CSRF-Token': csrfToken ? csrfToken.content : '',\n },\n })\n .then(response => {\n this.isSubmitting = false;\n if (response.ok) {\n this.triggerSuccess();\n this.clearForm();\n } else {\n window.App.showErrorFlash('Failed to send message');\n }\n })\n .catch(_error => {\n this.isSubmitting = false;\n window.App.showErrorFlash('Failed to send message');\n });\n }\n\n validateForm() {\n let isValid = true;\n\n if (this.hasRecipientsFieldTarget && !this.recipientsFieldTarget.value) {\n isValid = false;\n }\n\n if (this.hasSubjectFieldTarget && !this.subjectFieldTarget.value.trim()) {\n isValid = false;\n }\n\n if (this.hasMessageFieldTarget && !this.messageFieldTarget.value.trim()) {\n isValid = false;\n }\n\n return isValid;\n }\n\n clearForm() {\n if (this.hasSubjectFieldTarget) {\n this.subjectFieldTarget.value = '';\n }\n if (this.hasMessageFieldTarget) {\n this.messageFieldTarget.value = '';\n }\n }\n\n triggerSuccess() {\n const triggerOnSuccess = this.element.dataset.triggerOnSuccess || 'drawer:reload';\n const drawer = document.querySelector('[data-messages-drawer]');\n\n if (drawer && triggerOnSuccess === 'drawer:reload') {\n const event = new CustomEvent('drawer:reload');\n drawer.dispatchEvent(event);\n }\n\n // Update message count\n const updateCountFor = this.element.dataset.updateCountFor;\n if (updateCountFor) {\n const badge = document.querySelector(`[data-count-for=\"${updateCountFor}\"]`);\n if (badge) {\n const currentCount = parseInt(badge.textContent, 10) || 0;\n badge.textContent = currentCount + 1;\n badge.classList.remove('ae-hidden');\n }\n }\n\n window.App.showSuccessFlash('Message sent successfully');\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n static targets = ['form'];\n\n toggle(e) {\n e.preventDefault();\n\n if (this.hasFormTarget) {\n this.formTarget.classList.toggle('ae-hidden');\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n connect() {\n // Set up any necessary initialization\n }\n\n disconnect() {\n // Clean up any event listeners\n }\n\n async click(event) {\n event.preventDefault();\n\n const saleId = this.element.dataset.saleId;\n const orderNumber = this.element.dataset.orderNumber;\n\n if (!saleId || !orderNumber) {\n // Show user-friendly error instead of console logging\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash('Unable to process refund. Missing required information.');\n }\n return;\n }\n\n // Use the fulfillment endpoint that renders the modal partial\n const refundUrl = `/teamsites/${saleId}/orders/${orderNumber}/fulfillments/refund`;\n\n // Use the existing AjaxPopupController system like the orders index page\n await this.openRefundModal(refundUrl);\n }\n\n async openRefundModal(url) {\n // Use AjaxPopupController to show the modal with fallback handling\n try {\n const { default: AjaxPopupController } = await this.loadAjaxPopupController();\n AjaxPopupController.open(url);\n } catch (_error) {\n // Fallback to navigation if AjaxPopupController fails to load\n window.location.href = url;\n }\n }\n\n // Extract the dynamic import to make it testable\n loadAjaxPopupController() {\n return import('./ajax_popup_controller');\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n async click(e) {\n e.preventDefault();\n\n // Get container for the current view\n const container = document.getElementById('billing-and-shipping-info');\n if (!container) return;\n\n // Get data attributes for building URL\n const saleId = container.closest('[data-sale-id]')?.dataset?.saleId;\n const orderNumber = container.closest('[data-order-number]')?.dataset?.orderNumber;\n\n if (!saleId || !orderNumber) return;\n\n // Build the edit URL\n const url = `/teamsites/${saleId}/orders/${orderNumber}/address/edit`;\n\n // Get CSRF token for Rails\n const csrfToken = document.querySelector('meta[name=\"csrf-token\"]')?.getAttribute('content');\n const headers = {\n 'X-Requested-With': 'XMLHttpRequest',\n 'Accept': 'application/json',\n };\n\n if (csrfToken) {\n headers['X-CSRF-Token'] = csrfToken;\n }\n\n try {\n const response = await fetch(url, { headers });\n if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);\n\n const data = await response.json();\n\n if (data.html) {\n try {\n // Create temporary div to parse HTML\n const tempDiv = document.createElement('div');\n tempDiv.innerHTML = data.html;\n\n // Get the first element from the parsed HTML\n const newElement = tempDiv.firstElementChild;\n if (newElement) {\n // Replace container with the edit form\n container.replaceWith(newElement);\n }\n } catch {\n // Fallback: reload the page if HTML parsing fails\n window.location.reload();\n }\n }\n } catch {\n // Silent fallback per production standards - no console logging\n window.location.reload();\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n static targets = ['container', 'errorMessages', 'totalSection'];\n\n initialize() {\n this.showLoading = this.showLoading.bind(this);\n this.handleSuccess = this.handleSuccess.bind(this);\n this.handleError = this.handleError.bind(this);\n }\n\n connect() {\n // Listen for Rails UJS events\n this.element.addEventListener('ajax:before', this.showLoading);\n this.element.addEventListener('ajax:success', this.handleSuccess);\n this.element.addEventListener('ajax:error', this.handleError);\n }\n\n disconnect() {\n // Clean up event listeners\n this.element.removeEventListener('ajax:before', this.showLoading);\n this.element.removeEventListener('ajax:success', this.handleSuccess);\n this.element.removeEventListener('ajax:error', this.handleError);\n }\n\n showLoading() {\n // Add loading state to container\n const container = this.getContainer();\n if (container) {\n container.classList.add('is-loading');\n }\n\n // Address changes may affect order totals\n const totalSection = this.getTotalSection();\n if (totalSection) {\n totalSection.classList.add('is-loading');\n }\n }\n\n handleSuccess(event) {\n // Handle Rails UJS response structure\n let data = {};\n\n if (event.detail) {\n // Rails 6+ UJS format: event.detail contains the response\n data = Array.isArray(event.detail) && event.detail.length > 0 ? event.detail[0] : event.detail;\n }\n\n // Safety check for response data\n if (!data || typeof data !== 'object') {\n // Invalid response data received\n this.handleError(event);\n return;\n }\n\n this.removeLoadingStates();\n\n if (data.success && data.html) {\n // Replace the edit form with the display view\n const editElement = document.getElementById('billing-and-shipping-info-edit');\n if (editElement) {\n try {\n editElement.outerHTML = data.html;\n } catch {\n // Fallback: reload the page if HTML parsing fails\n window.location.reload();\n }\n }\n\n // Show success message\n if (data.message) {\n this.showSuccessMessage(data.message);\n }\n } else if (data.errors && data.html) {\n // Show validation errors and keep edit form open\n this.showValidationErrors(data.errors);\n\n // Replace form content to show validation state\n const editElement = document.getElementById('billing-and-shipping-info-edit');\n if (editElement) {\n try {\n editElement.outerHTML = data.html;\n } catch {\n // Fallback: reload the page if HTML parsing fails\n window.location.reload();\n }\n }\n }\n }\n\n handleError(_event) {\n this.removeLoadingStates();\n\n // Show error message\n this.showErrorMessage('Unable to update address information');\n }\n\n // Helper methods\n getContainer() {\n // First try to use Stimulus target, then fallback to closest lookup\n if (this.hasContainerTarget) {\n return this.containerTarget;\n }\n return this.element.closest('.ae-panel-content');\n }\n\n getTotalSection() {\n // First try to use Stimulus target, then fallback to query selector\n if (this.hasTotalSectionTarget) {\n return this.totalSectionTarget;\n }\n return document.querySelector('[data-order-total-section] .ae-panel-content');\n }\n\n removeLoadingStates() {\n const container = this.getContainer();\n if (container) {\n container.classList.remove('is-loading');\n }\n\n const totalSection = this.getTotalSection();\n if (totalSection) {\n totalSection.classList.remove('is-loading');\n }\n }\n\n showValidationErrors(errors) {\n const container = this.getContainer();\n if (!container || !Array.isArray(errors) || errors.length === 0) {\n return;\n }\n\n const errorContainer = container.querySelector('.error-messages');\n if (errorContainer) {\n try {\n const errorHtml = errors.map(error =>\n `
    ${error}
    `,\n ).join('');\n errorContainer.innerHTML = errorHtml;\n } catch {\n // Fallback: display basic error if HTML parsing fails\n errorContainer.textContent = 'Unable to display validation errors. Please check your input and try again.';\n }\n }\n }\n\n showSuccessMessage(message) {\n // Use unified flash system if available, otherwise basic alert\n if (window.App && App.showSuccessFlash) {\n App.showSuccessFlash(message);\n }\n }\n\n showErrorMessage(message) {\n // Use unified flash system if available, otherwise basic alert\n if (window.App && App.showErrorFlash) {\n App.showErrorFlash(message);\n }\n }\n\n async cancel(e) {\n e.preventDefault();\n\n // Get data attributes from the parent container\n const container = this.element.closest('#billing-and-shipping-info-edit');\n const saleId = container?.dataset.saleId;\n const orderNumber = container?.dataset.orderNumber;\n\n if (!saleId || !orderNumber) {\n // Missing saleId or orderNumber for cancel action\n return;\n }\n\n // Build the URL to get the original view\n const url = `/teamsites/${saleId}/orders/${orderNumber}/address`;\n\n // Get CSRF token for Rails\n const csrfToken = document.querySelector('meta[name=\"csrf-token\"]')?.getAttribute('content');\n const headers = {\n 'X-Requested-With': 'XMLHttpRequest',\n 'Accept': 'text/html',\n };\n\n if (csrfToken) {\n headers['X-CSRF-Token'] = csrfToken;\n }\n\n try {\n const response = await fetch(url, { headers });\n if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);\n\n const html = await response.text();\n const editContainer = document.getElementById('billing-and-shipping-info-edit');\n if (editContainer) {\n try {\n // Create temporary div to parse HTML\n const tempDiv = document.createElement('div');\n tempDiv.innerHTML = html;\n\n // Get the first element from the parsed HTML\n const newElement = tempDiv.firstElementChild;\n if (newElement) {\n // Replace container with the new element\n editContainer.replaceWith(newElement);\n } else {\n // If no element found, remove the container\n editContainer.remove();\n }\n } catch {\n // Fallback: reload the page if HTML parsing fails\n window.location.reload();\n }\n }\n } catch {\n // Silent fallback per production standards - no console logging\n window.location.reload();\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n initialize() {\n // Find the customer-info container using Stimulus patterns\n this.container = document.getElementById('customer-info');\n if (this.container) {\n this.saleId = this.container.dataset.saleId;\n this.orderNumber = this.container.dataset.orderNumber;\n } else {\n this.saleId = null;\n this.orderNumber = null;\n }\n }\n\n disconnect() {\n // Clean up any references or event listeners\n }\n\n click(event) {\n event.preventDefault();\n\n // Build the edit URL using Teamsites namespace\n const url = `/teamsites/${this.saleId}/orders/${this.orderNumber}/customer/edit`;\n\n // Get CSRF token\n const csrfTokenMeta = document.querySelector('meta[name=\"csrf-token\"]');\n const csrfToken = csrfTokenMeta?.content;\n\n // Make AJAX request to load the edit form\n const headers = {\n 'Accept': 'application/json',\n 'Content-Type': 'application/json',\n };\n if (csrfToken) {\n headers['X-CSRF-Token'] = csrfToken;\n }\n\n fetch(url, { headers })\n .then(response => {\n if (!response.ok) {\n throw new Error(`HTTP ${response.status}: ${response.statusText}`);\n }\n return response.json();\n })\n .then(data => {\n if (data.html) {\n this.replaceWithEditable(data.html);\n }\n })\n .catch(() => {\n // Silent fallback per production standards\n });\n }\n\n replaceWithEditable(html) {\n if (!this.container || !html) {\n return;\n }\n\n // Create temporary div to parse HTML\n const temp = document.createElement('div');\n temp.innerHTML = html;\n const newElement = temp.firstElementChild;\n\n if (newElement) {\n // Replace the container with the editable version\n this.container.replaceWith(newElement);\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n static targets = ['form', 'errorMessages'];\n\n initialize() {\n // Get data attributes from the parent container (since controller is now on form)\n const container = this.element.closest('#customer-info-edit');\n if (container) {\n this.saleId = container.dataset.saleId;\n this.orderNumber = container.dataset.orderNumber;\n }\n }\n\n disconnect() {\n // Clean up any event listeners or references\n }\n\n submit(event) {\n event.preventDefault();\n\n const formData = new FormData(this.formTarget);\n const url = `/teamsites/${this.saleId}/orders/${this.orderNumber}/customer`;\n\n // Show loading state (you could add spinner here)\n this.setLoadingState(true);\n\n // Get CSRF token\n const csrfTokenMeta = document.querySelector('meta[name=\"csrf-token\"]');\n const csrfToken = csrfTokenMeta?.content;\n\n fetch(url, {\n method: 'PATCH',\n body: formData,\n headers: {\n 'X-Requested-With': 'XMLHttpRequest',\n 'X-CSRF-Token': csrfToken,\n },\n })\n .then(response => {\n if (!response.ok) {\n throw new Error(`HTTP ${response.status}: ${response.statusText}`);\n }\n return response.json();\n })\n .then(data => {\n if (data.success) {\n // Replace the edit form with the updated view\n const container = document.getElementById('customer-info-edit');\n if (container && data.html) {\n try {\n const tempDiv = document.createElement('div');\n tempDiv.innerHTML = data.html;\n const newElement = tempDiv.firstElementChild;\n if (newElement) {\n container.replaceWith(newElement);\n }\n } catch {\n // Fallback: reload the page if HTML parsing fails\n window.location.reload();\n }\n }\n\n // Show success message if needed\n if (data.message) {\n this.showSuccessMessage(data.message);\n }\n } else {\n this.handleErrors(data.errors || ['Update failed']);\n }\n })\n .catch(_error => {\n // Error updating customer info\n this.handleErrors(['Network error occurred']);\n })\n .finally(() => {\n this.setLoadingState(false);\n });\n }\n\n setLoadingState(isLoading) {\n const submitButton = this.element.querySelector('button[type=\"submit\"]');\n if (submitButton) {\n submitButton.disabled = isLoading;\n submitButton.textContent = isLoading ? 'Saving...' : 'Save';\n }\n }\n\n handleErrors(errors) {\n // Clear existing error messages\n this.clearErrors();\n\n // Display errors using Stimulus target (preferred) or fallback to querySelector\n const errorContainer = this.hasErrorMessagesTarget\n ? this.errorMessagesTarget\n : this.element.querySelector('.error-messages');\n\n if (errorContainer) {\n errorContainer.innerHTML = errors.map(error =>\n `
    ${error}
    `,\n ).join('');\n }\n }\n\n clearErrors() {\n // Clear errors using Stimulus target (preferred) or fallback to querySelector\n const errorContainer = this.hasErrorMessagesTarget\n ? this.errorMessagesTarget\n : this.element.querySelector('.error-messages');\n\n if (errorContainer) {\n errorContainer.innerHTML = '';\n }\n }\n\n showSuccessMessage(message) {\n // Use unified flash system for user feedback\n if (window.App && typeof window.App.showSuccessFlash === 'function') {\n window.App.showSuccessFlash(message);\n }\n }\n\n async cancel(e) {\n e.preventDefault();\n\n // Build the URL to get the original view\n const url = `/teamsites/${this.saleId}/orders/${this.orderNumber}/customer`;\n\n // Get CSRF token for Rails\n const csrfToken = document.querySelector('meta[name=\"csrf-token\"]')?.getAttribute('content');\n const headers = {\n 'X-Requested-With': 'XMLHttpRequest',\n 'Accept': 'text/html',\n };\n\n if (csrfToken) {\n headers['X-CSRF-Token'] = csrfToken;\n }\n\n try {\n const response = await fetch(url, { headers });\n if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);\n\n const html = await response.text();\n const container = document.getElementById('customer-info-edit');\n if (container) {\n try {\n // Create temporary div to parse HTML\n const tempDiv = document.createElement('div');\n tempDiv.innerHTML = html;\n\n // Get the first element from the parsed HTML\n const newElement = tempDiv.firstElementChild;\n if (newElement) {\n // Replace container with the new element\n container.replaceWith(newElement);\n } else {\n // If no element found, remove the container\n container.remove();\n }\n } catch {\n // Fallback: reload the page if HTML parsing fails\n window.location.reload();\n }\n }\n } catch {\n // Silent fallback per production standards - no console logging\n window.location.reload();\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n static get targets() { return ['discardBtn']; }\n\n discard(event) {\n event.preventDefault();\n\n const confirmed = confirm(\n 'Discard all changes and restore original line items?\\n\\n' +\n 'Shipping labels for removed items will need to be recreated manually.'\n );\n\n if (!confirmed) {\n this.resetDebounce();\n return;\n }\n\n const discardUrl = this.element.dataset.discardUrl;\n const csrfToken = document.querySelector('meta[name=\"csrf-token\"]')?.content;\n\n fetch(discardUrl, {\n method: 'POST',\n headers: {\n 'X-Requested-With': 'XMLHttpRequest',\n 'X-CSRF-Token': csrfToken,\n },\n })\n .then(response => {\n if (response.ok) {\n document.body.classList.add('is-loading');\n window.location.reload();\n } else {\n return response.text().then(errorText => {\n throw new Error(errorText || 'Error discarding changes');\n });\n }\n })\n .catch(error => {\n this.resetDebounce();\n\n document.body.classList.remove('is-loading');\n\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash(error.message);\n }\n });\n }\n\n resetDebounce() {\n if (!this.hasDiscardBtnTarget) return;\n\n const debounceController = this.application.getControllerForElementAndIdentifier(\n this.discardBtnTarget,\n 'button-debounce',\n );\n if (debounceController) {\n debounceController.manualReset();\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n static targets = ['confirmBtn', 'message'];\n\n connect() {\n this.orderNumber = this.element.dataset.orderNumber;\n this.saleId = this.element.dataset.saleId;\n this.paymentState = this.element.dataset.paymentState;\n\n // Clear message field on connect\n if (this.hasMessageTarget) {\n this.messageTarget.value = '';\n }\n\n // Enable confirm button\n if (this.hasConfirmBtnTarget) {\n this.confirmBtnTarget.disabled = false;\n }\n\n // Add beforeunload warning\n this.beforeUnloadHandler = (e) => {\n e.preventDefault();\n e.returnValue = '';\n return '';\n };\n window.addEventListener('beforeunload', this.beforeUnloadHandler);\n }\n\n disconnect() {\n // Remove beforeunload warning\n window.removeEventListener('beforeunload', this.beforeUnloadHandler);\n }\n\n confirm(event) {\n event.preventDefault();\n\n // Get CSRF token\n const csrfToken = document.querySelector('meta[name=\"csrf-token\"]')?.content;\n\n // Prepare form data\n const formData = new FormData();\n if (this.hasMessageTarget && this.messageTarget.value) {\n formData.append('message', this.messageTarget.value);\n }\n\n // Make AJAX request\n fetch(`/teamsites/${this.saleId}/orders/${this.orderNumber}/confirm_payment_adjustment`, {\n method: 'POST',\n body: formData,\n headers: {\n 'X-Requested-With': 'XMLHttpRequest',\n 'X-CSRF-Token': csrfToken,\n },\n })\n .then(response => {\n if (response.ok) {\n // Remove beforeunload warning before reload\n window.removeEventListener('beforeunload', this.beforeUnloadHandler);\n\n // Close modal via simple-popup\n this.closeModal();\n\n // Show loading overlay\n document.body.classList.add('is-loading');\n\n // Reload page to show updated state\n window.location.reload();\n } else {\n return response.text().then(errorText => {\n throw new Error(errorText || 'Error confirming changes');\n });\n }\n })\n .catch(error => {\n // Manual reset of button-debounce controller to re-enable button\n const debounceController = this.application.getControllerForElementAndIdentifier(\n this.confirmBtnTarget,\n 'button-debounce',\n );\n if (debounceController) {\n debounceController.manualReset();\n }\n\n // Defensive cleanup: Remove any loading states (even though only added on success)\n document.body.classList.remove('is-loading');\n\n // Close modal and remove backdrop\n this.closeModal();\n\n // Show error flash\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash(error.message);\n }\n // Silent fallback - flash system unavailable\n });\n }\n\n cancel(event) {\n event.preventDefault();\n // Remove beforeunload handler when cancelling\n window.removeEventListener('beforeunload', this.beforeUnloadHandler);\n this.closeModal();\n }\n\n closeModal() {\n // Find the modal element\n const modal = this.element.closest('.ae-popup');\n if (modal) {\n // Trigger close on Magnific Popup controller (mfp-close matches template)\n const closeBtn = modal.querySelector('.mfp-close');\n if (closeBtn) {\n closeBtn.click();\n } else {\n // Fallback: hide modal manually\n modal.classList.add('mfp-hide');\n modal.style.display = 'none';\n\n // Hide backdrop if exists\n const backdrop = document.querySelector('.simple-popup-backdrop');\n if (backdrop) {\n backdrop.style.display = 'none';\n }\n }\n }\n }\n}\n", "import { Controller } from '../stimulus';\nimport StimulusReinitMixin from '../mixins/stimulus_reinit_mixin';\n\nexport default class extends Controller {\n connect() {\n this.saleId = this.element.dataset.saleId;\n this.orderNumber = this.element.dataset.orderNumber;\n\n // Apply Stimulus reinit mixin\n Object.assign(this, StimulusReinitMixin);\n }\n\n showForm(e) {\n e.preventDefault();\n\n const url = `/teamsites/${this.saleId}/orders/${this.orderNumber}/coupons/new`;\n const csrfToken = document.querySelector('meta[name=\"csrf-token\"]').content;\n\n return fetch(url, {\n method: 'GET',\n headers: {\n 'X-CSRF-Token': csrfToken,\n 'X-Requested-With': 'XMLHttpRequest',\n },\n credentials: 'same-origin',\n })\n .then(response => {\n\n if (!response.ok) {\n console.error('[CouponButton] HTTP error - status:', response.status);\n throw new Error(`HTTP error! status: ${response.status}`);\n }\n return response.text();\n })\n .then(html => {\n\n const row = this.element.closest('tr');\n\n try {\n // Insert new row before the old one\n row.insertAdjacentHTML('beforebegin', html);\n\n // Get reference to the newly inserted row (it's the previousSibling)\n const newRow = row.previousElementSibling;\n\n // Remove the old row\n row.remove();\n\n // Reinitialize Stimulus controllers on the new row\n if (newRow) {\n this.reinitializeStimulusControllers(newRow);\n }\n\n } catch (error) {\n console.error('[CouponButton] Error replacing row:', error);\n if (typeof App !== 'undefined' && App.showErrorFlash) {\n App.showErrorFlash('Failed to load coupon form');\n }\n }\n })\n .catch(error => {\n console.error('[CouponButton] Error loading coupon form:', error);\n console.error('[CouponButton] Error stack:', error.stack);\n if (typeof App !== 'undefined' && App.showErrorFlash) {\n App.showErrorFlash('Failed to load coupon form');\n }\n });\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n connect() {\n this.submitTimeout = null;\n // Controller is ready\n }\n\n disconnect() {\n // Clean up any event listeners and timeouts\n if (this.submitTimeout) {\n clearTimeout(this.submitTimeout);\n this.submitTimeout = null;\n }\n }\n\n confirm(event) {\n event.preventDefault();\n\n // Use element.dataset directly instead of this.data.get() for reliability\n const orderNumber = this.element.dataset.orderNumber;\n const saleId = this.element.dataset.saleId;\n\n if (!orderNumber || !saleId) {\n // Missing order number or sale ID\n console.error('Missing order number or sale ID', {\n orderNumber: orderNumber,\n saleId: saleId,\n });\n return;\n }\n\n // Clear any existing timeout to prevent multiple submissions\n if (this.submitTimeout) {\n clearTimeout(this.submitTimeout);\n }\n\n // Store button reference and original text\n const button = event.target;\n this.currentButton = button;\n this.originalButtonText = button.textContent;\n\n // Disable button and show debounce feedback\n button.disabled = true;\n button.textContent = 'Processing in 2s...';\n\n // Implement 2-second debounce delay\n this.submitTimeout = setTimeout(() => {\n this.processChange(orderNumber, saleId);\n }, 2000);\n }\n\n processChange(orderNumber, saleId) {\n // Update button to show actual processing\n if (this.currentButton) {\n this.currentButton.textContent = 'Processing...';\n }\n\n const csrfToken = document.querySelector('meta[name=\"csrf-token\"]');\n const headers = {\n 'Accept': 'application/json',\n 'Content-Type': 'application/json',\n };\n\n if (csrfToken) {\n headers['X-CSRF-Token'] = csrfToken.getAttribute('content');\n }\n\n fetch(`/teamsites/${saleId}/orders/${orderNumber}/fulfillments/change`, {\n method: 'PATCH',\n headers: headers,\n body: JSON.stringify({}),\n })\n .then(response => {\n if (!response.ok) {\n if (response.status === 500) {\n throw new Error(`Server error (${response.status}): Please try again or contact support if the problem persists.`);\n } else if (response.status >= 400) {\n return response.json().then(data => {\n throw new Error(data.message || `Request failed with status ${response.status}`);\n }).catch(() => {\n throw new Error(`Request failed with status ${response.status}`);\n });\n }\n }\n return response.json();\n })\n .then(data => {\n if (data.status === 'success') {\n this.handleSuccess(data);\n } else {\n this.handleError(data.message || 'Failed to change fulfillment status');\n }\n this.resetButton();\n })\n .catch(error => {\n // Change fulfillment request failed\n this.handleError(error.message || 'Network error');\n this.resetButton();\n });\n }\n\n resetButton() {\n // Clear timeout and re-enable button\n if (this.submitTimeout) {\n clearTimeout(this.submitTimeout);\n this.submitTimeout = null;\n }\n\n if (this.currentButton) {\n this.currentButton.disabled = false;\n this.currentButton.textContent = this.originalButtonText || 'Confirm status change';\n this.currentButton = null;\n this.originalButtonText = null;\n }\n }\n\n handleSuccess(data) {\n // Show success message\n this.showNotification('success', data.message || 'Order changed to fulfilled successfully');\n\n // Close modal\n this.closeModal();\n\n // Trigger page refresh after a short delay to allow the notification to be seen\n // This will update the fulfillment status display\n if (window.location.pathname.includes('/orders')) {\n window.location.reload();\n }\n }\n\n handleError(message) {\n // Use App.showErrorFlash if available, otherwise fallback to custom notification\n if (typeof App !== 'undefined' && App.showErrorFlash) {\n App.showErrorFlash(message);\n } else {\n this.showNotification('error', message);\n }\n }\n\n showNotification(type, message) {\n // Create notification element\n const notification = document.createElement('div');\n notification.className = `ae-alert ae-alert-${type === 'success' ? 'success' : 'danger'}`;\n notification.style.cssText = 'position:fixed;top:20px;right:20px;z-index:10000;';\n notification.textContent = message;\n\n document.body.appendChild(notification);\n\n // Auto-remove after 5 seconds\n setTimeout(() => {\n if (notification.parentNode) {\n notification.parentNode.removeChild(notification);\n }\n }, 5000);\n }\n\n closeModal() {\n // Find and hide the modal\n const modal = this.element.closest('.ae-popup');\n if (modal) {\n modal.style.display = 'none';\n } else {\n // Find and click the close button to properly close the modal\n const closeButton = document.querySelector('.mfp-close');\n if (closeButton) {\n closeButton.click();\n }\n }\n }\n}\n", "import { Controller } from '../stimulus';\nimport NotificationMixin from '../mixins/notification_mixin';\n\nexport default class extends Controller {\n connect() {\n // Apply NotificationMixin\n Object.assign(this, NotificationMixin);\n }\n\n disconnect() {\n // Clean up any event listeners\n }\n\n confirm(event) {\n event.preventDefault();\n\n // Use element.dataset directly instead of this.data.get() for reliability\n const orderNumber = this.element.dataset.orderNumber;\n const saleId = this.element.dataset.saleId;\n\n if (!orderNumber || !saleId) {\n this.handleError('Missing order number or sale ID');\n return;\n }\n\n const csrfToken = document.querySelector('meta[name=\"csrf-token\"]');\n const headers = {\n 'Accept': 'application/json',\n 'Content-Type': 'application/json',\n };\n\n if (csrfToken) {\n headers['X-CSRF-Token'] = csrfToken.getAttribute('content');\n }\n\n fetch(`/teamsites/${saleId}/orders/${orderNumber}/fulfillments/clear`, {\n method: 'PATCH',\n headers: headers,\n })\n .then(response => {\n // Check if response is OK before parsing\n if (!response.ok) {\n // Try to get error message from response\n return response.text().then(text => {\n // Try to parse as JSON first\n try {\n const errorData = JSON.parse(text);\n const errorMessage = errorData.message || `Server error: ${response.status}`;\n throw new Error(errorMessage);\n } catch (jsonError) {\n // If JSON parsing failed, throw generic error\n if (jsonError instanceof SyntaxError) {\n throw new Error(`Server error: ${response.status} - ${response.statusText}`);\n }\n // Re-throw if it's our intentional error\n throw jsonError;\n }\n });\n }\n\n return response.json();\n })\n .then(data => {\n if (data.status === 'success') {\n this.handleSuccess(data);\n } else {\n this.handleError(data.message || 'Failed to clear fulfillment status');\n }\n })\n .catch(error => {\n this.handleError(error.message || 'Network error occurred');\n });\n }\n\n handleSuccess(data) {\n // Show success message using window.App.showSuccessFlash for consistency\n if (window.App && window.App.showSuccessFlash) {\n window.App.showSuccessFlash(data.message || 'Fulfillment status cleared successfully');\n } else {\n // Fallback using consistent Express UI classes\n this.showNotification('success', data.message || 'Fulfillment status cleared successfully');\n }\n\n // Close modal\n this.closeModal();\n\n // Only reload on orders page\n if (window.location.pathname.includes('/orders')) {\n // For now, reload the page to ensure proper UI state\n // This is more reliable than trying to update Select2 dropdowns manually\n setTimeout(() => {\n window.location.reload();\n }, 1000);\n }\n }\n\n handleError(message) {\n // Use window.App.showErrorFlash if available, otherwise fallback to custom notification\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash(message);\n } else {\n this.showNotification('error', message);\n }\n }\n\n showNotification(type, message) {\n // Create notification element with consistent Express UI classes\n const notification = document.createElement('div');\n notification.className = `ae-alert ae-alert-${type === 'success' ? 'success' : 'danger'}`;\n notification.style.cssText = 'position:fixed;top:20px;right:20px;z-index:10000;';\n notification.textContent = message;\n\n document.body.appendChild(notification);\n\n // Auto-remove after 5 seconds\n setTimeout(() => {\n if (notification.parentNode) {\n notification.parentNode.removeChild(notification);\n }\n }, 5000);\n }\n\n closeModal() {\n // Find and click the close button to properly close the modal\n const closeButton = document.querySelector('.mfp-close');\n if (closeButton) {\n closeButton.click();\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n connect() {\n // Controller is ready\n }\n\n disconnect() {\n // Clean up any event listeners\n }\n\n confirm(event) {\n event.preventDefault();\n\n // Use element.dataset directly instead of this.data.get() for reliability\n const orderNumber = this.element.dataset.orderNumber;\n const saleId = this.element.dataset.saleId;\n\n if (!orderNumber || !saleId) {\n this.handleError('Missing required data');\n return;\n }\n\n const url = `/teamsites/${saleId}/orders/${orderNumber}/fulfillments/confirm`;\n\n const csrfToken = document.querySelector('meta[name=\"csrf-token\"]');\n const headers = {\n 'Accept': 'application/json',\n 'Content-Type': 'application/json',\n };\n\n if (csrfToken) {\n headers['X-CSRF-Token'] = csrfToken.getAttribute('content');\n }\n\n fetch(url, {\n method: 'PATCH',\n headers: headers,\n body: JSON.stringify({}),\n })\n .then(response => {\n if (!response.ok) {\n throw new Error(`HTTP error! status: ${response.status}`);\n }\n return response.json();\n })\n .then(data => {\n if (data.status === 'success') {\n this.handleSuccess(data);\n } else {\n this.handleError(data.message || 'Failed to confirm fulfillment');\n }\n })\n .catch(error => {\n this.handleError('Network error occurred: ' + error.message);\n });\n }\n\n getSaleId() {\n // Try to extract sale ID from current URL\n const pathMatch = window.location.pathname.match(/\\/teamsites\\/(\\d+)/);\n if (pathMatch) {\n return pathMatch[1];\n }\n\n // Fallback: look for sale ID in data attributes on the page\n const saleElement = document.querySelector('[data-sale-id]');\n if (saleElement) {\n return saleElement.dataset.saleId;\n }\n\n return null;\n }\n\n handleSuccess(data) {\n // Show success message\n this.showNotification('success', data.message || 'Order marked as fulfilled');\n\n // Close modal\n this.closeModal();\n\n // Trigger page refresh after a short delay to allow the notification to be seen\n // This will update the fulfillment status display\n if (window.location.pathname.includes('/orders')) {\n setTimeout(() => {\n window.location.reload();\n }, 1000);\n }\n }\n\n handleError(message) {\n this.showNotification('error', message);\n }\n\n showNotification(type, message) {\n if (window.App) {\n if (type === 'success' && window.App.showSuccessFlash) {\n window.App.showSuccessFlash(message);\n } else if (type === 'error' && window.App.showErrorFlash) {\n window.App.showErrorFlash(message);\n } else {\n this.createFallbackNotification(type, message);\n }\n } else {\n this.createFallbackNotification(type, message);\n }\n }\n\n createFallbackNotification(type, message) {\n const notification = document.createElement('div');\n notification.className = `ae-alert ae-alert-${type === 'success' ? 'success' : 'danger'}`;\n notification.style.cssText = 'position:fixed;top:20px;right:20px;z-index:10000;';\n notification.textContent = message;\n\n document.body.appendChild(notification);\n\n setTimeout(() => {\n if (notification.parentNode) {\n notification.parentNode.removeChild(notification);\n }\n }, 5000);\n }\n\n closeModal() {\n // Find and click the close button to properly close the modal\n const closeButton = document.querySelector('.mfp-close');\n if (closeButton) {\n closeButton.click();\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n click(event) {\n event.preventDefault();\n\n const saleId = this.element.dataset.saleId;\n const orderNumber = this.element.dataset.orderNumber;\n\n // Fetch order status to determine which modal to show\n this.fetchOrderStatus(saleId, orderNumber)\n .then(status => {\n const url = this.getModalUrl(saleId, orderNumber, status);\n\n // Use AjaxPopupController to show the modal with fallback handling\n this.loadAjaxPopupController().then(({ default: AjaxPopupController }) => {\n AjaxPopupController.open(url);\n }).catch(_error => {\n // Fallback if dynamic import fails - create manual modal\n this.createFallbackModal(url);\n });\n })\n .catch(_error => {\n // If status fetch fails, default to partial modal (existing behavior)\n const url = `/teamsites/${saleId}/orders/${orderNumber}/fulfillments/partial`;\n this.loadAjaxPopupController().then(({ default: AjaxPopupController }) => {\n AjaxPopupController.open(url);\n }).catch(_error => {\n this.createFallbackModal(url);\n });\n });\n }\n\n // Fetch order fulfillment status from server\n fetchOrderStatus(saleId, orderNumber) {\n const url = `/teamsites/${saleId}/orders/${orderNumber}/fulfillment_status`;\n const csrfToken = document.querySelector('meta[name=\"csrf-token\"]')?.getAttribute('content');\n\n const headers = {\n 'Accept': 'application/json',\n 'X-Requested-With': 'XMLHttpRequest',\n };\n\n if (csrfToken) {\n headers['X-CSRF-Token'] = csrfToken;\n }\n\n return fetch(url, { headers })\n .then(response => {\n if (!response.ok) {\n throw new Error(`HTTP error! status: ${response.status}`);\n }\n return response.json();\n })\n .then(data => data.fulfillment_status);\n }\n\n // Determine which modal URL to use based on order status\n getModalUrl(saleId, orderNumber, status) {\n // If order is already partially_fulfilled or fulfilled, show \"change\" modal\n // which displays current line item statuses\n if (status === 'partially_fulfilled' || status === 'fulfilled') {\n return `/teamsites/${saleId}/orders/${orderNumber}/fulfillments/change`;\n }\n\n // For orders without fulfillment status, show \"partial\" modal\n // which allows marking items as fulfilled for the first time\n return `/teamsites/${saleId}/orders/${orderNumber}/fulfillments/partial`;\n }\n\n // Extract the dynamic import to make it testable\n loadAjaxPopupController() {\n return import('./ajax_popup_controller');\n }\n\n // Fallback modal creation if AjaxPopupController import fails\n createFallbackModal(url) {\n // Clean up any existing modals first\n this.cleanupExistingModals();\n\n // Create backdrop\n const backdrop = document.createElement('div');\n backdrop.className = 'mfp-bg mfp-ready';\n backdrop.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:1042;background:rgba(0,0,0,0.8);display:block;';\n document.body.appendChild(backdrop);\n\n // Create wrapper with correct structure\n const wrapper = document.createElement('div');\n wrapper.className = 'mfp-wrap mfp-close-btn-in mfp-auto-cursor mfp-ajax-holder mfp-ready';\n wrapper.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:1050;overflow:auto;display:flex;align-items:center;justify-content:center;';\n document.body.appendChild(wrapper);\n\n // Create content container with ae-popup structure\n const content = document.createElement('div');\n content.className = 'mfp-content';\n content.style.cssText = 'position:relative;z-index:1044;';\n\n // Add loading state\n const loader = document.createElement('div');\n loader.className = 'ae-popup ae-popup-md';\n loader.innerHTML = `\n
    \n
    Loading...
    \n
    \n `;\n content.appendChild(loader);\n wrapper.appendChild(content);\n\n // Fetch content\n const csrfToken = document.querySelector('meta[name=\"csrf-token\"]')?.getAttribute('content');\n const headers = {\n 'X-Requested-With': 'XMLHttpRequest',\n 'Accept': 'text/html',\n };\n\n if (csrfToken) {\n headers['X-CSRF-Token'] = csrfToken;\n }\n\n fetch(url, { headers })\n .then(response => {\n if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);\n return response.text();\n })\n .then(html => {\n // Replace loader with actual content\n content.innerHTML = html;\n\n // Ensure content has ae-popup structure\n const popup = content.querySelector('.ae-popup');\n if (!popup) {\n // Wrap content in ae-popup structure\n const popupWrapper = document.createElement('div');\n popupWrapper.className = 'ae-popup ae-popup-md';\n popupWrapper.innerHTML = `\n
    \n ${html}\n
    \n `;\n content.innerHTML = '';\n content.appendChild(popupWrapper);\n }\n\n // Setup close handlers\n this.setupCloseHandlers(backdrop, wrapper);\n\n // Add body class for proper styling\n document.body.classList.add('mfp-open');\n\n // Try to reinitialize Stimulus controllers if available\n if (window.Stimulus && window.Stimulus.application) {\n setTimeout(() => {\n const temp = document.createElement('div');\n temp.style.display = 'none';\n content.appendChild(temp);\n requestAnimationFrame(() => {\n if (temp.parentNode) {\n temp.remove();\n }\n });\n }, 10);\n }\n })\n .catch(_error => {\n // Error handling\n content.innerHTML = `\n
    \n
    \n

    Sorry, there was an error loading the content.

    \n \n
    \n
    \n `;\n this.setupCloseHandlers(backdrop, wrapper);\n });\n }\n\n // Setup close button handlers for fallback modal\n setupCloseHandlers(backdrop, wrapper) {\n // Close button handlers\n const closeButtons = wrapper.querySelectorAll('.mfp-close, [data-action=\"close\"]');\n closeButtons.forEach(button => {\n button.addEventListener('click', (e) => {\n e.preventDefault();\n this.closeFallbackModal(backdrop, wrapper);\n });\n });\n\n // Backdrop click handler\n backdrop.addEventListener('click', (e) => {\n if (e.target === backdrop) {\n this.closeFallbackModal(backdrop, wrapper);\n }\n });\n\n // ESC key handler\n const escHandler = (e) => {\n if (e.key === 'Escape') {\n this.closeFallbackModal(backdrop, wrapper);\n document.removeEventListener('keydown', escHandler);\n }\n };\n document.addEventListener('keydown', escHandler);\n }\n\n // Close fallback modal\n closeFallbackModal(backdrop, wrapper) {\n if (backdrop && backdrop.parentNode) {\n backdrop.remove();\n }\n if (wrapper && wrapper.parentNode) {\n wrapper.remove();\n }\n document.body.classList.remove('mfp-open');\n }\n\n // Clean up existing modals\n cleanupExistingModals() {\n const existingBackdrops = document.querySelectorAll('.mfp-bg');\n const existingWrappers = document.querySelectorAll('.mfp-wrap');\n\n existingBackdrops.forEach(el => el.remove());\n existingWrappers.forEach(el => el.remove());\n\n document.body.classList.remove('mfp-open');\n\n // Close any existing magnificPopup instances\n if (window.$ && window.$.magnificPopup && window.$.magnificPopup.instance) {\n window.$.magnificPopup.close();\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\n/**\n * Base controller for enhanced select dropdowns\n *\n * This controller provides common functionality for select elements that can be\n * enhanced with Select2 (if available) or work as native selects.\n *\n * The base controller has NO jQuery dependencies. It only provides:\n * - Lifecycle management (connect/disconnect)\n * - Event handling abstraction\n * - Turbolinks compatibility\n * - Value management\n *\n * If Select2 is available at runtime, it will be used. Otherwise, it falls\n * back to native select behavior.\n *\n * Usage:\n * import Select2BaseController from './select2_base_controller';\n *\n * export default class extends Select2BaseController {\n * connect() {\n * super.connect();\n * // Your custom initialization\n * }\n * }\n */\nexport default class extends Controller {\n // Override Stimulus initialize if it exists to prevent conflicts\n initialize() {\n // Don't do anything here - wait for connect()\n if (super.initialize) {\n super.initialize();\n }\n }\n connect() {\n try {\n\n // Validate that we have an element\n if (!this.element) {\n return;\n }\n\n // Store reference to the element\n this.selectElement = this.element;\n\n // Check if jQuery and Select2 are available\n\n // Initialize the select (enhanced or native)\n this.initializeSelect();\n\n // Set up Turbolinks cleanup\n this.setupTurbolinksHandlers();\n\n } catch {\n // Don't throw - let the controller degrade gracefully\n }\n }\n\n disconnect() {\n // Clean up on disconnect\n this.cleanup();\n\n // Remove Turbolinks handlers\n this.removeTurbolinksHandlers();\n }\n\n /**\n * Initialize the select element\n * Will use Select2 if available, otherwise native\n */\n initializeSelect() {\n\n // Clean up any existing initialization\n this.cleanup();\n\n if (this.isSelect2Available()) {\n this.initializeEnhanced();\n } else {\n this.initializeNative();\n }\n }\n\n /**\n * Clean up any existing initialization\n */\n cleanup() {\n // Remove native event listeners if they exist\n if (this.handleNativeChange && this.selectElement) {\n this.selectElement.removeEventListener('change', this.handleNativeChange);\n this.handleNativeChange = null;\n }\n\n // Clean up enhanced select if needed\n if (this.isSelect2Available() && this.isEnhanced()) {\n try {\n // Use the enhancer's cleanup method\n this.getEnhancer()(this.selectElement).select2('destroy');\n } catch {\n // Ignore errors during cleanup\n }\n }\n }\n\n /**\n * Check if Select2 enhancement is available\n * This method checks for jQuery and Select2 without directly depending on them\n */\n isSelect2Available() {\n try {\n return !!(window.$ && window.$.fn && window.$.fn.select2);\n } catch {\n return false;\n }\n }\n\n /**\n * Get the enhancer function (jQuery) if available\n * This isolates jQuery access to a single method\n */\n getEnhancer() {\n return window.$;\n }\n\n /**\n * Check if this element has been enhanced with Select2\n */\n isEnhanced() {\n if (!this.isSelect2Available()) return false;\n\n try {\n const enhancer = this.getEnhancer();\n const enhanced = enhancer(this.selectElement);\n return enhanced.hasClass('select2-hidden-accessible');\n } catch {\n return false;\n }\n }\n\n /**\n * Initialize as enhanced select (Select2)\n */\n initializeEnhanced() {\n try {\n\n // Double-check we have an element\n if (!this.selectElement) {\n this.initializeNative();\n return;\n }\n\n const enhancer = this.getEnhancer();\n const options = this.getEnhancementOptions();\n\n // Temporarily make element visible if it's hidden (Select2 needs visible elements)\n const wasInvisible = this.selectElement.classList && this.selectElement.classList.contains('ae-invisible');\n if (wasInvisible) {\n this.selectElement.classList.remove('ae-invisible');\n this.selectElement.style.visibility = 'visible';\n this.selectElement.style.display = 'block';\n }\n\n // Initialize Select2\n enhancer(this.selectElement).select2(options);\n\n // Restore invisible state if needed (Select2 will handle visibility)\n if (wasInvisible) {\n // Don't add ae-invisible back as Select2 manages visibility\n this.selectElement.style.visibility = '';\n this.selectElement.style.display = '';\n }\n\n // Set up event handlers using Select2's event system\n this.setupEnhancedEvents();\n } catch {\n // Fall back to native select\n this.initializeNative();\n }\n }\n\n /**\n * Initialize as native select\n */\n initializeNative() {\n if (!this.selectElement) return;\n\n // Store bound handler so we can remove it later\n this.handleNativeChange = (e) => this.onSelectChange(e);\n this.selectElement.addEventListener('change', this.handleNativeChange);\n }\n\n /**\n * Get options for Select2\n * Override this method to provide custom options\n */\n getEnhancementOptions() {\n return {\n width: '100%',\n };\n }\n\n /**\n * Set up enhanced select event handlers\n */\n setupEnhancedEvents() {\n if (!this.isSelect2Available()) return;\n\n try {\n const enhancer = this.getEnhancer();\n const element = enhancer(this.selectElement);\n\n // Use Select2's event system\n element.on('select2:select', (e) => {\n // Convert to a native-like event\n const event = new CustomEvent('change', {\n bubbles: true,\n detail: {\n value: e.params?.data?.id,\n data: e.params?.data,\n },\n });\n // Copy the target reference\n Object.defineProperty(event, 'target', {\n value: this.selectElement,\n enumerable: true,\n });\n this.onSelectChange(event);\n });\n\n element.on('select2:unselect', (e) => {\n // Convert to a native-like event\n const event = new CustomEvent('change', {\n bubbles: true,\n detail: {\n value: null,\n data: e.params?.data,\n },\n });\n Object.defineProperty(event, 'target', {\n value: this.selectElement,\n enumerable: true,\n });\n this.onSelectChange(event);\n });\n } catch {\n // Silently continue without Select2 events\n }\n }\n\n /**\n * Handle select change event\n * Override this method to handle selection changes\n * This receives native events or converted Select2 events\n */\n onSelectChange(_event) {\n // Override in child controller\n }\n\n /**\n * Set up Turbolinks cleanup handlers\n */\n setupTurbolinksHandlers() {\n this.turbolinksBeforeCache = () => this.handleTurbolinksBeforeCache();\n document.addEventListener('turbolinks:before-cache', this.turbolinksBeforeCache);\n }\n\n /**\n * Remove Turbolinks cleanup handlers\n */\n removeTurbolinksHandlers() {\n if (this.turbolinksBeforeCache) {\n document.removeEventListener('turbolinks:before-cache', this.turbolinksBeforeCache);\n }\n }\n\n /**\n * Handle Turbolinks before-cache event\n */\n handleTurbolinksBeforeCache() {\n this.cleanup();\n }\n\n /**\n * Get the current value\n * Works with both enhanced and native select\n */\n getValue() {\n if (this.isEnhanced()) {\n try {\n return this.getEnhancer()(this.selectElement).val();\n } catch {\n // Fall back to native\n }\n }\n return this.selectElement.value;\n }\n\n /**\n * Set the value\n * Works with both enhanced and native select\n */\n setValue(value) {\n if (this.isEnhanced()) {\n try {\n this.getEnhancer()(this.selectElement).val(value).trigger('change');\n return;\n } catch {\n // Fall back to native\n }\n }\n\n this.selectElement.value = value;\n this.selectElement.dispatchEvent(new Event('change', { bubbles: true }));\n }\n\n /**\n * Enable the select\n */\n enable() {\n this.selectElement.disabled = false;\n }\n\n /**\n * Disable the select\n */\n disable() {\n this.selectElement.disabled = true;\n }\n\n /**\n * Clear the selection\n */\n clear() {\n this.setValue('');\n }\n}\n", "import Select2BaseController from './select2_base_controller';\n\nexport default class extends Select2BaseController {\n connect() {\n super.connect();\n\n this.saleId = this.element.dataset.saleId;\n this.orderNumber = this.element.dataset.orderNumber;\n\n // Track modal state to prevent conflicts\n this.modalState = {\n isOpen: false,\n currentModal: null,\n pendingStatus: null,\n };\n\n // Set up event listeners for modal state management\n this.setupModalEventListeners();\n }\n\n disconnect() {\n super.disconnect();\n\n // Clean up event listeners\n if (this.boundHandleModalCancelled) {\n document.removeEventListener('refund-modal:cancelled', this.boundHandleModalCancelled);\n document.removeEventListener('ajax-popup:afterClose', this.boundHandleModalCancelled);\n }\n }\n\n getEnhancementOptions() {\n return {\n ...super.getEnhancementOptions(),\n placeholder: 'Select Status',\n templateResult: this.customStatusTemplate.bind(this),\n };\n }\n\n customStatusTemplate(state) {\n if (!state.id) {\n return state.text;\n }\n\n if (state.id === 'clear') {\n const span = document.createElement('span');\n span.style.color = 'red';\n\n // Create icon element safely\n const icon = document.createElement('i');\n icon.className = 'fas fa-window-close';\n\n // Add text content safely (no XSS risk)\n const textNode = document.createTextNode(state.text);\n\n span.appendChild(icon);\n span.appendChild(textNode);\n return span;\n } else {\n return state.text;\n }\n }\n\n setupModalEventListeners() {\n // Listen for modal cancellation events\n this.boundHandleModalCancelled = this.handleModalCancelled.bind(this);\n document.addEventListener('refund-modal:cancelled', this.boundHandleModalCancelled);\n\n // Listen for AJAX popup events\n document.addEventListener('ajax-popup:afterClose', this.boundHandleModalCancelled);\n }\n\n onSelectChange(event) {\n const target = event.target;\n const status = target.value;\n\n if (!status) return;\n\n // Make sure we have the required data\n if (!this.saleId || !this.orderNumber) {\n return;\n }\n\n // Check if a modal is already open for this order\n if (this.modalState.isOpen) {\n // Reset dropdown to previous state and exit\n target.value = this.element.dataset.currentStatus;\n return;\n }\n\n const statusHandlers = {\n 'refunded': this.handleRefunded,\n 'partially_fulfilled': this.handlePartiallyFulfilled,\n 'clear': this.handleClear,\n 'fulfilled': this.handleFulfilled,\n };\n\n const handler = statusHandlers[status];\n\n if (!handler) return;\n\n // Store the pending status\n this.modalState.pendingStatus = status;\n\n handler.call(this, target);\n }\n\n handleRefunded(target) {\n const url = `/teamsites/${this.saleId}/orders/${this.orderNumber}/fulfillments/refund`;\n const modalId = '#refund-fulfillment-popup';\n\n // Mark modal as opening\n this.modalState.isOpen = true;\n this.modalState.currentModal = 'refund';\n\n this.showModal(url, {}, modalId);\n }\n\n handleModalCancelled(event) {\n // Only handle events for this specific order\n if (event.detail && event.detail.orderNumber && event.detail.orderNumber !== this.orderNumber) {\n return;\n }\n\n // Reset modal state\n this.modalState.isOpen = false;\n this.modalState.currentModal = null;\n this.modalState.pendingStatus = null;\n\n // Reset dropdown using Select2BaseController's proper API\n this.resetDropdownState();\n }\n\n resetDropdownState() {\n // Validate we have an element first\n if (!this.element) {\n return;\n }\n\n try {\n // Get the original status from data attribute\n const originalStatus = this.element.dataset.currentStatus;\n\n if (originalStatus && originalStatus.trim() !== '') {\n // Use Select2BaseController's setValue method which handles both Select2 and native selects\n this.setValue(originalStatus);\n } else {\n // Fallback: clear the selection if no original status is available\n this.clear();\n }\n } catch (error) {\n // Fallback to manual reset if Select2BaseController methods fail\n try {\n if (this.element.dataset.currentStatus) {\n this.element.value = this.element.dataset.currentStatus;\n this.element.dispatchEvent(new Event('change', { bubbles: true }));\n } else {\n this.element.value = '';\n this.element.dispatchEvent(new Event('change', { bubbles: true }));\n }\n } catch (fallbackError) {\n // Silent failure - don't break the application\n }\n }\n }\n\n handleClear() {\n const url = `${this.baseFulfillmentsUrl(this.saleId, this.orderNumber)}/clear`;\n\n // Mark modal as opening\n this.modalState.isOpen = true;\n this.modalState.currentModal = 'clear';\n\n this.showModal(url, {}, '#reset-fulfillment-popup');\n }\n\n handlePartiallyFulfilled() {\n const url = `${this.baseFulfillmentsUrl(this.saleId, this.orderNumber)}/partial`;\n\n // Mark modal as opening\n this.modalState.isOpen = true;\n this.modalState.currentModal = 'partial';\n\n this.showModal(url, {}, '#partial-fulfillments-popup');\n }\n\n handleFulfilled(target) {\n // Mark modal as opening\n this.modalState.isOpen = true;\n this.modalState.currentModal = 'fulfilled';\n\n if (target.dataset.currentStatus === 'partially_fulfilled') {\n const url = `${this.baseFulfillmentsUrl(this.saleId, this.orderNumber)}/change`;\n this.showModal(url, { s: 'fulfilled' }, '#confirm-fulfillment-update-popup');\n } else {\n const url = `${this.baseFulfillmentsUrl(this.saleId, this.orderNumber)}/confirm`;\n this.showModal(url, {}, '#confirm-fulfill-order-popup');\n }\n }\n\n baseFulfillmentsUrl(saleId, orderNumber) {\n return `/teamsites/${saleId}/orders/${orderNumber}/fulfillments`;\n }\n\n commit(url, orderNumber, data = {}) {\n return fetch(url, {\n method: 'PATCH',\n headers: {\n 'Content-Type': 'application/json',\n 'X-CSRF-Token': document.querySelector('meta[name=\"csrf-token\"]')?.content || '',\n },\n body: JSON.stringify(data),\n })\n .then(response => {\n if (response.ok) {\n // Close modal if it exists\n const closeButton = document.querySelector('.mfp-close');\n if (closeButton) closeButton.click();\n\n // Trigger row reload event\n const orderRow = document.querySelector(`#order-row-${orderNumber}`);\n if (orderRow) {\n orderRow.dispatchEvent(new CustomEvent('orders:row:reload', { bubbles: true }));\n }\n\n // Show success message\n this.showSuccessFlash('Order updated');\n } else {\n throw new Error('Request failed');\n }\n })\n .catch(() => {\n this.showErrorFlash('Something went wrong');\n });\n }\n\n showModal(url, params, modalId) {\n // Import and use the AjaxPopupController to show the modal\n import('./ajax_popup_controller').then(({ default: AjaxPopupController }) => {\n // Build URL with params\n const urlObj = new URL(url, window.location.origin);\n Object.keys(params).forEach(key => {\n urlObj.searchParams.append(key, params[key]);\n });\n\n // Use static method to open the modal\n AjaxPopupController.open(urlObj.toString());\n });\n }\n\n showSuccessFlash(message) {\n const alertDiv = document.createElement('div');\n alertDiv.className = 'ae-alert ae-alert-success';\n alertDiv.textContent = message;\n alertDiv.style.cssText = 'position:fixed;top:20px;right:20px;z-index:9999;';\n\n document.body.appendChild(alertDiv);\n\n // Remove after 3 seconds\n setTimeout(() => {\n if (alertDiv.parentNode) {\n alertDiv.remove();\n }\n }, 3000);\n }\n\n showErrorFlash(message) {\n const alertDiv = document.createElement('div');\n alertDiv.className = 'ae-alert ae-alert-danger';\n alertDiv.textContent = message;\n alertDiv.style.cssText = 'position:fixed;top:20px;right:20px;z-index:9999;';\n\n document.body.appendChild(alertDiv);\n\n // Remove after 5 seconds (longer for errors)\n setTimeout(() => {\n if (alertDiv.parentNode) {\n alertDiv.remove();\n }\n }, 5000);\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n connect() {\n // Setup targets using Stimulus 1.1.0 syntax\n this.setupTargets();\n this.initializeElements();\n this.bindEvents();\n }\n\n disconnect() {\n if (this.quantityInput) {\n this.quantityInput.removeEventListener('input', this.boundHandleQuantity);\n }\n }\n\n setupTargets() {\n // Manual target setup for Stimulus 1.1.0 compatibility\n this.quantityInput = this.element.querySelector('[data-target*=\"quantity\"]');\n this.statusElement = this.element.querySelector('[data-target*=\"status\"]');\n this.errorElement = this.element.querySelector('[data-target*=\"error\"]');\n this.noteElement = this.element.querySelector('[data-target*=\"note\"]');\n this.tooltipElement = this.element.querySelector('.ae-tooltip-icon');\n }\n\n initializeElements() {\n // Store bound method reference for cleanup\n this.boundHandleQuantity = this.handleQuantity.bind(this);\n }\n\n bindEvents() {\n if (this.quantityInput) {\n this.quantityInput.addEventListener('input', this.boundHandleQuantity);\n }\n }\n\n handleQuantity(event) {\n const input = event.target;\n const maxQuantity = parseInt(input.dataset.maxQuantity);\n let inputValue = parseInt(input.value) || 0;\n\n // Enforce max quantity constraint (match baseline behavior)\n if (inputValue > maxQuantity) {\n input.value = maxQuantity;\n inputValue = maxQuantity;\n }\n\n // Prevent negative values\n if (inputValue < 0) {\n input.value = 0;\n inputValue = 0;\n }\n\n const isValid = this.validateQuantity(input.value);\n\n if (isValid) {\n // Always update status and tooltip when valid, matching baseline\n this.updateStatus(inputValue, maxQuantity);\n this.updateTooltip(inputValue, maxQuantity);\n }\n\n this.showError(isValid);\n }\n\n validateQuantity(value) {\n // Check if value is a valid number\n const numValue = parseInt(value);\n return !isNaN(numValue) && numValue >= 0;\n }\n\n updateStatus(inputValue, maxQuantity) {\n if (!this.statusElement) return;\n\n // Match baseline logic exactly: compare input value to max quantity\n if (inputValue === maxQuantity) {\n this.statusElement.textContent = 'Fulfilled';\n // Set the complete class structure for fulfilled status\n this.statusElement.className = 'ae-status ae-status-fixed ae-status-order-fulfillment-status-fulfilled';\n } else {\n this.statusElement.textContent = 'Partially Fulfilled';\n // Set the complete class structure for partially fulfilled status\n this.statusElement.className = 'ae-status ae-status-fixed ae-alert-pending';\n }\n }\n\n updateTooltip(inputValue, maxQuantity) {\n if (!this.tooltipElement) return;\n\n const remainingQuantity = maxQuantity - inputValue;\n const currentDate = new Date().toLocaleDateString();\n\n let tooltipText = '';\n if (remainingQuantity > 0) {\n tooltipText += `${remainingQuantity} back-ordered
    `;\n }\n\n const status = (remainingQuantity === 0 && inputValue > 0) ? 'Fulfilled' : 'Partially Fulfilled';\n tooltipText += `${status} ${currentDate}`;\n\n this.tooltipElement.setAttribute('title', tooltipText);\n }\n\n showError(isValid) {\n if (!this.errorElement) return;\n\n if (isValid) {\n this.errorElement.style.display = 'none';\n this.errorElement.textContent = '';\n } else {\n this.errorElement.style.display = 'block';\n this.errorElement.textContent = 'Please enter a valid quantity';\n }\n }\n\n // Method to get row data for form submission\n getRowData() {\n const dataset = this.element.dataset;\n\n return {\n product_id: dataset.productId,\n product_name: dataset.productName,\n product_size: dataset.productSize,\n product_style_number: dataset.productStyleNumber,\n product_color: dataset.productColor,\n line_item_id: dataset.lineItemId,\n note: this.noteElement ? this.noteElement.value : '',\n partial_quantity: this.quantityInput ? parseInt(this.quantityInput.value) || 0 : 0,\n item_quantity: parseInt(dataset.itemQuantity) || 0,\n };\n }\n\n // Method to validate this row for form submission\n isValidForSubmission() {\n const quantity = this.quantityInput ? parseInt(this.quantityInput.value) || 0 : 0;\n const maxQuantity = this.quantityInput ? parseInt(this.quantityInput.dataset.maxQuantity) || 0 : 0;\n\n return quantity >= 0 && quantity <= maxQuantity;\n }\n\n // Method to clear any errors\n clearErrors() {\n this.showError(true);\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n connect() {\n // Controller is ready\n this.submitTimeout = null;\n this.currentButton = null;\n this.originalButtonText = null;\n }\n\n disconnect() {\n // Clean up any event listeners and pending timeouts\n if (this.submitTimeout) {\n if (typeof window.clearTimeout === 'function') {\n window.clearTimeout(this.submitTimeout);\n } else if (typeof clearTimeout === 'function') {\n clearTimeout(this.submitTimeout);\n }\n this.submitTimeout = null;\n }\n }\n\n confirm(event) {\n event.preventDefault();\n\n // Use element.dataset directly instead of this.data.get() for reliability\n const orderNumber = this.element.dataset.orderNumber;\n const saleId = this.element.dataset.saleId;\n\n if (!orderNumber || !saleId) {\n // Missing order number or sale ID\n return;\n }\n\n // Collect line items data from the form\n const lineItemsData = this.collectLineItemsData();\n\n if (lineItemsData.length === 0) {\n this.showNotification('error', 'No line items found to update fulfillment status');\n return;\n }\n\n // Clear any existing timeout\n if (this.submitTimeout) {\n if (typeof window.clearTimeout === 'function') {\n window.clearTimeout(this.submitTimeout);\n } else if (typeof clearTimeout === 'function') {\n clearTimeout(this.submitTimeout);\n }\n this.submitTimeout = null;\n }\n\n // Store button state\n const button = event.target;\n this.currentButton = button;\n this.originalButtonText = button.textContent;\n\n // Show debounce feedback\n button.disabled = true;\n button.textContent = 'Processing in 2s...';\n\n // Set debounce timeout\n this.submitTimeout = (typeof window.setTimeout === 'function' ? window.setTimeout : setTimeout)(() => {\n // Update to processing state\n button.textContent = 'Processing...';\n\n const requestData = {\n line_items: lineItemsData,\n };\n\n const csrfToken = document.querySelector('meta[name=\"csrf-token\"]');\n const headers = {\n 'Accept': 'application/json',\n 'Content-Type': 'application/json',\n };\n\n if (csrfToken) {\n headers['X-CSRF-Token'] = csrfToken.getAttribute('content');\n }\n\n fetch(`/teamsites/${saleId}/orders/${orderNumber}/fulfillments/partial`, {\n method: 'PATCH',\n headers: headers,\n body: JSON.stringify(requestData),\n })\n .then(response => response.json())\n .then(data => {\n if (data.status === 'success') {\n this.handleSuccess(data);\n } else {\n this.handleError(data.message || 'Failed to update partial fulfillment');\n }\n // Re-enable button and reset state\n if (this.currentButton) {\n this.currentButton.disabled = false;\n this.currentButton.textContent = this.originalButtonText;\n this.currentButton = null;\n this.originalButtonText = null;\n }\n })\n .catch(_error => {\n // Partial fulfillment request failed\n this.handleError('Network error occurred');\n // Re-enable button and reset state\n if (this.currentButton) {\n this.currentButton.disabled = false;\n this.currentButton.textContent = this.originalButtonText;\n this.currentButton = null;\n this.originalButtonText = null;\n }\n });\n\n // Clear timeout reference\n this.submitTimeout = null;\n }, 2000); // 2 second debounce\n }\n\n collectLineItemsData() {\n const lineItems = [];\n const lineItemRows = this.element.querySelectorAll('[data-controller*=\"order-fulfillment-line-item-row\"]');\n\n lineItemRows.forEach(row => {\n const quantityInput = row.querySelector('[data-target*=\"quantity\"]');\n const notesTextarea = row.querySelector('textarea');\n\n // Include items with qty >= 0 to allow tracking \"0 fulfilled out of N\"\n if (quantityInput && quantityInput.value !== '' && parseInt(quantityInput.value) >= 0) {\n // Extract data attributes from the row\n const lineItemData = {\n line_item_id: row.dataset.lineItemId,\n product_id: row.dataset.productId,\n product_name: row.dataset.productName,\n product_color: row.dataset.productColor,\n product_style_number: row.dataset.productStyleNumber,\n product_size: row.dataset.productSize,\n partial_quantity: parseInt(quantityInput.value),\n item_quantity: parseInt(row.dataset.itemQuantity || quantityInput.max),\n note: notesTextarea ? notesTextarea.value : '',\n };\n\n lineItems.push(lineItemData);\n }\n });\n\n return lineItems;\n }\n\n handleSuccess(data) {\n // Show success message\n this.showNotification('success', data.message || 'Partial fulfillment updated successfully');\n\n // Close modal\n this.closeModal();\n\n // Trigger page refresh after a short delay to allow the notification to be seen\n // This will update the fulfillment status display\n if (window.location.pathname.includes('/orders')) {\n (typeof window.setTimeout === 'function' ? window.setTimeout : setTimeout)(() => {\n window.location.reload();\n }, 1000);\n }\n }\n\n handleError(message) {\n this.showNotification('error', message);\n }\n\n showNotification(type, message) {\n // Create notification element\n const notification = document.createElement('div');\n notification.className = `ae-alert ae-alert-${type === 'success' ? 'success' : 'danger'}`;\n notification.style.cssText = 'position:fixed;top:20px;right:20px;z-index:10000;';\n notification.textContent = message;\n\n document.body.appendChild(notification);\n\n // Auto-remove after 5 seconds\n (typeof window.setTimeout === 'function' ? window.setTimeout : setTimeout)(() => {\n if (notification.parentNode) {\n notification.parentNode.removeChild(notification);\n }\n }, 5000);\n }\n\n closeModal() {\n // Find and click the close button to properly close the modal\n const closeButton = document.querySelector('.mfp-close');\n if (closeButton) {\n closeButton.click();\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n static get targets() { return ['reasonSelect', 'confirmButton', 'form']; }\n\n connect() {\n this.setupForm();\n this.setupCustomEventListener();\n this.updateButtonState();\n }\n\n disconnect() {\n // Clean up event listeners\n if (this.element && this.boundHandleReasonChanged) {\n this.element.removeEventListener('refund-reason-changed', this.boundHandleReasonChanged);\n }\n\n // Clean up any additional event listeners on form elements\n if (this.hasReasonSelectTarget && this.boundUpdateButtonState) {\n this.reasonSelectTarget.removeEventListener('change', this.boundUpdateButtonState);\n }\n }\n\n setupForm() {\n // Store bound function reference for proper cleanup\n this.boundUpdateButtonState = this.updateButtonState.bind(this);\n\n // Add change listener to reason select\n if (this.hasReasonSelectTarget) {\n this.reasonSelectTarget.addEventListener('change', this.boundUpdateButtonState);\n }\n }\n\n setupCustomEventListener() {\n // Listen for custom events from nested controllers\n this.boundHandleReasonChanged = this.handleReasonChanged.bind(this);\n this.element.addEventListener('refund-reason-changed', this.boundHandleReasonChanged);\n }\n\n handleReasonChanged(_event) {\n // Update button state based on custom event from nested controllers\n this.updateButtonState();\n }\n\n updateButtonState() {\n const reasonSelected = this.hasReasonSelectTarget && this.reasonSelectTarget.value !== '';\n\n if (this.hasConfirmButtonTarget) {\n this.confirmButtonTarget.disabled = !reasonSelected;\n }\n }\n\n confirm(event) {\n event.preventDefault();\n\n // Prevent double submission\n if (this.hasConfirmButtonTarget && this.confirmButtonTarget.dataset.refundProcessing === 'true') return;\n if (this.hasConfirmButtonTarget) this.confirmButtonTarget.dataset.refundProcessing = 'true';\n\n if (!this.hasFormTarget) {\n return;\n }\n\n const formData = new FormData(this.formTarget);\n // Use element.dataset directly instead of this.data.get() for reliability\n const orderNumber = this.element.dataset.orderNumber;\n const saleId = this.element.dataset.saleId;\n\n if (!orderNumber || !saleId) {\n return;\n }\n\n fetch(`/teamsites/${saleId}/orders/${orderNumber}/fulfillments/refund`, {\n method: 'PATCH',\n body: formData,\n headers: {\n 'X-CSRF-Token': document.querySelector('meta[name=\"csrf-token\"]')?.getAttribute('content'),\n 'Accept': 'application/json',\n },\n })\n .then(response => response.json())\n .then(data => {\n if (data.status === 'success') {\n this.handleSuccess(data);\n } else {\n this.handleError(data.message || 'Refund failed');\n }\n })\n .catch(_error => {\n // Refund request failed\n this.handleError('Network error occurred');\n });\n }\n\n handleSuccess(data) {\n // Show success message via unified flash system\n if (window.App && typeof window.App.showSuccessFlash === 'function') {\n window.App.showSuccessFlash(data.message || 'Order refunded successfully');\n }\n\n // Close modal\n this.closeModal();\n\n // Trigger page refresh after a delay to allow the flash to be seen\n // (flash renders in page layout outside modal; 2500ms gives time after ~400ms modal close animation)\n if (window.location.pathname.includes('/orders')) {\n setTimeout(() => {\n window.location.reload();\n }, 2500);\n }\n }\n\n handleError(message) {\n // Reset processing flag to allow retry (was in old handler's finally block)\n if (this.hasConfirmButtonTarget) {\n this.confirmButtonTarget.dataset.refundProcessing = '';\n }\n // Reset button-debounce to re-enable button immediately\n this.resetDebounce();\n\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash(message);\n }\n }\n\n resetDebounce() {\n if (!this.hasConfirmButtonTarget) return;\n const debounceController = this.application.getControllerForElementAndIdentifier(\n this.confirmButtonTarget,\n 'button-debounce',\n );\n if (debounceController) {\n debounceController.manualReset();\n }\n }\n\n closeModal() {\n // Find and click the close button to properly close the modal\n const closeButton = document.querySelector('.mfp-close');\n if (closeButton) {\n closeButton.click();\n }\n }\n\n cancel(event) {\n event.preventDefault();\n this.resetFormState();\n this.dispatchCancelEvent();\n this.closeModal();\n }\n\n resetFormState() {\n // Reset form when form target exists\n if (this.hasFormTarget) {\n this.formTarget.reset();\n }\n\n // Reset button state\n if (this.hasConfirmButtonTarget) {\n this.confirmButtonTarget.disabled = true;\n this.confirmButtonTarget.textContent = 'Process Refund';\n }\n\n // Reset reason select\n if (this.hasReasonSelectTarget) {\n this.reasonSelectTarget.value = '';\n }\n }\n\n dispatchCancelEvent() {\n const orderNumber = this.element.dataset.orderNumber;\n const saleId = this.element.dataset.saleId;\n\n const event = new CustomEvent('refund-modal:cancelled', {\n detail: {\n orderNumber: orderNumber,\n saleId: saleId,\n controller: this,\n },\n bubbles: true,\n cancelable: true,\n });\n\n // Dispatch on element if it has a parent, otherwise on document\n const target = this.element && this.element.parentNode ? this.element : document;\n target.dispatchEvent(event);\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n initialize() {\n // Find the products container that will be replaced with editable forms\n this.productsContainer = document.getElementById('paid-products-container');\n\n // Get sale and order information from current context\n const customerInfo = document.getElementById('customer-info');\n if (customerInfo) {\n this.saleId = customerInfo.dataset.saleId;\n this.orderNumber = customerInfo.dataset.orderNumber;\n } else {\n this.saleId = null;\n this.orderNumber = null;\n }\n }\n\n disconnect() {\n // Clean up any references or event listeners\n }\n\n click(event) {\n event.preventDefault();\n\n if (!this.saleId || !this.orderNumber) {\n return;\n }\n\n // Build the edit URL using Teamsites namespace\n const url = `/teamsites/${this.saleId}/orders/${this.orderNumber}/line_items/edit`;\n\n // Get CSRF token\n const csrfTokenMeta = document.querySelector('meta[name=\"csrf-token\"]');\n const csrfToken = csrfTokenMeta?.content;\n\n // Make AJAX request to load the edit form\n const headers = {\n 'Accept': 'application/json',\n 'Content-Type': 'application/json',\n };\n if (csrfToken) {\n headers['X-CSRF-Token'] = csrfToken;\n }\n\n // Return the fetch promise for testing\n return fetch(url, { headers })\n .then(response => {\n if (!response.ok) {\n throw new Error(`HTTP ${response.status}: ${response.statusText}`);\n }\n return response.json();\n })\n .then(data => {\n if (data.html) {\n this.replaceWithEditable(data.html);\n }\n })\n .catch(() => {\n // Silent fallback per production standards\n });\n }\n\n replaceWithEditable(html) {\n if (!this.productsContainer || !html) {\n return;\n }\n\n // Replace the inner content of the products container with the editable forms\n // The edit response contains the full form structure for all products\n this.productsContainer.innerHTML = html;\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n static get targets() {\n return ['saveButton'];\n }\n\n initialize() {\n // Controller is attached to the form element itself, so this.element IS the form\n // (see _products_editable_wrapper.html.erb:13 where data-controller is on the form tag)\n this.form = this.element;\n // Find the products container that will be replaced with view mode\n // This matches the edit button controller pattern (order_line_items_edit_button_controller.js:6)\n this.container = document.getElementById('paid-products-container');\n\n // Capture original form values for change detection\n this.captureOriginalValues();\n\n // Bind handleInputChange to preserve 'this' context\n this.boundHandleInputChange = this.handleInputChange.bind(this);\n }\n\n connect() {\n // Set up event delegation for input changes\n // Stimulus 1.1.0 doesn't reliably handle form-level data-action for input events\n // So we manually add event listeners\n // Listen for both 'input' (text fields) and 'change' (number fields, selects) events\n this.form.addEventListener('input', this.boundHandleInputChange);\n this.form.addEventListener('change', this.boundHandleInputChange);\n }\n\n captureOriginalValues() {\n this.originalValues = new Map();\n\n // Capture all input, select, and textarea values\n const inputs = this.form.querySelectorAll('input[type=\"number\"], input[type=\"text\"], input[type=\"hidden\"], select, textarea');\n inputs.forEach((input) => {\n // Use a unique identifier for each input\n const identifier = input.name || input.id;\n if (identifier) {\n this.originalValues.set(identifier, input.value);\n }\n });\n }\n\n handleInputChange() {\n this.checkForChanges();\n }\n\n checkForChanges() {\n let hasChanges = false;\n\n // Check all inputs for changes\n const inputs = this.form.querySelectorAll('input[type=\"number\"], input[type=\"text\"], input[type=\"hidden\"], select, textarea');\n for (const input of inputs) {\n const identifier = input.name || input.id;\n if (identifier) {\n const originalValue = this.originalValues.get(identifier);\n const currentValue = input.value;\n\n if (originalValue !== currentValue) {\n hasChanges = true;\n break;\n }\n }\n }\n\n this.updateButtonState(hasChanges);\n }\n\n updateButtonState(hasChanges) {\n if (!this.hasSaveButtonTarget) {\n return;\n }\n\n if (hasChanges) {\n this.saveButtonTarget.disabled = false;\n } else {\n this.saveButtonTarget.disabled = true;\n }\n }\n\n disconnect() {\n // Clean up event listeners\n if (this.form && this.boundHandleInputChange) {\n this.form.removeEventListener('input', this.boundHandleInputChange);\n this.form.removeEventListener('change', this.boundHandleInputChange);\n }\n }\n\n cancel(event) {\n event.preventDefault();\n\n // Get sale and order information from form action URL\n const formAction = this.form?.action;\n if (!formAction) {\n return;\n }\n\n // Extract sale ID and order number from the URL path\n const urlMatch = formAction.match(/\\/teamsites\\/(\\d+)\\/orders\\/([^/]+)\\/line_items/);\n if (!urlMatch) {\n return;\n }\n\n const saleId = urlMatch[1];\n const orderNumber = urlMatch[2];\n\n // Build the display URL\n const url = `/teamsites/${saleId}/orders/${orderNumber}/line_items`;\n\n // Get CSRF token\n const csrfTokenMeta = document.querySelector('meta[name=\"csrf-token\"]');\n const csrfToken = csrfTokenMeta?.content;\n\n // Make AJAX request to load the display version\n const headers = {\n 'Accept': 'application/json',\n 'Content-Type': 'application/json',\n };\n if (csrfToken) {\n headers['X-CSRF-Token'] = csrfToken;\n }\n\n // Return the fetch promise for testing\n return fetch(url, { headers })\n .then(response => {\n if (!response.ok) {\n throw new Error(`HTTP ${response.status}: ${response.statusText}`);\n }\n return response.json();\n })\n .then(data => {\n if (data.html) {\n this.replaceWithDisplay(data.html);\n }\n })\n .catch(() => {\n // Silent fallback per production standards\n });\n }\n\n replaceWithDisplay(html) {\n if (!this.container || !html) {\n return;\n }\n\n // Replace the inner content of the products container with the view mode\n // This matches the edit button controller pattern (order_line_items_edit_button_controller.js:71)\n this.container.innerHTML = html;\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n // Note: Submit is handled via data-action=\"submit->order-line-items-form#submit\"\n // No manual addEventListener needed - Stimulus handles the binding\n\n connect() {\n this.isSubmitting = false;\n }\n\n submit(event) {\n event.preventDefault();\n\n // Guard against double submission\n if (this.isSubmitting) {\n return;\n }\n this.isSubmitting = true;\n\n const formData = new FormData(this.element);\n const url = this.element.action;\n\n // Show loading state\n this.setLoadingState(true);\n\n // Get CSRF token\n const csrfTokenMeta = document.querySelector('meta[name=\"csrf-token\"]');\n const csrfToken = csrfTokenMeta?.content;\n\n fetch(url, {\n method: 'PATCH',\n body: formData,\n headers: {\n 'X-Requested-With': 'XMLHttpRequest',\n 'Accept': 'application/json',\n 'X-CSRF-Token': csrfToken,\n },\n })\n .then(response => {\n if (!response.ok) {\n throw new Error(`HTTP ${response.status}: ${response.statusText}`);\n }\n return response.json();\n })\n .then(data => {\n if (data.success) {\n // Show success flash\n if (window.App && typeof window.App.showSuccessFlash === 'function') {\n window.App.showSuccessFlash(data.message || 'Line items updated successfully');\n }\n\n // Always reload page to show updated values and avoid innerHTML parsing issues\n // Small delay to ensure flash message is visible\n // Don't reset loading state - page is reloading\n setTimeout(() => {\n window.location.reload();\n }, 500);\n } else {\n // Show error flash with detailed messages\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n const errorMessage = data.errors?.join(', ') || 'Unable to save changes';\n window.App.showErrorFlash(errorMessage);\n }\n\n // If we have error HTML, update the form to show validation errors\n if (data.html) {\n this.replaceFormWithErrorDisplay(data.html);\n }\n\n // Reset loading state and submission guard on error (we're staying on page)\n this.setLoadingState(false);\n this.isSubmitting = false;\n }\n })\n .catch(_error => {\n // Show generic error for network/unexpected failures\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash('Unable to save changes. Please try again.');\n }\n\n // Reset loading state and submission guard on error (we're staying on page)\n this.setLoadingState(false);\n this.isSubmitting = false;\n });\n }\n\n setLoadingState(isLoading) {\n const submitButton = this.element.querySelector('input[type=\"submit\"], button[type=\"submit\"]');\n if (submitButton) {\n submitButton.disabled = isLoading;\n if (isLoading) {\n // Store original value before changing\n if (submitButton.tagName === 'INPUT') {\n submitButton.dataset.originalValue = submitButton.value;\n submitButton.value = 'Saving...';\n } else {\n submitButton.dataset.originalValue = submitButton.textContent;\n submitButton.textContent = 'Saving...';\n }\n } else {\n // Restore original value\n if (submitButton.tagName === 'INPUT') {\n submitButton.value = submitButton.dataset.originalValue || 'Save Changes';\n } else {\n submitButton.textContent = submitButton.dataset.originalValue || 'Save Changes';\n }\n // Clean up the stored value\n delete submitButton.dataset.originalValue;\n }\n }\n }\n\n replaceFormWithErrorDisplay(html) {\n // Replace the entire form to show validation errors\n const formContainer = this.element.parentElement;\n if (formContainer && html) {\n const temp = document.createElement('div');\n temp.innerHTML = html;\n const newForm = temp.firstElementChild;\n if (newForm) {\n this.element.replaceWith(newForm);\n }\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\n/**\n * Line Item Partial Refund Modal Controller\n *\n * Handles the refund confirmation modal for individual line items.\n * Works in conjunction with AjaxPopupController which loads the modal content.\n *\n * ## Usage\n *\n * This controller is automatically bound to the modal loaded by ajax-popup controller.\n * The modal partial at app/views/shared/_order_partial_refund_modal.html.erb\n * includes data-controller=\"order-partial-refund-modal\".\n *\n * ## Features\n *\n * - Form submission via fetch() API\n * - Success/error notifications\n * - Page reload after successful refund\n * - Modal close handling\n */\nexport default class extends Controller {\n static targets = ['button'];\n\n connect() {\n // Controller is ready\n }\n\n disconnect() {\n // Clean up any event listeners\n }\n\n /**\n * Handle refund button click - submit the form\n * @param {Event} event - The click event\n */\n async refund(event) {\n event.preventDefault();\n\n if (!this.hasButtonTarget) {\n return;\n }\n\n // Get data attributes from modal element\n const saleId = this.element.dataset.saleId;\n const orderNumber = this.element.dataset.orderNumber;\n const lineItemId = this.element.dataset.lineItemId;\n\n if (!saleId || !orderNumber || !lineItemId) {\n console.error('[REFUND] Missing required data:', { saleId, orderNumber, lineItemId });\n this.showErrorMessage('Missing required information. Please try again.');\n return;\n }\n\n\n // Disable button during processing\n const originalText = this.buttonTarget.textContent;\n this.buttonTarget.disabled = true;\n this.buttonTarget.querySelector('span').textContent = 'Processing...';\n\n try {\n const url = `/teamsites/${saleId}/orders/${orderNumber}/partial_refund`;\n const csrfToken = document.querySelector('meta[name=\"csrf-token\"]')?.getAttribute('content');\n\n\n const response = await fetch(url, {\n method: 'POST',\n headers: {\n 'X-CSRF-Token': csrfToken,\n 'Content-Type': 'application/json',\n 'Accept': 'application/json',\n },\n body: JSON.stringify({\n line_item_id: lineItemId,\n }),\n credentials: 'same-origin',\n });\n\n const data = await response.json();\n\n\n if (response.ok && data.success) {\n this.handleSuccess(data.message || 'Line item refunded successfully');\n } else {\n // Pass full data object for enhanced error display\n this.handleError(data.message || 'Refund failed. Please try again.', data);\n }\n } catch (error) {\n console.error('[REFUND] Network error:', error);\n this.showErrorMessage('Network error occurred. Please try again.');\n } finally {\n // Re-enable button\n if (this.hasButtonTarget && this.buttonTarget) {\n this.buttonTarget.disabled = false;\n if (this.buttonTarget.querySelector('span')) {\n this.buttonTarget.querySelector('span').textContent = originalText.trim();\n }\n }\n }\n }\n\n /**\n * Handle cancel button click - close modal\n * @param {Event} event - The click event\n */\n cancel(event) {\n event.preventDefault();\n this.closeModal();\n }\n\n /**\n * Handle successful refund\n * @param {string} message - Success message\n * @private\n */\n handleSuccess(message) {\n this.showSuccessMessage(message);\n this.closeModal();\n\n // Reload page after short delay to update line items display\n setTimeout(() => {\n window.location.reload();\n }, 1000);\n }\n\n /**\n * Handle refund error\n * @param {string} message - Error message\n * @param {Object} data - Full response data with potential debug info\n * @private\n */\n handleError(message, data = {}) {\n // Log error details for debugging\n console.error('[REFUND] Refund failed:', message);\n\n // Log debug info if available (development mode)\n if (data.debug_info) {\n console.error('[REFUND] Debug information:', data.debug_info);\n }\n\n // Display error message to user\n this.showErrorMessage(message);\n\n // IMPORTANT: Keep modal open on error so user can see what went wrong\n // Do NOT call closeModal() here - let user manually close the modal\n }\n\n /**\n * Show success message using unified flash system\n * @param {string} message - Success message to display\n * @private\n */\n showSuccessMessage(message) {\n // Use App.showSuccessFlash if available for consistency\n if (window.App && window.App.showSuccessFlash) {\n window.App.showSuccessFlash(message);\n } else {\n // Fallback: Dispatch event for flash message\n const event = new CustomEvent('flash-message', {\n detail: {\n type: 'success',\n message: message,\n },\n });\n document.dispatchEvent(event);\n }\n }\n\n /**\n * Show error message using unified flash system\n * @param {string} message - Error message to display\n * @private\n */\n showErrorMessage(message) {\n // Use App.showErrorFlash if available for consistency\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash(message);\n } else {\n // Fallback: Dispatch event for flash message\n const event = new CustomEvent('flash-message', {\n detail: {\n type: 'error',\n message: message,\n },\n });\n document.dispatchEvent(event);\n }\n }\n\n /**\n * Close the modal by triggering close button\n * @private\n */\n closeModal() {\n // Find and click the modal close button\n const closeButton = document.querySelector('.mfp-close');\n if (closeButton) {\n closeButton.click();\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\n/**\n * Orders Filter Controller\n *\n * Provides real-time filtering of orders across all payment status sections\n * by customer name, player name, and order number.\n */\nexport default class extends Controller {\n static targets = ['input', 'section', 'emptyState', 'content'];\n\n connect() {\n this.lastSearchTerm = '';\n this.debounceTimeout = null;\n }\n\n disconnect() {\n if (this.debounceTimeout) {\n clearTimeout(this.debounceTimeout);\n this.debounceTimeout = null;\n }\n }\n\n /**\n * Public action handler - debounces calls to performFilter\n */\n filter() {\n if (this.debounceTimeout) {\n clearTimeout(this.debounceTimeout);\n }\n\n const self = this;\n this.debounceTimeout = setTimeout(function() {\n self.performFilter();\n }, 150);\n }\n\n /**\n * Performs the actual filtering logic across all sections\n */\n performFilter() {\n const searchTerm = this.inputTarget.value.toLowerCase().trim();\n\n if (searchTerm === this.lastSearchTerm) {\n return;\n }\n this.lastSearchTerm = searchTerm;\n\n let totalVisibleCount = 0;\n\n for (let i = 0; i < this.sectionTargets.length; i++) {\n const section = this.sectionTargets[i];\n const sectionVisibleCount = this.filterSection(section, searchTerm);\n totalVisibleCount += sectionVisibleCount;\n }\n\n this.updateEmptyState(totalVisibleCount);\n }\n\n /**\n * Filter rows within a single section\n * Returns count of visible rows\n */\n filterSection(section, searchTerm) {\n const rows = section.querySelectorAll('tr[data-orders-filter-searchable]');\n let visibleCount = 0;\n\n for (let j = 0; j < rows.length; j++) {\n const row = rows[j];\n const searchable = row.getAttribute('data-orders-filter-searchable') || '';\n\n if (searchTerm === '' || searchable.indexOf(searchTerm) !== -1) {\n row.style.display = '';\n visibleCount++;\n } else {\n row.style.display = 'none';\n }\n }\n\n // Hide section if no visible rows, show otherwise\n if (visibleCount === 0 && searchTerm !== '') {\n section.style.display = 'none';\n } else {\n section.style.display = '';\n }\n\n return visibleCount;\n }\n\n /**\n * Show/hide global empty state based on total visible count\n */\n updateEmptyState(totalVisibleCount) {\n if (!this.hasEmptyStateTarget || !this.hasContentTarget) return;\n\n const hasSearchTerm = this.inputTarget.value.trim() !== '';\n\n if (totalVisibleCount === 0 && hasSearchTerm) {\n this.emptyStateTarget.style.display = 'block';\n this.contentTarget.style.display = 'none';\n } else {\n this.emptyStateTarget.style.display = 'none';\n this.contentTarget.style.display = '';\n }\n }\n\n /**\n * Clear filter and reset view\n */\n clear() {\n this.inputTarget.value = '';\n // Force performFilter to run by ensuring lastSearchTerm is different\n this.lastSearchTerm = '_cleared_';\n this.performFilter();\n }\n}\n", "/**\n * Bulk Actions Mixin\n * \n * Provides common functionality for bulk actions controllers:\n * - Product selection validation\n * - Modal creation and management\n * - AJAX link creation with fallbacks\n * - Error handling\n */\nexport const BulkActionsMixin = {\n /**\n * Disables the clicked element to prevent race conditions\n * @param {HTMLElement} element - The element to disable\n * @returns {Function} A function to re-enable the element\n */\n disableElement(element) {\n if (!element) return () => {};\n \n // Store original state\n const wasDisabled = element.disabled;\n const originalText = element.textContent;\n const originalCursor = element.style.cursor;\n \n // Disable the element\n if ('disabled' in element) {\n element.disabled = true;\n }\n element.style.cursor = 'not-allowed';\n element.style.opacity = '0.6';\n \n // Add loading indicator if it's a button or anchor\n // Only modify textContent for elements where we want to show \"...\" loading state\n const shouldModifyText = element.tagName === 'BUTTON' || element.tagName === 'A';\n if (shouldModifyText) {\n element.textContent = originalText + '...';\n }\n\n // Return re-enable function\n return () => {\n if ('disabled' in element) {\n element.disabled = wasDisabled;\n }\n element.style.cursor = originalCursor;\n element.style.opacity = '';\n // Only restore textContent if we modified it\n // Restoring textContent unconditionally destroys child elements (icons, links)\n // for container elements like
  • that weren't modified on disable\n if (shouldModifyText) {\n element.textContent = originalText;\n }\n };\n },\n\n /**\n * Gets selected product IDs from checkboxes\n * @returns {Array} Array of selected product IDs\n */\n getSelectedIds() {\n const checkboxes = document.querySelectorAll('.sidebar-product-list-item input[type=\"checkbox\"]');\n const selectedIds = [];\n\n checkboxes.forEach((checkbox) => {\n if (checkbox.checked) {\n const id = checkbox.getAttribute('data-product-id');\n if (id) {\n selectedIds.push(id);\n }\n }\n });\n\n return selectedIds;\n },\n\n /**\n * Creates and shows a \"no selection\" modal\n * @param {string} controllerName - Name of the controller for cleanup tracking\n */\n showNoSelectionMessage(controllerName = 'bulk-action') {\n // Find or create the modal container properly\n let modal = document.getElementById('no-selection-message');\n\n if (!modal) {\n // Create the modal structure using DOM methods for safety\n modal = document.createElement('div');\n modal.className = 'ae-popup ae-popup-sm mfp-hide';\n modal.id = 'no-selection-message';\n modal.dataset.createdBy = controllerName; // Mark for cleanup\n\n const content = document.createElement('div');\n content.className = 'ae-popup-content';\n\n const wrapper = document.createElement('div');\n wrapper.className = 'text-center';\n\n const message = document.createElement('p');\n message.textContent = 'No products selected for bulk action.';\n\n const button = document.createElement('button');\n button.type = 'button';\n button.className = 'btn btn-primary ae-popup-close';\n button.textContent = 'OK';\n\n wrapper.appendChild(message);\n wrapper.appendChild(button);\n content.appendChild(wrapper);\n modal.appendChild(content);\n\n document.body.appendChild(modal);\n }\n\n // Try dynamic import with proper error handling\n import('../controllers/simple_popup_controller').then(module => {\n const SimplePopupController = module.default;\n // If it has a static open method, use it\n if (SimplePopupController && typeof SimplePopupController.open === 'function') {\n SimplePopupController.open('#no-selection-message');\n } else {\n // Fallback without another dynamic operation\n this.showModalWithoutDynamicImport();\n }\n }).catch(error => {\n // Error: Failed to load simple-popup controller\n // Use non-dynamic fallback\n this.showModalWithoutDynamicImport();\n });\n },\n\n /**\n * Creates an error modal with a custom message\n * @param {string} message - The error message to display\n */\n showErrorMessage(message) {\n // Reuse the message display logic\n let existingModal = document.getElementById('error-message-modal');\n\n if (!existingModal) {\n // Create the modal structure using DOM methods for safety\n existingModal = document.createElement('div');\n existingModal.className = 'ae-popup ae-popup-sm mfp-hide';\n existingModal.id = 'error-message-modal';\n\n const content = document.createElement('div');\n content.className = 'ae-popup-content';\n\n const wrapper = document.createElement('div');\n wrapper.className = 'text-center';\n\n const messageEl = document.createElement('p');\n messageEl.className = 'error-message-text';\n messageEl.textContent = message; // Safe text assignment\n\n const button = document.createElement('button');\n button.type = 'button';\n button.className = 'btn btn-primary ae-popup-close';\n button.textContent = 'OK';\n\n wrapper.appendChild(messageEl);\n wrapper.appendChild(button);\n content.appendChild(wrapper);\n existingModal.appendChild(content);\n\n document.body.appendChild(existingModal);\n } else {\n // Update the message text\n const messageEl = existingModal.querySelector('.error-message-text');\n if (messageEl) {\n messageEl.textContent = message;\n }\n }\n\n this.createSimplePopupLink('#error-message-modal');\n },\n\n /**\n * Shows modal without dynamic import (fallback)\n */\n showModalWithoutDynamicImport() {\n // Try existing modal library first\n if (window.$ && window.$.magnificPopup) {\n window.$.magnificPopup.open({\n items: {\n src: '#no-selection-message',\n type: 'inline',\n },\n });\n } else if (typeof window.alert !== 'undefined') {\n // Show a simple alert as last resort\n window.alert('No products selected for bulk action.');\n } else {\n // If nothing else works, just log\n // No products selected - handled by alert above\n }\n },\n\n /**\n * Creates and clicks an AJAX link using the ajax-popup controller\n * @param {string} url - The URL to open\n */\n createAndClickAjaxLink(url) {\n // Import the ajax-popup controller class\n import('../controllers/ajax_popup_controller').then(module => {\n const AjaxPopupController = module.default;\n // Use the static open method which handles everything properly\n if (AjaxPopupController && typeof AjaxPopupController.open === 'function') {\n AjaxPopupController.open(url);\n } else {\n // If static method doesn't exist, use fallback\n this.createFallbackLink(url);\n }\n }).catch(error => {\n // Error: Failed to load ajax-popup controller\n // Fallback: create a link element without dynamic imports\n this.createFallbackLink(url);\n });\n },\n\n /**\n * Creates a fallback AJAX link when dynamic import fails\n * @param {string} url - The URL to open\n */\n createFallbackLink(url) {\n // Prevent multiple simultaneous calls for the same URL\n if (this._pendingFallbackUrls && this._pendingFallbackUrls.has(url)) {\n return;\n }\n \n if (!this._pendingFallbackUrls) {\n this._pendingFallbackUrls = new Set();\n }\n this._pendingFallbackUrls.add(url);\n\n const link = document.createElement('a');\n link.href = url;\n link.setAttribute('data-controller', 'ajax-popup');\n link.setAttribute('data-action', 'click->ajax-popup#click');\n link.style.display = 'none';\n\n document.body.appendChild(link);\n\n // Use requestAnimationFrame to ensure DOM and Stimulus are ready\n requestAnimationFrame(() => {\n link.click();\n // Clean up after a reasonable delay\n setTimeout(() => {\n link.remove();\n // Clear the pending URL after cleanup\n if (this._pendingFallbackUrls) {\n this._pendingFallbackUrls.delete(url);\n }\n }, 300);\n });\n },\n\n /**\n * Creates a simple popup link\n * @param {string} selector - The CSS selector for the modal\n */\n createSimplePopupLink(selector) {\n // Prevent multiple simultaneous calls for the same selector\n if (this._pendingPopupSelectors && this._pendingPopupSelectors.has(selector)) {\n return;\n }\n \n if (!this._pendingPopupSelectors) {\n this._pendingPopupSelectors = new Set();\n }\n this._pendingPopupSelectors.add(selector);\n\n const link = document.createElement('a');\n link.href = selector;\n link.setAttribute('data-controller', 'simple-popup');\n link.setAttribute('data-action', 'click->simple-popup#click');\n link.style.display = 'none';\n\n document.body.appendChild(link);\n\n // Use requestAnimationFrame for proper timing\n requestAnimationFrame(() => {\n link.click();\n setTimeout(() => {\n link.remove();\n // Clear the pending selector after cleanup\n if (this._pendingPopupSelectors) {\n this._pendingPopupSelectors.delete(selector);\n }\n }, 300);\n });\n },\n\n /**\n * Cleans up modals created by a specific controller\n * @param {string} controllerName - Name of the controller for cleanup tracking\n */\n cleanupModals(controllerName) {\n // Clean up no-selection message modal\n const noSelectionModal = document.getElementById('no-selection-message');\n if (noSelectionModal && noSelectionModal.dataset.createdBy === controllerName) {\n noSelectionModal.remove();\n }\n\n // Clean up error message modal (created by showErrorMessage)\n const errorModal = document.getElementById('error-message-modal');\n if (errorModal) {\n errorModal.remove();\n }\n\n // Clear any pending operations to prevent memory leaks\n if (this._pendingFallbackUrls) {\n this._pendingFallbackUrls.clear();\n }\n if (this._pendingPopupSelectors) {\n this._pendingPopupSelectors.clear();\n }\n },\n};\n", "import { Controller } from '../stimulus';\nimport { BulkActionsMixin } from '../mixins/bulk_actions_mixin';\n\nexport default class extends Controller {\n connect() {\n // Get sale ID from data attribute\n this.saleId = this.element.dataset.saleId;\n\n // Apply mixin methods\n Object.assign(this, BulkActionsMixin);\n }\n\n disconnect() {\n // Clean up any temporary modals created by this controller\n if (this.cleanupModals) {\n this.cleanupModals('category-controller');\n }\n }\n\n click(e) {\n e.preventDefault();\n\n // Disable the button immediately to prevent race conditions\n const reenableButton = this.disableElement(e.currentTarget);\n\n const productIds = this.getSelectedIds();\n\n if (productIds.length === 0) {\n this.showNoSelectionMessage('category-controller');\n // Re-enable the button since we're not proceeding\n reenableButton();\n return;\n }\n\n this.openCategoryModal(productIds);\n\n // Re-enable after a delay to allow modal to open\n // Using 3 seconds to account for slow connections and AJAX loading\n // This prevents race conditions where users might click again before the modal loads\n const MODAL_LOAD_TIMEOUT = 3000; // 3 seconds\n setTimeout(reenableButton, MODAL_LOAD_TIMEOUT);\n }\n\n openCategoryModal(productIds) {\n const url = `/teamsites/${this.saleId}/bulk_set_category`;\n const params = new URLSearchParams();\n\n // Add each product ID as array-style parameters for Rails\n productIds.forEach(id => params.append('product_ids[]', id));\n\n const fullUrl = `${url}?${params}`;\n\n // Create a temporary link with ajax-popup controller\n // (Stimulus 1.1.0 doesn't have getControllerConstructor)\n this.createAndClickAjaxLink(fullUrl);\n }\n}\n", "import { Controller } from '../stimulus';\nimport { BulkActionsMixin } from '../mixins/bulk_actions_mixin';\n\nexport default class extends Controller {\n connect() {\n // Get sale ID from data attribute\n this.saleId = this.element.dataset.saleId;\n\n // Apply mixin methods\n Object.assign(this, BulkActionsMixin);\n }\n\n disconnect() {\n // Clean up any temporary modals created by this controller\n if (this.cleanupModals) {\n this.cleanupModals('product-bulk-actions-decorator');\n }\n }\n\n click(e) {\n e.preventDefault();\n\n // Disable the button immediately to prevent race conditions\n const reenableButton = this.disableElement(e.currentTarget);\n\n const productIds = this.getSelectedIds();\n\n if (productIds.length === 0) {\n this.showNoSelectionMessage('product-bulk-actions-decorator');\n // Re-enable the button since we're not proceeding\n reenableButton();\n return;\n }\n\n this.openDecoratorModal(productIds);\n\n // Re-enable after a delay to allow modal to open\n // Using 3 seconds to account for slow connections and AJAX loading\n // This prevents race conditions where users might click again before the modal loads\n const MODAL_LOAD_TIMEOUT = 3000; // 3 seconds\n setTimeout(reenableButton, MODAL_LOAD_TIMEOUT);\n }\n\n openDecoratorModal(productIds) {\n const url = `/teamsites/${this.saleId}/decorator`;\n const params = new URLSearchParams();\n\n // Add bulk mode flag\n params.append('is_bulk', 'true');\n\n // Add each product ID as array-style parameters for Rails\n productIds.forEach(id => params.append('product_ids[]', id));\n\n const fullUrl = `${url}?${params}`;\n\n // Create a temporary link with ajax-popup controller\n // (Stimulus 1.1.0 doesn't have getControllerConstructor)\n this.createAndClickAjaxLink(fullUrl);\n }\n}\n", "import { Controller } from '../stimulus';\nimport { BulkActionsMixin } from '../mixins/bulk_actions_mixin';\n\nexport default class extends Controller {\n connect() {\n // Get sale ID from data attribute\n this.saleId = this.element.dataset.saleId;\n\n // Apply mixin methods\n Object.assign(this, BulkActionsMixin);\n }\n\n disconnect() {\n // Clean up any temporary modals created by this controller\n if (this.cleanupModals) {\n this.cleanupModals('delete-controller');\n }\n }\n\n click(e) {\n e.preventDefault();\n\n // Disable the button immediately to prevent race conditions\n const reenableButton = this.disableElement(e.currentTarget);\n\n const productIds = this.getSelectedIds();\n\n if (productIds.length === 0) {\n this.showNoSelectionMessage('delete-controller');\n // Re-enable the button since we're not proceeding\n reenableButton();\n return;\n }\n\n // Open the modal - button stays disabled\n // It will either re-enable on error or the page will reload on success\n this.openDeleteModal(productIds);\n }\n\n openDeleteModal(productIds) {\n const url = `/teamsites/${this.saleId}/bulk_delete_products`;\n const params = new URLSearchParams();\n\n // Add each product ID as array-style parameters for Rails\n productIds.forEach(id => params.append('product_ids[]', id));\n\n const fullUrl = `${url}?${params}`;\n\n // Create a temporary link with ajax-popup controller\n // (Stimulus 1.1.0 doesn't have getControllerConstructor)\n this.createAndClickAjaxLink(fullUrl);\n }\n}\n", "import { Controller } from '../stimulus';\nimport { BulkActionsMixin } from '../mixins/bulk_actions_mixin';\nimport CSRFTokenMixin from '../mixins/csrf_token_mixin';\n\nexport default class extends Controller {\n connect() {\n // Get sale ID from data attribute\n this.saleId = this.element.dataset.saleId;\n\n // \u2705 CSRF PROTECTION - Apply security and bulk action mixins\n Object.assign(this, BulkActionsMixin, CSRFTokenMixin);\n }\n\n disconnect() {\n // \u2705 MEMORY LEAK PREVENTION - Clean up any created modals and pending operations\n this.cleanupModals('price-controller');\n }\n\n click(e) {\n e.preventDefault();\n\n // Disable the button immediately to prevent race conditions\n const reenableButton = this.disableElement(e.currentTarget);\n\n const productIds = this.getSelectedIds();\n\n if (productIds.length === 0) {\n this.showNoSelectionMessage();\n // Re-enable the button since we're not proceeding\n reenableButton();\n return;\n }\n\n this.openPricingModal(productIds);\n\n // Re-enable after a delay to allow modal to open\n // Using 3 seconds to account for slow connections and AJAX loading\n // This prevents race conditions where users might click again before the modal loads\n const MODAL_LOAD_TIMEOUT = 3000; // 3 seconds\n setTimeout(reenableButton, MODAL_LOAD_TIMEOUT);\n }\n\n\n openPricingModal(productIds) {\n const url = `/teamsites/${this.saleId}/bulk_additional_pricing`;\n const params = new URLSearchParams();\n\n // Add each product ID as a separate parameter\n productIds.forEach(id => params.append('product_ids', id));\n\n const fullUrl = `${url}?${params}`;\n\n // Create a temporary link with ajax-popup controller\n // (Stimulus 1.1.0 doesn't have getControllerConstructor)\n this.createAndClickAjaxLink(fullUrl);\n }\n\n}\n", "import { Controller } from '../stimulus';\nimport { BulkActionsMixin } from '../mixins/bulk_actions_mixin';\nimport CSRFTokenMixin from '../mixins/csrf_token_mixin';\n\nexport default class extends Controller {\n connect() {\n this.saleId = this.element.dataset.saleId;\n Object.assign(this, BulkActionsMixin, CSRFTokenMixin);\n }\n\n disconnect() {\n if (this.cleanupModals) {\n this.cleanupModals('set-pricing-controller');\n }\n }\n\n click(e) {\n e.preventDefault();\n\n const reenableButton = this.disableElement(e.currentTarget);\n const productIds = this.getSelectedIds();\n\n if (productIds.length === 0) {\n this.showNoSelectionMessage();\n reenableButton();\n return;\n }\n\n this.openSetPricingModal(productIds);\n\n const MODAL_LOAD_TIMEOUT = 3000;\n setTimeout(reenableButton, MODAL_LOAD_TIMEOUT);\n }\n\n openSetPricingModal(productIds) {\n const url = `/teamsites/${this.saleId}/bulk_set_pricing`;\n const params = new URLSearchParams();\n\n productIds.forEach(id => params.append('product_ids[]', id));\n\n const fullUrl = `${url}?${params}`;\n\n this.createAndClickAjaxLink(fullUrl);\n }\n}\n", "import { Controller } from '../stimulus';\nimport { BulkActionsMixin } from '../mixins/bulk_actions_mixin';\n\nexport default class extends Controller {\n connect() {\n // Get sale ID from data attribute\n this.saleId = this.element.dataset.saleId;\n\n // Apply mixin methods\n Object.assign(this, BulkActionsMixin);\n }\n\n disconnect() {\n // Clean up any temporary modals created by this controller\n if (this.cleanupModals) {\n this.cleanupModals('sizing-controller');\n }\n }\n\n click(e) {\n e.preventDefault();\n\n // Disable the button immediately to prevent race conditions\n const reenableButton = this.disableElement(e.currentTarget);\n\n const productIds = this.getSelectedIds();\n\n if (productIds.length === 0) {\n this.showNoSelectionMessage('sizing-controller');\n // Re-enable the button since we're not proceeding\n reenableButton();\n return;\n }\n\n this.openSizeGuideModal(productIds);\n\n // Re-enable after a delay to allow modal to open\n // Using 3 seconds to account for slow connections and AJAX loading\n // This prevents race conditions where users might click again before the modal loads\n const MODAL_LOAD_TIMEOUT = 3000; // 3 seconds\n setTimeout(reenableButton, MODAL_LOAD_TIMEOUT);\n }\n\n openSizeGuideModal(productIds) {\n const url = `/teamsites/${this.saleId}/bulk_upload_size_guide`;\n const params = new URLSearchParams();\n\n // Add each product ID as array-style parameters for Rails\n productIds.forEach(id => params.append('product_ids[]', id));\n\n const fullUrl = `${url}?${params}`;\n\n // Create a temporary link with ajax-popup controller\n // (Stimulus 1.1.0 doesn't have getControllerConstructor)\n this.createAndClickAjaxLink(fullUrl);\n }\n}\n", "/**\n * @file Product Colors Modal Drag Controller\n * @description Stimulus controller for handling drag-and-drop functionality of color variant images.\n * Allows horizontal reordering of images within the gallery using vanilla JavaScript.\n * No external dependencies required.\n *\n * Key Features:\n * - Horizontal drag detection with visual feedback\n * - Smooth reordering animation\n * - Server synchronization via PATCH requests\n * - Touch and mouse support\n *\n * @example\n *
      \n *
    • ...
    • \n *
    • ...
    • \n *
    \n */\n\nimport { Controller } from '../stimulus';\n\nconst DRAG_THRESHOLD_PX = 5; // Minimum movement to consider it a drag\n\nexport default class extends Controller {\n initialize() {\n this.debug = false; // Set via data attribute if needed\n\n // Bind methods\n this.handleMouseDown = this.handleMouseDown.bind(this);\n this.handleMouseMove = this.handleMouseMove.bind(this);\n this.handleMouseUp = this.handleMouseUp.bind(this);\n this.handleTouchStart = this.handleTouchStart.bind(this);\n this.handleTouchMove = this.handleTouchMove.bind(this);\n this.handleTouchEnd = this.handleTouchEnd.bind(this);\n\n // Initialize state\n this.isDragging = false;\n this.draggedElement = null;\n this.startX = 0;\n this.startY = 0;\n this.currentX = 0;\n this.initialMouseX = 0;\n this.initialElementX = 0;\n\n // Initialize debouncing state\n this.pendingUpdates = new Map(); // Track pending updates per image\n this.updateTimeouts = new Map(); // Track timeouts per image\n this.isUpdating = new Set(); // Track images currently being updated\n this.debounceDelay = 300; // Default delay in milliseconds\n this.maxRetries = 3; // Default maximum retry attempts\n this.retryDelay = 1000; // Default delay between retries (ms)\n\n // Initialize lifecycle tracking\n this.isConnected = false;\n this.abortControllers = new Map(); // Track active fetch requests\n this.retryAttempts = new Map(); // Track retry attempts per image\n this.retryTimeouts = new Map(); // Track retry timeouts per image\n this.rowUpdateAbortController = null; // Abort controller for row updates\n }\n\n connect() {\n // Mark as connected\n this.isConnected = true;\n\n // Get data attributes\n this.saleId = this.element.dataset.saleId;\n this.productId = this.element.dataset.productId;\n this.colorVariantId = this.element.dataset.colorVariantId;\n\n // Get configurable debounce delay (default to 300ms if not specified)\n const configuredDelay = parseInt(this.element.dataset.debounceDelay, 10);\n if (!isNaN(configuredDelay) && configuredDelay >= 0) {\n this.debounceDelay = configuredDelay;\n }\n\n // Get configurable max retries (default to 3 if not specified)\n const configuredMaxRetries = parseInt(this.element.dataset.maxRetries, 10);\n if (!isNaN(configuredMaxRetries) && configuredMaxRetries >= 0) {\n this.maxRetries = configuredMaxRetries;\n }\n\n // Get configurable retry delay (default to 1000ms if not specified)\n const configuredRetryDelay = parseInt(this.element.dataset.retryDelay, 10);\n if (!isNaN(configuredRetryDelay) && configuredRetryDelay >= 0) {\n this.retryDelay = configuredRetryDelay;\n }\n\n // Add event listeners\n this.addEventListeners();\n }\n\n disconnect() {\n // Mark as disconnected\n this.isConnected = false;\n\n // Abort all active fetch requests\n this.abortAllRequests();\n\n // Abort row update if in progress\n if (this.rowUpdateAbortController) {\n this.rowUpdateAbortController.abort();\n this.rowUpdateAbortController = null;\n }\n\n // Clean up\n this.removeEventListeners();\n this.cleanup();\n this.cleanupPendingUpdates();\n }\n\n addEventListeners() {\n const images = this.getImageElements();\n images.forEach(img => {\n img.addEventListener('mousedown', this.handleMouseDown);\n img.addEventListener('touchstart', this.handleTouchStart, { passive: false });\n });\n }\n\n removeEventListeners() {\n const images = this.getImageElements();\n images.forEach(img => {\n img.removeEventListener('mousedown', this.handleMouseDown);\n img.removeEventListener('touchstart', this.handleTouchStart);\n });\n\n // Remove document-level listeners\n document.removeEventListener('mousemove', this.handleMouseMove);\n document.removeEventListener('mouseup', this.handleMouseUp);\n document.removeEventListener('touchmove', this.handleTouchMove);\n document.removeEventListener('touchend', this.handleTouchEnd);\n }\n\n getImageElements() {\n return Array.from(this.element.children);\n }\n\n handleMouseDown(event) {\n // Prevent default to avoid text selection\n event.preventDefault();\n\n const imageElement = event.currentTarget;\n\n // Check for required data-image-id attribute\n if (!imageElement.dataset.imageId) {\n this.logDebug('Drag failed: Missing data-image-id attribute on element', imageElement);\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash('Unable to reorder this image. Please refresh the page and try again.');\n }\n return;\n }\n\n this.startDrag(imageElement, event.clientX, event.clientY);\n\n // Add document-level listeners\n document.addEventListener('mousemove', this.handleMouseMove);\n document.addEventListener('mouseup', this.handleMouseUp);\n }\n\n handleTouchStart(event) {\n // Prevent scrolling while dragging\n event.preventDefault();\n\n const touch = event.touches[0];\n const imageElement = event.currentTarget;\n\n // Check for required data-image-id attribute\n if (!imageElement.dataset.imageId) {\n this.logDebug('Drag failed: Missing data-image-id attribute on element', imageElement);\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash('Unable to reorder this image. Please refresh the page and try again.');\n }\n return;\n }\n\n this.startDrag(imageElement, touch.clientX, touch.clientY);\n\n // Add document-level listeners\n document.addEventListener('touchmove', this.handleTouchMove, { passive: false });\n document.addEventListener('touchend', this.handleTouchEnd);\n }\n\n handleMouseMove(event) {\n this.updateDrag(event.clientX, event.clientY);\n }\n\n handleTouchMove(event) {\n event.preventDefault();\n const touch = event.touches[0];\n this.updateDrag(touch.clientX, touch.clientY);\n }\n\n handleMouseUp() {\n this.endDrag();\n\n // Remove document-level listeners\n document.removeEventListener('mousemove', this.handleMouseMove);\n document.removeEventListener('mouseup', this.handleMouseUp);\n }\n\n handleTouchEnd() {\n this.endDrag();\n\n // Remove document-level listeners\n document.removeEventListener('touchmove', this.handleTouchMove);\n document.removeEventListener('touchend', this.handleTouchEnd);\n }\n\n startDrag(element, clientX, clientY) {\n this.startX = clientX;\n this.startY = clientY;\n this.currentX = clientX;\n this.draggedElement = element;\n this.initialMouseX = clientX;\n\n // Get initial position\n const rect = element.getBoundingClientRect();\n this.initialElementX = rect.left;\n }\n\n updateDrag(clientX, clientY) {\n this.currentX = clientX;\n\n // Check if we've moved enough to start dragging\n const deltaX = Math.abs(clientX - this.startX);\n const deltaY = Math.abs(clientY - this.startY);\n\n if (!this.isDragging && (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX)) {\n this.isDragging = true;\n this.initializeDragVisuals();\n }\n\n if (this.isDragging) {\n // Update dragged element position\n const offsetX = clientX - this.initialMouseX;\n this.draggedElement.style.transform = `translateX(${offsetX}px)`;\n\n // Check for reordering\n this.checkForReorder();\n }\n }\n\n initializeDragVisuals() {\n if (!this.draggedElement) return;\n\n // Style dragged element (no placeholder - matches size variant pattern)\n this.draggedElement.style.position = 'relative';\n this.draggedElement.style.zIndex = '1000';\n this.draggedElement.style.opacity = '0.8';\n this.draggedElement.style.cursor = 'grabbing';\n this.draggedElement.classList.add('dragging');\n }\n\n checkForReorder() {\n const images = this.getImageElements().filter(img =>\n img !== this.draggedElement,\n );\n\n const draggedRect = this.draggedElement.getBoundingClientRect();\n const draggedCenterX = draggedRect.left + draggedRect.width / 2;\n\n for (const img of images) {\n const rect = img.getBoundingClientRect();\n const centerX = rect.left + rect.width / 2;\n\n // Check if we should swap (direct moves like size variant - no placeholder)\n if (draggedCenterX < centerX && this.isAfter(this.draggedElement, img)) {\n // Move dragged element before this image\n img.parentNode.insertBefore(this.draggedElement, img);\n // Adjust transform after DOM reordering to prevent acceleration\n this.adjustAfterReorder();\n } else if (draggedCenterX > centerX && this.isBefore(this.draggedElement, img)) {\n // Move dragged element after this image\n img.parentNode.insertBefore(this.draggedElement, img.nextSibling);\n // Adjust transform after DOM reordering to prevent acceleration\n this.adjustAfterReorder();\n }\n }\n }\n\n isAfter(el1, el2) {\n return !!(el1.compareDocumentPosition(el2) & Node.DOCUMENT_POSITION_PRECEDING);\n }\n\n isBefore(el1, el2) {\n return !!(el1.compareDocumentPosition(el2) & Node.DOCUMENT_POSITION_FOLLOWING);\n }\n\n adjustAfterReorder() {\n // After DOM reordering, the element's natural position changes\n // We need to adjust the transform to maintain the same visual position\n\n // Clear transform to get true DOM position\n this.draggedElement.style.transform = '';\n\n // Get new position in DOM (without transform)\n const newRect = this.draggedElement.getBoundingClientRect();\n\n // Calculate offset to maintain same visual position\n // Use center of element for horizontal drag\n const offsetX = this.currentX - newRect.left - newRect.width / 2;\n\n // Reapply transform with corrected offset\n this.draggedElement.style.transform = `translateX(${offsetX}px)`;\n\n // Update initialMouseX for future calculations\n this.initialMouseX = this.currentX - offsetX;\n }\n\n endDrag() {\n if (!this.isDragging) return;\n\n // Remove visual styles\n if (this.draggedElement) {\n const droppedElement = this.draggedElement;\n\n droppedElement.style.position = '';\n droppedElement.style.zIndex = '';\n droppedElement.style.opacity = '';\n droppedElement.style.cursor = '';\n droppedElement.style.transform = '';\n droppedElement.classList.remove('dragging');\n\n // Add drop-settling animation for visual feedback\n droppedElement.classList.add('drop-settling');\n setTimeout(() => {\n // Null check in case element was removed from DOM\n if (droppedElement && droppedElement.classList) {\n droppedElement.classList.remove('drop-settling');\n }\n }, 250);\n\n // Get new position\n const images = this.getImageElements();\n const newPosition = images.indexOf(droppedElement) + 1;\n\n // Debounce the server update\n this.debouncedUpdateImagePosition(droppedElement, newPosition);\n }\n\n // Cleanup\n this.cleanup();\n }\n\n cleanup() {\n this.isDragging = false;\n this.draggedElement = null;\n this.startX = 0;\n this.startY = 0;\n this.currentX = 0;\n this.initialMouseX = 0;\n this.initialElementX = 0;\n }\n\n cleanupPendingUpdates() {\n // Clear all pending timeouts\n this.updateTimeouts.forEach((timeoutId) => {\n clearTimeout(timeoutId);\n });\n this.updateTimeouts.clear();\n\n // Clear all retry timeouts\n this.retryTimeouts.forEach((timeoutId) => {\n clearTimeout(timeoutId);\n });\n this.retryTimeouts.clear();\n\n // Clear pending updates\n this.pendingUpdates.clear();\n\n // Clear updating set\n this.isUpdating.clear();\n\n // Clear retry attempts\n this.retryAttempts.clear();\n }\n\n abortAllRequests() {\n // Abort all active fetch requests\n this.abortControllers.forEach((controller) => {\n controller.abort();\n });\n this.abortControllers.clear();\n }\n\n debouncedUpdateImagePosition(element, position) {\n const imageId = element.dataset.imageId;\n if (!imageId) {\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash('Unable to save image order. Missing image information.');\n }\n return;\n }\n\n // Cancel any existing timeout for this image\n if (this.updateTimeouts.has(imageId)) {\n clearTimeout(this.updateTimeouts.get(imageId));\n this.updateTimeouts.delete(imageId);\n }\n\n // Store the pending update (reset retry count for new user action)\n this.pendingUpdates.set(imageId, { element, position, retryCount: 0 });\n\n // If this image is already being updated, wait for it to complete\n if (this.isUpdating.has(imageId)) {\n // The update will be retried after the current one completes\n return;\n }\n\n // Set a new timeout\n const timeoutId = setTimeout(() => {\n // Check if controller is still connected first\n if (!this.isConnected) {\n return;\n }\n\n // Get the latest pending update for this image\n const pendingUpdate = this.pendingUpdates.get(imageId);\n if (pendingUpdate) {\n // Don't delete the pending update yet - let updateImagePosition handle it\n // This allows the success handler to check for queued updates\n this.updateTimeouts.delete(imageId);\n this.updateImagePosition(pendingUpdate.element, pendingUpdate.position);\n }\n }, this.debounceDelay);\n\n this.updateTimeouts.set(imageId, timeoutId);\n }\n\n updateImagePosition(element, position) {\n const imageId = element.dataset.imageId;\n if (!imageId) {\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash('Unable to save image order. Missing image information.');\n }\n return;\n }\n\n // Validate required data attributes\n if (!this.saleId || !this.colorVariantId || !this.productId) {\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash('Unable to save image order. Please refresh the page and try again.');\n }\n return;\n }\n\n // Check if controller is still connected\n if (!this.isConnected) {\n return;\n }\n\n // Mark as updating\n this.isUpdating.add(imageId);\n\n // Create abort controller for this request\n const abortController = new AbortController();\n\n // Cancel any existing request for this image\n if (this.abortControllers.has(imageId)) {\n this.abortControllers.get(imageId).abort();\n }\n this.abortControllers.set(imageId, abortController);\n\n const url = `/teamsites/${this.saleId}/colors/${this.colorVariantId}/images/${imageId}/reorder`;\n const token = document.querySelector('meta[name=\"csrf-token\"]')?.getAttribute('content');\n\n // Show loading feedback (only if still connected)\n if (this.isConnected) {\n element.style.opacity = '0.6';\n element.classList.add('updating');\n }\n\n fetch(url, {\n method: 'PATCH',\n headers: {\n 'X-CSRF-Token': token,\n 'Content-Type': 'application/json',\n 'Accept': 'application/json',\n },\n body: JSON.stringify({\n position: position,\n product_id: this.productId,\n }),\n credentials: 'same-origin',\n signal: abortController.signal,\n })\n .then(response => {\n // Check if controller is still connected before processing\n if (!this.isConnected) {\n throw new Error('Controller disconnected');\n }\n\n if (!response.ok) {\n return response.json().then(errorData => {\n throw new Error(errorData.error || `Server error: ${response.status}`);\n });\n }\n return response.json();\n })\n .then(data => {\n // Check if controller is still connected before updating UI\n if (!this.isConnected) {\n // Clean up tracking even if disconnected\n this.abortControllers.delete(imageId);\n this.isUpdating.delete(imageId);\n return;\n }\n\n // Show success message\n if (window.App && window.App.showSuccessFlash) {\n window.App.showSuccessFlash(data.message || 'Image order updated');\n }\n\n // Remove loading state\n element.style.opacity = '';\n element.classList.remove('updating');\n\n // Clean up abort controller\n this.abortControllers.delete(imageId);\n\n // Mark as no longer updating\n this.isUpdating.delete(imageId);\n\n // Clear retry attempts on successful completion\n this.retryAttempts.delete(imageId);\n\n // Update the color variant table row if display image changed\n this.updateColorVariantTableRow();\n\n // Check if there's a pending update for this image\n if (this.pendingUpdates.has(imageId) && this.isConnected) {\n const pendingUpdate = this.pendingUpdates.get(imageId);\n // Always clear the current pending update first\n this.pendingUpdates.delete(imageId);\n\n // Only retry if the position is actually different\n if (pendingUpdate.position !== position) {\n // Retry with the latest position\n this.updateImagePosition(pendingUpdate.element, pendingUpdate.position);\n }\n }\n })\n .catch(error => {\n // Don't show error for aborted requests\n if (error.name === 'AbortError') {\n return;\n }\n\n // Check if controller is still connected before handling error\n if (!this.isConnected) {\n // Clean up tracking even if disconnected\n this.abortControllers.delete(imageId);\n this.isUpdating.delete(imageId);\n return;\n }\n\n // Remove loading state\n element.style.opacity = '';\n element.classList.remove('updating');\n\n // Clean up abort controller\n this.abortControllers.delete(imageId);\n\n // Mark as no longer updating\n this.isUpdating.delete(imageId);\n\n // Show specific error message\n let errorMessage = 'Failed to update image order. ';\n\n if (error.message.includes('404')) {\n errorMessage += 'Image not found.';\n } else if (error.message.includes('401') || error.message.includes('unauthorized')) {\n errorMessage += 'Please sign in and try again.';\n } else if (error.message.includes('422') || error.message.includes('Invalid position')) {\n errorMessage += 'Invalid position.';\n } else if (error.message.includes('Network')) {\n errorMessage += 'Please check your internet connection.';\n } else if (error.message === 'Controller disconnected') {\n // Don't show error for controller disconnection\n return;\n } else {\n errorMessage += error.message || 'Please try again.';\n }\n\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash(errorMessage);\n }\n\n const currentRetries = this.retryAttempts.get(imageId) || 0;\n\n // Check if we've exceeded max retries\n if (currentRetries >= this.maxRetries) {\n // Show final error message and clean up\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash(`Failed to update image order after ${this.maxRetries} retries. Please try again manually.`);\n }\n this.retryAttempts.delete(imageId);\n return;\n }\n\n // Increment retry count\n this.retryAttempts.set(imageId, currentRetries + 1);\n\n // Retry with fixed delay\n const retryTimeoutId = setTimeout(() => {\n // Clear the timeout from tracking\n this.retryTimeouts.delete(imageId);\n\n // Check if still connected before retrying\n if (this.isConnected) {\n // Clear updating state to allow retry\n this.isUpdating.delete(imageId);\n this.updateImagePosition(element, position);\n }\n }, this.retryDelay);\n\n // Track the retry timeout\n this.retryTimeouts.set(imageId, retryTimeoutId);\n });\n }\n\n updateColorVariantTableRow() {\n // Find the color variant row in the table\n const rowElement = document.getElementById(`color-variant-row-${this.colorVariantId}`);\n if (!rowElement) return;\n\n // Find the thumbnail image in the row\n const thumbnailCell = rowElement.querySelector('td:nth-child(2) img');\n if (!thumbnailCell) return;\n\n // Abort any existing row update request\n if (this.rowUpdateAbortController) {\n this.rowUpdateAbortController.abort();\n }\n\n // Create new abort controller for this request\n this.rowUpdateAbortController = new AbortController();\n\n // Fetch just the thumbnail URL from the server\n const url = `/teamsites/${this.saleId}/colors/${this.colorVariantId}/row?product_id=${this.productId}`;\n const token = document.querySelector('meta[name=\"csrf-token\"]')?.getAttribute('content');\n\n fetch(url, {\n method: 'GET',\n headers: {\n 'X-CSRF-Token': token,\n 'Accept': 'application/json',\n },\n credentials: 'same-origin',\n signal: this.rowUpdateAbortController.signal,\n })\n .then(response => {\n if (!response.ok) {\n throw new Error(`Failed to fetch thumbnail: ${response.status}`);\n }\n return response.json();\n })\n .then(data => {\n // Update the thumbnail image with URL validation\n if (data.thumbnail_url) {\n const sanitizedUrl = this.sanitizeImageUrl(data.thumbnail_url);\n if (sanitizedUrl) {\n thumbnailCell.src = sanitizedUrl;\n }\n }\n if (data.alt_text) {\n // Sanitize alt text to prevent XSS\n thumbnailCell.alt = this.sanitizeText(data.alt_text);\n }\n\n // Clean up abort controller\n this.rowUpdateAbortController = null;\n })\n .catch(error => {\n // Don't handle aborted requests\n if (error.name === 'AbortError') {\n return;\n }\n\n // Clean up abort controller\n this.rowUpdateAbortController = null;\n\n // Fallback: try to update just the thumbnail directly\n this.updateThumbnailDirectly();\n });\n }\n\n updateThumbnailDirectly() {\n // Fallback method: update thumbnail directly from modal images\n const images = this.getImageElements();\n if (images.length === 0) return;\n\n const firstImage = images[0];\n const rowElement = document.getElementById(`color-variant-row-${this.colorVariantId}`);\n if (!rowElement) return;\n\n const thumbnailCell = rowElement.querySelector('td:nth-child(2) img');\n if (!thumbnailCell) return;\n\n const modalImage = firstImage.querySelector('img');\n if (!modalImage || !modalImage.src) return;\n\n // Update the thumbnail src and alt with validation\n const sanitizedUrl = this.sanitizeImageUrl(modalImage.src);\n if (sanitizedUrl) {\n thumbnailCell.src = sanitizedUrl;\n }\n if (modalImage.alt) {\n thumbnailCell.alt = this.sanitizeText(modalImage.alt);\n }\n }\n\n sanitizeImageUrl(url) {\n if (!url || typeof url !== 'string') return null;\n\n try {\n // Create URL object to validate structure\n const urlObj = new URL(url, window.location.origin);\n\n // Only allow http, https, and relative URLs\n if (!['http:', 'https:', ''].includes(urlObj.protocol)) {\n return null;\n }\n\n // Only allow common image extensions\n const allowedExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'];\n const pathname = urlObj.pathname.toLowerCase();\n const hasValidExtension = allowedExtensions.some(ext => pathname.includes(ext));\n\n if (!hasValidExtension) {\n return null;\n }\n\n return urlObj.href;\n } catch {\n // Invalid URL\n return null;\n }\n }\n\n sanitizeText(text) {\n if (!text || typeof text !== 'string') return '';\n\n // Create a temporary element to use the browser's built-in text sanitization\n const temp = document.createElement('div');\n temp.textContent = text;\n return temp.textContent;\n }\n\n /**\n * Logs debug messages when debug mode is enabled\n */\n logDebug(_message, ..._args) {\n if (this.debug) {\n // Debug logging removed for production compliance\n }\n }\n}\n", "import { Controller } from '../stimulus';\nimport CSRFTokenMixin from '../mixins/csrf_token_mixin';\n\nexport default class extends Controller {\n static targets = ['form', 'nameInput', 'availableCheckbox', 'saveButton', 'cancelButton', 'error'];\n\n // Constants for element IDs to avoid hardcoding\n static elementIds = {\n editPopup: 'edit-color-popup',\n modalContainer: 'modal-container',\n colorNameField: 'images-tab-color-field',\n };\n\n initialize() {\n this.saleId = this.element.dataset.saleId;\n this.productId = this.element.dataset.productId;\n this.colorVariantId = this.element.dataset.colorVariantId;\n this.updateTimeout = null;\n this.updateNameTimeout = null;\n this.isUpdating = false;\n this.originalName = null;\n\n // Fallback: Extract color variant ID from the URL in the form if not in data attribute\n if (!this.colorVariantId) {\n const form = this.element.querySelector('form');\n if (form && form.action) {\n const match = form.action.match(/colors\\/(\\d+)/);\n if (match) {\n this.colorVariantId = match[1];\n }\n }\n }\n }\n\n connect() {\n // Apply CSRF token mixin\n Object.assign(this, CSRFTokenMixin);\n\n // Re-extract color variant ID when connected in case it wasn't available during initialize\n if (!this.colorVariantId) {\n const form = this.element.querySelector('form');\n if (form && form.action) {\n const match = form.action.match(/colors\\/(\\d+)/);\n if (match) {\n this.colorVariantId = match[1];\n }\n }\n }\n\n // Add input event listener to clear error when user types\n if (this.hasNameInputTarget) {\n this.clearErrorHandler = () => this.hideError();\n this.nameInputTarget.addEventListener('input', this.clearErrorHandler);\n\n // Store original name for error reversion\n this.originalName = this.nameInputTarget.value;\n }\n\n // Add change event listener for availability checkbox\n if (this.hasAvailableCheckboxTarget) {\n this.boundUpdateAvailable = this.updateAvailable.bind(this);\n this.availableCheckboxTarget.addEventListener('change', this.boundUpdateAvailable);\n }\n\n // Listen for dropzone upload success to refresh gallery\n this.uploadSuccessHandler = (event) => this.handleUploadSuccess(event);\n this.element.addEventListener('dropzone:uploadsuccess', this.uploadSuccessHandler);\n\n // Listen for image toggle events to refresh gallery\n this.imageToggleHandler = () => this.refreshGallery();\n document.addEventListener('color-variant-image-toggled', this.imageToggleHandler);\n }\n\n disconnect() {\n // Clean up event listeners\n if (this.hasNameInputTarget && this.clearErrorHandler) {\n this.nameInputTarget.removeEventListener('input', this.clearErrorHandler);\n }\n\n // Clean up availability checkbox listener\n if (this.hasAvailableCheckboxTarget && this.boundUpdateAvailable) {\n this.availableCheckboxTarget.removeEventListener('change', this.boundUpdateAvailable);\n }\n\n // Clean up upload success listener\n if (this.uploadSuccessHandler) {\n this.element.removeEventListener('dropzone:uploadsuccess', this.uploadSuccessHandler);\n }\n\n // Clean up image toggle listener\n if (this.imageToggleHandler) {\n document.removeEventListener('color-variant-image-toggled', this.imageToggleHandler);\n }\n }\n\n save(e) {\n // Only prevent default if event exists (for manual triggers)\n if (e && e.preventDefault) {\n e.preventDefault();\n e.stopPropagation(); // Stop Rails UJS from also handling the form\n }\n\n // Hide any existing errors\n this.hideError();\n\n const form = this.hasFormTarget ? this.formTarget : this.element.querySelector('form');\n if (!form) {\n // Form not found\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash('Form not found. Please refresh the page.');\n }\n return;\n }\n const url = form.action;\n\n // Ensure we have the color variant ID\n if (!this.colorVariantId) {\n const urlMatch = url.match(/colors\\/(\\d+)/);\n if (urlMatch) {\n this.colorVariantId = urlMatch[1];\n }\n }\n\n // Get form values - prefer targets over querySelector\n const nameInput = this.hasNameInputTarget\n ? this.nameInputTarget\n : form.querySelector(`#${this.constructor.elementIds.colorNameField}`);\n\n if (!nameInput) {\n // Name input not found\n return;\n }\n\n // Use target if available\n const availableCheckbox = this.hasAvailableCheckboxTarget\n ? this.availableCheckboxTarget\n : form.querySelector(`#color-variant-available-${this.colorVariantId}`);\n\n if (!this.colorVariantId && availableCheckbox) {\n // Extract from the checkbox ID if not set\n const match = availableCheckbox.id.match(/color-variant-available-(\\d+)/);\n if (match) {\n this.colorVariantId = match[1];\n }\n }\n\n // Validate name input\n const colorName = nameInput?.value?.trim() || '';\n if (!colorName) {\n this.showError('Color name is required.');\n return;\n }\n\n // Ensure we always have valid params\n const params = {\n name: colorName,\n available: availableCheckbox ? (availableCheckbox.checked ? '1' : '0') : '1', // Default to available if checkbox not found\n };\n\n const csrfToken = document.querySelector('meta[name=\"csrf-token\"]');\n if (!csrfToken) {\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash('Security token missing. Please refresh the page.');\n }\n return;\n }\n\n const headers = {\n 'Accept': 'application/json',\n 'Content-Type': 'application/json',\n 'X-CSRF-Token': csrfToken.content,\n };\n\n fetch(url, {\n method: 'PATCH',\n headers: headers,\n body: JSON.stringify(params),\n })\n .then(response => {\n return response.json().then(data => {\n if (!response.ok) {\n // Handle validation errors or other server errors\n throw new Error(data.error || 'Something went wrong');\n }\n return data;\n });\n })\n .then(response => {\n // Update the color name and availability in the table row\n this.updateColorInTable(params.name, params.available === '1');\n\n // Show success flash message\n if (window.App && window.App.showSuccessFlash) {\n window.App.showSuccessFlash(response.message || 'Changes saved');\n }\n\n // Close modal after updates\n this.closeModal();\n\n // Refresh product sidebar if coordinator is available\n // Using minimal delay to ensure modal closing animation completes\n setTimeout(() => {\n this.refreshProductSidebar();\n // Also refresh gallery to update Available/Unavailable sections\n this.refreshProductGallery();\n }, 10);\n })\n .catch(error => {\n // Show user-friendly error message\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash('Error updating color. Please try again.');\n }\n\n if (window.App && window.App.showErrorFlash) {\n // Show specific error message if available\n window.App.showErrorFlash(error.message || 'Something went wrong');\n }\n });\n }\n\n cancel(e) {\n e.preventDefault();\n\n this.closeModal();\n }\n\n updateAvailable(event) {\n // Prevent concurrent updates\n if (this.isUpdating) {\n return;\n }\n\n const isAvailable = event.target.checked;\n\n // Clear any existing timeout\n if (this.updateTimeout) {\n clearTimeout(this.updateTimeout);\n }\n\n // Debounce the update by 500ms\n this.updateTimeout = setTimeout(() => {\n this.performAvailabilityUpdate(isAvailable);\n }, 500);\n }\n\n performAvailabilityUpdate(isAvailable) {\n // Check if we have the necessary data\n if (!this.colorVariantId || !this.saleId) {\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash('Unable to update color. Please refresh the page.');\n }\n return;\n }\n\n // Check CSRF token availability\n if (!this.hasValidCSRFToken()) {\n this.isUpdating = false;\n return;\n }\n\n const csrfToken = this.getCSRFTokenSilently();\n const url = `/teamsites/${this.saleId}/colors/${this.colorVariantId}/toggle_availability`;\n\n this.isUpdating = true;\n\n // Prepare request parameters\n const params = {\n current_availability: (!isAvailable).toString(),\n };\n\n fetch(url, {\n method: 'PATCH',\n headers: {\n 'Accept': 'application/json',\n 'Content-Type': 'application/json',\n 'X-CSRF-Token': csrfToken,\n 'X-Requested-With': 'XMLHttpRequest',\n },\n body: JSON.stringify(params),\n })\n .then(response => {\n if (!response.ok) {\n throw new Error('Failed to update availability');\n }\n return response.json();\n })\n .then(response => {\n // Update the color availability in the table row\n this.updateColorInTable(this.getCurrentColorName(), isAvailable);\n\n // Show success flash message\n if (window.App && window.App.showSuccessFlash) {\n window.App.showSuccessFlash(response.message || 'Changes saved');\n }\n\n // Refresh product sidebar and gallery\n setTimeout(() => {\n this.refreshProductSidebar();\n this.refreshProductGallery();\n }, 10);\n })\n .catch(error => {\n // Revert checkbox state on error\n if (this.hasAvailableCheckboxTarget) {\n this.availableCheckboxTarget.checked = !isAvailable;\n }\n\n // Show error message\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash(error.message || 'Failed to update availability');\n }\n })\n .finally(() => {\n this.isUpdating = false;\n });\n }\n\n updateName(event) {\n // Get the new name from the input field\n const newName = event.target.value.trim();\n\n // Don't update if name hasn't changed\n if (newName === this.originalName) {\n return;\n }\n\n // Validate name is not empty\n if (!newName) {\n this.showError('Color name is required.');\n return;\n }\n\n // Clear any existing timeout\n if (this.updateNameTimeout) {\n clearTimeout(this.updateNameTimeout);\n }\n\n // Debounce the update by 500ms\n this.updateNameTimeout = setTimeout(() => {\n this.performNameUpdate(newName);\n }, 500);\n }\n\n performNameUpdate(newName) {\n // Check if we have the necessary data\n if (!this.colorVariantId || !this.saleId) {\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash('Unable to update color. Please refresh the page.');\n }\n return;\n }\n\n // Check CSRF token availability\n if (!this.hasValidCSRFToken()) {\n return;\n }\n\n // Prevent concurrent updates\n if (this.isUpdating) {\n return;\n }\n\n const csrfToken = this.getCSRFTokenSilently();\n const url = `/teamsites/${this.saleId}/colors/${this.colorVariantId}`;\n\n this.isUpdating = true;\n\n // Get current availability status from checkbox\n const isAvailable = this.hasAvailableCheckboxTarget\n ? this.availableCheckboxTarget.checked\n : true;\n\n // Prepare request parameters\n const params = {\n name: newName,\n available: isAvailable ? '1' : '0',\n };\n\n fetch(url, {\n method: 'PATCH',\n headers: {\n 'Accept': 'application/json',\n 'Content-Type': 'application/json',\n 'X-CSRF-Token': csrfToken,\n 'X-Requested-With': 'XMLHttpRequest',\n },\n body: JSON.stringify(params),\n })\n .then(response => {\n if (!response.ok) {\n throw new Error('Failed to update color name');\n }\n return response.json();\n })\n .then(response => {\n // Update the original name to the new value\n this.originalName = newName;\n\n // Update the color name in the table row\n this.updateColorInTable(newName, isAvailable);\n\n // Show success flash message\n if (window.App && window.App.showSuccessFlash) {\n window.App.showSuccessFlash(response.message || 'Changes saved');\n }\n\n // Refresh product sidebar and gallery\n setTimeout(() => {\n this.refreshProductSidebar();\n this.refreshProductGallery();\n }, 10);\n })\n .catch(error => {\n // Revert name field to original value on error\n if (this.hasNameInputTarget) {\n this.nameInputTarget.value = this.originalName || '';\n }\n\n // Show error message\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash(error.message || 'Failed to update color name');\n }\n })\n .finally(() => {\n this.isUpdating = false;\n });\n }\n\n getCurrentColorName() {\n // Try to get the name from the input field\n if (this.hasNameInputTarget) {\n return this.nameInputTarget.value.trim();\n }\n\n // Fallback: Try to get from the color row in the table\n const colorRow = document.querySelector(`#color-variant-row-${this.colorVariantId}`);\n if (colorRow) {\n const nameCell = colorRow.querySelector('.color-variant-name');\n if (nameCell) {\n return nameCell.textContent.trim();\n }\n }\n\n return 'Color';\n }\n\n closeModal() {\n // First, try to close via the ajax-popup controller if it exists\n const ajaxPopupElement = this.element.closest('[data-controller=\"ajax-popup\"]');\n if (ajaxPopupElement) {\n // Dispatch a custom event that ajax-popup controller listens for\n const closeEvent = new CustomEvent('ajax-popup:close', {\n bubbles: true,\n cancelable: true,\n });\n ajaxPopupElement.dispatchEvent(closeEvent);\n return;\n }\n\n // Fallback: Click the close button if it exists\n const closeButton = this.element.querySelector('.mfp-close');\n if (closeButton) {\n closeButton.click();\n return;\n }\n\n // Final fallback: Remove the modal content from DOM entirely\n // This ensures tests can detect the modal is gone\n const editColorPopup = document.getElementById(this.constructor.elementIds.editPopup);\n if (editColorPopup) {\n editColorPopup.remove();\n }\n\n // Also remove magnificPopup wrapper elements if they exist (for backward compatibility)\n const modalWrapper = document.querySelector('.mfp-wrap');\n const modalBg = document.querySelector('.mfp-bg');\n if (modalWrapper) {\n modalWrapper.remove();\n }\n if (modalBg) {\n modalBg.remove();\n }\n\n // Clear the modal container\n const modalContainer = document.getElementById(this.constructor.elementIds.modalContainer);\n if (modalContainer) {\n modalContainer.innerHTML = '';\n }\n\n // Remove the mfp-ready class from body if present\n if (document.body.classList.contains('mfp-ready')) {\n document.body.classList.remove('mfp-ready');\n }\n }\n\n refreshProductSidebar() {\n // Use CustomEvent for Stimulus 1.1.0 compatibility\n const coordinator = document.querySelector('[data-controller*=\"product-edit-coordinator\"]');\n\n if (coordinator) {\n // Dispatch custom event that the coordinator can listen to\n const event = new CustomEvent('product-colors-modal:refresh-sidebar', {\n detail: {\n productId: this.productId,\n colorVariantId: this.colorVariantId,\n },\n bubbles: true,\n });\n coordinator.dispatchEvent(event);\n return;\n }\n\n // If coordinator not available, try to refresh just the sidebar content\n this.refreshSidebarFallback();\n }\n\n refreshSidebarFallback() {\n // Dispatch a custom event that other controllers can listen to\n const event = new CustomEvent('color-variant-updated', {\n detail: { colorVariantId: this.colorVariantId },\n bubbles: true,\n });\n document.dispatchEvent(event);\n\n // Note: Full page reload removed to prevent disrupting user experience\n // Other controllers should listen for 'color-variant-updated' event\n }\n\n refreshProductGallery() {\n // Try to find the gallery refresh controller\n const galleryElement = document.querySelector('[data-controller*=\"product-gallery-refresh\"]');\n\n if (galleryElement) {\n // Dispatch the refresh event to the gallery\n const event = new CustomEvent('product-gallery-refresh', {\n detail: { productId: this.productId },\n });\n galleryElement.dispatchEvent(event);\n\n // Gallery refresh triggered successfully\n } else {\n // Gallery refresh controller not found - trying fallback\n\n // Fallback: Use CustomEvent for Stimulus 1.1.0 compatibility\n const coordinator = document.querySelector('[data-controller*=\"product-edit-coordinator\"]');\n if (coordinator) {\n const event = new CustomEvent('product-colors-modal:refresh-gallery', {\n detail: {\n productId: this.productId,\n colorVariantId: this.colorVariantId,\n },\n bubbles: true,\n });\n coordinator.dispatchEvent(event);\n }\n }\n }\n\n handleUploadSuccess(_event) {\n // Refresh the gallery to show the newly uploaded image\n this.refreshGallery();\n }\n\n async refreshGallery() {\n // Check if we have the necessary data\n if (!this.colorVariantId || !this.saleId) {\n return;\n }\n\n // Check CSRF token availability\n if (!this.hasValidCSRFToken()) {\n return;\n }\n\n const csrfToken = this.getCSRFTokenSilently();\n const url = `/teamsites/${this.saleId}/colors/${this.colorVariantId}/gallery`;\n\n try {\n const response = await fetch(url, {\n method: 'GET',\n headers: {\n 'Accept': 'text/html',\n 'X-CSRF-Token': csrfToken,\n 'X-Requested-With': 'XMLHttpRequest',\n },\n });\n\n if (!response.ok) {\n throw new Error('Failed to refresh gallery');\n }\n\n const html = await response.text();\n const gallery = this.element.querySelector('.photo-gallery');\n\n if (gallery) {\n gallery.innerHTML = html;\n\n // Reinitialize Stimulus controllers in the updated gallery\n this.reinitializeStimulusControllers(gallery);\n }\n } catch {\n // Silent failure - gallery refresh is not critical\n // User can refresh page if needed\n }\n }\n\n reinitializeStimulusControllers(_element) {\n // For Stimulus 1.1.0, we rely on mutation observers to reinitialize controllers\n // The framework automatically detects new elements with data-controller attributes\n // No manual action needed - just wait for the next tick\n setTimeout(() => {\n // Mutation observer will have processed the changes by now\n }, 0);\n }\n\n updateColorInTable(newName, isAvailable) {\n // Find the color variant row in the table\n const colorRow = document.querySelector(`#color-variant-row-${this.colorVariantId}`);\n if (colorRow) {\n // Update the name in the table cell\n const nameCell = colorRow.querySelector('.color-variant-name');\n if (nameCell) {\n nameCell.textContent = newName;\n }\n\n // Update the availability status\n const statusCell = colorRow.querySelector('.color-variant-status');\n if (statusCell) {\n // Remove existing status element\n const existingStatus = statusCell.querySelector('small');\n if (existingStatus) {\n existingStatus.remove();\n }\n\n // Create new status element safely\n const statusElement = document.createElement('small');\n if (isAvailable) {\n statusElement.className = 'ae-status ae-status-active';\n // Get text from data attribute with fallback\n statusElement.textContent = colorRow.dataset.statusAvailableText || 'Available';\n } else {\n statusElement.className = 'ae-status ae-status-deleted';\n // Get text from data attribute with fallback\n statusElement.textContent = colorRow.dataset.statusNotAvailableText || 'Not Available';\n }\n statusCell.appendChild(statusElement);\n }\n\n // Also update the edit modal title if it's still open\n const modalTitle = document.querySelector(`#${this.constructor.elementIds.editPopup} .ae-popup-title`);\n if (modalTitle) {\n // Use simple concatenation for clarity\n modalTitle.textContent = 'Edit Color: ' + newName;\n }\n }\n }\n\n showError(message) {\n if (this.hasErrorTarget) {\n this.errorTarget.textContent = message;\n this.errorTarget.classList.remove('ae-hidden');\n }\n }\n\n hideError() {\n if (this.hasErrorTarget) {\n this.errorTarget.textContent = '';\n this.errorTarget.classList.add('ae-hidden');\n }\n }\n}\n", "import { Controller } from '../stimulus';\nimport CSRFTokenMixin from '../mixins/csrf_token_mixin';\n\nexport default class extends Controller {\n initialize() {\n // Apply CSRF token mixin\n Object.assign(this, CSRFTokenMixin);\n\n // Get data attributes\n this.saleId = this.element.dataset.saleId;\n this.colorVariantId = this.element.dataset.colorVariantId;\n this.imageId = this.element.dataset.imageId;\n\n // Track deletion state\n this.isDeleting = false;\n }\n\n delete(event) {\n event.preventDefault();\n event.stopPropagation(); // Prevent triggering parent click handlers\n\n // Prevent double-deletion\n if (this.isDeleting) {\n return;\n }\n\n if (!confirm('Are you sure you want to delete this image?')) {\n return;\n }\n\n const url = `/teamsites/${this.saleId}/colors/${this.colorVariantId}/images/${this.imageId}`;\n\n // Disable the delete button if it exists\n const deleteButton = event.currentTarget;\n\n // Check CSRF token availability - error will be shown automatically if missing\n if (!this.hasValidCSRFToken()) {\n if (deleteButton) {\n deleteButton.disabled = false;\n }\n this.isDeleting = false;\n return;\n }\n\n const csrfToken = this.getCSRFTokenSilently();\n\n // Mark as deleting to prevent concurrent delete attempts\n this.isDeleting = true;\n if (deleteButton) {\n deleteButton.disabled = true;\n }\n\n // Show visual feedback\n this.element.style.opacity = '0.5';\n\n fetch(url, {\n method: 'DELETE',\n headers: {\n 'Accept': 'application/json',\n 'X-CSRF-Token': csrfToken,\n 'X-Requested-With': 'XMLHttpRequest',\n },\n })\n .then(response => {\n if (!response.ok) {\n return response.json().then(errorData => {\n throw new Error(errorData.error || 'Failed to delete image');\n });\n }\n return response.json();\n })\n .then(data => {\n // Remove the image element from DOM with animation\n this.element.style.transition = 'opacity 0.3s ease-out';\n this.element.style.opacity = '0';\n\n setTimeout(() => {\n this.element.remove();\n }, 300);\n\n if (window.App && window.App.showSuccessFlash) {\n window.App.showSuccessFlash(data.message || 'Image deleted successfully');\n }\n\n // Dispatch event to notify parent controllers if needed\n const event = new CustomEvent('color-variant-image-deleted', {\n detail: { imageId: this.imageId },\n bubbles: true,\n });\n document.dispatchEvent(event);\n\n // Reset deletion state\n this.isDeleting = false;\n })\n .catch(error => {\n // Error deleting image\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash(error.message || 'Failed to delete image');\n }\n\n // Re-enable the delete button and restore opacity on error\n if (deleteButton) {\n deleteButton.disabled = false;\n }\n this.element.style.opacity = '';\n this.isDeleting = false;\n });\n }\n}\n\n", "import { Controller } from '../stimulus';\nimport CSRFTokenMixin from '../mixins/csrf_token_mixin';\n\nexport default class extends Controller {\n initialize() {\n // Apply CSRF token mixin\n Object.assign(this, CSRFTokenMixin);\n\n // Get data attributes\n this.saleId = this.element.dataset.saleId;\n this.colorVariantId = this.element.dataset.colorVariantId;\n this.imageId = this.element.dataset.imageId;\n\n // Track toggle state\n this.isToggling = false;\n }\n\n toggle(event) {\n // Only toggle if clicking on the thumbnail, not on the delete button\n if (event.target.closest('.remove-icon')) {\n return;\n }\n\n event.preventDefault();\n\n // Prevent double-toggle\n if (this.isToggling) {\n return;\n }\n\n const url = `/teamsites/${this.saleId}/colors/${this.colorVariantId}/images/${this.imageId}/toggle`;\n\n // Check CSRF token availability\n if (!this.hasValidCSRFToken()) {\n return;\n }\n\n const csrfToken = this.getCSRFTokenSilently();\n\n // Mark as toggling\n this.isToggling = true;\n\n // Show visual feedback\n this.element.style.opacity = '0.5';\n\n fetch(url, {\n method: 'PATCH',\n headers: {\n 'Accept': 'application/json',\n 'X-CSRF-Token': csrfToken,\n 'X-Requested-With': 'XMLHttpRequest',\n },\n })\n .then(response => {\n if (!response.ok) {\n return response.json().then(errorData => {\n throw new Error(errorData.error || 'Failed to toggle image');\n });\n }\n return response.json();\n })\n .then(data => {\n const statusText = data.is_active ? 'active' : 'inactive';\n if (window.App && window.App.showSuccessFlash) {\n window.App.showSuccessFlash(`Image moved to ${statusText}`);\n }\n\n // Dispatch event to notify parent controllers to refresh gallery\n const toggleEvent = new CustomEvent('color-variant-image-toggled', {\n detail: { imageId: this.imageId, isActive: data.is_active },\n bubbles: true,\n });\n document.dispatchEvent(toggleEvent);\n\n // Reset toggle state\n this.isToggling = false;\n })\n .catch(error => {\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash(error.message || 'Failed to toggle image');\n }\n\n // Restore opacity on error\n this.element.style.opacity = '';\n this.isToggling = false;\n });\n }\n}\n", "import { Controller } from '../stimulus';\nimport CSRFTokenMixin from '../mixins/csrf_token_mixin';\n\nexport default class extends Controller {\n static targets = ['availableCheckbox', 'additionalPriceInput', 'costInput', 'orderLimitCheckbox', 'limitQuantityInput'];\n\n initialize() {\n this.sizeVariantId = this.element.dataset.sizeVariantId;\n this.saleId = this.element.dataset.saleId;\n this.productId = this.element.dataset.productId;\n this.colorVariantId = this.element.dataset.colorVariantId;\n this.updateTimeout = null;\n this.isUpdating = false;\n this.isDeleting = false;\n this.pendingUpdate = null;\n this.hasPendingUpdate = false;\n this.updateAbortController = null;\n\n // Store bound event handlers to properly remove them later\n this.boundUpdateAdditionalPrice = this.updateAdditionalPrice.bind(this);\n this.boundUpdateCost = this.updateCost.bind(this);\n this.boundUpdateLimitQuantity = this.updateLimitQuantity.bind(this);\n this.boundHandleUpdateRequest = this.handleUpdateRequest.bind(this);\n }\n\n connect() {\n // Apply CSRF token mixin\n Object.assign(this, CSRFTokenMixin);\n\n // Add input event listeners for auto-save\n if (this.hasAdditionalPriceInputTarget) {\n this.additionalPriceInputTarget.addEventListener('blur', this.boundUpdateAdditionalPrice);\n }\n\n if (this.hasCostInputTarget) {\n this.costInputTarget.addEventListener('blur', this.boundUpdateCost);\n }\n\n if (this.hasLimitQuantityInputTarget) {\n this.limitQuantityInputTarget.addEventListener('blur', this.boundUpdateLimitQuantity);\n }\n\n // Listen for update requests from numeric input controllers\n this.element.addEventListener('size-variant-update-requested', this.boundHandleUpdateRequest);\n }\n\n disconnect() {\n // Clear any pending timeouts\n if (this.updateTimeout) {\n clearTimeout(this.updateTimeout);\n this.updateTimeout = null;\n }\n\n // Abort any in-flight requests\n if (this.updateAbortController) {\n this.updateAbortController.abort();\n this.updateAbortController = null;\n }\n\n // Clear pending updates\n this.pendingUpdate = null;\n this.hasPendingUpdate = false;\n this.isUpdating = false;\n\n // Remove event listeners\n if (this.hasAdditionalPriceInputTarget) {\n this.additionalPriceInputTarget.removeEventListener('blur', this.boundUpdateAdditionalPrice);\n }\n\n if (this.hasCostInputTarget) {\n this.costInputTarget.removeEventListener('blur', this.boundUpdateCost);\n }\n\n if (this.hasLimitQuantityInputTarget) {\n this.limitQuantityInputTarget.removeEventListener('blur', this.boundUpdateLimitQuantity);\n }\n\n this.element.removeEventListener('size-variant-update-requested', this.boundHandleUpdateRequest);\n }\n\n toggleAvailable(event) {\n event.preventDefault();\n\n if (!this.hasAvailableCheckboxTarget) {\n return;\n }\n\n // Toggle the checkbox state\n this.availableCheckboxTarget.checked = !this.availableCheckboxTarget.checked;\n\n // Trigger the update with the new state\n this.updateSizeVariant({ available: this.availableCheckboxTarget.checked });\n }\n\n updateAvailable(event) {\n const isAvailable = event.target.checked;\n this.updateSizeVariant({ available: isAvailable });\n }\n\n updateAdditionalPrice(event) {\n const value = event.target.value;\n this.updateSizeVariant({ additional_price: value });\n }\n\n updateCost(event) {\n const value = event.target.value;\n this.updateSizeVariant({ cost: value });\n }\n\n updateOrderLimit(event) {\n const hasLimit = event.target.checked;\n\n if (this.hasLimitQuantityInputTarget) {\n this.limitQuantityInputTarget.disabled = !hasLimit;\n if (!hasLimit) {\n this.limitQuantityInputTarget.value = '';\n }\n }\n\n this.updateSizeVariant({ has_order_limit: hasLimit, limit_quantity: hasLimit && this.hasLimitQuantityInputTarget ? this.limitQuantityInputTarget.value : null });\n }\n\n updateLimitQuantity(event) {\n const value = event.target.value;\n this.updateSizeVariant({ limit_quantity: value });\n }\n\n handleUpdateRequest(event) {\n const { field, value } = event.detail;\n const params = {};\n\n // Map field names to parameter names\n // Handle both exact matches and pattern matches (e.g., additional-price-123)\n if (field === 'additional_price' || field.startsWith('additional-price-')) {\n params.additional_price = value;\n } else if (field === 'cost' || field.startsWith('cost-') || field.startsWith('cost-price-')) {\n params.cost = value;\n } else if (field === 'limit_quantity' || field.startsWith('limit-quantity-')) {\n params.limit_quantity = value;\n }\n\n this.updateSizeVariant(params);\n }\n\n delete(event) {\n event.preventDefault();\n\n // Check if button is disabled (size has orders)\n if (event.currentTarget.disabled) {\n return;\n }\n\n // Prevent double-deletion\n if (this.isDeleting) {\n return;\n }\n\n if (!confirm('Are you sure you want to delete this size?')) {\n return;\n }\n\n const url = `/teamsites/${this.saleId}/colors/${this.colorVariantId}/sizes/${this.sizeVariantId}`;\n\n // Disable the delete button if it exists\n const deleteButton = event.currentTarget;\n\n // Check CSRF token availability - error will be shown automatically if missing\n if (!this.hasValidCSRFToken()) {\n if (deleteButton) {\n deleteButton.disabled = false;\n }\n this.isDeleting = false;\n return;\n }\n\n const csrfToken = this.getCSRFTokenSilently();\n\n // Mark as deleting to prevent concurrent delete attempts\n this.isDeleting = true;\n if (deleteButton) {\n deleteButton.disabled = true;\n }\n\n fetch(url, {\n method: 'DELETE',\n headers: {\n 'Accept': 'application/json',\n 'X-CSRF-Token': csrfToken,\n 'X-Requested-With': 'XMLHttpRequest',\n },\n })\n .then(response => {\n if (!response.ok) {\n throw new Error('Failed to delete size');\n }\n return response.json();\n })\n .then(data => {\n // Dispatch event to notify parent controller\n const event = new CustomEvent('size-variant-deleted', {\n detail: { sizeVariantId: this.sizeVariantId },\n bubbles: true,\n });\n this.element.dispatchEvent(event);\n\n if (window.App && window.App.showSuccessFlash) {\n window.App.showSuccessFlash(data.message || 'Size deleted successfully');\n }\n })\n .catch(_error => {\n // Error deleting size\n // Failed to delete size variant\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash('Failed to delete size');\n }\n\n // Re-enable the delete button on error\n if (deleteButton) {\n deleteButton.disabled = false;\n }\n this.isDeleting = false;\n });\n }\n\n updateSizeVariant(params) {\n // Clear any existing timeout\n if (this.updateTimeout) {\n clearTimeout(this.updateTimeout);\n }\n\n // Merge new params with existing pending update\n this.pendingUpdate = { ...this.pendingUpdate, ...params };\n this.hasPendingUpdate = true;\n\n // Debounce the update\n this.updateTimeout = setTimeout(() => {\n const updateParams = this.pendingUpdate;\n this.pendingUpdate = null;\n this.hasPendingUpdate = false;\n this.performUpdate(updateParams);\n }, 500);\n }\n\n updateUIWithData(data) {\n // Validate data object exists and is an object\n if (!data || typeof data !== 'object') {\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash('Invalid product data received');\n }\n return;\n }\n\n try {\n // Update the input fields with the returned data\n if (this.hasCostInputTarget && data.cost_price !== undefined) {\n const costPrice = parseFloat(data.cost_price);\n // Only update if we have a valid number\n if (!isNaN(costPrice)) {\n this.costInputTarget.value = costPrice.toFixed(2);\n }\n }\n\n if (this.hasAdditionalPriceInputTarget && data.additional_price !== undefined) {\n const additionalPrice = parseFloat(data.additional_price);\n // Only update if we have a valid number\n if (!isNaN(additionalPrice)) {\n this.additionalPriceInputTarget.value = additionalPrice.toFixed(2);\n }\n }\n\n if (this.hasAvailableCheckboxTarget && data.available !== undefined) {\n this.availableCheckboxTarget.checked = data.available;\n }\n\n // Note: Order limit UI update not implemented yet as the UI is disabled\n } catch {\n // Error updating UI with data\n }\n }\n\n performUpdate(params) {\n // Prevent concurrent updates\n if (this.isUpdating) {\n // If there's a pending update, it will be handled after current one completes\n return;\n }\n\n // Abort any in-flight request\n if (this.updateAbortController) {\n this.updateAbortController.abort();\n }\n\n const url = `/teamsites/${this.saleId}/colors/${this.colorVariantId}/sizes/${this.sizeVariantId}`;\n\n // Check CSRF token availability - error will be shown automatically if missing\n if (!this.hasValidCSRFToken()) {\n this.isUpdating = false;\n this.updateAbortController = null;\n return;\n }\n\n const csrfToken = this.getCSRFTokenSilently();\n\n // Create new abort controller for this request\n this.updateAbortController = new AbortController();\n this.isUpdating = true;\n\n fetch(url, {\n method: 'PATCH',\n headers: {\n 'Accept': 'application/json',\n 'Content-Type': 'application/json',\n 'X-CSRF-Token': csrfToken,\n 'X-Requested-With': 'XMLHttpRequest',\n },\n body: JSON.stringify(params),\n signal: this.updateAbortController.signal,\n })\n .then(response => {\n if (!response.ok) {\n throw new Error('Failed to update size');\n }\n return response.json();\n })\n .then(data => {\n // Update UI with returned data if available\n if (data.updated_data) {\n this.updateUIWithData(data.updated_data);\n }\n\n // Dispatch event to notify parent controller\n const event = new CustomEvent('size-variant-updated', {\n detail: {\n sizeVariantId: this.sizeVariantId,\n params,\n updatedData: data.updated_data,\n },\n bubbles: true,\n });\n this.element.dispatchEvent(event);\n\n // Show success message\n if (window.App && window.App.showSuccessFlash) {\n window.App.showSuccessFlash('Size updated successfully');\n }\n })\n .catch(error => {\n // Don't show error for aborted requests\n if (error.name === 'AbortError') {\n return;\n }\n // Error updating size\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash('Failed to update size');\n }\n })\n .finally(() => {\n this.isUpdating = false;\n this.updateAbortController = null;\n\n // Check if there's a pending update that came in while we were updating\n if (this.hasPendingUpdate) {\n this.hasPendingUpdate = false;\n const nextUpdate = this.pendingUpdate;\n this.pendingUpdate = null;\n this.performUpdate(nextUpdate);\n }\n });\n }\n}\n", "import { Controller } from '../stimulus';\nimport CSRFTokenMixin from '../mixins/csrf_token_mixin';\n\nexport default class extends Controller {\n static targets = ['add', 'form', 'sizeInput', 'submitButton'];\n\n initialize() {\n this.saleId = this.element.dataset.saleId;\n this.productId = this.element.dataset.productId;\n this.colorVariantId = this.element.dataset.colorVariantId;\n }\n\n connect() {\n // Apply CSRF token mixin\n Object.assign(this, CSRFTokenMixin);\n\n // Hide form initially\n if (this.hasFormTarget) {\n this.formTarget.style.display = 'none';\n }\n }\n\n showForm(event) {\n event.preventDefault();\n\n if (this.hasAddTarget) {\n this.addTarget.style.display = 'none';\n }\n\n if (this.hasFormTarget) {\n this.formTarget.style.display = 'block';\n\n // Focus the size input\n if (this.hasSizeInputTarget) {\n this.sizeInputTarget.focus();\n }\n }\n }\n\n hideForm(event) {\n if (event) {\n event.preventDefault();\n }\n\n if (this.hasAddTarget) {\n this.addTarget.style.display = 'block';\n }\n\n if (this.hasFormTarget) {\n this.formTarget.style.display = 'none';\n\n // Clear the input\n if (this.hasSizeInputTarget) {\n this.sizeInputTarget.value = '';\n }\n }\n }\n\n submit(event) {\n event.preventDefault();\n\n if (!this.hasSizeInputTarget || !this.sizeInputTarget.value.trim()) {\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash('Please enter a size');\n }\n return;\n }\n\n const sizeName = this.sizeInputTarget.value.trim();\n\n // Disable submit button\n if (this.hasSubmitButtonTarget) {\n this.submitButtonTarget.disabled = true;\n }\n\n const url = `/teamsites/${this.saleId}/colors/${this.colorVariantId}/sizes`;\n\n // Build headers - only include CSRF token if available\n const headers = {\n 'Accept': 'application/json',\n 'Content-Type': 'application/json',\n 'X-Requested-With': 'XMLHttpRequest',\n };\n\n // Add CSRF token only if available (tests may not have it)\n const csrfToken = this.getCSRFTokenSilently();\n if (csrfToken) {\n headers['X-CSRF-Token'] = csrfToken;\n }\n\n fetch(url, {\n method: 'POST',\n headers: headers,\n body: JSON.stringify({ size_name: sizeName }),\n })\n .then(response => {\n if (!response.ok) {\n return response.json().then(data => {\n throw new Error(data.error || 'Failed to add size');\n });\n }\n return response.json();\n })\n .then(data => {\n // Hide form and clear input\n this.hideForm();\n\n // Dispatch event to notify parent controller\n const event = new CustomEvent('size-variant-added', {\n detail: { sizeVariantId: data.id, sizeName: data.name },\n bubbles: true,\n });\n this.element.dispatchEvent(event);\n\n if (window.App && window.App.showSuccessFlash) {\n window.App.showSuccessFlash(data.message || 'Size added successfully');\n }\n })\n .catch(error => {\n // Error adding size\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash(error.message || 'Failed to add size');\n }\n })\n .finally(() => {\n this.resetSubmitButton();\n });\n }\n\n resetSubmitButton() {\n if (this.hasSubmitButtonTarget) {\n this.submitButtonTarget.disabled = false;\n }\n }\n}\n", "import { Controller } from '../stimulus';\nimport CSRFTokenMixin from '../mixins/csrf_token_mixin';\n\nexport default class extends Controller {\n static targets = ['sizesTable', 'selectContainer'];\n\n initialize() {\n this.saleId = this.element.dataset.saleId;\n this.productId = this.element.dataset.productId;\n this.colorVariantId = this.element.dataset.colorVariantId;\n this.isRefreshing = false;\n this.refreshAbortController = null;\n\n // Store bound event handlers to properly remove them later\n this.boundHandleSizingOptionChange = this.handleSizingOptionChange.bind(this);\n this.boundHandleSizeVariantUpdate = this.handleSizeVariantUpdate.bind(this);\n this.boundHandleSizeVariantAdded = this.handleSizeVariantAdded.bind(this);\n this.boundHandleSizeVariantDeleted = this.handleSizeVariantDeleted.bind(this);\n }\n\n connect() {\n // Apply CSRF token mixin\n Object.assign(this, CSRFTokenMixin);\n\n // Listen for sizing option changes\n this.element.addEventListener('sizing-option-changed', this.boundHandleSizingOptionChange);\n\n // Listen for size variant updates\n this.element.addEventListener('size-variant-updated', this.boundHandleSizeVariantUpdate);\n\n // Listen for size variant added\n this.element.addEventListener('size-variant-added', this.boundHandleSizeVariantAdded);\n\n // Listen for size variant deleted\n this.element.addEventListener('size-variant-deleted', this.boundHandleSizeVariantDeleted);\n }\n\n disconnect() {\n // Abort any in-flight refresh request\n if (this.refreshAbortController) {\n this.refreshAbortController.abort();\n this.refreshAbortController = null;\n }\n\n // Remove event listeners\n this.element.removeEventListener('sizing-option-changed', this.boundHandleSizingOptionChange);\n this.element.removeEventListener('size-variant-updated', this.boundHandleSizeVariantUpdate);\n this.element.removeEventListener('size-variant-added', this.boundHandleSizeVariantAdded);\n this.element.removeEventListener('size-variant-deleted', this.boundHandleSizeVariantDeleted);\n }\n\n handleSizingOptionChange(event) {\n // If HTML provided in event, use it directly (eliminates race condition)\n // HTML is server-rendered and trusted (same source as refreshSizesTable)\n if (event.detail && event.detail.sizesTableHtml) {\n this.updateSizesTableContainer(event.detail.sizesTableHtml);\n } else {\n // Fallback to fetch (for backwards compatibility)\n this.refreshSizesTable();\n }\n }\n\n // Update sizes table container with provided HTML\n // Extracted for reuse by both handleSizingOptionChange and refreshSizesTable\n // Security: HTML is server-rendered via Rails ERB - same trust model as refreshSizesTable\n updateSizesTableContainer(html) {\n const container = document.getElementById('sizes-table-container');\n if (!container) {\n return;\n }\n\n try {\n // Create a temporary container to parse the server-rendered HTML\n const tempContainer = document.createElement('div');\n // HTML is server-rendered via Rails ERB - same trust model as refreshSizesTable\n tempContainer.innerHTML = html;\n\n // Validate that we have content to insert\n if (!tempContainer.firstChild) {\n return;\n }\n\n // Replace the entire container content to ensure proper cleanup\n while (container.firstChild) {\n container.removeChild(container.firstChild);\n }\n\n // Move all children from temp container to the actual container\n while (tempContainer.firstChild) {\n container.appendChild(tempContainer.firstChild);\n }\n\n // Explicitly null out the temporary container to help garbage collection\n tempContainer.remove();\n } catch {\n // Error updating sizes table DOM\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash('Failed to update sizes table');\n }\n }\n }\n\n handleSizeVariantUpdate(event) {\n // Only refresh if we didn't get updated data (for backwards compatibility)\n // If we have updated data, the UI was already updated optimistically\n if (!event.detail || !event.detail.updatedData) {\n this.refreshSizesTable();\n }\n }\n\n handleSizeVariantAdded(_event) {\n this.refreshSizesTable();\n }\n\n handleSizeVariantDeleted(event) {\n const { sizeVariantId } = event.detail;\n // Note: The row removal is handled optimistically in the size_variant_row_controller\n // which dispatches this event only after successful server deletion.\n // We could add additional verification here if needed, but the current\n // implementation ensures the event is only fired on success.\n const row = document.getElementById(`size-variant-row-${sizeVariantId}`);\n if (row) {\n row.remove();\n }\n }\n\n refreshSizesTable() {\n // Prevent concurrent refreshes\n if (this.isRefreshing) {\n return;\n }\n\n // Abort any previous refresh request\n if (this.refreshAbortController) {\n this.refreshAbortController.abort();\n }\n\n const url = `/teamsites/${this.saleId}/colors/${this.colorVariantId}/sizes/table`;\n\n // Check CSRF token availability\n if (!this.hasValidCSRFToken || !this.hasValidCSRFToken()) {\n this.isRefreshing = false;\n return;\n }\n\n const csrfToken = this.getCSRFTokenSilently ? this.getCSRFTokenSilently() : null;\n\n if (!csrfToken) {\n this.isRefreshing = false;\n return;\n }\n\n // Create new abort controller for this request\n this.refreshAbortController = new AbortController();\n this.isRefreshing = true;\n\n fetch(url, {\n headers: {\n 'Accept': 'text/html',\n 'X-CSRF-Token': csrfToken,\n 'X-Requested-With': 'XMLHttpRequest',\n },\n signal: this.refreshAbortController.signal,\n })\n .then(response => {\n if (!response.ok) {\n throw new Error('Failed to refresh sizes table');\n }\n return response.text();\n })\n .then(html => {\n // Update the sizes table container\n const container = document.getElementById('sizes-table-container');\n if (!container) {\n return;\n }\n\n try {\n // Create a temporary container to parse the HTML\n const tempContainer = document.createElement('div');\n tempContainer.innerHTML = html;\n\n // Validate that we have content to insert\n if (!tempContainer.firstChild) {\n return;\n }\n\n // Replace the entire container content to ensure proper cleanup\n while (container.firstChild) {\n container.removeChild(container.firstChild);\n }\n\n // Move all children from temp container to the actual container\n while (tempContainer.firstChild) {\n container.appendChild(tempContainer.firstChild);\n }\n\n // Explicitly null out the temporary container to help garbage collection\n tempContainer.remove();\n } catch {\n // Error updating sizes table DOM\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash('Failed to update sizes table');\n }\n }\n })\n .catch(error => {\n // Don't show error for aborted requests\n if (error.name === 'AbortError') {\n return;\n }\n // Error refreshing sizes table\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash('Failed to refresh sizes table');\n }\n })\n .finally(() => {\n this.isRefreshing = false;\n this.refreshAbortController = null;\n });\n }\n}\n", "/**\n * @file Product Colors Modal Sizes Drag Controller\n * @description Stimulus controller for handling drag-and-drop functionality of size variants.\n * Allows vertical reordering of table rows using vanilla JavaScript.\n * No external dependencies required.\n *\n * Key Features:\n * - Vertical drag detection with visual feedback\n * - Smooth reordering animation\n * - Server synchronization via PATCH requests\n * - Touch and mouse support\n * - Debouncing and retry logic\n * - Memory leak prevention\n *\n * @example\n * \n * ...\n * ...\n * \n */\n\nimport { Controller } from '../stimulus';\n\nconst DRAG_THRESHOLD_PX = 5; // Minimum movement to consider it a drag\n\nexport default class extends Controller {\n initialize() {\n this.debug = false; // Set via data attribute if needed\n\n // Bind methods\n this.handleMouseDown = this.handleMouseDown.bind(this);\n this.handleMouseMove = this.handleMouseMove.bind(this);\n this.handleMouseUp = this.handleMouseUp.bind(this);\n this.handleTouchStart = this.handleTouchStart.bind(this);\n this.handleTouchMove = this.handleTouchMove.bind(this);\n this.handleTouchEnd = this.handleTouchEnd.bind(this);\n\n // Initialize state\n this.isDragging = false;\n this.draggedElement = null;\n this.startX = 0;\n this.startY = 0;\n this.currentY = 0;\n this.initialMouseY = 0;\n this.initialElementY = 0;\n\n // Initialize debouncing state\n this.pendingUpdates = new Map(); // Track pending updates per size\n this.updateTimeouts = new Map(); // Track timeouts per size\n this.isUpdating = new Set(); // Track sizes currently being updated\n this.debounceDelay = 300; // Default delay in milliseconds\n this.maxRetries = 3; // Default maximum retry attempts\n this.retryDelay = 1000; // Default delay between retries (ms)\n\n // Initialize lifecycle tracking\n this.isConnected = false;\n this.abortControllers = new Map(); // Track active fetch requests\n this.retryAttempts = new Map(); // Track retry attempts per size\n this.retryTimeouts = new Map(); // Track retry timeouts per size\n }\n\n connect() {\n // Mark as connected\n this.isConnected = true;\n\n // Get data attributes\n this.saleId = this.element.dataset.saleId;\n this.productId = this.element.dataset.productId;\n this.colorVariantId = this.element.dataset.colorVariantId;\n\n // Get configurable debounce delay (default to 300ms if not specified)\n const configuredDelay = parseInt(this.element.dataset.debounceDelay, 10);\n if (!isNaN(configuredDelay) && configuredDelay >= 0) {\n this.debounceDelay = configuredDelay;\n }\n\n // Get configurable max retries (default to 3 if not specified)\n const configuredMaxRetries = parseInt(this.element.dataset.maxRetries, 10);\n if (!isNaN(configuredMaxRetries) && configuredMaxRetries >= 0) {\n this.maxRetries = configuredMaxRetries;\n }\n\n // Get configurable retry delay (default to 1000ms if not specified)\n const configuredRetryDelay = parseInt(this.element.dataset.retryDelay, 10);\n if (!isNaN(configuredRetryDelay) && configuredRetryDelay >= 0) {\n this.retryDelay = configuredRetryDelay;\n }\n\n // Add event listeners\n this.addEventListeners();\n }\n\n disconnect() {\n // Mark as disconnected\n this.isConnected = false;\n\n // Abort all active fetch requests\n this.abortAllRequests();\n\n // Clean up\n this.removeEventListeners();\n this.cleanup();\n this.cleanupPendingUpdates();\n }\n\n addEventListeners() {\n const handles = this.getDragHandles();\n handles.forEach(handle => {\n handle.addEventListener('mousedown', this.handleMouseDown);\n handle.addEventListener('touchstart', this.handleTouchStart, { passive: false });\n });\n }\n\n removeEventListeners() {\n const handles = this.getDragHandles();\n handles.forEach(handle => {\n handle.removeEventListener('mousedown', this.handleMouseDown);\n handle.removeEventListener('touchstart', this.handleTouchStart);\n });\n\n // Remove document-level listeners\n document.removeEventListener('mousemove', this.handleMouseMove);\n document.removeEventListener('mouseup', this.handleMouseUp);\n document.removeEventListener('touchmove', this.handleTouchMove);\n document.removeEventListener('touchend', this.handleTouchEnd);\n }\n\n getRowElements() {\n return Array.from(this.element.children).filter(child => child.tagName === 'TR');\n }\n\n getDragHandles() {\n return Array.from(this.element.querySelectorAll('.drag-handle'));\n }\n\n handleMouseDown(event) {\n // Prevent default to avoid text selection\n event.preventDefault();\n\n const handle = event.currentTarget;\n const rowElement = handle.closest('tr');\n\n // Check for required data-size-variant-id attribute\n if (!rowElement || !rowElement.dataset.sizeVariantId) {\n this.logDebug('Drag failed: Missing data-size-variant-id attribute on element', rowElement);\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash('Unable to reorder this size. Please refresh the page and try again.');\n }\n return;\n }\n\n this.startDrag(rowElement, event.clientX, event.clientY);\n\n // Add document-level listeners\n document.addEventListener('mousemove', this.handleMouseMove);\n document.addEventListener('mouseup', this.handleMouseUp);\n }\n\n handleTouchStart(event) {\n // Prevent scrolling while dragging\n event.preventDefault();\n\n const touch = event.touches[0];\n const handle = event.currentTarget;\n const rowElement = handle.closest('tr');\n\n // Check for required data-size-variant-id attribute\n if (!rowElement || !rowElement.dataset.sizeVariantId) {\n this.logDebug('Drag failed: Missing data-size-variant-id attribute on element', rowElement);\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash('Unable to reorder this size. Please refresh the page and try again.');\n }\n return;\n }\n\n this.startDrag(rowElement, touch.clientX, touch.clientY);\n\n // Add document-level listeners\n document.addEventListener('touchmove', this.handleTouchMove, { passive: false });\n document.addEventListener('touchend', this.handleTouchEnd);\n }\n\n handleMouseMove(event) {\n this.updateDrag(event.clientX, event.clientY);\n }\n\n handleTouchMove(event) {\n event.preventDefault();\n const touch = event.touches[0];\n this.updateDrag(touch.clientX, touch.clientY);\n }\n\n handleMouseUp() {\n this.endDrag();\n\n // Remove document-level listeners\n document.removeEventListener('mousemove', this.handleMouseMove);\n document.removeEventListener('mouseup', this.handleMouseUp);\n }\n\n handleTouchEnd() {\n this.endDrag();\n\n // Remove document-level listeners\n document.removeEventListener('touchmove', this.handleTouchMove);\n document.removeEventListener('touchend', this.handleTouchEnd);\n }\n\n startDrag(element, clientX, clientY) {\n this.startX = clientX;\n this.startY = clientY;\n this.currentY = clientY;\n this.draggedElement = element;\n this.initialMouseY = clientY;\n\n // Get initial position\n const rect = element.getBoundingClientRect();\n this.initialElementY = rect.top;\n }\n\n updateDrag(clientX, clientY) {\n this.currentY = clientY;\n\n // Check if we've moved enough to start dragging\n const deltaX = Math.abs(clientX - this.startX);\n const deltaY = Math.abs(clientY - this.startY);\n\n if (!this.isDragging && (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX)) {\n this.isDragging = true;\n this.initializeDragVisuals();\n }\n\n if (this.isDragging) {\n // Update dragged element position (vertical movement)\n const offsetY = clientY - this.initialMouseY;\n this.draggedElement.style.transform = `translateY(${offsetY}px)`;\n\n // Check for reordering\n this.checkForReorder();\n }\n }\n\n initializeDragVisuals() {\n if (!this.draggedElement) return;\n\n // Style dragged element\n // NOTE: position: relative is required for transform to work correctly during DOM reordering\n this.draggedElement.style.position = 'relative';\n this.draggedElement.style.zIndex = '1000';\n this.draggedElement.style.opacity = '0.9';\n this.draggedElement.style.cursor = 'grabbing';\n this.draggedElement.classList.add('dragging');\n }\n\n checkForReorder() {\n const rows = this.getRowElements().filter(row => row !== this.draggedElement);\n\n const draggedRect = this.draggedElement.getBoundingClientRect();\n const draggedCenterY = draggedRect.top + draggedRect.height / 2;\n\n for (const row of rows) {\n const rect = row.getBoundingClientRect();\n const centerY = rect.top + rect.height / 2;\n\n // Check if we should swap (vertical logic)\n if (draggedCenterY < centerY && this.isAfter(this.draggedElement, row)) {\n // Move dragged element before this row\n row.parentNode.insertBefore(this.draggedElement, row);\n // Adjust transform after DOM reordering to prevent acceleration\n this.adjustAfterReorder();\n } else if (draggedCenterY > centerY && this.isBefore(this.draggedElement, row)) {\n // Move dragged element after this row\n row.parentNode.insertBefore(this.draggedElement, row.nextSibling);\n // Adjust transform after DOM reordering to prevent acceleration\n this.adjustAfterReorder();\n }\n }\n }\n\n isAfter(el1, el2) {\n return !!(el1.compareDocumentPosition(el2) & Node.DOCUMENT_POSITION_PRECEDING);\n }\n\n isBefore(el1, el2) {\n return !!(el1.compareDocumentPosition(el2) & Node.DOCUMENT_POSITION_FOLLOWING);\n }\n\n adjustAfterReorder() {\n // After DOM reordering, the element's natural position changes\n // We need to adjust the transform to maintain the same visual position\n\n // Clear transform to get true DOM position\n this.draggedElement.style.transform = '';\n\n // Get new position in DOM (without transform)\n const newRect = this.draggedElement.getBoundingClientRect();\n\n // Calculate offset to maintain same visual position\n // currentY is where the mouse is now, newRect.top is where the element naturally is\n const offsetY = this.currentY - newRect.top;\n\n // Reapply transform with corrected offset\n this.draggedElement.style.transform = `translateY(${offsetY}px)`;\n\n // Update initialMouseY for future calculations\n // This ensures next updateDrag() calculates correctly from the new baseline\n this.initialMouseY = this.currentY - offsetY;\n }\n\n endDrag() {\n if (!this.isDragging) return;\n\n // Remove visual styles\n if (this.draggedElement) {\n const droppedElement = this.draggedElement;\n\n droppedElement.style.position = '';\n droppedElement.style.zIndex = '';\n droppedElement.style.opacity = '';\n droppedElement.style.cursor = '';\n droppedElement.style.transform = '';\n droppedElement.classList.remove('dragging');\n\n // Add drop-settling animation for visual feedback\n droppedElement.classList.add('drop-settling');\n setTimeout(() => {\n droppedElement.classList.remove('drop-settling');\n }, 250);\n\n // Get new position\n const rows = this.getRowElements();\n const newPosition = rows.indexOf(droppedElement) + 1;\n\n // Debounce the server update\n this.debouncedUpdateSizePosition(droppedElement, newPosition);\n }\n\n // Cleanup\n this.cleanup();\n }\n\n cleanup() {\n this.isDragging = false;\n this.draggedElement = null;\n this.startX = 0;\n this.startY = 0;\n this.currentY = 0;\n this.initialMouseY = 0;\n this.initialElementY = 0;\n }\n\n cleanupPendingUpdates() {\n // Clear all pending timeouts\n this.updateTimeouts.forEach((timeoutId) => {\n clearTimeout(timeoutId);\n });\n this.updateTimeouts.clear();\n\n // Clear all retry timeouts\n this.retryTimeouts.forEach((timeoutId) => {\n clearTimeout(timeoutId);\n });\n this.retryTimeouts.clear();\n\n // Clear pending updates\n this.pendingUpdates.clear();\n\n // Clear updating set\n this.isUpdating.clear();\n\n // Clear retry attempts\n this.retryAttempts.clear();\n }\n\n abortAllRequests() {\n // Abort all active fetch requests\n this.abortControllers.forEach((controller) => {\n controller.abort();\n });\n this.abortControllers.clear();\n }\n\n debouncedUpdateSizePosition(element, position) {\n const sizeVariantId = element.dataset.sizeVariantId;\n if (!sizeVariantId) {\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash('Unable to save size order. Missing size information.');\n }\n return;\n }\n\n // Cancel any existing timeout for this size\n if (this.updateTimeouts.has(sizeVariantId)) {\n clearTimeout(this.updateTimeouts.get(sizeVariantId));\n this.updateTimeouts.delete(sizeVariantId);\n }\n\n // Store the pending update (reset retry count for new user action)\n this.pendingUpdates.set(sizeVariantId, { element, position, retryCount: 0 });\n\n // If this size is already being updated, wait for it to complete\n if (this.isUpdating.has(sizeVariantId)) {\n // The update will be retried after the current one completes\n return;\n }\n\n // Set a new timeout\n const timeoutId = setTimeout(() => {\n // Check if controller is still connected first\n if (!this.isConnected) {\n return;\n }\n\n // Get the latest pending update for this size\n const pendingUpdate = this.pendingUpdates.get(sizeVariantId);\n if (pendingUpdate) {\n // Don't delete the pending update yet - let updateSizePosition handle it\n // This allows the success handler to check for queued updates\n this.updateTimeouts.delete(sizeVariantId);\n this.updateSizePosition(pendingUpdate.element, pendingUpdate.position);\n }\n }, this.debounceDelay);\n\n this.updateTimeouts.set(sizeVariantId, timeoutId);\n }\n\n updateSizePosition(element, position) {\n const sizeVariantId = element.dataset.sizeVariantId;\n if (!sizeVariantId) {\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash('Unable to save size order. Missing size information.');\n }\n return;\n }\n\n // Validate required data attributes\n if (!this.saleId || !this.colorVariantId) {\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash('Unable to save size order. Please refresh the page and try again.');\n }\n return;\n }\n\n // Check if controller is still connected\n if (!this.isConnected) {\n return;\n }\n\n // Mark as updating\n this.isUpdating.add(sizeVariantId);\n\n // Create abort controller for this request\n const abortController = new AbortController();\n\n // Cancel any existing request for this size\n if (this.abortControllers.has(sizeVariantId)) {\n this.abortControllers.get(sizeVariantId).abort();\n }\n this.abortControllers.set(sizeVariantId, abortController);\n\n const url = `/teamsites/${this.saleId}/colors/${this.colorVariantId}/sizes/${sizeVariantId}/reorder`;\n const token = document.querySelector('meta[name=\"csrf-token\"]')?.getAttribute('content');\n\n // Show loading feedback (only if still connected)\n if (this.isConnected) {\n element.style.opacity = '0.6';\n element.classList.add('updating');\n }\n\n fetch(url, {\n method: 'PATCH',\n headers: {\n 'X-CSRF-Token': token,\n 'Content-Type': 'application/json',\n 'Accept': 'application/json',\n },\n body: JSON.stringify({\n position: position,\n }),\n credentials: 'same-origin',\n signal: abortController.signal,\n })\n .then(response => {\n // Check if controller is still connected before processing\n if (!this.isConnected) {\n throw new Error('Controller disconnected');\n }\n\n if (!response.ok) {\n return response.json().then(errorData => {\n const error = new Error(errorData.error || `Server error: ${response.status}`);\n error.status = response.status; // Add status property for reliable detection\n throw error;\n });\n }\n return response.json();\n })\n .then(data => {\n // Check if controller is still connected before updating UI\n if (!this.isConnected) {\n // Clean up tracking even if disconnected\n this.abortControllers.delete(sizeVariantId);\n this.isUpdating.delete(sizeVariantId);\n return;\n }\n\n // Show success message\n if (window.App && window.App.showSuccessFlash) {\n window.App.showSuccessFlash(data.message || 'Size order updated');\n }\n\n // Remove loading state\n element.style.opacity = '';\n element.classList.remove('updating');\n\n // Clean up abort controller\n this.abortControllers.delete(sizeVariantId);\n\n // Mark as no longer updating\n this.isUpdating.delete(sizeVariantId);\n\n // Clear retry attempts on successful completion\n this.retryAttempts.delete(sizeVariantId);\n\n // Check if there's a pending update for this size\n if (this.pendingUpdates.has(sizeVariantId) && this.isConnected) {\n const pendingUpdate = this.pendingUpdates.get(sizeVariantId);\n // Always clear the current pending update first\n this.pendingUpdates.delete(sizeVariantId);\n\n // Only retry if the position is actually different\n if (pendingUpdate.position !== position) {\n // Retry with the latest position\n this.updateSizePosition(pendingUpdate.element, pendingUpdate.position);\n }\n }\n })\n .catch(error => {\n // Don't show error for aborted requests\n if (error.name === 'AbortError') {\n return;\n }\n\n // Check if controller is still connected before handling error\n if (!this.isConnected) {\n // Clean up tracking even if disconnected\n this.abortControllers.delete(sizeVariantId);\n this.isUpdating.delete(sizeVariantId);\n return;\n }\n\n // Remove loading state\n element.style.opacity = '';\n element.classList.remove('updating');\n\n // Clean up abort controller\n this.abortControllers.delete(sizeVariantId);\n\n // Mark as no longer updating\n this.isUpdating.delete(sizeVariantId);\n\n // Show specific error message\n let errorMessage = 'Failed to update size order. ';\n\n // Check error.status first (most reliable), then fall back to message string matching\n if (error.status === 404 || error.message.includes('404')) {\n errorMessage += 'Size not found.';\n } else if (error.status === 401 || error.message.includes('401') || error.message.toLowerCase().includes('unauthorized')) {\n errorMessage += 'Please sign in and try again.';\n } else if (error.status === 422 || error.message.includes('422') || error.message.includes('Invalid position')) {\n errorMessage += 'Invalid position.';\n } else if (error.message.includes('Network')) {\n errorMessage += 'Please check your internet connection.';\n } else if (error.message === 'Controller disconnected') {\n // Don't show error for controller disconnection\n return;\n } else {\n errorMessage += error.message || 'Please try again.';\n }\n\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash(errorMessage);\n }\n\n const currentRetries = this.retryAttempts.get(sizeVariantId) || 0;\n\n // Check if we've exceeded max retries\n if (currentRetries >= this.maxRetries) {\n // Show final error message and clean up\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash(`Failed to update size order after ${this.maxRetries} retries. Please try again manually.`);\n }\n this.retryAttempts.delete(sizeVariantId);\n return;\n }\n\n // Increment retry count\n this.retryAttempts.set(sizeVariantId, currentRetries + 1);\n\n // Retry with fixed delay\n const retryTimeoutId = setTimeout(() => {\n // Clear the timeout from tracking\n this.retryTimeouts.delete(sizeVariantId);\n\n // Check if still connected before retrying\n if (this.isConnected) {\n // Clear updating state to allow retry\n this.isUpdating.delete(sizeVariantId);\n this.updateSizePosition(element, position);\n }\n }, this.retryDelay);\n\n // Track the retry timeout\n this.retryTimeouts.set(sizeVariantId, retryTimeoutId);\n });\n }\n\n logDebug() {\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n initialize() {\n this.debug = false; // Set via data attribute if needed\n this.sizeVariantId = this.element.dataset.sizeVariantId;\n this.lastSavedValue = null;\n this.saveTimeout = null;\n\n // Store bound event handlers to properly remove them later\n this.boundHandleChange = this.handleChange.bind(this);\n this.boundAutosave = this.autosave.bind(this);\n this.boundClearErrorOnInput = this.clearErrorOnInput.bind(this);\n }\n\n connect() {\n this.element.addEventListener('change', this.boundHandleChange);\n this.element.addEventListener('blur', this.boundAutosave);\n this.element.addEventListener('input', this.boundClearErrorOnInput);\n\n // Store initial value\n this.lastSavedValue = this.element.value;\n }\n\n disconnect() {\n // Clear any pending save timeout\n if (this.saveTimeout) {\n clearTimeout(this.saveTimeout);\n }\n\n this.element.removeEventListener('change', this.boundHandleChange);\n this.element.removeEventListener('blur', this.boundAutosave);\n this.element.removeEventListener('input', this.boundClearErrorOnInput);\n }\n\n clearErrorOnInput() {\n if (this.element.classList.contains('error')) {\n this.element.classList.remove('error');\n this.element.removeAttribute('aria-invalid');\n }\n }\n\n handleChange(event) {\n event.preventDefault();\n\n const inputValue = this.element.value;\n const formattedValue = this.limitValueToTwoDecimalPlaces(inputValue);\n this.element.value = formattedValue;\n\n // Update last saved value to the formatted value to prevent\n // unnecessary saves when the value is just being formatted\n if (this.lastSavedValue === inputValue) {\n this.lastSavedValue = formattedValue;\n }\n }\n\n limitValueToTwoDecimalPlaces(inputValue) {\n // Return empty string as-is\n if (inputValue === '') {\n return inputValue;\n }\n\n // Remove any non-numeric characters except decimal point\n const cleanedValue = inputValue.replace(/[^\\d.]/g, '');\n\n // Handle multiple decimal points by keeping only the first\n const parts = cleanedValue.split('.');\n if (parts.length > 2) {\n // Keep first part and first decimal portion only, then process normally\n parts.splice(2); // Remove all parts after the second\n }\n\n // If no decimal point, parse and format to 2 decimal places\n if (parts.length === 1) {\n const numValue = parseFloat(cleanedValue);\n return isNaN(numValue) ? '' : numValue.toFixed(2);\n }\n\n // If has decimal point, limit decimal portion to 2 digits\n parts[1] = parts[1].substring(0, 2);\n // Pad with zero if only one decimal place\n if (parts[1].length === 1) {\n parts[1] = parts[1] + '0';\n }\n return parts.join('.');\n }\n\n autosave(event) {\n event.preventDefault();\n\n // Validate the value before saving\n const value = this.element.value;\n if (!this.isValidNumericValue(value)) {\n this.element.classList.add('error');\n this.element.setAttribute('aria-invalid', 'true');\n return;\n }\n\n this.element.classList.remove('error');\n this.element.removeAttribute('aria-invalid');\n\n // Check if value has actually changed\n if (value === this.lastSavedValue) {\n return; // No need to save if value hasn't changed\n }\n\n // Find the parent row element\n const row = this.element.closest('[data-controller*=\"size-variant-row\"]');\n if (!row) {\n this.logDebug('Could not find parent row for size variant', this.sizeVariantId);\n return;\n }\n\n // Clear any existing save timeout\n if (this.saveTimeout) {\n clearTimeout(this.saveTimeout);\n }\n\n // Add a small debounce here too to handle rapid tabbing\n this.saveTimeout = setTimeout(() => {\n // Dispatch a custom event to trigger the update\n const updateEvent = new CustomEvent('size-variant-update-requested', {\n bubbles: true,\n detail: {\n sizeVariantId: this.sizeVariantId,\n field: this.element.name,\n value: value,\n },\n });\n\n this.element.dispatchEvent(updateEvent);\n\n // Update last saved value\n this.lastSavedValue = value;\n }, 100); // Small debounce of 100ms for rapid tabbing\n }\n\n isValidNumericValue(value) {\n // Empty values are valid (will be treated as null/zero server-side)\n if (value === '') {\n return true;\n }\n\n // Check if it's a valid number\n const numValue = parseFloat(value);\n return !isNaN(numValue) && numValue >= 0;\n }\n\n /**\n * Logs debug messages when debug mode is enabled\n */\n logDebug(_message, ..._args) {\n if (this.debug) {\n // Debug logging removed for production compliance\n }\n }\n}\n", "import { Controller } from '../stimulus';\nimport CSRFTokenMixin from '../mixins/csrf_token_mixin';\n\nexport default class extends Controller {\n static targets = ['select'];\n\n initialize() {\n this.saleId = this.element.dataset.saleId;\n this.productId = this.element.dataset.productId;\n this.colorVariantId = this.element.dataset.colorVariantId;\n\n // Store bound event handler to properly remove it later\n this.boundHandleChange = this.handleChange.bind(this);\n }\n\n connect() {\n // Apply CSRF token mixin\n Object.assign(this, CSRFTokenMixin);\n\n // Add change event listener to the select element\n this.element.addEventListener('change', this.boundHandleChange);\n }\n\n disconnect() {\n this.element.removeEventListener('change', this.boundHandleChange);\n }\n\n handleChange(event) {\n const sizingOptionId = event.target.value;\n\n if (!sizingOptionId) {\n return;\n }\n\n // Update the color variant's sizing option\n const url = `/teamsites/${this.saleId}/colors/${this.colorVariantId}/update_sizing_option`;\n\n // Check CSRF token availability - error will be shown automatically if missing\n if (!this.hasValidCSRFToken()) {\n return;\n }\n\n const csrfToken = this.getCSRFTokenSilently();\n\n fetch(url, {\n method: 'PATCH',\n headers: {\n 'Accept': 'application/json',\n 'Content-Type': 'application/json',\n 'X-CSRF-Token': csrfToken,\n 'X-Requested-With': 'XMLHttpRequest',\n },\n body: JSON.stringify({ sizing_option_id: sizingOptionId }),\n })\n .then(response => {\n if (!response.ok) {\n throw new Error('Failed to update sizing option');\n }\n return response.json();\n })\n .then(_data => {\n // Dispatch event to notify parent controller\n const event = new CustomEvent('sizing-option-changed', {\n detail: { sizingOptionId },\n bubbles: true,\n });\n this.element.dispatchEvent(event);\n\n if (window.App && window.App.showSuccessFlash) {\n window.App.showSuccessFlash('Sizing option updated');\n }\n })\n .catch(_error => {\n // Error updating sizing option\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash('Failed to update sizing option');\n }\n });\n }\n}\n", "import CSRFTokenMixin from '../mixins/csrf_token_mixin';\nimport StimulusReinitMixin from '../mixins/stimulus_reinit_mixin';\n\nimport Select2BaseController from './select2_base_controller';\n\nexport default class extends Select2BaseController {\n // Constants\n static SELECT2_INIT_DELAY_MS = 100; // Time to wait for Select2 initialization\n static LOADING_TIMEOUT_MS = 5000; // Maximum time to wait for sizing option loading\n static MAX_RETRY_ATTEMPTS = 2; // Maximum retry attempts for failed requests\n\n initialize() {\n super.initialize();\n\n // Apply the Stimulus reinit mixin for dynamic content\n Object.assign(this, StimulusReinitMixin);\n\n this.loadingTimeout = null;\n this.scheduleTimeout = null;\n this.loadSizingOptionAbortController = null;\n this.retryCount = 0;\n this.debug = true; // Enable debug logging for troubleshooting\n\n // Atomic state management\n this._state = {\n isConnected: false,\n isDestroyed: false,\n operationInProgress: false,\n errorStateActive: false,\n retryInProgress: false,\n };\n\n // Track all abort controllers for proper cleanup\n this._abortControllers = new Set();\n\n // Track all timeouts for comprehensive cleanup\n this._activeTimeouts = new Set();\n\n // Instance ID for debugging race conditions\n this._instanceId = Math.random().toString(36).substr(2, 9);\n }\n\n // Override selectElement getter for Select2BaseController\n get selectElement() {\n // If we've already set it explicitly, return that\n if (this._selectElement) {\n return this._selectElement;\n }\n // Controller is now directly on the select element, so return this.element\n return this.element;\n }\n\n // Setter to store the element\n set selectElement(element) {\n this._selectElement = element;\n }\n\n // Atomic state management methods\n _updateState(updates) {\n Object.assign(this._state, updates);\n this.logDebug(`State updated [${this._instanceId}]:`, this._state);\n }\n\n _isValidState() {\n return this._state.isConnected &&\n !this._state.isDestroyed &&\n this.element &&\n this.element.isConnected;\n }\n\n _canStartOperation() {\n return this._isValidState() &&\n !this._state.operationInProgress &&\n !this._state.errorStateActive;\n }\n\n // Safe timeout management\n _createTimeout(callback, delay, description = 'timeout') {\n const timeoutId = setTimeout(() => {\n this._activeTimeouts.delete(timeoutId);\n if (this._isValidState()) {\n callback();\n } else {\n this.logDebug(`Skipping ${description} - invalid state`);\n }\n }, delay);\n\n this._activeTimeouts.add(timeoutId);\n this.logDebug(`Created ${description} with ID ${timeoutId}`);\n return timeoutId;\n }\n\n _clearTimeout(timeoutId, description = 'timeout') {\n if (timeoutId) {\n clearTimeout(timeoutId);\n this._activeTimeouts.delete(timeoutId);\n this.logDebug(`Cleared ${description} with ID ${timeoutId}`);\n return null;\n }\n return timeoutId;\n }\n\n _clearAllTimeouts() {\n const timeoutCount = this._activeTimeouts.size;\n this.logDebug(`Clearing ${timeoutCount} active timeouts`);\n\n for (const timeoutId of this._activeTimeouts) {\n clearTimeout(timeoutId);\n }\n this._activeTimeouts.clear();\n\n this.logDebug('All timeouts cleared');\n }\n\n connect() {\n try {\n this.logDebug(`Connect starting [${this._instanceId}]`);\n\n // Atomic state update\n this._updateState({\n isConnected: true,\n isDestroyed: false,\n operationInProgress: false,\n errorStateActive: false,\n retryInProgress: false,\n });\n\n // Cancel any pending operations from previous instances\n this.cancelPendingOperations();\n\n // Check if element exists and is attached to DOM\n if (!this.element || !this.element.isConnected) {\n return;\n }\n\n // Set data attributes before calling parent connect\n this.saleId = this.element.dataset.saleId || '';\n this.productId = this.element.dataset.productId || '';\n this.colorVariantId = this.element.dataset.colorVariantId || '';\n this.isAjaxRequest = this.element.dataset.ajaxRequest === 'true';\n\n // Apply the CSRF token mixin\n Object.assign(this, CSRFTokenMixin);\n\n this.logDebug(`Connected: saleId=${this.saleId}, productId=${this.productId}, colorVariantId=${this.colorVariantId}, isAjax=${this.isAjaxRequest}`);\n\n // Call parent connect to initialize Select2\n // Parent expects this.element to be the select element (which it now is)\n super.connect();\n this.logDebug('Parent connect successful');\n\n // Verify Select2 actually initialized\n if (!this.isEnhanced()) {\n console.error('[SizingOptionsDropdown] Select2 failed to initialize - dropdown may not be interactive');\n } else {\n this.logDebug('Select2 initialization verified');\n }\n\n // Ensure main modal buttons are visible when controller connects\n this.showMainModalButtons();\n\n // For AJAX requests, load current sizing option value after initialization\n if (this.isAjaxRequest && this._isValidState()) {\n this.logDebug('Scheduling loadCurrentSizingOption for AJAX request');\n this.scheduleLoadCurrentSizingOption();\n }\n } catch (error) {\n // Log error but don't disable dropdown - let it work in degraded mode\n console.error('[SizingOptionsDropdown] Controller connection error:', error);\n // Don't call showErrorState() - it permanently disables the dropdown\n }\n }\n\n disconnect() {\n this.logDebug(`Disconnect starting [${this._instanceId}]`);\n\n // Atomic state update\n this._updateState({\n isConnected: false,\n isDestroyed: true,\n operationInProgress: false,\n errorStateActive: false,\n retryInProgress: false,\n });\n\n // Cancel all pending operations when disconnecting\n this.cancelPendingOperations();\n\n try {\n super.disconnect();\n } catch {\n // Ignore errors during parent disconnect - may already be destroyed\n }\n\n this.logDebug(`Disconnect completed [${this._instanceId}]`);\n }\n\n cancelPendingOperations() {\n this.logDebug(`Cancelling all pending operations [${this._instanceId}]`);\n\n // Clear all tracked timeouts\n this._clearAllTimeouts();\n\n // Clear individual timeout references\n this.loadingTimeout = this._clearTimeout(this.loadingTimeout, 'loadingTimeout');\n this.scheduleTimeout = this._clearTimeout(this.scheduleTimeout, 'scheduleTimeout');\n\n // Comprehensive abort controller cleanup\n const controllerCount = this._abortControllers.size;\n this.logDebug(`Aborting ${controllerCount} tracked abort controllers`);\n\n // Create array to avoid modification during iteration\n const controllersToAbort = Array.from(this._abortControllers);\n\n for (const controller of controllersToAbort) {\n try {\n if (controller && !controller.signal.aborted) {\n controller.abort();\n this.logDebug('Aborted abort controller');\n }\n } catch {\n // Ignore errors during abort controller cleanup - controller may be invalid\n }\n }\n\n // Clear the set completely\n this._abortControllers.clear();\n\n // Force clear current abort controller reference\n if (this.loadSizingOptionAbortController) {\n this.logDebug('Force clearing current abort controller reference');\n this.loadSizingOptionAbortController = null;\n }\n\n this.logDebug(`Pending operations cleanup completed [${this._instanceId}]`);\n }\n\n scheduleLoadCurrentSizingOption() {\n // Check if we can start the operation\n if (!this._canStartOperation()) {\n this.logDebug('Skipping schedule - invalid state or operation in progress');\n return;\n }\n\n // Clear any existing timeouts using safe method\n this.loadingTimeout = this._clearTimeout(this.loadingTimeout, 'loadingTimeout');\n this.scheduleTimeout = this._clearTimeout(this.scheduleTimeout, 'scheduleTimeout');\n\n // Set a timeout to handle cases where loading gets stuck\n this.loadingTimeout = this._createTimeout(() => {\n this.handleLoadingTimeout();\n }, this.constructor.LOADING_TIMEOUT_MS, 'loadingTimeout');\n\n // Schedule the actual loading with safe timeout management\n this.scheduleTimeout = this._createTimeout(() => {\n this.loadCurrentSizingOption();\n }, this.constructor.SELECT2_INIT_DELAY_MS, 'scheduleTimeout');\n }\n\n handleLoadingTimeout() {\n // Check if controller is still valid\n if (!this._isValidState()) {\n return;\n }\n\n this.logDebug(`Handling loading timeout [${this._instanceId}]`);\n\n try {\n // Use robust DOM manipulation\n this._safelyManipulateDOM(() => {\n const firstOption = this.element.querySelector('option');\n if (firstOption && firstOption.textContent === 'Loading...') {\n // Remove all options safely\n this._clearAllOptions();\n\n // Add empty option for placeholder\n const emptyOption = new Option('', '', true, true);\n this.selectElement.appendChild(emptyOption);\n\n // Update placeholder\n this.updatePlaceholder('Select Sizing Option');\n }\n });\n } catch {\n // Ignore errors during timeout handling - element may be disconnected\n }\n\n // Clear the timeout reference\n this.loadingTimeout = null;\n }\n\n // Safe DOM manipulation helper\n _safelyManipulateDOM(callback) {\n if (!this._isValidState()) {\n throw new Error('Invalid state for DOM manipulation');\n }\n\n // Check element is still attached before and after operation\n if (!this.element.isConnected) {\n throw new Error('Element not connected to DOM');\n }\n\n callback();\n\n // Verify element is still connected after operation\n if (!this.element.isConnected) {\n throw new Error('Element became disconnected during operation');\n }\n }\n\n // Safe option clearing helper with comprehensive validation\n _clearAllOptions() {\n let iterationCount = 0;\n const maxIterations = 1000; // Safety limit to prevent infinite loops\n\n while (this.selectElement && this.selectElement.firstChild && this.selectElement.isConnected && iterationCount < maxIterations) {\n // Validate state before each removal\n if (!this._isValidState()) {\n this.logDebug('State became invalid during option clearing, stopping');\n break;\n }\n\n // Store reference to child before removal\n const childToRemove = this.selectElement.firstChild;\n\n // Verify child still exists and is attached\n if (!childToRemove || !childToRemove.parentNode) {\n this.logDebug('Child became detached during clearing, stopping');\n break;\n }\n\n try {\n this.selectElement.removeChild(childToRemove);\n iterationCount++;\n } catch (removeError) {\n this.logDebug('Error removing child during clearing:', removeError);\n break;\n }\n\n // Check if element is still connected after removal\n if (!this.selectElement.isConnected) {\n this.logDebug('Element became disconnected during option clearing');\n break;\n }\n }\n\n if (iterationCount >= maxIterations) {\n this.logDebug('Maximum iterations reached during option clearing - potential infinite loop prevented');\n }\n\n this.logDebug(`Cleared ${iterationCount} options safely`);\n }\n\n // Safe Select2 manipulation helper\n _safelyManipulateSelect2(callback) {\n if (!this._isValidState()) {\n throw new Error('Invalid state for Select2 manipulation');\n }\n\n if (!this.isEnhanced()) {\n throw new Error('Select2 not enhanced');\n }\n\n callback();\n }\n\n\n getEnhancementOptions() {\n // Ensure we have values when this is called\n const saleId = this.saleId || this.element.dataset.saleId || '';\n\n return {\n ajax: {\n url: `/sizing_options?sale_id=${saleId}`,\n dataType: 'json',\n delay: 250, // Debounce delay for search input\n cache: true, // Enable caching for performance\n processResults: (data) => {\n return {\n results: data.sizing_options.map((option) => ({\n id: option.id,\n text: option.name,\n })),\n };\n },\n },\n width: '100%',\n minimumInputLength: 0,\n placeholder: 'Select Sizing Option',\n disabled: false, // Force enable dropdown regardless of HTML attribute\n dropdownParent: this.element.closest('.ae-popup-content'), // Render dropdown inside modal\n };\n }\n\n /**\n * Load current sizing option for AJAX requests\n * This fetches the current color variant's sizing option and sets it as selected\n */\n async loadCurrentSizingOption() {\n // Check if operation can proceed\n if (!this._canStartOperation()) {\n this.logDebug('Skipping loadCurrentSizingOption - invalid state');\n return;\n }\n\n // Atomic state update - set operation in progress\n this._updateState({ operationInProgress: true });\n\n try {\n this.logDebug(`Loading current sizing option for color variant ${this.colorVariantId}`);\n\n // Cancel any existing request\n if (this.loadSizingOptionAbortController) {\n this._abortControllers.delete(this.loadSizingOptionAbortController);\n this.loadSizingOptionAbortController.abort();\n this.loadSizingOptionAbortController = null;\n }\n\n // Create new abort controller for this request\n this.loadSizingOptionAbortController = new AbortController();\n this._abortControllers.add(this.loadSizingOptionAbortController);\n\n const url = `/sizing_options/current?color_variant_id=${this.colorVariantId}`;\n this.logDebug(`Fetching from: ${url}`);\n\n const response = await fetch(url, {\n headers: {\n 'Accept': 'application/json',\n 'X-Requested-With': 'XMLHttpRequest',\n },\n signal: this.loadSizingOptionAbortController.signal,\n });\n\n // Clear the loading timeout since we got a response\n if (this.loadingTimeout) {\n clearTimeout(this.loadingTimeout);\n this.loadingTimeout = null;\n }\n\n if (response.ok) {\n const data = await response.json();\n this.logDebug('Response data:', data);\n\n // Check if still valid before manipulating DOM\n if (!this._isValidState()) {\n this.logDebug('Controller invalid during request, skipping DOM updates');\n return;\n }\n\n if (data.sizing_option) {\n // Add the current sizing option as an option and select it using safe DOM manipulation\n this._safelyManipulateDOM(() => {\n const option = new Option(data.sizing_option.name, data.sizing_option.id, true, true);\n this.selectElement.appendChild(option);\n\n // Trigger change event for Select2 with validation\n if (this.isEnhanced() && this._isValidState()) {\n this._safelyManipulateSelect2(() => {\n this.getEnhancer()(this.element).trigger('change');\n });\n }\n this.logDebug(`Set sizing option: ${data.sizing_option.name}`);\n });\n } else {\n // No sizing option assigned, clear the dropdown and show placeholder\n this._safelyManipulateDOM(() => {\n // Remove the \"Loading...\" option safely\n this._clearAllOptions();\n\n // Add an empty option for the placeholder\n const emptyOption = new Option('', '', true, true);\n this.selectElement.appendChild(emptyOption);\n\n // Update the placeholder\n this.updatePlaceholder('Select Sizing Option');\n this.logDebug('No sizing option assigned');\n });\n }\n\n // Reset retry count on success\n this.retryCount = 0;\n } else {\n await this.handleLoadError(new Error(`HTTP ${response.status}`));\n }\n } catch (error) {\n // Don't handle abort errors\n if (error.name === 'AbortError') {\n this.logDebug('Request was aborted');\n return;\n }\n\n // Only handle error if still valid\n if (this._isValidState()) {\n await this.handleLoadError(error);\n }\n } finally {\n // Clean up abort controller\n if (this.loadSizingOptionAbortController) {\n this._abortControllers.delete(this.loadSizingOptionAbortController);\n this.loadSizingOptionAbortController = null;\n }\n\n // Atomic state update - clear operation in progress flag\n this._updateState({ operationInProgress: false });\n }\n }\n\n\n async handleLoadError(_error) {\n // Don't handle errors if not valid or already retrying\n if (!this._isValidState() || this._state.retryInProgress) {\n return;\n }\n\n // Check if we should retry\n if (this.retryCount < this.constructor.MAX_RETRY_ATTEMPTS) {\n this.retryCount++;\n this.logDebug(`Retrying load (attempt ${this.retryCount}/${this.constructor.MAX_RETRY_ATTEMPTS})`);\n\n // Atomic state update - mark retry in progress\n this._updateState({ retryInProgress: true, operationInProgress: false });\n\n // Circuit breaker pattern - wait with exponential backoff\n try {\n await this._delayWithValidation(500 * this.retryCount);\n\n // Check once more before retrying\n if (this._canStartOperation()) {\n this._updateState({ retryInProgress: false });\n return this.loadCurrentSizingOption();\n } else {\n this.logDebug('Cannot retry - invalid state');\n }\n } catch {\n // Retry cancelled - controller disconnected during delay\n this.logDebug('Retry cancelled - controller disconnected');\n } finally {\n this._updateState({ retryInProgress: false });\n }\n return;\n }\n\n // Max retries reached\n this.logDebug('Max retries reached, giving up');\n\n // Clear the loading timeout\n if (this.loadingTimeout) {\n clearTimeout(this.loadingTimeout);\n this.loadingTimeout = null;\n }\n\n // For all error types, show \"Select Sizing Option\" as the dropdown remains functional\n this.updatePlaceholder('Select Sizing Option');\n }\n\n // Delay helper that validates state during wait with comprehensive tracking\n async _delayWithValidation(ms) {\n return new Promise((resolve, reject) => {\n let resolved = false;\n\n const timeoutId = this._createTimeout(() => {\n if (!resolved) {\n resolved = true;\n if (this._isValidState()) {\n resolve();\n } else {\n reject(new Error('Controller became invalid during delay'));\n }\n }\n }, ms, `delayValidation-${ms}ms`);\n\n // Create tracked interval for validation checking\n const checkInterval = setInterval(() => {\n if (!this._isValidState() && !resolved) {\n resolved = true;\n clearInterval(checkInterval);\n this._clearTimeout(timeoutId, `delayValidation-${ms}ms`);\n reject(new Error('Controller became invalid during delay'));\n }\n }, 100);\n\n // Auto-cleanup interval when delay completes (backup safety)\n const _cleanupTimeoutId = this._createTimeout(() => {\n clearInterval(checkInterval);\n }, ms + 100, `delayCleanup-${ms}ms`);\n });\n }\n\n updatePlaceholder(text) {\n // Check if controller and element are still valid\n if (!this._isValidState()) {\n return;\n }\n\n try {\n this._safelyManipulateDOM(() => {\n const firstOption = this.element.querySelector('option');\n if (firstOption) {\n firstOption.textContent = text;\n firstOption.value = '';\n\n // If Select2 is enhanced, update it safely\n if (this.isEnhanced() && this._isValidState()) {\n this._safelyManipulateSelect2(() => {\n // Set the value to empty string to show placeholder\n this.getEnhancer()(this.element).val('').trigger('change');\n\n // Also update Select2's placeholder text if needed\n const select2Container = this.element.nextElementSibling;\n if (select2Container && select2Container.classList.contains('select2-container')) {\n const placeholderElement = select2Container.querySelector('.select2-selection__placeholder');\n if (placeholderElement) {\n placeholderElement.textContent = text;\n }\n }\n });\n }\n }\n });\n } catch {\n // Ignore errors during placeholder update - element may be disconnected\n }\n }\n\n showErrorState(errorMessage) {\n // Check if controller and element are still valid\n if (!this._isValidState()) {\n return;\n }\n\n // True atomic operation: check-and-set in one operation\n if (this._state.errorStateActive) {\n this.logDebug('Error state already active, skipping concurrent operation');\n return;\n }\n\n // Set flag immediately and track success\n this._updateState({ errorStateActive: true });\n let operationSuccessful = false;\n\n try {\n this._safelyManipulateDOM(() => {\n // Disable the select element first to ensure consistent state\n // COMMENTED OUT: This conflicts with disabled: false in getEnhancementOptions()\n // if (this.selectElement) {\n // this.selectElement.disabled = true;\n // }\n\n // Try to destroy Select2 if it exists - fail gracefully\n if (this.isEnhanced()) {\n try {\n this._safelyManipulateSelect2(() => {\n this.getEnhancer()(this.element).select2('destroy');\n });\n } catch (destroyError) {\n this.logDebug('Select2 destroy failed during error state:', destroyError);\n // Continue anyway - destruction failure shouldn't block error display\n }\n }\n\n // Update the HTML with error message\n // Clear existing options\n this._clearAllOptions();\n // Add error option using safe DOM manipulation\n const errorOption = new Option(errorMessage, '', true, true);\n this.selectElement.appendChild(errorOption);\n\n // Try to reinitialize Select2 - fail gracefully\n if (this.isSelect2Available() && this._isValidState()) {\n try {\n this._safelyManipulateSelect2(() => {\n this.getEnhancer()(this.element).select2({\n width: '100%',\n // COMMENTED OUT: This conflicts with disabled: false in getEnhancementOptions()\n // disabled: true,\n placeholder: errorMessage,\n });\n });\n } catch (initError) {\n this.logDebug('Select2 init failed during error state:', initError);\n // That's okay - the dropdown is still functional as a native select\n }\n }\n });\n\n operationSuccessful = true;\n } catch {\n // Error during error state operation - should not happen but handle gracefully\n operationSuccessful = false;\n } finally {\n // Only reset flag if operation was successful or if we're disconnected\n if (operationSuccessful || !this._isValidState()) {\n this._updateState({ errorStateActive: false });\n } else {\n // Keep flag set if operation failed and we're still connected\n this.logDebug('Error state operation failed, keeping flag active for retry');\n }\n }\n }\n\n async onSelectChange(event) {\n this.logDebug('========== onSelectChange START ==========');\n this.logDebug('Event:', event);\n this.logDebug('Event type:', event.type);\n this.logDebug('Event target:', event.target);\n this.logDebug('Event target tagName:', event.target?.tagName);\n this.logDebug('Event target id:', event.target?.id);\n this.logDebug('Event target value:', event.target?.value);\n this.logDebug('Event detail:', event.detail);\n\n // Try to get value from different sources\n let sizingOptionId = event.target?.value;\n if (!sizingOptionId && event.detail?.value) {\n sizingOptionId = event.detail.value;\n this.logDebug('Got value from event.detail:', sizingOptionId);\n }\n\n this.logDebug('Final sizing option ID:', sizingOptionId);\n\n if (!sizingOptionId) {\n this.logDebug('No sizing option ID, hiding confirmation');\n this.hideConfirmation();\n return;\n }\n\n // Store the pending sizing option ID and previous value\n this.pendingSizingOptionId = sizingOptionId;\n this.previousSizingOptionId = this.element.dataset.currentSizingOptionId || '';\n this.logDebug('Previous sizing option ID:', this.previousSizingOptionId);\n this.logDebug('Pending sizing option ID:', this.pendingSizingOptionId);\n\n // Show confirmation with the selected sizing option\n this.logDebug('Calling showConfirmation...');\n this.showConfirmation(sizingOptionId);\n this.logDebug('========== onSelectChange END ==========');\n }\n\n async showConfirmation(sizingOptionId) {\n this.logDebug('showConfirmation called with sizing option ID:', sizingOptionId);\n this.logDebug('Sale ID:', this.saleId);\n this.logDebug('Color Variant ID:', this.colorVariantId);\n\n try {\n // Load the confirmation dialog HTML via AJAX\n const url = `/teamsites/${this.saleId}/colors/${this.colorVariantId}/sizing_options/${sizingOptionId}`;\n this.logDebug('Fetching confirmation HTML from URL:', url);\n\n const response = await fetch(url, {\n headers: {\n 'Accept': 'text/html',\n 'X-Requested-With': 'XMLHttpRequest',\n },\n });\n\n if (response.ok) {\n const confirmationHtml = await response.text();\n this.logDebug('Received confirmation HTML, length:', confirmationHtml.length);\n\n // Find the sizes table container to insert the confirmation above it\n const modalContent = this.element.closest('.ae-popup-content');\n const sizesTableContainer = modalContent ? modalContent.querySelector('#sizes-table-container') : null;\n\n this.logDebug('Modal content container:', modalContent);\n this.logDebug('Sizes table container:', sizesTableContainer);\n\n if (modalContent && sizesTableContainer) {\n // Remove any existing confirmation dialog\n this.hideConfirmation();\n\n // Create container for the confirmation dialog\n const confirmationContainer = document.createElement('div');\n confirmationContainer.className = 'sizing-option-confirmation-container';\n // Use a temporary element to parse HTML safely\n const tempDiv = document.createElement('div');\n tempDiv.innerHTML = confirmationHtml;\n // Move all child nodes from temp div to confirmation container\n while (tempDiv.firstChild) {\n confirmationContainer.appendChild(tempDiv.firstChild);\n }\n\n // Insert the confirmation dialog ABOVE the sizes table\n sizesTableContainer.parentNode.insertBefore(confirmationContainer, sizesTableContainer);\n\n // Hide the main modal's Save/Cancel buttons\n this.hideMainModalButtons();\n\n // Set up event handlers for the new buttons\n this.setupConfirmationEventHandlers(confirmationContainer);\n\n // Reinitialize Stimulus controllers (for button-debounce on dynamic content)\n this.reinitializeStimulusControllers(confirmationContainer);\n\n // Scroll to confirmation\n confirmationContainer.scrollIntoView({ behavior: 'smooth', block: 'nearest' });\n\n this.logDebug('Confirmation dialog embedded successfully');\n } else {\n this.logDebug('ERROR: Could not find modal content container or sizes table container');\n this.resetDropdownToPrevious();\n }\n } else {\n this.logDebug('Failed to fetch confirmation HTML, status:', response.status);\n this.logDebug('Response status text:', response.statusText);\n\n // Try to get error details from response\n try {\n const errorText = await response.text();\n this.logDebug('Error response body:', errorText);\n } catch (textError) {\n this.logDebug('Could not read error response body:', textError);\n }\n\n this.resetDropdownToPrevious();\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash(`Failed to load confirmation dialog (${response.status})`);\n }\n }\n } catch (error) {\n this.logDebug('Error loading confirmation dialog:', error);\n this.logDebug('Error name:', error.name);\n this.logDebug('Error message:', error.message);\n this.resetDropdownToPrevious();\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash('Network error occurred');\n }\n }\n }\n\n setupConfirmationEventHandlers(confirmationContainer) {\n this.logDebug('Setting up confirmation event handlers');\n\n // Find Confirm button\n const confirmButton = confirmationContainer.querySelector('[data-action*=\"confirm\"]');\n if (confirmButton) {\n confirmButton.addEventListener('click', (event) => {\n event.preventDefault();\n this.confirmSizingOption();\n });\n this.logDebug('Confirm button event handler set up');\n }\n\n // Find Cancel buttons (both close button and cancel button)\n const cancelButtons = confirmationContainer.querySelectorAll('[data-action*=\"cancel\"], .mfp-close');\n cancelButtons.forEach(button => {\n button.addEventListener('click', (event) => {\n event.preventDefault();\n this.cancelSizingOption();\n });\n });\n this.logDebug('Cancel button event handlers set up, count:', cancelButtons.length);\n }\n\n hideMainModalButtons() {\n const modal = this.element.closest('.ae-popup');\n if (modal) {\n const footer = modal.querySelector('.ae-popup-footer');\n if (footer) {\n footer.style.display = 'none';\n }\n }\n }\n\n showMainModalButtons() {\n const modal = this.element.closest('.ae-popup');\n if (modal) {\n const footer = modal.querySelector('.ae-popup-footer');\n if (footer) {\n footer.style.display = '';\n }\n }\n }\n\n hideConfirmation() {\n // Remove any existing confirmation dialog container\n const modalContent = this.element.closest('.ae-popup-content');\n if (modalContent) {\n const confirmationContainer = modalContent.querySelector('.sizing-option-confirmation-container');\n if (confirmationContainer) {\n confirmationContainer.remove();\n }\n }\n\n // Show the main modal buttons again\n this.showMainModalButtons();\n }\n\n resetDropdownToPrevious() {\n if (this.previousSizingOptionId !== undefined && this.selectElement) {\n // Reset to previous value\n this.selectElement.value = this.previousSizingOptionId;\n\n // Trigger change event for Select2\n if (this.isEnhanced() && this._isValidState()) {\n this._safelyManipulateSelect2(() => {\n this.getEnhancer()(this.selectElement).val(this.previousSizingOptionId).trigger('change');\n });\n }\n }\n }\n\n // Stimulus action method for confirm button (called via data-action)\n confirm(event) {\n if (event) event.preventDefault();\n this.confirmSizingOption();\n }\n\n // Stimulus action method for cancel button (called via data-action)\n cancel(event) {\n if (event) event.preventDefault();\n this.cancelSizingOption();\n }\n\n async confirmSizingOption() {\n if (!this.pendingSizingOptionId) return;\n\n // Get CSRF token and verify it exists\n const csrfToken = this.getCSRFToken();\n if (!csrfToken) {\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash('Security token missing. Please refresh the page.');\n }\n return;\n }\n\n try {\n const url = `/teamsites/${this.saleId}/colors/${this.colorVariantId}/update_sizing_option`;\n\n const response = await fetch(url, {\n method: 'PATCH',\n headers: {\n 'Accept': 'application/json',\n 'Content-Type': 'application/json',\n 'X-CSRF-Token': csrfToken,\n 'X-Requested-With': 'XMLHttpRequest',\n },\n body: JSON.stringify({ sizing_option_id: this.pendingSizingOptionId }),\n });\n\n const data = await response.json();\n\n if (response.ok) {\n this.logDebug('[SizingOptionsDropdown] Sizing option updated successfully');\n\n // Update the current sizing option ID\n this.element.dataset.currentSizingOptionId = this.pendingSizingOptionId;\n\n // Hide confirmation\n this.hideConfirmation();\n\n // Dispatch event to notify parent controller with sizes HTML (eliminates race condition)\n const event = new CustomEvent('sizing-option-changed', {\n detail: {\n sizingOptionId: this.pendingSizingOptionId,\n sizesTableHtml: data.sizes_table_html,\n },\n bubbles: true,\n });\n this.element.dispatchEvent(event);\n\n // Show success message\n if (window.App && typeof window.App.showSuccessFlash === 'function') {\n window.App.showSuccessFlash(data.message || 'Sizing option updated');\n } else {\n this.logDebug('[SizingOptionsDropdown] Sizing option updated successfully');\n }\n } else {\n const errorMessage = data.error || 'Failed to update sizing option';\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash(errorMessage);\n }\n this.cancelSizingOption();\n }\n } catch {\n // Network or other error during sizing option update\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash('Network error occurred');\n }\n this.cancelSizingOption();\n }\n }\n\n cancelSizingOption() {\n // Reset dropdown to previous value\n this.resetDropdownToPrevious();\n\n // Hide confirmation\n this.hideConfirmation();\n\n // Clear pending ID\n this.pendingSizingOptionId = null;\n }\n\n /**\n * Logs debug messages when debug mode is enabled\n * @param {string} message - The message to log\n * @param {...any} args - Additional arguments to log\n */\n logDebug(_message, ..._args) {\n if (this.debug) {\n // Debug logging removed for production compliance\n }\n }\n}\n", "import { Controller } from '../stimulus';\nimport CSRFTokenMixin from '../mixins/csrf_token_mixin';\n\nexport default class extends Controller {\n initialize() {\n this.debug = false; // Set via data attribute if needed\n this.saleId = this.element.dataset.saleId;\n this.productId = this.element.dataset.productId;\n this.colorVariantId = this.element.dataset.colorVariantId;\n }\n\n connect() {\n // Apply CSRF token mixin\n Object.assign(this, CSRFTokenMixin);\n }\n\n // The edit and configure methods are no longer needed since we're using\n // ajax-popup controller directly on the buttons in the view\n\n delete(e) {\n e.preventDefault();\n\n if (!confirm('Are you sure you wish to delete this color?')) return;\n\n const url = `/teamsites/${this.saleId}/colors/${this.colorVariantId}?product_id=${this.productId}`;\n\n // Check CSRF token availability - error will be shown automatically if missing\n if (!this.hasValidCSRFToken()) {\n return;\n }\n\n const csrfToken = this.getCSRFTokenSilently();\n\n const headers = {\n 'Accept': 'application/json',\n 'Content-Type': 'application/json',\n 'X-CSRF-Token': csrfToken,\n };\n\n return fetch(url, {\n method: 'DELETE',\n headers: headers,\n })\n .then(response => {\n if (!response.ok) {\n throw new Error('Network response was not ok');\n }\n return response.json();\n })\n .then(response => {\n const rowElement = document.getElementById(`color-variant-row-${this.colorVariantId}`);\n if (rowElement) {\n rowElement.remove();\n }\n\n if (window.App && window.App.showSuccessFlash) {\n window.App.showSuccessFlash(response.message);\n }\n })\n .catch(error => {\n this.logDebug('Error deleting color:', error);\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash('Something went wrong');\n }\n });\n }\n\n clone(e) {\n e.preventDefault();\n\n if (!confirm('Clone this color variant? A copy will be created with all images and sizes.')) return;\n\n const url = `/teamsites/${this.saleId}/colors/${this.colorVariantId}/clone?product_id=${this.productId}`;\n\n if (!this.hasValidCSRFToken()) {\n return;\n }\n\n const csrfToken = this.getCSRFTokenSilently();\n\n const headers = {\n 'Accept': 'application/json',\n 'Content-Type': 'application/json',\n 'X-CSRF-Token': csrfToken,\n };\n\n return fetch(url, {\n method: 'POST',\n headers: headers,\n })\n .then(response => {\n if (!response.ok) {\n return response.json().then(data => { throw new Error(data.error || 'Something went wrong'); });\n }\n return response.json();\n })\n .then(response => {\n if (window.App && window.App.showSuccessFlash) {\n window.App.showSuccessFlash(response.message);\n }\n window.location.reload();\n })\n .catch(error => {\n this.logDebug('Error cloning color:', error);\n if (window.App && window.App.showErrorFlash) {\n window.App.showErrorFlash(error.message || 'Something went wrong');\n }\n });\n }\n\n /**\n * Logs debug messages when debug mode is enabled\n */\n logDebug(_message, ..._args) {\n if (this.debug) {\n // Debug logging removed for production compliance\n }\n }\n}\n", "import { Controller } from '../../stimulus';\n\n/**\n * Swatch Item Controller\n *\n * This controller manages color swatch selection in product details.\n * It updates the main product image when a color swatch is clicked and\n * handles the visual selection state of swatches.\n *\n * Features:\n * - Updates main product image when a color swatch is clicked\n * - Handles visual selection state of swatches\n * - Works with the main product image to switch displayed color variants\n * - Preserves tooltips and other UI elements\n *\n * Usage:\n *
  • product-details-swatches--swatch-item#changeCurrentColor\"\n * data-product-id=\"123\"\n * data-color-variant-id=\"456\"\n * data-large-url=\"/path/to/large-image.jpg\">\n * \n * \n * \n *
  • \n *\n * Required data attributes:\n * - data-product-id: The ID of the product\n * - data-color-variant-id: The ID of the color variant\n * - data-large-url: The URL of the large image to display when selected\n */\nexport default class extends Controller {\n initialize() {\n this.productId = this.element.dataset.productId;\n this.colorVariantId = this.element.dataset.colorVariantId;\n this.largeUrl = this.element.dataset.largeUrl;\n }\n\n changeCurrentColor(e) {\n e.preventDefault();\n\n // Select the main component\n const mainImage = document.getElementById(`main-image-${this.productId}`);\n\n if (mainImage) {\n mainImage.src = this.largeUrl;\n }\n\n // Update the selected swatch styling\n const swatchItems = this.element.parentElement.querySelectorAll('li');\n swatchItems.forEach((item) => item.classList.remove('ae-selected'));\n this.element.classList.add('ae-selected');\n }\n}\n", "import { Controller } from '../stimulus';\n\nimport AjaxPopupController from './ajax_popup_controller';\n\nexport default class extends Controller {\n click(event) {\n event.preventDefault();\n event.stopPropagation();\n\n const saleId = this.element.dataset.saleId;\n if (!saleId) return;\n\n const url = `/dealers/search/library_products?sale_id=${saleId}`;\n AjaxPopupController.open(url);\n }\n}\n", "import { Controller } from '../stimulus';\n\n/**\n * Product Dropdown Add Product Controller\n *\n * Handles the \"Add Product\" action from the products dropdown menu.\n * Focuses on the dealer search bar to allow searching for products to add.\n *\n * Usage:\n * product-dropdown-add-product#click\">\n * Add Product\n * \n */\nexport default class extends Controller {\n click(event) {\n event.preventDefault();\n\n const searchInput = document.getElementById('search-query');\n if (searchInput) {\n searchInput.focus();\n } else {\n console.error('[ProductDropdownAddProduct] Search input not found');\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n initialize() {\n this.saleId = this.element.dataset.saleId;\n }\n\n click(e) {\n e.preventDefault();\n\n document.body.classList.add('is-loading');\n\n Turbolinks.visit(`/teamsites/${this.saleId}/products/new`);\n }\n}\n", "import { Controller } from '../stimulus';\n// Import StimulusReinitMixin for reinitializing controllers\nimport StimulusReinitMixin from '../mixins/stimulus_reinit_mixin';\n\n// Provide fallback implementation that matches the actual mixin behavior\nconst fallbackReinitMixin = {\n /**\n * Reinitialize Stimulus controllers for dynamically added content\n * This is a fallback that replicates the mixin's behavior\n * @param {Element} container - The container element with new content\n */\n reinitializeStimulusControllers(container) {\n if (!window.Stimulus || !window.Stimulus.application) {\n // Use this.logDebug if available (when bound to controller instance)\n if (this.logDebug) {\n this.logDebug('[ProductEditCoordinator Fallback] Stimulus application not found');\n }\n return;\n }\n\n // Match the actual mixin implementation:\n // For Stimulus 1.1.0, trigger the DOM mutation observer\n // by adding and removing a dummy element with a data-controller attribute\n const dummyDiv = document.createElement('div');\n dummyDiv.style.display = 'none';\n dummyDiv.setAttribute('data-controller', 'dummy-reinit');\n\n try {\n container.appendChild(dummyDiv);\n\n // Remove on next frame to trigger mutation observer\n requestAnimationFrame(() => {\n if (dummyDiv.parentNode) {\n dummyDiv.remove();\n }\n });\n } catch (error) {\n // Use this.logDebug if available (when bound to controller instance)\n if (this.logDebug) {\n this.logDebug('[ProductEditCoordinator Fallback] Failed to reinitialize controllers:', error);\n }\n }\n },\n};\n\n/**\n * Coordinates upload events with page updates on the product edit page\n *\n * This controller listens to dropzone upload events and handles:\n * - Gallery refresh after successful uploads\n * - Sidebar category refresh to maintain product highlighting\n * - Error notifications specific to the product edit page\n *\n * Usage:\n *
    \n * \n *
    \n */\nexport default class extends Controller {\n get saleId() {\n return this.element.dataset.saleId;\n }\n\n get productId() {\n return this.element.dataset.productId;\n }\n\n initialize() {\n // Debug flag - set to true to enable debug logging\n this.debug = false; // Set via data-product-edit-coordinator-debug=\"true\" on the element\n // Mix in the Stimulus reinit functionality with error handling\n try {\n // Check if StimulusReinitMixin is valid before using it\n if (StimulusReinitMixin && typeof StimulusReinitMixin === 'object' &&\n typeof StimulusReinitMixin.reinitializeStimulusControllers === 'function') {\n Object.assign(this, StimulusReinitMixin);\n // Mixin applied successfully\n } else {\n throw new Error('StimulusReinitMixin is not properly loaded or has incorrect format');\n }\n } catch (error) {\n // Use fallback if mixin application fails for any reason\n this.logDebug('[ProductEditCoordinator] Using fallback reinitialize implementation:', error.message);\n this.reinitializeStimulusControllers = fallbackReinitMixin.reinitializeStimulusControllers.bind(this);\n }\n\n // Initialize state\n this._sidebarRefreshTimeout = null;\n this._sidebarRefreshPromise = null;\n this._sidebarRefreshRetryCount = 0;\n this._sidebarAbortController = null;\n this._galleryRefreshPromise = null;\n this._galleryAbortController = null;\n\n // Store bound function references for proper cleanup\n this._boundHandleUploadSuccess = this.handleUploadSuccess.bind(this);\n this._boundHandleModalSidebarRefresh = this.handleModalSidebarRefresh.bind(this);\n this._boundHandleModalGalleryRefresh = this.handleModalGalleryRefresh.bind(this);\n\n // Track created DOM elements for cleanup\n this._createdElements = new Set();\n this._createdTimeouts = new Set();\n }\n\n connect() {\n // Check for debug flag from data attribute\n this.debug = this.element.dataset.productEditCoordinatorDebug === 'true' ||\n this.element.dataset.debug === 'true';\n\n // Listen for dropzone upload success events\n this.element.addEventListener('dropzone:uploadsuccess', this._boundHandleUploadSuccess);\n // Listen for color modal refresh events (Stimulus 1.1.0 compatible)\n this.element.addEventListener('product-colors-modal:refresh-sidebar', this._boundHandleModalSidebarRefresh);\n this.element.addEventListener('product-colors-modal:refresh-gallery', this._boundHandleModalGalleryRefresh);\n\n // Sync product ID when AJAX navigation changes the current product\n // (pushState doesn't trigger page reload, so layout data attributes stay stale)\n this._boundHandleProductNavigated = (event) => {\n let { productId } = event.detail;\n // Fallback: on back/forward navigation, pendingActiveElement is null so productId\n // is undefined. Extract from URL instead (event fires after pushState).\n if (!productId) {\n const match = window.location.pathname.match(/\\/products\\/(\\d+)/);\n if (match) productId = match[1];\n }\n if (productId) {\n this.element.dataset.productId = productId;\n }\n };\n document.addEventListener('product:navigated', this._boundHandleProductNavigated);\n }\n\n logDebug(message, ...args) {\n // Debug logging disabled for production\n }\n\n disconnect() {\n // Clean up event listeners with the same reference used in connect()\n this.element.removeEventListener('dropzone:uploadsuccess', this._boundHandleUploadSuccess);\n this.element.removeEventListener('product-colors-modal:refresh-sidebar', this._boundHandleModalSidebarRefresh);\n this.element.removeEventListener('product-colors-modal:refresh-gallery', this._boundHandleModalGalleryRefresh);\n document.removeEventListener('product:navigated', this._boundHandleProductNavigated);\n\n // Clear any pending timeouts\n if (this._sidebarRefreshTimeout) {\n clearTimeout(this._sidebarRefreshTimeout);\n }\n\n // Clear all tracked timeouts\n this._createdTimeouts.forEach(timeoutId => clearTimeout(timeoutId));\n this._createdTimeouts.clear();\n\n // Abort any in-flight requests\n if (this._sidebarAbortController) {\n this._sidebarAbortController.abort();\n this._sidebarAbortController = null;\n }\n if (this._galleryAbortController) {\n this._galleryAbortController.abort();\n this._galleryAbortController = null;\n }\n\n // Remove all created DOM elements\n this._createdElements.forEach(element => {\n if (element && element.parentNode) {\n element.remove();\n }\n });\n this._createdElements.clear();\n }\n\n handleUploadSuccess(event) {\n this.logDebug('[ProductEditCoordinator] Upload success event received:', event);\n\n // Detect size chart uploads - they don't affect sidebar content\n // (sidebar shows product names, categories, prices, and thumbnails \u2014 not size charts)\n const isSizeChartUpload = event && event.target &&\n event.target.closest('[data-controller=\"product-size-chart\"]');\n\n if (isSizeChartUpload) {\n this.logDebug('[ProductEditCoordinator] Size chart upload detected, refreshing gallery only');\n }\n\n // Try to refresh gallery first\n this.refreshGallery();\n\n // Only refresh sidebar for non-size-chart uploads (e.g., product images)\n // Size chart uploads don't change sidebar content, and the refresh races\n // with product_sidebar_controller's initial categories load\n if (!isSizeChartUpload) {\n this.refreshSidebar();\n }\n }\n\n refreshGallery() {\n this.logDebug('[ProductEditCoordinator] Starting gallery refresh');\n\n // Use CustomEvent for Stimulus 1.1.0 compatibility\n const gallery = this.element.querySelector('[data-controller*=\"product-gallery-refresh\"]');\n\n if (gallery) {\n this.logDebug('[ProductEditCoordinator] Found gallery refresh controller, triggering refresh via CustomEvent');\n // Sync gallery's product ID with coordinator's current value before dispatching.\n // Defense-in-depth: ensures gallery DOM has correct ID at the moment refresh() reads it,\n // even if a previous updateGalleryContent() left a stale value.\n if (this.productId) {\n gallery.dataset.productId = this.productId;\n }\n const event = new CustomEvent('product-gallery-refresh', {\n detail: { productId: this.productId },\n bubbles: true,\n });\n gallery.dispatchEvent(event);\n } else {\n // No gallery controller found, try direct refresh\n this.logDebug('[ProductEditCoordinator] No gallery refresh controller found, using direct refresh');\n this.directGalleryRefresh();\n }\n }\n\n async directGalleryRefresh() {\n if (!this.saleId || !this.productId) {\n this.logDebug('[ProductEditCoordinator] Missing sale ID or product ID for gallery refresh');\n return;\n }\n\n // If there's already a gallery refresh in progress, don't start another\n if (this._galleryRefreshPromise) {\n this.logDebug('[ProductEditCoordinator] Gallery refresh already in progress');\n return this._galleryRefreshPromise;\n }\n\n // Abort any previous request\n if (this._galleryAbortController) {\n this._galleryAbortController.abort();\n }\n\n // Create an AbortController for timeout handling if available\n let controller = null;\n let timeoutId = null;\n const fetchOptions = {\n headers: {\n 'X-Requested-With': 'XMLHttpRequest',\n 'Accept': 'text/html',\n 'X-CSRF-Token': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content'),\n },\n credentials: 'same-origin',\n };\n\n // Use AbortController if available (modern browsers)\n if (typeof window.AbortController !== 'undefined') {\n controller = new window.AbortController();\n this._galleryAbortController = controller; // Store for cleanup\n timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout\n fetchOptions.signal = controller.signal;\n }\n\n this._galleryRefreshPromise = fetch(`/teamsites/${this.saleId}/products/${this.productId}/gallery`, fetchOptions)\n .then(response => {\n if (timeoutId) clearTimeout(timeoutId); // Clear the timeout on successful response\n if (!response.ok) {\n throw new Error(`Gallery refresh failed: ${response.status}`);\n }\n return response.text();\n })\n .then(html => {\n this.logDebug('[ProductEditCoordinator] Gallery HTML received, updating DOM');\n const existingGallery = this.element.querySelector('.product-gallery');\n if (existingGallery) {\n const parser = new DOMParser();\n const doc = parser.parseFromString(html, 'text/html');\n const newGallery = doc.querySelector('.product-gallery');\n\n if (newGallery) {\n // Preserve the data-controller attribute from the existing gallery\n const existingControllers = existingGallery.getAttribute('data-controller');\n existingGallery.innerHTML = newGallery.innerHTML;\n\n // Copy over all data attributes from the new gallery\n Array.from(newGallery.attributes).forEach(attr => {\n if (attr.name.startsWith('data-')) {\n existingGallery.setAttribute(attr.name, attr.value);\n }\n });\n\n // Ensure the gallery refresh controller is included\n if (existingControllers && !existingGallery.getAttribute('data-controller')?.includes('product-gallery-refresh')) {\n existingGallery.setAttribute('data-controller',\n (existingGallery.getAttribute('data-controller') || '') + ' product-gallery-refresh');\n }\n\n // Reinitialize Stimulus controllers in the updated gallery\n this.reinitializeStimulusControllers(existingGallery);\n this.logDebug('[ProductEditCoordinator] Gallery DOM updated and controllers reinitialized');\n } else {\n this.logDebug('[ProductEditCoordinator] No gallery found in response HTML');\n }\n } else {\n this.logDebug('[ProductEditCoordinator] No existing gallery found in page');\n }\n })\n .catch(error => {\n if (timeoutId) clearTimeout(timeoutId); // Clear the timeout on error\n // Gallery refresh failed, but we'll continue with sidebar refresh\n // Show a non-intrusive error message if gallery refresh fails\n\n // Handle abort errors specifically\n if (error.name === 'AbortError') {\n // Don't show error if it was intentionally aborted (not a timeout)\n if (error.message && error.message.includes('timed out')) {\n this._showGalleryRefreshError(new Error('Gallery refresh timed out'));\n }\n // Otherwise it was aborted due to a new request, which is fine\n } else {\n this._showGalleryRefreshError(error);\n }\n })\n .finally(() => {\n // Clear the promise and controller references\n this._galleryRefreshPromise = null;\n this._galleryAbortController = null;\n });\n\n return this._galleryRefreshPromise;\n }\n\n refreshSidebar() {\n if (!this.saleId || !this.productId) {\n return;\n }\n\n // If there's already a sidebar refresh in progress, return that promise\n if (this._sidebarRefreshPromise) {\n return this._sidebarRefreshPromise;\n }\n\n // Debounce multiple rapid calls\n if (this._sidebarRefreshTimeout) {\n clearTimeout(this._sidebarRefreshTimeout);\n }\n\n // Debounce the refresh to avoid multiple concurrent requests\n this._sidebarRefreshTimeout = setTimeout(() => {\n this._performSidebarRefresh();\n }, 100);\n }\n\n _performSidebarRefresh() {\n // Don't start a new refresh if one is already in progress\n if (this._sidebarRefreshPromise) {\n return this._sidebarRefreshPromise;\n }\n\n // Abort any previous request\n if (this._sidebarAbortController) {\n this._sidebarAbortController.abort();\n }\n\n // Check if any accordion items are expanded before refresh\n const expandedAccordions = document.querySelectorAll('#products-sidebar .accordion-collapse.show');\n const wasExpanded = expandedAccordions.length > 0;\n\n // Add loading indicator to sidebar\n const existingContainer = document.querySelector('#products-sidebar-categories-container');\n if (existingContainer) {\n existingContainer.style.opacity = '0.6';\n existingContainer.style.transition = 'opacity 0.2s ease-in-out';\n }\n\n // Create an AbortController for timeout handling if available\n let controller = null;\n let timeoutId = null;\n const fetchOptions = {\n headers: {\n 'X-Requested-With': 'XMLHttpRequest',\n 'Accept': 'text/html',\n 'X-CSRF-Token': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content'),\n },\n credentials: 'same-origin',\n };\n\n // Use AbortController if available (modern browsers)\n if (typeof window.AbortController !== 'undefined') {\n controller = new window.AbortController();\n this._sidebarAbortController = controller; // Store for cleanup\n timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout\n fetchOptions.signal = controller.signal;\n }\n\n // Fetch sidebar content to refresh highlighting\n this._sidebarRefreshPromise = fetch(`/teamsites/${this.saleId}/products/${this.productId}/sidebar`, fetchOptions)\n .then(response => {\n if (timeoutId) clearTimeout(timeoutId); // Clear the timeout on successful response\n if (!response.ok) {\n throw new Error(`Failed to fetch sidebar: ${response.status}`);\n }\n return response.text();\n })\n .then(html => {\n const parser = new DOMParser();\n const doc = parser.parseFromString(html, 'text/html');\n\n const newContainer = doc.querySelector('#products-sidebar-categories-container');\n const existingContainer = document.querySelector('#products-sidebar-categories-container');\n\n if (!newContainer || !existingContainer) {\n // Restore opacity if it was changed\n if (existingContainer) {\n existingContainer.style.opacity = '1';\n }\n // Show user-friendly error message\n this._showSidebarRefreshError('Unable to update sidebar. Please refresh the page if the problem persists.');\n return;\n }\n\n // Replace the sidebar content\n existingContainer.innerHTML = newContainer.innerHTML;\n\n // Reset opacity with smooth transition\n existingContainer.style.opacity = '1';\n\n // Use the mixin to reinitialize Stimulus controllers\n this.reinitializeStimulusControllers(existingContainer);\n\n // Simple highlighting - no complex state management\n this._highlightCurrentProduct();\n\n // If accordion was expanded before, show a subtle notice\n if (wasExpanded) {\n this._showSidebarRefreshNotice();\n }\n\n // Reset retry count on success\n this._sidebarRefreshRetryCount = 0;\n })\n .catch(error => {\n if (timeoutId) clearTimeout(timeoutId); // Clear the timeout on error\n // Restore opacity on error\n const container = document.querySelector('#products-sidebar-categories-container');\n if (container) {\n container.style.opacity = '1';\n }\n\n // Increment retry count\n this._sidebarRefreshRetryCount++;\n\n // Show user-friendly error message based on error type\n let errorMessage = 'Unable to update sidebar. ';\n if (error.name === 'AbortError') {\n // Check if it was a timeout or intentional abort\n if (!this._sidebarAbortController || error.message.includes('timed out')) {\n errorMessage += 'Request timed out. Please check your connection.';\n } else {\n // Intentionally aborted for a new request, don't show error\n return;\n }\n } else if (error.message && error.message.includes('fetch')) {\n errorMessage += 'Network connection issue. Please check your connection.';\n } else if (error.message && error.message.includes('404')) {\n errorMessage += 'The requested page was not found.';\n } else if (this._sidebarRefreshRetryCount >= 3) {\n // After 3 failed attempts, suggest page refresh\n errorMessage = 'Sidebar update continues to fail. Please refresh the page to ensure everything is up to date.';\n } else {\n errorMessage += 'Please try again or refresh the page.';\n }\n\n this._showSidebarRefreshError(errorMessage);\n })\n .finally(() => {\n // Clear the promise and controller references\n this._sidebarRefreshPromise = null;\n this._sidebarRefreshTimeout = null;\n this._sidebarAbortController = null;\n });\n\n return this._sidebarRefreshPromise;\n }\n\n _highlightCurrentProduct() {\n // Clear all active states in the entire sidebar\n const sidebar = document.querySelector('#products-sidebar');\n if (!sidebar) return;\n\n // Only remove active classes from sidebar product list items\n // This selector specifically targets product items and excludes dropdown items\n sidebar.querySelectorAll('.sidebar-product-list-item.active').forEach(el => {\n el.classList.remove('active');\n });\n\n // Find and highlight the current product\n const productElement = sidebar.querySelector(`.sidebar-product-list-item[data-product-id=\"${this.productId}\"]`);\n if (productElement) {\n productElement.classList.add('active');\n // Ensure the product is visible\n requestAnimationFrame(() => {\n productElement.scrollIntoView({ behavior: 'smooth', block: 'center' });\n });\n }\n }\n\n _showSidebarRefreshNotice() {\n // Show a subtle, temporary notice about the sidebar refresh\n const notice = document.createElement('div');\n notice.className = 'sidebar-refresh-notice';\n notice.style.cssText = `\n position: fixed;\n bottom: 20px;\n right: 20px;\n background-color: #f8f9fa;\n color: #495057;\n padding: 12px 20px;\n border-radius: 4px;\n box-shadow: 0 2px 8px rgba(0,0,0,0.1);\n font-size: 14px;\n z-index: 1050;\n max-width: 300px;\n animation: slideInRight 0.3s ease-out;\n `;\n\n // Add animation keyframes if not already present\n if (!document.querySelector('#sidebar-refresh-notice-styles')) {\n const style = document.createElement('style');\n style.id = 'sidebar-refresh-notice-styles';\n style.textContent = `\n @keyframes slideInRight {\n from {\n transform: translateX(100%);\n opacity: 0;\n }\n to {\n transform: translateX(0);\n opacity: 1;\n }\n }\n @keyframes fadeOut {\n from {\n opacity: 1;\n }\n to {\n opacity: 0;\n }\n }\n `;\n document.head.appendChild(style);\n this._createdElements.add(style); // Track for cleanup\n }\n\n notice.innerHTML = `\n Sidebar Updated
    \n Categories were refreshed to show the latest changes.\n `;\n\n document.body.appendChild(notice);\n this._createdElements.add(notice); // Track for cleanup\n\n // Remove the notice after 4 seconds with fade out\n const fadeTimeout = setTimeout(() => {\n notice.style.animation = 'fadeOut 0.3s ease-out forwards';\n const removeTimeout = setTimeout(() => {\n if (notice.parentNode) {\n notice.remove();\n this._createdElements.delete(notice); // Remove from tracking\n }\n this._createdTimeouts.delete(removeTimeout);\n }, 300);\n this._createdTimeouts.add(removeTimeout);\n this._createdTimeouts.delete(fadeTimeout);\n }, 4000);\n this._createdTimeouts.add(fadeTimeout);\n }\n\n _showSidebarRefreshError(message) {\n // Try to use the global error flash if available\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash(message);\n return;\n }\n\n // Fallback: Show error notice with recovery options\n const errorNotice = document.createElement('div');\n errorNotice.className = 'sidebar-refresh-error-notice';\n errorNotice.style.cssText = `\n position: fixed;\n bottom: 20px;\n right: 20px;\n background-color: #f8d7da;\n color: #721c24;\n padding: 16px 20px;\n border-radius: 4px;\n box-shadow: 0 2px 8px rgba(0,0,0,0.1);\n font-size: 14px;\n z-index: 1050;\n max-width: 380px;\n border: 1px solid #f5c6cb;\n animation: slideInRight 0.3s ease-out;\n `;\n\n // Add close button\n const closeButton = document.createElement('button');\n closeButton.innerHTML = '\u00D7';\n closeButton.style.cssText = `\n position: absolute;\n top: 4px;\n right: 8px;\n background: none;\n border: none;\n font-size: 20px;\n line-height: 1;\n color: #721c24;\n cursor: pointer;\n padding: 0;\n opacity: 0.5;\n `;\n closeButton.onmouseover = () => { closeButton.style.opacity = '1'; };\n closeButton.onmouseout = () => { closeButton.style.opacity = '0.5'; };\n closeButton.onclick = () => {\n errorNotice.remove();\n this._createdElements.delete(errorNotice); // Remove from tracking\n };\n\n // Create content with retry option\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n Sidebar Update Failed
    \n ${message}\n `;\n\n // Add retry button if we haven't exceeded retry limit\n if (this._sidebarRefreshRetryCount < 3 && !message.includes('refresh the page')) {\n const retryButton = document.createElement('button');\n retryButton.textContent = 'Retry';\n retryButton.style.cssText = `\n margin-top: 8px;\n padding: 4px 12px;\n background-color: #721c24;\n color: white;\n border: none;\n border-radius: 3px;\n font-size: 12px;\n cursor: pointer;\n `;\n retryButton.onmouseover = () => { retryButton.style.backgroundColor = '#5a161c'; };\n retryButton.onmouseout = () => { retryButton.style.backgroundColor = '#721c24'; };\n retryButton.onclick = () => {\n errorNotice.remove();\n // Retry the sidebar refresh\n this.refreshSidebar();\n };\n contentDiv.appendChild(retryButton);\n }\n\n errorNotice.appendChild(contentDiv);\n errorNotice.appendChild(closeButton);\n\n document.body.appendChild(errorNotice);\n this._createdElements.add(errorNotice); // Track for cleanup\n\n // Auto-remove after 10 seconds (longer for errors)\n const autoRemoveTimeout = setTimeout(() => {\n if (errorNotice.parentNode) {\n errorNotice.style.animation = 'fadeOut 0.3s ease-out forwards';\n const removeTimeout = setTimeout(() => {\n if (errorNotice.parentNode) {\n errorNotice.remove();\n this._createdElements.delete(errorNotice); // Remove from tracking\n }\n this._createdTimeouts.delete(removeTimeout);\n }, 300);\n this._createdTimeouts.add(removeTimeout);\n }\n this._createdTimeouts.delete(autoRemoveTimeout);\n }, 10000);\n this._createdTimeouts.add(autoRemoveTimeout);\n\n // Clear timeout if user interacts with the notice\n errorNotice.onclick = (e) => {\n if (e.target !== closeButton) {\n clearTimeout(autoRemoveTimeout);\n this._createdTimeouts.delete(autoRemoveTimeout);\n }\n };\n }\n\n _showGalleryRefreshError(error) {\n // Don't show intrusive errors for gallery refresh\n // Image uploads succeeded, but gallery view update failed\n // User can still see the images after a page refresh\n\n // Only show error if it's a network failure or server error\n if (error && (\n error.name === 'AbortError' ||\n (error.message && (\n error.message.includes('fetch') ||\n error.message.includes('500') ||\n error.message.includes('Network') ||\n error.message.includes('timed out')\n ))\n )) {\n // Show a subtle notification\n const message = 'Images uploaded successfully but gallery update failed. Refresh the page to see new images.';\n\n // Try to use the global flash if available\n if (window.App && typeof window.App.showWarningFlash === 'function') {\n window.App.showWarningFlash(message);\n } else {\n // Create a subtle warning notice\n const notice = document.createElement('div');\n notice.className = 'gallery-refresh-warning';\n notice.style.cssText = `\n position: fixed;\n bottom: 80px;\n right: 20px;\n background-color: #fff3cd;\n color: #856404;\n padding: 12px 20px;\n border-radius: 4px;\n box-shadow: 0 2px 8px rgba(0,0,0,0.1);\n font-size: 14px;\n z-index: 1040;\n max-width: 350px;\n border: 1px solid #ffeeba;\n `;\n\n notice.innerHTML = `\n \n ${message}\n `;\n\n document.body.appendChild(notice);\n this._createdElements.add(notice); // Track for cleanup\n\n // Auto-remove after 6 seconds\n const fadeTimeout = setTimeout(() => {\n if (notice.parentNode) {\n notice.style.transition = 'opacity 0.3s ease-out';\n notice.style.opacity = '0';\n const removeTimeout = setTimeout(() => {\n if (notice.parentNode) {\n notice.remove();\n this._createdElements.delete(notice); // Remove from tracking\n }\n this._createdTimeouts.delete(removeTimeout);\n }, 300);\n this._createdTimeouts.add(removeTimeout);\n }\n this._createdTimeouts.delete(fadeTimeout);\n }, 6000);\n this._createdTimeouts.add(fadeTimeout);\n }\n }\n // For other errors (like missing elements), fail silently\n // as the sidebar refresh will still update the product state\n }\n\n // Event handlers for Stimulus 1.1.0 compatibility\n handleModalSidebarRefresh(event) {\n this.logDebug('[ProductEditCoordinator] Handling modal sidebar refresh request', event.detail);\n this.refreshSidebar();\n }\n\n handleModalGalleryRefresh(event) {\n this.logDebug('[ProductEditCoordinator] Handling modal gallery refresh request', event.detail);\n this.refreshGallery();\n }\n}\n", "import { Controller } from 'stimulus';\n\n/**\n * Product Filter Controller\n *\n * Provides real-time filtering of products by name or style number.\n *\n * Performance: Debounced by 150ms to reduce DOM operations during rapid typing.\n * This prevents excessive filtering while the user is still typing, improving\n * responsiveness especially on pages with many products.\n */\nexport default class extends Controller {\n static targets = ['input', 'product', 'emptyState', 'productsContainer'];\n\n connect() {\n // Track last search term to avoid redundant filtering\n this.lastSearchTerm = '';\n // Store debounce timeout reference for cleanup\n this.debounceTimeout = null;\n }\n\n disconnect() {\n // Clean up debounce timeout\n if (this.debounceTimeout) {\n clearTimeout(this.debounceTimeout);\n this.debounceTimeout = null;\n }\n }\n\n /**\n * Public action handler - debounces calls to performFilter\n * Called on input events from the search field\n */\n filter() {\n // Clear any existing debounce timeout\n if (this.debounceTimeout) {\n clearTimeout(this.debounceTimeout);\n }\n\n // Debounce: Wait 150ms before performing the actual filter\n // This reduces DOM operations during rapid typing\n this.debounceTimeout = setTimeout(() => {\n this.performFilter();\n }, 150);\n }\n\n /**\n * Performs the actual filtering logic\n * Called after debounce delay\n */\n performFilter() {\n const searchTerm = this.inputTarget.value.toLowerCase().trim();\n\n // Skip if search term hasn't changed\n if (searchTerm === this.lastSearchTerm) {\n return;\n }\n this.lastSearchTerm = searchTerm;\n\n let visibleCount = 0;\n\n this.productTargets.forEach(product => {\n const productName = product.dataset.productName || '';\n const productStyle = product.dataset.productStyle || '';\n\n const matches = productName.includes(searchTerm) || productStyle.includes(searchTerm);\n\n if (matches || searchTerm === '') {\n product.style.display = '';\n visibleCount++;\n } else {\n product.style.display = 'none';\n }\n });\n\n // Show/hide category sections based on visible products\n this.updateCategorySections();\n\n // Show/hide empty state based on visible count\n this.updateEmptyState(visibleCount);\n }\n\n updateCategorySections() {\n const categories = document.querySelectorAll('.category-section');\n\n categories.forEach(category => {\n const products = category.querySelectorAll('.product-card');\n const visibleProducts = Array.from(products).filter(p => p.style.display !== 'none');\n\n if (visibleProducts.length === 0) {\n category.style.display = 'none';\n } else {\n category.style.display = '';\n }\n });\n }\n\n updateEmptyState(visibleCount) {\n if (!this.hasEmptyStateTarget) return;\n\n if (visibleCount === 0 && this.hasProductsContainerTarget) {\n // Hide products container, show empty state\n this.emptyStateTarget.style.display = 'block';\n this.productsContainerTarget.style.display = 'none';\n } else {\n // Show products container, hide empty state\n this.emptyStateTarget.style.display = 'none';\n if (this.hasProductsContainerTarget) {\n this.productsContainerTarget.style.display = '';\n }\n }\n }\n}\n\n", "/**\n * Sidebar Refresh Mixin for Stimulus Controllers\n *\n * This mixin provides a standardized way to refresh the product sidebar via AJAX.\n * It handles fetching updated HTML, replacing content, reinitializing controllers,\n * and highlighting the current product.\n *\n * Dependencies:\n * - Requires CSRFTokenMixin to be applied to the controller\n * - Uses StimulusReinitMixin for reinitializing controllers\n *\n * Usage:\n * import SidebarRefreshMixin from '../mixins/sidebar_refresh_mixin';\n * import CSRFTokenMixin from '../mixins/csrf_token_mixin';\n *\n * export default class extends Controller {\n * connect() {\n * Object.assign(this, CSRFTokenMixin, SidebarRefreshMixin);\n * this.saleId = this.element.dataset.saleId;\n * this.productId = this.element.dataset.productId;\n * }\n *\n * async someAction() {\n * // Your action logic...\n * await this.refreshSidebar();\n * }\n * }\n */\n\nimport StimulusReinitMixin from './stimulus_reinit_mixin';\n\nconst SidebarRefreshMixin = {\n ...StimulusReinitMixin,\n /**\n * Refresh the sidebar categories via AJAX\n * @param {Object} options - Optional configuration\n * @param {boolean} options.debug - Enable debug logging\n * @returns {Promise}\n */\n async refreshSidebar(options = {}) {\n const debug = options.debug || false;\n\n // Ensure CSRF token mixin is available\n if (typeof this.getCSRFToken !== 'function') {\n console.error('[SidebarRefresh] CSRFTokenMixin must be applied to use SidebarRefreshMixin');\n throw new Error('CSRFTokenMixin is required but not found');\n }\n\n try {\n const url = `/teamsites/${this.saleId}/products/${this.productId}/sidebar`;\n // \u2705 PRODUCTION READY - Debug logging removed for production quality\n\n const response = await fetch(url, {\n headers: this.getHeadersWithCSRF({\n 'Accept': 'text/html',\n 'X-Requested-With': 'XMLHttpRequest',\n }),\n credentials: 'same-origin',\n });\n\n if (response.ok) {\n const html = await response.text();\n await this.replaceSidebarContent(html, debug);\n } else {\n console.error('[SidebarRefresh] Failed to refresh sidebar:', response.status, response.statusText);\n throw new Error(`Failed to refresh sidebar: ${response.statusText}`);\n }\n } catch (error) {\n console.error('[SidebarRefresh] Error refreshing sidebar:', error);\n throw error;\n }\n },\n\n /**\n * Replace the sidebar content with new HTML\n * @param {string} html - The new HTML content\n * @param {boolean} debug - Enable debug logging\n * @returns {Promise}\n */\n async replaceSidebarContent(html, debug = false) {\n const parser = new DOMParser();\n const doc = parser.parseFromString(html, 'text/html');\n\n const newContainer = doc.querySelector('#products-sidebar-categories-container');\n const currentContainer = document.querySelector('#products-sidebar-categories-container');\n\n if (newContainer && currentContainer) {\n // Replace the content\n currentContainer.innerHTML = newContainer.innerHTML;\n\n // Pass 1: Immediate highlight after innerHTML (DOM is synchronously ready)\n this.highlightCurrentProductInSidebar(currentContainer, debug);\n\n // Pass 2: Fallback after Stimulus controller initialization settles\n var self = this;\n setTimeout(function() {\n // Re-apply highlight in case Stimulus re-init cleared it\n self.highlightCurrentProductInSidebar(currentContainer, debug);\n\n // Also trigger URL-based highlighting from product-navigation controller\n var navEl = document.querySelector('[data-controller*=\"product-navigation\"]');\n if (navEl && self.application) {\n var controller = self.application.getControllerForElementAndIdentifier(\n navEl, 'product-navigation'\n );\n if (controller) controller.markActiveIfCurrent();\n }\n }, 150);\n } else {\n // Error: Could not find sidebar container\n }\n },\n\n\n /**\n * Highlight the current product in the sidebar\n * @param {Element} container - The sidebar container\n * @param {boolean} debug - Enable debug logging\n */\n highlightCurrentProductInSidebar(container, debug = false) {\n const productElement = container.querySelector(`.sidebar-product-list-item[data-product-id=\"${this.productId}\"]`);\n\n if (productElement) {\n // Remove existing active states\n container.querySelectorAll('.sidebar-product-list-item.active')\n .forEach(el => el.classList.remove('active'));\n\n // Add active class\n productElement.classList.add('active');\n\n // Scroll into view\n setTimeout(() => {\n if (typeof productElement.scrollIntoView === 'function') {\n productElement.scrollIntoView({ behavior: 'smooth', block: 'center' });\n }\n // Debug: Highlighted and scrolled to product\n }, 100);\n } else if (debug) {\n console.warn(`[SidebarRefresh] Could not find product with ID: ${this.productId}`);\n }\n },\n\n};\n\nexport default SidebarRefreshMixin;\n", "import { Controller } from '../stimulus';\nimport CSRFTokenMixin from '../mixins/csrf_token_mixin';\nimport SidebarRefreshMixin from '../mixins/sidebar_refresh_mixin';\n\nexport default class extends Controller {\n initialize() {\n // Apply the mixins (CSRFTokenMixin is required by SidebarRefreshMixin)\n Object.assign(this, CSRFTokenMixin, SidebarRefreshMixin);\n\n this.saleId = this.element.dataset.saleId;\n this.productId = this.element.dataset.productId;\n this.url = `/teamsites/${this.saleId}/products/${this.productId}/available`;\n }\n\n async click() {\n // Find the checkbox input within the element\n const checkbox = this.element.querySelector('input[type=\"checkbox\"]');\n\n // Store the original state before toggling\n const originalState = checkbox.checked;\n\n // Toggle the checkbox state\n checkbox.checked = !checkbox.checked;\n\n const available = checkbox.checked ? 1 : 0;\n\n // Allow the toggle animation to complete (typically 200-300ms)\n setTimeout(async () => {\n const formData = new FormData();\n formData.append('available', available);\n\n try {\n const response = await fetch(this.url, {\n method: 'PATCH',\n headers: {\n 'X-CSRF-Token': this.getCSRFToken(),\n },\n body: formData,\n });\n\n const result = await response.json();\n\n if (response.ok) {\n if (window.App && typeof window.App.showSuccessFlash === 'function') {\n window.App.showSuccessFlash('Product updated');\n }\n\n // Refresh the sidebar instead of full page reload\n try {\n await this.refreshSidebar();\n } catch {\n // Failed to refresh sidebar - fallback to Turbolinks\n // Fallback to Turbolinks if sidebar refresh fails\n if (window.Turbolinks) {\n Turbolinks.visit(window.location.href);\n }\n }\n } else {\n // Rollback the checkbox state on error\n checkbox.checked = originalState;\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash(result.message || 'Something went wrong');\n }\n }\n } catch {\n // Rollback the checkbox state on error\n checkbox.checked = originalState;\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash('Something went wrong');\n }\n }\n }, 300); // Wait 300ms for animation to complete\n }\n}\n", "/**\n * Select2CreateMixin\n *\n * Reusable mixin that adds \"create new record from filter\" functionality to Select2 dropdowns.\n * Extracted from product_form_category_dropdown_controller.js (November 2025).\n *\n * @purpose\n * Enable users to type a new value in Select2 filter field and create the record on-the-fly,\n * with automatic dropdown update and sidebar refresh.\n *\n * @features\n * - Tag creation detection (newTag flag + numeric ID check + numeric validation)\n * - Search term tracking (captures what user actually typed)\n * - Atomic create-and-replace (swap temporary text option with database ID)\n * - Debounced operations (prevent concurrent requests)\n * - Safe DOM manipulation (validates element connection before/after changes)\n * - Configurable API endpoints\n *\n * @usage\n * ```javascript\n * import Select2BaseController from './select2_base_controller';\n * import Select2CreateMixin from '../mixins/select2_create_mixin';\n *\n * export default class extends Select2BaseController {\n * connect() {\n * super.connect();\n *\n * // Apply mixin\n * Object.assign(this, Select2CreateMixin);\n *\n * // Initialize mixin with configuration\n * this.initializeSelect2Create({\n * resourceName: 'category', // Used in API endpoints\n * resourceIdParam: 'category_id', // Parameter name for resource ID\n * contextParams: { // Additional context for API calls\n * sale_id: this.saleId,\n * product_id: this.productId\n * },\n * createEndpoint: '/categories/create_and_assign',\n * assignEndpoint: (id) => `/categories/${id}/assign`,\n * onAfterCreate: async (data) => {\n * // Optional: Custom post-create logic\n * await this.refreshSidebar();\n * }\n * });\n * }\n *\n * getEnhancementOptions() {\n * return {\n * ...super.getEnhancementOptions(),\n * tags: true, // REQUIRED for create functionality\n * createTag: this.createTagHandler,\n * insertTag: this.insertTagHandler,\n * };\n * }\n * }\n * ```\n *\n * @requirements\n * - Must extend Select2BaseController\n * - Must apply CSRFTokenMixin (for getCSRFToken())\n * - Select2 options must include `tags: true`\n * - Must implement _isValidState() method\n * - Must implement _safelyManipulateDOM(callback) method\n * - Must implement _safelyManipulateSelect2(callback) method if using Select2\n *\n * @patterns\n * 1. Tag Creation Detection - Identify new vs existing via newTag flag + numeric ID\n * 2. Search Term Tracking - Capture user typing in filter field\n * 3. Atomic Create-and-Replace - Swap temporary option with real database ID\n * 4. Debounced Operation Flag - Prevent concurrent async operations\n *\n * @see app/javascript/controllers/product_form_category_dropdown_controller.js - Original implementation\n * @see doc/select2_create_standardization_guide.md - Complete pattern documentation\n */\n\nconst Select2CreateMixin = {\n /**\n * Initialize the Select2 create functionality\n *\n * @param {Object} config - Configuration object\n * @param {string} config.resourceName - Resource name (e.g., 'category', 'brand', 'tag')\n * @param {string} config.resourceIdParam - Parameter name for resource ID (e.g., 'category_id')\n * @param {Object} config.contextParams - Additional context parameters (e.g., { sale_id: 123 })\n * @param {string} config.createEndpoint - API endpoint for creating new records\n * @param {Function} config.assignEndpoint - Function that returns assign endpoint URL given ID\n * @param {Function} [config.onAfterCreate] - Optional callback after successful creation\n * @param {Function} [config.onAfterAssign] - Optional callback after successful assignment\n * @param {string} [config.successMessage] - Custom success message for creation\n * @param {string} [config.assignSuccessMessage] - Custom success message for assignment\n */\n initializeSelect2Create(config) {\n // Validate required configuration\n if (!config.resourceName) {\n throw new Error('Select2CreateMixin: resourceName is required');\n }\n if (!config.createEndpoint) {\n throw new Error('Select2CreateMixin: createEndpoint is required');\n }\n if (!config.assignEndpoint) {\n throw new Error('Select2CreateMixin: assignEndpoint is required');\n }\n\n // Store configuration\n this._select2CreateConfig = {\n resourceName: config.resourceName,\n resourceIdParam: config.resourceIdParam || `${config.resourceName}_id`,\n contextParams: config.contextParams || {},\n createEndpoint: config.createEndpoint,\n assignEndpoint: config.assignEndpoint,\n onAfterCreate: config.onAfterCreate || null,\n onAfterAssign: config.onAfterAssign || null,\n successMessage: config.successMessage || `${this._capitalizeFirst(config.resourceName)} created successfully`,\n assignSuccessMessage: config.assignSuccessMessage || `${this._capitalizeFirst(config.resourceName)} assigned successfully`,\n initialValueId: config.initialValueId || null,\n };\n\n // Initialize state\n this._lastSearchTerm = '';\n this._isCreatingResource = false;\n\n // Bind handlers for Select2 configuration\n this.createTagHandler = this.createTagHandler.bind(this);\n this.insertTagHandler = this.insertTagHandler.bind(this);\n this.handleSelect2Change = this.handleSelect2Change.bind(this);\n },\n\n /**\n * Set the initial value ID for skip-redundant-assign logic\n * Call this after loading the current value (e.g., from an AJAX request)\n *\n * @param {string|number|null} id - The current resource ID\n */\n setInitialValueId(id) {\n if (this._select2CreateConfig) {\n this._select2CreateConfig.initialValueId = id ? String(id) : null;\n }\n },\n\n /**\n * Setup search term tracking for Select2\n * Call this from setupEnhancedEvents() override\n */\n setupSelect2CreateEvents() {\n if (!this.isSelect2Available()) return;\n\n try {\n const enhancer = this.getEnhancer();\n const element = enhancer(this.selectElement);\n\n // Reset search term when dropdown opens\n element.on('select2:opening', () => {\n this._lastSearchTerm = '';\n });\n\n // Track what user is typing for tag creation detection\n element.on('select2:open', () => {\n setTimeout(() => {\n const searchField = document.querySelector('.select2-search__field');\n if (searchField) {\n searchField.focus();\n\n // Track search input\n searchField.addEventListener('input', (e) => {\n this._lastSearchTerm = e.target.value || '';\n });\n }\n }, 0);\n });\n } catch {\n // Silently continue without search tracking\n }\n },\n\n /**\n * Select2 createTag handler\n * Returns tag object with newTag flag for detection\n *\n * Usage in getEnhancementOptions():\n * ```javascript\n * createTag: this.createTagHandler,\n * ```\n */\n createTagHandler(params) {\n const term = params.term.trim();\n\n if (term === '') {\n return null;\n }\n\n return {\n id: term,\n text: term,\n newTag: true, // Critical flag for detection\n };\n },\n\n /**\n * Select2 insertTag handler\n * Inserts new tags at the beginning of dropdown list\n *\n * Usage in getEnhancementOptions():\n * ```javascript\n * insertTag: this.insertTagHandler,\n * ```\n */\n insertTagHandler(data, tag) {\n data.unshift(tag);\n },\n\n /**\n * Handle Select2 selection change\n * Detects new vs existing and routes to appropriate handler\n *\n * Call this from onSelectChange() override:\n * ```javascript\n * async onSelectChange(event) {\n * await this.handleSelect2Change(event);\n * }\n * ```\n */\n async handleSelect2Change(event) {\n // Debounce: Prevent concurrent resource creation requests\n if (this._isCreatingResource) {\n return;\n }\n\n // Get value from Select2 event detail or fall back to element value\n let resourceValue = event.detail?.value || event.target.value;\n\n if (!resourceValue) return;\n\n // Detect if this is a new resource or existing one\n const detection = this._detectNewResource(event, resourceValue);\n\n if (detection.isNew) {\n // Set flag before creating resource\n this._isCreatingResource = true;\n try {\n await this._createNewResource(detection.value);\n } finally {\n // Always reset flag, even if creation fails\n this._isCreatingResource = false;\n }\n } else {\n // Assign existing resource (no debounce needed - idempotent operation)\n await this._assignExistingResource(detection.value);\n }\n },\n\n /**\n * Pattern 1: Tag Creation Detection\n * Identify new vs existing selection using newTag flag + numeric ID check\n *\n * After removing selectOnClose (Feb 2026), select2:select only fires via\n * explicit user click/Enter. The typed search text is just a filter, not\n * the intended value. Text comparison strategy removed because it caused\n * substring creation bugs (e.g., typing \"Ni\" + clicking \"Nike\" created \"Ni\").\n *\n * @private\n * @param {Event} event - Select2 selection event\n * @param {string|number} resourceValue - Selected value\n * @returns {Object} { isNew: boolean, value: string|number }\n */\n _detectNewResource(event, resourceValue) {\n const selectedData = event.detail?.data || event.params?.data;\n\n // DETECTION STRATEGY (Priority Order):\n\n // 1. EXPLICIT FLAG: Most reliable - Select2 marked it as newTag\n if (selectedData && selectedData.newTag === true) {\n return { isNew: true, value: resourceValue };\n }\n\n // 2. DATABASE ITEM: Numeric selectedData.id = existing AJAX result\n // Search term was just a filter, not the intended value\n if (selectedData && /^\\d+$/.test(String(selectedData.id))) {\n return { isNew: false, value: resourceValue };\n }\n\n // 3. NUMERIC DETECTION: Fallback - only digits = existing ID\n return { isNew: !/^\\d+$/.test(String(resourceValue)), value: resourceValue };\n },\n\n /**\n * Pattern 3: Atomic Create-and-Replace\n * Create new resource and update dropdown with real database ID\n *\n * @private\n * @param {string} resourceName - Name of resource to create\n */\n async _createNewResource(resourceName) {\n const config = this._select2CreateConfig;\n\n // Get CSRF token (requires CSRFTokenMixin)\n const csrfToken = this.getCSRFToken();\n if (!csrfToken) {\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash('Security token missing. Please refresh the page.');\n }\n return;\n }\n\n try {\n // Build query string from context params\n const queryParams = new URLSearchParams(config.contextParams).toString();\n const url = queryParams\n ? `${config.createEndpoint}?${queryParams}`\n : config.createEndpoint;\n\n const response = await fetch(url, {\n method: 'POST',\n headers: {\n 'X-CSRF-Token': csrfToken,\n 'Accept': 'application/json',\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({ name: resourceName }),\n });\n\n if (!response.ok) {\n throw new Error('Network response was not ok');\n }\n\n const data = await response.json();\n\n // Pattern 3: Atomic Replace - Update dropdown with real ID\n if (this.element && this._isValidState() && this.isEnhanced()) {\n this._safelyManipulateDOM(() => {\n // Remove temporary tag option (text value)\n const tempOption = this.element.querySelector(`option[value=\"${resourceName}\"]`);\n if (tempOption) {\n this.element.removeChild(tempOption);\n }\n\n // Add new option with database ID\n const newOption = new Option(data.name, data.id, true, true);\n this.element.appendChild(newOption);\n\n // Trigger Select2 update - must set value explicitly for Select2 to update its internal state\n this._safelyManipulateSelect2(() => {\n this.getEnhancer()(this.element).val(data.id).trigger('change');\n });\n });\n }\n\n // Optional post-create callback\n if (config.onAfterCreate) {\n await config.onAfterCreate(data);\n }\n\n // Show success message\n if (window.App && typeof window.App.showSuccessFlash === 'function') {\n window.App.showSuccessFlash(data.message || config.successMessage);\n }\n } catch {\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash(`Failed to create ${config.resourceName}`);\n }\n }\n },\n\n /**\n * Assign existing resource to parent object\n *\n * @private\n * @param {string|number} resourceId - ID of resource to assign\n */\n async _assignExistingResource(resourceId) {\n const config = this._select2CreateConfig;\n\n // Skip if re-selecting the same value (no change)\n if (config.initialValueId && String(resourceId) === String(config.initialValueId)) {\n return; // Silently skip - nothing changed\n }\n\n // Get CSRF token (requires CSRFTokenMixin)\n const csrfToken = this.getCSRFToken();\n if (!csrfToken) {\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash('Security token missing. Please refresh the page.');\n }\n return;\n }\n\n try {\n // Get assign endpoint URL (function or string)\n const assignUrl = typeof config.assignEndpoint === 'function'\n ? config.assignEndpoint(resourceId)\n : config.assignEndpoint;\n\n const response = await fetch(assignUrl, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'X-CSRF-Token': csrfToken,\n },\n body: JSON.stringify(config.contextParams),\n });\n\n if (!response.ok) {\n throw new Error('Network response was not ok');\n }\n\n const data = await response.json();\n\n // Update initial value to the newly assigned value\n config.initialValueId = String(resourceId);\n\n // Optional post-assign callback\n if (config.onAfterAssign) {\n await config.onAfterAssign(data);\n }\n\n // Show success message\n if (window.App && typeof window.App.showSuccessFlash === 'function') {\n window.App.showSuccessFlash(data.message || config.assignSuccessMessage);\n }\n } catch {\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash(`An error occurred while updating the ${config.resourceName}`);\n }\n }\n },\n\n /**\n * Helper: Capitalize first letter\n * @private\n */\n _capitalizeFirst(str) {\n return str.charAt(0).toUpperCase() + str.slice(1);\n },\n};\n\nexport default Select2CreateMixin;\n", "import CSRFTokenMixin from '../mixins/csrf_token_mixin';\nimport SidebarRefreshMixin from '../mixins/sidebar_refresh_mixin';\nimport Select2CreateMixin from '../mixins/select2_create_mixin';\n\nimport Select2BaseController from './select2_base_controller';\n\nexport default class extends Select2BaseController {\n // Constants\n static SELECT2_INIT_DELAY_MS = 100; // Time to wait for Select2 initialization\n static LOADING_TIMEOUT_MS = 5000; // Maximum time to wait for brand loading\n static MAX_RETRY_ATTEMPTS = 2; // Maximum retry attempts for failed requests\n\n initialize() {\n super.initialize();\n this.loadingTimeout = null;\n this.loadBrandAbortController = null;\n this.retryCount = 0;\n this.debug = false; // Set to true to enable debug logging\n this._errorStateActive = false; // Prevent recursive error state initialization\n }\n\n connect() {\n try {\n // Cancel any pending operations from previous instances\n this.cancelPendingOperations();\n\n // Check if element exists before accessing properties\n if (!this.element) {\n return;\n }\n\n // Set data attributes before calling parent connect\n this.saleId = this.element.dataset.saleId || '';\n this.productId = this.element.dataset.productId || '';\n this.isAjaxRequest = this.element.dataset.ajaxRequest === 'true';\n\n // Defensive validation: Check required data attributes\n if (!this.productId || !this.saleId) {\n if (this.debug) {\n /* eslint-disable-next-line no-console */\n console.error('[ProductFormBrandDropdown] Missing required data attributes', {\n productId: this.productId,\n saleId: this.saleId,\n element: this.element,\n });\n }\n this.showErrorState('Configuration error - missing product or sale context');\n return;\n }\n\n // Apply the mixins (CSRFTokenMixin is required by SidebarRefreshMixin and Select2CreateMixin)\n Object.assign(this, CSRFTokenMixin, SidebarRefreshMixin, Select2CreateMixin);\n\n // Initialize Select2 create functionality\n this.initializeSelect2Create({\n resourceName: 'brand',\n resourceIdParam: 'brand_id',\n contextParams: {\n sale_id: this.saleId,\n product_id: this.productId,\n },\n createEndpoint: '/brands',\n assignEndpoint: (brandId) => `/brands/${brandId}/assign`,\n onAfterCreate: async () => {\n // Refresh sidebar after brand creation\n try {\n await this.refreshSidebar({ debug: this.debug });\n } catch {\n // Continue even if sidebar refresh fails\n }\n },\n onAfterAssign: async () => {\n // Refresh sidebar after brand assignment\n try {\n await this.refreshSidebar({ debug: this.debug });\n } catch {\n // Continue even if sidebar refresh fails\n }\n },\n });\n\n // Call parent connect to initialize Select2\n super.connect();\n\n // For AJAX requests, load current brand value after initialization\n // Always load - the partial already has the correct initial state\n if (this.isAjaxRequest) {\n // Add a small delay to ensure Select2 is fully initialized\n this.scheduleLoadCurrentBrand();\n }\n } catch (/* eslint-disable-line no-unused-vars */ error) {\n // Handle error state properly\n this.showErrorState('Error loading brands');\n }\n }\n\n disconnect() {\n // Cancel any pending operations when disconnecting\n this.cancelPendingOperations();\n // Reset error state flag to prevent issues on reconnection\n this._errorStateActive = false;\n super.disconnect();\n }\n\n cancelPendingOperations() {\n // Cancel loading timeout\n if (this.loadingTimeout) {\n clearTimeout(this.loadingTimeout);\n this.loadingTimeout = null;\n }\n\n // Abort any pending AJAX requests\n if (this.loadBrandAbortController) {\n this.loadBrandAbortController.abort();\n this.loadBrandAbortController = null;\n }\n }\n\n scheduleLoadCurrentBrand() {\n // Set a timeout to handle cases where loading gets stuck\n this.loadingTimeout = setTimeout(() => {\n this.handleLoadingTimeout();\n }, this.constructor.LOADING_TIMEOUT_MS);\n\n // Schedule the actual loading\n setTimeout(() => {\n this.loadCurrentBrand();\n }, this.constructor.SELECT2_INIT_DELAY_MS);\n }\n\n handleLoadingTimeout() {\n if (!this.element) {\n return;\n }\n\n // Clear the loading state and show a meaningful message\n const firstOption = this.element.querySelector('option');\n if (firstOption && firstOption.textContent === 'Loading...') {\n // Remove all options\n while (this.element.firstChild) {\n this.element.removeChild(this.element.firstChild);\n }\n\n // Add empty option for placeholder\n const emptyOption = new Option('', '', true, true);\n this.element.appendChild(emptyOption);\n\n // Update placeholder\n this.updatePlaceholder('Select Brand');\n }\n\n // Clear the timeout reference\n this.loadingTimeout = null;\n }\n\n /**\n * Provide custom Select2 options\n */\n getEnhancementOptions() {\n // Ensure we have values when this is called\n const saleId = this.saleId || this.element.dataset.saleId || '';\n const productId = this.productId || this.element.dataset.productId || '';\n\n return {\n tags: true,\n width: '100%',\n placeholder: 'Select Brand',\n allowClear: true,\n ajax: {\n url: `/brands?sale_id=${saleId}&product_id=${productId}`,\n dataType: 'json',\n delay: 250,\n data: function(params) {\n return {\n q: params.term || '',\n };\n },\n processResults: function(data) {\n return {\n results: data.brands.map(brand => ({\n id: brand.id,\n text: brand.name,\n })),\n };\n },\n cache: true,\n },\n minimumInputLength: 0,\n templateSelection: function (data) {\n return data.text;\n },\n // Use mixin handlers for tag creation\n createTag: this.createTagHandler,\n insertTag: this.insertTagHandler,\n };\n }\n\n\n /**\n * Load current brand for AJAX requests\n * This fetches the current product's brand and sets it as selected\n */\n async loadCurrentBrand() {\n\n // Cancel any existing request\n if (this.loadBrandAbortController) {\n this.loadBrandAbortController.abort();\n }\n\n // Create new abort controller for this request\n this.loadBrandAbortController = new AbortController();\n\n try {\n const url = `/brands/current?product_id=${this.productId}&sale_id=${this.saleId}`;\n\n const response = await fetch(url, {\n headers: {\n 'Accept': 'application/json',\n 'X-Requested-With': 'XMLHttpRequest',\n },\n signal: this.loadBrandAbortController.signal,\n });\n\n // Clear the loading timeout since we got a response\n if (this.loadingTimeout) {\n clearTimeout(this.loadingTimeout);\n this.loadingTimeout = null;\n }\n\n if (response.ok) {\n const data = await response.json();\n\n if (data.brand) {\n // Add the current brand as an option and select it\n const option = new Option(data.brand.name, data.brand.id, true, true);\n if (this.element) {\n this.element.appendChild(option);\n\n // Trigger change event for Select2\n if (this.isEnhanced()) {\n this.getEnhancer()(this.element).trigger('change');\n }\n }\n // Track initial value for skip-redundant-assign logic\n this.setInitialValueId(data.brand.id);\n } else {\n // No brand assigned, clear the dropdown and show placeholder\n if (this.element) {\n // First, remove the \"Loading...\" option\n while (this.element.firstChild) {\n this.element.removeChild(this.element.firstChild);\n }\n\n // Add an empty option for the placeholder\n const emptyOption = new Option('', '', true, true);\n this.element.appendChild(emptyOption);\n }\n\n // Update the placeholder\n this.updatePlaceholder('Select Brand');\n }\n\n // Reset retry count on success\n this.retryCount = 0;\n } else {\n await this.handleLoadError(new Error(`HTTP ${response.status}`));\n }\n } catch (error) {\n // Don't handle abort errors\n if (error.name === 'AbortError') {\n return;\n }\n\n await this.handleLoadError(error);\n } finally {\n // Clear the abort controller reference\n this.loadBrandAbortController = null;\n }\n }\n\n async handleLoadError(_error) {\n // Check if we should retry\n if (this.retryCount < this.constructor.MAX_RETRY_ATTEMPTS) {\n this.retryCount++;\n\n // Wait a bit before retrying\n await new Promise(resolve => setTimeout(resolve, 500 * this.retryCount));\n\n // Retry the load\n return this.loadCurrentBrand();\n }\n\n // Max retries reached\n\n // Clear the loading timeout\n if (this.loadingTimeout) {\n clearTimeout(this.loadingTimeout);\n this.loadingTimeout = null;\n }\n\n // For all error types, show \"Select Brand\" as the dropdown remains functional\n // Users can still manually select a brand even if the current brand failed to load\n this.updatePlaceholder('Select Brand');\n }\n\n /**\n * Override setupEnhancedEvents to add search term tracking from mixin\n */\n setupEnhancedEvents() {\n // Call parent implementation first\n super.setupEnhancedEvents();\n\n // Setup Select2 create event tracking from mixin\n this.setupSelect2CreateEvents();\n }\n\n updatePlaceholder(text) {\n if (!this.element) {\n return;\n }\n\n const firstOption = this.element.querySelector('option');\n if (firstOption) {\n firstOption.textContent = text;\n firstOption.value = '';\n\n // If Select2 is enhanced, we need to trigger change to update the display\n if (this.isEnhanced()) {\n try {\n // Set the value to empty string to show placeholder\n this.getEnhancer()(this.element).val('').trigger('change');\n\n // Also update Select2's placeholder text if needed\n const select2Container = this.element.nextElementSibling;\n if (select2Container && select2Container.classList.contains('select2-container')) {\n const placeholderElement = select2Container.querySelector('.select2-selection__placeholder');\n if (placeholderElement) {\n placeholderElement.textContent = text;\n }\n }\n } catch (/* eslint-disable-line no-unused-vars */ error) {\n // Ignore Select2 update errors\n }\n }\n }\n }\n\n showErrorState(errorMessage) {\n if (!this.element) {\n return;\n }\n\n // Simplify: always disable the element first to ensure consistent state\n this.element.disabled = true;\n\n // Try to destroy Select2 if it exists\n if (this.isEnhanced()) {\n try {\n this.getEnhancer()(this.element).select2('destroy');\n } catch (/* eslint-disable-line no-unused-vars */ destroyError) {\n // Continue anyway - we still want to show the error message\n }\n }\n\n // Update the HTML with error message\n this.element.innerHTML = ``;\n\n // Only try to reinitialize Select2 if it's available and we're not already in an error state\n if (this.isSelect2Available() && !this._errorStateActive) {\n this._errorStateActive = true; // Prevent recursive initialization attempts\n try {\n this.getEnhancer()(this.element).select2({\n width: '100%',\n disabled: true,\n placeholder: errorMessage,\n });\n } catch (/* eslint-disable-line no-unused-vars */ initError) {\n // That's okay - the dropdown is still functional as a native select\n } finally {\n // Reset the flag after initialization attempt completes\n this._errorStateActive = false;\n }\n }\n }\n\n /**\n * Handle selection change - delegate to mixin\n */\n async onSelectChange(event) {\n await this.handleSelect2Change(event);\n }\n\n /**\n * Validate controller state before DOM manipulation\n * Required by Select2CreateMixin\n */\n _isValidState() {\n return !!(this.element && !this._errorStateActive);\n }\n\n /**\n * Safely manipulate DOM elements\n * Required by Select2CreateMixin\n */\n _safelyManipulateDOM(callback) {\n if (!this.element) return;\n try {\n callback();\n } catch (error) {\n if (this.debug) {\n /* eslint-disable-next-line no-console */\n console.error('[ProductFormBrandDropdown] DOM manipulation failed:', error);\n }\n }\n }\n\n /**\n * Safely manipulate Select2 instance\n * Required by Select2CreateMixin\n */\n _safelyManipulateSelect2(callback) {\n if (!this.isEnhanced() || !this.element) return;\n try {\n callback();\n } catch (error) {\n if (this.debug) {\n /* eslint-disable-next-line no-console */\n console.error('[ProductFormBrandDropdown] Select2 manipulation failed:', error);\n }\n }\n }\n}\n", "import CSRFTokenMixin from '../mixins/csrf_token_mixin';\nimport SidebarRefreshMixin from '../mixins/sidebar_refresh_mixin';\nimport Select2CreateMixin from '../mixins/select2_create_mixin';\n\nimport Select2BaseController from './select2_base_controller';\n\nexport default class extends Select2BaseController {\n // Constants\n static SELECT2_INIT_DELAY_MS = 100; // Time to wait for Select2 initialization\n static LOADING_TIMEOUT_MS = 5000; // Maximum time to wait for category loading\n static MAX_RETRY_ATTEMPTS = 2; // Maximum retry attempts for failed requests\n\n initialize() {\n super.initialize();\n this.loadingTimeout = null;\n this.scheduleTimeout = null;\n this.loadCategoryAbortController = null;\n this.retryCount = 0;\n this.debug = false; // Set to true to enable debug logging\n\n // Atomic state management\n this._state = {\n isConnected: false,\n isDestroyed: false,\n operationInProgress: false,\n errorStateActive: false,\n retryInProgress: false,\n };\n\n // Track all abort controllers for proper cleanup\n this._abortControllers = new Set();\n\n // Track all timeouts for comprehensive cleanup\n this._activeTimeouts = new Set();\n\n // Instance ID for debugging race conditions\n this._instanceId = Math.random().toString(36).substr(2, 9);\n }\n\n // Atomic state management methods\n _updateState(updates) {\n Object.assign(this._state, updates);\n this.logDebug(`State updated [${this._instanceId}]:`, this._state);\n }\n\n _isValidState() {\n return this._state.isConnected &&\n !this._state.isDestroyed &&\n this.element &&\n this.element.isConnected;\n }\n\n _canStartOperation() {\n return this._isValidState() &&\n !this._state.operationInProgress &&\n !this._state.errorStateActive;\n }\n\n // Safe timeout management\n _createTimeout(callback, delay, description = 'timeout') {\n const timeoutId = setTimeout(() => {\n this._activeTimeouts.delete(timeoutId);\n if (this._isValidState()) {\n callback();\n } else {\n this.logDebug(`Skipping ${description} - invalid state`);\n }\n }, delay);\n\n this._activeTimeouts.add(timeoutId);\n this.logDebug(`Created ${description} with ID ${timeoutId}`);\n return timeoutId;\n }\n\n _clearTimeout(timeoutId, description = 'timeout') {\n if (timeoutId) {\n clearTimeout(timeoutId);\n this._activeTimeouts.delete(timeoutId);\n this.logDebug(`Cleared ${description} with ID ${timeoutId}`);\n return null;\n }\n return timeoutId;\n }\n\n _clearAllTimeouts() {\n const timeoutCount = this._activeTimeouts.size;\n this.logDebug(`Clearing ${timeoutCount} active timeouts`);\n\n for (const timeoutId of this._activeTimeouts) {\n clearTimeout(timeoutId);\n }\n this._activeTimeouts.clear();\n\n this.logDebug('All timeouts cleared');\n }\n\n connect() {\n try {\n this.logDebug(`Connect starting [${this._instanceId}]`);\n\n // Atomic state update\n this._updateState({\n isConnected: true,\n isDestroyed: false,\n operationInProgress: false,\n errorStateActive: false,\n retryInProgress: false,\n });\n\n // Cancel any pending operations from previous instances\n this.cancelPendingOperations();\n\n // Check if element exists and is attached to DOM\n if (!this.element || !this.element.isConnected) {\n return;\n }\n\n // Set data attributes before calling parent connect\n this.saleId = this.element.dataset.saleId || '';\n this.productId = this.element.dataset.productId || '';\n this.isAjaxRequest = this.element.dataset.ajaxRequest === 'true';\n\n // Defensive validation: Check required data attributes\n if (!this.productId || !this.saleId) {\n if (this.debug) {\n /* eslint-disable-next-line no-console */\n console.error('[ProductFormCategoryDropdown] Missing required data attributes', {\n productId: this.productId,\n saleId: this.saleId,\n element: this.element,\n });\n }\n this.showErrorState('Configuration error - missing product or sale context');\n return;\n }\n\n // Apply the mixins (CSRFTokenMixin is required by SidebarRefreshMixin and Select2CreateMixin)\n Object.assign(this, CSRFTokenMixin, SidebarRefreshMixin, Select2CreateMixin);\n\n // Initialize Select2 create functionality\n this.initializeSelect2Create({\n resourceName: 'category',\n resourceIdParam: 'category_id',\n contextParams: {\n sale_id: this.saleId,\n product_id: this.productId,\n },\n createEndpoint: '/categories/create_and_assign',\n assignEndpoint: (categoryId) => `/categories/${categoryId}/assign`,\n onAfterCreate: async () => {\n // Refresh sidebar after category creation\n try {\n await this.refreshSidebar({ debug: this.debug });\n } catch {\n // Continue even if sidebar refresh fails\n }\n },\n onAfterAssign: async () => {\n // Refresh sidebar after category assignment\n try {\n await this.refreshSidebar({ debug: this.debug });\n } catch {\n // Continue even if sidebar refresh fails\n }\n },\n });\n\n this.logDebug(`Connected: saleId=${this.saleId}, productId=${this.productId}, isAjax=${this.isAjaxRequest}`);\n\n // Call parent connect to initialize Select2\n super.connect();\n this.logDebug('Parent connect successful');\n\n // For AJAX requests, load current category value after initialization\n if (this.isAjaxRequest && this._isValidState()) {\n this.logDebug('Scheduling loadCurrentCategory for AJAX request');\n this.scheduleLoadCurrentCategory();\n }\n } catch (/* eslint-disable-line no-unused-vars */ error) {\n // Handle error state properly\n this.showErrorState('Error loading categories');\n }\n }\n\n disconnect() {\n this.logDebug(`Disconnect starting [${this._instanceId}]`);\n\n // Atomic state update\n this._updateState({\n isConnected: false,\n isDestroyed: true,\n operationInProgress: false,\n errorStateActive: false,\n retryInProgress: false,\n });\n\n // Cancel all pending operations when disconnecting\n this.cancelPendingOperations();\n\n try {\n super.disconnect();\n } catch (/* eslint-disable-line no-unused-vars */ error) {\n /* ignore disconnect errors */\n }\n\n this.logDebug(`Disconnect completed [${this._instanceId}]`);\n }\n\n cancelPendingOperations() {\n this.logDebug(`Cancelling all pending operations [${this._instanceId}]`);\n\n // Clear all tracked timeouts\n this._clearAllTimeouts();\n\n // Clear individual timeout references\n this.loadingTimeout = this._clearTimeout(this.loadingTimeout, 'loadingTimeout');\n this.scheduleTimeout = this._clearTimeout(this.scheduleTimeout, 'scheduleTimeout');\n\n // Comprehensive abort controller cleanup\n const controllerCount = this._abortControllers.size;\n this.logDebug(`Aborting ${controllerCount} tracked abort controllers`);\n\n // Create array to avoid modification during iteration\n const controllersToAbort = Array.from(this._abortControllers);\n\n for (const controller of controllersToAbort) {\n try {\n if (controller && !controller.signal.aborted) {\n controller.abort();\n this.logDebug('Aborted abort controller');\n }\n } catch (/* eslint-disable-line no-unused-vars */ abortError) {\n /* ignore abort errors */\n }\n }\n\n // Clear the set completely\n this._abortControllers.clear();\n\n // Force clear current abort controller reference\n if (this.loadCategoryAbortController) {\n this.logDebug('Force clearing current abort controller reference');\n this.loadCategoryAbortController = null;\n }\n\n this.logDebug(`Pending operations cleanup completed [${this._instanceId}]`);\n }\n\n scheduleLoadCurrentCategory() {\n // Check if we can start the operation\n if (!this._canStartOperation()) {\n this.logDebug('Skipping schedule - invalid state or operation in progress');\n return;\n }\n\n // Clear any existing timeouts using safe method\n this.loadingTimeout = this._clearTimeout(this.loadingTimeout, 'loadingTimeout');\n this.scheduleTimeout = this._clearTimeout(this.scheduleTimeout, 'scheduleTimeout');\n\n // Set a timeout to handle cases where loading gets stuck\n this.loadingTimeout = this._createTimeout(() => {\n this.handleLoadingTimeout();\n }, this.constructor.LOADING_TIMEOUT_MS, 'loadingTimeout');\n\n // Schedule the actual loading with safe timeout management\n this.scheduleTimeout = this._createTimeout(() => {\n this.loadCurrentCategory();\n }, this.constructor.SELECT2_INIT_DELAY_MS, 'scheduleTimeout');\n }\n\n handleLoadingTimeout() {\n // Check if controller is still valid\n if (!this._isValidState()) {\n return;\n }\n\n this.logDebug(`Handling loading timeout [${this._instanceId}]`);\n\n try {\n // Use robust DOM manipulation\n this._safelyManipulateDOM(() => {\n const firstOption = this.element.querySelector('option');\n if (firstOption && firstOption.textContent === 'Loading...') {\n // Remove all options safely\n this._clearAllOptions();\n\n // Add empty option for placeholder\n const emptyOption = new Option('', '', true, true);\n this.element.appendChild(emptyOption);\n\n // Update placeholder\n this.updatePlaceholder('Select Category');\n }\n });\n } catch (/* eslint-disable-line no-unused-vars */ error) {\n /* ignore timeout handling errors */\n }\n\n // Clear the timeout reference\n this.loadingTimeout = null;\n }\n\n // Safe DOM manipulation helper\n _safelyManipulateDOM(callback) {\n if (!this._isValidState()) {\n throw new Error('Invalid state for DOM manipulation');\n }\n\n // Check element is still attached before and after operation\n if (!this.element.isConnected) {\n throw new Error('Element not connected to DOM');\n }\n\n callback();\n\n // Verify element is still connected after operation\n if (!this.element.isConnected) {\n throw new Error('Element became disconnected during operation');\n }\n }\n\n // Safe option clearing helper with comprehensive validation\n _clearAllOptions() {\n let iterationCount = 0;\n const maxIterations = 1000; // Safety limit to prevent infinite loops\n\n while (this.element.firstChild && this.element.isConnected && iterationCount < maxIterations) {\n // Validate state before each removal\n if (!this._isValidState()) {\n this.logDebug('State became invalid during option clearing, stopping');\n break;\n }\n\n // Store reference to child before removal\n const childToRemove = this.element.firstChild;\n\n // Verify child still exists and is attached\n if (!childToRemove || !childToRemove.parentNode) {\n this.logDebug('Child became detached during clearing, stopping');\n break;\n }\n\n try {\n this.element.removeChild(childToRemove);\n iterationCount++;\n } catch (removeError) {\n this.logDebug('Error removing child during clearing:', removeError);\n break;\n }\n\n // Check if element is still connected after removal\n if (!this.element.isConnected) {\n this.logDebug('Element became disconnected during option clearing');\n break;\n }\n }\n\n if (iterationCount >= maxIterations) {\n this.logDebug('Maximum iterations reached during option clearing - potential infinite loop prevented');\n }\n\n this.logDebug(`Cleared ${iterationCount} options safely`);\n }\n\n /**\n * Provide custom Select2 options\n */\n getEnhancementOptions() {\n // Ensure we have values when this is called\n const saleId = this.saleId || this.element.dataset.saleId || '';\n const productId = this.productId || this.element.dataset.productId || '';\n\n return {\n tags: true, // REQUIRED for create functionality\n width: '100%',\n placeholder: 'Select Category',\n allowClear: true,\n ajax: {\n url: `/categories?sale_id=${saleId}&product_id=${productId}`,\n dataType: 'json',\n delay: 250,\n data: function(params) {\n return {\n q: params.term || '',\n };\n },\n processResults: function(data) {\n return {\n results: data.categories.map(category => ({\n id: category.id,\n text: category.name,\n })),\n };\n },\n cache: true,\n },\n minimumInputLength: 0,\n templateSelection: function (data) {\n return data.text;\n },\n // Use mixin handlers for tag creation\n createTag: this.createTagHandler,\n insertTag: this.insertTagHandler,\n };\n }\n\n /**\n * Override setupEnhancedEvents to add search term tracking from mixin\n */\n setupEnhancedEvents() {\n // Call parent implementation first\n super.setupEnhancedEvents();\n\n // Setup Select2 create event tracking from mixin\n this.setupSelect2CreateEvents();\n }\n\n\n /**\n * Load current category for AJAX requests\n * This fetches the current product's category and sets it as selected\n */\n async loadCurrentCategory() {\n // Check if operation can proceed\n if (!this._canStartOperation()) {\n this.logDebug('Skipping loadCurrentCategory - invalid state');\n return;\n }\n\n // Atomic state update - set operation in progress\n this._updateState({ operationInProgress: true });\n\n try {\n this.logDebug(`Loading current category for product ${this.productId} in sale ${this.saleId}`);\n\n // Cancel any existing request\n if (this.loadCategoryAbortController) {\n this._abortControllers.delete(this.loadCategoryAbortController);\n this.loadCategoryAbortController.abort();\n this.loadCategoryAbortController = null;\n }\n\n // Create new abort controller for this request\n this.loadCategoryAbortController = new AbortController();\n this._abortControllers.add(this.loadCategoryAbortController);\n\n const url = `/categories/current?product_id=${this.productId}&sale_id=${this.saleId}`;\n this.logDebug(`Fetching from: ${url}`);\n\n const response = await fetch(url, {\n headers: {\n 'Accept': 'application/json',\n 'X-Requested-With': 'XMLHttpRequest',\n },\n signal: this.loadCategoryAbortController.signal,\n });\n\n // Clear the loading timeout since we got a response\n if (this.loadingTimeout) {\n clearTimeout(this.loadingTimeout);\n this.loadingTimeout = null;\n }\n\n if (response.ok) {\n const data = await response.json();\n this.logDebug('Response data:', data);\n\n // Check if still valid before manipulating DOM\n if (!this._isValidState()) {\n this.logDebug('Controller invalid during request, skipping DOM updates');\n return;\n }\n\n if (data.category) {\n // Add the current category as an option and select it using safe DOM manipulation\n this._safelyManipulateDOM(() => {\n const option = new Option(data.category.name, data.category.id, true, true);\n this.element.appendChild(option);\n\n // Trigger change event for Select2 with validation\n if (this.isEnhanced() && this._isValidState()) {\n this._safelyManipulateSelect2(() => {\n this.getEnhancer()(this.element).trigger('change');\n });\n }\n this.logDebug(`Set category: ${data.category.name}`);\n });\n // Track initial value for skip-redundant-assign logic\n this.setInitialValueId(data.category.id);\n } else {\n // No category assigned, clear the dropdown and show placeholder\n this._safelyManipulateDOM(() => {\n // Remove the \"Loading...\" option safely\n this._clearAllOptions();\n\n // Add an empty option for the placeholder\n const emptyOption = new Option('', '', true, true);\n this.element.appendChild(emptyOption);\n\n // Update the placeholder\n this.updatePlaceholder('Select Category');\n this.logDebug('No category assigned');\n });\n }\n\n // Reset retry count on success\n this.retryCount = 0;\n } else {\n await this.handleLoadError(new Error(`HTTP ${response.status}`));\n }\n } catch (error) {\n // Don't handle abort errors\n if (error.name === 'AbortError') {\n this.logDebug('Request was aborted');\n return;\n }\n\n\n // Only handle error if still valid\n if (this._isValidState()) {\n await this.handleLoadError(error);\n }\n } finally {\n // Clean up abort controller\n if (this.loadCategoryAbortController) {\n this._abortControllers.delete(this.loadCategoryAbortController);\n this.loadCategoryAbortController = null;\n }\n\n // Atomic state update - clear operation in progress flag\n this._updateState({ operationInProgress: false });\n }\n }\n\n // Safe Select2 manipulation helper\n _safelyManipulateSelect2(callback) {\n if (!this._isValidState()) {\n throw new Error('Invalid state for Select2 manipulation');\n }\n\n if (!this.isEnhanced()) {\n throw new Error('Select2 not enhanced');\n }\n\n callback();\n }\n\n async handleLoadError( _error) {\n // Don't handle errors if not valid or already retrying\n if (!this._isValidState() || this._state.retryInProgress) {\n return;\n }\n\n // Check if we should retry\n if (this.retryCount < this.constructor.MAX_RETRY_ATTEMPTS) {\n this.retryCount++;\n this.logDebug(`Retrying load (attempt ${this.retryCount}/${this.constructor.MAX_RETRY_ATTEMPTS})`);\n\n // Atomic state update - mark retry in progress\n this._updateState({ retryInProgress: true, operationInProgress: false });\n\n // Circuit breaker pattern - wait with exponential backoff\n try {\n await this._delayWithValidation(500 * this.retryCount);\n\n // Check once more before retrying\n if (this._canStartOperation()) {\n this._updateState({ retryInProgress: false });\n return this.loadCurrentCategory();\n } else {\n this.logDebug('Cannot retry - invalid state');\n }\n } catch (/* eslint-disable-line no-unused-vars */ _retryError) {\n this.logDebug('Retry cancelled - controller disconnected');\n } finally {\n this._updateState({ retryInProgress: false });\n }\n return;\n }\n\n // Max retries reached\n this.logDebug('Max retries reached, giving up');\n\n // Clear the loading timeout\n if (this.loadingTimeout) {\n clearTimeout(this.loadingTimeout);\n this.loadingTimeout = null;\n }\n\n // For all error types, show \"Select Category\" as the dropdown remains functional\n this.updatePlaceholder('Select Category');\n }\n\n // Delay helper that validates state during wait with comprehensive tracking\n async _delayWithValidation(ms) {\n return new Promise((resolve, reject) => {\n let resolved = false;\n\n const timeoutId = this._createTimeout(() => {\n if (!resolved) {\n resolved = true;\n if (this._isValidState()) {\n resolve();\n } else {\n reject(new Error('Controller became invalid during delay'));\n }\n }\n }, ms, `delayValidation-${ms}ms`);\n\n // Create tracked interval for validation checking\n const checkInterval = setInterval(() => {\n if (!this._isValidState() && !resolved) {\n resolved = true;\n clearInterval(checkInterval);\n this._clearTimeout(timeoutId, `delayValidation-${ms}ms`);\n reject(new Error('Controller became invalid during delay'));\n }\n }, 100);\n\n // Auto-cleanup interval when delay completes (backup safety)\n const _cleanupTimeoutId = this._createTimeout(() => {\n clearInterval(checkInterval);\n }, ms + 100, `delayCleanup-${ms}ms`);\n });\n }\n\n updatePlaceholder(text) {\n // Check if controller and element are still valid\n if (!this._isValidState()) {\n return;\n }\n\n try {\n this._safelyManipulateDOM(() => {\n const firstOption = this.element.querySelector('option');\n if (firstOption) {\n firstOption.textContent = text;\n firstOption.value = '';\n\n // If Select2 is enhanced, update it safely\n if (this.isEnhanced() && this._isValidState()) {\n this._safelyManipulateSelect2(() => {\n // Set the value to empty string to show placeholder\n this.getEnhancer()(this.element).val('').trigger('change');\n\n // Also update Select2's placeholder text if needed\n const select2Container = this.element.nextElementSibling;\n if (select2Container && select2Container.classList.contains('select2-container')) {\n const placeholderElement = select2Container.querySelector('.select2-selection__placeholder');\n if (placeholderElement) {\n placeholderElement.textContent = text;\n }\n }\n });\n }\n }\n });\n } catch (/* eslint-disable-line no-unused-vars */ error) {\n /* ignore placeholder update errors */\n }\n }\n\n showErrorState(errorMessage) {\n // Check if controller and element are still valid\n if (!this._isValidState()) {\n return;\n }\n\n // True atomic operation: check-and-set in one operation\n if (this._state.errorStateActive) {\n this.logDebug('Error state already active, skipping concurrent operation');\n return;\n }\n\n // Set flag immediately and track success\n this._updateState({ errorStateActive: true });\n let operationSuccessful = false;\n\n try {\n this._safelyManipulateDOM(() => {\n // Disable the element first to ensure consistent state\n this.element.disabled = true;\n\n // Try to destroy Select2 if it exists - fail gracefully\n if (this.isEnhanced()) {\n try {\n this._safelyManipulateSelect2(() => {\n this.getEnhancer()(this.element).select2('destroy');\n });\n } catch (destroyError) {\n this.logDebug('Select2 destroy failed during error state:', destroyError);\n // Continue anyway - destruction failure shouldn't block error display\n }\n }\n\n // Update the HTML with error message\n this.element.innerHTML = ``;\n\n // Try to reinitialize Select2 - fail gracefully\n if (this.isSelect2Available() && this._isValidState()) {\n try {\n this._safelyManipulateSelect2(() => {\n this.getEnhancer()(this.element).select2({\n width: '100%',\n disabled: true,\n placeholder: errorMessage,\n });\n });\n } catch (initError) {\n this.logDebug('Select2 init failed during error state:', initError);\n // That's okay - the dropdown is still functional as a native select\n }\n }\n });\n\n operationSuccessful = true;\n } catch (/* eslint-disable-line no-unused-vars */ error) {\n operationSuccessful = false;\n } finally {\n // Only reset flag if operation was successful or if we're disconnected\n if (operationSuccessful || !this._isValidState()) {\n this._updateState({ errorStateActive: false });\n } else {\n // Keep flag set if operation failed and we're still connected\n this.logDebug('Error state operation failed, keeping flag active for retry');\n }\n }\n }\n\n /**\n * Handle selection change - delegate to mixin\n */\n async onSelectChange(event) {\n await this.handleSelect2Change(event);\n }\n\n\n /**\n * Logs debug messages when debug mode is enabled\n * @param {string} message - The message to log\n */\n logDebug( message, /* eslint-disable-line no-unused-vars */ ...args) {\n if (this.debug) {\n // Debug logging disabled for production\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n connect() {\n // Dirty state tracking\n this.hasUnsavedChanges = false;\n\n // Store bound handlers for cleanup\n this.boundHandleSuccess = this.handleSuccess.bind(this);\n this.boundHandleError = this.handleError.bind(this);\n this.boundHandleSaved = this.handleSaved.bind(this);\n this.boundMarkAsDirty = this.markAsDirty.bind(this);\n this.boundHandleLinkClick = this.handleLinkClick.bind(this);\n\n // Find the form element within this controller's element\n this.form = this.element.querySelector('form') || this.element;\n\n // Listen for ajax:success and ajax:error events on the form\n this.form.addEventListener('ajax:success', this.boundHandleSuccess);\n this.form.addEventListener('ajax:error', this.boundHandleError);\n // Listen for custom save event from update.js.erb (more reliable than ajax:success)\n this.form.addEventListener('product:saved', this.boundHandleSaved);\n\n // Setup dirty state tracking\n this.setupDirtyTracking();\n this.setupBeforeUnload();\n this.setupLinkInterception();\n }\n\n disconnect() {\n // Clean up event listeners\n this.form.removeEventListener('ajax:success', this.boundHandleSuccess);\n this.form.removeEventListener('ajax:error', this.boundHandleError);\n this.form.removeEventListener('product:saved', this.boundHandleSaved);\n\n // Clean up beforeunload listener\n if (this.boundBeforeUnload) {\n window.removeEventListener('beforeunload', this.boundBeforeUnload);\n }\n\n // Clean up link interception\n document.removeEventListener('click', this.boundHandleLinkClick);\n\n // Clean up dirty tracking listeners\n this.cleanupDirtyTracking();\n }\n\n // ============================================================================\n // DIRTY STATE TRACKING\n // ============================================================================\n\n setupDirtyTracking() {\n const self = this;\n\n // Store references for cleanup\n this.trackedInputs = [];\n this.documentListeners = [];\n\n // Track standard inputs within the form\n const inputs = this.form.querySelectorAll('input, select, textarea');\n for (let i = 0; i < inputs.length; i++) {\n const input = inputs[i];\n // Skip hidden fields that are not user-editable (except Trix-associated hidden fields)\n if (input.type === 'hidden' && !input.name.includes('description')) {\n continue;\n }\n // Skip availability toggle (uses separate PATCH endpoint)\n if (input.name && input.name.includes('available')) {\n continue;\n }\n input.addEventListener('change', this.boundMarkAsDirty);\n input.addEventListener('input', this.boundMarkAsDirty);\n this.trackedInputs.push(input);\n }\n\n // Track Trix editor changes (description field)\n const trixChangeHandler = function(e) {\n if (self.form.contains(e.target)) {\n self.markAsDirty();\n }\n };\n document.addEventListener('trix-change', trixChangeHandler);\n this.documentListeners.push({ event: 'trix-change', handler: trixChangeHandler });\n\n // Track Select2 changes (brand/category dropdowns fire 'change' on hidden input)\n // This is already covered by the change listener above\n }\n\n cleanupDirtyTracking() {\n // Remove input listeners\n if (this.trackedInputs) {\n for (let i = 0; i < this.trackedInputs.length; i++) {\n this.trackedInputs[i].removeEventListener('change', this.boundMarkAsDirty);\n this.trackedInputs[i].removeEventListener('input', this.boundMarkAsDirty);\n }\n this.trackedInputs = [];\n }\n\n // Remove document listeners\n if (this.documentListeners) {\n for (let j = 0; j < this.documentListeners.length; j++) {\n const listener = this.documentListeners[j];\n document.removeEventListener(listener.event, listener.handler);\n }\n this.documentListeners = [];\n }\n }\n\n markAsDirty() {\n this.hasUnsavedChanges = true;\n }\n\n resetDirtyState() {\n this.hasUnsavedChanges = false;\n }\n\n hasDirtyState() {\n return this.hasUnsavedChanges;\n }\n\n // Handler for custom product:saved event from update.js.erb\n handleSaved() {\n this.resetDirtyState();\n }\n\n // ============================================================================\n // BEFOREUNLOAD HANDLER (Browser tab close / external navigation)\n // ============================================================================\n\n setupBeforeUnload() {\n const self = this;\n this.boundBeforeUnload = function(e) {\n if (self.hasDirtyState()) {\n e.preventDefault();\n e.returnValue = ''; // Required for Chrome\n return '';\n }\n };\n window.addEventListener('beforeunload', this.boundBeforeUnload);\n }\n\n // ============================================================================\n // LINK INTERCEPTION (In-page navigation)\n // ============================================================================\n\n setupLinkInterception() {\n document.addEventListener('click', this.boundHandleLinkClick);\n }\n\n handleLinkClick(e) {\n const link = e.target.closest('a[href]');\n if (!link) return;\n\n // Skip external links, anchors, and javascript: links\n const href = link.getAttribute('href');\n if (!href || href.startsWith('#') || href.startsWith('javascript:')) return;\n\n // Skip links with target=\"_blank\"\n if (link.target === '_blank') return;\n\n // Skip links within dropzone or upload areas (they save immediately)\n if (link.closest('.dropzone') || link.closest('[data-controller*=\"dropzone\"]')) return;\n\n // Skip delete/remove links (they trigger immediate server action)\n if (link.classList.contains('remove-icon')) return;\n\n // Skip product sidebar navigation links (handled separately)\n if (link.closest('[data-controller*=\"product-sidebar\"]') || link.closest('[data-controller*=\"product-navigation\"]')) {\n // These will trigger navigation to another product - warn user\n }\n\n if (this.hasDirtyState()) {\n e.preventDefault();\n const self = this;\n const pendingHref = href;\n this.showUnsavedChangesModal().then(function(choice) {\n if (choice === 'discard') {\n self.resetDirtyState();\n window.location.href = pendingHref;\n } else if (choice === 'save') {\n self.pendingNavigationHref = pendingHref;\n self.submitFormAndNavigate();\n }\n });\n }\n }\n\n // ============================================================================\n // SAVE AND NAVIGATE (Submit form then navigate after success)\n // ============================================================================\n\n submitFormAndNavigate() {\n const self = this;\n\n // Create one-time listener for successful save\n const savedHandler = function() {\n self.form.removeEventListener('product:saved', savedHandler);\n // Navigate after successful save\n if (self.pendingNavigationHref) {\n window.location.href = self.pendingNavigationHref;\n self.pendingNavigationHref = null;\n }\n };\n\n this.form.addEventListener('product:saved', savedHandler);\n\n // Trigger form submission via Rails UJS\n if (window.Rails && window.Rails.fire) {\n window.Rails.fire(this.form, 'submit');\n } else {\n // Fallback: dispatch submit event\n this.form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));\n }\n }\n\n // ============================================================================\n // UNSAVED CHANGES MODAL (Following branding form pattern)\n // ============================================================================\n\n showUnsavedChangesModal() {\n const self = this;\n return new Promise(function(resolve) {\n // Create overlay (max z-index for visibility)\n const overlay = document.createElement('div');\n overlay.style.cssText = 'position: fixed; top: 0; left: 0; right: 0; bottom: 0; ' +\n 'background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; ' +\n 'justify-content: center; z-index: 2147483647;';\n\n // Create dialog\n const dialog = document.createElement('div');\n dialog.style.cssText = 'background: white; padding: 24px; border-radius: 8px; ' +\n 'max-width: 400px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);';\n\n const heading = document.createElement('h3');\n heading.style.cssText = 'margin: 0 0 12px 0; font-size: 18px; color: #333;';\n heading.textContent = 'Unsaved Changes';\n\n const message = document.createElement('p');\n message.style.cssText = 'margin: 0 0 20px 0; color: #666;';\n message.textContent = 'You have unsaved product changes. Are you sure you want to leave without saving?';\n\n const buttonContainer = document.createElement('div');\n buttonContainer.style.cssText = 'display: flex; gap: 8px; justify-content: flex-end;';\n\n const cancelBtn = document.createElement('button');\n cancelBtn.type = 'button';\n cancelBtn.style.cssText = 'padding: 8px 16px; border: 1px solid #ccc; background: #f5f5f5; ' +\n 'color: #333; border-radius: 4px; cursor: pointer; font-size: 14px;';\n cancelBtn.textContent = 'Stay on Page';\n cancelBtn.dataset.choice = 'cancel';\n\n const saveBtn = document.createElement('button');\n saveBtn.type = 'button';\n saveBtn.style.cssText = 'padding: 8px 16px; border: none; background: #27ae60; ' +\n 'color: white; border-radius: 4px; cursor: pointer; font-size: 14px;';\n saveBtn.textContent = 'Save';\n saveBtn.dataset.choice = 'save';\n\n const discardBtn = document.createElement('button');\n discardBtn.type = 'button';\n discardBtn.style.cssText = 'padding: 8px 16px; border: none; background: #c0392b; ' +\n 'color: white; border-radius: 4px; cursor: pointer; font-size: 14px;';\n discardBtn.textContent = 'Leave Page';\n discardBtn.dataset.choice = 'discard';\n\n buttonContainer.appendChild(cancelBtn);\n buttonContainer.appendChild(saveBtn);\n buttonContainer.appendChild(discardBtn);\n dialog.appendChild(heading);\n dialog.appendChild(message);\n dialog.appendChild(buttonContainer);\n overlay.appendChild(dialog);\n document.body.appendChild(overlay);\n\n // Store reference for potential cleanup\n self.pendingDialogOverlay = overlay;\n\n const cleanup = function(action) {\n document.removeEventListener('keydown', escHandler);\n overlay.remove();\n self.pendingDialogOverlay = null;\n resolve(action);\n };\n\n const escHandler = function(e) {\n if (e.key === 'Escape') {\n e.preventDefault();\n cleanup('cancel');\n }\n };\n document.addEventListener('keydown', escHandler);\n\n overlay.addEventListener('click', function(e) {\n if (e.target === overlay) {\n cleanup('cancel');\n }\n });\n\n dialog.addEventListener('click', function(e) {\n e.stopPropagation();\n const action = e.target.dataset.choice;\n if (action) {\n cleanup(action);\n }\n });\n });\n }\n\n // ============================================================================\n // FORM SUBMISSION HANDLERS\n // ============================================================================\n\n handleSuccess(event) {\n // For Rails UJS with remote: true and JS format responses,\n // the ajax:success event fires but event.detail might be empty or contain the JS response\n if (!event.detail || event.detail.length === 0) {\n // No data means the JS response was executed server-side\n // Still reset dirty state on successful save\n this.resetDirtyState();\n return;\n }\n\n const detail = event.detail;\n const _data = detail[0];\n const xhr = detail[2];\n\n // Check if this is a JS response (not JSON)\n const contentType = xhr && xhr.getResponseHeader && xhr.getResponseHeader('Content-Type');\n if (contentType && contentType.includes('text/javascript')) {\n // JS response will handle its own flash display via update.js.erb\n // Still reset dirty state on successful save\n this.resetDirtyState();\n return;\n }\n\n // Only handle JSON responses here\n // Reset dirty state after successful save\n this.resetDirtyState();\n }\n\n handleError() {\n // Note: Do NOT reset dirty state on error - user still has unsaved changes\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n static targets = [\n 'teamPrice', 'cog', 'salePrice', 'profit',\n 'percentage', 'percentageProfit', 'percentageSalePrice', 'percentagePrice',\n 'percentageBaseIndicator',\n 'shippingCost', 'shippingCostField', 'shippingCostLink',\n ];\n\n get isPercentageMode() {\n return this.hasPercentageTarget && this.percentageTarget.value === 'true';\n }\n\n connect() {\n // Initialize AbortController for race condition prevention\n this.abortController = new AbortController();\n\n // Update the appropriate labels based on current pricing mode\n if (this.isPercentageMode) {\n this.updatePercentageProfit();\n this.updatePercentageSalePrice();\n this.updatePercentageBaseIndicator();\n } else {\n this.updateProfit();\n }\n }\n\n disconnect() {\n // Clean up any pending operations to prevent race conditions\n if (this.abortController) {\n this.abortController.abort();\n }\n }\n\n keydown(event) {\n // Prevent form submission when Enter is pressed in pricing fields\n if (event.key === 'Enter') {\n event.preventDefault();\n event.target.blur(); // Remove focus from the field\n }\n }\n\n updateProfit() {\n if (this.hasProfitTarget) {\n this.profitTarget.innerHTML = `Profit: $${this.getProfit().toFixed(2)}`;\n }\n }\n\n updatePercentageProfit() {\n if (this.hasPercentageProfitTarget) {\n this.percentageProfitTarget.innerHTML = `Profit: $${this.getPercentageProfit().toFixed(2)}`;\n }\n }\n\n updatePercentageSalePrice() {\n if (this.hasPercentageSalePriceTarget) {\n this.percentageSalePriceTarget.innerHTML = `Sale Price: $${this.getPercentageSalePrice().toFixed(2)}`;\n }\n }\n\n updatePercentageBaseIndicator() {\n if (this.hasPercentageBaseIndicatorTarget) {\n const teamPrice = this.getTeamPrice();\n if (teamPrice > 0) {\n this.percentageBaseIndicatorTarget.innerHTML = 'Based on Team Price';\n } else {\n this.percentageBaseIndicatorTarget.innerHTML = 'Based on COG';\n }\n }\n }\n\n getProfit() {\n return this.getSalePrice() - this.getTeamPrice();\n }\n\n getSalePrice() {\n return parseFloat(this.salePriceTarget.value || 0);\n }\n\n getPercentagePrice() {\n return parseFloat(this.percentagePriceTarget.value || 0);\n }\n\n getCostPrice() {\n return parseFloat(this.cogTarget.value || 0);\n }\n\n getTeamPrice() {\n return parseFloat(this.teamPriceTarget.value || 0);\n }\n\n getPercentageProfit() {\n return this.getPercentageSalePrice() - this.getTeamPrice();\n }\n\n getPercentageSalePrice() {\n const teamPrice = this.getTeamPrice();\n const costPrice = this.getCostPrice();\n const percentage = this.getPercentagePrice();\n\n if (teamPrice > 0) {\n return teamPrice * (1 + (percentage / 100));\n } else {\n return costPrice * (1 + (percentage / 100));\n }\n }\n\n getPercentage() {\n return parseFloat(this.percentageTarget.value || 0);\n }\n\n clickDollar(event) {\n event.preventDefault();\n\n const dollarSection = document.querySelector('.sale-price-dollar');\n const percentageSection = document.querySelector('.sale-price-percentage');\n const pricePercentageInput = document.getElementById('details-price-percentage');\n\n if (dollarSection) {\n dollarSection.classList.add('ae-hidden');\n }\n if (percentageSection) {\n percentageSection.classList.remove('ae-hidden');\n }\n if (pricePercentageInput) {\n pricePercentageInput.value = 'true';\n }\n this.markFormDirty();\n this.autoSave();\n\n this.updatePercentageProfit();\n this.updatePercentageSalePrice();\n this.updatePercentageBaseIndicator();\n }\n\n clickPercentage(event) {\n event.preventDefault();\n\n const percentageSection = document.querySelector('.sale-price-percentage');\n const dollarSection = document.querySelector('.sale-price-dollar');\n const pricePercentageInput = document.getElementById('details-price-percentage');\n\n if (percentageSection) {\n percentageSection.classList.add('ae-hidden');\n }\n if (dollarSection) {\n dollarSection.classList.remove('ae-hidden');\n }\n if (pricePercentageInput) {\n pricePercentageInput.value = '';\n }\n this.markFormDirty();\n this.autoSave();\n\n this.updateProfit();\n }\n\n markFormDirty() {\n const el = document.querySelector('[data-controller*=\"product-form-dirty-tracker\"]');\n if (el) {\n const tracker = this.application.getControllerForElementAndIdentifier(el, 'product-form-dirty-tracker');\n if (tracker) tracker.markAsDirty();\n }\n }\n\n autoSave() {\n const el = document.querySelector('[data-controller*=\"sale-product-details-product-actions\"]');\n if (el) {\n const actions = this.application.getControllerForElementAndIdentifier(el, 'sale-product-details-product-actions');\n if (actions && actions.debouncedAutoSave) actions.debouncedAutoSave();\n }\n }\n\n limitValueToTwoDecimalPlaces(inputValue) {\n if (inputValue === '') {\n return inputValue;\n }\n\n const splitValue = inputValue.split('.');\n\n if (splitValue.length < 2) {\n return parseFloat(inputValue).toFixed(2);\n } else {\n splitValue[1] = splitValue[1].substring(0, 2);\n return splitValue.join('.');\n }\n }\n\n blur(_event) {\n if (this.isPercentageMode) {\n this.updatePercentageProfit();\n this.updatePercentageSalePrice();\n this.updatePercentageBaseIndicator();\n } else {\n this.updateProfit();\n }\n }\n\n change(event) {\n const inputValue = event.target.value;\n\n event.target.value = this.limitValueToTwoDecimalPlaces(inputValue);\n\n // Use more robust percentage mode detection\n if (this.isPercentageMode) {\n this.updatePercentageProfit();\n this.updatePercentageSalePrice();\n this.updatePercentageBaseIndicator();\n } else {\n this.updateProfit();\n }\n }\n\n showShippingCost(event) {\n event.preventDefault();\n\n // Use Stimulus targets for reliable DOM access\n if (this.hasShippingCostLinkTarget) {\n this.shippingCostLinkTarget.classList.add('ae-hidden');\n }\n\n if (this.hasShippingCostFieldTarget) {\n this.shippingCostFieldTarget.classList.remove('ae-hidden');\n }\n\n // Focus on the input\n if (this.hasShippingCostTarget) {\n this.shippingCostTarget.focus();\n }\n }\n\n hideShippingCost(event) {\n event.preventDefault();\n\n // Clear the shipping cost value\n if (this.hasShippingCostTarget) {\n this.shippingCostTarget.value = '';\n }\n\n // Use Stimulus targets for reliable DOM access\n if (this.hasShippingCostFieldTarget) {\n this.shippingCostFieldTarget.classList.add('ae-hidden');\n }\n\n if (this.hasShippingCostLinkTarget) {\n this.shippingCostLinkTarget.classList.remove('ae-hidden');\n }\n }\n}\n", "import { Controller } from '../stimulus';\nimport CSRFTokenMixin from '../mixins/csrf_token_mixin';\nimport NotificationMixin from '../mixins/notification_mixin';\n\nimport AjaxPopupController from './ajax_popup_controller';\n\nexport default class extends Controller {\n initialize() {\n // Apply CSRF mixin for secure requests\n Object.assign(this, CSRFTokenMixin);\n // Apply notification mixin for secondary notifications\n Object.assign(this, NotificationMixin);\n\n this.debug = false; // Set via data attribute if needed\n this.saleId = this.element.dataset.saleId;\n this.productId = this.element.dataset.productId;\n this.colorVariantId = this.element.dataset.colorVariantId;\n this.imageId = this.element.dataset.imageId;\n }\n\n editColor(e) {\n e.preventDefault();\n e.stopPropagation();\n const url = `/teamsites/${this.saleId}/colors/${this.colorVariantId}/edit?product_id=${this.productId}`;\n AjaxPopupController.open(url);\n }\n\n editSize(e) {\n e.preventDefault();\n e.stopPropagation();\n const url = `/teamsites/${this.saleId}/colors/${this.colorVariantId}/sizes?product_id=${this.productId}`;\n AjaxPopupController.open(url);\n }\n\n deleteImage(e) {\n e.preventDefault();\n e.stopPropagation();\n\n if (!confirm('Are you sure you wish to delete this color?')) return;\n\n const url = `/teamsites/${this.saleId}/colors/${this.colorVariantId}?product_id=${this.productId}`;\n\n fetch(url, {\n method: 'DELETE',\n headers: {\n 'X-CSRF-Token': document.querySelector('meta[name=\"csrf-token\"]').content,\n 'Content-Type': 'application/json',\n },\n })\n .then(response => {\n if (response.ok) {\n return response.json();\n }\n throw new Error('Delete failed');\n })\n .then(data => {\n if (window.App && typeof window.App.showSuccessFlash === 'function') {\n window.App.showSuccessFlash(data.message || 'Color deleted successfully');\n }\n })\n .catch(error => {\n this.logDebug('Error deleting color:', error);\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash('Something went wrong');\n } else {\n this.logDebug('Something went wrong');\n }\n });\n }\n\n setAsDefault(e) {\n e.preventDefault();\n e.stopPropagation();\n\n // Debug log to check values\n this.logDebug('setAsDefault called with:', {\n saleId: this.saleId,\n colorVariantId: this.colorVariantId,\n productId: this.productId,\n });\n\n // Validate required data\n if (!this.saleId || !this.colorVariantId) {\n this.logDebug('Missing required data for setAsDefault:', {\n saleId: this.saleId,\n colorVariantId: this.colorVariantId,\n });\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash('Unable to set default color: missing data');\n }\n return;\n }\n\n // Construct the URL using the controller's data attributes\n const url = `/teamsites/${this.saleId}/colors/${this.colorVariantId}/set_as_default`;\n\n // Show loading state on the image container\n const imageContainer = this.element.closest('.image-container');\n if (imageContainer) {\n imageContainer.classList.add('is-loading');\n }\n\n // Close dropdown\n document.body.click();\n\n // Show immediate feedback that action is processing\n this.showNotification('Setting as default color...', 'info', {\n duration: 2000,\n position: 'top-right',\n });\n\n fetch(url, {\n method: 'PATCH',\n headers: {\n 'X-CSRF-Token': document.querySelector('meta[name=\"csrf-token\"]').content,\n 'Content-Type': 'application/json',\n },\n })\n .then(response => {\n if (response.ok) {\n return response.json();\n }\n throw new Error('Set as default failed');\n })\n .then(data => {\n if (window.App && typeof window.App.showSuccessFlash === 'function') {\n window.App.showSuccessFlash(data.message || 'Changes saved');\n }\n\n // Update sidebar thumbnail immediately if URL is provided\n if (data.thumbnail_url && data.product_id) {\n this.updateSidebarThumbnail(data.product_id, data.thumbnail_url);\n }\n\n // Trigger gallery refresh\n this.triggerGalleryRefresh();\n })\n .catch(error => {\n this.logDebug('Error setting as default:', error);\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash('Something went wrong');\n }\n })\n .finally(() => {\n // Remove loading state\n if (imageContainer) {\n imageContainer.classList.remove('is-loading');\n }\n });\n }\n\n /**\n * Updates the sidebar thumbnail for a specific product\n */\n updateSidebarThumbnail(productId, thumbnailUrl) {\n const thumbnailContainer = document.getElementById(\n `sidebar-product-display-image-container-${productId}`,\n );\n\n if (!thumbnailContainer) {\n this.logDebug('Sidebar thumbnail container not found for product:', productId);\n return;\n }\n\n const thumbnailImg = thumbnailContainer.querySelector('img');\n if (thumbnailImg) {\n // Store old src for potential rollback\n const oldSrc = thumbnailImg.src;\n\n // Update to new thumbnail\n thumbnailImg.src = thumbnailUrl;\n\n // Handle image load error\n thumbnailImg.onerror = () => {\n this.logDebug('Failed to load new thumbnail, reverting to old image');\n thumbnailImg.src = oldSrc;\n };\n\n this.logDebug('Updated sidebar thumbnail for product:', productId);\n }\n }\n\n /**\n * Triggers gallery refresh with fallback mechanisms\n */\n triggerGalleryRefresh() {\n // Try event-based refresh first\n const galleryElement = document.querySelector('[data-controller*=\"product-gallery-refresh\"]');\n if (galleryElement) {\n const event = new CustomEvent('product-gallery-refresh', {\n detail: { productId: this.productId },\n });\n galleryElement.dispatchEvent(event);\n\n // Verify refresh happens within reasonable time\n this.verifyGalleryRefresh();\n } else {\n this.logDebug('Gallery refresh controller not found, attempting direct refresh');\n this.directGalleryRefresh();\n }\n }\n\n /**\n * Verifies gallery refresh completed, implements fallback if needed\n */\n async verifyGalleryRefresh() {\n // Wait a moment for refresh to complete\n await new Promise(resolve => setTimeout(resolve, 3000));\n\n // Check if gallery still shows old state (simplified check)\n // In production, you'd want a more robust verification\n const galleryElement = document.querySelector('[data-controller*=\"product-gallery-refresh\"]');\n if (galleryElement && !galleryElement.dataset.refreshCompleted) {\n this.logDebug('Gallery refresh may have failed, attempting direct refresh');\n this.directGalleryRefresh();\n }\n }\n\n /**\n * Direct gallery refresh as fallback\n */\n async directGalleryRefresh() {\n try {\n const url = `/teamsites/${this.saleId}/products/${this.productId}/gallery`;\n const response = await fetch(url, {\n headers: this.getHeadersWithCSRF({\n 'Accept': 'text/html',\n 'X-Requested-With': 'XMLHttpRequest',\n }),\n credentials: 'same-origin',\n });\n\n if (response.ok) {\n const html = await response.text();\n const galleryContainer = document.querySelector('.product-gallery');\n if (galleryContainer) {\n galleryContainer.outerHTML = html;\n this.logDebug('Direct gallery refresh completed');\n }\n }\n } catch (error) {\n this.logDebug('Direct gallery refresh failed:', error);\n // As absolute last resort, reload page after showing message\n if (confirm('Gallery refresh failed. Would you like to reload the page?')) {\n window.location.reload();\n }\n }\n }\n\n /**\n * Logs debug messages when debug mode is enabled\n */\n logDebug(_message, ..._args) {\n if (this.debug) {\n // Debug logging removed for production compliance\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\nexport default class extends Controller {\n preview(event) {\n event.preventDefault();\n event.stopPropagation();\n\n var largeUrl = this.element.dataset.largeUrl || this.element.dataset.originalUrl;\n if (!largeUrl) return;\n\n var originalUrl = this.element.dataset.originalUrl || largeUrl;\n\n var gallery = this.element.closest('.product-gallery');\n if (!gallery) return;\n\n var productId = gallery.dataset.productId;\n if (!productId) return;\n\n var mainImage = document.getElementById('main-image-' + productId);\n if (!mainImage) return;\n\n // Update data-has-image on the figure for the image-zoom controller\n var figure = mainImage.closest('.product-photo-primary');\n if (figure) {\n figure.dataset.hasImage = 'true';\n }\n\n // Swap main image (triggers image-zoom MutationObserver to reconfigure zoom)\n mainImage.src = largeUrl;\n mainImage.dataset.originalUrl = originalUrl;\n }\n}\n", "import { Controller } from '../stimulus';\nimport CSRFTokenMixin from '../mixins/csrf_token_mixin';\nimport StimulusReinitMixin from '../mixins/stimulus_reinit_mixin';\n\nexport default class extends Controller {\n initialize() {\n // Apply mixins\n Object.assign(this, CSRFTokenMixin, StimulusReinitMixin);\n\n this.saleId = this.element.dataset.saleId;\n this.productId = this.element.dataset.productId;\n this.colorVariantId = this.element.dataset.colorVariantId;\n this.available = this.element.dataset.available;\n this.isProcessing = false;\n }\n\n async click(e) {\n e.preventDefault();\n\n // Prevent multiple simultaneous requests\n if (this.isProcessing) {\n return;\n }\n\n this.isProcessing = true;\n this.disableToggleButton();\n\n // Store original state for rollback\n const wasAvailable = this.available === 'true';\n const parentLi = this.element.closest('li');\n\n // Apply immediate visual feedback\n if (wasAvailable) {\n parentLi.classList.remove('ae-selected');\n } else {\n parentLi.classList.add('ae-selected');\n }\n\n const url = `/teamsites/${this.saleId}/colors/${this.colorVariantId}/toggle_availability`;\n\n try {\n const response = await fetch(url, {\n method: 'PATCH',\n headers: this.getHeadersWithCSRF({\n 'Content-Type': 'application/json',\n 'Accept': 'application/json',\n }),\n body: JSON.stringify({\n current_availability: this.available,\n product_id: this.productId,\n }),\n });\n\n if (response.ok) {\n // Update the internal state\n this.available = wasAvailable ? 'false' : 'true';\n\n // Show success message\n if (window.App && typeof window.App.showSuccessFlash === 'function') {\n window.App.showSuccessFlash('Availability updated');\n }\n\n // Refresh only the gallery thumbnails\n await this.refreshGalleryThumbnails();\n } else {\n // Rollback visual state on error\n if (wasAvailable) {\n parentLi.classList.add('ae-selected');\n } else {\n parentLi.classList.remove('ae-selected');\n }\n throw new Error('Toggle failed');\n }\n } catch (/* eslint-disable-line no-unused-vars */ error) {\n // Rollback visual state on error\n if (wasAvailable) {\n parentLi.classList.add('ae-selected');\n } else {\n parentLi.classList.remove('ae-selected');\n }\n\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash('Something went wrong');\n } else {\n /* no error handling needed */\n }\n } finally {\n this.isProcessing = false;\n this.enableToggleButton();\n }\n }\n\n disableToggleButton() {\n this.element.disabled = true;\n this.element.classList.add('disabled', 'opacity-50', 'cursor-not-allowed');\n }\n\n enableToggleButton() {\n this.element.disabled = false;\n this.element.classList.remove('disabled', 'opacity-50', 'cursor-not-allowed');\n }\n\n async refreshGalleryThumbnails() {\n try {\n const url = `/teamsites/${this.saleId}/products/${this.productId}/gallery_thumbnails`;\n\n const response = await fetch(url, {\n headers: this.getHeadersWithCSRF({\n 'Accept': 'text/html',\n 'X-Requested-With': 'XMLHttpRequest',\n }),\n credentials: 'same-origin',\n });\n\n if (!response.ok) {\n throw new Error(`Failed to fetch gallery thumbnails: ${response.statusText}`);\n }\n\n const html = await response.text();\n this.updateGalleryThumbnails(html);\n } catch (/* eslint-disable-line no-unused-vars */ error) {\n // Fall back to page reload if thumbnail refresh fails\n Turbolinks.visit(`/teamsites/${this.saleId}/products/${this.productId}/edit`);\n }\n }\n\n updateGalleryThumbnails(html) {\n if (!html || typeof html !== 'string') {\n return;\n }\n\n const parser = new DOMParser();\n const doc = parser.parseFromString(html, 'text/html');\n\n // Check for parse errors\n const parserError = doc.querySelector('parsererror');\n if (parserError) {\n return;\n }\n\n // Update available gallery thumbnails\n const availableContainer = doc.querySelector(`#available-gallery-image-thumb-${this.productId}`);\n const currentAvailableContainer = document.querySelector(`#available-gallery-image-thumb-${this.productId}`);\n\n if (availableContainer && currentAvailableContainer) {\n currentAvailableContainer.innerHTML = availableContainer.innerHTML;\n // Reinitialize Stimulus controllers in the updated content\n this.reinitializeStimulusControllers(currentAvailableContainer);\n } else if (currentAvailableContainer && !availableContainer) {\n /* no available container to update */\n }\n\n // Update unavailable gallery thumbnails\n const unavailableContainer = doc.querySelector(`#unavailable-gallery-image-thumb-${this.productId}`);\n const currentUnavailableContainer = document.querySelector(`#unavailable-gallery-image-thumb-${this.productId}`);\n\n if (unavailableContainer && currentUnavailableContainer) {\n currentUnavailableContainer.innerHTML = unavailableContainer.innerHTML;\n // Reinitialize Stimulus controllers in the updated content\n this.reinitializeStimulusControllers(currentUnavailableContainer);\n } else if (currentUnavailableContainer && !unavailableContainer) {\n /* no unavailable container to update */\n }\n }\n}\n", "import { Controller } from '../stimulus';\nimport CSRFTokenMixin from '../mixins/csrf_token_mixin';\nimport StimulusReinitMixin from '../mixins/stimulus_reinit_mixin';\n\n/**\n * Product Gallery Refresh Controller\n *\n * Refreshes the product gallery via AJAX after successful image upload\n * without reloading the entire page.\n *\n * Usage:\n *
    product-gallery-refresh#refresh product-gallery-refresh->product-gallery-refresh#refresh\"\n * data-sale-id=\"<%= current_sale.id %>\"\n * data-product-id=\"<%= current_product.id %>\">\n * \n *
    \n */\nexport default class extends Controller {\n get productId() { return this.element.dataset.productId; }\n get saleId() { return this.element.dataset.saleId; }\n\n initialize() {\n // Apply mixins\n Object.assign(this, CSRFTokenMixin, StimulusReinitMixin);\n }\n\n connect() {\n // Mark controller as ready\n this.element.dataset.galleryRefreshReady = 'true';\n\n // Check for any pending refresh requests using data attributes instead of global state\n if (this.element.dataset.pendingRefresh) {\n setTimeout(() => {\n this.refresh({ type: 'pending-refresh' });\n delete this.element.dataset.pendingRefresh;\n }, 100);\n }\n }\n\n disconnect() {\n // Clean up any pending refresh state\n if (this.element.dataset.pendingRefresh) {\n delete this.element.dataset.pendingRefresh;\n }\n if (this.element.dataset.galleryRefreshReady) {\n delete this.element.dataset.galleryRefreshReady;\n }\n }\n\n /**\n * Refresh the gallery when an upload succeeds or when triggered by other events\n * @param {CustomEvent} event - The event that triggered the refresh\n */\n async refresh(event) {\n\n // For custom refresh events, use the productId from event detail if provided\n let productId = this.productId;\n if (event?.type === 'product-gallery-refresh' && event?.detail?.productId) {\n productId = event.detail.productId;\n }\n\n // Validate required values\n if (!this.saleId || !productId) {\n return;\n }\n\n try {\n const url = `/teamsites/${this.saleId}/products/${productId}/gallery`;\n\n const response = await fetch(url, {\n headers: this.getHeadersWithCSRF({\n 'Accept': 'text/html',\n 'X-Requested-With': 'XMLHttpRequest',\n }),\n credentials: 'same-origin',\n });\n\n if (!response.ok) {\n throw new Error(`Failed to fetch gallery: ${response.statusText}`);\n }\n\n const html = await response.text();\n const success = this.updateGalleryContent(html);\n\n // Only mark refresh as completed if content was successfully updated\n if (success) {\n this.element.dataset.refreshCompleted = 'true';\n setTimeout(() => {\n delete this.element.dataset.refreshCompleted;\n }, 5000);\n }\n\n } catch {\n // Optionally show user-friendly error message\n if (window.App && typeof window.App.showErrorFlash === 'function') {\n window.App.showErrorFlash('Failed to refresh gallery. Please reload the page.');\n }\n }\n }\n\n /**\n * Update the gallery content with new HTML\n * @param {string} html - The new gallery HTML\n */\n updateGalleryContent(html) {\n const parser = new DOMParser();\n const doc = parser.parseFromString(html, 'text/html');\n\n // Find the new gallery content\n const newGallery = doc.querySelector('.product-gallery');\n\n if (!newGallery) {\n return false;\n }\n\n // Replace the entire gallery content\n this.element.innerHTML = newGallery.innerHTML;\n\n // Copy over any data attributes from the new gallery element\n // This ensures updated product/sale IDs are preserved\n Array.from(newGallery.attributes).forEach(attr => {\n if (attr.name.startsWith('data-')) {\n this.element.setAttribute(attr.name, attr.value);\n }\n });\n\n // Phase 2 Experiment: Testing if Stimulus 1.1.0 mutation observer auto-detects DOM changes\n // Stimulus 1.1.0 configures MutationObserver with childList: true, subtree: true\n // which should automatically detect and initialize controllers when DOM changes\n // this.reinitializeStimulusControllers(this.element);\n\n return true;\n }\n}\n", "/* global Image */\nimport { Controller } from '../stimulus';\n\n/**\n * Product Image Fallback Controller\n *\n * Handles cascading fallback for product images that fail to load (404 errors).\n * Supports both tags and background-image CSS properties.\n *\n * Data attributes:\n * data-product-image-fallback-urls: JSON array of URLs to try in order\n *\n * Strategy for background-image:\n * 1. Template renders with first URL as background-image\n * 2. Controller preloads first URL to check if it loads\n * 3. If 404, tries next URL in the array\n * 4. Repeats until image loads or reaches placeholder\n *\n * Strategy for tags:\n * 1. Template renders with first URL as src\n * 2. Controller listens to native error events on img element\n * 3. If 404, updates src to next URL in the array\n * 4. Repeats until image loads or reaches placeholder\n *\n * Stimulus 1.1.0 compatible - uses data attributes (NOT values API)\n */\nexport default class extends Controller {\n connect() {\n // Parse fallback URLs from data attribute\n const urlsJson = this.element.getAttribute('data-product-image-fallback-urls');\n\n if (!urlsJson) {\n return;\n }\n\n try {\n this.fallbackUrls = JSON.parse(urlsJson);\n } catch {\n return;\n }\n\n if (!Array.isArray(this.fallbackUrls) || this.fallbackUrls.length === 0) {\n return;\n }\n\n // Detect if element is an IMG tag\n this.isImgElement = this.element.tagName === 'IMG';\n\n // Start with index 0 (first URL already applied in template)\n this.currentIndex = 0;\n\n if (this.isImgElement) {\n // For IMG tags, use native error event handling\n this.boundHandleImgError = this.handleImgError.bind(this);\n this.element.addEventListener('error', this.boundHandleImgError);\n } else {\n // For background-image elements, preload to detect failures\n this.tryCurrentUrl();\n }\n }\n\n disconnect() {\n // Clean up IMG error event listener if exists\n if (this.isImgElement && this.boundHandleImgError) {\n this.element.removeEventListener('error', this.boundHandleImgError);\n this.boundHandleImgError = null;\n }\n\n // Clean up image loader if exists\n if (this.imageLoader) {\n this.imageLoader.onload = null;\n this.imageLoader.onerror = null;\n this.imageLoader = null;\n }\n }\n\n tryCurrentUrl() {\n const url = this.fallbackUrls[this.currentIndex];\n\n if (!url) {\n return;\n }\n\n // Clean up previous image loader if it exists\n if (this.imageLoader) {\n this.imageLoader.onload = null;\n this.imageLoader.onerror = null;\n }\n\n // Create a new Image object to preload and detect errors\n this.imageLoader = new Image();\n\n // Bind event handlers to preserve 'this' context\n this.imageLoader.onload = this.handleSuccess.bind(this);\n this.imageLoader.onerror = this.handleError.bind(this);\n\n // Start loading the image\n this.imageLoader.src = url;\n }\n\n handleSuccess() {\n // Image loaded successfully, nothing more to do\n // The background-image is already set correctly\n this.cleanup();\n }\n\n handleError() {\n // Current URL failed, try next one\n this.currentIndex++;\n\n if (this.currentIndex >= this.fallbackUrls.length) {\n // No more fallbacks available - last one should be placeholder\n this.cleanup();\n return;\n }\n\n // Try next URL\n const nextUrl = this.fallbackUrls[this.currentIndex];\n\n // Update the background-image to the next URL\n this.element.style.backgroundImage = `url(${nextUrl})`;\n\n // Preload the new URL to detect if it also fails\n this.tryCurrentUrl();\n }\n\n handleImgError() {\n // Current image failed to load, try next one\n this.currentIndex++;\n\n if (this.currentIndex >= this.fallbackUrls.length) {\n // No more fallbacks available - last one should be placeholder\n // Let the browser display broken image or last attempted URL\n return;\n }\n\n // Try next URL by updating the src attribute\n const nextUrl = this.fallbackUrls[this.currentIndex];\n this.element.src = nextUrl;\n // Native error event will fire again if this URL also fails\n }\n\n cleanup() {\n // Remove event handlers\n if (this.imageLoader) {\n this.imageLoader.onload = null;\n this.imageLoader.onerror = null;\n this.imageLoader = null;\n }\n }\n}\n", "import { Controller } from '../stimulus';\n\nimport eventCoordinator from './event_coordinator';\n\n/**\n * Product Navigation Controller\n *\n * Handles AJAX navigation between products in the sidebar.\n *\n * Configuration via data attributes:\n * - data-product-navigation-debug=\"true\" - Enable debug logging\n * - data-product-navigation-navigation-timeout=\"3000\" - Set navigation timeout in ms (default: 5000)\n * - data-product-navigation-error-display-time=\"3000\" - Set error message display time in ms (default: 4000)\n *\n * Example:\n *