spree_core 5.4.0.rc5 → 5.4.0.rc7

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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/app/mailers/spree/base_mailer.rb +2 -0
  3. data/app/models/concerns/spree/default_price.rb +108 -6
  4. data/app/models/concerns/spree/stores/markets.rb +4 -2
  5. data/app/models/spree/exports/product_translations.rb +45 -0
  6. data/app/models/spree/import_schemas/product_translations.rb +14 -0
  7. data/app/models/spree/imports/product_translations.rb +17 -0
  8. data/app/models/spree/market.rb +1 -0
  9. data/app/models/spree/metafield.rb +1 -1
  10. data/app/models/spree/metafield_definition.rb +10 -0
  11. data/app/models/spree/option_type.rb +20 -2
  12. data/app/models/spree/option_value.rb +17 -1
  13. data/app/models/spree/order.rb +22 -2
  14. data/app/models/spree/price.rb +1 -1
  15. data/app/models/spree/product.rb +40 -13
  16. data/app/models/spree/search_provider/meilisearch.rb +41 -8
  17. data/app/models/spree/stock_location.rb +1 -1
  18. data/app/models/spree/stock_movement.rb +2 -1
  19. data/app/models/spree/variant.rb +13 -11
  20. data/app/presenters/spree/csv/product_translation_presenter.rb +31 -0
  21. data/app/presenters/spree/csv/product_variant_presenter.rb +16 -3
  22. data/app/presenters/spree/search_provider/product_presenter.rb +8 -13
  23. data/app/services/spree/cart/create.rb +1 -0
  24. data/app/services/spree/carts/create.rb +1 -0
  25. data/app/services/spree/carts/update.rb +21 -0
  26. data/app/services/spree/imports/row_processors/product_translation.rb +45 -0
  27. data/app/services/spree/imports/row_processors/product_variant.rb +11 -3
  28. data/app/services/spree/products/prepare_nested_attributes.rb +18 -1
  29. data/app/services/spree/sample_data/loader.rb +31 -20
  30. data/app/services/spree/seeds/all.rb +22 -20
  31. data/app/views/spree/shared/_mailer_line_item.html.erb +2 -2
  32. data/config/locales/en.yml +12 -0
  33. data/db/migrate/20250217171018_create_action_text_video_embeds.rb +11 -0
  34. data/db/migrate/20260402000001_add_kind_to_spree_option_types.rb +13 -0
  35. data/db/migrate/20260402000002_add_color_code_to_spree_option_values.rb +5 -0
  36. data/db/migrate/20260403000000_add_market_to_spree_orders.rb +5 -0
  37. data/db/sample_data/markets.rb +30 -0
  38. data/db/sample_data/metafield_definitions.rb +2 -2
  39. data/db/sample_data/options.rb +4 -0
  40. data/db/sample_data/orders.rb +2 -2
  41. data/db/sample_data/product_translations.csv +75 -0
  42. data/db/sample_data/products.csv +212 -1083
  43. data/lib/spree/core/configuration.rb +1 -0
  44. data/lib/spree/core/controller_helpers/order.rb +12 -10
  45. data/lib/spree/core/engine.rb +2 -0
  46. data/lib/spree/core/permission_configuration.rb +12 -0
  47. data/lib/spree/core/pricing/resolver.rb +5 -3
  48. data/lib/spree/core/version.rb +1 -1
  49. data/lib/spree/permitted_attributes.rb +3 -3
  50. data/lib/spree/testing_support/authorization_helpers.rb +16 -16
  51. data/lib/spree/testing_support/factories/import_factory.rb +12 -0
  52. data/lib/spree/testing_support/factories/options_factory.rb +11 -0
  53. data/lib/spree/testing_support/factories/product_factory.rb +12 -2
  54. data/lib/spree/testing_support/factories/variant_factory.rb +8 -2
  55. data/lib/tasks/markets.rake +5 -2
  56. data/lib/tasks/search.rake +3 -3
  57. data/lib/tasks/variants.rake +2 -2
  58. data/spec/fixtures/files/product_translations_import.csv +3 -0
  59. metadata +26 -7
  60. /data/lib/tasks/{images.rake → media.rake} +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d5513fa58ed4667fb34788917d1fa52e803267a9a9e43a2c76d6fa318d40645b
