spree_admin 5.4.3 → 5.5.0.rc2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/spree/admin/auth_rate_limiting.rb +58 -0
  3. data/app/controllers/spree/admin/assets_controller.rb +7 -1
  4. data/app/controllers/spree/admin/channels_controller.rb +13 -0
  5. data/app/controllers/spree/admin/products_controller.rb +8 -8
  6. data/app/controllers/spree/admin/user_passwords_controller.rb +4 -0
  7. data/app/controllers/spree/admin/user_sessions_controller.rb +4 -0
  8. data/app/helpers/spree/admin/assets_helper.rb +5 -1
  9. data/app/helpers/spree/admin/base_helper.rb +0 -8
  10. data/app/helpers/spree/admin/channels_helper.rb +14 -0
  11. data/app/helpers/spree/admin/json_preview_helper.rb +5 -0
  12. data/app/helpers/spree/admin/orders_helper.rb +0 -29
  13. data/app/helpers/spree/admin/publishing_helper.rb +73 -0
  14. data/app/helpers/spree/admin/turbo_helper.rb +0 -18
  15. data/app/helpers/spree/admin/webhook_endpoints_helper.rb +1 -0
  16. data/app/javascript/spree/admin/application.js +2 -0
  17. data/app/javascript/spree/admin/controllers/product_form_controller.js +0 -26
  18. data/app/javascript/spree/admin/controllers/product_publishing_controller.js +11 -0
  19. data/app/javascript/spree/admin/controllers/slug_form_controller.js +18 -1
  20. data/app/presenters/spree/admin/order_summary_presenter.rb +12 -0
  21. data/app/views/spree/admin/api_keys/_form.html.erb +18 -0
  22. data/app/views/spree/admin/assets/edit.html.erb +64 -9
  23. data/app/views/spree/admin/channels/_form.html.erb +19 -0
  24. data/app/views/spree/admin/channels/edit.html.erb +1 -0
  25. data/app/views/spree/admin/channels/index.html.erb +12 -0
  26. data/app/views/spree/admin/channels/new.html.erb +1 -0
  27. data/app/views/spree/admin/integrations/_integration.html.erb +7 -9
  28. data/app/views/spree/admin/integrations/edit.html.erb +1 -1
  29. data/app/views/spree/admin/product_translations/index.html.erb +1 -1
  30. data/app/views/spree/admin/products/_form.html.erb +2 -1
  31. data/app/views/spree/admin/products/form/_publishing.html.erb +125 -0
  32. data/app/views/spree/admin/products/form/_status.html.erb +3 -47
  33. data/app/views/spree/admin/shared/_media_asset.html.erb +1 -1
  34. data/app/views/spree/admin/variants/edit.html.erb +0 -1
  35. data/config/initializers/spree_admin_navigation.rb +9 -0
  36. data/config/initializers/spree_admin_tables.rb +60 -0
  37. data/config/locales/en.yml +32 -3
  38. data/config/routes.rb +1 -0
  39. data/lib/spree/admin/engine.rb +2 -0
  40. data/lib/spree/admin/runtime_configuration.rb +5 -0
  41. metadata +19 -11
  42. data/app/helpers/spree/admin/modal_helper.rb +0 -31
  43. data/app/views/spree/admin/shared/_modal.html.erb +0 -27
  44. /data/app/views/spree/admin/promotion_rules/forms/{_taxon.html.erb → _category.html.erb} +0 -0
  45. /data/app/views/spree/admin/promotion_rules/forms/{_user.html.erb → _customer.html.erb} +0 -0
  46. /data/app/views/spree/admin/promotion_rules/forms/{_user_logged_in.html.erb → _customer_logged_in.html.erb} +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9a5af2b78a13b13f570093bfe92c6d6c6b3ade67ec72e294fbf0f1ffd9c121d6
4
- data.tar.gz: 6dfa405d89717625d66b270fe16f7343bb8ec532f8ba673ee856361eb3682e92
3
+ metadata.gz: cf50ce30e9bb9efb5d77bfc984d87f921c997520125e78b375c375d391999aec
4
+ data.tar.gz: 8bbc5de5e700786db47adc040e6e048901503bb9572d382302336d33a7531866
5
5
  SHA512:
