spree_multi_store 1.0.0

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 (37) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.md +14 -0
  3. data/README.md +50 -0
  4. data/Rakefile +23 -0
  5. data/app/controllers/spree/admin/custom_domains_controller.rb +21 -0
  6. data/app/controllers/spree/admin/multi_store/base_controller_decorator.rb +13 -0
  7. data/app/controllers/spree/admin/multi_store/stores_controller.rb +35 -0
  8. data/app/finders/spree/stores/find_current.rb +28 -0
  9. data/app/helpers/spree/admin/multi_store_helper.rb +11 -0
  10. data/app/models/concerns/spree/multi_store_resource.rb +33 -0
  11. data/app/models/concerns/spree/store/multi_store_class_overrides.rb +15 -0
  12. data/app/models/concerns/spree/store/multi_store_methods.rb +108 -0
  13. data/app/models/concerns/spree/store/multi_store_overrides.rb +39 -0
  14. data/app/models/spree/custom_domain.rb +61 -0
  15. data/app/models/spree/multi_store/payment_method_decorator.rb +11 -0
  16. data/app/models/spree/multi_store/product_decorator.rb +11 -0
  17. data/app/models/spree/multi_store/promotion_decorator.rb +11 -0
  18. data/app/models/spree/multi_store/store_decorator.rb +13 -0
  19. data/app/views/spree/admin/custom_domains/_custom_domain.html.erb +11 -0
  20. data/app/views/spree/admin/custom_domains/_custom_domains.html.erb +19 -0
  21. data/app/views/spree/admin/custom_domains/_form.html.erb +7 -0
  22. data/app/views/spree/admin/custom_domains/edit.html.erb +1 -0
  23. data/app/views/spree/admin/custom_domains/index.html.erb +65 -0
  24. data/app/views/spree/admin/custom_domains/new.html.erb +1 -0
  25. data/app/views/spree/admin/multi_store/stores/new.html.erb +122 -0
  26. data/app/views/spree/admin/multi_store/stores/new.turbo_stream.erb +1 -0
  27. data/app/views/spree/admin/products/form/_stores.html.erb +29 -0
  28. data/app/views/spree/admin/shared/sidebar/_store_dropdown.html.erb +55 -0
  29. data/config/initializers/spree_multi_store.rb +19 -0
  30. data/config/initializers/spree_multi_store_navigation.rb +14 -0
  31. data/config/locales/en.yml +5 -0
  32. data/config/routes.rb +6 -0
  33. data/lib/spree/multi_store/engine.rb +22 -0
  34. data/lib/spree/multi_store/version.rb +5 -0
  35. data/lib/spree/multi_store.rb +3 -0
  36. data/lib/spree_multi_store.rb +1 -0
  37. metadata +134 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e04a1b3c2537beca45a3f71d694ab398b4beb627a8b1a7df529d2219720053fd
