spree_core 5.5.2 → 5.6.0.rc1

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 (137) hide show
  1. checksums.yaml +4 -4
  2. data/app/helpers/spree/base_helper.rb +12 -2
  3. data/app/helpers/spree/currency_helper.rb +1 -4
  4. data/app/helpers/spree/locale_helper.rb +18 -13
  5. data/app/helpers/spree/localized_names_helper.rb +25 -0
  6. data/app/jobs/spree/imports/process_group_job.rb +20 -9
  7. data/app/jobs/spree/imports/process_rows_job.rb +12 -4
  8. data/app/mailers/spree/admin_user_mailer.rb +71 -0
  9. data/app/mailers/spree/base_mailer.rb +73 -11
  10. data/app/mailers/spree/export_mailer.rb +7 -7
  11. data/app/mailers/spree/import_mailer.rb +19 -0
  12. data/app/mailers/spree/invitation_mailer.rb +16 -11
  13. data/app/mailers/spree/report_mailer.rb +6 -6
  14. data/app/mailers/spree/webhook_mailer.rb +7 -6
  15. data/app/models/concerns/spree/admin_user_methods.rb +44 -0
  16. data/app/models/concerns/spree/legacy_multi_store_support.rb +48 -0
  17. data/app/models/concerns/spree/presentation_translatable.rb +39 -0
  18. data/app/models/concerns/spree/product_scopes.rb +21 -3
  19. data/app/models/concerns/spree/single_store_resource.rb +5 -0
  20. data/app/models/concerns/spree/store_scoped_resource.rb +10 -0
  21. data/app/models/concerns/spree/stores/markets.rb +9 -1
  22. data/app/models/concerns/spree/stores/setup.rb +37 -23
  23. data/app/models/concerns/spree/translatable_resource.rb +95 -5
  24. data/app/models/concerns/spree/user_methods.rb +27 -2
  25. data/app/models/spree/ability.rb +5 -4
  26. data/app/models/spree/allowed_origin.rb +31 -0
  27. data/app/models/spree/category.rb +12 -2
  28. data/app/models/spree/channel/gating.rb +67 -0
  29. data/app/models/spree/channel.rb +1 -0
  30. data/app/models/spree/classification.rb +12 -0
  31. data/app/models/spree/country.rb +22 -1
  32. data/app/models/spree/credit_card.rb +29 -0
  33. data/app/models/spree/currency.rb +47 -0
  34. data/app/models/spree/current.rb +12 -1
  35. data/app/models/spree/customer_group.rb +1 -0
  36. data/app/models/spree/export.rb +19 -0
  37. data/app/models/spree/exports/coupon_codes.rb +1 -2
  38. data/app/models/spree/import.rb +93 -1
  39. data/app/models/spree/import_row.rb +7 -1
  40. data/app/models/spree/imports/product_translations.rb +6 -0
  41. data/app/models/spree/locale.rb +104 -0
  42. data/app/models/spree/option_type.rb +11 -13
  43. data/app/models/spree/option_value.rb +5 -17
  44. data/app/models/spree/order.rb +19 -2
  45. data/app/models/spree/payment_method.rb +6 -5
  46. data/app/models/spree/policy.rb +2 -1
  47. data/app/models/spree/product.rb +23 -2
  48. data/app/models/spree/promotion/rules/channel.rb +37 -0
  49. data/app/models/spree/promotion/rules/market.rb +37 -0
  50. data/app/models/spree/promotion.rb +5 -3
  51. data/app/models/spree/promotion_handler/promotion_duplicator.rb +1 -1
  52. data/app/models/spree/role_user.rb +4 -0
  53. data/app/models/spree/search_provider/meilisearch.rb +22 -9
  54. data/app/models/spree/setup_task.rb +12 -0
  55. data/app/models/spree/setup_tasks.rb +86 -0
  56. data/app/models/spree/shipment.rb +38 -1
  57. data/app/models/spree/shipment_handler.rb +1 -1
  58. data/app/models/spree/stock/availability_validator.rb +1 -1
  59. data/app/models/spree/stock/quantifier.rb +21 -1
  60. data/app/models/spree/stock_item.rb +2 -1
  61. data/app/models/spree/stock_location.rb +4 -1
  62. data/app/models/spree/store.rb +45 -9
  63. data/app/models/spree/store_payment_method.rb +5 -1
  64. data/app/models/spree/store_promotion.rb +4 -0
  65. data/app/models/spree/taxon.rb +112 -9
  66. data/app/models/spree/taxonomy.rb +1 -1
  67. data/app/models/spree/translations/batch.rb +104 -0
  68. data/app/models/spree/variant.rb +29 -2
  69. data/app/presenters/spree/search_provider/product_presenter.rb +3 -0
  70. data/app/services/spree/carts/complete.rb +3 -0
  71. data/app/services/spree/fulfillments/create.rb +259 -0
  72. data/app/services/spree/imports/row_processors/base.rb +9 -0
  73. data/app/services/spree/imports/row_processors/product_variant.rb +37 -19
  74. data/app/services/spree/locales/set_fallback_locale_for_store.rb +7 -1
  75. data/app/services/spree/locales.rb +52 -0
  76. data/app/services/spree/products/prepare_nested_attributes.rb +40 -12
  77. data/app/services/spree/shipments/update.rb +1 -1
  78. data/app/services/spree/stock_reservations/reserve.rb +4 -1
  79. data/app/services/spree/taxons/add_products.rb +3 -0
  80. data/app/services/spree/taxons/remove_products.rb +3 -0
  81. data/app/subscribers/spree/admin_user_email_subscriber.rb +29 -0
  82. data/app/subscribers/spree/import_email_subscriber.rb +26 -0
  83. data/app/views/layouts/spree/base_mailer.html.erb +14 -17
  84. data/app/views/spree/admin_user_mailer/confirmation_email.html.erb +7 -0
  85. data/app/views/spree/admin_user_mailer/password_reset_email.html.erb +8 -0
  86. data/app/views/spree/export_mailer/export_done.html.erb +6 -14
  87. data/app/views/spree/import_mailer/import_done.html.erb +12 -0
  88. data/app/views/spree/invitation_mailer/invitation_accepted.html.erb +4 -12
  89. data/app/views/spree/invitation_mailer/invitation_email.html.erb +11 -24
  90. data/app/views/spree/report_mailer/report_done.html.erb +6 -14
  91. data/app/views/spree/shared/_base_mailer_footer.html.erb +15 -8
  92. data/app/views/spree/shared/_base_mailer_header.html.erb +7 -5
  93. data/app/views/spree/shared/_base_mailer_stylesheets.html.erb +211 -273
  94. data/app/views/spree/shared/_mailer_button.html.erb +10 -0
  95. data/app/views/spree/shared/_mailer_hero.html.erb +12 -0
  96. data/app/views/spree/shared/_purchased_items_styles.html.erb +112 -0
  97. data/app/views/spree/webhook_mailer/endpoint_disabled.html.erb +39 -14
  98. data/config/initializers/spree_store_setup_tasks.rb +23 -0
  99. data/config/locales/en.yml +42 -992
  100. data/db/migrate/20260613000001_add_store_id_to_spree_role_users.rb +10 -0
  101. data/db/migrate/20260626000001_add_store_id_to_spree_taxons.rb +11 -0
  102. data/db/migrate/20260627000001_add_products_count_to_spree_taxons.rb +6 -0
  103. data/db/migrate/20260628000001_add_store_id_to_spree_promotions.rb +10 -0
  104. data/db/migrate/20260628000002_add_store_id_to_spree_payment_methods.rb +11 -0
  105. data/db/migrate/20260630000001_add_preorder_fields_to_spree_variants.rb +7 -0
  106. data/db/migrate/20260707000001_add_fingerprint_to_spree_credit_cards.rb +39 -0
  107. data/db/migrate/20260710000001_add_import_id_status_index_to_spree_import_rows.rb +7 -0
  108. data/db/migrate/20260711000001_add_preferences_to_spree_exports.rb +7 -0
  109. data/db/sample_data/promotions.rb +1 -1
  110. data/lib/generators/spree/authentication/dummy/templates/create_spree_admin_users.rb.tt +0 -1
  111. data/lib/mobility/plugins/store_based_fallbacks.rb +7 -5
  112. data/lib/spree/core/configuration.rb +1 -0
  113. data/lib/spree/core/controller_helpers/locale.rb +4 -1
  114. data/lib/spree/core/controller_helpers/strong_parameters.rb +1 -0
  115. data/lib/spree/core/dependencies.rb +3 -0
  116. data/lib/spree/core/engine.rb +24 -0
  117. data/lib/spree/core/preferences/preferable.rb +6 -1
  118. data/lib/spree/core/previews/admin_user_preview.rb +22 -0
  119. data/lib/spree/core/previews/export_preview.rb +37 -0
  120. data/lib/spree/core/previews/invitation_preview.rb +47 -0
  121. data/lib/spree/core/previews/preview_data.rb +52 -0
  122. data/lib/spree/core/previews/report_preview.rb +40 -0
  123. data/lib/spree/core/previews/webhook_preview.rb +34 -0
  124. data/lib/spree/core/version.rb +1 -1
  125. data/lib/spree/core.rb +22 -1
  126. data/lib/spree/permitted_attributes.rb +6 -5
  127. data/lib/spree/testing_support/factories/export_factory.rb +4 -0
  128. data/lib/spree/testing_support/factories/payment_method_factory.rb +1 -9
  129. data/lib/spree/testing_support/factories/promotion_factory.rb +1 -9
  130. data/lib/spree/testing_support/factories/promotion_rule_factory.rb +24 -0
  131. data/lib/spree/translations.rb +113 -0
  132. data/lib/spree/upgrades/5_5_to_5_6/manifest.yml +59 -0
  133. data/lib/tasks/role_users.rake +21 -0
  134. data/lib/tasks/single_store_associations.rake +65 -0
  135. data/lib/tasks/taxons.rake +62 -1
  136. metadata +47 -5
  137. data/app/models/spree/product/legacy_multi_store_support.rb +0 -40
