spree_core 5.5.3 → 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 (133) 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/mailers/spree/admin_user_mailer.rb +71 -0
  7. data/app/mailers/spree/base_mailer.rb +73 -11
  8. data/app/mailers/spree/export_mailer.rb +7 -7
  9. data/app/mailers/spree/import_mailer.rb +19 -0
  10. data/app/mailers/spree/invitation_mailer.rb +16 -11
  11. data/app/mailers/spree/report_mailer.rb +6 -6
  12. data/app/mailers/spree/webhook_mailer.rb +7 -6
  13. data/app/models/concerns/spree/admin_user_methods.rb +44 -0
  14. data/app/models/concerns/spree/legacy_multi_store_support.rb +48 -0
  15. data/app/models/concerns/spree/presentation_translatable.rb +39 -0
  16. data/app/models/concerns/spree/product_scopes.rb +21 -3
  17. data/app/models/concerns/spree/single_store_resource.rb +5 -0
  18. data/app/models/concerns/spree/store_scoped_resource.rb +10 -0
  19. data/app/models/concerns/spree/stores/markets.rb +9 -1
  20. data/app/models/concerns/spree/stores/setup.rb +37 -23
  21. data/app/models/concerns/spree/translatable_resource.rb +95 -5
  22. data/app/models/concerns/spree/user_methods.rb +27 -2
  23. data/app/models/spree/ability.rb +5 -4
  24. data/app/models/spree/allowed_origin.rb +31 -0
  25. data/app/models/spree/category.rb +12 -2
  26. data/app/models/spree/channel/gating.rb +67 -0
  27. data/app/models/spree/channel.rb +1 -0
  28. data/app/models/spree/classification.rb +12 -0
  29. data/app/models/spree/country.rb +22 -1
  30. data/app/models/spree/credit_card.rb +29 -0
  31. data/app/models/spree/currency.rb +47 -0
  32. data/app/models/spree/current.rb +12 -1
  33. data/app/models/spree/customer_group.rb +1 -0
  34. data/app/models/spree/export.rb +19 -0
  35. data/app/models/spree/exports/coupon_codes.rb +1 -2
  36. data/app/models/spree/import.rb +84 -1
  37. data/app/models/spree/import_row.rb +7 -1
  38. data/app/models/spree/imports/product_translations.rb +6 -0
  39. data/app/models/spree/locale.rb +104 -0
  40. data/app/models/spree/option_type.rb +11 -13
  41. data/app/models/spree/option_value.rb +5 -17
  42. data/app/models/spree/order.rb +19 -2
  43. data/app/models/spree/payment_method.rb +6 -5
  44. data/app/models/spree/policy.rb +2 -1
  45. data/app/models/spree/product.rb +23 -2
  46. data/app/models/spree/promotion/rules/channel.rb +37 -0
  47. data/app/models/spree/promotion/rules/market.rb +37 -0
  48. data/app/models/spree/promotion.rb +5 -3
  49. data/app/models/spree/promotion_handler/promotion_duplicator.rb +1 -1
  50. data/app/models/spree/role_user.rb +4 -0
  51. data/app/models/spree/search_provider/meilisearch.rb +22 -9
  52. data/app/models/spree/setup_task.rb +12 -0
  53. data/app/models/spree/setup_tasks.rb +86 -0
  54. data/app/models/spree/shipment.rb +38 -1
  55. data/app/models/spree/shipment_handler.rb +1 -1
  56. data/app/models/spree/stock/availability_validator.rb +1 -1
  57. data/app/models/spree/stock/quantifier.rb +21 -1
  58. data/app/models/spree/stock_item.rb +2 -1
  59. data/app/models/spree/stock_location.rb +4 -1
  60. data/app/models/spree/store.rb +45 -9
  61. data/app/models/spree/store_payment_method.rb +5 -1
  62. data/app/models/spree/store_promotion.rb +4 -0
  63. data/app/models/spree/taxon.rb +112 -9
  64. data/app/models/spree/taxonomy.rb +1 -1
  65. data/app/models/spree/translations/batch.rb +104 -0
  66. data/app/models/spree/variant.rb +29 -2
  67. data/app/presenters/spree/search_provider/product_presenter.rb +3 -0
  68. data/app/services/spree/carts/complete.rb +3 -0
  69. data/app/services/spree/fulfillments/create.rb +259 -0
  70. data/app/services/spree/locales/set_fallback_locale_for_store.rb +7 -1
  71. data/app/services/spree/locales.rb +52 -0
  72. data/app/services/spree/products/prepare_nested_attributes.rb +40 -12
  73. data/app/services/spree/shipments/update.rb +1 -1
  74. data/app/services/spree/stock_reservations/reserve.rb +4 -1
  75. data/app/services/spree/taxons/add_products.rb +3 -0
  76. data/app/services/spree/taxons/remove_products.rb +3 -0
  77. data/app/subscribers/spree/admin_user_email_subscriber.rb +29 -0
  78. data/app/subscribers/spree/import_email_subscriber.rb +26 -0
  79. data/app/views/layouts/spree/base_mailer.html.erb +14 -17
  80. data/app/views/spree/admin_user_mailer/confirmation_email.html.erb +7 -0
  81. data/app/views/spree/admin_user_mailer/password_reset_email.html.erb +8 -0
  82. data/app/views/spree/export_mailer/export_done.html.erb +6 -14
  83. data/app/views/spree/import_mailer/import_done.html.erb +12 -0
  84. data/app/views/spree/invitation_mailer/invitation_accepted.html.erb +4 -12
  85. data/app/views/spree/invitation_mailer/invitation_email.html.erb +11 -24
  86. data/app/views/spree/report_mailer/report_done.html.erb +6 -14
  87. data/app/views/spree/shared/_base_mailer_footer.html.erb +15 -8
  88. data/app/views/spree/shared/_base_mailer_header.html.erb +7 -5
  89. data/app/views/spree/shared/_base_mailer_stylesheets.html.erb +211 -273
  90. data/app/views/spree/shared/_mailer_button.html.erb +10 -0
  91. data/app/views/spree/shared/_mailer_hero.html.erb +12 -0
  92. data/app/views/spree/shared/_purchased_items_styles.html.erb +112 -0
  93. data/app/views/spree/webhook_mailer/endpoint_disabled.html.erb +39 -14
  94. data/config/initializers/spree_store_setup_tasks.rb +23 -0
  95. data/config/locales/en.yml +42 -992
  96. data/db/migrate/20260613000001_add_store_id_to_spree_role_users.rb +10 -0
  97. data/db/migrate/20260626000001_add_store_id_to_spree_taxons.rb +11 -0
  98. data/db/migrate/20260627000001_add_products_count_to_spree_taxons.rb +6 -0
  99. data/db/migrate/20260628000001_add_store_id_to_spree_promotions.rb +10 -0
  100. data/db/migrate/20260628000002_add_store_id_to_spree_payment_methods.rb +11 -0
  101. data/db/migrate/20260630000001_add_preorder_fields_to_spree_variants.rb +7 -0
  102. data/db/migrate/20260707000001_add_fingerprint_to_spree_credit_cards.rb +39 -0
  103. data/db/migrate/20260710000001_add_import_id_status_index_to_spree_import_rows.rb +7 -0
  104. data/db/migrate/20260711000001_add_preferences_to_spree_exports.rb +7 -0
  105. data/db/sample_data/promotions.rb +1 -1
  106. data/lib/generators/spree/authentication/dummy/templates/create_spree_admin_users.rb.tt +0 -1
  107. data/lib/mobility/plugins/store_based_fallbacks.rb +7 -5
  108. data/lib/spree/core/configuration.rb +1 -0
  109. data/lib/spree/core/controller_helpers/locale.rb +4 -1
  110. data/lib/spree/core/controller_helpers/strong_parameters.rb +1 -0
  111. data/lib/spree/core/dependencies.rb +3 -0
  112. data/lib/spree/core/engine.rb +24 -0
  113. data/lib/spree/core/preferences/preferable.rb +6 -1
  114. data/lib/spree/core/previews/admin_user_preview.rb +22 -0
  115. data/lib/spree/core/previews/export_preview.rb +37 -0
  116. data/lib/spree/core/previews/invitation_preview.rb +47 -0
  117. data/lib/spree/core/previews/preview_data.rb +52 -0
  118. data/lib/spree/core/previews/report_preview.rb +40 -0
  119. data/lib/spree/core/previews/webhook_preview.rb +34 -0
  120. data/lib/spree/core/version.rb +1 -1
  121. data/lib/spree/core.rb +22 -1
  122. data/lib/spree/permitted_attributes.rb +5 -4
  123. data/lib/spree/testing_support/factories/export_factory.rb +4 -0
  124. data/lib/spree/testing_support/factories/payment_method_factory.rb +1 -9
  125. data/lib/spree/testing_support/factories/promotion_factory.rb +1 -9
  126. data/lib/spree/testing_support/factories/promotion_rule_factory.rb +24 -0
  127. data/lib/spree/translations.rb +113 -0
  128. data/lib/spree/upgrades/5_5_to_5_6/manifest.yml +59 -0
  129. data/lib/tasks/role_users.rake +21 -0
  130. data/lib/tasks/single_store_associations.rake +65 -0
  131. data/lib/tasks/taxons.rake +62 -1
  132. metadata +47 -5
  133. data/app/models/spree/product/legacy_multi_store_support.rb +0 -40
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2edebb88f8cab59b634a4bbbae5a24b15feab2d7673ecd521d70a1265b4e7bd0
4
- data.tar.gz: 1fa56dfbe5ebd747212b1608490c5414246cfad12e721f49d040c97f8299ef33
3
+ metadata.gz: c8058b9d81b22cf12abe2193023f52faa57b5d757b99e0359e4592415b00a434
4
+ data.tar.gz: 13d6653cb85ab1364290621d668ed291aecbff9442ce205803595fe316ed4ea7
5
5
  SHA512:
