Skip to content

Commit 76b10b9

Browse files
committed
Add organization structured data fields to Spree stores for SEO
1. Added new fields to spree_stores to support organization structured data for SEO. 2. Fields include legal_name, contact_email, contact_phone, description, vat_id, tax_id, address1, address2, city, zipcode, state_name, country_id, and state_id. 3. This update allows to return all organization data on frontend via jsonb if implemented according to schema documentation. schema.org reference: https://schema.org/Organization Google Implementation: https://developers.google.com/search/docs/appearance/structured-data/organization 4. Enhance store API schema with additional attributes 5. Updated the API schema for store-related endpoints to include additional store attributes. 6. Update admin view for store new and edit form - Added component for rendering the store edit and new views. - Added address partial as a separate component within the store form. - Added the test cases for new, edit and address form component
1 parent 43b4607 commit 76b10b9

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)