@@ -123,12 +123,16 @@ module Spree
123
123
 
124
124
  def prepare_shipping_category
125
125
  shipping_category_name = attributes['shipping_category'].strip
126
- Spree::ShippingCategory.find_by(name: shipping_category_name)
126
+ cached_lookup(:shipping_category, shipping_category_name) do
127
+ Spree::ShippingCategory.find_by(name: shipping_category_name)
128
+ end
127
129
  end
128
130
 
129
131
  def prepare_tax_category
130
132
  tax_category_name = attributes['tax_category'].strip
131
- Spree::TaxCategory.find_by(name: tax_category_name)
133
+ cached_lookup(:tax_category, tax_category_name) do
134
+ Spree::TaxCategory.find_by(name: tax_category_name)
135
+ end
132
136
  end
133
137
 
134
138
  def prepare_option_value_variants
@@ -171,27 +175,39 @@ module Spree
171
175
  # surfaces via the DB unique index (RecordNotUnique) or the AR uniqueness
172
176
  # validator (RecordInvalid with a :taken error on the relevant attribute).
173
177
  def find_or_create_option_type!(presentation)
174
- Spree::OptionType.search_by_name(presentation).first || Spree::OptionType.create!(presentation: presentation)
175
- rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
176
- raise unless uniqueness_conflict?(e, :name)
178
+ cached_lookup(:option_type, presentation) do
179
+ begin
180
+ Spree::OptionType.search_by_name(presentation).first || Spree::OptionType.create!(presentation: presentation)
181
+ rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
182
+ raise unless uniqueness_conflict?(e, :name)
177
183
 