6
- metadata.gz: 20f0b006c40c37d8e8cbf25d97b99a73e9f3ce72d6a4903f6238aec8cad435f45c1f6517cfb54356732f7c0f69910e1013f45b7a344bc29506c888521661f46a
7
- data.tar.gz: c72003a7f9002a1443ef60fa19f70fe6aa4e4acf9f466012101889bd964c7aaf806d9f136fe158453c751d4f11dee74f4c42b9c9e1d39ec82b3994a5293337ee
6
+ metadata.gz: ecbab7dc49344e7c228237344ff01b7addacb6309e0e70cc6a42510823265cfd97827c2a00fb9c990a90b6ace535529b2d60e11c0b45bce745d3185a8422851a
7
+ data.tar.gz: a2657923c327254ee18b0b222aff4b6b7c3075b26d4c9ff001f66d3b2ad5b35f7daf6d36b5d16161cfaf62613707722ae8dcf55549c2a5c811c3333638240bab
@@ -8,7 +8,7 @@ module Spree
8
8
  countries = current_store.countries_available_for_checkout
9
9
 
10
10
  countries.collect do |country|
11
- country.name = Spree.t(country.iso, scope: 'country_names', default: country.name)
11
+ country.name = localized_country_name(country)
12
12
  country
13
13
  end.sort_by { |c| c.name.parameterize }