4
- data.tar.gz: 02b5467d26e71c5f111ffdd0677aa7e6b2732a5ff42ef75a25e7a782a7090737
3
+ metadata.gz: 07eb5e06c1dbe2e9d89b69a3768a376fcf5d23f0be28bb15bd4e9fac1ce0bcaf
4
+ data.tar.gz: 99e243c978fb95cfd04e4b8ee42cc42b63f3580493939b4271be7992006c2bbb
5
5
  SHA512:
6
- metadata.gz: e83cdd25848537e36b5f96b113697818cacec896a58b663415d28295d2e368c1b81dbe076baa86d7858ea8357ad1fd9b1754212c5b4d13a8a54e77c0d360ee29
7
- data.tar.gz: 6c6cf8922e2b2c242572723b176608b12a675e64f4982647aca28a0ce85269d23889ba44d5db7ad539a04f1765fde2afbb7c552b28a7e3e7163d263559b79bc6
6
+ metadata.gz: 2967ba8a3825d98180adb93f0a025370c6aa6aee76520cf5400be56bfb6328b63f121434065f2acf8b600099f53136b067becec17c0f1523078dedd95cc4ccd5
7
+ data.tar.gz: ca2d9d12300bd8a765b3130cf86fd857d8c8b0ad7cc413f895956a806a642d5ef44c20983b08d227de1fff80b9bcfd68995173061fc60849ebe5dab185631ffb
@@ -1,5 +1,7 @@
1
1
  module Spree
2
2
  class BaseMailer < ActionMailer::Base
3
+ helper Spree::ImagesHelper
4
+
3
5
  def current_store
4
6
  @current_store ||= @order&.store.presence || Spree::Store.current || Spree::Store.default
5
7
  end
@@ -2,24 +2,126 @@ module Spree
2
2
  module DefaultPrice
3
3
  extend ActiveSupport::Concern
4
4
 
5
+ DEPRECATION_MSG = 'Spree::DefaultPrice is deprecated and will be removed in Spree 6.0. ' \
6
+ 'Use variant.set_price(currency, amount) and variant.price_in(currency) instead.'
7
+
5
8
  included do
6
9
  has_one :default_price,
7
10
  -> { with_deleted.where(currency: Spree::Store.default.default_currency) },
8
11
  class_name: 'Spree::Price',
9
12
  dependent: :destroy
10
13
 
11
- delegate :display_price, :display_amount, :price, :currency, :price=,
12
- :price_including_vat_for, :currency=, :display_compare_at_price,
13
- :compare_at_price, :compare_at_price=, to: :find_or_build_default_price
14
+ after_save :save_default_price, if: -> { Spree::Config.enable_legacy_default_price }
15
+
16
+ def price
17
+ Spree::Deprecation.warn(Spree::DefaultPrice::DEPRECATION_MSG)
18
+ if Spree::Config.enable_legacy_default_price
19
+ find_or_build_default_price.price
20
+ else
21
+ price_in(Spree::Store.default.default_currency).amount
22
+ end
23
+ end
24
+
25
+ def price=(value)
26
+ Spree::Deprecation.warn(Spree::DefaultPrice::DEPRECATION_MSG)
27
+ if Spree::Config.enable_legacy_default_price
28
+ find_or_build_default_price.price = value
29
+ else
30
+ set_price(Spree::Store.default.default_currency, value)
31
+ end
32
+ end
33
+
34
+ def currency
35
+ Spree::Deprecation.warn(Spree::DefaultPrice::DEPRECATION_MSG)
36
+ if Spree::Config.enable_legacy_default_price
37
+ find_or_build_default_price.currency
38
+ else
39
+ Spree::Store.default.default_currency
40
+ end
41
+ end
42
+
43
+ def currency=(value)
44
+ Spree::Deprecation.warn(Spree::DefaultPrice::DEPRECATION_MSG)
45
+ if Spree::Config.enable_legacy_default_price
46
+ find_or_build_default_price.currency = value
47
+ end
48
+ # no-op when legacy is disabled — currency is determined by the store
49
+ end
50
+
51
+ def display_price
52
+ Spree::Deprecation.warn(Spree::DefaultPrice::DEPRECATION_MSG)
53
+ if Spree::Config.enable_legacy_default_price
54
+ find_or_build_default_price.display_price
55
+ else
56
+ price_in(Spree::Store.default.default_currency).display_amount
57
+ end
58
+ end
59
+
60
+ def display_amount
61
+ Spree::Deprecation.warn(Spree::DefaultPrice::DEPRECATION_MSG)
62
+ if Spree::Config.enable_legacy_default_price
63
+ find_or_build_default_price.display_amount
64
+ else
65
+ price_in(Spree::Store.default.default_currency).display_amount
66
+ end
67
+ end
68
+
69
+ def compare_at_price
70
+ Spree::Deprecation.warn(Spree::DefaultPrice::DEPRECATION_MSG)
71
+ if Spree::Config.enable_legacy_default_price
72
+ find_or_build_default_price.compare_at_price
73
+ else
74
+ price_in(Spree::Store.default.default_currency).compare_at_amount
75
+ end
76
+ end
14
77
 