178
- Spree::OptionType.search_by_name(presentation).first!
184
+ Spree::OptionType.search_by_name(presentation).first!
185
+ end
186
+ end
179
187
  end
180
188
 
181
189
  def find_or_create_option_value!(option_type, presentation)
182
- option_type.option_values.search_by_name(presentation).first || option_type.option_values.create!(presentation: presentation)
183
- rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
184
- raise unless uniqueness_conflict?(e, :name)
190
+ cached_lookup(:option_value, option_type.id, presentation) do
191
+ begin
192
+ option_type.option_values.search_by_name(presentation).first || option_type.option_values.create!(presentation: presentation)
193
+ rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
194
+ raise unless uniqueness_conflict?(e, :name)
185
195
 
186
- option_type.option_values.search_by_name(presentation).first!
196
+ option_type.option_values.search_by_name(presentation).first!
197
+ end
198
+ end
187
199
  end
188
200
 
189
201
  def find_or_create_product_option_type!(option_type)
190
- Spree::ProductOptionType.find_or_create_by!(product: product, option_type: option_type)
191
- rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
192
- raise unless uniqueness_conflict?(e, :product_id)
202
+ cached_lookup(:product_option_type, product.id, option_type.id) do
203
+ begin
204
+ Spree::ProductOptionType.find_or_create_by!(product: product, option_type: option_type)
205
+ rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
206
+ raise unless uniqueness_conflict?(e, :product_id)
193
207
 
194
- Spree::ProductOptionType.find_by!(product: product, option_type: option_type)
208
+ Spree::ProductOptionType.find_by!(product: product, option_type: option_type)
209
+ end
210
+ end
195
211
  end
196
212
 
197
213
  # RecordNotUnique is always a uniqueness conflict; RecordInvalid only when the
@@ -245,11 +261,13 @@ module Spree
245
261
  namespace, key = product.extract_namespace_and_key(full_key)
246
262
 
247
263
  # Find or initialize metafield definition
248
- metafield_definition = Spree::MetafieldDefinition.find_by(
249
- namespace: namespace,
250
- key: key,
251
- resource_type: product.class.name
252
- )
264
+ metafield_definition = cached_lookup(:metafield_definition, namespace, key, product.class.name) do
265
+ Spree::MetafieldDefinition.find_by(
266
+ namespace: namespace,
267
+ key: key,
268
+ resource_type: product.class.name
269
+ )
270
+ end
253
271
 
254
272
  next unless metafield_definition
255
273
 
@@ -9,7 +9,13 @@ module Spree
9
9
  object[locale] = [store_default_locale]
10
10
  end
11
11
 
