# v1.0.2 migration guide
# Introduction
In this release, we:
- removed the
useUserCredentials
composable for security reasons. User credentials are validated in theupdateCustomer
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
};