Vue Storefront is now Alokai! Learn More
Legacy

Legacy

v1.0.2 migration guide

Introduction

In this release, we:

  • removed the useUserCredentials composable for security reasons. User credentials are validated in the updateCustomer API call.
  • split useCategory composable into:
    • useCategory composable to get category information,
    • useCategoryTree composable to get category tree.
  • added pagination to reviews.

User validation

PasswordResetForm component

The useUserCredentials composable has been removed, and validation is done on the API level. You should remove the useUserCredentials composable from setup function and add information necessary for validation to form.value.validation.

# components/MyAccount/PasswordResetForm.vue

import {
  defineComponent,
  ref,
  reactive,
+  useContext
} from '@nuxtjs/composition-api';
- import { useUserCredentials } from '@vsf-enterprise/bigcommerce';

...

setup(_, { emit }) {
  const userData = useUserData();
-   const { user } = useUser();
-   const { isValid, validate } = useUserCredentials(
-     'validateCredentials'
-   );
+   const { user, error: updateUserError } = useUser();
+   const { i18n } = useContext();
  const resetForm = () => ({
    currentPassword: '',
    newPassword: '',
-     repeatPassword: ''
+     repeatPassword: '',
+     validation: {
+       email: '',
+       password: ''
+     }
  });
  const error = reactive({
    authentication: null
  });
  const form = ref(resetForm());
  const submitForm = (resetValidationFn) => () => {
    const onComplete = () => {
-       form.value = resetForm();
-       resetValidationFn();
+       if (updateUserError.value?.changePassword) {
+         error.authentication = i18n.t('Invalid credentials');
+       } else {
+         error.authentication = '';
+         form.value = resetForm();
+         resetValidationFn();
+       }
    };

    const onError = () => {
      // Handle error
    };

-     validate({
+     form.value.validation = {
      email: userData.getEmailAddress(user.value),
      password: form.value.currentPassword
-     }).then(() => {
-       error.authentication = null;
-       if (isValid.value) {
-         emit('submit', { form, onComplete, onError });
-       } else {
-         error.authentication = 'Invalid password';
-       }
-     });
+     };
+
+     emit('submit', { form, onComplete, onError });
  };

  return {
    form,
    error,
    submitForm,
    userData
  };
}

ProfileUpdateForm component

The useUserCredentials composable has been removed, and validation is done on the API level. You should remove the useUserCredentials composable from setup function and add information necessary for validation to form.value.validation.

# components/MyAccount/ProfileUpdateForm.vue

import {
  defineComponent,
  PropType,
  reactive,
+   useContext,
  ref
} from '@nuxtjs/composition-api';

- import { useUserCredentials } from '@vsf-enterprise/bigcommerce';

...

setup(props, { emit }) {
  const { i18n } = useContext();
+   const userData = useUserData();
-   const { user, loading } = useUser();
-   const { isValid, validate } = useUserCredentials(
-     'validateCredentials'
-   );
+   const { user, loading, error: updateUserError } = useUser();
  const currentPassword = ref('');
  const requirePassword = ref(false);
  const error = reactive({
    authentication: null
  });
  const resetForm = () => ({
    firstName: userData.getFirstName(user.value),
    lastName: userData.getLastName(user.value),
    email: userData.getEmailAddress(user.value),
+     validation: {
+       email: userData.getEmailAddress(user.value),
+       password: ''
+     }
  });
  const form = ref<{
    firstName: string;
    lastName: string;
    email: string;
    password?: string;
+     validation: {
+       email: string;
+       password: string;
+     };
  }>(resetForm());
  const submitForm = (resetValidationFn) => () => {
    requirePassword.value = true;
+     error.authentication = '';
    const onComplete = () => {
-       requirePassword.value = false;
-       currentPassword.value = '';
-       resetValidationFn();
+       if (updateUserError.value?.updateUser) {
+         error.authentication = i18n.t('Invalid credentials');
+         requirePassword.value = true;
+       } else {
+         requirePassword.value = false;
+         currentPassword.value = '';
+         resetValidationFn();
+       }
    };
    const onError = () => {
      form.value = resetForm();
      requirePassword.value = false;
      currentPassword.value = '';
    };
    if (currentPassword.value) {
      form.value.password = currentPassword.value;
-       validate({
+       form.value.validation = {
        email: userData.getEmailAddress(user.value),
        password: currentPassword.value
-       }).then(() => {
-         if (isValid.value) {
-           emit('submit', { form, onComplete, onError });
-         } else {
-           error.authentication = 'Invalid password';
-         }
-       });
+       };
+
+       emit('submit', { form, onComplete, onError });
    }
  };

  return {
    requirePassword,
    currentPassword,
    form,
    error,
    submitForm,
    loading
  };
}

MyNewsletter page

Every change related to the current user requires password confirmation now. You should add a confirmation modal to the MyNewsletter page and handle validation information in the submitNewsletterPreferences method.

# pages/MyAccount/MyNewsletter.vue

- <form class="form" v-on:submit.prevent="submitNewsletterPreferences">
+ <form class="form" v-on:submit.prevent="triggerModal">
+   <SfModal
+     :visible="requirePassword"
+     :title="$t('Attention!')"
+     cross
+     persistent
+     @close="requirePassword = false"
+   >
+     {{
+       $t(
+         'Please type your current password to change your personal details.'
+       )
+     }}
+     <SfInput
+       v-model="currentPassword"
+       type="password"
+       name="currentPassword"
+       label="Current Password"
+       required
+       class="form__element"
+       style="margin-top: 10px"
+       @keypress.enter="submitNewsletterPreferences"
+     />
+     <div v-if="error.authentication">
+       {{ error.authentication }}
+     </div>
+     <SfButton
+       class="form__button form__button-inline form__button-width-auto"
+       :disabled="loading"
+       @click="submitNewsletterPreferences"
+     >
+       <SfLoader :class="{ loader: loading }" :loading="loading">
+         <div>
+           {{ $t('Update personal data') }}
+         </div>
+       </SfLoader>
+     </SfButton>
+   </SfModal>
  <p class="form__title">
    {{ $t('I would like to receive marketing newsletters') }}
  </p>
  <div class="form__checkbox-group">
    <SfCheckbox
      v-model="newsletter"
      label="Yes"
      value="true"
      class="form__element"
    />
  </div>
  <SfButton class="form__button" :disabled="loading">
    <SfLoader :class="{ loader: loading }" :loading="loading">
      <div>{{ $t('Save changes') }}</div>
    </SfLoader>
  </SfButton>
