Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/api-client/src/index.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ const onCreate = (settings: Config): { config: Config; client: ClientInstance }
const client = apolloClientFactory({
link: apolloLink,
...settings.customOptions,
defaultOptions: {
query: {
errorPolicy: 'all',
},
},
});

return {
Expand Down
4 changes: 3 additions & 1 deletion packages/composables/src/composables/useCart/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const factoryParams: UseCartFactoryParams<Cart, CartItem, Product> = {
throw errors[0];
}

data.cart.items = data.cart.items.filter(Boolean);
return data.cart as unknown as Cart;
};

Expand Down Expand Up @@ -83,6 +84,7 @@ const factoryParams: UseCartFactoryParams<Cart, CartItem, Product> = {
}

apiState.setCartId(data.customerCart.id);
data.customerCart.items = data.customerCart.items.filter(Boolean);

return data.customerCart as unknown as Cart;
} catch {
Expand Down Expand Up @@ -381,7 +383,7 @@ const factoryParams: UseCartFactoryParams<Cart, CartItem, Product> = {
currentCart,
product,
},
) => !!currentCart?.items.find((cartItem) => cartItem.product.uid === product.uid),
) => !!currentCart?.items.find((cartItem) => cartItem?.product?.uid === product.uid),
};

export default useCartFactory<Cart, CartItem, Product>(factoryParams);
14 changes: 4 additions & 10 deletions packages/composables/src/getters/cartGetters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,7 @@ export const getItemAttributes = (
return attributes;
};

export const getItemSku = (product: CartItem): string => {
if (!product.product) {
return '';
}

return product.product.sku;
};
export const getItemSku = (product: CartItem): string => product?.product?.sku || '';

const calculateDiscounts = (discounts: Discount[]): number => discounts.reduce((a, b) => Number.parseFloat(`${a}`) + Number.parseFloat(`${b.amount.value}`), 0);

Expand Down Expand Up @@ -173,14 +167,13 @@ export const getAvailablePaymentMethods = (cart: Cart): AgnosticPaymentMethod[]
value: p.code,
}));

export const getStockStatus = (product: CartItem): string => product.product.stock_status;
export interface CartGetters extends CartGettersBase<Cart, CartItem> {
getAppliedCoupon(cart: Cart): AgnosticCoupon | null;

getAvailablePaymentMethods(cart: Cart): AgnosticPaymentMethod[];

getSelectedShippingMethod(cart: Cart): SelectedShippingMethod | null;

productHasSpecialPrice(product: CartItem): boolean;
getStockStatus(product: CartItem): string;
}