12
- fallbacks_instance = I18n::Locale::Fallbacks.new(fallbacks)
12
+ # Pass the store default as the terminal default so EVERY locale —
13
+ # including regional variants not in `supported_locales_list` (e.g.
14
+ # `pt-BR` when only `pt` is configured) — ultimately falls back to it.
15
+ # The store default resolves to the populated DB column via Mobility's
16
+ # `column_fallback`, so translated reads never return nil for a locale
17
+ # that lacks a translation row.
18
+ fallbacks_instance = I18n::Locale::Fallbacks.new(store_default_locale, fallbacks)
13
19
 
14
20
  Mobility.store_based_fallbacks = fallbacks_instance
15
21
  end
@@ -0,0 +1,52 @@
1
+ module Spree
2
+ module Locales
3
+ # Canonical set of locale codes a merchant may translate **data** into
4
+ # (Mobility-backed content such as product names, descriptions, taxons,
5
+ # option values, etc.).
6
+ #
7
+ # This is deliberately **independent of the admin/storefront UI translation
8
+ # set** (`Spree.available_locales`, which reflects which `spree_i18n` UI
9
+ # bundles happen to be installed). In a headless setup the storefront ships
10
+ # its own UI translations, so the languages a merchant can store content in
11
+ # must not be constrained by which admin chrome translations exist.
12
+ #
13
+ # Codes follow BCP-47 casing (lowercase language, uppercase region —
14
+ # `pt-BR`, `zh-CN`) so the browser's `Intl.DisplayNames` resolves a clean
15
+ # localized label for each. The list is the full ISO 639-1 base-language set
16
+ # plus the regional variants that matter for commerce (British vs. US
17
+ # English, Brazilian vs. European Portuguese, Simplified vs. Traditional
18
+ # Chinese, etc.).
19
+ #
20
+ # The underlying translation tables accept any locale string, so this list
21
+ # governs only what the locale pickers offer — it is not a hard storage
22
+ # constraint.
23
+ ALL = %w[
24
+ aa ab ae af ak am an ar as av ay az
25
+ ba be bg bh bi bm bn bo br bs
26
+ ca ce ch co cr cs cu cv cy
27
+ da de de-AT de-CH dv dz
28
+ ee el en en-AU en-CA en-GB en-IN en-NZ eo es es-419 es-MX et eu
29
+ fa ff fi fj fo fr fr-CA fy
30
+ ga gd gl gn gu gv
31
+ ha he hi ho hr ht hu hy hz
32
+ ia id ie ig ii ik io is it iu
33
+ ja jv
34
+ ka kg ki kj kk kl km kn ko kr ks ku kv kw ky
35
+ la lb lg li ln lo lt lu lv
36
+ mg mh mi mk ml mn mr ms mt my
37
+ na nb nd ne ng nl nn no nr nv ny
38
+ oc oj om or os
39
+ pa pi pl ps pt pt-BR pt-PT
40
+ qu
41
+ rm rn ro ru rw
42
+ sa sc sd se sg si sk sl sm sn so sq sr ss st su sv sw
43
+ ta te tg th ti tk tl tn to tr ts tt tw ty
44
+ ug uk ur uz
45
+ ve vi vo
46
+ wa wo
47
+ xh
48
+ yi yo
49
+ za zh-CN zh-HK zh-TW zu
50
+ ].freeze
51
+ end
52
+ end
@@ -7,6 +7,12 @@ module Spree
7
7
  # from other stores are preserved. This prevents accidental data loss when a store
8
8
  # admin updates product categories in their store without affecting other stores.
9
9
  #
10
+ # Variant removal is opt-in: only variants explicitly listed in the
11
+ # `removed_variant_ids` param are marked for destruction (or collected for
12
+ # discontinuation when they have completed orders). Variants merely absent from
13
+ # `variants_attributes` are left untouched, so a partially rendered or broken
14
+ # form can never silently mass-delete variants.
15
+ #
10
16
  # @example
11
17
  # service = Spree::Products::PrepareNestedAttributes.new(
12
18
  # product,
@@ -28,10 +34,13 @@ module Spree
28
34
  end
29
35
 
30
36
  def call
37
+ extract_removed_variant_ids
38
+
31
39
  if params[:variants_attributes]
32
40
  params[:variants_attributes].each do |key, variant_params|
33
41
  existing_variant = variant_params[:id].presence && @product.variants.find_by(id: variant_params[:id])
34
- variants_to_remove.delete(variant_params[:id]) if variant_params[:id].present?
42
+ # a re-submitted variant always wins over a removal request
43
+ variants_to_remove.delete(variant_params[:id].to_s) if variant_params[:id].present?
35
44
 
36
45
  variant_params.delete(:price) # remove legacy price param
37
46
 
@@ -72,17 +81,20 @@ module Spree
72
81
  # ensure the product is owned by a store
73
82
  params[:store_id] = store.id if params[:store_id].blank? && product.store_id.blank?
74
83
 