</form>

...

<script>
import {
  defineComponent,
  onMounted,
  ref,
+   reactive,
+   useContext
} from '@nuxtjs/composition-api';
import {
  SfTabs,
  SfCheckbox,
  SfButton,
+   SfModal,
  SfLoader,
  SfLink,
+   SfInput
} from '@storefront-ui/vue';
import { useUser } from '@vsf-enterprise/bigcommerce';
- import { useUserData } from '../../composables/useUserData';
export default defineComponent({
  name: 'MyNewsletter',
  components: {
    SfTabs,
+     SfInput,
    SfCheckbox,
    SfButton,
    SfLoader,
+     SfLink,
+     SfModal
  },
  setup() {
-     const { updateUser, user, loading } = useUser();
-     const userData = useUserData();
+     const { updateUser, user, loading, error: updateUserError } = useUser();
+     const { i18n } = useContext();
    const newsletter = ref(false);
+     const requirePassword = ref(false);
+     const currentPassword = ref('');
+     const email = ref('');
+     const error = reactive({
+       authentication: null
+     });
+     const triggerModal = () => {
+       requirePassword.value = true;
+     };

    const submitNewsletterPreferences = async () => {
      error.authentication = '';
      try {
        await updateUser({
          user: {
            acceptsMarketingEmails: newsletter.value,
+             validation: {
+               email: email.value,
+               password: currentPassword.value
+             }
          }
        });

+         if (updateUserError.value.updateUser) {
+           error.authentication = i18n.t('Invalid credentials');
+         } else {
+           requirePassword.value = false;
+           currentPassword.value = '';
+         }
      } catch (error) {
        throw new Error(error);
      }
    };

    onMounted(async () => {
      if (user.value) {
        newsletter.value =
          user.value.accepts_product_review_abandoned_cart_emails;
+         email.value = user.value.email;
      }
    });

    return {
+       requirePassword,
+       currentPassword,
      newsletter,
      user,
-       userData,
+       error,
      loading,
+       triggerModal,
      submitNewsletterPreferences
    };
  }
});
</script>

MyProfile page

Due to changes in the ProfileUpdateForm and PasswordResetForm components, you should handle updateUser errors in the formHandler method and validation property in the updatePassword method.

# pages/MyAccount/MyProfile.vue

setup() {
-   const { updateUser, changePassword, user } = useUser();
+   const { updateUser, changePassword, user, error } = useUser();
  const userData = useUserData();
  const showEditProfileForm = ref(false);
  const closeEditProfileForm = () => {
    showEditProfileForm.value = false;
  };

  const formHandler = async (fn, onComplete, onError) => {
    try {
      const data = await fn();
      await onComplete(data);
-       closeEditProfileForm();
+       if (!error?.value?.updateUser) {
+         closeEditProfileForm();
+       }
    } catch (error) {
      onError(error);
    }
  };

  const updatePersonalData = ({ form, onComplete, onError }) =>
    formHandler(() => updateUser({ user: form.value }), onComplete, onError);

  const updatePassword = ({ form, onComplete, onError }) =>
    formHandler(
      () =>
        changePassword({
+           user,
          current: form.value.currentPassword,
          new: form.value.newPassword,
+           validation: form.value.validation
        }),
      onComplete,
      onError
    );

  return {
    userData,
    updatePersonalData,
    updatePassword,
    user,
    showEditProfileForm,
    closeEditProfileForm
  };
}

useCategory and useCategoryTree composables

The useCategory has been split into useCategory and useCategoryTree composables. You can use the useCategory to get category information and useCategoryTree to get a category tree.

AppHeader component

In the AppHeader.vue component, you should use useCategoryTree to build a category tree.

# components/AppHeader.vue

import {
  useCart,
-   useCategory
+   useCategoryTree,
  useUser,
  useWishlist
} from '@vsf-enterprise/bigcommerce';

...

- const { categories: categoryResults, search: categorySearch } = 
-   useCategory('category-tree');
+ const { categoryTreeItems, load: categorySearch } = 
+   useCategoryTree('category-tree');
const navigation = computed(() =>
-   buildCategoryNavigation(categoryResults.value)
+   buildCategoryNavigation(categoryTreeItems.value)
);

...

const categories = buildSearchCategories(
  products.value.data,
-   categoryResults.value
+   categoryTreeItems.value
);

Category page

On the Category page, you should use the useCategoryTree composable to create breadcrumbs and useCategory to get category meta information.

# pages/Category.vue

import {
  useCart,
  useWishlist,
  useProduct,
  useCategory,
+   useCategoryTree,
  getPurchasableDefaultVariant
} from '@vsf-enterprise/bigcommerce';

...

- const { categories, search: categorySearch } = useCategory('category-tree');
- const { categories: categoryData, search: searchCategoriesData } =
-   useCategory('category-data-search');
+ const { categoryTreeItems, load: loadCategoryTree } =
+   useCategoryTree('category-tree');
+ const { category: categoryData, search: searchCategoriesData } =
+   useCategory('category-data-search');

...

const categoryTree = computed(() => {
-   let categoriesData = categories.value;
-   const category = getCategoryBySlug(categorySlug, categories.value);
+   let categoriesData = categoryTreeItems.value;
+   const category = getCategoryBySlug(categorySlug, categoryTreeItems.value);
  const breadcrumbs = getBreadcrumbs(
    category?.id,
-    categories.value,
+    categoryTreeItems.value,
    localePath,
    i18n
  );
  const rootSlug =
    breadcrumbs && breadcrumbs[1] ? breadcrumbs[1]?.link?.substring(2) : '';
  const rootCategory = getCategoryBySlug(rootSlug, categoriesData);
  if (rootCategory) {
    categoriesData = rootCategory.children;
  }
  return {
    items: categoriesData
  };
});