const cartGetters: CartGetters = {
Expand All @@ -202,6 +195,7 @@ const cartGetters: CartGetters = {
getTotalItems,
getTotals,
productHasSpecialPrice,
getStockStatus,
};

export default cartGetters;
38 changes: 29 additions & 9 deletions packages/theme/components/CartSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,6 @@
$n(cartGetters.getItemPrice(product).special, 'currency')
: ''
"
:stock="99999"
:qty="cartGetters.getItemQty(product)"
:link="
localePath(
`/p/${cartGetters.getItemSku(product)}${cartGetters.getSlug(
Expand All @@ -119,14 +117,25 @@
@click:remove="sendToRemove({ product })"
>
<template #input>
<div class="sf-collected-product__quantity-wrapper">
<div
v-if="isInStock(product)"
class="sf-collected-product__quantity-wrapper"
>
<SfQuantitySelector
:disabled="loading"
:qty="cartGetters.getItemQty(product)"
class="sf-collected-product__quantity-selector"
@input="updateItemQty({ product, quantity: $event })"
/>
</div>
<SfBadge
v-else
class="color-danger sf-badge__absolute"
>
<template #default>
<span>{{ $t('Out of stock') }}</span>
</template>
</SfBadge>
</template>
<template #configuration>
<div v-if="getAttributes(product).length > 0">
Expand Down Expand Up @@ -221,6 +230,7 @@
</div>
</template>
<script>
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import {
SfLoader,
SfNotification,
Expand All @@ -232,23 +242,24 @@ import {
SfCollectedProduct,
SfImage,
SfQuantitySelector,
SfBadge,
} from '@storefront-ui/vue';
import {
computed,
defineComponent,
ref,
useRouter,
useContext,
useContext, onMounted,
} from '@nuxtjs/composition-api';
import {
useCart,
useUser,
cartGetters,
useExternalCheckout,
} from '@vue-storefront/magento';
import { onSSR } from '@vue-storefront/core';
import { useUiState, useUiNotification } from '~/composables';
import CouponCode from './CouponCode.vue';
import stockStatusEnum from '~/enums/stockStatusEnum';

export default defineComponent({
name: 'CartSidebar',
Expand All @@ -263,6 +274,7 @@ export default defineComponent({
SfCollectedProduct,
SfImage,
SfQuantitySelector,
SfBadge,
CouponCode,
},
setup() {
Expand All @@ -280,7 +292,7 @@ export default defineComponent({
const { isAuthenticated } = useUser();
const { send: sendNotification, notifications } = useUiNotification();

const products = computed(() => cartGetters.getItems(cart.value));
const products = computed(() => cartGetters.getItems(cart.value).filter(Boolean));
const totals = computed(() => cartGetters.getTotals(cart.value));
const totalItems = computed(() => cartGetters.getTotalItems(cart.value));
const getAttributes = (product) => product.configurable_options || [];
Expand All @@ -289,8 +301,9 @@ export default defineComponent({
const isLoaderVisible = ref(false);
const tempProduct = ref();

onSSR(async () => {
await loadCart();
onMounted(() => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
loadCart();
});

const goToCheckout = async () => {
Expand Down Expand Up @@ -327,10 +340,12 @@ export default defineComponent({
});
};

const isInStock = (product) => cartGetters.getStockStatus(product) === stockStatusEnum.inStock;

return {
sendToRemove,
actionRemoveItem,
loading,
loading: computed(() => (!!loading.value)),
isAuthenticated,
products,
removeItem,
Expand All @@ -347,6 +362,7 @@ export default defineComponent({
cartGetters,
getAttributes,
getBundles,
isInStock,
};
},
});
Expand Down Expand Up @@ -494,5 +510,9 @@ export default defineComponent({
}
}
}
.sf-badge__absolute {
position: absolute;
left: 0;
}
}
</style>
6 changes: 3 additions & 3 deletions packages/theme/components/SearchResults.vue
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
>
<SfMenuItem
:label="category.label"
:link="localePath(th.getAgnosticCatLink(category))"
:link="th.getAgnosticCatLink(category)"
>
<template #mobile-nav-icon>
&#8203;
Expand Down Expand Up @@ -76,7 +76,7 @@
:image="productGetters.getProductThumbnailImage(product)"
:alt="productGetters.getName(product)"
:title="productGetters.getName(product)"
:link="localePath(`/p/${productGetters.getProductSku(product)}${productGetters.getSlug(product, product.categories[0])}`)"
:link="`/p/${productGetters.getProductSku(product)}${productGetters.getSlug(product, product.categories[0])}`"
:wishlist-icon="isAuthenticated ? 'heart' : ''"
:is-in-wishlist-icon="isAuthenticated ? 'heart_fill' : ''"
:is-in-wishlist="product.isInWishlist"
Expand All @@ -96,7 +96,7 @@
:image="productGetters.getProductThumbnailImage(product)"
:alt="productGetters.getName(product)"
:title="productGetters.getName(product)"
:link="localePath(`/p/${productGetters.getProductSku(product)}${productGetters.getSlug(product, product.categories[0])}`)"
:link="`/p/${productGetters.getProductSku(product)}${productGetters.getSlug(product, product.categories[0])}`"
:wishlist-icon="isAuthenticated ? 'heart' : ''"
:is-in-wishlist-icon="isAuthenticated ? 'heart_fill' : ''"
:is-in-wishlist="product.isInWishlist"
Expand Down
94 changes: 94 additions & 0 deletions packages/theme/components/__tests__/CartSidebar.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import userEvent from '@testing-library/user-event';
import {
useCart,
useUser,
} from '@vue-storefront/magento';

import { useUiState } from '~/composables';
import {
render, useCartMock, useUserMock, useUiStateMock, useEmptyCartMock,
} from '~/test-utils';
import CartSidebar from '~/components/CartSidebar';

jest.mock('@vue-storefront/magento', () => ({
...jest.requireActual('@vue-storefront/magento'),
useExternalCheckout: jest.fn(() => ({ initializeCheckout: {} })),
useCart: jest.fn(),
useUser: jest.fn(),
}));

jest.mock('~/composables/useUiState');

useUser.mockReturnValue(useUserMock());
useCart.mockReturnValue(useCartMock());

describe('<CartSidebar>', () => {
it('should be not visible by default', () => {
useUiState.mockReturnValue(useUiStateMock());
const { queryByText } = render(CartSidebar);
expect(queryByText('My Cart')).toBeNull();
});

describe('If the cart is empty', () => {
beforeAll(() => {
useCart.mockReturnValue(useEmptyCartMock());
});
describe('And the cart sidebar is open', () => {
it('should render empty state', () => {
useUiState.mockReturnValue(useUiStateMock({ isCartSidebarOpen: true }));
const { queryByText } = render(CartSidebar);
expect(queryByText('Your cart is empty')).toBeTruthy();
});

it('go back button must close the cart sidebar', () => {
const uiStateMock = useUiStateMock({ isCartSidebarOpen: true });
useUiState.mockReturnValue(uiStateMock);
const { queryByText } = render(CartSidebar);
const closeSidebarBtn = queryByText('Go back shopping');
expect(closeSidebarBtn).toBeTruthy();

// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
userEvent.click(closeSidebarBtn);
expect(uiStateMock.toggleCartSidebar).toHaveBeenCalledTimes(1);
});
});
});

describe('If the cart has two products', () => {
beforeAll(() => {
useCart.mockReturnValue(useCartMock());
useUiState.mockReturnValue(useUiStateMock({ isCartSidebarOpen: true }));
});

it('should render two Product Cards', () => {
const { container } = render(CartSidebar);
const productCards = container.querySelectorAll('div.sf-collected-product');
expect(productCards).toHaveLength(2);
});

it('should display proper total items value', () => {
const { container } = render(CartSidebar);
const totalItemsLValue = container.querySelector('div.cart-summary .sf-property__value');
const totalItemsLabel = container.querySelector('div.cart-summary .sf-property__name');
expect(totalItemsLabel).toHaveTextContent('Total items');
expect(totalItemsLValue).toHaveTextContent(2);
});

it('should render "go to checkout" button', () => {
const { getByText } = render(CartSidebar);
expect(getByText('Go to checkout')).toBeTruthy();
});

it('should render promo code input', () => {
const { getByTestId } = render(CartSidebar);
expect(getByTestId('promoCode')).toBeTruthy();
});

describe('And exactly one product is out of stock', () => {
it('should display exactly one out of stock badge', () => {
const { getAllByText } = render(CartSidebar);
expect(getAllByText('Out of stock')).toHaveLength(1);
});
});
});
});
4 changes: 4 additions & 0 deletions packages/theme/enums/stockStatusEnum.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module.exports = {
inStock: 'IN_STOCK',
outOfStock: 'OUT_OF_STOCK',
};
3 changes: 2 additions & 1 deletion packages/theme/lang/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -167,5 +167,6 @@ export default {
forgotPasswordConfirmation: 'Thanks! If there is an account registered with the {0} email, you will find message with a password reset link in your inbox.',
subscribeToNewsletterModalContent: 'After signing up for the newsletter, you will receive special offers and messages from VSF via email. We will not sell or distribute your email to any third party at any time. Please see our {0}.',
'Default Shipping Address': 'Default Shipping Address',
'Default Billing Address': 'Default Billing Address'
'Default Billing Address': 'Default Billing Address',
'Out of stock': 'Out of stock'
};
7 changes: 1 addition & 6 deletions packages/theme/layouts/default.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import LazyHydrate from 'vue-lazy-hydration';
import { onSSR } from '@vue-storefront/core';
import { useRoute, defineComponent } from '@nuxtjs/composition-api';
import {
useCart,
useUser,
} from '@vue-storefront/magento';
import AppHeader from '~/components/AppHeader.vue';
Expand Down Expand Up @@ -57,16 +56,12 @@ export default defineComponent({
setup() {
const route = useRoute();
const { load: loadUser } = useUser();
const { load: loadCart } = useCart();

const { loadConfiguration } = useMagentoConfiguration();

onSSR(async () => {
await loadConfiguration();
await Promise.all([
loadUser(),
loadCart(),
]);
await loadUser();
});

return {
Expand Down
7 changes: 5 additions & 2 deletions packages/theme/test-utils.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import { render } from '@testing-library/vue';

const $t = (text) => text;

const $n = (text) => text;
const localePath = (path) => path;
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const customRender = (component, options = {}, callback = null) => render(component, {
mocks: {
$t,
$n,
localePath,
$nuxt: {
context: {
app: {
localePath: (path) => path,
localePath,
},
},
},
Expand Down
Loading