6
- metadata.gz: 9172a34304882f931b1bf56502ffd09121ca3497d8e3b24b5520dc03cf6cebf5a70c6e284615098dc4c70462f153aa204899911f152902e75317b5130760222d
7
- data.tar.gz: 91f7065af97ecddea447d6add3cd60ab8d36ceb6590ced8ca3e74bbe65a26eeded78625209b38e5b5ad9f26e43605bdc05fb594664f0d1aad1fbe51af22f17b9
6
+ metadata.gz: 3dd8c8013f072e443fb99b2c70450f005904f0d738870dcf6182e4edd39810fb462e6869eea08cc0ebc235d2d55895750469eaff710b4958fd86880952108aff
7
+ data.tar.gz: d032bec7f6a7f7eee57054a1b4af6e36e19fca1b80ef276c9a95eb71d6451151c629e05446426893cf1fae120b52a97e9863eafc2644ef2b1a2ac65475030d59
@@ -0,0 +1,58 @@
1
+ module Spree
2
+ module Admin
3
+ module AuthRateLimiting
4
+ extend ActiveSupport::Concern
5
+
6
+ class_methods do
7
+ # @param limit_preference [Symbol] e.g. :rate_limit_login / :rate_limit_password_reset
8
+ # @param redirect_to [Proc] evaluated in controller context to the path to bounce
9
+ # back to when rate limited, e.g. `-> { new_session_path(resource_name) }`.
10
+ # @return [void]
11
+ def auth_rate_limit(limit_preference, redirect_to:)
12
+ limit = Spree::Admin::RuntimeConfig[limit_preference] || 5
13
+ window = (Spree::Admin::RuntimeConfig[:rate_limit_window] || 60).seconds
14
+ prefix = limit_preference.to_s # unique namespace per controller/action
15
+
16
+ # By IP — always present; backstops blank-email floods.
17
+ rate_limit(
18
+ to: limit,
19
+ within: window,
20
+ by: -> { "#{prefix}-ip:#{request.remote_ip}" },
21
+ with: -> { admin_auth_rate_limit_response(redirect_to) },
22
+ store: Rails.cache,
23
+ only: :create
24
+ )
25
+
26
+ # By submitted email (case-insensitive). Falls back to per-IP bucketing when
27
+ # the email is blank, so blank submissions don't all share one global bucket.
28
+ rate_limit(
29
+ to: limit,
30
+ within: window,
31
+ by: lambda {
32
+ email = admin_auth_rate_limit_email
33
+ email.present? ? "#{prefix}-email:#{email}" : "#{prefix}-email-ip:#{request.remote_ip}"
34
+ },
35
+ with: -> { admin_auth_rate_limit_response(redirect_to) },
36
+ store: Rails.cache,
37
+ only: :create
38
+ )
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ # Email is read via Devise's `resource_params` so it works regardless of the
45
+ # configured admin user class / resource param key.
46
+ def admin_auth_rate_limit_email
47
+ resource_params[:email].to_s.strip.downcase.presence
48
+ rescue StandardError
49
+ nil
50
+ end
51
+
52
+ def admin_auth_rate_limit_response(redirect_path)
53
+ flash[:alert] = I18n.t('devise.failure.too_many_attempts')
54
+ redirect_to instance_exec(&redirect_path)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -38,8 +38,14 @@ module Spree
38
38
 
39
39
  private
40
40
 
41
+ # Includes :variant_ids so the variant-assignment checkboxes mass-assign
42
+ # straight into Spree::Asset#variant_ids= — the model resolves prefixed
43
+ # ids and rejects cross-product variants.
41
44
  def permitted_resource_params
42
- params.require(:asset).permit(Spree::PermittedAttributes.asset_attributes)
45
+ params.require(:asset).permit(
46
+ *Spree::PermittedAttributes.asset_attributes,
47
+ variant_ids: []
48
+ )
43
49
  end
44
50
 
45
51
  def create_turbo_stream_enabled?
@@ -0,0 +1,13 @@
1
+ module Spree
2
+ module Admin
3
+ class ChannelsController < ResourceController
4
+ include Spree::Admin::SettingsConcern
5
+
6
+ private
7
+
8
+ def permitted_resource_params
9
+ params.require(:channel).permit(permitted_channel_attributes)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -21,7 +21,7 @@ module Spree
21
21
  new_action.before :build_master_stock_items
22
22
  edit_action.before :build_master_prices