const breadcrumbs = computed(() => {
-   if (!categories.value || !categories.value?.length) {
+   if (!categoryTreeItems.value || !categoryTreeItems.value?.length) {
    return '';
  }
-   const category = getCategoryBySlug(categorySlug, categories.value);
+   const category = getCategoryBySlug(
+     categorySlug,
+     categoryTreeItems.value
+   );
  const breadcrumbs = getBreadcrumbs(
    category?.id,
    categories.value,
+    categoryTreeItems.value,
    localePath,
    i18n
  );
  return breadcrumbs;
});

onSSR(async () => {
  isProductsLoading.value = true;
-   await categorySearch();
+   await loadCategoryTree();
  const { categorySlug, page, itemsPerPage, sort, direction } =
    th.getFacetsFromURL();
-   const category = getCategoryBySlug(categorySlug, categories.value);
+   const category = getCategoryBySlug(
+     categorySlug,
+     categoryTreeItems.value
+   );
  await searchCategoriesData({ id: category.id });
  
  ...
});

const metaTags = computed(() => ({
-   title: categoryData.value?.[0]?.page_title || categoryData.value?.[0]?.name,
+   title: categoryData.value?.page_title || categoryData.value?.name,
  meta: [
    {
      hid: 'description',
      name: 'description',
-       content: categoryData.value?.[0]?.meta_description || ''
+       content: categoryData.value?.meta_description || ''
    },
    {
      hid: 'keywords',
      name: 'keywords',
-       content: categoryData.value?.[0]?.meta_keywords || ''
+       content: categoryData.value?.meta_keywords || ''
    }
  ]
}));

Product page

On the Product page, you should use the useCategoryTree composable to create breadcrumbs.

# pages/Product.vue

- import { useCategory } from '@vsf-enterprise/bigcommerce';
+ import { useCategoryTree } from '@vsf-enterprise/bigcommerce';

...

- const { categories, search: categorySearch } = useCategory('category-tree');
+ const { categoryTreeItems, load: categorySearch } =
+   useCategoryTree('category-tree');

...

const breadcrumbs = computed(() => {
  if (
-     !categories.value ||
-     !categories.value?.length ||
+     !categoryTreeItems.value ||
+     !categoryTreeItems.value?.length ||
    !product.value ||
    !product.value.categories
  ) {
    return [];
  }
  const categoryId = product.value.categories[0];
  const breadcrumbs = getBreadcrumbs(
    categoryId,
-     categories.value,
+     categoryTreeItems.value,
    localePath,
    i18n
  );
  breadcrumbs.push({ text: product.value.name, link: '#' });
  return breadcrumbs;
});

Reviews pagination

Reviews on the Product page are now paginated. To add pagination to your project, you should create the InternalPagination component, usePaginationData composable, and modify the Product page to use them.

InternalPagination component

Create the components/InternalPagination.vue component with the following content:

<template>
  <nav class="sf-pagination">
    <slot name="prev" v-bind="{ isDisabled: !canGoPrev, go, prev: getPrev }">
      <div
        :class="{ 'display-none': !hasArrows }"
        class="sf-pagination__item prev"
      >
        <SfButton
          :class="{
            'sf-arrow--transparent': !canGoPrev
          }"
          class="sf-button--pure"
          :disabled="!canGoPrev ? true : false"
          aria-label="Go to previous page"
          data-testid="pagination-button-prev"
          @click="!canGoPrev ? null : go(getPrev)"
        >
          <SfIcon icon="arrow_left" size="1.125rem" />
        </SfButton>
      </div>
    </slot>
    <template>
      <slot name="number" v-bind="{ page: 1 }">
        <SfButton
          class="sf-pagination__item"
          :class="{
            'sf-button--pure': !canGoPrev,
            'display-none': !showFirst
          }"
          @click="canGoPrev ? null : go(1)"
        >
          1
        </SfButton>
      </slot>
      <slot name="points">
        <div
          :class="{ 'display-none': firstVisiblePageNumber <= 2 }"
          class="sf-pagination__item"
        >
          ...
        </div>
      </slot>
    </template>
    <template v-for="page in limitedPageNumbers">
      <slot name="number" v-bind="{ page, currentPage }">
        <component
          :is="currentPage === page ? 'span' : 'SfButton'"
          :key="page"
          class="sf-pagination__item"
          :class="{
            'sf-button--pure': currentPage !== page,
            current: currentPage === page
          }"
          @click="currentPage !== page ? go(page) : null"
        >
          {{ page }}
        </component>
      </slot>
    </template>
    <template v-if="showLast">
      <slot name="points">
        <div
          :class="{
            'display-none': lastVisiblePageNumber >= total - 1
          }"
          class="sf-pagination__item"
        >
          ...
        </div>
      </slot>
      <slot name="number" v-bind="{ page: total }">
        <SfButton
          class="sf-pagination__item sf-button--pure"
          @click="go(total)"
        >
          {{ total }}
        </SfButton>
      </slot>
    </template>
    <slot name="next" v-bind="{ isDisabled: !canGoNext, go, next: getNext }">
      <div
        :class="{ 'display-none': !hasArrows }"
        class="sf-pagination__item next"
      >
        <SfButton
          class="sf-button--pure"
          :class="{
            'sf-arrow--transparent': !canGoNext
          }"
          :disabled="!canGoNext ? true : false"
          aria-label="Go to previous next"
          data-testid="pagination-button-next"
          @click="!canGoNext ? null : go(getNext)"
        >
          <SfIcon icon="arrow_right" size="1.125rem" />
        </SfButton>
      </div>
    </slot>
  </nav>
</template>
<script>
  import { SfLink, SfIcon, SfButton } from '@storefront-ui/vue';
  export default {
    name: 'InternalPagination',
    components: {
      SfIcon,
      SfLink,
      SfButton
    },
    props: {
      total: {
        type: Number,
        default: 0
      },
      navigate: {
        type: Function,
        required: true
      },
      visible: {
        type: Number,
        default: 5
      },
      hasArrows: {
        type: Boolean,
        default: true
      },
      current: {
        type: Number,
        default: 1
      },
      pageParamName: {
        type: String,
        default: 'page'
      }
    },
    computed: {
      currentPage() {
        return this.current || 1;
      },
      getPrev() {
        return this.currentPage === this.firstVisiblePageNumber
          ? this.currentPage
          : this.currentPage - 1;
      },
      canGoPrev() {
        return this.currentPage !== this.firstVisiblePageNumber;
      },
      getNext() {
        return this.currentPage === this.lastVisiblePageNumber
          ? this.currentPage
          : this.currentPage + 1;
      },
      canGoNext() {
        return this.currentPage !== this.lastVisiblePageNumber;
      },
      showFirst() {
        return this.firstVisiblePageNumber > 1;
      },
      showLast() {
        return this.lastVisiblePageNumber < this.total;
      },
      listOfPageNumbers() {
        return Array.from(Array(this.total), (_, i) => i + 1);
      },
      limitedPageNumbers() {
        if (this.total <= this.visible) {
          return this.listOfPageNumbers;
        }

        if (
          this.currentPage <
          this.visible - Math.floor(this.visible / 2) + 1
        ) {
          return this.listOfPageNumbers.slice(0, this.visible);
        }

        if (
          this.total - this.currentPage <
          this.visible - Math.ceil(this.visible / 2) + 1
        ) {
          return this.listOfPageNumbers.slice(this.total - this.visible);
        }

        return this.listOfPageNumbers.slice(
          this.currentPage - Math.ceil(this.visible / 2),
          this.currentPage + Math.floor(this.visible / 2)
        );
      },
      firstVisiblePageNumber() {
        return this.limitedPageNumbers[0];
      },
      lastVisiblePageNumber() {
        return this.limitedPageNumbers[this.limitedPageNumbers.length - 1];
      }
    },
    methods: {
      go(page) {
        this.navigate(page);
      },
      getLinkTo(page) {
        return page.toString();
      }
    }
  };