14
14
  end
@@ -17,7 +17,7 @@ module Spree
17
17
  countries = Spree::Country.all
18
18
 
19
19
  countries.collect do |country|
20
- country.name = Spree.t(country.iso, scope: 'country_names', default: country.name)
20
+ country.name = localized_country_name(country)
21
21
  country
22
22
  end.sort_by { |c| c.name.parameterize }
23
23
  end
@@ -112,6 +112,16 @@ module Spree
112
112
  inline_svg "payment_icons/#{payment_method}.svg", opts
113
113
  end
114
114
 
115
+ # @param country [Spree::Country]
116
+ # @return [String]
117
+ def localized_country_name(country)
118
+ Spree.t(
119
+ country.iso,
120
+ scope: 'country_names',
121
+ default: country.localized_name
122
+ )
123
+ end
124
+
115
125
  private
116
126
 
117
127
  I18N_PLURAL_MANY_COUNT = 2.1
@@ -36,10 +36,7 @@ module Spree
36
36
  # @param currency [String] the currency ISO code
37
37
  # @return [Array] the currency presentation and ISO code
38
38
  def currency_presentation(currency)
39
- currency_money = currency_money(currency)
40
- label = "#{currency_money.name} (#{currency_money.iso_code})"
41
-
42
- [label, currency]
39
+ [Spree::Currency.new(code: currency).label, currency]
43
40
  end
44
41
 
45
42
  def currency_money(currency = current_currency)
@@ -4,10 +4,27 @@ module Spree
4
4
  supported_locales_for_all_stores.map { |locale| locale_presentation(locale) }
5
5
  end
6
6
 
7
+ # Locales the admin UI is translated into, as [name, code] pairs for a
8
+ # select. Self-contained (reads Spree.available_locales directly) so it
9
+ # works outside the storefront/admin controller stack — e.g. the pre-auth
10
+ # login screen, which has no current store.
11
+ def admin_locales_options
12
+ Spree.available_locales.map { |locale| locale_presentation(locale) }
13
+ end
14
+
7
15
  def available_locales_options
8
16
  available_locales.map { |locale| locale_presentation(locale) }
9
17
  end
10
18
 
19
+ # Locales a merchant may translate **content** into, as [name, code] pairs
20
+ # for a select. Backed by `Spree::Locales::ALL` (the canonical translation
21
+ # locale set) rather than the installed UI-translation bundles, so a
22
+ # market/store can adopt any supported locale instead of only ones already
23
+ # in use.
24
+ def translation_locales_options
25
+ Spree::Locales::ALL.map { |locale| locale_presentation(locale) }
26
+ end
27
+
11
28
  def supported_locales_options
12
29
  return if current_store.nil?
13
30
 
@@ -15,19 +32,7 @@ module Spree
15
32
  end
16
33
 
17
34
  def locale_presentation(locale)
