diff --git a/admin/app/components/solidus_admin/stores/address_form/component.html.erb b/admin/app/components/solidus_admin/stores/address_form/component.html.erb
new file mode 100644
index 00000000000..9eee9d51b51
--- /dev/null
+++ b/admin/app/components/solidus_admin/stores/address_form/component.html.erb
@@ -0,0 +1,50 @@
+
diff --git a/admin/app/components/solidus_admin/stores/address_form/component.js b/admin/app/components/solidus_admin/stores/address_form/component.js
new file mode 100644
index 00000000000..1d57e7eb40b
--- /dev/null
+++ b/admin/app/components/solidus_admin/stores/address_form/component.js
@@ -0,0 +1,56 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["country", "state", "stateName", "stateWrapper", "stateNameWrapper"]
+
+ loadStates() {
+ const countryId = this.countryTarget.value
+
+ fetch(`/admin/countries/${countryId}/states`)
+ .then((response) => response.json())
+ .then((data) => {
+ this.updateStateOptions(data)
+ })
+ }
+
+ updateStateOptions(states) {
+ if (states.length === 0) {
+ this.toggleStateFields(false)
+ } else {
+ this.toggleStateFields(true)
+ this.populateStateSelect(states)
+ }
+ }
+
+ toggleStateFields(showSelect) {
+ const stateWrapper = this.stateWrapperTarget
+ const stateNameWrapper = this.stateNameWrapperTarget
+ const stateSelect = this.stateTarget
+ const stateName = this.stateNameTarget
+
+ if (showSelect) {
+ // Show state select dropdown.
+ stateSelect.disabled = false
+ stateName.value = ""
+ stateWrapper.classList.remove("hidden")
+ stateNameWrapper.classList.add("hidden")
+ } else {
+ // Show state name text input if no states to choose from.
+ stateSelect.disabled = true
+ stateWrapper.classList.add("hidden")
+ stateNameWrapper.classList.remove("hidden")
+ }
+ }
+
+ populateStateSelect(states) {
+ const stateSelect = this.stateTarget
+ stateSelect.innerHTML = ""
+
+ states.forEach((state) => {
+ const option = document.createElement("option")
+ option.value = state.id
+ option.innerText = state.name
+ stateSelect.appendChild(option)
+ })
+ }
+}
diff --git a/admin/app/components/solidus_admin/stores/address_form/component.rb b/admin/app/components/solidus_admin/stores/address_form/component.rb
new file mode 100644
index 00000000000..cd3e96162d1
--- /dev/null
+++ b/admin/app/components/solidus_admin/stores/address_form/component.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class SolidusAdmin::Stores::AddressForm::Component < SolidusAdmin::BaseComponent
+ def initialize(store:)
+ @name = "store"
+ @store = store
+ end
+
+ def state_options
+ country = @store.country
+ return [] unless country && country.states_required
+
+ country.states.pluck(:name, :id)
+ end
+end
diff --git a/admin/app/components/solidus_admin/stores/edit/component.html.erb b/admin/app/components/solidus_admin/stores/edit/component.html.erb
new file mode 100644
index 00000000000..0fc523ad3c8
--- /dev/null
+++ b/admin/app/components/solidus_admin/stores/edit/component.html.erb
@@ -0,0 +1,65 @@
+<%= page do %>
+ <%= page_header do %>
+ <%= page_header_back(solidus_admin.stores_path) %>
+ <%= page_header_title(t(".title", store: @store&.name)) %>
+ <% end %>
+
+ <%= form_for @store, url: solidus_admin.store_path(@store), html: { id: form_id } do |f| %>
+ <%= page_with_sidebar do %>
+ <%= page_with_sidebar_main do %>
+ <%= render component("ui/panel").new(title: t(".store_details")) do %>
+
+ <%= render component("ui/forms/field").text_field(f, :name, required: true) %>
+ <%= render component("ui/forms/field").text_field(f, :code, required: true) %>
+ <%= render component("ui/forms/field").text_field(f, :seo_title) %>
+ <%= render component("ui/forms/field").text_field(f, :meta_keywords) %>
+ <%= render component("ui/forms/field").text_area(f, :meta_description) %>
+ <%= render component("ui/forms/field").text_field(f, :tax_id) %>
+ <%= render component("ui/forms/field").text_field(f, :vat_id) %>
+ <%= render component("ui/forms/field").text_field(f, :url, required: true) %>
+ <%= render component("ui/forms/field").text_field(f, :mail_from_address, required: true) %>
+ <%= render component("ui/forms/field").text_field(f, :bcc_email) %>
+ <%= render component("ui/forms/field").select(
+ f,
+ :default_currency,
+ currency_options,
+ include_blank: true
+ ) %>
+ <%= render component("ui/forms/field").select(
+ f,
+ :cart_tax_country_iso,
+ cart_tax_country_options,
+ include_blank: t(".no_cart_tax_country")
+ ) %>
+ <%= render component("ui/forms/field").select(
+ f,
+ :available_locales,
+ localization_options,
+ multiple: true,
+ class: "select2",
+ name: "store[available_locales][]"
+ ) %>
+
+ <%= render component("ui/forms/field").text_area(f, :description) %>
+ <% end %>
+
+ <%= render component("ui/panel").new(title: t(".address")) do %>
+
+ <%= render component("stores/address_form").new(
+ store: @store,
+ ) %>
+
+ <% end %>
+ <% end %>
+ <% end %>
+ <% end %>
+
+ <%= page_footer do %>
+ <%= page_footer_actions do %>
+
+ <%= render component("ui/button").new(tag: :button, text: t(".update"), form: form_id) %>
+ <%= render component("ui/button").new(tag: :a, text: t(".cancel"), href: solidus_admin.edit_store_path(@store), scheme: :secondary) %>
+
+ <% end %>
+ <% end %>
+<% end %>
diff --git a/admin/app/components/solidus_admin/stores/edit/component.rb b/admin/app/components/solidus_admin/stores/edit/component.rb
new file mode 100644
index 00000000000..8c170c0a2c9
--- /dev/null
+++ b/admin/app/components/solidus_admin/stores/edit/component.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+class SolidusAdmin::Stores::Edit::Component < SolidusAdmin::BaseComponent
+ include SolidusAdmin::Layout::PageHelpers
+
+ # Define the necessary attributes for the component
+ attr_reader :store, :available_countries
+
+ # Initialize the component with required data
+ def initialize(store:)
+ @store = store
+ @available_countries = fetch_available_countries
+ end
+
+ def form_id
+ @form_id ||= "#{stimulus_id}--form-#{@store.id}"
+ end
+
+ def currency_options
+ Spree::Config.available_currencies.map(&:iso_code)
+ end
+
+ # Generates options for cart tax countries
+ def cart_tax_country_options
+ fetch_available_countries(restrict_to_zone: Spree::Config[:checkout_zone]).map do |country|
+ [country.name, country.iso]
+ end
+ end
+
+ # Generates available locales
+ def localization_options
+ Spree.i18n_available_locales.map do |locale|
+ [
+ I18n.t('spree.i18n.this_file_language', locale: locale, default: locale.to_s),
+ locale
+ ]
+ end
+ end
+
+ # Fetch countries for the address form
+ def available_country_options
+ Spree::Country.order(:name).map { |country| [country.name, country.id] }
+ end
+
+ private
+
+ # Fetch the available countries for the localization section
+ def fetch_available_countries(restrict_to_zone: Spree::Config[:checkout_zone])
+ countries = Spree::Country.available(restrict_to_zone:)
+
+ country_names = Carmen::Country.all.map do |country|
+ [country.code, country.name]
+ end.to_h
+
+ country_names.update I18n.t('spree.country_names', default: {}).stringify_keys
+
+ countries.collect do |country|
+ country.name = country_names.fetch(country.iso, country.name)
+ country
+ end.sort_by { |country| country.name.parameterize }
+ end
+end
diff --git a/admin/app/components/solidus_admin/stores/edit/component.yml b/admin/app/components/solidus_admin/stores/edit/component.yml
new file mode 100644
index 00000000000..ae5ffcf75d1
--- /dev/null
+++ b/admin/app/components/solidus_admin/stores/edit/component.yml
@@ -0,0 +1,6 @@
+en:
+ title: "%{store}"
+ store_details: Store Details
+ address: Address
+ update: Update
+ cancel: Cancel
diff --git a/admin/app/components/solidus_admin/stores/new/component.html.erb b/admin/app/components/solidus_admin/stores/new/component.html.erb
new file mode 100644
index 00000000000..a1cba8b493f
--- /dev/null
+++ b/admin/app/components/solidus_admin/stores/new/component.html.erb
@@ -0,0 +1,65 @@
+<%= page do %>
+ <%= page_header do %>
+ <%= page_header_back(solidus_admin.stores_path) %>
+ <%= page_header_title(t(".title")) %>
+ <% end %>
+
+ <%= form_for @store, url: solidus_admin.stores_path, html: { id: form_id } do |f| %>
+ <%= page_with_sidebar do %>
+ <%= page_with_sidebar_main do %>
+ <%= render component("ui/panel").new(title: t(".store_details")) do %>
+
+ <%= render component("ui/forms/field").text_field(f, :name, required: true) %>
+ <%= render component("ui/forms/field").text_field(f, :code, required: true) %>
+ <%= render component("ui/forms/field").text_field(f, :seo_title) %>
+ <%= render component("ui/forms/field").text_field(f, :meta_keywords) %>
+ <%= render component("ui/forms/field").text_area(f, :meta_description) %>
+ <%= render component("ui/forms/field").text_field(f, :tax_id) %>
+ <%= render component("ui/forms/field").text_field(f, :vat_id) %>
+ <%= render component("ui/forms/field").text_field(f, :url, required: true) %>
+ <%= render component("ui/forms/field").text_field(f, :mail_from_address, required: true) %>
+ <%= render component("ui/forms/field").text_field(f, :bcc_email) %>
+ <%= render component("ui/forms/field").select(
+ f,
+ :default_currency,
+ currency_options,
+ include_blank: true
+ ) %>
+ <%= render component("ui/forms/field").select(
+ f,
+ :cart_tax_country_iso,
+ cart_tax_country_options,
+ include_blank: t(".no_cart_tax_country")
+ ) %>
+ <%= render component("ui/forms/field").select(
+ f,
+ :available_locales,
+ localization_options,
+ multiple: true,
+ class: "select2",
+ name: "store[available_locales][]"
+ ) %>
+
+ <%= render component("ui/forms/field").text_area(f, :description) %>
+ <% end %>
+
+ <%= render component("ui/panel").new(title: t(".address")) do %>
+
+ <%= render component("stores/address_form").new(
+ store: @store,
+ ) %>
+
+ <% end %>
+ <% end %>
+ <% end %>
+ <% end %>
+
+ <%= page_footer do %>
+ <%= page_footer_actions do %>
+
+ <%= render component("ui/button").new(tag: :button, text: t(".save"), form: form_id) %>
+ <%= render component("ui/button").new(tag: :a, text: t(".cancel"), href: solidus_admin.new_store_path, scheme: :secondary) %>
+
+ <% end %>
+ <% end %>
+<% end %>
diff --git a/admin/app/components/solidus_admin/stores/new/component.rb b/admin/app/components/solidus_admin/stores/new/component.rb
new file mode 100644
index 00000000000..a1c7d288f8f
--- /dev/null
+++ b/admin/app/components/solidus_admin/stores/new/component.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+class SolidusAdmin::Stores::New::Component < SolidusAdmin::BaseComponent
+ include SolidusAdmin::Layout::PageHelpers
+
+ # Define the necessary attributes for the component
+ attr_reader :store, :available_countries
+
+ # Initialize the component with required data
+ def initialize(store:)
+ @store = store
+ @available_countries = fetch_available_countries
+ end
+
+ def form_id
+ @form_id ||= "#{stimulus_id}--form-#{@store.id}"
+ end
+
+ def currency_options
+ Spree::Config.available_currencies.map(&:iso_code)
+ end
+
+ # Generates options for cart tax countries
+ def cart_tax_country_options
+ fetch_available_countries(restrict_to_zone: Spree::Config[:checkout_zone]).map do |country|
+ [country.name, country.iso]
+ end
+ end
+
+ # Generates available locales
+ def localization_options
+ Spree.i18n_available_locales.map do |locale|
+ [
+ I18n.t('spree.i18n.this_file_language', locale: locale, default: locale.to_s),
+ locale
+ ]
+ end
+ end
+
+ # Fetch countries for the address form
+ def available_country_options
+ Spree::Country.order(:name).map { |country| [country.name, country.id] }
+ end
+
+ private
+
+ # Fetch the available countries for the localization section
+ def fetch_available_countries(restrict_to_zone: Spree::Config[:checkout_zone])
+ countries = Spree::Country.available(restrict_to_zone:)
+
+ country_names = Carmen::Country.all.map do |country|
+ [country.code, country.name]
+ end.to_h
+
+ country_names.update I18n.t('spree.country_names', default: {}).stringify_keys
+
+ countries.collect do |country|
+ country.name = country_names.fetch(country.iso, country.name)
+ country
+ end.sort_by { |country| country.name.parameterize }
+ end
+end
diff --git a/admin/app/components/solidus_admin/stores/new/component.yml b/admin/app/components/solidus_admin/stores/new/component.yml
new file mode 100644
index 00000000000..f189c79fcda
--- /dev/null
+++ b/admin/app/components/solidus_admin/stores/new/component.yml
@@ -0,0 +1,6 @@
+en:
+ title: "New Store"
+ save: Save
+ store_details: Store Details
+ address: Address
+ cancel: Cancel
diff --git a/admin/app/controllers/solidus_admin/stores_controller.rb b/admin/app/controllers/solidus_admin/stores_controller.rb
index c653163d5c9..162e434392d 100644
--- a/admin/app/controllers/solidus_admin/stores_controller.rb
+++ b/admin/app/controllers/solidus_admin/stores_controller.rb
@@ -17,6 +17,26 @@ def index
end
end
+ def new
+ @store ||= Spree::Store.new
+
+ respond_to do |format|
+ format.html {
+ render component("stores/new").new(
+ store: @store
+ )
+ }
+ end
+ end
+
+ def edit
+ @store = Spree::Store.find_by(id: params[:id])
+
+ respond_to do |format|
+ format.html { render component('stores/edit').new(store: @store) }
+ end
+ end
+
def destroy
@stores = Spree::Store.where(id: params[:id])
diff --git a/admin/config/routes.rb b/admin/config/routes.rb
index 4f70c26e766..88607c30600 100644
--- a/admin/config/routes.rb
+++ b/admin/config/routes.rb
@@ -77,7 +77,7 @@
admin_resources :shipping_methods, only: [:index, :destroy]
admin_resources :shipping_categories, except: [:show]
admin_resources :stock_locations, only: [:index, :destroy]
- admin_resources :stores, only: [:index, :destroy]
+ admin_resources :stores, only: [:index, :edit, :show, :destroy, :new]
admin_resources :zones, only: [:index, :destroy]
admin_resources :refund_reasons, except: [:show]
admin_resources :reimbursement_types, only: [:index]
diff --git a/admin/spec/components/solidus_admin/stores/address_form/component_spec.rb b/admin/spec/components/solidus_admin/stores/address_form/component_spec.rb
new file mode 100644
index 00000000000..4382ecd2157
--- /dev/null
+++ b/admin/spec/components/solidus_admin/stores/address_form/component_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe SolidusAdmin::Stores::AddressForm::Component, type: :component do
+ let(:country) { create(:country, states_required: true) }
+ let(:state) { create(:state, country: country) }
+ let(:store) { create(:store, country: country, state: state) }
+
+ subject(:component) { described_class.new(store: store) }
+
+ describe "#state_options" do
+ context "when the country has states and requires states" do
+ it "returns a list of state names and IDs" do
+ expect(component.state_options).to include([state.name, state.id])
+ end
+ end
+
+ context "when the country does not require states" do
+ let(:country) { create(:country, states_required: false) }
+
+ it "returns an empty array" do
+ expect(component.state_options).to eq([])
+ end
+ end
+
+ context "when there is no country assigned to the store" do
+ let(:store) { create(:store, country: nil) }
+
+ it "returns an empty array" do
+ expect(component.state_options).to eq([])
+ end
+ end
+ end
+end
diff --git a/admin/spec/components/solidus_admin/stores/edit/component_spec.rb b/admin/spec/components/solidus_admin/stores/edit/component_spec.rb
new file mode 100644
index 00000000000..780579d7437
--- /dev/null
+++ b/admin/spec/components/solidus_admin/stores/edit/component_spec.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe SolidusAdmin::Stores::Edit::Component, type: :component do
+ let(:store) { build(:store, id: 1, name: "Test Store") }
+ let(:component) { described_class.new(store: store) }
+
+ describe "#render" do
+ it "renders the edit store form with existing data" do
+ store = Spree::Store.create!(name: "Test Store", url: "test-store.com", code: 'test-store', mail_from_address: 'test@mail.co')
+ render_inline described_class.new(store: store)
+
+ expect(rendered_content).to have_selector("form")
+ expect(rendered_content).to have_field("store[name]", with: "Test Store")
+ expect(rendered_content).to have_field("store[url]", with: "test-store.com")
+ expect(rendered_content).to have_field("store[code]", with: "test-store")
+ expect(rendered_content).to have_field("store[mail_from_address]", with: "test@mail.co")
+ end
+ end
+
+ describe "#form_id" do
+ it "returns a unique form id based on the store" do
+ expect(component.form_id).to match(/stores--edit--form-1/)
+ end
+ end
+
+ describe "#currency_options" do
+ it "returns the available currencies" do
+ allow(Spree::Config).to receive(:available_currencies).and_return([Money::Currency.new("USD"), Money::Currency.new("EUR")])
+
+ expect(component.currency_options).to contain_exactly("USD", "EUR")
+ end
+ end
+
+ describe "#cart_tax_country_options" do
+ it "returns available countries for cart tax selection" do
+ country = create(:country, name: "United States of America", iso: "US")
+ allow(Spree::Country).to receive(:available).and_return([country])
+
+ expect(component.cart_tax_country_options).to include(["United States of America", "US"])
+ end
+ end
+
+ describe "#localization_options" do
+ it "returns available locales with translated names" do
+ allow(Spree).to receive(:i18n_available_locales).and_return([:en, :fr])
+ expect(component.localization_options).to include(["English (US)", :en])
+ expect(component.localization_options).to include(["English (US)", :fr])
+ end
+ end
+
+ describe "#available_country_options" do
+ it "returns a list of available countries" do
+ country = create(:country, name: "United States", id: 1)
+ allow(Spree::Country).to receive(:order).and_return([country])
+
+ expect(component.available_country_options).to include(["United States", 1])
+ end
+ end
+end
diff --git a/admin/spec/components/solidus_admin/stores/new/component_spec.rb b/admin/spec/components/solidus_admin/stores/new/component_spec.rb
new file mode 100644
index 00000000000..300e584a225
--- /dev/null
+++ b/admin/spec/components/solidus_admin/stores/new/component_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe SolidusAdmin::Stores::New::Component, type: :component do
+ let(:store) { Spree::Store.new }
+ let(:component) { described_class.new(store: store) }
+
+ describe "#render" do
+ it "renders the new store form" do
+ store = Spree::Store.new
+ render_inline described_class.new(store: store)
+
+ expect(rendered_content).to have_selector("form")
+ expect(rendered_content).to have_field("store[name]")
+ expect(rendered_content).to have_field("store[url]")
+ end
+ end
+
+ describe "#form_id" do
+ it "generates a unique form ID for the store" do
+ expect(component.form_id).to match(/--form-/)
+ end
+ end
+
+ describe "#currency_options" do
+ it "returns a list of available currency ISO codes" do
+ allow(Spree::Config).to receive(:available_currencies).and_return([Money::Currency.new("USD"), Money::Currency.new("EUR")])
+ expect(component.currency_options).to contain_exactly("USD", "EUR")
+ end
+ end
+
+ describe "#cart_tax_country_options" do
+ it "returns an array of available tax country names and ISO codes" do
+ country = create(:country, name: "United States", iso: "US")
+ allow(component).to receive(:fetch_available_countries).and_return([country])
+ expect(component.cart_tax_country_options).to include(["United States", "US"])
+ end
+ end
+
+ describe "#localization_options" do
+ it "returns available locales with translated names" do
+ allow(Spree).to receive(:i18n_available_locales).and_return([:en, :fr])
+ expect(component.localization_options).to include(["English (US)", :en])
+ expect(component.localization_options).to include(["English (US)", :fr])
+ end
+ end
+
+ describe "#available_country_options" do
+ it "returns a list of available countries sorted by name" do
+ country1 = create(:country, name: "Germany", id: 1)
+ country2 = create(:country, name: "France", id: 2)
+ allow(Spree::Country).to receive(:order).and_return([country1, country2])
+ expect(component.available_country_options).to eq([["Germany", 1], ["France", 2]])
+ end
+ end
+end
diff --git a/admin/spec/requests/solidus_admin/stores_spec.rb b/admin/spec/requests/solidus_admin/stores_spec.rb
new file mode 100644
index 00000000000..6ed6fb71ab7
--- /dev/null
+++ b/admin/spec/requests/solidus_admin/stores_spec.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe "SolidusAdmin::StoresController", type: :request do
+ let(:admin_user) { create(:admin_user) }
+
+ before do
+ allow_any_instance_of(SolidusAdmin::BaseController).to receive(:spree_current_user).and_return(admin_user)
+ allow(admin_user).to receive(:has_spree_role?).with('admin').and_return(true)
+ end
+
+ let(:resource_class) { Spree::Store }
+ let(:valid_attributes) { { name: "New Store", code: "new-store" } }
+ let(:invalid_attributes) { { name: "", code: "", domain: "" } }
+
+ describe "GET /new" do
+ it "renders the new template with a 200 OK status" do
+ get solidus_admin.new_store_path
+ expect(response).to have_http_status(:ok)
+ end
+ end
+
+ describe "GET /edit" do
+ let(:store) { create(:store) }
+
+ it "renders the edit template with a 200 OK status" do
+ get solidus_admin.edit_store_path(store)
+ expect(response).to have_http_status(:ok)
+ end
+ end
+
+ describe "Strong Parameters" do
+ it "permits the expected parameters" do
+ params = ActionController::Parameters.new(store: { store_id: 1, name: "Test Store", code: "test-store" })
+ permitted_params = params.require(:store).permit(:store_id, :name, :code)
+ expect(permitted_params.keys).to contain_exactly("store_id", "name", "code")
+ end
+ end
+end
diff --git a/api/lib/spree/api_configuration.rb b/api/lib/spree/api_configuration.rb
index 77b047f3258..1916cfd04ee 100644
--- a/api/lib/spree/api_configuration.rb
+++ b/api/lib/spree/api_configuration.rb
@@ -147,9 +147,10 @@ def promotion_attributes=(value)
deprecate "promotion_attributes=" => promotion_attributes_deprecation_message, deprecator: Spree.deprecator
preference :store_attributes, :array, default: [
- :id, :name, :url, :meta_description, :meta_keywords, :seo_title,
- :mail_from_address, :default_currency, :code, :default, :available_locales,
- :bcc_email
+ :id, :name, :legal_name, :url, :meta_description, :meta_keywords, :seo_title,
+ :mail_from_address, :default_currency, :code, :default,
+ :bcc_email, :contact_phone, :contact_email, :tax_id, :vat_id, :description,
+ :address1, :address2, :city, :zipcode, :country_id, :state_id, :state_name, :available_locales
]
preference :store_credit_history_attributes, :array, default: [
diff --git a/api/openapi/solidus-api.oas.yml b/api/openapi/solidus-api.oas.yml
index a59c2662b8f..f324ebdf717 100644
--- a/api/openapi/solidus-api.oas.yml
+++ b/api/openapi/solidus-api.oas.yml
@@ -6474,6 +6474,33 @@ components:
type: string
url:
type: string
+ legal_name:
+ type: string
+ contact_email:
+ type: string
+ nullable: true
+ contact_phone:
+ type: string
+ description:
+ type: string
+ vat_id:
+ type: string
+ tax_id:
+ type: string
+ address1:
+ type: string
+ address2:
+ type: string
+ city:
+ type: string
+ zipcode:
+ type: string
+ state_name:
+ type: string
+ country_id:
+ type: integer
+ state_id:
+ type: integer
taxonomy:
type: object
properties:
@@ -6955,6 +6982,33 @@ components:
type: string
cart_tax_country_iso:
type: string
+ legal_name:
+ type: string
+ contact_email:
+ type: string
+ nullable: true
+ contact_phone:
+ type: string
+ description:
+ type: string
+ vat_id:
+ type: string
+ tax_id:
+ type: string
+ address1:
+ type: string
+ address2:
+ type: string
+ city:
+ type: string
+ zipcode:
+ type: string
+ state_name:
+ type: string
+ country_id:
+ type: integer
+ state_id:
+ type: integer
taxonomy-input:
type: object
title: Taxonomy input
diff --git a/api/spec/requests/spree/api/stores_spec.rb b/api/spec/requests/spree/api/stores_spec.rb
index 55b4f102416..8553ddf3d6a 100644
--- a/api/spec/requests/spree/api/stores_spec.rb
+++ b/api/spec/requests/spree/api/stores_spec.rb
@@ -4,6 +4,11 @@
module Spree::Api
describe 'Stores', type: :request do
+ let(:country) { create :country, states_required: true }
+ let(:country_without_states) { create :country, states_required: false }
+ let(:state) { create :state, name: 'maryland', abbr: 'md', country: }
+ let!(:base_attributes) { Spree::Api::Config.store_attributes }
+
let!(:store) do
create(:store, name: "My Spree Store", url: "spreestore.example.com")
end
@@ -22,6 +27,58 @@ module Spree::Api
default: false)
end
+ describe "store state validation" do
+ context "when store country has states_required" do
+ it "is invalid without a state" do
+ store = Spree::Store.new(name: "Test Store", country: country, state: nil, url: "spreestore.example.com",
+ mail_from_address: "spreestore@example.com", code: "test-store",)
+ expect(store).not_to be_valid
+ expect(store.errors[:state]).to include("can't be blank")
+ end
+
+ it "is valid with a state" do
+ store = Spree::Store.new(name: "Test Store", country: country, state: state, url: "spreestore.example.com",
+ mail_from_address: "spreestore@example.com", code: "test-store",)
+ expect(store).to be_valid
+ end
+ end
+
+ context "when store country has no states" do
+ it "is valid without a state" do
+ store = Spree::Store.new(name: "Test Store", country: country_without_states, state: nil, url: "spreestore.example.com",
+ mail_from_address: "spreestore@example.com", code: "test-store",)
+ expect(store).to be_valid
+ end
+ end
+
+ it "is valid without an address and without country/state" do
+ expect(store).to be_valid
+ end
+
+ it "is valid with only correct country and state" do
+ store = Spree::Store.create!(
+ name: "Test Store",
+ url: "spreestore.example.com",
+ mail_from_address: "spreestore.example.com",
+ code: "test-store",
+ address1: "123 Main St",
+ city: "New York",
+ zipcode: "10001",
+ state: state,
+ country: country,
+ )
+ expect(store).to be_valid
+ end
+ end
+
+ describe "#index" do
+ it "ensures the API store attributes match the expected attributes" do
+ get spree.api_stores_path
+ first_store = json_response["stores"].first
+ expect(first_store.keys).to include(*base_attributes.map(&:to_s))
+ end
+ end
+
it "can list the available stores" do
get spree.api_stores_path
expect(json_response["stores"]).to match_array([
@@ -37,7 +94,20 @@ module Spree::Api
"default_currency" => nil,
"code" => store.code,
"default" => true,
- "available_locales" => ["en"]
+ "available_locales" => ["en"],
+ "legal_name" => nil,
+ "contact_email" => nil,
+ "contact_phone" => nil,
+ "description" => nil,
+ "tax_id" => nil,
+ "vat_id" => nil,
+ "address1" => nil,
+ "address2" => nil,
+ "city" => nil,
+ "zipcode" => nil,
+ "country_id" => nil,
+ "state_id" => nil,
+ "state_name" => nil
},
{
"id" => non_default_store.id,
@@ -51,7 +121,20 @@ module Spree::Api
"default_currency" => nil,
"code" => non_default_store.code,
"default" => false,
- "available_locales" => ["en"]
+ "available_locales" => ["en"],
+ "legal_name" => nil,
+ "contact_email" => nil,
+ "contact_phone" => nil,
+ "description" => nil,
+ "tax_id" => nil,
+ "vat_id" => nil,
+ "address1" => nil,
+ "address2" => nil,
+ "city" => nil,
+ "zipcode" => nil,
+ "country_id" => nil,
+ "state_id" => nil,
+ "state_name" => nil
}
])
end
@@ -70,7 +153,20 @@ module Spree::Api
"default_currency" => nil,
"code" => store.code,
"default" => true,
- "available_locales" => ["en"]
+ "available_locales" => ["en"],
+ "legal_name" => nil,
+ "contact_email" => nil,
+ "contact_phone" => nil,
+ "description" => nil,
+ "tax_id" => nil,
+ "vat_id" => nil,
+ "address1" => nil,
+ "address2" => nil,
+ "city" => nil,
+ "zipcode" => nil,
+ "country_id" => nil,
+ "state_id" => nil,
+ "state_name" => nil
)
end
@@ -79,7 +175,14 @@ module Spree::Api
code: "spree123",
name: "Hack0rz",
url: "spree123.example.com",
- mail_from_address: "me@example.com"
+ mail_from_address: "me@example.com",
+ legal_name: 'ABC Corp',
+ address1: "123 Main St",
+ city: 'San Francisco',
+ country_id: country.id,
+ state_id: state.id,
+ phone: "123-456-7890",
+ zipcode: "12345"
}
post spree.api_stores_path, params: { store: store_hash }
expect(response.status).to eq(201)
@@ -89,13 +192,34 @@ module Spree::Api
store_hash = {
url: "spree123.example.com",
mail_from_address: "me@example.com",
- bcc_email: "bcc@example.net"
+ bcc_email: "bcc@example.net",
+ legal_name: 'XYZ Corp',
+ description: "Leading provider of high-quality tech accessories, offering the latest gadgets, peripherals, and electronics to enhance your digital lifestyle.",
+ tax_id: "TX-987654321",
+ vat_id: "VAT-123456789",
+ address1: "123 Innovation Drive",
+ address2: "Suite 456",
+ city: "New York",
+ country_id: country.id,
+ state_id: state.id,
+ contact_phone: "123-456-7888",
+ zipcode: "10001"
}
put spree.api_store_path(store), params: { store: store_hash }
expect(response.status).to eq(200)
expect(store.reload.url).to eql "spree123.example.com"
expect(store.reload.mail_from_address).to eql "me@example.com"
expect(store.reload.bcc_email).to eql "bcc@example.net"
+ expect(store.reload.legal_name).to eql "XYZ Corp"
+ expect(store.reload.tax_id).to eql "TX-987654321"
+ expect(store.reload.vat_id).to eql "VAT-123456789"
+ expect(store.reload.address1).to eql "123 Innovation Drive"
+ expect(store.reload.address2).to eql "Suite 456"
+ expect(store.reload.city).to eql "New York"
+ expect(store.reload.country_id).to eql country.id
+ expect(store.reload.state_id).to eql state.id
+ expect(store.reload.contact_phone).to eql "123-456-7888"
+ expect(store.reload.zipcode).to eql "10001"
end
context "deleting a store" do
diff --git a/backend/app/views/spree/admin/stores/_address_form.html.erb b/backend/app/views/spree/admin/stores/_address_form.html.erb
new file mode 100644
index 00000000000..0450b311644
--- /dev/null
+++ b/backend/app/views/spree/admin/stores/_address_form.html.erb
@@ -0,0 +1,65 @@
+
+<% s_or_b = type.chars.first %>
+
+
+
+
+
">
+ <%= f.label :legal_name %>
+ <%= f.text_field :legal_name, class: 'fullwidth' %>
+
+
+
">
+ <%= f.label :address1 %>
+ <%= f.text_field :address1, class: 'fullwidth' %>
+
+
+
">
+ <%= f.label :address2 %>
+ <%= f.text_field :address2, class: 'fullwidth' %>
+
+
+
">
+ <%= f.label :contact_phone %>
+ <%= f.phone_field :contact_phone, class: 'fullwidth' %>
+
+
+
+
+
">
+ <%= f.label :city %>
+ <%= f.text_field :city, class: 'fullwidth' %>
+
+
+
">
+ <%= f.label :zipcode %>
+ <%= f.text_field :zipcode, class: 'fullwidth' %>
+
+
+
">
+ <%= f.label :country_id, Spree::Country.model_name.human %>
+
+ <%= f.collection_select :country_id, available_countries, :id, :name, { include_blank: true }, {class: 'custom-select fullwidth js-country_id'} %>
+
+
+
+
">
+ <%= f.label :state_id, Spree::State.model_name.human %>
+
+ <%= f.hidden_field :state_name, value: nil %>
+ <% states = f.object.country.try(:states).nil? ? [] : f.object.country.states %>
+ <%= f.text_field :state_name,
+ style: "display: #{states.empty? ? 'block' : 'none' };",
+ disabled: !states.empty?, class: 'fullwidth state_name js-state_name' %>
+ <%= f.hidden_field :state_id, value: nil %>
+ <%= f.collection_select :state_id,
+ states.sort,
+ :id, :name,
+ { include_blank: true },
+ { class: 'custom-select fullwidth js-state_id',
+ style: "display: #{states.empty? ? 'none' : 'block' };",
+ disabled: states.empty? } %>
+
+
+
+
diff --git a/backend/app/views/spree/admin/stores/_form.html.erb b/backend/app/views/spree/admin/stores/_form.html.erb
index 59dc4a4e899..6d6563af063 100644
--- a/backend/app/views/spree/admin/stores/_form.html.erb
+++ b/backend/app/views/spree/admin/stores/_form.html.erb
@@ -30,7 +30,21 @@
<%= f.text_area :meta_description, class: 'fullwidth' %>
<%= f.error_message_on :meta_description %>
<% end %>
+
+ <%= f.field_container :tax_id do %>
+ <%= f.label :tax_id %>
+ <%= f.text_field :tax_id, class: 'fullwidth' %>
+ <%= f.error_message_on :tax_id %>
+ <% end %>
+
+ <%= f.field_container :vat_id do %>
+ <%= f.label :vat_id %>
+ <%= f.text_field :vat_id, class: 'fullwidth' %>
+ <%= f.error_message_on :vat_id %>
+ <% end %>
+
+
<%= f.field_container :url do %>
<%= f.label :url, class: 'required' %>
@@ -81,5 +95,17 @@
{ class: 'select2 fullwidth', multiple: true } %>
<%= f.error_message_on :default_currency %>
<% end %>
+
+ <%= f.field_container :description do %>
+ <%= f.label :description %>
+ <%= f.text_area :description, class: 'fullwidth' %>
+ <%= f.error_message_on :description %>
+ <% end %>
+
+
+
+
+ <%= render partial: 'address_form', locals: { f: f, type: 'store' } %>
+
diff --git a/core/app/models/spree/store.rb b/core/app/models/spree/store.rb
index 9350fb0a2b8..462c55682cd 100644
--- a/core/app/models/spree/store.rb
+++ b/core/app/models/spree/store.rb
@@ -15,12 +15,16 @@ class Store < Spree::Base
has_many :store_shipping_methods, inverse_of: :store
has_many :shipping_methods, through: :store_shipping_methods
+ belongs_to :state, class_name: 'Spree::State', optional: true
+ belongs_to :country, class_name: 'Spree::Country', optional: true
+
has_many :orders, class_name: "Spree::Order"
validates :code, presence: true, uniqueness: { allow_blank: true, case_sensitive: true }
validates :name, presence: true
validates :url, presence: true
validates :mail_from_address, presence: true
+ validates :state, presence: true, if: -> { country&.states_required }
self.allowed_ransackable_attributes = %w[name url code]
diff --git a/core/config/locales/en.yml b/core/config/locales/en.yml
index 0dd3145a750..f75958f39f6 100644
--- a/core/config/locales/en.yml
+++ b/core/config/locales/en.yml
@@ -354,18 +354,31 @@ en:
quantity: Quantity
variant: Variant
spree/store:
+ address: Address
+ address1: Street Address
+ address2: Street Address (cont'd)
available_locales: Locales Available in the Storefront
bcc_email: BCC Email
cart_tax_country_iso: Tax Country for Empty Carts
+ city: City
code: Slug
+ contact_email: Contact Email
+ contact_phone: Contact Phone
+ country_id: Country
default: Default
default_currency: Default Currency
+ description: Store Description
+ legal_name: Legal Name
mail_from_address: Mail From Address
meta_description: Meta Description
meta_keywords: Meta Keywords
name: Site Name
+ postal_code: Postal Code
seo_title: Seo Title
+ state_id: State
+ tax_id: Tax ID
url: Site URL
+ vat_id: VAT ID
spree/store_credit:
amount: Amount
amount_authorized: Amount Authorized
diff --git a/core/db/migrate/20250202173007_add_store_attributes_to_spree_stores.rb b/core/db/migrate/20250202173007_add_store_attributes_to_spree_stores.rb
new file mode 100644
index 00000000000..f1a854f2b7b
--- /dev/null
+++ b/core/db/migrate/20250202173007_add_store_attributes_to_spree_stores.rb
@@ -0,0 +1,19 @@
+class AddStoreAttributesToSpreeStores < ActiveRecord::Migration[7.2]
+ def change
+ add_column :spree_stores, :legal_name, :string
+ add_column :spree_stores, :contact_email, :string
+ add_column :spree_stores, :description, :text
+ add_column :spree_stores, :vat_id, :string
+ add_column :spree_stores, :tax_id, :string
+ add_column :spree_stores, :address1, :string
+ add_column :spree_stores, :address2, :string
+ add_column :spree_stores, :city, :string
+ add_column :spree_stores, :zipcode, :string
+ add_column :spree_stores, :state_name, :string
+ add_column :spree_stores, :contact_phone, :string
+ add_column :spree_stores, :country_id, :integer
+ add_column :spree_stores, :state_id, :integer
+ add_index :spree_stores, :country_id
+ add_index :spree_stores, :state_id
+ end
+end
diff --git a/core/lib/spree/permitted_attributes.rb b/core/lib/spree/permitted_attributes.rb
index cd59e762e21..eecb914fd57 100644
--- a/core/lib/spree/permitted_attributes.rb
+++ b/core/lib/spree/permitted_attributes.rb
@@ -121,10 +121,12 @@ module PermittedAttributes
:quantity, :stock_item, :stock_item_id, :originator, :action
]
- @@store_attributes = [:name, :url, :seo_title, :meta_keywords,
+ @@store_attributes = [:name, :legal_name, :url, :seo_title, :meta_keywords,
:meta_description, :default_currency,
:mail_from_address, :cart_tax_country_iso,
- :bcc_email]
+ :bcc_email, :contact_email, :contact_phone, :code,
+ :tax_id, :vat_id, :description, :address1, :address2,
+ :city, :zipcode, :country_id, :state_id, :state_name]
@@taxonomy_attributes = [:name]