23
23
  edit_action.before :build_master_stock_items
24
- create.after :assign_master_images
24
+ create.after :assign_session_uploaded_assets
25
25
  update.before :skip_updating_status
26
26
  update.before :update_status
27
27
  update.before :remove_empty_params
@@ -185,7 +185,7 @@ module Spree
185
185
  @product_variant_ids[variant.human_name] = variant.id.to_s
186
186
  @product_variant_prefix_ids[variant.human_name] = variant.to_param
187
187
 
188
- image = variant.primary_media
188
+ image = variant.primary_media || @product.primary_media
189
189
  if image.present? && image.attached? && image.variable?
190
190
  @product_variant_images[variant.human_name] = helpers.spree_image_url(image, variant: :mini)
191
191
  end
@@ -287,15 +287,15 @@ module Spree
287
287
  {}
288
288
  end
289
289
 
290
- def assign_master_images
291
- return unless @product.master.persisted?
292
-
293
- uploaded_assets = session_uploaded_assets('Spree::Variant')
290
+ def assign_session_uploaded_assets
291
+ uploaded_assets = session_uploaded_assets('Spree::Product')
294
292
 
295
293
  return if uploaded_assets.empty?
296
294
 
297
- uploaded_assets.update_all(viewable_id: @product.master.id, viewable_type: 'Spree::Variant', updated_at: Time.current)
298
- clear_session_for_uploaded_assets('Spree::Variant')
295
+ uploaded_assets.update_all(viewable_id: @product.id, viewable_type: 'Spree::Product', updated_at: Time.current)
296
+ @product.update_thumbnail!
297
+
298
+ clear_session_for_uploaded_assets('Spree::Product')
299
299
  end
300
300
 
301
301
  def check_slug_availability
@@ -1,8 +1,12 @@
1
1
  module Spree
2
2
  module Admin
3
3
  class UserPasswordsController < defined?(Devise::PasswordsController) ? Devise::PasswordsController : Spree::Admin::BaseController
4
+ include Spree::Admin::AuthRateLimiting
5
+
4
6
  layout 'spree/minimal'
5
7
 
8
+ auth_rate_limit :rate_limit_password_reset, redirect_to: -> { new_password_path(resource_name) }
9
+
6
10
  def create
7
11
  self.resource = resource_class.send_reset_password_instructions(resource_params)
8
12
  yield resource if block_given?
@@ -1,8 +1,12 @@
1
1
  module Spree
2
2
  module Admin
3
3
  class UserSessionsController < defined?(Devise::SessionsController) ? Devise::SessionsController : Spree::Admin::BaseController
4
+ include Spree::Admin::AuthRateLimiting
5
+
4
6
  layout 'spree/minimal'
5
7
 
8
+ auth_rate_limit :rate_limit_login, redirect_to: -> { new_session_path(resource_name) }
9
+
6
10
  # We need to overwrite this action because `return_to` url may be in a different domain
7
11
  # So we need to pass `allow_other_host` option to `redirect_to` method
8
12
  def create
@@ -3,7 +3,11 @@ module Spree
3
3
  module AssetsHelper
4
4
  def media_form_assets(viewable, viewable_type)
5
5
  if viewable&.persisted?
6
- viewable.images
6
+ # Product#images delegates to the master variant (legacy alias), so
7
+ # 5.5+ product-level uploads wouldn't show. gallery_media returns
8
+ # product-level media when present and falls back to legacy
9
+ # variant-pinned images during the transition.
10
+ viewable.respond_to?(:gallery_media) ? viewable.gallery_media : viewable.images
7
11
  elsif session_uploaded_assets(viewable_type).any?
8
12
  Spree::Asset.accessible_by(current_ability, :manage).where(id: session_uploaded_assets(viewable_type))
9
13
  else
@@ -36,14 +36,6 @@ module Spree
36
36
  @settings_area.present?
37
37
  end
38
38
 
39
- def settings_active?
40
- Spree::Deprecation.warn('settings_active? is deprecated and will be removed in Spree 5.5. Please use settings_area? instead')
41
- @settings_active || %w[admin_users audits custom_domains exports invitations oauth_applications
42
- payment_methods refund_reasons reimbursement_types return_authorization_reasons roles
43
- shipping_categories shipping_methods stock_locations store_credit_categories
44
- stores tax_categories tax_rates webhooks webhooks_subscribers zones policies metafield_definitions].include?(controller_name) || settings_area?
45
- end
46
-
47
39
  # @return [Array<String>] the available countries for checkout