15
- after_save :save_default_price
78
+ def compare_at_price=(value)
79
+ Spree::Deprecation.warn(Spree::DefaultPrice::DEPRECATION_MSG)
80
+ if Spree::Config.enable_legacy_default_price
81
+ find_or_build_default_price.compare_at_price = value
82
+ else
83
+ default_currency = Spree::Store.default.default_currency
84
+ price_record = price_in(default_currency)
85
+ price_record.compare_at_amount = value
86
+ price_record.save! if price_record.persisted?
87
+ end
88
+ end
89
+
90
+ def display_compare_at_price
91
+ Spree::Deprecation.warn(Spree::DefaultPrice::DEPRECATION_MSG)
92
+ if Spree::Config.enable_legacy_default_price
93
+ find_or_build_default_price.display_compare_at_price
94
+ else
95
+ price_in(Spree::Store.default.default_currency).display_compare_at_amount
96
+ end
97
+ end
98
+
99
+ def price_including_vat_for(price_options)
100
+ Spree::Deprecation.warn(Spree::DefaultPrice::DEPRECATION_MSG)
101
+ if Spree::Config.enable_legacy_default_price
102
+ find_or_build_default_price.price_including_vat_for(price_options)
103
+ else
104
+ price_in(Spree::Store.default.default_currency).price_including_vat_for(price_options)
105
+ end
106
+ end
16
107
 
17
108
  def has_default_price?
18
- !default_price.nil?
109
+ Spree::Deprecation.warn(Spree::DefaultPrice::DEPRECATION_MSG)
110
+ if Spree::Config.enable_legacy_default_price
111
+ !default_price.nil?
112
+ else
113
+ prices.base_prices.any? { |p| p.currency == Spree::Store.default.default_currency }
114
+ end
19
115
  end
20
116
 
21
117
  def find_or_build_default_price
22
- default_price || build_default_price
118
+ Spree::Deprecation.warn(Spree::DefaultPrice::DEPRECATION_MSG)
119
+ if Spree::Config.enable_legacy_default_price
120
+ default_price || build_default_price
121
+ else
122
+ prices.base_prices.find { |p| p.currency == Spree::Store.default.default_currency } ||
123
+ prices.build(currency: Spree::Store.default.default_currency)
124
+ end
23
125
  end
24
126
 
25
127
  private
@@ -104,12 +104,14 @@ module Spree
104
104
  end
105
105
  end
106
106
 
107
- private
108
-
107
+ # Returns true if the store has markets, false otherwise
108
+ # @return [Boolean]
109
109
  def has_markets?
110
110
  @has_markets ||= persisted? && (markets.loaded? ? markets.any? : markets.exists?)
111
111
  end
112
112
 
113
+ private
114
+
113
115
  def legacy_supported_currencies_list