</script>

usePaginationData composable

Create the composables/usePaginationData/index.ts file with the usePaginationData composable:

import { AgnosticPagination } from '@vue-storefront/core';
import { useContext } from '@nuxtjs/composition-api';

export const usePaginationData = () => {
  const { $config } = useContext();

  const getPagination = (meta): AgnosticPagination => {
    if (!meta?.pagination) {
      return {
        currentPage: 1,
        totalPages: 1,
        totalItems: 1,
        itemsPerPage: $config.theme?.itemsPerPage?.[0],
        pageOptions: $config.theme?.itemsPerPage
      };
    }

    return {
      currentPage: meta?.pagination?.current_page,
      totalPages: meta?.pagination?.total_pages,
      totalItems: meta?.pagination?.total,
      itemsPerPage: meta?.pagination?.per_page,
      pageOptions: $config.theme?.itemsPerPage
    };
  };

  return {
    getPagination
  };
};

export default usePaginationData;

useProductData composable

You should remove getPagination from useProductData, as instead use the new usePaginationData composable that covers their functionalities:

# composables/useProductData/index.ts

- import { AgnosticPagination } from '@vue-storefront/core';
- import { useContext } from '@nuxtjs/composition-api';

...

- const { $config } = useContext();

...

- const getPagination = (meta): AgnosticPagination => {
-   if (!meta?.pagination) {
-     return {
-       currentPage: 1,
-       totalPages: 1,
-       totalItems: 1,
-       itemsPerPage: $config.theme?.itemsPerPage?.[0],
-       pageOptions: $config.theme?.itemsPerPage
-     };
-   }
- 
-   return {
-     currentPage: meta?.pagination?.current_page,
-     totalPages: meta?.pagination?.total_pages,
-     totalItems: meta?.pagination?.total,
-     itemsPerPage: meta?.pagination?.per_page,
-     pageOptions: $config.theme?.itemsPerPage
-   };
- };

Category page

On the Category page, you should use the usePaginationData composable for paginating categories:

# pages/Category.vue

+ import { usePaginationData } from '../composables/usePaginationData';

...

const productData = useProductData();
+ const paginationData = usePaginationData();

...

const pagination = computed(() =>
-   productData.getPagination(productsResult.value?.meta)
+   paginationData.getPagination(productsResult.value?.meta)
);

...

return {
  isCategoryGridView,
  th,
  products,
  categoryTree,
  categoryData,
  isProductsLoading,
  pagination,
  activeCategory,
  breadcrumbs,
  wishlist,
  addItemToWishlist,
  removeItemFromWishlist,
  isInWishlist,
  wishlistHelpers,
  addItemToCart,
  isInCart,
  productsQuantity,
  productData,
+   paginationData,
  getPurchasableDefaultVariant,
  isMobile
};

Product page

On the Product page, you should use the InternalPagination component and usePaginationData composable for paginating product reviews:

# pages/Product.vue

<AddReview :product-id="Number(productData.getId(product))" />
+ <InternalPagination
+   v-show="reviewsPagination.totalPages > 1"
+   :current="reviewsPagination.currentPage"
+   :total="reviewsPagination.totalPages"
+   :navigate="reviewsPageNavigation"
+   :visible="5"
+ />

...

+ import InternalPagination from '~/components/InternalPagination.vue';
+ import { usePaginationData } from '../composables/usePaginationData';

...

components: {
    SfAlert,
    SfColor,
    SfHeading,
    SfPrice,
    SfRating,
    SfSelect,
    SfAddToCart,
    SfTabs,
    SfGallery,
    SfIcon,
    SfReview,
    SfBreadcrumbs,
    SfButton,
    SfLoader,
    RelatedProducts,
+     InternalPagination,
    LazyHydrate,
    AddReview
  },

...

- const { reviews: productReviews, search: searchReviews } =
-   useReview('productReviews');
+ const paginationData = usePaginationData();
+ const {
+   reviews: productReviews,
+   search: searchReviews,
+   loading: reviewsLoading
+ } = useReview('productReviews');
+
+ const reviewsPagination = computed(() =>
+   paginationData.getPagination(productReviews.value?.meta)
+ );
+
+ const reviewsPageNavigation = (pageNum) => {
+   searchReviews({
+     productId: Number(id?.value),
+     query: { status: 1, limit: 5, page: pageNum }
+   });
+ };

...

searchReviews({
  productId: Number(id.value),
-   query: { status: 1 }
+   query: { status: 1, limit: 5 }
});

...

return {
  activeVariant,
  updateFilter,
  configuration,
  openTab,
  productLoading,
  product,
  breadcrumbs,
+   reviewsPagination,
+   paginationData,
+   reviewsLoading,
+   reviewsPageNavigation,
  reviews: computed(() => productReviews.value?.data),
  averageRating: computed(() =>
    productData.getAverageRating(product.value)
  ),
  totalReviews: computed(() => productData.getTotalReviews(product.value)),
  relatedProducts: computed(() => relatedProducts.value?.data ?? []),
  relatedLoading,
  options,
  qty,
  addItem,
  loading,
  productData,
  productGallery,
  stock,
  reviewHelpers,
  showReviews,
  tabsRef
};

v1.0.3 release notes

