Skip to content

Commit a7fa008

Browse files
committed
[Compliance / SEO] Add structured data fields to Spree stores
This Commit provides the possibility to store legal information in the store resource. Given the wide array of different information required by country, the information stored here provides a baseline for most countries, but might need to be augmented in some jurisdictions. This commit is not meant as legal advisory. Inform yourself about eventual requirements in your country of operation. Some additional information that might be needed: - Legal Representative, - Share Capital of the company, - Contacts for privacy. Schema.org has a schema to markup organizations that has been widely adopted by various search engines: | | https://schema.org/Organization Support / Docs | |--------|-----------------------------------------------------------------------------------| | Bing | https://www.bing.com/webmasters/help/?topicid=cc507c09 | | Google | https://developers.google.com/search/docs/appearance/structured-data/organization | | Yandex | https://yandex.com/support/webmaster/schema-org/what-is-schema-org.html | This update allows to return all organization data on frontend via jsonb if implemented according to schema documentation on a per store basis and edit the data via API and Backend (Old / New). The implemented fields are: - legal_name, - contact_email, - contact_phone, - vat_id, - tax_id, - address1, - address2, - Zip code and city, - state_name, - country_id, - and state_id. Some additional notes regarding the size of the commit: While this commit seems massive in size, no functionality has been added apart from storing the values, no logical changes have been made, hence the commit while bigger in size is simple to analyse. Apart from testing routines, this commit does not contain functional code. Where possible the structure of current address forms has been adopted. Fixes #6173
1 parent 43b4607 commit a7fa008

File tree

25 files changed

+1116
-87
lines changed

25 files changed

