From 1d72ff1d44edaa2207d0d361a3db88f75f054f30 Mon Sep 17 00:00:00 2001 From: Islam Elsayed Date: Sat, 17 Oct 2026 11:55:55 +0300 Subject: [PATCH 1/3] Add logo and favicon attachments to Spree::Store Stores can now have their own logo and favicon, so that each store of a multi-store setup can be branded differently. They are declared with has_one_attached and are only defined when images use the Active Storage adapter; Spree::Store.attachments_available? tells callers whether they exist. --- core/app/models/spree/store.rb | 24 +++++++++++++++ core/config/locales/en.yml | 2 ++ core/lib/spree/permitted_attributes.rb | 1 + core/spec/models/spree/store_spec.rb | 41 ++++++++++++++++++++++++++ 4 files changed, 68 insertions(+) diff --git a/core/app/models/spree/store.rb b/core/app/models/spree/store.rb index 96866402e87..229ca5f40a5 100644 --- a/core/app/models/spree/store.rb +++ b/core/app/models/spree/store.rb @@ -9,6 +9,20 @@ module Spree # hosted by a single Solidus implementation can be built. # class Store < Spree::Base + # The logo and favicon are stored with Active Storage, so a store has them + # only when images use the Active Storage attachment adapter. + # + # @return [Boolean] + def self.attachments_available? + Spree::Image.include?(Spree::Image::ActiveStorageAttachment) + end + + if attachments_available? + has_one_attached :logo + has_one_attached :favicon + validate :logo_and_favicon_are_supported_images + end + has_many :store_payment_methods, inverse_of: :store has_many :payment_methods, through: :store_payment_methods @@ -68,6 +82,16 @@ def default_cart_tax_location private + def logo_and_favicon_are_supported_images + [:logo, :favicon].each do |name| + attachment = public_send(name) + next unless attachment.attached? + next if attachment.content_type.in?(Spree::Config.allowed_image_mime_types) + + errors.add(name, :content_type_not_supported) + end + end + def ensure_default_exists_and_is_unique if default Spree::Store.where.not(id:).update_all(default: false) diff --git a/core/config/locales/en.yml b/core/config/locales/en.yml index 9086192cf7d..c09d10b7409 100644 --- a/core/config/locales/en.yml +++ b/core/config/locales/en.yml @@ -377,6 +377,8 @@ en: code: Slug default: Default default_currency: Default Currency + favicon: Favicon + logo: Logo mail_from_address: Mail From Address meta_description: Meta Description meta_keywords: Meta Keywords diff --git a/core/lib/spree/permitted_attributes.rb b/core/lib/spree/permitted_attributes.rb index 11f28ed64bb..16b43c62fb7 100644 --- a/core/lib/spree/permitted_attributes.rb +++ b/core/lib/spree/permitted_attributes.rb @@ -126,6 +126,7 @@ module PermittedAttributes :meta_description, :default_currency, :mail_from_address, :cart_tax_country_iso, :bcc_email, :reverse_charge_status, + :logo, :favicon, address_attributes:] @@taxonomy_attributes = [:name] diff --git a/core/spec/models/spree/store_spec.rb b/core/spec/models/spree/store_spec.rb index c03e25d58eb..a37b6fb7dfb 100644 --- a/core/spec/models/spree/store_spec.rb +++ b/core/spec/models/spree/store_spec.rb @@ -5,6 +5,47 @@ RSpec.describe Spree::Store, type: :model do it { is_expected.to respond_to(:cart_tax_country_iso) } + describe "#logo and #favicon", if: described_class.attachments_available? do + let(:store) { create(:store) } + + def upload(name, content_type) + Rack::Test::UploadedFile.new(Spree::Core::Engine.root.join("lib/spree/testing_support/fixtures", name), content_type) + end + + it "attaches each image separately" do + store.update!(logo: upload("blank.jpg", "image/jpeg")) + + expect(store.reload.logo).to be_attached + expect(store.favicon).not_to be_attached + end + + it "accepts the allowed image types" do + store.favicon.attach(upload("blank.jpg", "image/jpeg")) + + expect(store).to be_valid + end + + it "rejects files that are not one of the allowed image types" do + store.logo.attach(io: StringIO.new("not an image"), filename: "logo.txt", content_type: "text/plain") + store.favicon.attach(io: StringIO.new("not an image"), filename: "favicon.txt", content_type: "text/plain") + + expect(store).not_to be_valid + expect(store.errors.details[:logo]).to include(error: :content_type_not_supported) + expect(store.errors.details[:favicon]).to include(error: :content_type_not_supported) + end + end + + describe ".attachments_available?" do + it "is true only with the Active Storage image adapter" do + expect(described_class.attachments_available?).to eq(Spree::Config.image_attachment_module == Spree::Image::ActiveStorageAttachment) + end + + it "defines the logo and favicon only when available" do + expect(described_class.new.respond_to?(:logo)).to eq(described_class.attachments_available?) + expect(described_class.new.respond_to?(:favicon)).to eq(described_class.attachments_available?) + end + end + describe ".default" do it "should ensure saved store becomes default if one doesn't exist yet" do expect(Spree::Store.where(default: true).count).to eq(0) From 9e2ad3ecd86518992f57eedd8d7d31875ff47674 Mon Sep 17 00:00:00 2001 From: Islam Elsayed Date: Sat, 17 Oct 2026 11:55:55 +0300 Subject: [PATCH 2/3] Allow uploading a store's logo and favicon in solidus_admin The store form gets a file field for each attachment, showing the current image when there is one, and skips them when the store has no attachments. The form input component now accepts the file type, and the field component gets a file_field helper to render it. --- .../stores/form/attachment/component.html.erb | 8 ++++ .../stores/form/attachment/component.rb | 29 ++++++++++++ .../stores/form/component.html.erb | 11 ++++- .../solidus_admin/stores/form/component.yml | 1 + .../solidus_admin/ui/forms/field/component.rb | 19 ++++++++ .../solidus_admin/ui/forms/input/component.rb | 1 + .../solidus_admin/stores_controller.rb | 1 + .../requests/solidus_admin/stores_spec.rb | 44 +++++++++++++++++++ 8 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 admin/app/components/solidus_admin/stores/form/attachment/component.html.erb create mode 100644 admin/app/components/solidus_admin/stores/form/attachment/component.rb diff --git a/admin/app/components/solidus_admin/stores/form/attachment/component.html.erb b/admin/app/components/solidus_admin/stores/form/attachment/component.html.erb new file mode 100644 index 00000000000..20b990f8c22 --- /dev/null +++ b/admin/app/components/solidus_admin/stores/form/attachment/component.html.erb @@ -0,0 +1,8 @@ +
+ <% if (url = preview_url) %> + <%= render component("ui/thumbnail").new(src: url, alt: label) %> + <% end %> +
+ <%= render component("ui/forms/field").file_field(@form, name, accept: Spree::Config.allowed_image_mime_types.join(",")) %> +
+
diff --git a/admin/app/components/solidus_admin/stores/form/attachment/component.rb b/admin/app/components/solidus_admin/stores/form/attachment/component.rb new file mode 100644 index 00000000000..9d3a17639ad --- /dev/null +++ b/admin/app/components/solidus_admin/stores/form/attachment/component.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# A file field for one of the store's image attachments, with a thumbnail of +# the saved image. +class SolidusAdmin::Stores::Form::Attachment::Component < SolidusAdmin::BaseComponent + # @param form [ActionView::Helpers::FormBuilder] the store form + # @param attachment [ActiveStorage::Attached::One] the store's logo or favicon + def initialize(form:, attachment:) + @form = form + @attachment = attachment + end + + def name + @attachment.name + end + + def label + Spree::Store.human_attribute_name(name) + end + + # Only a saved attachment has a URL: after a failed save the form is shown + # again with a new file assigned but not stored yet. + def preview_url + store = @attachment.record + return unless store.persisted? && store.errors.empty? + + @attachment.url if @attachment.attached? + end +end diff --git a/admin/app/components/solidus_admin/stores/form/component.html.erb b/admin/app/components/solidus_admin/stores/form/component.html.erb index 07910464e1f..eac83dbc860 100644 --- a/admin/app/components/solidus_admin/stores/form/component.html.erb +++ b/admin/app/components/solidus_admin/stores/form/component.html.erb @@ -1,4 +1,4 @@ -<%= form_for @store, url: @url, html: { id: @id } do |f| %> +<%= form_for @store, url: @url, html: { id: @id, multipart: true } do |f| %> <%= page_with_sidebar do %> <%= page_with_sidebar_main do %> <%= render component('ui/panel').new do |panel| %> @@ -11,6 +11,15 @@ <% end %> <% end %> + <%# solidus_admin also supports Solidus versions without store attachments, hence try. %> + <% if Spree::Store.try(:attachments_available?) %> + <%= render component('ui/panel').new(title: t('.branding')) do |panel| %> + <% panel.with_section(class: "flex flex-col gap-4") do %> + <%= render component("stores/form/attachment").new(form: f, attachment: @store.logo) %> + <%= render component("stores/form/attachment").new(form: f, attachment: @store.favicon) %> + <% end %> + <% end %> + <% end %> <%# This check can be removed once we drop support for Solidus 4.7 and earlier. %> <% if Spree::Store.reflect_on_association(:address) %> <%= render component('ui/panel').new(title: t('.address')) do |panel| %> diff --git a/admin/app/components/solidus_admin/stores/form/component.yml b/admin/app/components/solidus_admin/stores/form/component.yml index 72fdf85aadf..9c0d30fc7e9 100644 --- a/admin/app/components/solidus_admin/stores/form/component.yml +++ b/admin/app/components/solidus_admin/stores/form/component.yml @@ -1,5 +1,6 @@ en: address: Address + branding: Logo and favicon localization: Localization plugins: install: Install diff --git a/admin/app/components/solidus_admin/ui/forms/field/component.rb b/admin/app/components/solidus_admin/ui/forms/field/component.rb index e1af7e47615..d9a77ac6942 100644 --- a/admin/app/components/solidus_admin/ui/forms/field/component.rb +++ b/admin/app/components/solidus_admin/ui/forms/field/component.rb @@ -33,6 +33,25 @@ def self.text_field(form, method, object: nil, hint: nil, tip: nil, size: :m, ** ) end + def self.file_field(form, method, object: nil, hint: nil, tip: nil, size: :m, **attributes) + object_name, _object, label, errors = extract_form_details(form, object, method) + + new( + label:, + hint:, + tip:, + error: errors, + input_attributes: { + name: "#{object_name}[#{method}]", + tag: :input, + type: :file, + size:, + error: errors&.to_sentence&.capitalize, + **attributes + } + ) + end + def self.select(form, method, choices, object: nil, hint: nil, tip: nil, size: :m, **attributes) object_name, object, label, errors = extract_form_details(form, object, method) diff --git a/admin/app/components/solidus_admin/ui/forms/input/component.rb b/admin/app/components/solidus_admin/ui/forms/input/component.rb index e2aa5e18f2c..fa7bc2cecaf 100644 --- a/admin/app/components/solidus_admin/ui/forms/input/component.rb +++ b/admin/app/components/solidus_admin/ui/forms/input/component.rb @@ -35,6 +35,7 @@ class SolidusAdmin::UI::Forms::Input::Component < SolidusAdmin::BaseComponent search time hidden + file ]).freeze def initialize(tag: :input, size: :m, error: nil, **attributes) diff --git a/admin/app/controllers/solidus_admin/stores_controller.rb b/admin/app/controllers/solidus_admin/stores_controller.rb index 5426b24cf33..06aa88097df 100644 --- a/admin/app/controllers/solidus_admin/stores_controller.rb +++ b/admin/app/controllers/solidus_admin/stores_controller.rb @@ -59,6 +59,7 @@ def permitted_resource_params ] # This check can be removed once we drop support for Solidus 4.7 and earlier. permitted << {address_attributes: Spree::PermittedAttributes.address_attributes} if Spree::Store.reflect_on_association(:address) + permitted.push(:logo, :favicon) if Spree::Store.try(:attachments_available?) params.require(:store).permit(*permitted).tap do |store_params| without_address_parameter = store_params[:address_attributes]&.except(:country_id, :state_id, :reverse_charge_status)&.compact_blank&.empty? diff --git a/admin/spec/requests/solidus_admin/stores_spec.rb b/admin/spec/requests/solidus_admin/stores_spec.rb index 1624da3877c..a738e0b5e84 100644 --- a/admin/spec/requests/solidus_admin/stores_spec.rb +++ b/admin/spec/requests/solidus_admin/stores_spec.rb @@ -81,4 +81,48 @@ end end end + + describe "logo and favicon", if: Spree::Store.attachments_available? do + let(:admin_user) { create(:admin_user) } + let(:store) { create(:store, default: false) } + let(:image) { fixture_file_upload(Spree::Core::Engine.root.join("lib/spree/testing_support/fixtures/blank.jpg"), "image/jpeg") } + + before { allow_any_instance_of(SolidusAdmin::BaseController).to receive(:spree_current_user).and_return(admin_user) } + + it "renders a multipart form with a file field for each" do + get solidus_admin.edit_store_path(store) + expect(response.body).to include('enctype="multipart/form-data"') + expect(response.body).to include('name="store[logo]"').and include('name="store[favicon]"') + expect(response.body.scan('type="file"').size).to eq(2) + end + + it "saves the uploaded images" do + patch solidus_admin.store_path(store), params: {store: {logo: image, favicon: image}} + expect(response).to have_http_status(:see_other) + expect(store.reload.logo).to be_attached + expect(store.favicon).to be_attached + end + + it "shows the saved images on the form" do + store.update!(logo: image, favicon: image) + # These specs draw the app's routes without Active Storage's, so its URLs can't be generated. + allow_any_instance_of(ActiveStorage::Blob).to receive(:url).and_return("/stored.jpg") + + get solidus_admin.edit_store_path(store) + expect(response.body).to include('alt="Logo"').and include('alt="Favicon"') + end + end + + describe "logo and favicon without Active Storage", unless: Spree::Store.attachments_available? do + let(:admin_user) { create(:admin_user) } + let(:store) { create(:store, default: false) } + + before { allow_any_instance_of(SolidusAdmin::BaseController).to receive(:spree_current_user).and_return(admin_user) } + + it "renders the form without file fields" do + get solidus_admin.edit_store_path(store) + expect(response).to have_http_status(:ok) + expect(response.body).not_to include('type="file"') + end + end end From 0904e825fbdfba96547ebf2998fb413b0cda04fd Mon Sep 17 00:00:00 2001 From: Islam Elsayed Date: Sat, 17 Oct 2026 11:55:55 +0300 Subject: [PATCH 3/3] Use the store's logo and favicon in the storefront The layout, header and footer use the current store's favicon and logo when it has them, and keep the Solidus ones otherwise. --- .../templates/app/helpers/layout_helper.rb | 14 +++++++++++ .../app/views/layouts/_footer.html.erb | 8 ++++-- .../app/views/layouts/_header.html.erb | 6 ++++- .../app/views/layouts/storefront.html.erb | 8 ++++-- .../spec/system/template_rendering_spec.rb | 25 +++++++++++++++++++ 5 files changed, 56 insertions(+), 5 deletions(-) diff --git a/storefront/templates/app/helpers/layout_helper.rb b/storefront/templates/app/helpers/layout_helper.rb index 7c24ff691f3..2c541023123 100644 --- a/storefront/templates/app/helpers/layout_helper.rb +++ b/storefront/templates/app/helpers/layout_helper.rb @@ -1,4 +1,18 @@ module LayoutHelper + # @return [String, nil] the URL of the current store's logo, if it has one + def store_logo_url + return unless Spree::Store.attachments_available? + + url_for(current_store.logo) if current_store&.logo&.attached? + end + + # @return [String, nil] the URL of the current store's favicon, if it has one + def store_favicon_url + return unless Spree::Store.attachments_available? + + url_for(current_store.favicon) if current_store&.favicon&.attached? + end + # Generates a simple canonical tag based on the request path, preserving allowed # parameters. For collection actions, a trailing slash is added to the href. # For more advanced use cases, consider using the `canonical-rails` gem. diff --git a/storefront/templates/app/views/layouts/_footer.html.erb b/storefront/templates/app/views/layouts/_footer.html.erb index f2f4a1c6f3a..4ad1a022301 100644 --- a/storefront/templates/app/views/layouts/_footer.html.erb +++ b/storefront/templates/app/views/layouts/_footer.html.erb @@ -2,8 +2,12 @@
- <%= image_tag "logo_full_with_label.svg", class: "mx-auto w-52 mb-7 dark:hidden" %> - <%= image_tag "logo_full_with_label_white.svg", class: "mx-auto w-52 mb-7 hidden dark:block" %> + <% if (logo_url = store_logo_url) %> + <%= image_tag logo_url, alt: current_store.name, class: "mx-auto w-52 mb-7" %> + <% else %> + <%= image_tag "logo_full_with_label.svg", class: "mx-auto w-52 mb-7 dark:hidden" %> + <%= image_tag "logo_full_with_label_white.svg", class: "mx-auto w-52 mb-7 hidden dark:block" %> + <% end %> <%= render partial: "shared/call_to_action", :locals => { :label => "Visit Solidus.io", :url => 'https://solidus.io', :target => '_blank', :additional_classes => "block mx-auto" } %>
diff --git a/storefront/templates/app/views/layouts/_header.html.erb b/storefront/templates/app/views/layouts/_header.html.erb index f5abb3ba954..c9117ad7fb5 100644 --- a/storefront/templates/app/views/layouts/_header.html.erb +++ b/storefront/templates/app/views/layouts/_header.html.erb @@ -13,7 +13,11 @@
<%= link_to root_path, class: 'absolute lg:relative left-1/2 lg:left-0 lg:-translate-x-0 -translate-x-1/2 flex items-center space-x-3' do %> - <%= image_tag "logo_small_no_label.svg" %> + <% if (logo_url = store_logo_url) %> + <%= image_tag logo_url, alt: current_store.name, class: "h-8 w-auto" %> + <% else %> + <%= image_tag "logo_small_no_label.svg" %> + <% end %> <%= Spree::Store.default.name %> <% end %> diff --git a/storefront/templates/app/views/layouts/storefront.html.erb b/storefront/templates/app/views/layouts/storefront.html.erb index 9b155048e66..ce599a40b06 100644 --- a/storefront/templates/app/views/layouts/storefront.html.erb +++ b/storefront/templates/app/views/layouts/storefront.html.erb @@ -14,8 +14,12 @@ <%= javascript_importmap_tags %> - <%= favicon_link_tag '/storefront_favicon.svg', type: 'image/svg+xml', rel: 'icon' %> - <%= favicon_link_tag '/storefront_favicon.ico', rel: 'icon', type: 'image/ico' %> + <% if (favicon_url = store_favicon_url) %> + <%= favicon_link_tag favicon_url, rel: 'icon', type: nil %> + <% else %> + <%= favicon_link_tag '/storefront_favicon.svg', type: 'image/svg+xml', rel: 'icon' %> + <%= favicon_link_tag '/storefront_favicon.ico', rel: 'icon', type: 'image/ico' %> + <% end %>