4
+ data.tar.gz: 3faad6a5bf518fa4a3939bee2475ee0799a5d545fed6dedd0b72521ef9c0ffae
5
+ SHA512:
6
+ metadata.gz: '0508c322500915fcd34e1c8f1d004020d2075c7abd27615b5b607a08374ced3eb074a64ab4fe1fbe4a764f4d46ebd64d65be80dc0b08c2147115a7c7f601eda5'
7
+ data.tar.gz: 4746391eeae2143b67e1fca2d70fac6853b00db0c8acc172f66fba4e7d84b94a2d1dd48cabbe8f6f723e63231f3d1ea4f04b46fd0217c3b8b151f648bfc4fdba
data/LICENSE.md ADDED
@@ -0,0 +1,14 @@
1
+ Copyright (c) 2026 Vendo Connect Inc. Vendo Sp. z o.o.
2
+
3
+ This program is free software: you can redistribute it and/or modify
4
+ it under the terms of the GNU Affero General Public License as published by
5
+ the Free Software Foundation, either version 3 of the License, or
6
+ (at your option) any later version.
7
+
8
+ This program is distributed in the hope that it will be useful,
9
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
10
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
+ GNU Affero General Public License for more details.
12
+
13
+ You should have received a copy of the GNU Affero General Public License
14
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
data/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # Spree Multi-Store
2
+
3
+ Multi-store switching, custom domains, and store resolution for [Spree Commerce](https://spreecommerce.org).
4
+
5
+ ## Features
6
+
7
+ - **Multi-Store Management** — create and manage multiple storefronts from a single Spree installation
8
+ - **Custom Domains** — map custom domain names to individual stores
9
+ - **Store Resolution** — automatically detect the current store based on request URL or custom domain
10
+ - **Resource Scoping** — scope products, promotions, and payment methods to specific stores
11
+ - **Store Creation Wizard** — admin UI for creating new stores with product/payment method imports
12
+
13
+ # Multi-store vs Multi-tenant
14
+
15
+ * Multi-Store setup is recommended for running multiple brands or multiple language versions of the same store
16
+ * Multi-Tenant allows you to create a SaaS application with multiple tenants, each of them with their own Spree instance
17
+
18
+ > [!TIP]
19
+ > For a full [Multi-Tenant](https://spreecommerce.org/multi-tenant-white-label-ecommerce/) solution please [contact us](https://spreecommerce.org/get-started/) to obtain commercial license.
20
+
21
+ ## Installation
22
+
23
+ Add to your Gemfile:
24
+
25
+ ```ruby
26
+ gem 'spree_multi_store'
27
+ ```
28
+
29
+ Then run:
30
+
31
+ ```bash
32
+ bundle install
33
+ ```
34
+
35
+ ## Configuration
36
+
37
+ Set your root domain for automatic subdomain-based store URLs:
38
+
39
+ ```ruby
40
+ # config/initializers/spree.rb
41
+ Spree.root_domain = ENV.fetch('SPREE_ROOT_DOMAIN', 'lvh.me')
42
+ ```
43
+
44
+ ## License
45
+
46
+ Copyright (c) 2026 Vendo Connect Inc., Vendo Sp. z o.o.
47
+
48
+ Licensed under the [GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later)](LICENSE.md).
49
+
50
+ For commercial licensing options, contact [sales@getvendo.com](mailto:sales@getvendo.com).
data/Rakefile ADDED
@@ -0,0 +1,23 @@
1
+ require 'bundler'
2
+ Bundler::GemHelper.install_tasks
3
+
4
+ require 'rspec/core/rake_task'
5
+ require 'spree/testing_support/extension_rake'
6
+
7
+ RSpec::Core::RakeTask.new
8
+
9
+ task :default do
10
+ if Dir['spec/dummy'].empty?
11
+ Rake::Task[:test_app].invoke
12
+ Dir.chdir('../../')
13
+ end
14
+ Rake::Task[:spec].invoke
15
+ end
16
+
17
+ desc 'Generates a dummy app for testing'
18
+ task :test_app do
19
+ ENV['LIB_NAME'] = 'spree_multi_store'
20
+ Rake::Task['extension:test_app'].execute(
21
+ install_admin: true
22
+ )
23
+ end
@@ -0,0 +1,21 @@
1
+ module Spree
2
+ module Admin
3
+ class CustomDomainsController < ResourceController
4
+ include Spree::Admin::SettingsConcern
5
+
6
+ protected
7
+
8
+ def collection_url
9
+ spree.admin_custom_domains_path
10
+ end
11
+
12
+ def location_after_save
13
+ spree.admin_custom_domains_path
14
+ end
15
+
16
+ def permitted_resource_params
17
+ params.require(:custom_domain).permit(Spree::PermittedAttributes.custom_domain_attributes)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,13 @@
1
+ module Spree
2
+ module Admin
3
+ module MultiStore
4
+ module BaseControllerDecorator
5
+ def self.prepended(base)
6
+ base.helper 'spree/admin/multi_store'
7
+ end
8
+ end
9
+ end
10
+
11
+ BaseController.prepend(MultiStore::BaseControllerDecorator)
12
+ end
13
+ end
@@ -0,0 +1,35 @@
1
+ module Spree
2
+ module Admin
3
+ module MultiStore
4
+ class StoresController < Spree::Admin::BaseController
5
+ def new
6
+ @store = Spree::Store.new(default_country_iso: 'US')
7
+ render :new, layout: 'spree/admin_wizard'
8
+ end
9
+
10
+ def create
11
+ @store = Spree::Store.new(permitted_store_params)
12
+ @store.mail_from_address = current_store.mail_from_address
13
+
14
+ if @store.save
15
+ # Move/copy all existing users (staff) to the new store
16
+ current_store.role_users.each do |role_user|
17
+ @store.add_user(role_user.user, role_user.role)
18
+ end
19
+
20
+ flash[:success] = flash_message_for(@store, :successfully_created)
21
+ redirect_to spree.admin_getting_started_url(host: @store.url), allow_other_host: true
22
+ else
23
+ render :new, status: :unprocessable_content, layout: 'spree/admin_wizard'
24
+ end
25
+ end
26
+
27
+ protected
28
+
29
+ def permitted_store_params
30
+ params.require(:store).permit(permitted_store_attributes)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,28 @@
1
+ module Spree
2
+ module Stores
3
+ class FindCurrent
4
+ def initialize(scope: nil, url: nil)
5
+ @scope = scope || Spree::Store
6
+ @url = url
7
+ end
8
+
9
+ def execute
10
+ store = by_url(scope) || scope.default
11
+ return if store.nil?
12
+
13
+ Spree::Current.store = store
14
+ store
15
+ end
16
+
17
+ protected
18
+
19
+ attr_reader :scope, :url
20
+
21
+ def by_url(scope)
22
+ return if url.blank?
23
+
24
+ scope.by_custom_domain(url).or(scope.by_url(url)).first
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,11 @@
1
+ module Spree
2
+ module Admin
3
+ module MultiStoreHelper
4
+ def available_stores
5
+ scope = Spree::Store.accessible_by(current_ability, :manage).includes(:logo_attachment)
6
+ scope = scope.includes(:default_custom_domain) if Spree::Store.reflect_on_association(:default_custom_domain)
7
+ @available_stores ||= scope
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,33 @@
1
+ module Spree
2
+ module MultiStoreResource
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ scope :for_store, ->(store) { joins(:stores).where(Store.table_name => { id: store.id }) }
7
+
8
+ before_validation :set_default_store, if: :new_record?
9
+
10
+ validate :must_have_one_store, unless: :disable_store_presence_validation?
11
+ end
12
+
13
+ protected
14
+
15
+ def must_have_one_store
16
+ return if stores.any?
17
+
18
+ errors.add(:stores, Spree.t(:must_have_one_store))
19
+ end
20
+
21
+ def set_default_store
22
+ return if disable_store_presence_validation?
23
+ return if stores.any?
24
+
25
+ stores << Spree::Store.default
26
+ end
27
+
28
+ # this can be overridden on model basis
29
+ def disable_store_presence_validation?
30
+ Spree::Config[:disable_store_presence_validation]
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,15 @@
1
+ module Spree
2
+ module Store::MultiStoreClassOverrides
3
+ def current(url = nil)
4
+ if url.present?
5
+ Spree.current_store_finder.new(url: url).execute
6
+ else
7
+ Spree::Current.store
8
+ end
9
+ end
10
+
11
+ def available_locales
12
+ Spree::Store.all.map(&:supported_locales_list).flatten.uniq
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,108 @@
1
+ module Spree
2
+ module Store::MultiStoreMethods
3
+ extend ActiveSupport::Concern
4
+
5
+ RESERVED_CODES = %w(
6
+ admin default app api www cdn files assets checkout account auth login user
7
+ )
8
+
9
+ included do
10
+ has_many :custom_domains, class_name: 'Spree::CustomDomain', dependent: :destroy
11
+ has_one :default_custom_domain, -> { where(default: true) }, class_name: 'Spree::CustomDomain'
12
+
13
+ attribute :import_products_from_store_id, :string, default: nil
14
+ attribute :import_payment_methods_from_store_id, :string, default: nil
15
+
16
+ attr_accessor :skip_validate_not_last
17
+
18
+ validates :code, uniqueness: { case_sensitive: false, conditions: -> { with_deleted } },
19
+ exclusion: Spree::Store::MultiStoreMethods::RESERVED_CODES
20
+
21
+ before_validation :set_url
22
+ after_create :import_products_from_store, if: -> { import_products_from_store_id.present? }
23
+ after_create :import_payment_methods_from_store, if: -> { import_payment_methods_from_store_id.present? }
24
+ after_commit :handle_code_changes, on: :update, if: -> { code_previously_changed? }
25
+ before_destroy :validate_not_last, unless: :skip_validate_not_last
26
+ before_destroy :pass_default_flag_to_other_store
27
+
28
+ scope :by_custom_domain, ->(url) { left_joins(:custom_domains).where("#{Spree::CustomDomain.table_name}.url" => url) }
29
+ scope :by_url, ->(url) { where(url: url).or(where("#{table_name}.url like ?", "%#{url}%")) }
30
+
31
+ # Re-configure FriendlyId to use :history for code tracking across renames
32
+ friendly_id :slug_candidates, use: [:slugged, :history], slug_column: :code, routes: :normal
33
+ end
34
+
35
+ def formatted_custom_domain
36
+ return unless default_custom_domain
37
+
38
+ @formatted_custom_domain ||= if Rails.env.development? || Rails.env.test?
39
+ URI::Generic.build(
40
+ scheme: Rails.application.routes.default_url_options[:protocol] || 'http',
41
+ host: default_custom_domain.url,
42
+ port: Rails.application.routes.default_url_options[:port]
43
+ ).to_s
44
+ else
45
+ URI::HTTPS.build(host: default_custom_domain.url).to_s
46
+ end
47
+ end
48
+
49
+ def can_be_deleted?
50
+ self.class.where.not(id: id).any?
51
+ end
52
+
53
+ def import_products_from_store
54
+ store = Spree::Store.find(import_products_from_store_id)
55
+ product_ids = store.products.pluck(:id)
56
+
57
+ return if product_ids.empty?
58
+
59
+ Spree::StoreProduct.insert_all(product_ids.map { |product_id| { store_id: id, product_id: product_id } })
60
+ end
61
+
62
+ def import_payment_methods_from_store
63
+ store = Spree::Store.find(import_payment_methods_from_store_id)
64
+ payment_method_ids = store.payment_method_ids
65
+
66
+ return if payment_method_ids.empty?
67
+
68
+ Spree::StorePaymentMethod.insert_all(payment_method_ids.map { |payment_method_id| { store_id: id, payment_method_id: payment_method_id } })
69
+ end
70
+
71
+ private
72
+
73
+ def validate_not_last
74
+ unless can_be_deleted?
75
+ errors.add(:base, :cannot_destroy_only_store)
76
+ throw(:abort)
77
+ end
78
+ end
79
+
80
+ def pass_default_flag_to_other_store
81
+ if default? && can_be_deleted?
82
+ self.class.where.not(id: id).first.update!(default: true)
83
+ self.default = false
84
+ end
85
+ end
86
+
87
+ def handle_code_changes
88
+ # hook for custom logic on code changes
89
+ end
90
+
91
+ # Auto-assign internal URL for stores based on code + root domain
92
+ def set_url
93
+ return if url_changed?
94
+ return unless code_changed?
95
+ return unless Spree.root_domain.present?
96
+
97
+ self.url = [code, Spree.root_domain].join('.')
98
+ end
99
+
100
+ def slug_candidates
101
+ []
102
+ end
103
+
104
+ def should_generate_new_friendly_id?
105
+ false
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,39 @@
1
+ module Spree
2
+ module Store::MultiStoreOverrides
3
+ def url_or_custom_domain
4
+ default_custom_domain&.url || url
5
+ end
6
+
7
+ def formatted_url_or_custom_domain
8
+ formatted_custom_domain || formatted_url
9
+ end
10
+
11
+ def can_be_deleted?
12
+ self.class.where.not(id: id).any?
13
+ end
14
+
15
+ private
16
+
17
+ # Override core's simple set_default_code with full code generation logic
18
+ def set_default_code
19
+ self.code = if code.present?
20
+ code.parameterize.strip
21
+ elsif name.present?
22
+ name.parameterize.strip
23
+ end
24
+
25
+ return if self.code.blank?
26
+
27
+ # ensure code is unique
28
+ self.code = [name.parameterize, rand(9999)].join('-') while Spree::Store.with_deleted.where(code: self.code).exists?
29
+ end
30
+
31
+ def should_generate_new_friendly_id?
32
+ false
33
+ end
34
+
35
+ def slug_candidates
36
+ []
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,61 @@
1
+ module Spree
2
+ class CustomDomain < Spree::Base
3
+ has_prefix_id :domain
4
+
5
+ include Spree::SingleStoreResource
6
+ include Spree::Metafields
7
+ include Spree::Metadata
8
+
9
+ normalizes :url, with: ->(value) { value&.to_s&.squish&.presence }
10
+
11
+ #
12
+ # Associations
13
+ #
14
+ belongs_to :store, class_name: 'Spree::Store', inverse_of: :custom_domains, touch: true
15
+
16
+ #
17
+ # Validations
18
+ #
19
+ validates :url, presence: true, uniqueness: true, format: {
20
+ with: %r{\A(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z}i
21
+ }, length: { in: 1..63 }
22
+ validate :url_is_valid
23
+
24
+ #
25
+ # Callbacks
26
+ #
27
+ before_validation :sanitize_url
28
+ after_save :ensure_has_one_default
29
+ after_validation :ensure_default, on: :create
30
+
31
+ def url_is_valid
32
+ return if url.blank?
33
+ parts = url.split('.')
34
+
35
+ errors.add(:url, 'use domain or subdomain') if parts.size > 4 || parts.size < 2
36
+ end
37
+
38
+ def ensure_default
39
+ self.default = store.custom_domains.count.zero?
40
+ end
41
+
42
+ def ensure_has_one_default
43
+ store.custom_domains.where.not(id: id).update_all(default: false) if default?
44
+ end
45
+
46
+ def active?
47
+ true
48
+ end
49
+
50
+ def name
51
+ url
52
+ end
53
+
54
+ private
55
+
56
+ # remove https:// and http:// from the url
57
+ def sanitize_url
58
+ self.url = url&.gsub(%r{https?://}, '')
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,11 @@
1
+ module Spree
2
+ module MultiStore
3
+ module PaymentMethodDecorator
4
+ def self.prepended(base)
5
+ base.include Spree::MultiStoreResource
6
+ end
7
+ end
8
+ end
9
+
10
+ PaymentMethod.prepend(MultiStore::PaymentMethodDecorator)
11
+ end
@@ -0,0 +1,11 @@
1
+ module Spree
2
+ module MultiStore
3
+ module ProductDecorator
4
+ def self.prepended(base)
5
+ base.include Spree::MultiStoreResource
6
+ end
7
+ end
8
+ end
9
+
10
+ Product.prepend(MultiStore::ProductDecorator)
11
+ end
@@ -0,0 +1,11 @@
1
+ module Spree
2
+ module MultiStore
3
+ module PromotionDecorator
4
+ def self.prepended(base)
5
+ base.include Spree::MultiStoreResource
6
+ end
7
+ end
8
+ end
9
+
10
+ Promotion.prepend(MultiStore::PromotionDecorator)
11
+ end
@@ -0,0 +1,13 @@
1
+ module Spree
2
+ module MultiStore
3
+ module StoreDecorator
4
+ def self.prepended(base)
5
+ base.include Spree::Store::MultiStoreMethods
6
+ base.singleton_class.prepend Spree::Store::MultiStoreClassOverrides
7
+ end
8
+ end
9
+ end
10
+
11
+ Store.prepend(MultiStore::StoreDecorator)
12
+ Store.prepend(Store::MultiStoreOverrides)
13
+ end
@@ -0,0 +1,11 @@
1
+ <tr id="<%= spree_dom_id custom_domain %>">
2
+ <td><%= custom_domain.url %></td>
3
+ <td>
4
+ <%= active_badge(custom_domain.active?) %>
5
+ </td>
6
+
7
+ <td><%= active_badge(custom_domain.default?) %></td>
8
+ <td class="actions">
9
+ <%= link_to_edit(custom_domain, no_text: true, url: spree.edit_admin_custom_domain_path(custom_domain), data: { turbo: false }) %>
10
+ </td>
11
+ </tr>
@@ -0,0 +1,19 @@
1
+ <% if current_store.custom_domains.any? %>
2
+ <div class="table-responsive rounded-lg mt-4 border">
3
+ <table class="table">
4
+ <thead>
5
+ <tr>
6
+ <th scope="col"><%= sort_link @search, :name, Spree.t(:name) %></th>
7
+ <th scope="col"><%= Spree.t(:active) %>?</th>
8
+ <th scope="col"><%= Spree.t(:default) %>?</th>
9
+ <th scope="col"></th>
10
+ </tr>
11
+ </thead>
12
+ <tbody>
13
+ <%= render collection: current_store.custom_domains, partial: 'spree/admin/custom_domains/custom_domain', cached: spree_base_cache_scope %>
14
+ </tbody>
15
+ </table>
16
+ </div>
17
+ <% else %>
18
+ <%= render 'spree/admin/shared/no_resource_found' %>
19
+ <% end %>
@@ -0,0 +1,7 @@
1
+ <div class="card mb-6">
2
+ <div class="card-body">
3
+ <%= f.spree_text_field :url, label: Spree.t(:domain), prepend: 'https://', autofocus: f.object.new_record?, required: true %>
4
+
5
+ <%= f.spree_check_box :default, help: 'We will use this domain in emails to customers.' %>
6
+ </div>
7
+ </div>
@@ -0,0 +1 @@
1
+ <%= render 'spree/admin/shared/edit_resource' %>
@@ -0,0 +1,65 @@
1
+ <%= content_for(:page_title) do %>
2
+ <%= Spree.t(:domains) %>
3
+ <% end %>
4
+
5
+ <% content_for :page_actions do %>
6
+ <%= render_admin_partials(:custom_domains_actions_partials) %>
7
+ <% end %>
8
+
9
+ <%= render_admin_partials(:custom_domains_header_partials) %>
10
+
11
+ <div class="card-lg p-6">
12
+ <h5 class="mb-2">Internal URL</h5>
13
+ <div class="grid grid-cols-12 gap-6 mb-6">
14
+ <div class="col-span-12 lg:col-span-4">
15
+ <p class="text-gray-600">
16
+ This is your internal Admin URL.
17
+ </p>
18
+ </div>
19
+ <div class="col-span-12 lg:col-span-7 lg:col-start-6">
20
+ <%= form_for current_store, url: spree.admin_store_path, data: { turbo: false, controller: 'enable-button', 'enable-button-disable-when-not-changed-value': true } do |f| %>
21
+ <% if Spree.root_domain.present? %>
22
+ <div class="flex items-center gap-6">
23
+ <div class="input-group pr-2 grow <% if current_store.custom_domains.any? %>disabled<% end %>">
24
+ <span class="text-gray-400 pl-3 pr-0">https://</span>
25
+ <%= f.text_field :code, class: 'border-0 focus:ring-0 focus:outline-none grow rounded-lg text-base pl-0', data: { enable_button_target: 'input' }, required: true, disabled: current_store.custom_domains.any? %>
26
+ <span>.<%= Spree.root_domain %></span>
27
+
28
+ <%= clipboard_component(current_store.formatted_url) %>
29
+ </div>
30
+ <% unless current_store.custom_domains.any? %>
31
+ <%= turbo_save_button_tag %>
32
+ <% end %>
33
+ </div>
34
+ <% else %>
35
+ <div class="flex items-center gap-6">
36
+ <div class="input-group pr-2 grow">
37
+ <span class="text-gray-400 pl-3 pr-0">https://</span>
38
+ <%= f.text_field :url, class: 'border-0 focus:ring-0 focus:outline-none grow rounded-lg text-base pl-0', required: true, data: { enable_button_target: 'input' } %>
39
+ <%= clipboard_component(current_store.formatted_url) %>
40
+ </div>
41
+ <%= turbo_save_button_tag %>
42
+ </div>
43
+ <% end %>
44
+ <% end %>
45
+ </div>
46
+ </div>
47
+ <hr class="my-12" />
48
+ <h5 class="mb-2"><%= Spree.t(:custom_domains) %></h5>
49
+ <div class="grid grid-cols-12 gap-6">
50
+ <div class="col-span-12 lg:col-span-4">
51
+ <p class="text-gray-600">
52
+ Connect your domain or subdomain to your storefront.
53
+ </p>
54
+ </div>
55
+ <div class="col-span-12 lg:col-span-7 lg:col-start-6">
56
+ <div class="text-right">
57
+ <%= link_to Spree.t(:new_domain), spree.new_admin_custom_domain_path, class: "btn btn-primary" %>
58
+ </div>
59
+
60
+ <%= turbo_frame_tag 'admin_custom_domains_index' do %>
61
+ <%= render 'custom_domains' %>
62
+ <% end %>
63
+ </div>
64
+ </div>
65
+ </div>
@@ -0,0 +1 @@
1
+ <%= render 'spree/admin/shared/new_resource' %>
@@ -0,0 +1,122 @@
1
+ <%= form_for @store, url: spree.admin_stores_path, data: { turbo: false, controller: 'enable-button' } do |f| %>
2
+ <div class="container mx-auto px-4 pt-8">
3
+ <h1 class="mb-6"><%= Spree.t(:new_store) %></h1>
4
+
5
+ <%= render partial: 'spree/admin/shared/error_messages', locals: { target: @store } %>
6
+ <div class="grid grid-cols-12 gap-16">
7
+ <div class="col-span-12 lg:col-span-6">
8
+ <div class="form-group">
9
+ <%= f.label :name %>
10
+ <%= f.text_field :name, class: 'form-input',
11
+ placeholder: Spree.t(:store_name),
12
+ required: true,
13
+ autofocus: true,
14
+ data: {
15
+ enable_button_target: 'input',
16
+ } %>
17
+ </div>
18
+
19
+ <div class="form-group">
20
+ <%= f.label :default_country_iso, Spree.t(:country) %>
21
+ <%= f.collection_select :default_country_iso, Spree::Country.order(:name), :iso, :name, { }, { data: { controller: 'autocomplete-select', enable_button_target: 'input' } } %>
22
+ </div>
23
+
24
+ <% if available_stores.any? %>
25
+ <div class="form-group">
26
+ <%= f.label :import_products_from_store_id, Spree.t(:import_products_from) %>
27
+ <%= f.select :import_products_from_store_id,
28
+ options_for_select(available_stores.pluck(:name, :id)),
29
+ { include_blank: Spree.t(:do_not_import) },
30
+ {
31
+ data: { controller: 'autocomplete-select' }
32
+ } %>
33
+ </div>
34
+
35
+ <div class="form-group">
36
+ <%= f.label :import_payment_methods_from_store_id, Spree.t(:import_payment_methods_from) %>
37
+ <%= f.select :import_payment_methods_from_store_id,
38
+ options_for_select(available_stores.pluck(:name, :id)),
39
+ { include_blank: Spree.t(:do_not_import) },
40
+ {
41
+ data: { controller: 'autocomplete-select' }
42
+ } %>
43
+ </div>
44
+ <% end %>
45
+ </div>
46
+ <div class="col-span-12 lg:col-span-5 lg:col-start-8">
47
+ <h5 class="mb-2">What will be shared with this new store:</h5>
48
+ <ul class="list-none p-0">
49
+ <li>
50
+ <%= icon('check', class: 'text-green-700') %>
51
+ <%= Spree.t(:products) %>, <%= Spree.t(:stock_locations) %> & <%= Spree.t(:inventory) %><br>
52
+ <em class="ml-5 text-gray-600 text-sm">you can select which ones will be available for this store</em>
53
+ </li>
54
+ <li>
55
+ <%= icon('check', class: 'text-green-700') %>
56
+ <%= Spree.t(:customers) %>
57
+ </li>
58
+ <li>
59
+ <%= icon('check', class: 'text-green-700') %>
60
+ <%= Spree.t(:shipping_methods) %>
61
+ </li>
62
+ <li>
63
+ <%= icon('check', class: 'text-green-700') %>
64
+ <%= Spree.t(:payment_methods) %><br>
65
+ <em class="ml-5 text-gray-600 text-sm">you can select which ones will be available for this store</em>
66
+ </li>
67
+ <li>
68
+ <%= icon('check', class: 'text-green-700') %>
69
+ Admin user privileges
70
+ </li>
71
+ </ul>
72
+
73
+ <h5 class="mt-6 mb-2">What will not be shared with this new store:</h5>
74
+ <ul class="list-none p-0">
75
+ <li>
76
+ <%= icon('x', class: 'text-danger') %>
77
+ <%= Spree.t(:orders) %>
78
+ </li>
79
+ <li>
80
+ <%= icon('x', class: 'text-danger') %>
81
+ <%= Spree.t(:shipments) %>
82
+ </li>
83
+ <li>
84
+ <%= icon('x', class: 'text-danger') %>
85
+ <%= Spree.t(:payments) %>
86
+ </li>
87
+ <li>
88
+ <%= icon('x', class: 'text-danger') %>
89
+ <%= Spree.t(:refunds) %>
90
+ </li>
91
+ <li>
92
+ <%= icon('x', class: 'text-danger') %>
93
+ <%= Spree.t(:store_credits) %>
94
+ </li>
95
+ <li>
96
+ <%= icon('x', class: 'text-danger') %>
97
+ <%= Spree.t(:gift_cards) %>
98
+ </li>
99
+ <li>
100
+ <%= icon('x', class: 'text-danger') %>
101
+ <%= Spree.t(:blogs_posts) %>
102
+ </li>
103
+ <li>
104
+ <%= icon('x', class: 'text-danger') %>
105
+ <%= Spree.t(:markets) %>
106
+ </li>
107
+ <li>
108
+ <%= icon('x', class: 'text-danger') %>
109
+ <%= Spree.t(:themes) %> & <%= Spree.t(:pages) %>
110
+ </li>
111
+ <li>
112
+ <%= icon('x', class: 'text-danger') %>
113
+ <%= Spree.t(:integrations) %> eg. Google Analytics, Meta Pixel, etc.
114
+ </li>
115
+ </ul>
116
+ </div>
117
+ </div>
118
+ </div>
119
+ <footer id="footer" class="fixed bottom-0 left-0 w-full border-t border-gray-200 px-6 py-4 bg-gray-25 flex items-center justify-end gap-4 z-[1000]">
120
+ <%= turbo_save_button_tag Spree.t('actions.create'), data: { admin_target: 'save' } %>
121
+ </footer>
122
+ <% end %>
@@ -0,0 +1 @@
1
+ <%= render template: 'spree/admin/stores/new', formats: [:html] %>
@@ -0,0 +1,29 @@
1
+ <% if available_stores.count > 1 %>
2
+ <div class="card mb-6">
3
+ <div class="card-header">
4
+ <h5 class="card-title">
5
+ <%= Spree.t(:stores) %>
6
+ </h5>
7
+ </div>
8
+ <div class="card-body">
9
+ <div class="form-group">
10
+ <% if can? :modify, Spree::StoreProduct %>
11
+ <p class="form-text"><%= Spree.t('admin.products.stores.choose_stores') %>.</p>
12
+
13
+ <%= f.collection_check_boxes :store_ids, available_stores, :id, :name do |b| %>
14
+ <div class="custom-control form-checkbox mb-1">
15
+ <%= b.check_box(class: 'custom-control-input') %>
16
+ <%= b.label(class: 'custom-control-label') %>
17
+ </div>
18
+ <% end %>
19
+ <% elsif @product.stores.any? %>
20
+ <ul class="text_list">
21
+ <% @product.stores.pluck(:name).each do |store_name| %>
22
+ <li><%= store_name %></li>
23
+ <% end %>
24
+ </ul>
25
+ <% end %>
26
+ </div>
27
+ </div>
28
+ </div>
29
+ <% end %>
@@ -0,0 +1,55 @@
1
+ <div class="store-dropdown" id="store-menu">
2
+ <%= dropdown do %>
3
+ <%= dropdown_toggle class: 'store-dropdown-button dropdown-toggle' do %>
4
+ <%= store_admin_icon(current_store, height: 32, width: 32) %>
5
+ <span class="store-name hidden lg:block">
6
+ <%= truncate(current_store.name, length: 10) %>
7
+ </span>
8
+ <% end %>
9
+ <%= dropdown_menu do %>
10
+ <%= link_to current_store.formatted_url_or_custom_domain, class: 'dropdown-item', target: '_blank' do %>
11
+ <%= icon 'eye' %>
12
+ <%= Spree.t(:view_store) %>
13
+ <% end %>
14
+ <%= link_to spree.edit_admin_theme_path(current_store.default_theme), class: 'dropdown-item', data: { turbo_prefetch: false } do %>
15
+ <%= icon 'tools' %>
16
+ <%= Spree.t('admin.edit_theme') %>
17
+ <% end if current_store.respond_to?(:default_theme) && current_store.default_theme && can?(:manage, Spree::Theme) %>
18
+
19
+ <% if available_stores.count > 1 %>
20
+ <div class="dropdown-divider"></div>
21
+ <h6 class="dropdown-header">
22
+ <%= Spree.t(:switch_store) %>
23
+ </h6>
24
+ <div class="store-chooser">
25
+ <% available_stores.each do |store| %>
26
+ <% if store.id == current_store.id %>
27
+ <div class="store-chooser-item active">
28
+ <%= store_admin_icon(store, height: 32, width: 32) %>
29
+ <span class="flex flex-col mr-2">
30
+ <%= store.name %>
31
+ <small class="text-gray-600">
32
+ <%= store.url_or_custom_domain %>
33
+ </small>
34
+ </span>
35
+ <%= icon('check', class: 'ml-auto') %>
36
+ </div>
37
+ <% else %>
38
+ <%= link_to spree.admin_dashboard_url(host: store.url), class: 'store-chooser-item' do %>
39
+ <%= store_admin_icon(store, height: 32, width: 32) %>
40
+ <span class="flex flex-col">
41
+ <%= store.name %>
42
+ <small class="text-gray-600">
43
+ <%= store.url_or_custom_domain %>
44
+ </small>
45
+ </span>
46
+ <% end %>
47
+ <% end %>
48
+ <% end %>
49
+ </div>
50
+ <% end %>
51
+ <% end %>
52
+ <% end %>
53
+
54
+ <%= render 'spree/admin/shared/new_item_dropdown' %>
55
+ </div>
@@ -0,0 +1,19 @@
1
+ Rails.application.config.after_initialize do
2
+ Spree::Dependencies.current_store_finder = 'Spree::Stores::FindCurrent'
3
+
4
+ Spree.metafields.enabled_resources << Spree::CustomDomain
5
+
6
+ Spree::PermittedAttributes.store_attributes.push(
7
+ :import_products_from_store_id,
8
+ :import_payment_methods_from_store_id
9
+ )
10
+
11
+ if defined?(Spree::Admin)
12
+ Spree.admin.partials.product_form_sidebar << 'spree/admin/products/form/stores'
13
+ end
14
+
15
+ # Multi-store setup
16
+ # You need to set a wildcard `root_domain` on the store to enable multi-store setup
17
+ # all new stores will be created in a subdomain of the root domain, eg. store1.localhost, store2.localhost, etc.
18
+ Spree.root_domain = ENV.fetch('SPREE_ROOT_DOMAIN', 'localhost')
19
+ end
@@ -0,0 +1,14 @@
1
+ Rails.application.config.after_initialize do
2
+ next unless defined?(Spree::Admin)
3
+
4
+ settings_nav = Spree.admin.navigation.settings
5
+
6
+ # Domains
7
+ settings_nav.add :domains,
8
+ label: :domains,
9
+ url: :admin_custom_domains_path,
10
+ icon: 'world-www',
11
+ position: 60,
12
+ active: -> { controller_name == 'custom_domains' },
13
+ if: -> { can?(:manage, Spree::CustomDomain) }
14
+ end
@@ -0,0 +1,5 @@
1
+ ---
2
+ en:
3
+ spree:
4
+ multi_store:
5
+ name: "Multi-Store"
data/config/routes.rb ADDED
@@ -0,0 +1,6 @@
1
+ Spree::Core::Engine.add_routes do
2
+ namespace :admin, path: Spree.admin_path do
3
+ resources :stores, only: [:new, :create], controller: 'multi_store/stores'
4
+ resources :custom_domains, except: :show
5
+ end
6
+ end
@@ -0,0 +1,22 @@
1
+ module Spree
2
+ module MultiStore
3
+ class Engine < ::Rails::Engine
4
+ isolate_namespace Spree
5
+ engine_name 'spree_multi_store'
6
+
7
+ # Rails 7.1 introduced a new feature that raises an error if a callback action is missing.
8
+ # We need to disable it as we use a lot of concerns that add callback actions.
9
+ initializer 'spree.multi_store.disable_raise_on_missing_callback_actions' do |app|
10
+ app.config.action_controller.raise_on_missing_callback_actions = false
11
+ end
12
+
13
+ def self.activate
14
+ Dir.glob(File.join(File.dirname(__FILE__), '../../../app/**/*_decorator*.rb')) do |c|
15
+ Rails.configuration.cache_classes ? require(c) : load(c)
16
+ end
17
+ end
18
+
19
+ config.to_prepare(&method(:activate).to_proc)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,5 @@
1
+ module Spree
2
+ module MultiStore
3
+ VERSION = '1.0.0'
4
+ end
5
+ end
@@ -0,0 +1,3 @@
1
+ require 'spree_core'
2
+ require 'spree/multi_store/version'
3
+ require 'spree/multi_store/engine'
@@ -0,0 +1 @@
1
+ require 'spree/multi_store'
metadata ADDED
@@ -0,0 +1,134 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: spree_multi_store
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Vendo Connect Inc.
8
+ - Vendo Sp. z o.o.
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 1980-01-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: spree
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 5.4.0.beta
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 5.4.0.beta
27
+ - !ruby/object:Gem::Dependency
28
+ name: spree_admin
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: 5.4.0.beta
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 5.4.0.beta
41
+ - !ruby/object:Gem::Dependency
42
+ name: spree_dev_tools
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ description: Adds multi-store switching, custom domains, and store resolution to Spree
56
+ Commerce
57
+ email: hello@spreecommerce.org
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - LICENSE.md
63
+ - README.md
64
+ - Rakefile
65
+ - app/controllers/spree/admin/custom_domains_controller.rb
66
+ - app/controllers/spree/admin/multi_store/base_controller_decorator.rb
67
+ - app/controllers/spree/admin/multi_store/stores_controller.rb
68
+ - app/finders/spree/stores/find_current.rb
69
+ - app/helpers/spree/admin/multi_store_helper.rb
70
+ - app/models/concerns/spree/multi_store_resource.rb
71
+ - app/models/concerns/spree/store/multi_store_class_overrides.rb
72
+ - app/models/concerns/spree/store/multi_store_methods.rb
73
+ - app/models/concerns/spree/store/multi_store_overrides.rb
74
+ - app/models/spree/custom_domain.rb
75
+ - app/models/spree/multi_store/payment_method_decorator.rb
76
+ - app/models/spree/multi_store/product_decorator.rb
77
+ - app/models/spree/multi_store/promotion_decorator.rb
78
+ - app/models/spree/multi_store/store_decorator.rb
79
+ - app/views/spree/admin/custom_domains/_custom_domain.html.erb
80
+ - app/views/spree/admin/custom_domains/_custom_domains.html.erb
81
+ - app/views/spree/admin/custom_domains/_form.html.erb
82
+ - app/views/spree/admin/custom_domains/edit.html.erb
83
+ - app/views/spree/admin/custom_domains/index.html.erb
84
+ - app/views/spree/admin/custom_domains/new.html.erb
85
+ - app/views/spree/admin/multi_store/stores/new.html.erb
86
+ - app/views/spree/admin/multi_store/stores/new.turbo_stream.erb
87
+ - app/views/spree/admin/products/form/_stores.html.erb
88
+ - app/views/spree/admin/shared/sidebar/_store_dropdown.html.erb
89
+ - config/initializers/spree_multi_store.rb
90
+ - config/initializers/spree_multi_store_navigation.rb
91
+ - config/locales/en.yml
92
+ - config/routes.rb
93
+ - lib/spree/multi_store.rb
94
+ - lib/spree/multi_store/engine.rb
95
+ - lib/spree/multi_store/version.rb
96
+ - lib/spree_multi_store.rb
97
+ homepage: https://github.com/spree/spree_multi_store
98
+ licenses:
99
+ - AGPL-3.0-or-later
100
+ metadata:
101
+ bug_tracker_uri: https://github.com/spree/spree_multi_store/issues
102
+ changelog_uri: https://github.com/spree/spree_multi_store/releases/tag/v1.0.0
103
+ documentation_uri: https://docs.spreecommerce.org/
104
+ source_code_uri: https://github.com/spree/spree_multi_store/tree/v1.0.0
105
+ post_install_message: |
106
+ --------------------------------------------------------------
107
+ Thank you for installing Spree Multi-Store!
108
+
109
+ This gem is licensed under AGPL-3.0-or-later.
110
+ To obtain a commercial license for your project,
111
+ check out Spree Enterprise:
112
+
113
+ https://spreecommerce.org/enterprise/
114
+
115
+ --------------------------------------------------------------
116
+ rdoc_options: []
117
+ require_paths:
118
+ - lib
119
+ required_ruby_version: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '3.2'
124
+ required_rubygems_version: !ruby/object:Gem::Requirement
125
+ requirements:
126
+ - - ">="
127
+ - !ruby/object:Gem::Version
128
+ version: '0'
129
+ requirements:
130
+ - none
131
+ rubygems_version: 4.0.2
132
+ specification_version: 4
133
+ summary: Multi-store switching and resolution for Spree Commerce
134
+ test_files: []