75
- # Add empty list for option_type_ids and mark variants as removed if there are no variants and options
76
- if params[:variants_attributes].blank? && variants_to_remove.any? && !params.key?(:option_type_ids)
77
- params[:option_type_ids] = []
78
- params[:variants_attributes] = {}
84
+ # The variants matrix was emptied: no variant rows re-submitted, removals sent instead.
85
+ # Option types are detached only when the removal list covers every variant, so a
86
+ # partial (possibly broken) submission never turns the product into a simple one.
87
+ # Variants kept alive by discontinuation still count as removed from the matrix,
88
+ # so the detachment can't hinge on any of them yielding a `_destroy` row.
89
+ if params[:variants_attributes].blank? && variants_to_remove.any? && can_remove_variants?
90
+ attributes = removed_variants_attributes
79
91
 
80
- populate_variants_to_discontinue
81
- variant_ids_to_destroy.each_with_index do |variant_id, index|
82
- params[:variants_attributes][index.to_s] = { id: variant_id, _destroy: '1' }
83
- end
92
+ params[:option_type_ids] = [] if removing_all_variants? && !params.key?(:option_type_ids)
84
93
 
85
- params[:variants_attributes].permit!
94
+ if attributes.any?
95
+ params[:variants_attributes] = attributes
96
+ params[:variants_attributes].permit!
97
+ end
86
98
  end
87
99
 
88
100
  params
@@ -116,8 +128,24 @@ module Spree
116
128
  @product_option_types_to_remove ||= product.product_option_type_ids
117
129
  end
118
130
 
131
+ # Pulls `removed_variant_ids` out of the params so it never reaches Product#update.
132
+ # Must run before any `variants_to_remove` access.
133
+ def extract_removed_variant_ids
134
+ @removed_variant_ids = Array(params.delete(:removed_variant_ids)).map(&:to_s)
135
+ end
136
+
137
+ # Only variants the client explicitly asked to remove, and only ones that
138
+ # actually belong to this product.
119
139
  def variants_to_remove
120
- @variants_to_remove ||= product.variant_ids.map(&:to_s)
140
+ @variants_to_remove ||= (@removed_variant_ids || []).uniq & all_variant_ids
141
+ end
142
+
143
+ def all_variant_ids
144
+ @all_variant_ids ||= product.variant_ids.map(&:to_s)
145
+ end
146
+
147
+ def removing_all_variants?
148
+ (all_variant_ids - variants_to_remove).empty?
121
149
  end
122
150
 
123
151
  def can_update_prices?
@@ -142,7 +170,7 @@ module Spree
142
170
  populate_variants_to_discontinue
143
171
 
144
172
  attributes = {}
145
- last_index = params[:variants_attributes].keys.map(&:to_i).max
173
+ last_index = params[:variants_attributes].presence&.keys&.map(&:to_i)&.max || -1
146
174
  variant_ids_to_destroy.each_with_index do |variant_id, index|
147
175
  attributes[(last_index + 1 + index).to_s] = { id: variant_id, _destroy: '1' }
148
176
  end
@@ -8,7 +8,7 @@ module Spree
8
8
  ActiveRecord::Base.transaction do
9
9
  return failure(shipment) unless shipment.update(shipment_attributes)
10
10
 
11
- if shipment_attributes.key?(:selected_shipping_rate_id)
11
+ if shipment_attributes.key?(:selected_shipping_rate_id) || shipment_attributes.key?(:selected_delivery_rate_id)
12
12
  order = shipment.order
13
13
 
14
14
  # Changing the selected Shipping Rate won't update the cost (for now)
@@ -65,7 +65,10 @@ module Spree
65
65
  next unless variant&.should_track_inventory?
66
66
 
67
67
  stock_item = select_stock_item(variant)
68
- next if stock_item.nil? || stock_item.backorderable?
68
+ # Backorderable and pre-order variants oversell past count_on_hand,
69
+ # so a reservation capped at on-hand stock would wrongly reject them;
70
+ # their cap is enforced by Stock::Quantifier#can_supply? instead.
71
+ next if stock_item.nil? || stock_item.backorderable? || variant.preorder?
69
72
 
70
73
  [line_item, stock_item]
71
74
  end
@@ -33,6 +33,9 @@ module Spree
33
33
  product_ids = products.pluck(:id)
34
34
  taxon_ids.each { |id| Spree::Taxon.reset_counters(id, :classifications) }
35
35
  product_ids.each { |id| Spree::Product.reset_counters(id, :classifications) }
36
+ # Recompute the descendant-inclusive products_count for the taxons and
37
+ # their ancestors (bulk insert skips Classification callbacks).
38
+ Spree::Taxon.recalculate_products_count(taxon_ids)
36
39
 
37
40
  # clear cache & index products
38
41
  Spree::Product.where(id: product_ids).touch_all
@@ -48,6 +48,9 @@ module Spree
48
48
  # update counter caches