18
- if I18n.exists?('spree.i18n.this_file_language', locale: locale, fallback: false)
19
- [locale_full_name(locale), locale.to_s]
20
- elsif defined?(SpreeI18n::Locale) && (language_name = SpreeI18n::Locale.local_language_name(locale))
21
- ["#{language_name} (#{locale})", locale.to_s]
22
- elsif locale.to_s == 'en'
23
- ['English (US)', 'en']
24
- else
25
- [locale, locale.to_s]
26
- end
27
- end
28
-
29
- def locale_full_name(locale)
30
- Spree.t('i18n.this_file_language', locale: locale)
35
+ [Spree::Locale.new(code: locale).label, locale.to_s]
31
36
  end
32
37
 
33
38
  def should_render_locale_dropdown?
@@ -0,0 +1,25 @@
1
+ module Spree
2
+ module LocalizedNamesHelper
3
+ # @param code [String, Symbol]
4
+ # @return [String]
5
+ def locale_display_label(code)
6
+ return if code.blank?
7
+
8
+ content_tag(
9
+ :span,
10
+ Spree::Locale.new(code: code).label,
11
+ data: {
12
+ controller: 'display-name',
13
+ display_name_type_value: 'language',
14
+ display_name_code_value: code
15
+ }
16
+ )
17
+ end
18
+
19
+ # @param countries [Enumerable<Spree::Country>]
20
+ # @return [Array<Array>]
21
+ def country_select_options(countries)
22
+ countries.map { |country| [country.option_label, country.id] }
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,71 @@
1
+ module Spree
2
+ # Auth emails for admin users. Reached two ways: the legacy Rails admin's
3
+ # Devise flow (via Spree::AdminUserMethods::DeviseNotifications) and the Admin
4
+ # API's `admin_user.password_reset_requested` event (dashboard SPA).
5
+ class AdminUserMailer < BaseMailer
6
+ def password_reset_email(admin_user, token, store, redirect_url: nil)
7
+ @user = admin_user
8
+ @current_store = store
9
+ @reset_url = password_reset_url(token, store, redirect_url)
10
+
11
+ with_store_locale(store, preferred_locale(admin_user, store)) do
12
+ mail(
13
+ to: admin_user.email,
14
+ subject: "#{store.name} #{Spree.t('admin_user_mailer.password_reset_email.subject')}",
15
+ store_url: store.formatted_url
16
+ )
17
+ end
18
+ end
19
+
20
+ def confirmation_email(admin_user, token, store)
21
+ @user = admin_user
22
+ @current_store = store
23
+ @confirmation_url = confirmation_url(token, store)
24
+
25
+ with_store_locale(store, preferred_locale(admin_user, store)) do
26
+ mail(
27
+ to: admin_user.email,
28
+ subject: "#{store.name} #{Spree.t('admin_user_mailer.confirmation_email.subject')}",
29
+ store_url: store.formatted_url
30
+ )
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ # Locale chain for admin auth emails:
37
+ # the admin's own dashboard language (persisted by the profile/language
38
+ # switcher via `PATCH /api/v3/admin/me`) → the store's configured admin
39
+ # locale → nil, which lets with_store_locale fall back to the store's
40
+ # default (storefront) locale. Blank or unavailable values fall through.
41
+ def preferred_locale(admin_user, store)
42
+ [admin_user.try(:selected_locale), store&.preferred_admin_locale]
43
+ .find { |locale| available_locale?(locale) }
44
+ end
45
+
46
+ def available_locale?(locale)
47
+ locale.present? && I18n.available_locales.map(&:to_s).include?(locale.to_s)
48
+ end
49
+
50
+ # The dashboard SPA passes a validated redirect URL (token appended as a
51
+ # query param); the legacy Devise flow links to its own edit-password route
52
+ # when the host app installed it.
53
+ def password_reset_url(token, store, redirect_url)
54
+ return append_token(redirect_url, token) if redirect_url.present?
55
+
56
+ if spree.respond_to?(:edit_admin_user_password_url)
57
+ spree.edit_admin_user_password_url(reset_password_token: token, host: store.formatted_url)
58
+ else
59
+ append_token(store.formatted_url, token)
60
+ end
61
+ end
62
+
63
+ def confirmation_url(token, store)
64
+ if spree.respond_to?(:admin_user_confirmation_url)
65
+ spree.admin_user_confirmation_url(confirmation_token: token, host: store.formatted_url)
66
+ else
67
+ append_token(store.formatted_url, token)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -2,18 +2,46 @@ module Spree
2
2
  class BaseMailer < ActionMailer::Base
3
3
  helper Spree::ImagesHelper
4
4
 
5
+ default from: -> { from_address }, reply_to: -> { reply_to_address }
6
+
5
7
  def current_store
6
8
  @current_store ||= @order&.store.presence || Spree::Store.current || Spree::Store.default
7
9
  end
8
10
 
9
11
  helper_method :current_store
10
12
 
