spree_custom_domains 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 906736f5a9fa445e809467df13ff1c880db136e4934c77427cb85a1825471ffa
4
+ data.tar.gz: 57606025e58cc523e63f1491dd237806c7be7ba085d6880f8f0cbd90da57d9c1
5
+ SHA512:
6
+ metadata.gz: df72cfba3c89faf6ac53191e0bea9ba00b766f9a1cdd35f9636a4d412f28dedd523debf63e5f3a64ea0a813d752369500f2db8af92fedc21d78038c3dc0ecff8
7
+ data.tar.gz: 6ec8dc795a203ffd1e0d6de8fbc46c5e02e8ae37ac8cb79f5fe15018b9734441cdb3ff3ea3b63c8b398d309f8fcf94f459c599627d3dc4d5ddb5069b21ba00ca
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,44 @@
1
+ # Spree Custom Domains
2
+
3
+ Serve a [Spree Commerce](https://spreecommerce.org) store on its own custom domain.
4
+
5
+ This extension adds:
6
+
7
+ - A `Spree::CustomDomain` model and admin management UI (Settings → Domains).
8
+ - Host-based current-store resolution, so a request to `shop.example.com` is served by the store that owns that domain.
9
+ - `Spree::Store#url_or_custom_domain` / `#formatted_url_or_custom_domain` that prefer the store's default custom domain when one is configured.
10
+
11
+ Without this extension installed, Spree core serves every request from the default store and `url_or_custom_domain` falls back to the store's internal URL.
12
+
13
+ ## Installation
14
+
15
+ Add to your `Gemfile`:
16
+
17
+ ```ruby
18
+ gem 'spree_custom_domains'
19
+ ```
20
+
21
+ Then run:
22
+
23
+ ```bash
24
+ bundle install
25
+ bin/rails spree:install:migrations
26
+ bin/rails db:migrate
27
+ ```
28
+
29
+ The `spree_custom_domains` table ships with Spree core, so `spree:install:migrations`
30
+ copies it into your app if it isn't already present.
31
+
32
+ ## Multi-store and multi-tenant
33
+
34
+ Custom domains are independent of multi-store catalog sharing. Install this gem
35
+ on its own for single-store / multi-tenant setups that just need per-store
36
+ domains, or alongside
37
+ [`spree_multi_store`](https://github.com/spree/spree_multi_store) for legacy
38
+ catalog sharing across stores.
39
+
40
+ ## License
41
+
42
+ Spree Custom Domains is released under the
43
+ [AGPL-3.0-or-later](LICENSE.md) license. A commercial license is available
44
+ through [Spree Enterprise](https://spreecommerce.org/enterprise/).
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_custom_domains'
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,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,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,58 @@
1
+ module Spree
2
+ module CustomDomains
3
+ module StoreDecorator
4
+ def self.prepended(base)
5
+ base.has_many :custom_domains, class_name: 'Spree::CustomDomain', dependent: :destroy
6
+ base.has_one :default_custom_domain, -> { where(default: true) }, class_name: 'Spree::CustomDomain'
7
+
8
+ # Host-based resolution: +by_custom_domain+ matches an attached custom
9
+ # domain, +by_url+ matches the store's internal URL. Both are consumed
10
+ # by +Spree::Stores::FindCurrent+.
11
+ base.scope :by_custom_domain, ->(url) { left_joins(:custom_domains).where("#{Spree::CustomDomain.table_name}.url" => url) }
12
+ base.scope :by_url, ->(url) { where(url: url).or(where("#{base.table_name}.url like ?", "%#{url}%")) }
13
+
14
+ base.singleton_class.prepend(ClassMethods)
15
+ end
16
+
17
+ module ClassMethods
18
+ # Resolve a store from a request host. Core's +Store.current+ ignores
19
+ # the URL and returns +Spree::Current.store+; with custom domains a
20
+ # host can map to a specific store.
21
+ def current(url = nil)
22
+ return Spree::Current.store if url.blank?
23
+
24
+ Spree.current_store_finder.new(url: url).execute
25
+ end
26
+ end
27
+
28
+ # Prefer the store's default custom domain over its internal URL.
29
+ def url_or_custom_domain
30
+ default_custom_domain&.url || super
31
+ end
32
+
33
+ def formatted_url_or_custom_domain
34
+ formatted_custom_domain || super
35
+ end
36
+
37
+ def storefront_url
38
+ formatted_custom_domain || super
39
+ end
40
+
41
+ def formatted_custom_domain
42
+ return unless default_custom_domain
43
+
44
+ @formatted_custom_domain ||= if Rails.env.development? || Rails.env.test?
45
+ URI::Generic.build(
46
+ scheme: Rails.application.routes.default_url_options[:protocol] || 'http',
47
+ host: default_custom_domain.url,
48
+ port: Rails.application.routes.default_url_options[:port]
49
+ ).to_s
50
+ else
51
+ URI::HTTPS.build(host: default_custom_domain.url).to_s
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ Store.prepend(CustomDomains::StoreDecorator)
58
+ 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,3 @@
1
+ Rails.application.config.after_initialize do
2
+ Spree.metafields.enabled_resources << Spree::CustomDomain
3
+ 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
data/config/routes.rb ADDED
@@ -0,0 +1,5 @@
1
+ Spree::Core::Engine.add_routes do
2
+ namespace :admin, path: Spree.admin_path do
3
+ resources :custom_domains, except: :show
4
+ end
5
+ end
@@ -0,0 +1,29 @@
1
+ module Spree
2
+ module CustomDomains
3
+ class Engine < ::Rails::Engine
4
+ isolate_namespace Spree
5
+ engine_name 'spree_custom_domains'
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.custom_domains.disable_raise_on_missing_callback_actions' do |app|
10
+ app.config.action_controller.raise_on_missing_callback_actions = false
11
+ end
12
+
13
+ # Resolve the current store from the request host (custom domain or
14
+ # internal URL). Replaces core's default +Spree::Stores::FindDefault+,
15
+ # which ignores the host and always returns the default store.
16
+ initializer 'spree.custom_domains.dependencies', after: 'spree.environment' do
17
+ Spree::Dependencies.current_store_finder = 'Spree::Stores::FindCurrent'
18
+ end
19
+
20
+ def self.activate
21
+ Dir.glob(File.join(File.dirname(__FILE__), '../../../app/**/*_decorator*.rb')) do |c|
22
+ Rails.configuration.cache_classes ? require(c) : load(c)
23
+ end
24
+ end
25
+
26
+ config.to_prepare(&method(:activate).to_proc)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,9 @@
1
+ module Spree
2
+ module CustomDomains
3
+ VERSION = '0.1.0'.freeze
4
+
5
+ def gem_version
6
+ Gem::Version.new(VERSION)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,4 @@
1
+ require 'spree_core'
2
+
3
+ require 'spree/custom_domains/version'
4
+ require 'spree/custom_domains/engine'
metadata ADDED
@@ -0,0 +1,118 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: spree_custom_domains
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.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: Lets a Spree store be served on its own custom domain, with admin management
56
+ and host-based store resolution.
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/finders/spree/stores/find_current.rb
67
+ - app/models/spree/custom_domain.rb
68
+ - app/models/spree/custom_domains/store_decorator.rb
69
+ - app/views/spree/admin/custom_domains/_custom_domain.html.erb
70
+ - app/views/spree/admin/custom_domains/_custom_domains.html.erb
71
+ - app/views/spree/admin/custom_domains/_form.html.erb
72
+ - app/views/spree/admin/custom_domains/edit.html.erb
73
+ - app/views/spree/admin/custom_domains/index.html.erb
74
+ - app/views/spree/admin/custom_domains/new.html.erb
75
+ - config/initializers/spree_custom_domains.rb
76
+ - config/initializers/spree_custom_domains_navigation.rb
77
+ - config/routes.rb
78
+ - lib/spree/custom_domains/engine.rb
79
+ - lib/spree/custom_domains/version.rb
80
+ - lib/spree_custom_domains.rb
81
+ homepage: https://github.com/spree/spree_custom_domains
82
+ licenses:
83
+ - AGPL-3.0-or-later
84
+ metadata:
85
+ bug_tracker_uri: https://github.com/spree/spree_custom_domains/issues
86
+ changelog_uri: https://github.com/spree/spree_custom_domains/releases/tag/v0.1.0
87
+ documentation_uri: https://docs.spreecommerce.org/
88
+ source_code_uri: https://github.com/spree/spree_custom_domains/tree/v0.1.0
89
+ post_install_message: |
90
+ --------------------------------------------------------------
91
+ Thank you for installing Spree Custom Domains!
92
+
93
+ This gem is licensed under AGPL-3.0-or-later.
94
+ To obtain a commercial license for your project,
95
+ check out Spree Enterprise:
96
+
97
+ https://spreecommerce.org/enterprise/
98
+
99
+ --------------------------------------------------------------
100
+ rdoc_options: []
101
+ require_paths:
102
+ - lib
103
+ required_ruby_version: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ version: '3.2'
108
+ required_rubygems_version: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ version: '0'
113
+ requirements:
114
+ - none
115
+ rubygems_version: 4.0.2
116
+ specification_version: 4
117
+ summary: Custom domains for Spree Commerce stores
118
+ test_files: []