114
116
  ([default_currency] + read_attribute(:supported_currencies).to_s.split(',')).uniq.map(&:to_s).map do |code|
115
117
  ::Money::Currency.find(code.strip)
@@ -0,0 +1,45 @@
1
+ module Spree
2
+ module Exports
3
+ class ProductTranslations < Spree::Export
4
+ def scope_includes
5
+ []
6
+ end
7
+
8
+ def multi_line_csv?
9
+ true
10
+ end
11
+
12
+ def scope
13
+ if search_params.nil?
14
+ super.where.not(status: 'archived')
15
+ else
16
+ super
17
+ end
18
+ end
19
+
20
+ def csv_headers
21
+ Spree::CSV::ProductTranslationPresenter::CSV_HEADERS
22
+ end
23
+
24
+ def generate_csv
25
+ locales = store.supported_locales_list - [store.default_locale]
26
+ return super if locales.empty?
27
+
28
+ ::CSV.open(export_tmp_file_path, 'wb', encoding: 'UTF-8', col_sep: ',', row_sep: "\r\n") do |csv|
29
+ csv << csv_headers
30
+ records_to_export.includes(scope_includes).find_in_batches do |batch|
31
+ batch.each do |product|
32
+ product.to_translation_csv(store, locales).each do |line|
33
+ csv << line
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ def model_class
41
+ Spree::Product
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,14 @@
1
+ module Spree
2
+ module ImportSchemas
3
+ class ProductTranslations < Spree::ImportSchema
4
+ FIELDS = [
5
+ { name: 'slug', label: 'Slug', required: true },
6
+ { name: 'locale', label: 'Locale', required: true },
7
+ { name: 'name', label: 'Name' },
8
+ { name: 'description', label: 'Description' },
9
+ { name: 'meta_title', label: 'Meta Title' },
10
+ { name: 'meta_description', label: 'Meta Description' }
11
+ ].freeze
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,17 @@
1
+ module Spree
2
+ module Imports
3
+ class ProductTranslations < Spree::Import
4
+ def row_processor_class
5
+ Spree::Imports::RowProcessors::ProductTranslation
6
+ end
7
+
8
+ def model_class
9
+ Spree::Product
10
+ end
11
+
12
+ def self.model_class
13
+ Spree::Product
14
+ end
15
+ end
16
+ end
17
+ end
@@ -13,6 +13,7 @@ module Spree
13
13
  belongs_to :store, class_name: 'Spree::Store', touch: true
14
14
  has_many :market_countries, class_name: 'Spree::MarketCountry', dependent: :destroy
15
15
  has_many :countries, through: :market_countries, class_name: 'Spree::Country'
16
+ has_many :orders, class_name: 'Spree::Order', dependent: :nullify
16
17
 
17
18
  #
18
19
  # Validations
@@ -11,7 +11,7 @@ module Spree
11
11
  #
12
12
  # Delegations
13
13
  #
14
- delegate :key, :full_key, :name, :display_on, to: :metafield_definition, allow_nil: true
14
+ delegate :key, :full_key, :name, :label, :display_on, to: :metafield_definition, allow_nil: true
15
15
 
16
16
  #
17
17
  # Callbacks
@@ -51,6 +51,16 @@ module Spree
51
51
  self.whitelisted_ransackable_attributes = %w[key namespace name resource_type display_on]
52
52
  self.whitelisted_ransackable_scopes = %w[search multi_search]
53
53
 
54
+ # 5.5 API naming bridge (DB column rename in 6.0)
55
+ # Aligns with OptionType/OptionValue which also expose `label` for the display name.
56
+ def label
57
+ name
58
+ end
59
+
60
+ def label=(value)
61
+ self.name = value
62
+ end
63
+
54
64
  # Returns the full key with namespace
55
65
  # @return [String] eg. custom.id
56
66
  def full_key
@@ -3,6 +3,7 @@ module Spree
3
3
  has_prefix_id :opt # Spree-specific: option type
4
4
 
5
5
  COLOR_NAMES = %w[color colour].freeze
6
+ KINDS = %w[dropdown color_swatch buttons].freeze
6
7
 