13
+ # Render an email in the given locale, with the store's translation fallbacks
14
+ # active, and restore both afterwards. Controllers set these fallbacks per
15
+ # request via `set_fallback_locale`, but mailers run in background jobs where
16
+ # that never happens — so without this, translatable attributes (store name,
17
+ # product names, taxon names, …) return nil under a non-default locale and
18
+ # leave e.g. the footer blank. Setting the fallbacks here mirrors a request,
19
+ # so reads fall back to the store's default-locale value.
20
+ #
21
+ # @param store [Spree::Store]
22
+ # @param locale [String, Symbol, nil] defaults to the store's default locale
23
+ def with_store_locale(store, locale = nil, &block)
24
+ locale = locale.presence || store&.default_locale
25
+ return yield if locale.blank?
26
+
27
+ previous_fallbacks = Mobility.store_based_fallbacks
28
+ previously_active = @_store_locale_active
29
+ @_store_locale_active = true
30
+ begin
31
+ Spree::Locales::SetFallbackLocaleForStore.new.call(store: store) if store
32
+ I18n.with_locale(locale, &block)
33
+ ensure
34
+ @_store_locale_active = previously_active
35
+ Mobility.store_based_fallbacks = previous_fallbacks
36
+ end
37
+ end
38
+
11
39
  def from_address
12
40
  current_store.mail_from_address
13
41
  end
14
42
 
15
43
  def reply_to_address
16
- current_store.mail_from_address
44
+ current_store.customer_support_email.presence || current_store.mail_from_address
17
45
  end
18
46
 
19
47
  def money(amount, currency = nil)
@@ -29,8 +57,50 @@ module Spree
29
57
 
30
58
  def mail(headers = {}, &block)
31
59
  ensure_default_action_mailer_url_host(headers[:store_url])
