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 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) 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 %>