49
49
  taxon_ids.each { |id| Spree::Taxon.reset_counters(id, :classifications) }
50
50
  product_ids.each { |id| Spree::Product.reset_counters(id, :classifications) }
51
+ # Recompute the descendant-inclusive products_count for the taxons and
52
+ # their ancestors (delete_all skips Classification callbacks).
53
+ Spree::Taxon.recalculate_products_count(taxon_ids)
51
54
 
52
55
  # clear cache & index products
53
56
  Spree::Product.where(id: product_ids).touch_all
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ class AdminUserEmailSubscriber < Spree::Subscriber
5
+ subscribes_to 'admin_user.password_reset_requested'
6
+
7
+ def handle(event)
8
+ user = Spree.admin_user_class.find_by(email: event.payload['email'])
9
+ return unless user
10
+
11
+ store = find_store(event)
12
+
13
+ AdminUserMailer.password_reset_email(
14
+ user,
15
+ event.payload['reset_token'],
16
+ store,
17
+ redirect_url: event.payload['redirect_url']
18
+ ).deliver_later
19
+ end
20
+
21
+ private
22
+
23
+ def find_store(event)
24
+ Spree::Store.find_by_prefix_id(event.payload['store_id']) ||
25
+ Spree::Current.store ||
26
+ Spree::Store.default
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ # Emails the import's owner when processing finishes — including after a
5
+ # failed-rows retry pass, which publishes `import.completed` again.
6
+ # App-created imports (API key without an admin creator) have no recipient
7
+ # and send nothing.
8
+ #
9
+ # We use async: false because this subscriber only enqueues the mail job.
10
+ class ImportEmailSubscriber < Spree::Subscriber
11
+ subscribes_to 'import.completed', async: false
12
+
13
+ on 'import.completed', :send_import_done_email
14
+
15
+ def send_import_done_email(event)
16
+ import_id = event.payload['id']
17
+ return unless import_id
18
+
19
+ import = Spree::Import.find_by_prefix_id(import_id)
20
+ return unless import
21
+ return if import.user&.email.blank?
22
+
23
+ Spree::ImportMailer.import_done(import).deliver_later
24
+ end
25
+ end
26
+ end
@@ -1,46 +1,43 @@
1
1
  <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
2
- <html>
2
+ <html lang="<%= I18n.locale %>">
3
3
  <head>
4
4
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
5
5
  <meta name="x-apple-disable-message-reformatting"/>
6
6
  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
7
+ <meta name="color-scheme" content="light"/>
8
+ <meta name="supported-color-schemes" content="light"/>
7
9
 
8
10
  <%= render partial: 'spree/shared/base_mailer_stylesheets' %>
11
+ <%# Per-email styles — templates add their own via `content_for :head` %>
12
+ <%= yield :head %>
9
13
  </head>
10
- <body>
11
- <table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
14
+ <body class="email-bg" style="margin: 0; padding: 0; -webkit-text-size-adjust: none;" bgcolor="#FFFFFF">
15
+ <table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation" bgcolor="#FFFFFF">
12
16
  <tr>
13
17
  <td align="center">
14
18
  <table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
15
- <%= render partial: 'spree/shared/base_mailer_header' %>
16
19
  <!-- Email Body -->
17
20
  <tr>
18
- <td class="email-body" width="570" cellpadding="0" cellspacing="0">
19
- <table class="email-body_inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
20
- <!-- Body content -->
21
- <tbody>
21
+ <td class="email-card" bgcolor="#F6F6F6">
22
+ <%= render partial: 'spree/shared/base_mailer_header' %>
23
+ <table width="100%" cellpadding="0" cellspacing="0" role="presentation">
22
24
  <tr>
23
- <td class="content-cell">
24
- <div class="f-fallback">
25
- <%= yield %>
26
- </div>
25
+ <td class="content-cell f-fallback" align="left">
26
+ <%= yield %>
27
27
  </td>
28
28
  </tr>
29
- </tbody>
30
29
  </table>
31
30
  </td>
32
31
  </tr>
32
+ <!-- Email Footer -->
33
33
  <tr>
34
- <td>
34
+ <td class="email-footer" align="center">
35
35
  <%= render partial: 'spree/shared/base_mailer_footer' %>
36
36
  </td>
37
37
  </tr>
38
38
  </table>
39
39
  </td>
40
40
  </tr>
41
- <tr>
42
- <td height="60px">&nbsp;</td>
43
- </tr>
44
41
  </table>
45
42
  </body>
46
43
  </html>