32
- set_email_locale
33
- super if Spree::Config[:send_core_emails]
60
+ return unless Spree::Config[:send_core_emails]
61
+
62
+ if @_store_locale_active
63
+ super
64
+ else
65
+ # Subclasses that call `mail` without wrapping their action in
66
+ # `with_store_locale` (e.g. Devise mailers, extensions) still get the
67
+ # store default locale, as `mail` applied before Spree 5.6.
68
+ with_store_locale(current_store) { super }
69
+ end
70
+ end
71
+
72
+ # @deprecated Each mailer action now wraps its body in {#with_store_locale},
73
+ # which also activates the store's translation fallbacks and restores the
74
+ # previous locale afterwards. This method mutates `I18n.locale` for the rest
75
+ # of the thread without restoring it. Will be removed in Spree 6.0.
76
+ def set_email_locale
77
+ Spree::Deprecation.warn(
78
+ 'Spree::BaseMailer#set_email_locale is deprecated and will be removed in Spree 6.0. ' \
79
+ 'Wrap the mailer action body in `with_store_locale(store, locale) { ... }` instead.'
80
+ )
81
+ locale = @order&.locale.presence || @order&.store&.default_locale || current_store&.default_locale
82
+ I18n.locale = locale if locale.present?
83
+ end
84
+
85
+ protected
86
+
87
+ # The "<store> <subject> #<number>" subject line shared by customer-facing
88
+ # order emails, with the optional [RESEND] prefix.
89
+ def order_email_subject(store, subject, number, resend: false)
90
+ "#{resend ? "[#{Spree.t(:resend).upcase}] " : ''}#{store.name} #{subject} ##{number}"
91
+ end
92
+
93
+ # URI-based merge preserves existing query params and fragments so the token
94
+ # doesn't get swallowed by a `#section` or clobber an existing `?source=`.
95
+ def append_token(url, token)
96
+ uri = URI.parse(url.to_s)
97
+ params = URI.decode_www_form(uri.query || '')
98
+ params << ['token', token.to_s]
99
+ uri.query = URI.encode_www_form(params)
100
+ uri.to_s
101
+ rescue URI::InvalidURIError
102
+ separator = url.include?('?') ? '&' : '?'
103
+ "#{url}#{separator}token=#{CGI.escape(token.to_s)}"
34
104
  end
35
105
 
36
106
  private
@@ -46,13 +116,5 @@ module Spree
46
116
  ActionMailer::Base.default_url_options ||= {}
47
117
  ActionMailer::Base.default_url_options[:host] = host_url
48
118
  end
49
-
50
- # Sets the I18n locale for the email.
51
- # Prefers the order's locale (the language the customer used),
52
- # falls back to the store's default locale.
53
- def set_email_locale
54
- locale = @order&.locale.presence || @order&.store&.default_locale || current_store&.default_locale
55
- I18n.locale = locale if locale.present?
56
- end
57
119
  end
58
120
  end
@@ -3,13 +3,13 @@ module Spree
3
3
  def export_done(export)
4
4
  @export = export
5
5
 
6
- mail(
7
- to: @export.user.email,
8
- subject: Spree.t('export_mailer.export_done.subject', export_number: @export.number).to_s,
9
- from: from_address,
10
- reply_to: reply_to_address,
11
- store_url: current_store.url
12
- )
6
+ with_store_locale(@export.store) do
7
+ mail(
8
+ to: @export.user.email,
9
+ subject: Spree.t('export_mailer.export_done.subject', export_number: @export.number).to_s,
10
+ store_url: current_store.url
11
+ )
12
+ end
13
13
  end
14
14
 
15
15
  def current_store
@@ -0,0 +1,19 @@
1
+ module Spree
2
+ class ImportMailer < Spree::BaseMailer
3
+ def import_done(import)
4
+ @import = import
5
+
6
+ with_store_locale(@import.store) do
7
+ mail(
8
+ to: @import.user.email,
9
+ subject: Spree.t('import_mailer.import_done.subject', import_number: @import.number).to_s,
10
+ store_url: current_store.url
11
+ )
12
+ end
13
+ end
14
+
15
+ def current_store
16
+ @current_store ||= @import.store
17
+ end
18
+ end
19
+ end
@@ -3,22 +3,27 @@ module Spree
3
3
  # invitation email, sending email to the invited to let them know they have been invited to join a store/account/vendor
4
4
  def invitation_email(invitation)
5
5
  @invitation = invitation
6
- mail(to: invitation.email,
7
- from: from_address,
8
- reply_to: reply_to_address,
9
- subject: Spree.t('invitation_mailer.invitation_email.subject',
10
- resource_name: invitation.resource&.name))
6
+ # The shared header/footer and from/reply-to addresses read
7
+ # current_store — without this, background delivery falls back to the
8
+ # default store's branding on multi-store installs.
9
+ @current_store = invitation.store
10
+ with_store_locale(invitation.store) do
11
+ mail(to: invitation.email,
12
+ subject: Spree.t('invitation_mailer.invitation_email.subject',
13
+ resource_name: invitation.resource&.name))
14
+ end
11
15
  end
12
16
 
13
17
  # sending email to the inviter to let them know the invitee has accepted the invitation
14
18
  def invitation_accepted(invitation)
15
19
  @invitation = invitation
16
- mail(to: invitation.inviter.email,
17
- from: from_address,
18
- reply_to: reply_to_address,
19
- subject: Spree.t('invitation_mailer.invitation_accepted.subject',
20
- invitee_name: invitation.invitee&.name,
21
- resource_name: invitation.resource&.name))
20
+ @current_store = invitation.store
21
+ with_store_locale(invitation.store) do
22
+ mail(to: invitation.inviter.email,
23
+ subject: Spree.t('invitation_mailer.invitation_accepted.subject',
24
+ invitee_name: invitation.invitee&.name,
25
+ resource_name: invitation.resource&.name))
26
+ end
22
27
  end
23
28
  end
24
29
  end
@@ -3,12 +3,12 @@ module Spree
3
3
  def report_done(report)
4
4
  @report = report
5
5
 
6
- mail(
7
- to: @report.user.email,
8
- subject: Spree.t('report_mailer.report_done.subject', report_name: @report.human_name).to_s,
9
- from: from_address,
10
- reply_to: reply_to_address
11
- )
6
+ with_store_locale(@report.store) do
7
+ mail(
8
+ to: @report.user.email,
9
+ subject: Spree.t('report_mailer.report_done.subject', report_name: @report.human_name).to_s
10
+ )
11
+ end
12
12
  end
13
13
 
14
14
  private
@@ -6,12 +6,13 @@ module Spree
6
6
  @endpoint = webhook_endpoint
7
7
  @current_store = webhook_endpoint.store
8
8
 
9
- mail(
10
- to: @current_store.new_order_notifications_email.presence || @current_store.mail_from_address,
11
- from: from_address,
12
- subject: Spree.t('webhook_mailer.endpoint_disabled.subject', endpoint_name: @endpoint.name || @endpoint.url),
13
- store_url: @current_store.formatted_url
14
- )
9
+ with_store_locale(webhook_endpoint.store) do
10
+ mail(
11
+ to: @current_store.new_order_notifications_email.presence || @current_store.mail_from_address,
12
+ subject: Spree.t('webhook_mailer.endpoint_disabled.subject', endpoint_name: @endpoint.name || @endpoint.url),
13
+ store_url: @current_store.formatted_url
14
+ )
15
+ end
15
16
  end
16
17
  end
17
18
  end
@@ -7,12 +7,34 @@ module Spree
7
7
  include Spree::RansackableAttributes
8
8
 
9
9
  included do
10
+ # Wins over Devise's implementation regardless of module include order, so
11
+ # host apps that add `devise ...` after this concern still send admin auth
12
+ # emails through Spree's mailer.
13
+ prepend Spree::AdminUserMethods::DeviseNotifications
14
+
10
15
  has_prefix_id :admin
11
16
 
12
17
  has_person_name
13
18
 
14
19
  normalizes :email, :first_name, :last_name, with: ->(value) { value&.to_s&.squish&.presence }
15
20
 
21
+ # Token auto-invalidates when password changes (salt changes)
22
+ # Expiration is configurable via Spree::Config.admin_password_reset_expires_in (in minutes)
23
+ generates_token_for :password_reset, expires_in: Spree::Config.admin_password_reset_expires_in.minutes do
24
+ # `try` rather than `&.`: models without a password_salt method or
25
+ # column at all (modern Devise) must fall through to encrypted_password
26
+ # — a bare call would raise NameError before the fallback.
27
+ try(:password_salt)&.last(10) || encrypted_password&.last(10)
28
+ end
29
+
30
+ def self.find_by_password_reset_token(token)
31
+ find_by_token_for(:password_reset, token)
32
+ end
33
+
34
+ def self.find_by_password_reset_token!(token)
35
+ find_by_token_for!(:password_reset, token)
36
+ end
37
+
16
38
  # Associations
17
39
  has_many :identities, class_name: 'Spree::UserIdentity', as: :user, dependent: :destroy
18
40
  has_many :canceled_orders, class_name: 'Spree::Order', foreign_key: :canceler_id
@@ -33,6 +55,8 @@ module Spree
33
55
  # Attachments
34
56
  has_one_attached :avatar, service: Spree.public_storage_service_name
35
57
 
58
+ validates :avatar, content_type: Rails.application.config.active_storage.web_image_content_types
59
+
36
60
  #
37
61
  # Attributes
38
62
  #
@@ -78,5 +102,25 @@ module Spree
78
102
  reports.destroy_all
79
103
  exports.destroy_all
80
104
  end
105
+
106
+ # Routes Devise's auth notifications through Spree's own mailer so admin
107
+ # password reset and account confirmation emails carry the store design and
108
+ # locale instead of Devise's built-in templates. Other notification kinds
109
+ # (e.g. unlock instructions) fall through to Devise.
110
+ module DeviseNotifications
111
+ def send_devise_notification(notification, *args)
112
+ token = args.first
113
+ store = Spree::Current.store || Spree::Store.default
114
+
115
+ case notification
116
+ when :reset_password_instructions
117
+ Spree::AdminUserMailer.password_reset_email(self, token, store).deliver_later
118
+ when :confirmation_instructions
119
+ Spree::AdminUserMailer.confirmation_email(self, token, store).deliver_later
120
+ else
121
+ super
122
+ end
123
+ end
124
+ end
81
125
  end
82
126
  end
@@ -0,0 +1,48 @@
1
+ module Spree
2
+ # Backwards-compatible shim for resources migrated from a many-to-many store
3
+ # relationship to a single-store +belongs_to :store+ FK (Spree::Product in 5.5,
4
+ # Spree::Promotion and Spree::PaymentMethod in 5.6). Maps the historic
5
+ # +stores+/+store_ids+ accessors onto the singular +store+/+store_id+, warning
6
+ # on use. The +spree_multi_store+ extension defines +SpreeMultiStore+ to
7
+ # suppress the include and restore the real +has_many :stores+ association.
8
+ module LegacyMultiStoreSupport
9
+ extend ActiveSupport::Concern
10
+
11
+ included do
12
+ # Legacy accessors for the many-to-many store relationship. These are deprecated
13
+ # and will be removed in Spree 6.0 once the models finish migrating to single-store ownership.
14
+ # @return [ActiveRecord::Relation<Spree::Store>] the single store wrapped in a relation, or an empty relation if no store is set
15
+ def stores
16
+ Spree::Deprecation.warn(
17
+ "#{self.class.base_class}#stores is deprecated. Please use #{self.class.base_class}#store instead. " \
18
+ 'If you want to continue using multiple stores please install spree_multi_store gem'
19
+ )
20
+ store ? Spree::Store.where(id: store.id) : Spree::Store.none
21
+ end
22
+
23
+ def store_ids
24
+ Spree::Deprecation.warn(
25
+ "#{self.class.base_class}#store_ids is deprecated. Please use #{self.class.base_class}#store_id instead. " \
26
+ 'If you want to continue using multiple stores please install spree_multi_store gem'
27
+ )
28
+ store_id ? [store_id] : []
29
+ end
30
+
31
+ def stores=(stores)
32
+ Spree::Deprecation.warn(
33
+ "#{self.class.base_class}#stores= is deprecated. Please use #{self.class.base_class}#store= instead. " \
34
+ 'If you want to continue using multiple stores please install spree_multi_store gem'
35
+ )
36
+ self.store = Array(stores).compact.first
37
+ end
38
+
39
+ def store_ids=(store_ids)
40
+ Spree::Deprecation.warn(
41
+ "#{self.class.base_class}#store_ids= is deprecated. Please use #{self.class.base_class}#store_id= instead. " \
42
+ 'If you want to continue using multiple stores please install spree_multi_store gem'
43
+ )
44
+ self.store_id = Array(store_ids).compact_blank.first
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,39 @@
1
+ module Spree
2
+ # Shared by models whose single translatable field is the legacy
3
+ # +presentation+ column exposed publicly as +label+ (OptionType, OptionValue).
4
+ # Carries the Mobility setup, the translation-table normalizer, the
5
+ # locale-aware +label+/+label=+ bridge, and the +label → presentation+
6
+ # translation-matrix alias in one place so the two models don't restate it.
7
+ module PresentationTranslatable
8
+ extend ActiveSupport::Concern
9
+ include Spree::TranslatableResource
10
+
11
+ TRANSLATABLE_FIELDS = %i[presentation].freeze
12
+
13
+ included do
14
+ translates(*TRANSLATABLE_FIELDS, column_fallback: Spree.mobility_column_fallback)
15
+
16
+ self::Translation.class_eval do
17
+ normalizes :presentation, with: ->(value) { value&.to_s&.squish&.presence }
18
+ end
19
+ end
20
+
21
+ class_methods do
22
+ # The translation matrix uses +label+ (the public API name) for read/write
23
+ # symmetry; the +label+/+label=+ bridge below routes it to +presentation+.
24
+ def translatable_field_aliases
25
+ { label: :presentation }
26
+ end
27
+ end
28
+
29
+ # alias_attribute bypasses Mobility's locale-aware reader/writer, so these
30
+ # explicit delegations are required to keep +label+ translation-aware.
31
+ def label(*args, **kwargs)
32
+ presentation(*args, **kwargs)
33
+ end
34
+
35
+ def label=(value)
36
+ self.presentation = value
37
+ end
38
+ end
39
+ end
@@ -308,7 +308,11 @@ module Spree
308
308
  # +.available+ without args returned future-dated products too.
309
309
  # @param currency [String, nil] currency to require a price in; nil
310
310
  # falls back to the default store's default currency.
311
- def self.available(available_on = nil, currency = nil)
311
+ # @param include_preorderable [Boolean] when true (storefront), products
312
+ # scheduled to publish later are still included if they have an active
313
+ # pre-order variant, so a "coming soon" launch can be pre-ordered before
314
+ # its publish date. Channel context only.
315
+ def self.available(available_on = nil, currency = nil, include_preorderable: false)
312
316
  cutoff = available_on
313
317
  cutoff = cutoff.beginning_of_minute if cutoff.respond_to?(:beginning_of_minute)
314
318
 
@@ -316,8 +320,22 @@ module Spree
316
320
 
317
321
  if cutoff
318
322
  scope = if (channel = Spree::Current.channel)
319
- scope.for_channel(channel)
320
- .where(Spree::ProductPublication.table_name => { published_at: [nil, ..cutoff] })
323
+ pub = Spree::ProductPublication.table_name
324
+ channel_scope = scope.for_channel(channel)
325
+ if include_preorderable
326
+ variants = Spree::Variant.quoted_table_name
327
+ products = Product.quoted_table_name
328
+ channel_scope.where(
329
+ "#{pub}.published_at IS NULL OR #{pub}.published_at <= :cutoff OR " \
330
+ "EXISTS (SELECT 1 FROM #{variants} WHERE #{variants}.product_id = #{products}.id " \
331
+ "AND #{variants}.deleted_at IS NULL AND #{variants}.preorderable = :preorderable " \
332
+ "AND (#{variants}.discontinue_on IS NULL OR #{variants}.discontinue_on > :cutoff) " \
333
+ "AND (#{variants}.preorder_ships_at IS NULL OR #{variants}.preorder_ships_at > :cutoff))",
334
+ cutoff: cutoff, preorderable: true
335
+ )
336
+ else
337
+ channel_scope.where(pub => { published_at: [nil, ..cutoff] })
338
+ end
321
339
  else
322
340
  scope.where(Product.table_name => { available_on: ..cutoff })
323
341
  end
@@ -3,6 +3,7 @@ module Spree
3
3
  extend ActiveSupport::Concern
4
4
 
5
5
  included do
6
+ before_validation :ensure_store, unless: :store_id?
6
7
  validate :ensure_store_association_is_not_changed
7
8
 
8
9
  scope :for_store, ->(store) { where(store_id: store.id) }
@@ -10,6 +11,10 @@ module Spree
10
11
 
11
12
  protected
12
13
 
14
+ def ensure_store
15
+ self.store ||= Spree::Current.store
16
+ end
17
+
13
18
  def ensure_store_association_is_not_changed
14
19
  if store_id_changed? && persisted?
15
20
  errors.add(:store, Spree.t('errors.messages.store_association_can_not_be_changed'))