Introduction

In the 1.0.3 released we:

  • migrated to Node 16, which from this release is the minimum version required for this integration,
  • upgraded to 2.5.6 version of the @vue-storefront/core package,
  • improved performance by optimizing images in templates,
  • removed the id parameter from the useCategoryTree composable.

To upgrade your projects, follow the instructions below.

Upgrade to Node 16

The integration starting from this release requires Node 16. There are no changes that need to be applied to the projects unless you use 3rd party dependencies not present in Alokai by default that don't support it.

To ensure that you don't run an older version of Node by mistake, add the engines configuration to the package.json file:

# package.json

+ "engines": {
+   "node": ">=16.x"
+ }

Remove parameter from the useCategoryTree composable

The BigCommerce API responds with the same category tree every time you call the load method from the useCategoryTree composable. For this reason, there is no need to pass the ID to the useCategoryTree composable because it can share the data between multiple instances. For this reason, we decided to remove the ID parameter from this composable, so you should do it too in your project.

# components/AppHeader.vue

- const { categoryTreeItems, load: categorySearch } = useCategoryTree('category-tree');
+ const { categoryTreeItems, load: categorySearch } = useCategoryTree();
# pages/Category.vue

- const { categoryTreeItems, load: loadCategoryTree } = useCategoryTree('category-tree');
+ const { categoryTreeItems, load: loadCategoryTree } = useCategoryTree();
# pages/Product.vue

- const { categoryTreeItems, load: categorySearch } = useCategoryTree('category-tree');
+ const { categoryTreeItems, load: categorySearch } = useCategoryTree();

v1.1.0 release notes

Introduction

Starting with version 1.1.0, integration becomes only available to our enterprise customers. Because at Alokai, we have a private registry for enterprise packages, the organization name for this integration changes from @vue-storefront to @vsf-enterprise.

Check out the Enterprise page to learn more about the Alokai Enterprise offering and how to connect to it.

Due to this change, the last open-source version (1.0.3) becomes deprecated.

Migration guide

To upgrade to version 1.1.0, update the following imports:

  • from @vue-storefront/bigcommerce to @vsf-enterprise/bigcommerce
- import { ... } from '@vue-storefront/bigcommerce';
+ import { ... } from '@vsf-enterprise/bigcommerce';
  • from @vue-storefront/bigcommerce-api to @vsf-enterprise/bigcommerce-api
- import { ... } from '@vue-storefront/bigcommerce-api';
+ import { ... } from '@vsf-enterprise/bigcommerce-api';

v1.2.0 release notes

Introduction

In this release, we:

  • upgraded @vue-storefront/core package to version 2.5.12, which added an optional middlewareUrl property to define the URL of the Server Middleware. See its migration guide for more information. Cache control is no longer included into @vue-storefront/core package. To use caching see tips and tricks.
  • upgraded @storefront-ui/vue package to version 0.13.0, which removed mobileObserver and SfSlidingSection and requires adding type="submit" to SfButtons in forms.
  • fixed an issue with SfSearchBar and @keydown.enter

Migration guide

Migrating to @vue-storefront/core with v2.5.12

# nuxt.config.js

publicRuntimeConfig: {
  theme,
+ middlewareUrl:
+   process.env.NODE_ENV === 'production'
+     ? process.env.API_BASE_URL
+     : 'http://localhost:3000/api/'
},
# .env
+ API_BASE_URL=<URL to the Server Middleware>

Migrating to @storefront-ui/vue with 0.13.0

Exclude core-js package

Exclude the core-js package from Babel transpilation, as shown below:

# nuxt.config.js

babel: {
  plugins: [
    [
      '@babel/plugin-proposal-private-methods',
      { loose: true }
    ]
  ],
+ exclude: [/\bcore-js\b/, /\bwebpack\/buildin\b/]
},
# pages/Home.vue
# pages/Category.vue
# pages/Product.vue

- middleware: cacheControl({
-   'max-age': 60,
-   'stale-when-revalidate': 5
- }),

Update SfButton

The SfButton component used in forms now requires the type="submit" attribute. The following components use it in the default theme:

  • AddReview component
  • ReserPasswordForm component
  • ProfileUpdateForm component
  • MyNewsletter page
<form>
- <SfButton>
+ <SfButton type="submit">
    Submit
  </SfButton>
</form>

Remove mobileObserver

# components/AppHeader

<template #navigation>
- <HeaderNavigation :categories="navigation" :isMobile="isMobile" />
+ <HeaderNavigation :categories="navigation" />
</template>

...

- import {
-   mapMobileObserver,
-   unMapMobileObserver
- } from '@storefront-ui/vue/src/utilities/mobile-observer.js';

...

- const isMobile = ref(mapMobileObserver().isMobile.get());

...