48
40
  def available_countries_iso
49
41
  @available_countries_iso ||= current_store.countries_available_for_checkout.pluck(:iso)
@@ -0,0 +1,14 @@
1
+ module Spree
2
+ module Admin
3
+ module ChannelsHelper
4
+ # Registered +Spree::OrderRouting::Strategy::Base+ subclasses presented in
5
+ # the channel edit form, sourced from +Spree.order_routing.strategies+ so the
6
+ # picker can never drift from what the model accepts. A blank value clears the
7
+ # channel-level override and falls back to
8
+ # +Store#preferred_order_routing_strategy+.
9
+ def channel_order_routing_strategy_options
10
+ Spree.order_routing.strategies.map { |strategy| [strategy.display_name, strategy.to_s] }
11
+ end
12
+ end
13
+ end
14
+ end
@@ -69,6 +69,11 @@ module Spree
69
69
  return Spree.api.public_send(method_name)
70
70
  end
71
71
 
72
+ if namespace == 'Spree::Api::V3::Admin'
73
+ method_name = "admin_#{class_name.underscore}_serializer"
74
+ return Spree.api.public_send(method_name) if Spree.api.respond_to?(method_name)
75
+ end
76
+
72
77
  # Fall back to direct constant lookup
73
78
  serializer_class_name = "#{namespace}::#{class_name}Serializer"
74
79
  serializer_class_name.safe_constantize
@@ -1,24 +1,6 @@
1
1
  module Spree
2
2
  module Admin
3
3
  module OrdersHelper
4
- TaxLine = Struct.new(:label, :display_amount, :item, :for_shipment, keyword_init: true) do
5
- def name
6
- Spree::Deprecation.warn("TaxLine is deprecated and will be removed in Spree 5.5")
7
- item_name = item.name
8
- item_name += " #{Spree.t(:shipment).downcase}" if for_shipment
9
-
10
- "#{label} (#{item_name})"
11
- end
12
- end
13
-
14
- def order_summary_tax_lines_additional(order)
15
- Spree::Deprecation.warn("order_summary_tax_lines_additional is deprecated and will be removed in Spree 5.5")
16
- line_item_taxes = order.line_item_adjustments.tax.map { |tax_adjustment| map_to_tax_line(tax_adjustment) }
17
- shipment_taxes = order.shipment_adjustments.tax.map { |tax_adjustment| map_to_tax_line(tax_adjustment, for_shipment: true) }
18
-
19
- line_item_taxes + shipment_taxes
20
- end
21
-
22
4
  def order_payment_state(order, options = {})
23
5
  return if order.payment_state.blank?
24
6
 
@@ -140,17 +122,6 @@ module Spree
140
122
  '' => 'Transaction failed because wrong CVV2 number was entered or no CVV2 number was entered'
141
123
  }
142
124
  end
143
-
144
- private
145
-
146
- def map_to_tax_line(tax_adjustment, for_shipment: false)
147
- TaxLine.new(
148
- label: tax_adjustment.label,
149
- display_amount: tax_adjustment.display_amount,
150
- item: tax_adjustment.adjustable,
151
- for_shipment: for_shipment
152
- )
153
- end
154
125
  end
155
126
  end
156
127
  end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module Admin