7
8
  include Spree::ParameterizableName
8
9
  include Spree::UniqueName
@@ -34,18 +35,28 @@ module Spree
34
35
  has_many :prototypes, through: :option_type_prototypes, class_name: 'Spree::Prototype'
35
36
 
36
37
  # 5.5 API naming bridge (DB column rename in 6.0)
37
- alias_attribute :label, :presentation
38
+ # NOTE: alias_attribute bypasses Mobility's locale-aware reader/writer,
39
+ # so we define explicit delegating methods instead.
40
+ def label(*args)
41
+ presentation(*args)
42
+ end
43
+
44
+ def label=(value)
45
+ self.presentation = value
46
+ end
38
47
 
39
48
  #
40
49
  # Validations
41
50
  #
42
51
  validates :presentation, presence: true
52
+ validates :kind, presence: true, inclusion: { in: KINDS }
43
53
 
44
54
  #
45
55
  # Scopes
46
56
  #
47
57
  default_scope { order(:position) }
48
58
  scope :colors, -> { where(name: COLOR_NAMES) }
59
+ scope :color_swatches, -> { where(kind: 'color_swatch') }
49
60
  scope :filterable, -> { where(filterable: true) }
50
61
 
51
62
  #
@@ -72,8 +83,15 @@ module Spree
72
83
  colors.first
73
84
  end
74
85
 
86
+ def color_swatch?
87
+ kind == 'color_swatch'
88
+ end
89
+
75
90
  def color?
76
- name.in?(COLOR_NAMES)
91
+ Spree::Deprecation.warn(
92
+ 'Spree::OptionType#color? is deprecated. Use #color_swatch? instead. Will be removed in Spree 6.0.'
93
+ )
94
+ color_swatch?
77
95
  end
78
96
 
79
97
  private
@@ -20,6 +20,11 @@ module Spree
20
20
  acts_as_list scope: :option_type
21
21
  self.whitelisted_ransackable_attributes = ['presentation']
22
22
 
23
+ #
24
+ # Attachments
25
+ #
26
+ has_one_attached :image
27
+
23
28
  #
24
29
  # Associations
25
30
  #
@@ -29,7 +34,15 @@ module Spree
29
34
  has_many :products, through: :variants, class_name: 'Spree::Product'
30
35
 
31
36
  # 5.5 API naming bridge (DB column rename in 6.0)
32
- alias_attribute :label, :presentation
37
+ # NOTE: alias_attribute bypasses Mobility's locale-aware reader/writer,
38
+ # so we define explicit delegating methods instead.
39
+ def label(*args)
40
+ presentation(*args)
41
+ end
42
+
43
+ def label=(value)
44
+ self.presentation = value
45
+ end
33
46
 
34
47
  #
35
48
  # Validations
@@ -39,6 +52,9 @@ module Spree
39
52
  validates :presentation
40
53
  end
41
54
 