+1116
-87
lines changed
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<fieldset class="<%= stimulus_id %>"
2+
data-controller="<%= stimulus_id %>"
3+
>
4+
<div class="<%= stimulus_id %>--address-form flex flex-wrap gap-4 pb-4">
5+
<%= render component("ui/forms/field").text_field(@name, :legal_name, object: @store) %>
6+
<%= render component("ui/forms/field").text_field(@name, :address1, object: @store) %>
7+
<%= render component("ui/forms/field").text_field(@name, :address2, object: @store) %>
8+
<div class="flex gap-4 w-full">
9+
<%= render component("ui/forms/field").text_field(@name, :city, object: @store) %>
10+
<%= render component("ui/forms/field").text_field(@name, :zipcode, object: @store) %>
11+
</div>
12+
13+
<%= render component("ui/forms/field").select(
14+
@name,
15+
:country_id,
16+
Spree::Country.pluck(:name, :id),
17+
object: @store,
18+
value: @store.country_id,
19+
"data-#{stimulus_id}-target": "country",
20+
"data-action": "change->#{stimulus_id}#loadStates"
21+
) %>
22+
<%= content_tag :div,
23+
class: "flex flex-col gap-2 w-full #{'hidden' if @store.country&.states_required}",
24+
data: { "#{stimulus_id}-target": "stateNameWrapper" } do %>
25+
<%= render component("ui/forms/field").text_field(
26+
@name, :state_name,
27+
object: @store,
28+
value: @store.state_name,
29+
data: { "#{stimulus_id}-target": "stateName" }
30+
) %>
31+
<% end %>
32+
33+
<input autocomplete="off" type="hidden" name=<%= "#{@name}[state_id]" %>>
34+
35+
<%= content_tag :div,
36+
class: "flex flex-col gap-2 w-full #{'hidden' unless @store.country&.states_required}",
37+
data: { "#{stimulus_id}-target": "stateWrapper" } do %>
38+
<%= render component("ui/forms/field").select(
39+
@name, :state_id,
40+
state_options,
41+
object: @store,
42+
value: @store.state_id,
43+
data: { "#{stimulus_id}-target": "state" }
44+
) %>
45+
<% end %>
46+
<%= render component("ui/forms/field").text_field(@name, :tax_id, object: @store) %>
47+
<%= render component("ui/forms/field").text_field(@name, :vat_id, object: @store, hint: t(".hint.vat_id").html_safe) %>
48+
</div>
49+
</fieldset>
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { Controller } from "@hotwired/stimulus"
2+
3+
export default class extends Controller {
4+
static targets = ["country", "state", "stateName", "stateWrapper", "stateNameWrapper"]
5+
6+
loadStates() {
7+
const countryId = this.countryTarget.value
8+
9+
fetch(`/admin/countries/${countryId}/states`)
10+
.then((response) => response.json())
11+
.then((data) => {
12+
this.updateStateOptions(data)
13+
})
14+
}
15+
16+
updateStateOptions(states) {
17+
if (states.length === 0) {
18+
this.toggleStateFields(false)
19+
} else {
20+
this.toggleStateFields(true)
21+
this.populateStateSelect(states)
22+
}
23+
}
24+
25+
toggleStateFields(showSelect) {
26+
const stateWrapper = this.stateWrapperTarget
27+
const stateNameWrapper = this.stateNameWrapperTarget
28+
const stateSelect = this.stateTarget
29+
const stateName = this.stateNameTarget
30+
31+
if (showSelect) {
32+
// Show state select dropdown.
33+
stateSelect.disabled = false
34+
stateName.value = ""
35+
stateWrapper.classList.remove("hidden")
36+
stateNameWrapper.classList.add("hidden")
37+
} else {
38+
// Show state name text input if no states to choose from.
39+
stateSelect.disabled = true
40+
stateWrapper.classList.add("hidden")
41+
stateNameWrapper.classList.remove("hidden")
42+
}
43+
}
44+
45+
populateStateSelect(states) {
46+
const stateSelect = this.stateTarget
47+
stateSelect.innerHTML = ""
48+
49+
states.forEach((state) => {
50+
const option = document.createElement("option")
51+
option.value = state.id
52+
option.innerText = state.name
53+
stateSelect.appendChild(option)
54+
})
55+
}
56+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# frozen_string_literal: true
2+
3+
class SolidusAdmin::Stores::AddressForm::Component < SolidusAdmin::BaseComponent
4+
def initialize(store:)
5+
@name = "store"
6+
@store = store
7+
end
8+
9+
def state_options
10+
country = @store.country
11+
return [] unless country && country.states_required
12+
13+
country.states.pluck(:name, :id)
14+
end
15+
end
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
en:
2+
hint:
3+
vat_id: Enter your VAT-ID without the Country Prefix (eg IT for Italy or DE for Germany) but solely the identification string.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<%= page do %>
2+
<%= page_header do %>
3+
<%= page_header_back(solidus_admin.stores_path) %>
4+
<%= page_header_title(t(".title", store: @store&.name)) %>
5+
<%= page_header_actions do %>
6+
<div class="py-1.5 text-center">
7+
<%= render component("ui/button").new(tag: :button, text: t(".update"), form: form_id) %>
8+
<%= render component("ui/button").new(tag: :a, text: t(".cancel"), href: solidus_admin.edit_store_path(@store), scheme: :secondary) %>
9+
</div>
10+
<% end %>
11+
<% end %>
12+
13+
<%= form_for @store, url: solidus_admin.store_path(@store), html: { id: form_id } do |f| %>
14+
<%= page_with_sidebar do %>
15+
<%= page_with_sidebar_main do %>
16+
<%= render component("ui/panel").new(title: t(".store_settings")) do %>
17+
<div class="flex flex-wrap gap-4 pb-4">
18+
<%= render component("ui/forms/field").text_field(f, :name, required: true) %>
19+
<%= render component("ui/forms/field").text_field(f, :url, required: true) %>
20+
<%= render component("ui/forms/field").text_field(f, :code, required: true, hint: t(".hint.code").html_safe) %>
21+
</div>
22+
<% end %>
23+
24+
<%= render component("ui/panel").new(title: t(".regional_settings")) do %>
25+
<div class="flex flex-wrap gap-4 pb-4">
26+
<%= render component("ui/forms/field").select(
27+
f,
28+
:default_currency,
29+
currency_options,
30+
include_blank: true,
31+
hint: t(".hint.default_currency").html_safe
32+
) %>
33+
<%= render component("ui/forms/field").select(
34+
f,
35+
:cart_tax_country_iso,
36+
cart_tax_country_options,
37+
include_blank: t(".no_cart_tax_country"),
38+
hint: t(".hint.cart_tax_country_iso").html_safe
39+
) %>
40+
<%= render component("ui/forms/field").select(
41+
f,
42+
:available_locales,
43+
localization_options,
44+
multiple: true,
45+
class: "select2",
46+
name: "store[available_locales][]",
47+
hint: t(".hint.available_locales").html_safe
48+
) %>
49+
</div>
50+
<% end %>
51+
52+
<%= render component("ui/panel").new(title: t(".email_settings")) do %>
53+
<div class="flex flex-wrap gap-4 pb-4">
54+
<%= render component("ui/forms/field").text_field(f, :mail_from_address, required: true) %>
55+
<%= render component("ui/forms/field").text_field(f, :bcc_email) %>
56+
</div>
57+
<% end %>
58+
59+
<%= render component("ui/panel").new(title: t(".store_legal_addres")) do %>
60+
<div class="flex flex-wrap gap-4 pb-4">
61+
<div class="js-addresses-form">
62+
<%= render component("stores/address_form").new(
63+
store: @store,
64+
) %>
65+
</div>
66+
</div>
67+
<% end %>
68+
69+
<%= render component("ui/panel").new(title: t(".contact_options")) do %>
70+
<div class="flex flex-wrap gap-4 pb-4">
71+
<%= render component("ui/forms/field").text_field(f, :contact_phone) %>
72+
<%= render component("ui/forms/field").text_field(f, :contact_email) %>
73+
</div>
74+
<% end %>
75+
76+
<%= render component("ui/panel").new(title: t(".content_on_storefront")) do %>
77+
<div class="flex flex-wrap gap-4 pb-4">
78+
<%= render component("ui/forms/field").text_field(f, :seo_title) %>
79+
<%= render component("ui/forms/field").text_area(f, :description) %>
80+
<%= render component("ui/forms/field").text_field(f, :meta_keywords) %>
81+
<%= render component("ui/forms/field").text_area(f, :meta_description) %>
82+
</div>
83+
<% end %>
84+
<% end %>
85+
<% end %>
86+
<% end %>
87+
<% end %>
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# frozen_string_literal: true
2+
3+
class SolidusAdmin::Stores::Edit::Component < SolidusAdmin::BaseComponent
4+
include SolidusAdmin::Layout::PageHelpers
5+
6+
# Define the necessary attributes for the component
7+
attr_reader :store, :available_countries
8+
9+
# Initialize the component with required data
10+
def initialize(store:)
11+
@store = store
12+
@available_countries = fetch_available_countries
13+
end
14+
15+
def form_id
16+
@form_id ||= "#{stimulus_id}--form-#{@store.id}"
17+
end
18+
19+
def currency_options
20+
Spree::Config.available_currencies.map(&:iso_code)
21+
end
22+
23+
# Generates options for cart tax countries
24+
def cart_tax_country_options
25+
fetch_available_countries(restrict_to_zone: Spree::Config[:checkout_zone]).map do |country|
26+
[country.name, country.iso]
27+
end
28+
end
29+
30+
# Generates available locales
31+
def localization_options
32+
Spree.i18n_available_locales.map do |locale|
33+
[
34+
I18n.t('spree.i18n.this_file_language', locale: locale, default: locale.to_s),
35+
locale
36+
]
37+
end
38+
end
39+
40+
# Fetch countries for the address form
41+
def available_country_options
42+
Spree::Country.order(:name).map { |country| [country.name, country.id] }
43+
end
44+
45+
private
46+
47+
# Fetch the available countries for the localization section
48+
def fetch_available_countries(restrict_to_zone: Spree::Config[:checkout_zone])
49+
countries = Spree::Country.available(restrict_to_zone:)
50+
51+
country_names = Carmen::Country.all.map do |country|
52+
[country.code, country.name]
53+
end.to_h
54+
55+
country_names.update I18n.t('spree.country_names', default: {}).stringify_keys
56+
57+
countries.collect do |country|
58+
country.name = country_names.fetch(country.iso, country.name)
59+
country
60+
end.sort_by { |country| country.name.parameterize }
61+
end
62+
end
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
en:
2+
address: Address
3+
cancel: Cancel
4+
contact_options: Contact Options
5+
content_on_storefront: Content on Storefront
6+
email_settings: Email Settings
7+
hint:
8+
available_locales: This determines which locales are available for your customers to choose from in the storefront.
9+
cart_tax_country_iso: 'This determines which country is used for taxes on carts (orders which don''t yet have an address).<br> Default: None.'
10+
code: An identifier for your store. Developers may need this value if you operate multiple storefronts.
11+
default_currency: This determines which currency will be used for the storefront's product prices. Please, be aware that changing this configuration, only products that have prices in the selected currency will be listed on your storefront. <br>This setting won't change the default currency used when you create a product. For that, only the global `Spree::Config.currency` is taken into account.
12+
regional_settings: Regional Settings
13+
store_details: Store Details
14+
store_legal_addres: Store Legal Address
15+
store_settings: Store Name & URL / URI Settings
16+
title: "%{store}"
17+
update: Update
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<%= page do %>
2+
<%= page_header do %>
3+
<%= page_header_back(solidus_admin.stores_path) %>
4+
<%= page_header_title(t(".title")) %>
5+
<%= page_header_actions do %>
6+
<div class="py-1.5 text-center">
7+
<%= render component("ui/button").new(tag: :button, text: t(".save"), form: form_id) %>
8+
<%= render component("ui/button").new(tag: :a, text: t(".cancel"), href: solidus_admin.new_store_path, scheme: :secondary) %>
9+
</div>
10+
<% end %>
11+
<% end %>
12+
13+
<%= form_for @store, url: solidus_admin.stores_path, html: { id: form_id } do |f| %>
14+
<%= page_with_sidebar do %>
15+
<%= page_with_sidebar_main do %>
16+
<%= render component("ui/panel").new(title: t(".store_settings")) do %>
17+
<div class="flex flex-wrap gap-4 pb-4">
18+
<%= render component("ui/forms/field").text_field(f, :name, required: true) %>
19+
<%= render component("ui/forms/field").text_field(f, :url, required: true) %>
20+
<%= render component("ui/forms/field").text_field(f, :code, required: true, hint: t(".hint.code").html_safe) %>
21+
</div> <!-- Closing div was missing -->
22+
<% end %>
23+
24+
<%= render component("ui/panel").new(title: t(".regional_settings")) do %>
25+
<div class="flex flex-wrap gap-4 pb-4">
26+
<%= render component("ui/forms/field").select(
27+
f,
28+
:default_currency,
29+
currency_options,
30+
include_blank: true,
31+
hint: t(".hint.default_currency").html_safe
32+
) %>
33+
<%= render component("ui/forms/field").select(
34+
f,
35+
:cart_tax_country_iso,
36+
cart_tax_country_options,
37+
include_blank: t(".no_cart_tax_country"),
38+
hint: t(".hint.cart_tax_country_iso").html_safe
39+
) %>
40+
<%= render component("ui/forms/field").select(
41+
f,
42+
:available_locales,
43+
localization_options,
44+
multiple: true,
45+
class: "select2",
46+
name: "store[available_locales][]",
47+
hint: t(".hint.available_locales").html_safe
48+
) %>
49+
</div>
50+
<% end %>
51+
52+
<%= render component("ui/panel").new(title: t(".email_settings")) do %>
53+
<div class="flex flex-wrap gap-4 pb-4">
54+
<%= render component("ui/forms/field").text_field(f, :mail_from_address, required: true) %>
55+
<%= render component("ui/forms/field").text_field(f, :bcc_email) %>
56+
</div>
57+
<% end %>
58+
59+
<%= render component("ui/panel").new(title: t(".store_legal_addres")) do %>
60+
<div class="flex flex-wrap gap-4 pb-4">
61+
<div class="js-addresses-form">
62+
<%= render component("stores/address_form").new(
63+
store: @store,
64+
) %>
65+
</div>
66+
</div>
67+
<% end %>
68+
69+
<%= render component("ui/panel").new(title: t(".contact_options")) do %>
70+
<div class="flex flex-wrap gap-4 pb-4">
71+
<%= render component("ui/forms/field").text_field(f, :contact_phone) %>
72+
<%= render component("ui/forms/field").text_field(f, :contact_email) %>
73+
</div>
74+
<% end %>
75+
76+
<%= render component("ui/panel").new(title: t(".content_on_storefront")) do %>
77+
<div class="flex flex-wrap gap-4 pb-4">
78+
<%= render component("ui/forms/field").text_field(f, :seo_title) %>
79+
<%= render component("ui/forms/field").text_area(f, :description) %>
80+
<%= render component("ui/forms/field").text_field(f, :meta_keywords) %>
81+
<%= render component("ui/forms/field").text_area(f, :meta_description) %>
82+
</div>
83+
<% end %>
84+
<% end %>
85+
<% end %>
86+
<% end %>
87+
<% end %>

0 commit comments

Comments
 (0)