@@ -0,0 +1,7 @@
1
+ <%= render layout: 'spree/shared/mailer_hero' do %>
2
+ <h1><%= Spree.t('admin_user_mailer.confirmation_email.action') %></h1>
3
+ <p class="greeting"><%= Spree.t('admin_user_mailer.confirmation_email.greeting', name: @user.first_name.presence || @user.email) %></p>
4
+ <p><%= Spree.t('admin_user_mailer.confirmation_email.instructions', store_name: @current_store.name) %></p>
5
+ <%= render 'spree/shared/mailer_button', url: @confirmation_url, label: Spree.t('admin_user_mailer.confirmation_email.action') %>
6
+ <p class="sub"><%= Spree.t('admin_user_mailer.confirmation_email.ignore_notice') %></p>
7
+ <% end %>
@@ -0,0 +1,8 @@
1
+ <%= render layout: 'spree/shared/mailer_hero' do %>
2
+ <h1><%= Spree.t('admin_user_mailer.password_reset_email.action') %></h1>
3
+ <p class="greeting"><%= Spree.t('admin_user_mailer.password_reset_email.greeting', name: @user.first_name.presence || @user.email) %></p>
4
+ <p><%= Spree.t('admin_user_mailer.password_reset_email.instructions', store_name: @current_store.name) %></p>
5
+ <%= render 'spree/shared/mailer_button', url: @reset_url, label: Spree.t('admin_user_mailer.password_reset_email.action') %>
6
+ <p class="sub"><%= Spree.t('admin_user_mailer.password_reset_email.expiry_notice') %></p>
7
+ <p class="sub"><%= Spree.t('admin_user_mailer.password_reset_email.ignore_notice') %></p>
8
+ <% end %>
@@ -1,15 +1,7 @@
1
- <h1>
2
- <%= Spree.t('export_mailer.greeting', user_name: @export.user&.first_name) %>
3
- </h1>
4
- <p>
5
- <%= Spree.t('export_mailer.export_done.message') %>
6
- </p>
7
- <% if spree.respond_to?(:admin_export_url) %>
8
- <p>
9
- <%= link_to @export.attachment.filename, spree.admin_export_url(@export, host: @current_store.formatted_url) %>
10
- </p>
1
+ <%= render layout: 'spree/shared/mailer_hero' do %>
2
+ <h1><%= Spree.t('export_mailer.greeting', user_name: @export.user&.first_name) %></h1>
3
+ <p><%= Spree.t('export_mailer.export_done.message') %></p>
4
+ <% if @export.results_url.present? %>
5
+ <%= render 'spree/shared/mailer_button', url: @export.results_url, label: @export.attachment.filename %>
6
+ <% end %>
11
7
  <% end %>
12
- <p>
13
- <%= Spree.t('export_mailer.thanks') %><br />
14
- <%= "#{current_store.name}" %>
15
- </p>
@@ -0,0 +1,12 @@
1
+ <%= render layout: 'spree/shared/mailer_hero' do %>
2
+ <h1><%= Spree.t('import_mailer.greeting', user_name: @import.user&.first_name) %></h1>
3
+ <p><%= Spree.t('import_mailer.import_done.message', completed_count: @import.rows_status_counts['completed'] || 0) %></p>
4
+ <% failed_count = @import.rows_status_counts['failed'] || 0 %>
5
+ <% if failed_count > 0 %>
6
+ <p><strong><%= Spree.t('import_mailer.import_done.failed_message', failed_count: failed_count) %></strong></p>
7
+ <% end %>
8
+ <% results_url = @import.results_page_url %>
9
+ <% if results_url.present? %>
10
+ <%= render 'spree/shared/mailer_button', url: results_url, label: Spree.t('import_mailer.import_done.view_results') %>
11
+ <% end %>
12
+ <% end %>
@@ -1,12 +1,4 @@
1
- <h1>
2
- <%= Spree.t('invitation_mailer.greeting', user_name: @invitation.inviter.first_name) %>
3
- </h1>
4
- <p>
5
- <%= Spree.t('invitation_mailer.invitation_accepted.body', invitee_name: @invitation.invitee&.name, resource_name: @invitation.resource.name) %>
6
- </p>
7
-
8
- <p>
9
- <%= Spree.t('invitation_mailer.thanks') %><br />
10
- <%= current_store.name %>
11
- </p>
12
-
1
+ <%= render layout: 'spree/shared/mailer_hero' do %>
2
+ <h1><%= Spree.t('invitation_mailer.greeting', user_name: @invitation.inviter.first_name) %></h1>
3
+ <p><%= Spree.t('invitation_mailer.invitation_accepted.body', invitee_name: @invitation.invitee&.name, resource_name: @invitation.resource.name) %></p>
4
+ <% end %>
@@ -1,25 +1,12 @@
1
- <h1>
2
- <%= Spree.t('invitation_mailer.greeting', user_name: @invitation.invitee&.first_name || @invitation.email) %>
3
- </h1>
4
- <p>
5
- <%= Spree.t('invitation_mailer.invitation_email.body', inviter_name: @invitation.inviter.name, resource_name: @invitation.resource.name) %>
6
- </p>
7
- <p>
8
- <%= Spree.t('invitation_mailer.invitation_email.link_description') %>
9
- </p>
10
-
11
- <% if spree.respond_to?(:admin_invitation_url) %>
12
- <p>
13
- <%= link_to Spree.t(:accept), spree.admin_invitation_url(@invitation, token: @invitation.token, host: @invitation.store.formatted_url) %>
14
- </p>
15
- <% else %>
16
- <p>
17
- <%= link_to Spree.t(:accept), main_app.admin_invitation_acceptance_url(@invitation) %>
18
- </p>
1
+ <%= render layout: 'spree/shared/mailer_hero' do %>
2
+ <h1><%= Spree.t('invitation_mailer.invitation_email.subject', resource_name: @invitation.resource.name) %></h1>
3
+ <p class="greeting"><%= Spree.t('invitation_mailer.greeting', user_name: @invitation.invitee&.first_name || @invitation.email) %></p>
4
+ <p><%= Spree.t('invitation_mailer.invitation_email.body', inviter_name: @invitation.inviter.name, resource_name: @invitation.resource.name) %></p>
5
+ <p><%= Spree.t('invitation_mailer.invitation_email.link_description') %></p>
6
+ <% accept_url = if spree.respond_to?(:admin_invitation_url)
7
+ spree.admin_invitation_url(@invitation, token: @invitation.token, host: @invitation.store.formatted_url)
8
+ else
9
+ main_app.admin_invitation_acceptance_url(@invitation)
10
+ end %>
11
+ <%= render 'spree/shared/mailer_button', url: accept_url, label: Spree.t(:accept) %>
19
12
  <% end %>