- const closeOrFocusSearchBar = () => {
-   if (isMobile.value) {
-     return closeSearch();
-   } else {
-     term.value = '';
-     return searchBarRef.value.$el.children[0].focus();
-   }
+ const clearAndFocusSearchBar = () => {
+   term.value = '';
+   return searchBarRef.value.$el.children[0]?.children[0]?.focus();

...

const onSearchBarIconClick = computed(() =>
  term.value
-   ? closeOrFocusSearchBar
+   ? clearAndFocusSearchBar
    : () => {
      isSearchOpen.value
        ? (isSearchOpen.value = false)

...

- watch(
-   () => term.value,
-   (newVal, oldVal) => {
-     const shouldSearchBeOpened =
-       !isMobile.value &&
-       term.value.length > 0 &&
-       ((!oldVal && newVal) ||
-         (newVal.length !== oldVal.length && isSearchOpen.value === false));
-     if (shouldSearchBeOpened) {
-       isSearchOpen.value = true;
-     }
-   }
- );
- onBeforeUnmount(() => {
-   unMapMobileObserver();
- });

...

return {
  accountIcon,
  cartTotalItems,
  handleAccountClick,
  toggleCartSidebar,
  toggleWishlistSidebar,
  term,
  isSearchOpen,
  isCheckoutPage,
  closeSearch,
  handleSearch,
  loading,
  result,
- closeOrFocusSearchBar,
+ clearAndFocusSearchBar,
  searchBarRef,
- isMobile,
  isMobileMenuOpen,
  products,
  navigation,
  wishlistTotalItems,
  searchBarIcon,
  onSearchBarIconClick
};
# components/HeaderNavigation.vue

<template>
- <div class="sf-header__navigation desktop" v-if="!isMobile">
-   <SfHeaderNavigationItem
-     v-for="(category, key) in categories"
-     :key="key"
-     class="nav-item"
-     v-e2e="`app-header-url_${category.slug}`"
-     :label="category.label"
-     :link="localePath(`/c${category.slug}`)"
-   />
- </div>
- <SfModal v-else :visible="isMobileMenuOpen">
+ <div>
+   <div class="sf-header__navigation desktop">
+     <SfHeaderNavigationItem
+       v-for="(category, key) in categories"
+       :key="key"
+       class="nav-item"
+       v-e2e="`app-header-url_${category.slug}`"
+       :label="category.label"
+       :link="localePath(`/c${category.slug}`)"
+     />
+   </div>
+   <SfModal :visible="isMobileMenuOpen" class="smartphone-only">
      <SfHeaderNavigationItem
        v-for="(category, key) in categories"
        :key="key"
        class="nav-item"
        v-e2e="`app-header-url_${category.slug}`"
      >
        <template #desktop-navigation-item>
          <SfMenuItem
            :label="category.label"
            class="sf-header-navigation-item__menu-item"
            @click="navigate(`/c${category.slug}`)"
          />
        </template>
        <template #mobile-navigation-item>
          <SfMenuItem
            :label="category.label"
            class="sf-header-navigation-item__menu-item"
            @click="navigate(`/c${category.slug}`)"
          />
        </template>
      </SfHeaderNavigationItem>
    </SfModal>
+ </div>
</template>

...

props: {
- isMobile: {
-   type: Boolean,
-   default: false
- },
  categories: {
    type: Array as PropType<Array<SearchResultNavigationItem>>,
    default: () => []
  }
},
# components/RelatedProducts.vue

- :imageWidth="isMobile ? 154 : 216"
- :imageHeight="isMobile ? 154 : 216"
+ :imageWidth="216"
+ :imageHeight="216"

...

- import {
-   mapMobileObserver,
-   unMapMobileObserver
- } from '@storefront-ui/vue/src/utilities/mobile-observer.js';

...

import {
  defineComponent,
- onBeforeUnmount,
- PropType,
- ref
+ PropType
} from '@nuxtjs/composition-api';

...

- const isMobile = ref(mapMobileObserver().isMobile.get());

...

- onBeforeUnmount(() => {
-   unMapMobileObserver();
- });

return {
  productData,
  wishlistHelpers,
  wishlist,
  isInWishlist,
  addItemToWishlist,
  removeItemFromWishlist,
  isInCart,
  addItemToCart,
- getPurchasableDefaultVariant,
- isMobile
+ getPurchasableDefaultVariant
};

...

&__item {
    margin: 1.9375rem 0 2.4375rem 0;
  }
+
+ ::v-deep .sf-product-card__image .sf-image {
+   --image-width:  9.625rem;
+   --image-height:  9.625rem;
+     @include for-desktop {
+       --image-width:  13.5rem;
+       --image-height:  13.5rem;
+     }
+ }
}
</style>
# layouts/error.vue

- :width="isMobile ? 230 : 412"
- :height="isMobile ? 230 : 412"
+ :width="412"
+ :height="412"

...

- import { computed, useRouter } from '@nuxtjs/composition-api';
+ import { useRouter } from '@nuxtjs/composition-api';

-import { mapMobileObserver } from '@storefront-ui/vue/src/utilities/mobile-observer.js';

...

- const isMobile = computed(() => mapMobileObserver().isMobile.get());

return {
  router,
- isMobile,
  addBasePath
};

...

- .image {
+
+ ::v-deep .sf-image {
  --image-width: 14.375rem;
+ --image-height: 14.375rem;
  padding: var(--spacer-xl) 0;
  @include for-desktop {
    --image-width: 25.75rem;
+   --image-height: 25.75rem;
  }
}
# pages/Category.vue

- :imageWidth="isMobile ? 150 : 216"
- :imageHeight="isMobile ? 150 : 216"
+ :imageWidth="216"
+ :imageHeight="216"

...

- :imageWidth="isMobile ? 85 : 140"
- :imageHeight="isMobile ? 113 : 200"
+ :imageWidth="140"
+ :imageHeight="200"

...

import {
  computed,
  defineComponent,
- onBeforeUnmount,
  ref,
  useContext,
  useMeta
} from '@nuxtjs/composition-api';
- import {
-   mapMobileObserver,
-   unMapMobileObserver
- } from '@storefront-ui/vue/src/utilities/mobile-observer.js';

...

- import cacheControl from './../helpers/cacheControl';

...

- const isMobile = ref(mapMobileObserver().isMobile.get());

...

- onBeforeUnmount(() => {
-   unMapMobileObserver();
- });

...

&__product-card {
  --product-card-title-margin: var(--spacer-base) 0 0 0;
  --product-card-title-font-weight: var(--font-weight--medium);
  --product-card-title-margin: var(--spacer-xs) 0 0 0;
  flex: 1 1 50%;
  @include for-desktop {
    --product-card-title-font-weight: var(--font-weight--normal);
    --product-card-add-button-bottom: var(--spacer-base);
    --product-card-title-margin: var(--spacer-sm) 0 0 0;
  }
+ ::v-deep .sf-image {
+   --image-width: 9.375rem;
+   --image-height: 9.375rem;
+   @include for-desktop {
+     --image-width: 13.5rem;
+     --image-height: 13.5rem;
+   }
+ }
}
&__product-card-horizontal {
  flex: 0 0 100%;
- @include for-mobile {
-   ::v-deep .sf-image {
-     --image-width: 5.3125rem;
-     --image-height: 7.0625rem;
+ ::v-deep .sf-image {
+   --image-width: 5.3125rem;
+   --image-height: 7.0625rem;
+   @include for-desktop {
+     --image-width: 8.75rem;
+     --image-height: 12.5rem;
    }
  }
}

Fixing SfSearchBar component

# components/AppHeader.vue
<SfSearchBar
  ref="searchBarRef"
  :placeholder="$t('Search for items')"
  aria-label="Search"
  class="sf-header__search"
  :class="{ 'search-hidden': isCheckoutPage }"
  :value="term"
  @input="handleSearch"
-   @keydown.enter="handleSearch($event)"
+   @keyup.enter="handleSearch($event)"
  @focus="isSearchOpen = true"
  @keydown.esc="closeSearch"
  :icon="searchBarIcon"
  @click:icon="onSearchBarIconClick"
/>

v1.2.1 release notes

Introduction

In this release, we fixed issues:

  • missing mobile images for SfHero component,
  • missing desktop images for SfCallToAction component.

Bug fix

# pages/Checkout.vue

+ /*
+  * Fix to the bug: https://github.com/vuestorefront/storefront-ui/issues/2366.
+  * Bug is realted to @storefront-ui/core v0.13.0.
+  * Images are passed by props to SfCalToAction component where they are assigned to scss vars, but in the wrong way.
+  * This fix sets up mobile image for mobile devices and desktop image for desktop devices.
+  */
+ .sf-call-to-action {
+   background-image: var(--_call-to-action-background-image);
+   @include for-desktop {
+     background-image: var(--_call-to-action-background-desktop-image, --_call-to-action-background-image);
+   }
+ }
# pages/Home.vue

.sf-hero-item {
+ /*
+   * Fix to the bug: https://github.com/vuestorefront/storefront-ui/issues/2365.
+   * Bug is realted to @storefront-ui/core v0.13.0.
+   * Images are passed by props to SfHero component where they are assigned to scss vars, but in the wrong way.
+   * This fix sets up mobile image for mobile devices and desktop image for desktop devices.
+   */
+ background-image: var(
+   --hero-item-background-image-mobile,
+   --hero-item-background-image
+ );
+ @include for-desktop {
+   background-image: var(--hero-item-background-image);
+ }

...

+ /*
+  * Fix to the bug: https://github.com/vuestorefront/storefront-ui/issues/2366.
+  * Bug is realted to @storefront-ui/core v0.13.0.
+  * Images are passed by props to SfCalToAction component where they are assigned to scss vars, but in the wrong way.
+  * This fix sets up mobile image for mobile devices and desktop image for desktop devices.
+  */
+ .sf-call-to-action {
+   background-image: var(--_call-to-action-background-image);
+   @include for-desktop {
+     background-image: var(--_call-to-action-background-desktop-image, --_call-to-action-background-image);
+   }
+ }

v1.2.2 release notes

In this release, we fixed a bug with categories.map is not a function error, when there is no connection with the API.

v1.3.0 release notes

Introduction

In this release, we:

  • updated a Product model (see Product reference),
  • added scripts for load testing with k6 to the repository,
  • fixed the bug with the missing position attribute of the SfSidebar component,
  • removed the @nuxtjs/google-fonts module and instead added fonts directly to the project,
  • updated caching headers for demo.

Migration guide

Fixing sidebars

We added the position="right" to <SfSidebar> components used for cart and wishlist sidebars to fix their opening animations.

# components/CartSidebar.vue

  <SfSidebar
    v-e2e="'sidebar-cart'"
    :visible="isCartSidebarOpen"
    title="My Cart"
    class="sf-sidebar--right"
    @close="toggleCartSidebar"
+   position="right"
  >

# ...

  <div class="empty-cart__banner">
    <SfImage
      alt="Empty bag"
      class="empty-cart__image"
-     src="/icons/empty-cart.svg"
+     src="/icons/empty-cart.webp"
      :width="256"
      :height="173"
    />
# components/WishlistSidebar.vue

  <SfSidebar
    :visible="isWishlistSidebarOpen"
    :button="false"
    title="My Wishlist"
    @close="toggleWishlistSidebar"
    class="sidebar sf-sidebar--right"
+   position="right"
  >

# ...

  <div class="empty-wishlist__banner">
    <SfImage
-     src="/icons/empty-cart.svg"
+     src="/icons/empty-cart.webp"
      alt="Empty bag"
      class="empty-wishlist__icon"
      :width="256"
      :height="173"
    />

Using fonts locally

To improve performance and reduce the number of dependencies, we removed the @nuxtjs/google-fonts module and instead added fonts directly to the project. You can download the files from the this folder in the repository.

# nuxt.config.js

  buildModules: [
    '@pinia/nuxt',
    // to core
    '@nuxtjs/composition-api/module',
    '@nuxt/typescript-build',
-   '@nuxtjs/google-fonts',
    '@nuxtjs/pwa',
#   ...    
  ]

- googleFonts: {
-   families: {
-     Raleway: {
-       wght: [300, 400, 500, 600, 700],
-       ital: [400]
-     },
-     Roboto: {
-       wght: [300, 400, 500, 700],
-       ital: [300, 400]
-     }
-   },
-   display: 'swap'
- },
# layouts/default.vue

+ head: {
+   link: [
+     { rel: 'preload', href: '/fonts/fonts.css', as: 'style'},
+     { rel: 'stylesheet', href: '/fonts/fonts.css' }
+   ]
+ }
# static/fonts/fonts.css

+ /* === Raleway - 300 */
+ @font-face {
+   font-family: 'Raleway';
+   font-style: normal;
+   font-weight: 300;
+   font-display: swap;
+   src: url('./Raleway-Light.eot');
+   src: local('Raleway'), url('./Raleway-Light.eot') format('embedded-opentype'),
+     url('./Raleway-Light.woff') format('woff'),
+     url('./Raleway-Light.woff2') format('woff2'),
+     url('./Raleway-Light.ttf') format('truetype'),
+     url('./Raleway-Light.svg') format('svg');
+ }
+ 
+ /* === Raleway - regular */
+ @font-face {
+   font-family: 'Raleway';
+   font-style: normal;
+   font-weight: 400;
+   font-display: swap;
+   src: url('./Raleway-Regular.eot');
+   src: local('Raleway'),
+     url('./Raleway-Regular.eot') format('embedded-opentype'),
+     url('./Raleway-Regular.woff') format('woff'),
+     url('./Raleway-Regular.woff2') format('woff2'),
+     url('./Raleway-Regular.ttf') format('truetype'),
+     url('./Raleway-Regular.svg') format('svg');
+ }
+ 
+ /* === Raleway - 500 */
+ @font-face {
+   font-family: 'Raleway';
+   font-style: normal;
+   font-weight: 500;
+   font-display: swap;
+   src: url('./Raleway-Medium.eot');
+   src: local('Raleway'), url('./Raleway-Medium.eot') format('embedded-opentype'),
+     url('./Raleway-Medium.woff') format('woff'),
+     url('./Raleway-Medium.woff2') format('woff2'),
+     url('./Raleway-Medium.ttf') format('truetype'),
+     url('./Raleway-Medium.svg') format('svg');
+ }
+ 
+ /* === Raleway - 600 */
+ @font-face {
+   font-family: 'Raleway';
+   font-style: normal;
+   font-weight: 600;
+   font-display: swap;
+   src: url('./Raleway-SemiBold.eot');
+   src: local('Raleway'),
+     url('./Raleway-SemiBold.eot') format('embedded-opentype'),
+     url('./Raleway-SemiBold.woff') format('woff'),
+     url('./Raleway-SemiBold.woff2') format('woff2'),
+     url('./Raleway-SemiBold.ttf') format('truetype'),
+     url('./Raleway-SemiBold.svg') format('svg');
+ }
+ 
+ /* === Raleway - 700 */
+ @font-face {
+   font-family: 'Raleway';
+   font-style: normal;
+   font-weight: 700;
+   font-display: swap;
+   src: url('./Raleway-Bold.eot');
+   src: local('Raleway'), url('./Raleway-Bold.eot') format('embedded-opentype'),
+     url('./Raleway-Bold.woff') format('woff'),
+     url('./Raleway-Bold.woff2') format('woff2'),
+     url('./Raleway-Bold.ttf') format('truetype'),
+     url('./Raleway-Bold.svg') format('svg');
+ }
+ 
+ /* === Raleway - italic */
+ @font-face {
+   font-family: 'Raleway';
+   font-style: italic;
+   font-weight: 400;
+   font-display: swap;
+   src: url('./Raleway-Italic.eot');
+   src: local('Raleway'), url('./Raleway-Italic.eot') format('embedded-opentype'),
+     url('./Raleway-Italic.woff') format('woff'),
+     url('./Raleway-Italic.woff2') format('woff2'),
+     url('./Raleway-Italic.ttf') format('truetype'),
+     url('./Raleway-Italic.svg') format('svg');
+ }
+ 
+ /* === Roboto - 300 */
+ @font-face {
+   font-family: 'Roboto';
+   font-style: normal;
+   font-weight: 300;
+   font-display: swap;
+   src: url('./Roboto-Light.eot');
+   src: local('Roboto'), url('./Roboto-Light.eot') format('embedded-opentype'),
+     url('./Roboto-Light.woff') format('woff'),
+     url('./Roboto-Light.woff2') format('woff2'),
+     url('./Roboto-Light.ttf') format('truetype'),
+     url('./Roboto-Light.svg') format('svg');
+ }
+ 
+ /* === Roboto - regular */
+ @font-face {
+   font-family: 'Roboto';
+   font-style: normal;
+   font-weight: 400;
+   font-display: swap;
+   src: url('./Roboto-Regular.eot');
+   src: local('Roboto'), url('./Roboto-Regular.eot') format('embedded-opentype'),
+     url('./Roboto-Regular.woff') format('woff'),
+     url('./Roboto-Regular.woff2') format('woff2'),
+     url('./Roboto-Regular.ttf') format('truetype'),
+     url('./Roboto-Regular.svg') format('svg');
+ }
+ 
+ /* === Roboto - 500 */
+ @font-face {
+   font-family: 'Roboto';
+   font-style: normal;
+   font-weight: 500;
+   font-display: swap;
+   src: url('./Roboto-Medium.eot');
+   src: local('Roboto'), url('./Roboto-Medium.eot') format('embedded-opentype'),
+     url('./Roboto-Medium.woff') format('woff'),
+     url('./Roboto-Medium.woff2') format('woff2'),
+     url('./Roboto-Medium.ttf') format('truetype'),
+     url('./Roboto-Medium.svg') format('svg');
+ }
+ 
+ /* === Roboto - 700 */
+ @font-face {
+   font-family: 'Roboto';
+   font-style: normal;
+   font-weight: 700;
+   font-display: swap;
+   src: url('./Roboto-Bold.eot');
+   src: local('Roboto'), url('./Roboto-Bold.eot') format('embedded-opentype'),
+     url('./Roboto-Bold.woff') format('woff'),
+     url('./Roboto-Bold.woff2') format('woff2'),
+     url('./Roboto-Bold.ttf') format('truetype'),
+     url('./Roboto-Bold.svg') format('svg');
+ }
+ 
+ /* === Roboto - 300italic */
+ @font-face {
+   font-family: 'Roboto';
+   font-style: italic;
+   font-weight: 300;
+   font-display: swap;
+   src: url('./Roboto-LightItalic.eot');
+   src: local('Roboto'),
+     url('./Roboto-LightItalic.eot') format('embedded-opentype'),
+     url('./Roboto-LightItalic.woff') format('woff'),
+     url('./Roboto-LightItalic.woff2') format('woff2'),
+     url('./Roboto-LightItalic.ttf') format('truetype'),
+     url('./Roboto-LightItalic.svg') format('svg');
+ }
+ 
+ /* === Roboto - italic */
+ @font-face {
+   font-family: 'Roboto';
+   font-style: italic;
+   font-weight: 400;
+   font-display: swap;
+   src: url('./Roboto-Italic.eot');
+   src: local('Roboto'), url('./Roboto-Italic.eot') format('embedded-opentype'),
+     url('./Roboto-Italic.woff') format('woff'),
+     url('./Roboto-Italic.woff2') format('woff2'),
+     url('./Roboto-Italic.ttf') format('truetype'),
+     url('./Roboto-Italic.svg') format('svg');
+ }

Load tests

Internally we use the k6 tool to perform load testing to ensure the proper scalability of the integration. If you'd like to test your projects, you can use scripts from the tests/load directory, which include the following test scenarios:

Browsing products

  1. Visit home page
  2. Visit product listingĀ 
  3. Sort by Latest, Low to High
  4. Visit Product Display Page

Search for product

  1. Visit home page
  2. Search by keyword
  3. Visit Product Display Page
  4. Add to cart

New user registration

  1. Visit home page
  2. Go to Create a New Account
  3. Fill form and press submit
  4. Log out

Buy product as guest

  1. Visit home page
  2. Visit random Product Display Page
  3. Add to cart
  4. Proceed to checkout as a guest
  5. Fill in the form
  6. Place order

Buy product as user

  1. Visit home page
  2. Visit random Product Display Page
  3. Add to cart
  4. Log in
  5. Fill in the form
  6. Place order