5
+ # Helpers for the product Publishing card in the Rails admin. Mirrors the
6
+ # SPA's publishing card (see packages/dashboard/src/components/spree/products/publishing-card.tsx):
7
+ # per-channel status is gated by product status — Draft/Archived products
8
+ # render every publication as +not_available+ regardless of window.
9
+ module PublishingHelper
10
+ # @param product_status [String] the product's status (draft/active/archived)
11
+ # @param publication [Spree::ProductPublication]
12
+ # @return [Symbol] one of :live, :scheduled, :hidden, :not_available
13
+ def publication_schedule_status(product_status, publication)
14
+ return :not_available unless product_status == 'active'
15
+
16
+ now = Time.current
17
+ return :hidden if publication.unpublished_at && publication.unpublished_at <= now
18
+ return :scheduled if publication.published_at && publication.published_at > now
19
+
20
+ :live
21
+ end
22
+
23
+ # Renders a colored dot + status label for a publication row.
24
+ def publication_status_badge(product_status, publication)
25
+ status = publication_schedule_status(product_status, publication)
26
+ dot_class = case status
27
+ when :live then 'bg-success'
28
+ when :scheduled then 'bg-warning'
29
+ else 'bg-secondary'
30
+ end
31
+
32
+ label = Spree.t("admin.publishing.status_#{status}")
33
+
34
+ content_tag(:span, class: 'd-inline-flex align-items-center gap-1 text-muted small') do
35
+ content_tag(:span, '', class: "publication-dot rounded-circle #{dot_class}",
36
+ style: 'display:inline-block; width:0.5rem; height:0.5rem;') +
37
+ content_tag(:span, label)
38
+ end
39
+ end
40
+
41
+ # One-line summary of the publication window in the store's timezone.
42
+ def publication_caption(product_status, publication, store)
43
+ status = publication_schedule_status(product_status, publication)
44
+ tz = ActiveSupport::TimeZone[store.preferred_timezone] || Time.zone
45
+
46
+ case status
47
+ when :not_available
48
+ Spree.t('admin.publishing.caption_not_available',
49
+ product_status: Spree.t("admin.products.status_options.#{product_status}", default: product_status.to_s.humanize))
50
+ when :hidden
51
+ Spree.t('admin.publishing.caption_unpublished',
52
+ date: l(publication.unpublished_at.in_time_zone(tz), format: :short))
53
+ when :scheduled
54
+ if publication.unpublished_at
55
+ Spree.t('admin.publishing.caption_window',
56
+ start: l(publication.published_at.in_time_zone(tz), format: :short),
57
+ end: l(publication.unpublished_at.in_time_zone(tz), format: :short))
58
+ else
59
+ Spree.t('admin.publishing.caption_scheduled',
60
+ date: l(publication.published_at.in_time_zone(tz), format: :short))
61
+ end
62
+ else # :live
63
+ if publication.unpublished_at
64
+ Spree.t('admin.publishing.caption_hidden_after',
65
+ date: l(publication.unpublished_at.in_time_zone(tz), format: :short))
66
+ else
67
+ Spree.t('admin.publishing.caption_live')
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -1,24 +1,6 @@
1
1
  module Spree
2
2
  module Admin
3
3
  module TurboHelper
4
- def turbo_close_modal(modal_id = nil)
5
- Spree::Deprecation.warn('turbo_close_modal is deprecated and will be removed in Spree 5.5. Use turbo_close_dialog instead.')
6
-
7
- modal_id ||= 'modal'
8
-
9
- turbo_stream.replace :modal_scripts do
10
- turbo_frame_tag :modal_scripts do
11
- javascript_tag do
12
- raw <<~JS
13
- if (document.querySelector('##{modal_id}')) {
14
- window.$("##{modal_id}").modal('hide');
15
- }
16
- JS
17
- end
18
- end
19
- end
20
- end
21
-
22
4
  def turbo_close_dialog
23
5
  turbo_stream.replace 'main-dialog' do
24
6
  render 'spree/admin/shared/dialog'
@@ -93,6 +93,7 @@ module Spree
93
93
  def custom_webhook_events