20
-
21
- <p>
22
- <%= Spree.t('invitation_mailer.thanks') %><br />
23
- <%= current_store.name %>
24
- </p>
25
-
@@ -1,15 +1,7 @@
1
- <h1>
2
- <%= Spree.t('report_mailer.greeting', user_name: @report.user&.first_name) %>
3
- </h1>
4
- <p>
5
- <%= Spree.t('report_mailer.report_done.message') %>
6
- </p>
7
- <% if spree.respond_to?(:admin_report_url) %>
8
- <p>
9
- <%= link_to @report.attachment.filename, spree.admin_report_url(@report, host: @current_store.formatted_url) %>
10
- </p>
1
+ <%= render layout: 'spree/shared/mailer_hero' do %>
2
+ <h1><%= Spree.t('report_mailer.greeting', user_name: @report.user&.first_name) %></h1>
3
+ <p><%= Spree.t('report_mailer.report_done.message') %></p>
4
+ <% if spree.respond_to?(:admin_report_url) %>
5
+ <%= render 'spree/shared/mailer_button', url: spree.admin_report_url(@report, host: @current_store.formatted_url), label: @report.attachment.filename %>
6
+ <% end %>
11
7
  <% end %>
12
- <p>
13
- <%= Spree.t('report_mailer.thanks') %><br />
14
- <%= "#{current_store.name}" %>
15
- </p>
@@ -1,12 +1,19 @@
1
- <!-- You can override this template to design your own footer.
2
- <table class="email-footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
1
+ <!-- You can override this template to design your own footer. -->
2
+ <table width="100%" cellpadding="0" cellspacing="0" role="presentation">
3
3
  <tr>
4
- <td class="content-cell" align="center">
5
- <p class="f-fallback sub align-center">
6
- <a href="#">Terms</a> | <a href="#">Privacy</a> |
7
- <a href="#">Unsubscribe</a>
8
- </p>
4
+ <td align="center">
5
+ <div class="footer_content f-fallback">
6
+ <p class="footer_brand">
7
+ <%= link_to current_store.name, current_store.storefront_url %>
8
+ </p>
9
+ <% if current_store.address.present? %>
10
+ <p class="footer_meta"><%= current_store.address %></p>
11
+ <% end %>
12
+ <% if current_store.customer_support_email.present? %>
13
+ <p class="footer_meta"><%= mail_to current_store.customer_support_email %></p>
14
+ <% end %>
15
+ <p class="footer_note">&copy; <%= Time.current.year %> <%= current_store.name %></p>
16
+ </div>
9
17
  </td>
10
18
  </tr>
11
19
  </table>
12
- -->
@@ -1,6 +1,8 @@
1
1
  <!-- You can override this template to design your own header. -->
2
- <tr>
3
- <td class="email-masthead">
4
- <%= render 'spree/shared/mailer_logo', logo: current_store.mailer_logo.attached? ? current_store.mailer_logo : current_store.logo %>
5
- </td>
6
- </tr>
2
+ <table width="100%" cellpadding="0" cellspacing="0" role="presentation">
3
+ <tr>
4
+ <td class="email-masthead" align="center">
5
+ <%= render 'spree/shared/mailer_logo', logo: current_store.mailer_logo.attached? ? current_store.mailer_logo : current_store.logo, height: 32 %>
6
+ </td>
7
+ </tr>
8
+ </table>