Repository: shopware/frontends
Pull request #284 (2023-06-22 15:06:16)

fix(vue-demo-store): use locale from accept-language header on SSR
### Description

closes: #273 

### TODO
- [x] add docs


### Type of change

<!--
  Please select the relevant option.
  Remember to run `pnpm run changeset` and describe your change (plus potential migration guide/important notes) to your pull request. Read more: https://github.com/shopware/frontends/blob/main/CONTRIBUTION.md#changelog-preparation
-->

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)

### Screenshots (if applicable)

<!-- Please attach any relevant screenshots or images to help explain your changes. -->

### Additional context

<!-- Add any other context about the pull request here. -->


vercel[bot]: [vc]: #wNF98T0yoRx95GzWCIskBlQh/tDug/mdx/Y6DzV7wDU=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJzaG9wd2FyZS1mcm9udGVuZHMtZG9jcyIsInJvb3REaXJlY3RvcnkiOm51bGwsImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9zaG9wd2FyZS1mcm9udGVuZHMvc2hvcHdhcmUtZnJvbnRlbmRzLWRvY3MvOExkYmFoZ1dWTjVObVl1OEpSSDdxVGFiQUsxeSIsIm5leHRDb21taXRTdGF0dXMiOiJQRU5ESU5HIn0seyJuYW1lIjoiZnJvbnRlbmRzLWRlbW8iLCJyb290RGlyZWN0b3J5IjoidGVtcGxhdGVzL3Z1ZS1kZW1vLXN0b3JlIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3Nob3B3YXJlLWZyb250ZW5kcy9mcm9udGVuZHMtZGVtby81RThkcUQxaFhZUzdHRnc3YkFwcTJUb3h1Z3dlIiwibmV4dENvbW1pdFN0YXR1cyI6IlBFTkRJTkcifV19
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Updated (UTC) |
| :--- | :----- | :------ | :------ |
| **frontends-demo** | 🔄 Building ([Inspect](https://vercel.com/shopware-frontends/frontends-demo/5E8dqD1hXYS7GFw7bApq2Toxugwe)) |  | Jun 22, 2023 3:06pm |
| **shopware-frontends-docs** | 🔄 Building ([Inspect](https://vercel.com/shopware-frontends/shopware-frontends-docs/8LdbahgWVN5NmYu8JRH7qTabAK1y)) |  | Jun 22, 2023 3:06pm |




Patch:
diff --git a/.changeset/four-months-nail.md b/.changeset/four-months-nail.md
new file mode 100644
index 00000000..ba44f44a
--- /dev/null
+++ b/.changeset/four-months-nail.md
@@ -0,0 +1,6 @@
+---
+"vue-demo-store": patch
+"docs": patch
+---
+
+Set default locale from accept-language header in SSR
diff --git a/.changeset/orange-buses-bake.md b/.changeset/orange-buses-bake.md
new file mode 100644
index 00000000..b7a68a0d
--- /dev/null
+++ b/.changeset/orange-buses-bake.md
@@ -0,0 +1,5 @@
+---
+"vue-demo-store": minor
+---
+
+Add state to the address forms
diff --git a/.changeset/tame-toys-grab.md b/.changeset/tame-toys-grab.md
new file mode 100644
index 00000000..9c2c50bc
--- /dev/null
+++ b/.changeset/tame-toys-grab.md
@@ -0,0 +1,5 @@
+---
+"@shopware-pwa/composables-next": minor
+---
+
+Use function constructor instead of init method to set config for price displaying
diff --git a/.changeset/yellow-shrimps-explain.md b/.changeset/yellow-shrimps-explain.md
new file mode 100644
index 00000000..318100c9
--- /dev/null
+++ b/.changeset/yellow-shrimps-explain.md
@@ -0,0 +1,5 @@
+---
+"@shopware-pwa/api-client": patch
+---
+
+Change getContextCountryEndpoint request type to POST
diff --git a/apps/docs/src/packages/composables/usePrice.md b/apps/docs/src/packages/composables/usePrice.md
index 3679e637..8fc18d99 100644
--- a/apps/docs/src/packages/composables/usePrice.md
+++ b/apps/docs/src/packages/composables/usePrice.md
@@ -7,11 +7,10 @@ category: CMS
 Internally, `usePrice` composable uses [Intl.NumberFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat) in order to format a price according to the right currency standard, for corresponding locale and symbol.
 
 ```js
-const { init, getFormattedPrice } = usePrice();
-init({
+const { getFormattedPrice } = usePrice({
     currencyCode: 'EUR'
     localeCode: 'de-DE' // value taken from browser's navigator.language variable if localeCode is not provided
-})
+});
 
 const regularPrice = getFormattedPrice(49.95);
 // regularPrice: '49,95 €'
diff --git a/packages/api-client/src/services/contextService.ts b/packages/api-client/src/services/contextService.ts
index 2a092515..5a3ec5f4 100644
--- a/packages/api-client/src/services/contextService.ts
+++ b/packages/api-client/src/services/contextService.ts
@@ -182,9 +182,13 @@ export async function setCurrentLanguage(
 export async function getAvailableCountries(
   contextInstance: ShopwareApiInstance = defaultInstance
 ): Promise<EntityResult<"country", Country>> {
-  const { data } = await contextInstance.invoke.get<
+  const { data } = await contextInstance.invoke.post<
     EntityResult<"country", Country>
-  >(getContextCountryEndpoint());
+  >(getContextCountryEndpoint(), {
+    associations: {
+      states: {},
+    },
+  });
   return data;
 }
 
diff --git a/packages/composables/src/useCountries.ts b/packages/composables/src/useCountries.ts
index dfb5e981..79bfb908 100644
--- a/packages/composables/src/useCountries.ts
+++ b/packages/composables/src/useCountries.ts
@@ -8,13 +8,14 @@ import {
   provide,
 } from "vue";
 import { getAvailableCountries } from "@shopware-pwa/api-client";
-import { Country } from "@shopware-pwa/types";
+import { Country, CountryState } from "@shopware-pwa/types";
 import { useShopwareContext } from "./useShopwareContext";
 
 export type UseCountriesReturn = {
   mountedCallback(): Promise<void>;
   getCountries: ComputedRef<Country[]>;
   fetchCountries(): Promise<void>;
+  getStatesForCountry(countryId: string): CountryState[] | null;
 };
 
 /**
@@ -43,11 +44,20 @@ export function useCountries(): UseCountriesReturn {
     }
   };
 
+  const getStatesForCountry = (countryId: string) => {
+    return (
+      getCountries.value.find((element: Country) => {
+        return element.id === countryId;
+      })?.states || null
+    );
+  };
+
   onMounted(mountedCallback);
 
   return {
     mountedCallback,
     fetchCountries,
+    getStatesForCountry,
     getCountries,
   };
 }
diff --git a/packages/composables/src/usePrice.ts b/packages/composables/src/usePrice.ts
index ddd3dbcc..5af20aac 100644
--- a/packages/composables/src/usePrice.ts
+++ b/packages/composables/src/usePrice.ts
@@ -1,46 +1,57 @@
-import { ref } from "vue";
+import { ref, watch } from "vue";
 import { createSharedComposable } from "@vueuse/core";
+import { useSessionContext } from "./useSessionContext";
 
 export type UsePriceReturn = {
-  /**
-   * Set init data: localeCode & currencyCode
-   */
-  init(options: { localeCode: string | undefined; currencyCode: string }): void;
   /**
    * Format price i.e. (2) -> 2.00 $
    */
   getFormattedPrice(value: number | string | undefined): string;
+  /**
+   * Update configuration
+   */
+  update(params: {
+    localeCode: string | undefined;
+    currencyCode: string;
+  }): void;
 };
 
 /**
  * Composable for getting formatted price
+ * Set the default currency code and locale in order to format a price correctly
+ *
  * @public
  * @category Product
  */
-function _usePrice(): UsePriceReturn {
-  const currencyLocale = ref<string>("");
+function _usePrice(params?: {
+  localeCode: string | undefined;
+  currencyCode: string;
+}): UsePriceReturn {
+  const { sessionContext } = useSessionContext();
+  const currencyLocale = ref<string | undefined>();
   const currencyCode = ref<string>("");
 
-  // TODO: make sure why there is no decimal precision in api response
-  const decimalPrecision = 2;
-  /**
-   * Set init data from backend response
-   *
-   * as a fallback for params.localeCode is navigator?.language
-   * @param params
-   */
-  function init(params: {
-    localeCode: string | undefined;
+  if (params) {
+    currencyCode.value = params.currencyCode;
+    currencyLocale.value = params.localeCode;
+  }
+
+  function update(params: {
+    localeCode?: string | undefined;
     currencyCode: string;
-  }): void {
+  }) {
     _setCurrencyCode(params.currencyCode);
     _setLocaleCode(
       params.localeCode ||
+        currencyLocale.value ||
         (typeof navigator !== "undefined" && navigator?.language) ||
         "en-US"
     );
   }
 
+  // TODO: make sure why there is no decimal precision in api response
+  const decimalPrecision = 2;
+
   function _setCurrencyCode(code: string) {
     currencyCode.value = code;
   }
@@ -67,9 +78,23 @@ function _usePrice(): UsePriceReturn {
     }).format(+value);
   }
 
+  watch(
+    () => sessionContext.value?.currency,
+    (newCurrency) => {
+      if (!!newCurrency)
+        update({
+          // locale code is read only once on SSR because it's unavailable in the context
+          currencyCode: newCurrency?.isoCode as string,
+        });
+    },
+    {
+      immediate: true,
+    }
+  );
+
   return {
-    init,
     getFormattedPrice,
+    update,
   };
 }
 
diff --git a/packages/composables/src/useSessionContext.ts b/packages/composables/src/useSessionContext.ts
index 3f5821ff..a83535dd 100644
--- a/packages/composables/src/useSessionContext.ts
+++ b/packages/composables/src/useSessionContext.ts
@@ -1,4 +1,4 @@
-import { computed, ComputedRef, inject, provide, Ref, ref } from "vue";
+import { computed, ComputedRef } from "vue";
 import {
   ShippingMethod,
   PaymentMethod,
@@ -20,7 +20,6 @@ import {
   setCurrentLanguage,
 } from "@shopware-pwa/api-client";
 import { useShopwareContext } from "./useShopwareContext";
-import { usePrice } from "./usePrice";
 import { _useContext } from "./internal/_useContext";
 
 export type UseSessionContextReturn = {
@@ -108,14 +107,6 @@ export function useSessionContext(
   newContext?: SessionContext
 ): UseSessionContextReturn {
   const { apiInstance } = useShopwareContext();
-  const { init } = usePrice();
-
-  if (newContext) {
-    init({
-      currencyCode: newContext.currency?.isoCode,
-      localeCode: newContext.salesChannel?.language?.locale?.code,
-    });
-  }
 
   const _sessionContext = _useContext("swSessionContext", {
     replace: newContext,
@@ -126,11 +117,6 @@ export function useSessionContext(
     try {
       const context = await getSessionContext(apiInstance);
       _sessionContext.value = context;
-
-      init({
-        currencyCode: context.currency?.isoCode,
-        localeCode: context.salesChannel?.language?.locale?.code,
-      });
     } catch (e) {
       console.error("[UseSessionContext][refreshSessionContext]", e);
     }
diff --git a/packages/composables/src/useState.ts b/packages/composables/src/useState.ts
new file mode 100644
index 00000000..e69de29b
diff --git a/templates/vue-demo-store/app.vue b/templates/vue-demo-store/app.vue
index b540f465..04578b3e 100644
--- a/templates/vue-demo-store/app.vue
+++ b/templates/vue-demo-store/app.vue
@@ -24,6 +24,17 @@ const { data: sessionContextData } = await useAsyncData(
   }
 );
 
+// read the locale from accept-language header (i.e. en-GB or de-DE)
+// and set configuration for price formatting globally
+const headers = useRequestHeaders();
+const localeFromHeader = headers?.["accept-language"]
+  ?.split(",")
+  ?.find((languageConfig) => languageConfig.match(/([a-z]){2}\-([A-Z]{2})/));
+usePrice({
+  currencyCode: sessionContextData.value?.currency?.isoCode || "",
+  localeCode: localeFromHeader,
+});
+
 useSessionContext(sessionContextData.value as SessionContext);
 
 const { getWishlistProducts } = useWishlist();
diff --git a/templates/vue-demo-store/components/account/AccountAddressForm.vue b/templates/vue-demo-store/components/account/AccountAddressForm.vue
index f87b33e7..745eaa67 100644
--- a/templates/vue-demo-store/components/account/AccountAddressForm.vue
+++ b/templates/vue-demo-store/components/account/AccountAddressForm.vue
@@ -20,13 +20,13 @@ const props = withDefaults(
   }
 );
 
-const { getCountries } = useCountries();
 const { getSalutations } = useSalutations();
 const { t } = useI18n();
 const { pushError } = useNotifications();
 
 const formData = reactive<CustomerAddress>({
   countryId: props.address?.countryId ?? "",
+  countryStateId: props.address?.countryStateId ?? "",
   salutationId: props.address?.salutationId ?? "",
   firstName: props.address?.firstName ?? "",
   lastName: props.address?.lastName ?? "",
@@ -130,33 +130,11 @@ useFocus(firstNameInputElement, { initialValue: true });
                 data-testid="account-address-form-lastname-input"
               />
             </div>
-            <div class="col-span-6 sm:col-span-6">
-              <label
-                for="country"
-                class="block mb-2 text-sm font-medium text-gray-500"
-              >
-                {{ $t("form.country") }}
-              </label>
-              <select
-                id="country"
-                v-model="formData.countryId"
-                required
-                name="country"
-                autocomplete="country-name"
-                class="mt-1 block w-full py-2.5 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-brand-light focus:border-brand-light sm:text-sm"
-                data-testid="account-address-form-country-select"
-              >
-                <option
-                  v-for="country in getCountries"
-                  :key="country.id"
-                  :value="country.id"
-                  data-testid="account-address-form-country-select-option"
-                >
-                  {{ country.name }}
-                </option>
-              </select>
-            </div>
-
+            <SharedCountryStateInput
+              v-model:countryId="formData.countryId"
+              v-model:stateId="formData.countryStateId"
+              class="col-span-6 sm:col-span-6"
+            />
             <div class="col-span-6">
               <label
                 for="street-address"
diff --git a/templates/vue-demo-store/components/account/AccountRegisterForm.vue b/templates/vue-demo-store/components/account/AccountRegisterForm.vue
index ff344bf2..1b205247 100644
--- a/templates/vue-demo-store/components/account/AccountRegisterForm.vue
+++ b/templates/vue-demo-store/components/account/AccountRegisterForm.vue
@@ -1,6 +1,6 @@
 <script setup lang="ts">
 import { useVuelidate } from "@vuelidate/core";
-import { required, email, minLength } from "@vuelidate/validators";
+import { required, email, minLength, requiredIf } from "@vuelidate/validators";
 import { ClientApiError } from "@shopware-pwa/types";
 
 const props = defineProps<{
@@ -8,7 +8,7 @@ const props = defineProps<{
 }>();
 
 const { getSalutations } = useSalutations();
-const { getCountries } = useCountries();
+const { getStatesForCountry } = useCountries();
 const { register, isLoggedIn } = useUser();
 const { pushError } = useNotifications();
 
@@ -34,6 +34,7 @@ const initialState = {
     zipcode: "",
     city: "",
     countryId: "",
+    countryStateId: "",
   },
 };
 
@@ -73,6 +74,11 @@ const rules = computed(() => ({
     countryId: {
       required,
     },
+    countryStateId: {
+      required: requiredIf(() => {
+        return !!getStatesForCountry(state.billingAddress.countryId)?.length;
+      }),
+    },
   },
 }));
 
@@ -278,7 +284,7 @@ useBreadcrumbs([
       </h3>
       <div class="grid grid-cols-12 gap-5 mb-10">
         <div class="col-span-12 md:col-span-4">
-          <label for="street">{{ $t("form.street") }} *</label>
+          <label for="street">{{ $t("form.streetAddress") }} *</label>
           <input
             id="Street"
             v-model="state.billingAddress.street"
@@ -358,40 +364,13 @@ useBreadcrumbs([
           </span>
         </div>
 
-        <div class="col-span-12 md:col-span-4">
-          <label for="country">{{ $t("form.country") }} *</label>
-          <select
-            id="country"
-            v-model="state.billingAddress.countryId"
-            name="country"
-            class="appearance-none relative block w-full px-3 py-2 border placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:z-10 sm:text-sm"
-            :class="[
-              $v.billingAddress.countryId.$error
-                ? 'border-red-600 focus:border-red-600'
-                : 'border-gray-300 focus:border-indigo-500',
-            ]"
-            :disabled="loading"
-            data-testid="registration-country-select"
-            @blur="$v.billingAddress.countryId.$touch()"
-          >
-            <option disabled selected value="">
-              {{ $t("form.chooseCountry") }}
-            </option>
-            <option
-              v-for="country in getCountries"
-              :key="country.id"
-              :value="country.id"
-            >
-              {{ country.name }}
-            </option>
-          </select>
-          <span
-            v-if="$v.salutationId.$error"
-            class="pt-1 text-sm text-red-600 focus:ring-brand-primary border-gray-300"
-          >
-            {{ $v.salutationId.$errors[0].$message }}
-          </span>
-        </div>
+        <SharedCountryStateInput
+          v-model:countryId="state.billingAddress.countryId"
+          v-model:stateId="state.billingAddress.countryStateId"
+          :country-id-validation="$v.billingAddress.countryId"
+          :state-id-validation="$v.billingAddress.countryStateId"
+          class="col-span-12 md:col-span-4"
+        />
       </div>
       <div class="mb-5 text-right">
         <button
diff --git a/templates/vue-demo-store/components/shared/SharedCountryStateInput.vue b/templates/vue-demo-store/components/shared/SharedCountryStateInput.vue
new file mode 100644
index 00000000..a2ac23a3
--- /dev/null
+++ b/templates/vue-demo-store/components/shared/SharedCountryStateInput.vue
@@ -0,0 +1,97 @@
+<script lang="ts" setup>
+const emit = defineEmits<{
+  (e: "update:countryId", value: string): void;
+  (e: "update:stateId", value: string): void;
+}>();
+
+const props = withDefaults(
+  defineProps<{
+    countryId: string;
+    stateId: string | null;
+    countryIdValidation?: object | null;
+    stateIdValidation?: object | null;
+  }>(),
+  {
+    countryId: "",
+    stateId: "",
+    countryIdValidation: null,
+    stateIdValidation: null,
+  }
+);
+
+const { countryId, stateId } = useVModels(props, emit);
+const { getCountries, getStatesForCountry } = useCountries();
+const states = computed(() => {
+  return getStatesForCountry(countryId.value);
+});
+function onCountrySelectChanged() {
+  stateId.value = "";
+}
+</script>
+
+<template>
+  <div class="flex gap-6">
+    <div class="w-full">
+      <label for="country" class="block text-sm font-medium text-gray-700">{{
+        $t("form.country")
+      }}</label>
+      <select
+        id="country"
+        v-model="countryId"
+        required
+        name="country"
+        autocomplete="country-name"
+        class="mt-1 block w-full p-2.5 border border-gray-300 text-gray-900 text-sm rounded-md shadow-sm focus:ring-brand-light focus:border-brand-light"
+        data-testid="country-select"
+        @change="onCountrySelectChanged"
+        @blur="countryIdValidation && countryIdValidation.$touch()"
+      >
+        <option disabled selected value="">
+          {{ $t("form.chooseCountry") }}
+        </option>
+        <option
+          v-for="country in getCountries"
+          :key="country.id"
+          :value="country.id"
+        >
+          {{ country.name }}
+        </option>
+      </select>
+      <span
+        v-if="countryIdValidation && countryIdValidation.$error"
+        class="pt-1 text-sm text-red-600 focus:ring-brand-primary border-gray-300"
+      >
+        {{ countryIdValidation.$errors[0].$message }}
+      </span>
+    </div>
+    <div v-if="states && states.length" class="w-full">
+      <label for="state" class="block text-sm font-medium text-gray-700">{{
+        $t("form.state")
+      }}</label>
+      <select
+        id="state"
+        v-model="stateId"
+        required
+        name="state"
+        autocomplete="state-name"
+        class="mt-1 block w-full p-2.5 border border-gray-300 text-gray-900 text-sm rounded-md shadow-sm focus:ring-brand-light focus:border-brand-light"
+        data-testid="checkout-pi-state-input"
+        @blur="stateIdValidation && stateIdValidation.$touch()"
+      >
+        <option disabled selected value="">
+          {{ $t("form.chooseState") }}
+        </option>
+
+        <option v-for="state in states" :key="state.id" :value="state.id">
+          {{ state.name }}
+        </option>
+      </select>
+      <span
+        v-if="stateIdValidation && stateIdValidation.$error"
+        class="pt-1 text-sm text-red-600 focus:ring-brand-primary border-gray-300"
+      >
+        {{ stateIdValidation.$errors[0].$message }}
+      </span>
+    </div>
+  </div>
+</template>
diff --git a/templates/vue-demo-store/i18n/de-DE/form.json b/templates/vue-demo-store/i18n/de-DE/form.json
index 5265a719..79634495 100644
--- a/templates/vue-demo-store/i18n/de-DE/form.json
+++ b/templates/vue-demo-store/i18n/de-DE/form.json
@@ -24,6 +24,8 @@
     "save": "Speichern",
     "submit": "Senden",
     "promoCodePlaceholder": "Promo-Code eingeben",
-    "searchPlaceholder": "Produkte suchen"
+    "searchPlaceholder": "Produkte suchen",
+    "chooseState": "Wählen Sie den Staat",
+    "state": "Zustand"
   }
 }
diff --git a/templates/vue-demo-store/i18n/en-GB/form.json b/templates/vue-demo-store/i18n/en-GB/form.json
index fa0f6173..3e48fd6e 100644
--- a/templates/vue-demo-store/i18n/en-GB/form.json
+++ b/templates/vue-demo-store/i18n/en-GB/form.json
@@ -24,6 +24,8 @@
     "save": "Save",
     "submit": "Submit",
     "promoCodePlaceholder": "Enter promo code",
-    "searchPlaceholder": "Search products"
+    "searchPlaceholder": "Search products",
+    "chooseState": "Choose state",
+    "state": "State"
   }
 }
diff --git a/templates/vue-demo-store/i18n/pl-PL/form.json b/templates/vue-demo-store/i18n/pl-PL/form.json
index 4670a1cf..bfd0fe68 100644
--- a/templates/vue-demo-store/i18n/pl-PL/form.json
+++ b/templates/vue-demo-store/i18n/pl-PL/form.json
@@ -24,6 +24,8 @@
     "save": "Zapisz",
     "submit": "Zatwierdź",
     "promoCodePlaceholder": "Wpisz kod promocyjny",
-    "searchPlaceholder": "Szukaj produktów"
+    "searchPlaceholder": "Szukaj produktów",
+    "chooseState": "Wybierz stan",
+    "state": "Stan"
   }
 }
diff --git a/templates/vue-demo-store/pages/checkout/index.vue b/templates/vue-demo-store/pages/checkout/index.vue
index dc52469e..db79c4f8 100644
--- a/templates/vue-demo-store/pages/checkout/index.vue
+++ b/templates/vue-demo-store/pages/checkout/index.vue
@@ -14,7 +14,7 @@ definePageMeta({
 });
 
 const { push } = useRouter();
-const { getCountries } = useCountries();
+const { getCountries, getStatesForCountry } = useCountries();
 const { getSalutations } = useSalutations();
 const { pushInfo } = useNotifications();
 const { t } = useI18n();
@@ -125,6 +125,7 @@ const state = reactive({
     zipcode: "",
     city: "",
     countryId: "",
+    countryStateId: "",
   },
   customShipping: false,
 });
@@ -172,6 +173,11 @@ const rules = computed(() => ({
     countryId: {
       required,
     },
+    countryStateId: {
+      required: requiredIf(() => {
+        return !!getStatesForCountry(state.billingAddress.countryId)?.length;
+      }),
+    },
   },
 }));
 
@@ -507,7 +513,6 @@ const addAddressModalController = useModal();
                     {{ $v.billingAddress.zipcode.$errors[0].$message }}
                   </span>
                 </div>
-
                 <div class="col-span-6 sm:col-span-3">
                   <label
                     for="city"
@@ -533,41 +538,13 @@ const addAddressModalController = useModal();
                     {{ $v.billingAddress.city.$errors[0].$message }}
                   </span>
                 </div>
-
-                <div class="col-span-6">
-                  <label
-                    for="country"
-                    class="block text-sm font-medium text-gray-700"
-                    >{{ $t("form.country") }}</label
-                  >
-                  <select
-                    id="country"
-                    v-model="state.billingAddress.countryId"
-                    required
-                    name="country"
-                    autocomplete="country-name"
-                    class="mt-1 block w-full p-2.5 border border-gray-300 text-gray-900 text-sm rounded-md shadow-sm focus:ring-brand-light focus:border-brand-light"
-                    data-testid="checkout-pi-country-input"
-                    @blur="$v.billingAddress.countryId.$touch()"
-                  >
-                    <option disabled selected value="">
-                      {{ $t("form.chooseCountry") }}
-                    </option>
-                    <option
-                      v-for="country in getCountries"
-                      :key="country.id"
-                      :value="country.id"
-                    >
-                      {{ country.name }}
-                    </option>
-                  </select>
-                  <span
-                    v-if="$v.billingAddress.countryId.$error"
-                    class="pt-1 text-sm text-red-600 focus:ring-brand-primary border-gray-300"
-                  >
-                    {{ $v.billingAddress.countryId.$errors[0].$message }}
-                  </span>
-                </div>
+                <SharedCountryStateInput
+                  v-model:countryId="state.billingAddress.countryId"
+                  v-model:stateId="state.billingAddress.countryStateId"
+                  :country-id-validation="$v.billingAddress.countryId"
+                  :state-id-validation="$v.billingAddress.countryStateId"
+                  class="col-span-6"
+                />
               </div>
               <button
                 type="submit"


Test patch:
diff --git a/apps/e2e-tests/page-objects/CheckoutPage.ts b/apps/e2e-tests/page-objects/CheckoutPage.ts
index da2aeb5e..a33cb691 100644
--- a/apps/e2e-tests/page-objects/CheckoutPage.ts
+++ b/apps/e2e-tests/page-objects/CheckoutPage.ts
@@ -33,7 +33,7 @@ export class CheckoutPage {
     this.street = page.getByTestId("checkout-pi-street-address-input");
     this.zipcode = page.getByTestId("checkout-pi-zip-code-input");
     this.city = page.getByTestId("checkout-pi-city-input");
-    this.country = page.getByTestId("checkout-pi-country-input");
+    this.country = page.getByTestId("country-select");
     this.submitButton = page.getByTestId("checkout-pi-submit-button");
     this.termsBox = page.getByTestId("checkout-terms-box");
     this.termCheckbox = page.getByTestId("checkout-t&c-checkbox-tos");
diff --git a/apps/e2e-tests/page-objects/RegisterPage.ts b/apps/e2e-tests/page-objects/RegisterPage.ts
index e74fe328..832c2b86 100644
--- a/apps/e2e-tests/page-objects/RegisterPage.ts
+++ b/apps/e2e-tests/page-objects/RegisterPage.ts
@@ -26,7 +26,7 @@ export class RegisterForm {
     this.street = page.getByTestId("registration-street-input");
     this.zipcode = page.getByTestId("registration-zipcode-input");
     this.city = page.getByTestId("registration-city-input");
-    this.country = page.getByTestId("registration-country-select");
+    this.country = page.getByTestId("country-select");
     this.submitButton = page.getByTestId("registration-submit-button");
   }
 
diff --git a/packages/api-client/src/services/ContextServiceTests/getAvailableCountries.spec.ts b/packages/api-client/src/services/ContextServiceTests/getAvailableCountries.spec.ts
index f94c28cf..328f34ee 100644
--- a/packages/api-client/src/services/ContextServiceTests/getAvailableCountries.spec.ts
+++ b/packages/api-client/src/services/ContextServiceTests/getAvailableCountries.spec.ts
@@ -6,19 +6,23 @@ vi.mock("../../../src/apiService");
 const mockedApiInstance = defaultInstance;
 
 describe("ContextService - getAvailableCountries", () => {
-  const mockedGet = vi.fn();
+  const mockedPost = vi.fn();
   beforeEach(() => {
     vi.resetAllMocks();
     mockedApiInstance.invoke = {
-      get: mockedGet,
+      post: mockedPost,
     } as any;
   });
   it("should return array with countries", async () => {
-    mockedGet.mockResolvedValueOnce({ data: { total: 2 } });
+    mockedPost.mockResolvedValueOnce({ data: { total: 2 } });
 
     const result = await getAvailableCountries();
-    expect(mockedGet).toBeCalledTimes(1);
-    expect(mockedGet).toBeCalledWith("/store-api/country");
+    expect(mockedPost).toBeCalledTimes(1);
+    expect(mockedPost).toBeCalledWith("/store-api/country", {
+      associations: {
+        states: {},
+      },
+    });
     expect(result.total).toEqual(2);
   });
 });
diff --git a/packages/composables/src/usePrice.test.ts b/packages/composables/src/usePrice.test.ts
index 211cef29..0d9058ea 100644
--- a/packages/composables/src/usePrice.test.ts
+++ b/packages/composables/src/usePrice.test.ts
@@ -1,9 +1,22 @@
-import { describe, expect, it } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
 import { createApp } from "vue";
 import { usePrice } from "./usePrice";
 
 export function withSetup(composable: any) {
   let result;
+
+  vi.mock("./useSessionContext.ts", () => ({
+    useSessionContext() {
+      return {
+        sessionContext: {
+          currency: {
+            isoCode: "EUR",
+          },
+        },
+      };
+    },
+  }));
+
   const app = createApp({
     setup() {
       result = composable();
@@ -12,15 +25,13 @@ export function withSetup(composable: any) {
       return () => {};
     },
   });
+
   app.mount(document.createElement("div"));
-  // return the result and the app instance
-  // for testing provide / unmount
   return [result, app];
 }
 
 describe("usePrice", () => {
-  const { init, getFormattedPrice } = usePrice();
-  init({
+  const { getFormattedPrice, update } = usePrice({
     localeCode: "en-US",
     currencyCode: "USD",
   });
@@ -34,7 +45,7 @@ describe("usePrice", () => {
   });
 
   it("should update config", () => {
-    init({
+    update({
       localeCode: "de-DE",
       currencyCode: "EUR",
     });
@@ -42,11 +53,11 @@ describe("usePrice", () => {
     expect(getFormattedPrice(4.1).replace(/\s/g, " ")).toBe("4,10 €");
   });
 
-  it("should return price with language locale code taken from navigator", () => {
-    init({
+  it("should return price with current locale", () => {
+    update({
       currencyCode: "USD",
       currencyLocale: undefined,
     } as any);
-    expect(getFormattedPrice(2.55)).toStrictEqual(`$2.55`);
+    expect(getFormattedPrice(2.55).replace(/\s/g, " ")).toStrictEqual(`2,55 $`);
   });
 });