94
94
  %w[
95
95
  customer.password_reset_requested
96
+ newsletter_subscriber.subscription_requested
96
97
  order.completed
97
98
  order.paid
98
99
  order.canceled
@@ -68,6 +68,7 @@ import OrderBillingAddressController from 'spree/admin/controllers/order_billing
68
68
  import PageBuilderController from 'spree/admin/controllers/page_builder_controller'
69
69
  import PasswordToggle from 'spree/admin/controllers/password_toggle_controller'
70
70
  import ProductFormController from 'spree/admin/controllers/product_form_controller'
71
+ import ProductPublishingController from 'spree/admin/controllers/product_publishing_controller'
71
72
  import QueryBuilderController from 'spree/admin/controllers/query_builder_controller'
72
73
  import RangeInputController from 'spree/admin/controllers/range_input_controller'
73
74
  import TableController from 'spree/admin/controllers/table_controller'
@@ -140,6 +141,7 @@ application.register('page-builder', PageBuilderController)
140
141
  application.register('password-toggle', PasswordToggle)
141
142
  application.register('password-visibility', PasswordVisibility)
142
143
  application.register('product-form', ProductFormController)
144
+ application.register('product-publishing', ProductPublishingController)
143
145
  application.register('query-builder', QueryBuilderController)
144
146
  application.register('range-input', RangeInputController)
145
147
  application.register('table', TableController)
@@ -4,10 +4,6 @@ export default class extends Controller {
4
4
  static targets = [
5
5
  'trackInventoryCheckbox',
6
6
  'quantityForm',
7
- 'availableOn',
8
- 'makeActiveAt',
9
- 'discontinueOn',
10
- 'status',
11
7
  'pricesForm'
12
8
  ]
13
9
 
@@ -26,28 +22,6 @@ export default class extends Controller {
26
22
  this.togglePricesFormVisibility()
27
23
  }
28
24
 
29
- switchAvailabilityDatesFields(event) {
30
- let status = event.target.value
31
- if (status === 'draft') {
32
- this.show(this.availableOnTarget)
33
- this.show(this.makeActiveAtTarget)
34
- } else if (status === 'active') {
35
- this.show(this.availableOnTarget)
36
- this.hide(this.makeActiveAtTarget)
37
- } else {
38
- this.hide(this.availableOnTarget)
39
- this.hide(this.makeActiveAtTarget)
40
- }
41
- }
42
-
43
- show(element) {
44
- element.classList.remove('hidden', 'd-none')
45
- }
46
-
47
- hide(element) {
48
- element.classList.add('hidden')
49
- }
50
-
51
25
  toggleQuantityFormVisibility() {
52
26
  if (this.hasQuantityFormTarget) {
53
27
  if (!this.hasVariantsValue && this.trackInventoryCheckboxTarget.checked) {
@@ -0,0 +1,11 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+
3
+ // Toggles the "Manage" channel-checkbox panel on the product Publishing card.
4
+ // Pairs with spree/admin/app/views/spree/admin/products/form/_publishing.html.erb.
5
+ export default class extends Controller {
6
+ static targets = ['manage']
7
+
8
+ toggleManage() {
9
+ this.manageTarget.classList.toggle('d-none')
10
+ }
11
+ }
@@ -6,9 +6,26 @@ export default class extends Controller {
6
6
  'url',
7
7
  ]
8
8
 
9
+ connect() {
10
+ this.urlTouched = this.hasUrlTarget && this.urlTarget.value.length > 0
11
+ if (this.hasUrlTarget) {
12
+ this.urlTarget.addEventListener('input', () => { this.urlTouched = true })
13
+ }
14
+ }
15
+
9
16
  updateUrlFromName() {
17
+ if (this.urlTouched) return
10
18
  const name = this.nameTarget.value
11
- const url = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, '')
19
+ // Mirrors ActiveSupport's +String#parameterize+: NFKD decompose, drop
20
+ // combining marks (accents), lowercase, hyphenate the rest. Keeps the
21
+ // legacy admin's auto-fill aligned with what +normalizes :code+ stores
22
+ // server-side — without NFKD, "Café" rendered "caf-" instead of "cafe".
23
+ const url = name
24
+ .normalize('NFKD')
25
+ .replace(/\p{M}/gu, '')
26
+ .toLowerCase()
27
+ .replace(/[^a-z0-9]+/g, '-')
28
+ .replace(/(^-|-$)+/g, '')
12
29
  this.urlTarget.value = url
13
30
  }
14
31
  }
@@ -13,6 +13,7 @@ module Spree
13
13
  [
14
14
  *metadata_rows,
15
15
  :separator,
16
+ channel_row,
16
17
  market_row,
17
18
  locale_row,
18
19
  currency_row,
@@ -73,6 +74,17 @@ module Spree
73
74
  rows
74
75
  end
75
76
 
77
+ def channel_row
78
+ return nil if order.channel.blank?
79
+
80
+ {
81
+ label: Spree.t(:channel),
82
+ value: order.channel.name,
83
+ id: 'channel',
84
+ link: Spree::Core::Engine.routes.url_helpers.edit_admin_channel_path(order.channel)
85
+ }
86
+ end
87
+
76
88
  def market_row
77
89
  return nil if order.market.blank?
78
90
 
@@ -24,3 +24,21 @@
24
24
  <% end %>
25
25
  </div>
26
26
  </div>
27
+
28
+ <div class="card mb-6" id="api-key-scopes" data-secret-only data-show-when-key-type="secret">
29
+ <div class="card-header">
30
+ <h5 class="card-title"><%= Spree.t('admin.api_keys.scopes', default: 'Scopes') %></h5>
31
+ <p class="text-sm text-gray-500 mt-1">
32
+ <%= Spree.t('admin.api_keys.scopes_description', default: 'Required for secret keys. Pick the narrowest set of scopes your integration needs.') %>
33
+ </p>
34
+ </div>
35
+ <div class="card-body">
36
+ <% Spree::ApiKey::SCOPES.each do |scope| %>
37
+ <div class="custom-control form-checkbox mb-1">
38
+ <%= f.check_box :scopes, { multiple: true, class: 'custom-control-input', id: "api_key_scopes_#{scope}" }, scope, nil %>
39
+ <%= f.label "scopes_#{scope}", scope, class: 'custom-control-label font-mono text-sm' %>
40
+ </div>
41
+ <% end %>
42
+ <%= f.hidden_field :scopes, value: '', multiple: true %>
43
+ </div>
44
+ </div>
@@ -1,17 +1,72 @@
1
- <%= turbo_frame_tag :dialog do %>
2
- <%= dialog_header(Spree.t(:edit) + ' ' + Spree.t(:image)) %>
1
+ <%= turbo_frame_tag :drawer do %>
3
2
  <%= form_with model: @asset, url: spree.admin_asset_path(@asset), method: :put, scope: :asset do |f| %>
4
- <div class="dialog-body pb-0" data-turbo-permanent id="asset-<%= @asset.key.parameterize %>">
5
- <%= f.spree_file_field :attachment, width: 200, height: 200, can_delete: false, label: Spree.t(:image) %>
3
+ <%= drawer_header(Spree.t(:edit) + ' ' + Spree.t(:image)) %>
4
+
5
+ <div class="drawer-body">
6
+ <% if @asset.attachment.attached? %>
7
+ <% blob_url_opts = { host: request.host_with_port, protocol: (request.ssl? ? 'https' : 'http') } %>
8
+ <% download_url = Rails.application.routes.url_helpers.rails_blob_url(@asset.attachment.blob, **blob_url_opts, disposition: 'attachment') %>
9
+ <% original_url = Rails.application.routes.url_helpers.rails_blob_url(@asset.attachment.blob, **blob_url_opts) %>
10
+ <div class="mb-4 rounded-lg border bg-light overflow-hidden">
11
+ <a href="<%= original_url %>"
12
+ target="_blank"
13
+ rel="noopener"
14
+ title="<%= Spree.t(:view_full_size) %>"
15
+ style="display: block; position: relative; cursor: zoom-in;">
16
+ <%= spree_image_tag(@asset.attachment, variant: :large, alt: @asset.alt, class: 'w-full max-h-96 object-contain') %>
17
+ <span style="position: absolute; top: 8px; right: 8px; display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 6px; background: rgba(255,255,255,0.95); color: #4b5563; box-shadow: 0 1px 2px rgba(0,0,0,0.1); pointer-events: none;">
18
+ <%= icon('zoom-in', class: 'mr-0') %>
19
+ </span>
20
+ </a>
21
+ </div>
22
+ <div class="flex gap-2 mb-4">
23
+ <%= link_to original_url, target: '_blank', rel: 'noopener', class: 'btn btn-light btn-sm' do %>
24
+ <%= icon('external-link', class: 'mr-1') %><%= Spree.t(:view_full_size) %>
25
+ <% end %>
26
+ <%= link_to download_url, target: '_blank', rel: 'noopener', class: 'btn btn-light btn-sm' do %>
27
+ <%= icon('download', class: 'mr-1') %><%= Spree.t(:download) %>
28
+ <% end %>
29
+ </div>
30
+ <% else %>
31
+ <%= f.spree_file_field :attachment, width: 200, height: 200, can_delete: false, label: Spree.t(:image) %>
32
+ <% end %>
33
+
34
+ <%= f.spree_text_area :alt, label: Spree.t(:alt_text), rows: 3 %>
35
+
36
+ <%# Variant assignment shows only for product-level assets — variant-pinned assets
37
+ already live on a single variant via the polymorphic viewable, so the
38
+ join-table UI doesn't apply. Empty submission (no boxes ticked) clears
39
+ all links thanks to the hidden field below. %>
40
+ <% if @asset.viewable_type == 'Spree::Product' && @asset.viewable.present? && @asset.viewable.variants.any? %>
41
+ <% product = @asset.viewable %>
42
+ <% linked_ids = @asset.variant_media.pluck(:variant_id).to_set %>
43
+ <div class="mt-4 pt-4 border-t">
44
+ <label class="form-label"><%= Spree.t(:assigned_variants) %></label>
45
+ <p class="text-muted small mb-2">
46
+ <%= Spree.t(:assigned_variants_help) %>
47
+ </p>
48
+ <%= hidden_field_tag 'asset[variant_ids][]', '' %>
49
+ <div class="flex flex-col gap-1">
50
+ <% product.variants.each do |variant| %>
51
+ <% chip_id = "asset_variant_#{variant.id}" %>
52
+ <div class="custom-control form-checkbox rounded-lg hover:bg-gray-100 p-2 transition-colors duration-100 ease-linear">
53
+ <%= check_box_tag('asset[variant_ids][]', variant.to_param, linked_ids.include?(variant.id), id: chip_id, class: 'custom-control-input') %>
54
+ <%= label_tag chip_id, class: 'custom-control-label' do %>
55
+ <%= variant.descriptive_name.presence || variant.sku %>
56
+ <% end %>
57
+ </div>
58
+ <% end %>
59
+ </div>
60
+ </div>
61
+ <% end %>
6
62
  </div>
7
- <div class="dialog-body" style="min-height: 200px">
8
- <%= f.spree_text_area :alt, label: Spree.t(:alt_text), rows: 4 %>
9
- </div>
10
- <div class="dialog-footer">
63
+
64
+ <div class="drawer-footer gap-3">
11
65
  <%= turbo_save_button_tag %>
66
+ <%= drawer_discard_button %>
12
67
  <%= link_to Spree.t('actions.destroy'), object_url(@asset),
13
68
  data: { turbo_method: :delete, turbo_confirm: Spree.t(:are_you_sure_delete), turbo_frame: '_top' },
14
69
  class: 'btn btn-danger ml-auto' if can?(:destroy, @asset) %>
15
70
  </div>
16
71
  <% end %>
17
- <% end %>
72
+ <% end %>
@@ -0,0 +1,19 @@
1
+ <div class="card mb-6">
2
+ <div class="card-body" data-controller="slug-form">
3
+ <%= f.spree_text_field :name,
4
+ autofocus: f.object.new_record?,
5
+ required: true,
6
+ data: { slug_form_target: :name, action: 'slug-form#updateUrlFromName' } %>
7
+ <%= f.spree_text_field :code,
8
+ required: true,
9
+ hint: Spree.t('admin.channels.code_hint'),
10
+ data: { slug_form_target: :url } %>
11
+ <%= f.spree_check_box :active, label: Spree.t(:active) %>
12
+ <%= f.spree_check_box :default, label: Spree.t('admin.channels.default'), hint: Spree.t('admin.channels.default_hint') %>
13
+ <%= f.spree_select :preferred_order_routing_strategy,
14
+ options_for_select(channel_order_routing_strategy_options, f.object.preferred_order_routing_strategy),
15
+ { label: Spree.t('admin.channels.order_routing_strategy'),
16
+ help_bubble: Spree.t('admin.channels.order_routing_strategy_hint'),
17
+ include_blank: Spree.t('admin.channels.order_routing_strategy_inherit') } %>
18
+ </div>
19
+ </div>
@@ -0,0 +1 @@
1
+ <%= render 'spree/admin/shared/edit_resource' %>
@@ -0,0 +1,12 @@
1
+ <% content_for :page_title do %>
2
+ <%= Spree.t(:channels) %>
3
+ <% end %>
4
+
5
+ <% content_for :page_actions do %>
6
+ <%= render_admin_partials(:channels_actions_partials) %>
7
+ <%= link_to_with_icon 'plus', Spree.t(:new_channel), new_object_url, class: "btn btn-primary" if can? :create, Spree::Channel %>
8
+ <% end %>
9
+
10
+ <%= render_admin_partials(:channels_header_partials) %>
11
+
12
+ <%= render_table @collection, :channels %>
@@ -0,0 +1 @@
1
+ <%= render 'spree/admin/shared/new_resource' %>