55
+ validates :color_code, format: { with: /\A#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?\z/, message: 'must be a valid hex color (e.g. #FF0000)' },
56
+ allow_blank: true
57
+
42
58
  #
43
59
  # Scopes
44
60
  #
@@ -54,7 +54,7 @@ module Spree
54
54
  alias display_ship_total display_shipment_total
55
55
  alias_attribute :ship_total, :shipment_total
56
56
  def amount_due
57
- outstanding_balance
57
+ [outstanding_balance - total_applied_store_credit, 0].max
58
58
  end
59
59
 
60
60
  # Transient warnings populated by remove_out_of_stock_items!
@@ -140,6 +140,7 @@ module Spree
140
140
  alias_attribute :shipping_address_id, :ship_address_id
141
141
 
142
142
  belongs_to :store, class_name: 'Spree::Store'
143
+ belongs_to :market, class_name: 'Spree::Market', optional: true
143
144
 
144
145
  with_options dependent: :destroy do
145
146
  has_many :state_changes, as: :stateful, class_name: 'Spree::StateChange'
@@ -181,6 +182,8 @@ module Spree
181
182
  alias_attribute :fulfillment_status, :shipment_state
182
183
  alias_attribute :payment_status, :payment_state
183
184
 
185
+ delegate :has_markets?, to: :store, prefix: true
186
+
184
187
  accepts_nested_attributes_for :line_items
185
188
  accepts_nested_attributes_for :bill_address
186
189
  accepts_nested_attributes_for :ship_address
@@ -191,12 +194,14 @@ module Spree
191
194
 
192
195
  # Needs to happen before save_permalink is called
193
196
  before_validation :ensure_store_presence
197
+ before_validation :ensure_market_presence
194
198
  before_validation :ensure_currency_presence
195
199
  before_validation :ensure_locale_presence
200
+ before_validation :resolve_market_from_currency, if: -> { persisted? && currency_changed? && !skip_market_resolution }
196
201
 
197
202
  before_validation :clone_billing_address, if: :use_billing?
198
203
  before_validation :clone_shipping_address, if: :use_shipping?
199
- attr_accessor :use_billing, :use_shipping
204
+ attr_accessor :use_billing, :use_shipping, :skip_market_resolution
200
205
 
201
206
  before_create :link_by_email
202
207
  before_update :ensure_updated_shipments, :homogenize_line_item_currencies, if: :currency_changed?
@@ -220,6 +225,7 @@ module Spree
220
225
  validates :shipment_total, MONEY_VALIDATION
221
226
  validates :promo_total, NEGATIVE_MONEY_VALIDATION
222
227
  validates :total, MONEY_VALIDATION
228
+ validates :market, presence: true, if: :store_has_markets?
223
229
  validate :currency_must_be_supported_by_store
224
230
  validate :locale_must_be_supported_by_store
225
231
 
@@ -450,6 +456,10 @@ module Spree
450
456
  self.store ||= Spree::Store.default
451
457
  end
452
458
 
459
+ def ensure_market_presence
460
+ self.market ||= Spree::Current.market || store&.default_market
461
+ end
462
+
453
463
  def allow_cancel?
454
464
  return false if !completed? || canceled?
455
465
 
@@ -1050,6 +1060,16 @@ module Spree
1050
1060
  end
1051
1061
  end
1052
1062
 
1063
+ # When currency changes, auto-resolve the matching market.
1064
+ # Only applies when the store has markets configured.
1065
+ def resolve_market_from_currency
1066
+ return unless store_has_markets?
1067
+ return if market&.currency == currency
1068
+
1069
+ resolved = store.markets.find_by(currency: currency)
1070
+ self.market = resolved if resolved
1071
+ end
1072
+
1053
1073
  def collect_payment_methods
1054
1074
  Spree::Deprecation.warn('`Order#collect_payment_methods` is deprecated and will be removed in Spree 5.5. Use `collect_frontend_payment_methods` instead.')
1055
1075
 
@@ -135,7 +135,7 @@ module Spree
135
135
  #
136
136
  # @return [Boolean]
137
137
  def discounted?
138
- compare_at_amount.to_i.positive? && compare_at_amount > amount
138
+ compare_at_amount.to_i.positive? && amount.present? && compare_at_amount > amount
139
139
  end
140
140
 
141
141
  # returns true if the price was discounted
@@ -604,19 +604,40 @@ module Spree
604
604
  taxons_for_csv.fill(nil, taxons_for_csv.size...3)
605
605
 
606
606
  csv_lines = []
607
+ all_variants = has_variants? ? variants_including_master.to_a : [master]
608
+ default_currency = store.default_currency
609
+ additional_currencies = store.supported_currencies_list.map(&:iso_code) - [default_currency]
610
+
611
+ # Primary rows in the store's default currency
612
+ all_variants.each_with_index do |variant, index|
613
+ csv_lines << Spree::CSV::ProductVariantPresenter.new(self, variant, index, properties_for_csv, taxons_for_csv, store,
614
+ metafields_for_csv).call
615
+ end
607
616
 
608
- if has_variants?
609
- variants_including_master.each_with_index do |variant, index|
610
- csv_lines << Spree::CSV::ProductVariantPresenter.new(self, variant, index, properties_for_csv, taxons_for_csv, store,
611
- metafields_for_csv).call
617
+ # Price-only rows for each additional currency
618
+ additional_currencies.each do |currency|
619
+ all_variants.each do |variant|
620
+ next unless variant.amount_in(currency)
621
+
622
+ csv_lines << Spree::CSV::ProductVariantPresenter.new(self, variant, 0, [], [], store,
623
+ [], currency).call
612
624
  end
613
- else
614
- csv_lines << Spree::CSV::ProductVariantPresenter.new(self, master, 0, properties_for_csv, taxons_for_csv, store, metafields_for_csv).call
615
625
  end
616
626
 
617
627
  csv_lines
618
628
  end
619
629
 
630
+ def to_translation_csv(store = nil, locales = [])
631
+ locales.filter_map do |locale|
632
+ # Only export if at least one field has a translation for this locale
633
+ has_translation = Spree::CSV::ProductTranslationPresenter::TRANSLATABLE_FIELDS.any? do |field|
634
+ get_field_with_locale(locale, field).present?
635
+ end
636
+
637
+ Spree::CSV::ProductTranslationPresenter.new(self, locale).call if has_translation
638
+ end
639
+ end
640
+
620
641
  private
621
642
 
622
643
  # Determines which variant should be used for displaying media.
@@ -652,11 +673,12 @@ module Spree
652
673
  values = option_values_hash.values
653
674
  values = values.inject(values.shift) { |memo, value| memo.product(value).map(&:flatten) }
654
675
 
676
+ default_currency = stores.first&.default_currency || Spree::Store.default.default_currency
677
+ master_price = master.price_in(default_currency).amount
678
+
655
679
  values.each do |ids|
656
- variants.create(
657
- option_value_ids: ids,
658
- price: master.price
659
- )
680
+ variant = variants.create!(option_value_ids: ids)
681
+ variant.set_price(default_currency, master_price) if master_price.present?
660
682
  end
661
683
  save
662
684
  end
@@ -690,6 +712,7 @@ module Spree
690
712
  master.new_record? ||
691
713
  master.changed? ||
692
714
  (
715
+ Spree::Config.enable_legacy_default_price &&
693
716
  master.default_price &&
694
717
  (
695
718
  master.default_price.new_record? ||
@@ -712,9 +735,13 @@ module Spree
712
735
  # If the master cannot be saved, the Product object will get its errors
713
736
  # and will be destroyed
714
737
  def validate_master
715
- # We call master.default_price here to ensure price is initialized.
716
- # Required to avoid Variant#check_price validation failing on create.
717
- unless master.default_price && master.valid?
738
+ if Spree::Config.enable_legacy_default_price
739
+ # We call master.default_price here to ensure price is initialized.
740
+ # Required to avoid Variant#check_price validation failing on create.
741
+ master.default_price
742
+ end
743
+
744
+ unless master.valid?
718
745
  master.errors.map { |error| { field: error.attribute, message: error&.message } }.each do |err|
719
746
  next if err[:field].blank? || err[:message].blank?
720
747
 
@@ -103,9 +103,10 @@ module Spree
103
103
  product_prefixed_ids = ms_result['hits'].map { |h| h['product_id'] }.uniq
104
104
  raw_ids = product_prefixed_ids.filter_map { |pid| Spree::Product.decode_prefixed_id(pid) }
105
105
 
106
- # Intersect with AR scope for security/visibility.
106
+ # Intersect with AR scope for security/visibility, preserving Meilisearch sort order.
107
107
  products = if raw_ids.any?
108
- scope.where(id: raw_ids).reorder(nil)
108
+ records = scope.where(id: raw_ids).reorder(nil).index_by(&:id)
109
+ raw_ids.filter_map { |id| records[id] }
109
110
  else
110
111
  scope.none
111
112
  end
@@ -123,8 +124,9 @@ module Spree
123
124
  end
124
125
 
125
126
  def index(product)
127
+ ensure_index_settings_once!
126
128
  documents = presenter_class.new(product, store).call
127
- client.index(index_name).add_documents(documents, 'prefixed_id')
129
+ client.index(index_name).add_documents(documents, 'id')
128
130
  end
129
131
 
130
132
  def remove(product)
@@ -132,7 +134,9 @@ module Spree
132
134
  end
133
135
 
134
136
  def index_batch(documents)
135
- client.index(index_name).add_documents(documents, 'prefixed_id')
137
+ return if documents.empty?
138
+
139
+ client.index(index_name).add_documents(documents, 'id')
136
140
  end
137
141
 
138
142
  # Remove all documents for a product by its prefixed_id (e.g. 'prod_abc')
@@ -147,21 +151,44 @@ module Spree
147
151
  scope ||= store.products
148
152
  ensure_index_settings!
149
153
 
154
+ indexed = 0
150
155
  scope.reorder(id: :asc)
151
156
  .preload_associations_lazily
152
157
  .find_in_batches(batch_size: 500) do |batch|
153
158
  documents = batch.flat_map { |product| presenter_class.new(product, store).call }
159
+ next if documents.empty?
160
+
154
161
  index_batch(documents)
162
+ indexed += documents.size
163
+
164
+ Rails.logger.info { "[Meilisearch] Enqueued #{documents.size} documents (#{indexed} total) for #{index_name}" }
155
165
  end
166
+
167
+ Rails.logger.info { "[Meilisearch] Reindex complete: #{indexed} documents enqueued for #{index_name}" }
168
+ indexed
156
169
  end
157
170
 
158
171
  # Configure index settings for filtering, sorting, and faceting.
159
172
  # Called automatically by reindex, but can be called separately.
173
+ # Waits for all settings tasks to complete before returning so that
174
+ # subsequent add_documents calls use the correct filterable/sortable attributes.
160
175
  def ensure_index_settings!
161
176
  index = client.index(index_name)
162
- index.update_filterable_attributes(filterable_attributes)
163
- index.update_sortable_attributes(sortable_attributes)
164
- index.update_searchable_attributes(searchable_attributes)
177
+ tasks = []
178
+ tasks << index.update_filterable_attributes(filterable_attributes)
179
+ tasks << index.update_sortable_attributes(sortable_attributes)
180
+ tasks << index.update_searchable_attributes(searchable_attributes)
181
+ tasks.each { |task| task&.await }
182
+ @index_settings_configured = true
183
+ end
184
+
185
+ # Lightweight guard — configures index settings once per provider instance.
186
+ # Meilisearch settings updates are idempotent, so repeated calls are safe
187
+ # but we avoid the overhead by memoizing.
188
+ def ensure_index_settings_once!
189
+ return if @index_settings_configured
190
+
191
+ ensure_index_settings!
165
192
  end
166
193
 
167
194
  private
@@ -359,7 +386,12 @@ module Spree
359
386
 
360
387
  ot = ov.option_type
361
388
  by_option_type[ot] ||= []
362
- by_option_type[ot] << { id: ov.prefixed_id, name: ov.name, label: ov.label, position: ov.position, count: count }
389
+ by_option_type[ot] << {
390
+ id: ov.prefixed_id, name: ov.name, label: ov.label, position: ov.position,
391
+ color_code: ov.color_code,
392
+ image_url: ov.image.attached? ? Rails.application.routes.url_helpers.cdn_image_url(ov.image) : nil,
393
+ count: count
394
+ }
363
395
  end
364
396
 
365
397
  by_option_type.map do |option_type, values|
@@ -368,6 +400,7 @@ module Spree
368
400
  type: 'option',
369
401
  name: option_type.name,
370
402
  label: option_type.label,
403
+ kind: option_type.kind,
371
404
  options: values.sort_by { |o| o[:position] }
372
405
  }
373
406
  end
@@ -27,7 +27,7 @@ module Spree
27
27
  after_save :ensure_one_default
28
28
  after_update :conditional_touch_records
29
29
 
30
- delegate :name, :iso3, :iso, :iso_name, to: :country, prefix: true
30
+ delegate :name, :iso3, :iso, :iso_name, to: :country, prefix: true, allow_nil: true
31
31
 
32
32
  def state_text
33
33
  state.try(:abbr) || state.try(:name) || state_name