├── lang └── .gitkeep ├── pages ├── README.md ├── MyAccount │ ├── MyReviews.vue │ ├── LoyaltyCard.vue │ ├── MyNewsletter.vue │ ├── MyProfile.vue │ ├── BillingDetails.vue │ └── ShippingDetails.vue ├── Checkout.vue ├── MyAccount.vue └── ResetPassword.vue ├── components ├── README.md ├── TopBar.vue ├── UserBillingAddress.vue ├── UserShippingAddress.vue ├── RelatedProducts.vue ├── HeaderNavigation.vue ├── Checkout │ ├── VsfPaymentProvider.vue │ ├── UserShippingAddresses.vue │ ├── UserBillingAddresses.vue │ ├── VsfPaymentProviderMock.vue │ └── CartPreview.vue ├── MobileStoreBanner.vue ├── BottomNavigation.vue ├── Notification.vue ├── LocaleSelector.vue ├── AppFooter.vue ├── InstagramFeed.vue ├── MyAccount │ ├── PasswordResetForm.vue │ └── ProfileUpdateForm.vue └── StoreLocaleSelector.vue ├── static ├── favicon.ico ├── homepage │ ├── apple.png │ ├── bannerD.png │ ├── google.png │ ├── bannerA.webp │ ├── bannerB.webp │ ├── bannerC.webp │ ├── bannerE.webp │ ├── bannerF.webp │ ├── bannerG.webp │ ├── bannerH.webp │ ├── imageAd.webp │ ├── imageAm.webp │ ├── imageBd.webp │ ├── imageBm.webp │ ├── imageCd.webp │ ├── imageCm.webp │ ├── imageDd.webp │ ├── imageDm.webp │ ├── productA.webp │ ├── productB.webp │ ├── productC.webp │ └── newsletter.webp ├── icons │ ├── langs │ │ ├── de.webp │ │ └── en.webp │ ├── facebook.svg │ └── logo.svg ├── thank-you │ ├── bannerD.png │ └── bannerM.png ├── productpage │ ├── productA.jpg │ ├── productB.jpg │ └── productM.jpg └── README.md ├── helpers ├── README.md ├── category │ ├── getCategoryPath.js │ ├── index.js │ └── getCategorySearchParameters.js ├── validators │ └── phone.ts ├── cacheControl.js └── Checkout │ └── getShippingMethodPrice.ts ├── middleware ├── is-authenticated.js ├── README.md └── checkout.js ├── tests └── e2e │ ├── types │ ├── address.ts │ ├── customer.ts │ └── types.ts │ ├── support │ ├── index.d.ts │ ├── commands.js │ └── index.js │ ├── pages │ ├── my-account.ts │ ├── home.ts │ ├── base.ts │ ├── utils │ │ └── element.ts │ ├── product.ts │ ├── components │ │ ├── header.ts │ │ ├── login-modal.ts │ │ └── cart-sidebar.ts │ ├── factory.ts │ ├── category.ts │ └── checkout.ts │ ├── fixtures │ └── test-data │ │ ├── e2e-user-login.json │ │ ├── e2e-user-registration.json │ │ ├── e2e-add-to-cart.json │ │ ├── e2e-remove-from-cart.json │ │ ├── e2e-product-page.json │ │ ├── e2e-update-cart.json │ │ ├── e2e-place-order.json │ │ ├── e2e-checkout-order-summary.json │ │ └── e2e-carts-merging.json │ ├── utils │ ├── data-generator.ts │ └── network.ts │ ├── cypress.json │ ├── plugins │ └── index.js │ ├── integration │ ├── e2e-add-to-cart.spec.ts │ ├── e2e-user-login.spec.ts │ ├── e2e-user-registration.spec.ts │ ├── e2e-remove-from-cart.spec.ts │ ├── e2e-product-page.spec.ts │ ├── e2e-update-cart.spec.ts │ ├── e2e-checkout-order-summary.spec.ts │ ├── e2e-place-order.spec.ts │ ├── e2e-carts-merging.spec.ts │ ├── e2e-checkout-shipping-validation.spec.ts │ └── e2e-checkout-billing-validation.spec.ts │ └── api │ └── requests.ts ├── composables ├── index.ts ├── usePaymentProviderMock │ └── index.ts ├── useUiNotification │ └── index.ts ├── useUiState.ts └── useUiHelpers │ └── index.ts ├── .editorconfig ├── .babelrc ├── .github ├── pull_request_template.md └── workflows │ └── edgio.yml ├── sw └── service-worker.js ├── shims-webpack.d.ts ├── jest.config.js ├── edgio.config.js ├── .env-example ├── tsconfig.json ├── edgio ├── createHttpLink.js ├── createProductURL.js └── createCategoryURL.js ├── layouts ├── account.vue ├── blank.vue ├── error.vue └── default.vue ├── themeConfig.js ├── middleware.config.js ├── .gitignore ├── routes.ts ├── package.json ├── README.md ├── mockedSearchProducts.json └── nuxt.config.js /lang/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pages/README.md: -------------------------------------------------------------------------------- 1 | Put here theme-specific pages to override default ones -------------------------------------------------------------------------------- /components/README.md: -------------------------------------------------------------------------------- 1 | Put here theme-specific components to override default ones -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/uoona/main/static/favicon.ico -------------------------------------------------------------------------------- /helpers/README.md: -------------------------------------------------------------------------------- 1 | Put here platform-specific, non-agnostic functions that overwrite default code. 2 | -------------------------------------------------------------------------------- /static/homepage/apple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/uoona/main/static/homepage/apple.png -------------------------------------------------------------------------------- /static/homepage/bannerD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/uoona/main/static/homepage/bannerD.png -------------------------------------------------------------------------------- /static/homepage/google.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/uoona/main/static/homepage/google.png -------------------------------------------------------------------------------- /static/icons/langs/de.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/uoona/main/static/icons/langs/de.webp -------------------------------------------------------------------------------- /static/icons/langs/en.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/uoona/main/static/icons/langs/en.webp -------------------------------------------------------------------------------- /static/homepage/bannerA.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/uoona/main/static/homepage/bannerA.webp -------------------------------------------------------------------------------- /static/homepage/bannerB.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/uoona/main/static/homepage/bannerB.webp -------------------------------------------------------------------------------- /static/homepage/bannerC.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/uoona/main/static/homepage/bannerC.webp -------------------------------------------------------------------------------- /static/homepage/bannerE.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/uoona/main/static/homepage/bannerE.webp -------------------------------------------------------------------------------- /static/homepage/bannerF.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/uoona/main/static/homepage/bannerF.webp -------------------------------------------------------------------------------- /static/homepage/bannerG.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/uoona/main/static/homepage/bannerG.webp -------------------------------------------------------------------------------- /static/homepage/bannerH.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/uoona/main/static/homepage/bannerH.webp -------------------------------------------------------------------------------- /static/homepage/imageAd.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/uoona/main/static/homepage/imageAd.webp -------------------------------------------------------------------------------- /static/homepage/imageAm.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/uoona/main/static/homepage/imageAm.webp -------------------------------------------------------------------------------- /static/homepage/imageBd.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/uoona/main/static/homepage/imageBd.webp -------------------------------------------------------------------------------- /static/homepage/imageBm.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/uoona/main/static/homepage/imageBm.webp -------------------------------------------------------------------------------- /static/homepage/imageCd.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/uoona/main/static/homepage/imageCd.webp -------------------------------------------------------------------------------- /static/homepage/imageCm.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/uoona/main/static/homepage/imageCm.webp -------------------------------------------------------------------------------- /static/homepage/imageDd.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/uoona/main/static/homepage/imageDd.webp -------------------------------------------------------------------------------- /static/homepage/imageDm.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/uoona/main/static/homepage/imageDm.webp -------------------------------------------------------------------------------- /static/homepage/productA.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/uoona/main/static/homepage/productA.webp -------------------------------------------------------------------------------- /static/homepage/productB.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/uoona/main/static/homepage/productB.webp -------------------------------------------------------------------------------- /static/homepage/productC.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/uoona/main/static/homepage/productC.webp -------------------------------------------------------------------------------- /static/thank-you/bannerD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/uoona/main/static/thank-you/bannerD.png -------------------------------------------------------------------------------- /static/thank-you/bannerM.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/uoona/main/static/thank-you/bannerM.png -------------------------------------------------------------------------------- /static/homepage/newsletter.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/uoona/main/static/homepage/newsletter.webp -------------------------------------------------------------------------------- /static/productpage/productA.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/uoona/main/static/productpage/productA.jpg -------------------------------------------------------------------------------- /static/productpage/productB.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/uoona/main/static/productpage/productB.jpg -------------------------------------------------------------------------------- /static/productpage/productM.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spider-yamet/uoona/main/static/productpage/productM.jpg -------------------------------------------------------------------------------- /helpers/category/getCategoryPath.js: -------------------------------------------------------------------------------- 1 | export function getCategoryPath(category, context = this) { 2 | return `/c/${context.$route.params.slug_1}/${category.slug}`; 3 | } 4 | -------------------------------------------------------------------------------- /middleware/is-authenticated.js: -------------------------------------------------------------------------------- 1 | export default async ({ app, redirect }) => { 2 | if (!app.$cookies.get('vsf-commercetools-token')?.scope?.includes('customer_id')) { 3 | return redirect('/'); 4 | } 5 | }; 6 | -------------------------------------------------------------------------------- /tests/e2e/types/address.ts: -------------------------------------------------------------------------------- 1 | export type Address = { 2 | streetName: string; 3 | apartment: string; 4 | city: string; 5 | state: string; 6 | country: string; 7 | zipcode: string; 8 | phone: string; 9 | } 10 | -------------------------------------------------------------------------------- /composables/index.ts: -------------------------------------------------------------------------------- 1 | import useUiHelpers from './useUiHelpers'; 2 | import useUiState from './useUiState'; 3 | import useUiNotification from './useUiNotification'; 4 | 5 | export { 6 | useUiHelpers, 7 | useUiState, 8 | useUiNotification 9 | }; 10 | -------------------------------------------------------------------------------- /tests/e2e/support/index.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable spaced-comment */ 2 | /// 3 | /// 4 | 5 | declare namespace Cypress { 6 | interface Chainable { 7 | fixtures?: any; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tests/e2e/pages/my-account.ts: -------------------------------------------------------------------------------- 1 | import { contains } from './utils/element'; 2 | 3 | class Sidebar { 4 | get heading(): Cypress.Chainable { 5 | return contains('my-account-content-pages', 'My Account'); 6 | } 7 | } 8 | 9 | export { 10 | Sidebar 11 | }; 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /composables/usePaymentProviderMock/index.ts: -------------------------------------------------------------------------------- 1 | import { sharedRef } from '@vue-storefront/core'; 2 | 3 | export const usePaymentProviderMock = () => { 4 | const status = sharedRef(false, 'usePaymentProviderMock-status'); 5 | 6 | return { 7 | status 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /helpers/category/index.js: -------------------------------------------------------------------------------- 1 | import { getCategorySearchParameters } from './getCategorySearchParameters'; 2 | import { getCategoryPath } from './getCategoryPath'; 3 | 4 | // TODO: remove, use faceting instead 5 | export { 6 | getCategorySearchParameters, 7 | getCategoryPath 8 | }; 9 | -------------------------------------------------------------------------------- /tests/e2e/types/customer.ts: -------------------------------------------------------------------------------- 1 | import { Address } from './address'; 2 | 3 | export type Customer = { 4 | firstName?: string; 5 | lastName?: string; 6 | email?: string; 7 | password?: string; 8 | address?: { 9 | shipping: Address, 10 | billing: Address 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "test": { 4 | "presets": [ 5 | [ 6 | "@babel/preset-env", 7 | { 8 | "targets": { 9 | "node": "current" 10 | } 11 | } 12 | ] 13 | ] 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | **NOTICE TO CONTRIBUTORS** 2 | 3 | This repository is not actively monitored and any pull requests made to this repository will be closed/ignored. 4 | 5 | Please submit the pull request to [edgio-docs/edgio-examples](https://github.com/edgio-docs/edgio-examples) instead. 6 | -------------------------------------------------------------------------------- /helpers/category/getCategorySearchParameters.js: -------------------------------------------------------------------------------- 1 | export const getCategorySearchParameters = (context) => { 2 | const { params } = context.root.$route; 3 | const lastSlug = Object.keys(params).reduce((prev, curr) => params[curr] || prev, params.slug_1); 4 | 5 | return { 6 | slug: lastSlug 7 | }; 8 | }; 9 | -------------------------------------------------------------------------------- /tests/e2e/pages/home.ts: -------------------------------------------------------------------------------- 1 | import Base from './base'; 2 | import Header from './components/header'; 3 | 4 | class Home extends Base { 5 | get header() { 6 | return Header; 7 | } 8 | 9 | visit(): Cypress.Chainable { 10 | return cy.visit('/'); 11 | } 12 | } 13 | 14 | export default new Home(); 15 | -------------------------------------------------------------------------------- /sw/service-worker.js: -------------------------------------------------------------------------------- 1 | import { skipWaiting, clientsClaim } from 'workbox-core' 2 | import { precacheAndRoute } from 'workbox-precaching' 3 | import { Prefetcher } from '@edgio/prefetch/sw' 4 | 5 | skipWaiting() 6 | clientsClaim() 7 | precacheAndRoute(self.__WB_MANIFEST || []) 8 | 9 | new Prefetcher().route() 10 | -------------------------------------------------------------------------------- /helpers/validators/phone.ts: -------------------------------------------------------------------------------- 1 | import { extend } from 'vee-validate'; 2 | import PhoneNumber from 'awesome-phonenumber'; 3 | 4 | extend('phone', { 5 | message: 'This is not a valid phone number', 6 | validate (value) { 7 | const phone = new PhoneNumber(value); 8 | return phone.isValid(); 9 | } 10 | }); 11 | -------------------------------------------------------------------------------- /tests/e2e/pages/base.ts: -------------------------------------------------------------------------------- 1 | import Header from './components/header'; 2 | 3 | export default class Base { 4 | 5 | get path(): string { 6 | return '/'; 7 | } 8 | 9 | get header() { 10 | return Header; 11 | } 12 | 13 | visit(): Cypress.Chainable { 14 | return cy.visit(this.path); 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /helpers/cacheControl.js: -------------------------------------------------------------------------------- 1 | const cacheControl = (values) => ({ res }) => { 2 | if (!process.server) return; 3 | 4 | const cacheControlValue = Object.entries(values) 5 | .map(([key, value]) => `${key}=${value}`) 6 | .join(','); 7 | 8 | res.setHeader('Cache-Control', cacheControlValue); 9 | }; 10 | 11 | export default cacheControl; 12 | -------------------------------------------------------------------------------- /tests/e2e/pages/utils/element.ts: -------------------------------------------------------------------------------- 1 | export function el(selector: string, children?: string): Cypress.Chainable { 2 | return children ? cy.get(`[data-e2e="${selector}"] ${children}`) : cy.get(`[data-e2e="${selector}"]`); 3 | } 4 | 5 | export function contains(selector: string, text: string): Cypress.Chainable { 6 | return cy.contains(`[data-e2e="${selector}"]`, text); 7 | } 8 | -------------------------------------------------------------------------------- /shims-webpack.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'webpack-hot-middleware' { 2 | const middleware: any; 3 | export interface Options { 4 | [proName: string]: any; 5 | } 6 | 7 | export interface ClientOptions { 8 | [proName: string]: any; 9 | } 10 | 11 | export interface MiddlewareOptions { 12 | [proName: string]: any; 13 | } 14 | 15 | export default middleware; 16 | } 17 | -------------------------------------------------------------------------------- /tests/e2e/fixtures/test-data/e2e-user-login.json: -------------------------------------------------------------------------------- 1 | { 2 | "Should successfully login": { 3 | "customer": { 4 | "password": "Vu3S70r3fr0n7!" 5 | } 6 | }, 7 | "Incorrect credentials - should display an error": { 8 | "customer": { 9 | "password": "Vu3S70r3fr0n7!" 10 | }, 11 | "errorMessage": "Account with the given credentials not found." 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /middleware/README.md: -------------------------------------------------------------------------------- 1 | # MIDDLEWARE 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your application middleware. 6 | Middleware let you define custom functions that can be run before rendering either a page or a group of pages. 7 | 8 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/routing#middleware). 9 | -------------------------------------------------------------------------------- /tests/e2e/utils/data-generator.ts: -------------------------------------------------------------------------------- 1 | const generator = { 2 | get email(): string { 3 | return `cypress.${Date.now()}@vuestorefront.test`; 4 | }, 5 | 6 | get uuid(): string { 7 | return 'xxxxxxxx-xxxx-xxxx-xxxxxxxxxxxx'.replace(/[x]/g, () => { 8 | const random = Math.floor(Math.random() * 16); 9 | return random.toString(16); 10 | }); 11 | } 12 | 13 | }; 14 | 15 | export default generator; 16 | -------------------------------------------------------------------------------- /helpers/Checkout/getShippingMethodPrice.ts: -------------------------------------------------------------------------------- 1 | import { ShippingMethod } from '@vue-storefront/commercetools-api'; 2 | 3 | export default (shippingMethod: ShippingMethod, total: number) => { 4 | const centAmount = shippingMethod?.zoneRates[0].shippingRates[0].freeAbove?.centAmount; 5 | if (centAmount && total >= (centAmount / 100)) { 6 | return 0; 7 | } 8 | return shippingMethod.zoneRates[0].shippingRates[0].price.centAmount / 100; 9 | }; 10 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleNameMapper: { 3 | '^@/(.*)$': '/$1', 4 | '^~/(.*)$': '/$1', 5 | '^vue$': 'vue/dist/vue.common.js' 6 | }, 7 | moduleFileExtensions: ['js', 'vue', 'json'], 8 | transform: { 9 | '^.+\\.js$': 'babel-jest', 10 | '.*\\.(vue)$': 'vue-jest' 11 | }, 12 | collectCoverage: true, 13 | collectCoverageFrom: [ 14 | '/components/**/*.vue', 15 | '/pages/**/*.vue' 16 | ] 17 | }; 18 | -------------------------------------------------------------------------------- /static/README.md: -------------------------------------------------------------------------------- 1 | # STATIC 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your static files. 6 | Each file inside this directory is mapped to `/`. 7 | Thus you'd want to delete this README.md before deploying to production. 8 | 9 | Example: `/static/robots.txt` is mapped as `/robots.txt`. 10 | 11 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/assets#static). 12 | -------------------------------------------------------------------------------- /tests/e2e/fixtures/test-data/e2e-user-registration.json: -------------------------------------------------------------------------------- 1 | { 2 | "Should successfully register": { 3 | "customer": { 4 | "password": "Vu3S70r3fr0n7!", 5 | "firstName": "Jane", 6 | "lastName": "Doe" 7 | } 8 | }, 9 | "Existing user - should display an error": { 10 | "customer": { 11 | "password": "Vu3S70r3fr0n7!", 12 | "firstName": "Jane", 13 | "lastName": "Doe" 14 | }, 15 | "errorMessage": "There is already an existing customer with the email" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /edgio.config.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | // This file was automatically added by edgio deploy. 4 | // You should commit this file to source control. 5 | 6 | module.exports = { 7 | connector: "@edgio/nuxt", 8 | backends: { 9 | api: { 10 | domainOrIp: "api.commercetools.com", 11 | hostHeader: "api.commercetools.com" 12 | } 13 | }, 14 | includeNodeModules: true, 15 | includeFiles: { 16 | middleware: true, 17 | "middleware.config.js": true, 18 | "themeConfig.js": true 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /tests/e2e/utils/network.ts: -------------------------------------------------------------------------------- 1 | import generator from '../utils/data-generator'; 2 | 3 | function _intercept(path: string, alias?: string) { 4 | const as = alias ?? generator.uuid; 5 | cy.intercept(path).as(as); 6 | return `@${as}`; 7 | } 8 | 9 | const intercept = { 10 | getProduct(as?: string): string { 11 | return _intercept('/getProduct', as); 12 | }, 13 | 14 | updateCartQuantity(as?: string): string { 15 | return _intercept('/updateCartQuantity', as); 16 | } 17 | }; 18 | 19 | export default intercept; 20 | -------------------------------------------------------------------------------- /.github/workflows/edgio.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to Edgio 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | 7 | jobs: 8 | deploy-to-edgio: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-node@v3 13 | - run: if [ -f yarn.lock ]; then yarn install; else npm ci; fi 14 | - run: if [ -f yarn.lock ]; then yarn edgio:deploy -- --token=$EDGIO_DEPLOY_TOKEN; else npm run edgio:deploy -- --token=$EDGIO_DEPLOY_TOKEN; fi 15 | env: 16 | EDGIO_DEPLOY_TOKEN: ${{secrets.EDGIO_DEPLOY_TOKEN}} 17 | -------------------------------------------------------------------------------- /tests/e2e/types/types.ts: -------------------------------------------------------------------------------- 1 | export type Address = { 2 | firstName?: string; 3 | lastName?: string; 4 | streetName?: string; 5 | apartment?: string; 6 | city?: string; 7 | state?: string; 8 | country?: string; 9 | postalCode?: string; 10 | phone?: string; 11 | } 12 | 13 | export type Customer = { 14 | firstName?: string; 15 | lastName?: string; 16 | email?: string; 17 | password?: string; 18 | address?: { 19 | shipping: Address, 20 | billing: Address 21 | } 22 | } 23 | 24 | export type Product = { 25 | sku: string; 26 | id: number; 27 | } 28 | -------------------------------------------------------------------------------- /.env-example: -------------------------------------------------------------------------------- 1 | CTP_PROJECT_KEY=vsf-ct-dev 2 | CTP_CLIENT_SECRET=5eBt3yfZJWw1j7V6kXjfKXpuFP 3 | CTP_CLIENT_ID=RT4iJGDbDzZe4b2E6RyeNe9s 4 | CTP_AUTH_URL=https://auth.sphere.io 5 | CTP_API_URL=https://api.commercetools.com/vsf-ct-dev/graphql 6 | CTP_SCOPES=view_published_products:l0_test1 view_categories:l0_test1 manage_my_payments:l0_test1 manage_my_shopping_lists:l0_test1 view_stores:l0_test1 manage_my_profile:l0_test1 manage_my_orders:l0_test1 create_anonymous_token:l0_test1 7 | # EDGIO_DEPLOY_TOKEN= # Use this to enable github actions https://docs.layer0.co/guides/deploying#section_github_actions 8 | -------------------------------------------------------------------------------- /tests/e2e/fixtures/test-data/e2e-add-to-cart.json: -------------------------------------------------------------------------------- 1 | { 2 | "Should successfully add product to cart - Category grid view": { 3 | "product": { 4 | "category": "women", 5 | "name": "T-Shirt Moschino Cheap And Chic black" 6 | } 7 | }, 8 | "Should successfully add product to cart - Category list view": { 9 | "product": { 10 | "category": "men", 11 | "name": "Sweater Polo Ralph Lauren pink" 12 | } 13 | }, 14 | "Should successfully add product to cart - Product details page": { 15 | "product": { 16 | "name": "Lace up shoes Tods dark blue", 17 | "id": "eda439df-e655-493d-9fe9-b93c45144374", 18 | "slug": "tods-laceupshoes-C20RE0U820-darkblue" 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/e2e/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:3000", 3 | "fixturesFolder": "tests/e2e/fixtures", 4 | "integrationFolder": "tests/e2e/integration", 5 | "pluginsFile": "tests/e2e/plugins/index.js", 6 | "supportFile": "tests/e2e/support/index.js", 7 | "viewportHeight": 1080, 8 | "viewportWidth": 1920, 9 | "pageLoadTimeout": 180000, 10 | "screenshotOnRunFailure": true, 11 | "screenshotsFolder": "tests/e2e/report/assets/screenshots", 12 | "video": false, 13 | "reporter": "../../../../node_modules/mochawesome", 14 | "reporterOptions": { 15 | "reportDir": "tests/e2e/report", 16 | "reportFilename": "report", 17 | "overwrite": false, 18 | "html": false 19 | }, 20 | "retries": { 21 | "runMode": 2, 22 | "openMode": 0 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /middleware/checkout.js: -------------------------------------------------------------------------------- 1 | const canEnterPayment = cart => cart.shippingInfo && cart.shippingAddress; 2 | 3 | const canEnterReview = cart => Boolean(cart.billingAddress); 4 | 5 | export default async ({ app, $vsf }) => { 6 | const currentPath = app.context.route.fullPath.split('/checkout/')[1]; 7 | 8 | if (!currentPath) return; 9 | 10 | const { data } = await $vsf.$ct.api.getMe(); 11 | 12 | if (!data || !data.me.activeCart) return; 13 | const { activeCart } = data.me; 14 | 15 | switch (currentPath) { 16 | case 'billing': 17 | if (!canEnterPayment(activeCart)) { 18 | app.context.redirect('/'); 19 | } 20 | break; 21 | case 'payment': 22 | if (!canEnterReview(activeCart)) { 23 | app.context.redirect('/'); 24 | } 25 | break; 26 | } 27 | }; 28 | 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "lib": [ 7 | "esnext", 8 | "esnext.asynciterable", 9 | "dom" 10 | ], 11 | "esModuleInterop": true, 12 | "allowJs": true, 13 | "sourceMap": true, 14 | "strict": false, 15 | "noEmit": true, 16 | "baseUrl": ".", 17 | "paths": { 18 | "~/*": [ 19 | "./*" 20 | ], 21 | "@/*": [ 22 | "./*" 23 | ] 24 | }, 25 | "types": [ 26 | "@types/node", 27 | "@nuxt/types", 28 | "nuxt-i18n", 29 | "cypress" 30 | ] 31 | }, 32 | "exclude": [ 33 | "node_modules" 34 | ], 35 | "include": [ 36 | "tests/e2e/**/*.ts", 37 | "tests/e2e/plugins/index.js", 38 | "tests/e2e/support/index.js" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /tests/e2e/fixtures/test-data/e2e-remove-from-cart.json: -------------------------------------------------------------------------------- 1 | { 2 | "Should remove all products from cart": { 3 | "product": { 4 | "name": "Shirt Aspesi white M", 5 | "id": 1, 6 | "sku": "M0E20000000ED0W", 7 | "quantity": 2, 8 | "size": 34 9 | } 10 | }, 11 | "Should remove single product from cart": { 12 | "products": [{ 13 | "name": "Shirt Aspesi white M", 14 | "id": 1, 15 | "sku": "M0E20000000ED0W", 16 | "quantity": 1, 17 | "size": 34 18 | }, 19 | { 20 | "name": "Shirt ”David” MU light blue", 21 | "id": 1, 22 | "sku": "M0E20000000DL5W", 23 | "quantity": 1, 24 | "size": 36 25 | } 26 | ], 27 | "productToRemove": { 28 | "name": "Shirt Aspesi white M" 29 | }, 30 | "expectedCart": [{ 31 | "name": "Shirt ”David” MU light blue" 32 | }] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /edgio/createHttpLink.js: -------------------------------------------------------------------------------- 1 | import { createHttpLink as edgioCreateHttpLink } from '@edgio/apollo' 2 | import { createHttpLink as ogCreateHttpLink } from 'original-apollo-link-http' 3 | 4 | /* 5 | 6 | This createHttpLink wrapper replaces all imports of the apollo-link-http 7 | createHttpLink module by way of the @edgio/vue-storefront/module webpack 8 | transform. 9 | 10 | The purpose of this module is to predefine which graphql operations to 11 | convert to HTTP GET. By changing the requests to GET, we can cache them 12 | for quicker responses later. 13 | 14 | If you need to change or expand which operations to cache, you only need to 15 | modify the `convertOperationsToGet` array property below. 16 | 17 | */ 18 | export function createHttpLink(config) { 19 | return edgioCreateHttpLink( 20 | { 21 | ...config, 22 | convertOperationsToGet: ['products', 'categories'], 23 | }, 24 | ogCreateHttpLink 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /components/TopBar.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 29 | 38 | -------------------------------------------------------------------------------- /tests/e2e/plugins/index.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line spaced-comment 2 | /// 3 | // *********************************************************** 4 | // This example plugins/index.js can be used to load plugins 5 | // 6 | // You can change the location of this file or turn off loading 7 | // the plugins file with the 'pluginsFile' configuration option. 8 | // 9 | // You can read more here: 10 | // https://on.cypress.io/plugins-guide 11 | // *********************************************************** 12 | 13 | // This function is called when a project is opened or re-opened (e.g. due to 14 | // the project's config changing) 15 | 16 | const tagify = require('cypress-tags'); 17 | 18 | /** 19 | * @type {Cypress.PluginConfig} 20 | */ 21 | module.exports = (on, config) => { 22 | // `on` is used to hook into various events Cypress emits 23 | // `config` is the resolved Cypress config 24 | on('file:preprocessor', tagify(config)); 25 | }; 26 | -------------------------------------------------------------------------------- /static/icons/facebook.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/e2e/support/commands.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | // *********************************************** 3 | // This example commands.js shows you how to 4 | // create various custom commands and overwrite 5 | // existing commands. 6 | // 7 | // For more comprehensive examples of custom 8 | // commands please read more here: 9 | // https://on.cypress.io/custom-commands 10 | // *********************************************** 11 | // 12 | // 13 | // -- This is a parent command -- 14 | // Cypress.Commands.add("login", (email, password) => { ... }) 15 | // 16 | // 17 | // -- This is a child command -- 18 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 19 | // 20 | // 21 | // -- This is a dual command -- 22 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 23 | // 24 | // 25 | // -- This will overwrite an existing command -- 26 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 27 | -------------------------------------------------------------------------------- /edgio/createProductURL.js: -------------------------------------------------------------------------------- 1 | import { createApolloURL } from '@edgio/apollo' 2 | import { apolloClient, getSettings } from '@vue-storefront/commercetools-api' 3 | import { buildProductWhere } from '@vue-storefront/commercetools-api/src/helpers/search' 4 | import defaultQuery from '@vue-storefront/commercetools-api/src/api/getProduct/defaultQuery' 5 | 6 | /* 7 | 8 | Builds a query URL for apollo to obtain product data during prefetching 9 | 10 | You can wrap product links to prefetch with the @edgio/vue `Prefetch` component. 11 | 12 | e.g. 13 | 14 | 15 | 16 | 17 | 18 | */ 19 | export default function createProductURL({ _id }) { 20 | const { locale, acceptLanguage, currency, country } = getSettings() 21 | const variables = { 22 | where: buildProductWhere({ id: _id }), 23 | locale, 24 | acceptLanguage, 25 | currency, 26 | country, 27 | } 28 | return createApolloURL(apolloClient, defaultQuery, variables) 29 | } 30 | -------------------------------------------------------------------------------- /tests/e2e/pages/product.ts: -------------------------------------------------------------------------------- 1 | import Base from './base'; 2 | import { el } from './utils/element'; 3 | 4 | export class Product extends Base { 5 | 6 | private _id: string; 7 | private _slug: string; 8 | 9 | constructor(id?: string, slug?: string) { 10 | super(); 11 | if (id) this.id = id; 12 | if (slug) this.slug = slug; 13 | } 14 | 15 | get id(): string { 16 | return this._id; 17 | } 18 | 19 | set id(id: string) { 20 | this._id = id; 21 | } 22 | 23 | get slug(): string { 24 | return this._slug; 25 | } 26 | 27 | set slug(slug: string) { 28 | this._slug = slug; 29 | } 30 | 31 | get path() { 32 | return `/p/${this.id}/${this.slug}`; 33 | } 34 | 35 | get addToCartButton(): Cypress.Chainable { 36 | return el('product_add-to-cart'); 37 | } 38 | 39 | get sizeSelect(): Cypress.Chainable { 40 | return el('size-select', 'select'); 41 | } 42 | 43 | get sizeOptions(): Cypress.Chainable { 44 | return el('size-select', 'select option'); 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /tests/e2e/pages/components/header.ts: -------------------------------------------------------------------------------- 1 | import { el } from '../utils/element'; 2 | 3 | class Header { 4 | 5 | get account(): Cypress.Chainable { 6 | return el('app-header-account'); 7 | } 8 | 9 | get cart(): Cypress.Chainable { 10 | return el('app-header-cart'); 11 | } 12 | 13 | get categories(): Cypress.Chainable { 14 | return cy.get('[data-e2e*="app-header"]'); 15 | } 16 | 17 | get category() { 18 | return { 19 | women: () => el('app-header-url_women'), 20 | men: () => el('app-header-url_men') 21 | }; 22 | } 23 | 24 | openCart(): Cypress.Chainable { 25 | const click = $el => $el.click(); 26 | return this.cart.pipe(click).should(() => { 27 | expect(Cypress.$('[data-e2e="sidebar-cart"]')).to.exist; 28 | }); 29 | } 30 | 31 | openLoginModal(): Cypress.Chainable { 32 | const click = $el => $el.click(); 33 | return this.account.pipe(click).should(() => { 34 | expect(Cypress.$('[data-e2e="login-modal"]')).to.exist; 35 | }); 36 | } 37 | } 38 | 39 | export default new Header(); 40 | -------------------------------------------------------------------------------- /tests/e2e/pages/factory.ts: -------------------------------------------------------------------------------- 1 | import { Billing, Payment, Shipping, ThankYou } from './checkout'; 2 | import Cart from './components/cart-sidebar'; 3 | import LoginModal from './components/login-modal'; 4 | import Home from './home'; 5 | import { Sidebar } from './my-account'; 6 | import { Product } from './product'; 7 | import { Category } from './category'; 8 | 9 | const page = { 10 | get checkout() { 11 | return { 12 | shipping: new Shipping(), 13 | billing: new Billing(), 14 | payment: new Payment(), 15 | thankyou: new ThankYou() 16 | }; 17 | }, 18 | 19 | get components() { 20 | return { 21 | cart: Cart, 22 | loginModal: LoginModal 23 | }; 24 | }, 25 | 26 | get home() { 27 | return Home; 28 | }, 29 | 30 | get myAccount() { 31 | return { 32 | sidebar: new Sidebar() 33 | }; 34 | }, 35 | 36 | category(category?: string) { 37 | return new Category(category); 38 | }, 39 | 40 | product(id?: string, slug?: string) { 41 | return new Product(id, slug); 42 | } 43 | 44 | }; 45 | 46 | export default page; 47 | -------------------------------------------------------------------------------- /layouts/account.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 33 | 34 | 50 | -------------------------------------------------------------------------------- /tests/e2e/fixtures/test-data/e2e-product-page.json: -------------------------------------------------------------------------------- 1 | { 2 | "Should contain all size options": { 3 | "product": { 4 | "id": "38143c0c-c9b0-448c-93cd-60eb90d8da57", 5 | "slug": "aspesi-shirt-h805-white", 6 | "attributes": { 7 | "size": [ 8 | "34", 9 | "36", 10 | "38", 11 | "40", 12 | "42", 13 | "44", 14 | "46", 15 | "48", 16 | "50", 17 | "52", 18 | "54", 19 | "56", 20 | "58", 21 | "70" 22 | ] 23 | } 24 | } 25 | }, 26 | "Should select correct size option": { 27 | "product": { 28 | "id": "38143c0c-c9b0-448c-93cd-60eb90d8da57", 29 | "slug": "aspesi-shirt-h805-white", 30 | "attributes": { 31 | "size": "58" 32 | } 33 | } 34 | }, 35 | "Should add correct variant to cart": { 36 | "product": { 37 | "id": "38143c0c-c9b0-448c-93cd-60eb90d8da57", 38 | "slug": "aspesi-shirt-h805-white", 39 | "attributes": { 40 | "size": "38", 41 | "color": "white" 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/e2e/support/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | // *********************************************************** 3 | // This example support/index.js is processed and 4 | // loaded automatically before your test files. 5 | // 6 | // This is a great place to put global configuration and 7 | // behavior that modifies Cypress. 8 | // 9 | // You can change the location of this file or turn off 10 | // automatically serving support files with the 11 | // 'supportFile' configuration option. 12 | // 13 | // You can read more here: 14 | // https://on.cypress.io/configuration 15 | // *********************************************************** 16 | 17 | // Import commands.js using ES2015 syntax: 18 | import './commands.js'; 19 | import 'cypress-pipe'; 20 | 21 | // Alternatively you can use CommonJS syntax: 22 | // require('./commands') 23 | 24 | import addContext from 'mochawesome/addContext'; 25 | 26 | Cypress.on('test:after:run', (test, runnable) => { 27 | if (test.state === 'failed') { 28 | const screenshot = `assets/screenshots/${Cypress.spec.name}/${runnable.parent.title} -- ${test.title} (failed).png`; 29 | addContext({test}, { 30 | title: 'Screenshot', 31 | value: screenshot 32 | }); 33 | } 34 | }); 35 | 36 | -------------------------------------------------------------------------------- /static/icons/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pages/MyAccount/MyReviews.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 23 | 24 | 47 | -------------------------------------------------------------------------------- /pages/MyAccount/LoyaltyCard.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 23 | 24 | 48 | -------------------------------------------------------------------------------- /themeConfig.js: -------------------------------------------------------------------------------- 1 | // default configuration for links and images 2 | const defaultConfig = { 3 | home: { 4 | bannerA: { 5 | link: '/', 6 | image: { 7 | mobile: '/homepage/bannerB.webp', 8 | desktop: '/homepage/bannerF.webp' 9 | } 10 | }, 11 | bannerB: { 12 | link: '/', 13 | image: '/homepage/bannerE.webp' 14 | }, 15 | bannerC: { 16 | link: '/', 17 | image: '/homepage/bannerC.webp' 18 | }, 19 | bannerD: { 20 | link: '/', 21 | image: '/homepage/bannerG.webp' 22 | } 23 | } 24 | }; 25 | // configuration used for links and images for demo purposes 26 | const demoConfig = { 27 | home: { 28 | bannerA: { 29 | link: '/c/women/women-clothing-skirts', 30 | image: { 31 | mobile: '/homepage/bannerB.webp', 32 | desktop: '/homepage/bannerF.webp' 33 | } 34 | }, 35 | bannerB: { 36 | link: '/c/women/women-clothing-dresses', 37 | image: '/homepage/bannerE.webp' 38 | }, 39 | bannerC: { 40 | link: '/c/women/women-clothing-shirts', 41 | image: '/homepage/bannerC.webp' 42 | }, 43 | bannerD: { 44 | link: '/c/women/women-shoes-sandals', 45 | image: '/homepage/bannerG.webp' 46 | } 47 | } 48 | }; 49 | 50 | export default (() => { 51 | if (process.env.IS_DEMO) { 52 | return demoConfig; 53 | } 54 | return defaultConfig; 55 | })(); 56 | -------------------------------------------------------------------------------- /composables/useUiNotification/index.ts: -------------------------------------------------------------------------------- 1 | import { computed, reactive } from '@vue/composition-api'; 2 | 3 | interface UiNotification { 4 | message: string; 5 | action: { text: string; onClick: (...args: any) => void }; 6 | type: 'danger' | 'success' | 'info'; 7 | icon: string; 8 | persist: boolean; 9 | id: symbol; 10 | dismiss: () => void; 11 | } 12 | 13 | interface Notifications { 14 | notifications: Array; 15 | } 16 | 17 | const state = reactive({ 18 | notifications: [] 19 | }); 20 | const maxVisibleNotifications = 3; 21 | const timeToLive = 3000; 22 | 23 | const useUiNotification = () => { 24 | const send = (notification: UiNotification) => { 25 | const id = Symbol(); 26 | 27 | const dismiss = () => { 28 | const index = state.notifications.findIndex(notification => notification.id === id); 29 | 30 | if (index !== -1) state.notifications.splice(index, 1); 31 | }; 32 | 33 | const newNotification = { 34 | ...notification, 35 | id, 36 | dismiss 37 | }; 38 | 39 | state.notifications.push(newNotification); 40 | if (state.notifications.length > maxVisibleNotifications) state.notifications.shift(); 41 | 42 | if (!notification.persist) { 43 | setTimeout(dismiss, timeToLive); 44 | } 45 | }; 46 | 47 | return { 48 | send, 49 | notifications: computed(() => state.notifications) 50 | }; 51 | }; 52 | 53 | export default useUiNotification; 54 | -------------------------------------------------------------------------------- /edgio/createCategoryURL.js: -------------------------------------------------------------------------------- 1 | import { createApolloURL } from '@edgio/apollo' 2 | import { apolloClient, getSettings } from '@vue-storefront/commercetools-api' 3 | import defaultQuery from '@vue-storefront/commercetools-api/src/api/getCategory/defaultQuery' 4 | 5 | // This was just copied from commercetools-api. It was not exported. 6 | // Simply a an apollo filter for categories 7 | const buildCategoryWhere = (search, acceptLanguage) => { 8 | if (search?.catId) { 9 | return `id="${search.catId}"` 10 | } 11 | 12 | if (search?.slug) { 13 | const predicate = acceptLanguage.map(locale => `${locale}="${search.slug}"`).join(' or ') 14 | return `slug(${predicate})` 15 | } 16 | 17 | return undefined 18 | } 19 | 20 | /* 21 | 22 | Builds a query URL for apollo to obtain category data during prefetching. 23 | 24 | You can wrap category links to prefetch with the @edgio/vue `Prefetch` component. 25 | 26 | e.g. 27 | 28 | 29 | 30 | 31 | 32 | */ 33 | 34 | export default function createCategoryURL(category) { 35 | const { acceptLanguage } = getSettings() 36 | const variables = category 37 | ? { 38 | where: buildCategoryWhere(category, acceptLanguage), 39 | limit: category.limit, 40 | offset: category.offset, 41 | acceptLanguage, 42 | } 43 | : { acceptLanguage } 44 | return createApolloURL(apolloClient, defaultQuery, variables) 45 | } 46 | -------------------------------------------------------------------------------- /tests/e2e/pages/components/login-modal.ts: -------------------------------------------------------------------------------- 1 | import { Customer } from '../../types/types'; 2 | import { el } from '../utils/element'; 3 | 4 | class LoginModal { 5 | 6 | get container(): Cypress.Chainable { 7 | return el('login-modal', '.sf-modal__container'); 8 | } 9 | 10 | get email(): Cypress.Chainable { 11 | return el('login-modal-email'); 12 | } 13 | 14 | get firstName(): Cypress.Chainable { 15 | return el('login-modal-firstName'); 16 | } 17 | 18 | get lastName(): Cypress.Chainable { 19 | return el('login-modal-lastName'); 20 | } 21 | 22 | get password(): Cypress.Chainable { 23 | return el('login-modal-password'); 24 | } 25 | 26 | get iWantToCreateAccountCheckbox(): Cypress.Chainable { 27 | return el('login-modal-create-account'); 28 | } 29 | 30 | get submitButton(): Cypress.Chainable { 31 | return el('login-modal-submit'); 32 | } 33 | 34 | get loginToAccountButton(): Cypress.Chainable { 35 | return el('login-modal-login-to-your-account'); 36 | } 37 | 38 | get loginBtn(): Cypress.Chainable { 39 | return el('login-modal-submit'); 40 | } 41 | 42 | fillForm(customer: Customer): void { 43 | if (customer.email) this.email.type(customer.email); 44 | if (customer.firstName) this.firstName.type(customer.firstName); 45 | if (customer.lastName) this.lastName.type(customer.lastName); 46 | if (customer.password) this.password.type(customer.password); 47 | } 48 | 49 | } 50 | 51 | export default new LoginModal(); 52 | -------------------------------------------------------------------------------- /middleware.config.js: -------------------------------------------------------------------------------- 1 | require("dotenv"); 2 | 3 | module.exports = { 4 | integrations: { 5 | ct: { 6 | location: "@vue-storefront/commercetools-api/server", 7 | configuration: { 8 | api: { 9 | uri: "https://api.commercetools.com/vsf-ct-dev/graphql", 10 | authHost: "https://auth.sphere.io", 11 | projectKey: "vsf-ct-dev", 12 | clientId: "RT4iJGDbDzZe4b2E6RyeNe9s", 13 | clientSecret: "5eBt3yfZJWw1j7V6kXjfKXpuFP-YQXpg", 14 | scopes: [ 15 | "manage_products:vsf-ct-dev", 16 | "create_anonymous_token:vsf-ct-dev", 17 | "manage_my_profile:vsf-ct-dev", 18 | "manage_customer_groups:vsf-ct-dev", 19 | "view_categories:vsf-ct-dev", 20 | "introspect_oauth_tokens:vsf-ct-dev", 21 | "manage_my_payments:vsf-ct-dev", 22 | "manage_my_orders:vsf-ct-dev", 23 | "manage_my_shopping_lists:vsf-ct-dev", 24 | "view_published_products:vsf-ct-dev", 25 | "view_stores:vsf-ct-dev" 26 | ] 27 | // Using .env file 28 | // projectKey: process.env.CTP_PROJECT_KEY, 29 | // uri: process.env.CTP_API_URL, 30 | // authHost: process.env.CTP_AUTH_URL, 31 | // clientId: process.env.CTP_CLIENT_ID, 32 | // clientSecret: process.env.CTP_CLIENT_SECRET, 33 | // scopes: [ process.env.CTP_SCOPES ] 34 | }, 35 | currency: "USD", 36 | country: "US" 37 | } 38 | } 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /components/UserBillingAddress.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 54 | 55 | 63 | -------------------------------------------------------------------------------- /components/UserShippingAddress.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 55 | 56 | 64 | -------------------------------------------------------------------------------- /tests/e2e/fixtures/test-data/e2e-update-cart.json: -------------------------------------------------------------------------------- 1 | { 2 | "Should increase product quantity": { 3 | "products": [{ 4 | "name": "Shirt Aspesi white M", 5 | "id": 1, 6 | "sku": "M0E20000000ED0W", 7 | "quantity": 2, 8 | "size": 34 9 | }, 10 | { 11 | "name": "Shirt ”David” MU light blue", 12 | "id": 1, 13 | "sku": "M0E20000000DL5W", 14 | "quantity": 2, 15 | "size": 36 16 | } 17 | ], 18 | "productToUpdate": { 19 | "name": "Shirt ”David” MU light blue" 20 | }, 21 | "expectedCart": [{ 22 | "name": "Shirt Aspesi white M", 23 | "quantity": 2 24 | }, 25 | { 26 | "name": "Shirt ”David” MU light blue", 27 | "quantity": 3 28 | } 29 | ] 30 | }, 31 | "Should decrease product quantity": { 32 | "products": [{ 33 | "name": "Shirt Aspesi white M", 34 | "id": 1, 35 | "sku": "M0E20000000ED0W", 36 | "quantity": 2, 37 | "size": 34 38 | }, 39 | { 40 | "name": "Shirt ”David” MU light blue", 41 | "id": 1, 42 | "sku": "M0E20000000DL5W", 43 | "quantity": 2, 44 | "size": 36 45 | } 46 | ], 47 | "productToUpdate": { 48 | "name": "Shirt ”David” MU light blue" 49 | }, 50 | "expectedCart": [{ 51 | "name": "Shirt Aspesi white M", 52 | "quantity": 2 53 | }, 54 | { 55 | "name": "Shirt ”David” MU light blue", 56 | "quantity": 1 57 | } 58 | ] 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /layouts/blank.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | 15 | 78 | -------------------------------------------------------------------------------- /tests/e2e/integration/e2e-add-to-cart.spec.ts: -------------------------------------------------------------------------------- 1 | import page from '../pages/factory'; 2 | 3 | context('Add product to cart', () => { 4 | beforeEach(function () { 5 | cy.fixture('test-data/e2e-add-to-cart').then((fixture) => { 6 | this.fixtures = { 7 | data: fixture 8 | }; 9 | }); 10 | }); 11 | it(['regression'], 'Should successfully add product to cart - Category grid view', function() { 12 | const data = this.fixtures.data[this.test.title]; 13 | const category = page.category(data.product.category); 14 | category.visit(); 15 | category.addToCart(data.product.name); 16 | category.header.openCart(); 17 | page.components.cart.product(data.product.name).should('be.visible'); 18 | }); 19 | 20 | it(['regression'], 'Should successfully add product to cart - Category list view', function() { 21 | const data = this.fixtures.data[this.test.title]; 22 | const category = page.category(data.product.category); 23 | category.visit(); 24 | category.changeView('list'); 25 | category.addToCart(data.product.name); 26 | category.header.openCart(); 27 | page.components.cart.product(data.product.name).should('be.visible'); 28 | }); 29 | 30 | it(['regression'], 'Should successfully add product to cart - Product details page', function() { 31 | const data = this.fixtures.data[this.test.title]; 32 | page.product(data.product.id, data.product.slug).visit(); 33 | page.product().addToCartButton.click(); 34 | page.product().header.openCart(); 35 | page.components.cart.product(data.product.name).should('be.visible'); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /tests/e2e/integration/e2e-user-login.spec.ts: -------------------------------------------------------------------------------- 1 | import requests from '../api/requests'; 2 | import page from '../pages/factory'; 3 | import generator from '../utils/data-generator'; 4 | 5 | context(['regression'], 'User login', () => { 6 | beforeEach(function () { 7 | cy.fixture('test-data/e2e-user-login').then((fixture) => { 8 | this.fixtures = { 9 | data: fixture 10 | }; 11 | }); 12 | cy.clearLocalStorage(); 13 | }); 14 | 15 | it('Should successfully login', function() { 16 | const data = this.fixtures.data[this.test.title]; 17 | data.customer.email = generator.email; 18 | requests.customerSignMeUp(data.customer).then(() => { 19 | cy.clearCookies(); 20 | }); 21 | page.home.visit(); 22 | page.home.header.openLoginModal(); 23 | page.components.loginModal.loginToAccountButton.click(); 24 | page.components.loginModal.fillForm(data.customer); 25 | page.components.loginModal.loginBtn.click(); 26 | page.components.loginModal.container.should('not.exist'); 27 | page.home.header.account.click(); 28 | page.myAccount.sidebar.heading.should('be.visible'); 29 | }); 30 | 31 | it('Incorrect credentials - should display an error', function () { 32 | const data = this.fixtures.data[this.test.title]; 33 | data.customer.email = generator.email; 34 | page.home.visit(); 35 | page.home.header.openLoginModal(); 36 | page.components.loginModal.loginToAccountButton.click(); 37 | page.components.loginModal.fillForm(data.customer); 38 | page.components.loginModal.loginBtn.click(); 39 | page.components.loginModal.container.contains(data.errorMessage).should('be.visible'); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /tests/e2e/integration/e2e-user-registration.spec.ts: -------------------------------------------------------------------------------- 1 | import page from '../pages/factory'; 2 | import requests from '../api/requests'; 3 | import generator from '../utils/data-generator'; 4 | 5 | context(['regression'], 'User registration', () => { 6 | beforeEach(function () { 7 | cy.fixture('test-data/e2e-user-registration').then((fixture) => { 8 | this.fixtures = { 9 | data: fixture 10 | }; 11 | }); 12 | }); 13 | 14 | it('Should successfully register', function () { 15 | const data = this.fixtures.data[this.test.title]; 16 | data.customer.email = generator.email; 17 | page.home.visit(); 18 | page.home.header.openLoginModal(); 19 | page.components.loginModal.fillForm(data.customer); 20 | page.components.loginModal.iWantToCreateAccountCheckbox.click(); 21 | page.components.loginModal.submitButton.click(); 22 | page.components.loginModal.container.should('not.exist'); 23 | page.home.header.account.click(); 24 | page.myAccount.sidebar.heading.should('be.visible'); 25 | }); 26 | 27 | it('Existing user - should display an error', function () { 28 | const data = this.fixtures.data[this.test.title]; 29 | data.customer.email = generator.email; 30 | requests.customerSignMeUp(data.customer).then(() => { 31 | cy.clearCookies(); 32 | }); 33 | page.home.visit(); 34 | page.home.header.openLoginModal(); 35 | page.components.loginModal.fillForm(data.customer); 36 | page.components.loginModal.iWantToCreateAccountCheckbox.click(); 37 | page.components.loginModal.submitButton.click(); 38 | page.components.loginModal.container.contains(`${data.errorMessage} '"${data.customer.email}"'`).should('be.visible'); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /tests/e2e/pages/category.ts: -------------------------------------------------------------------------------- 1 | import { el } from './utils/element'; 2 | import Base from './base'; 3 | 4 | type View = 'tiles' | 'list' 5 | 6 | export class Category extends Base { 7 | 8 | private readonly _category: string; 9 | private _view: View = 'tiles'; 10 | 11 | constructor(category?: string) { 12 | super(); 13 | if (category) this._category = category; 14 | } 15 | 16 | set view(view: View) { 17 | this._view = view; 18 | } 19 | 20 | get view(): View { 21 | return this._view; 22 | } 23 | 24 | get category(): string { 25 | return this._category; 26 | } 27 | 28 | get path(): string { 29 | return `/c/${this.category}`; 30 | } 31 | 32 | get products(): Cypress.Chainable { 33 | return el('category-product-card', 'a'); 34 | } 35 | 36 | get selectors(): { [key: string]: { [key in View]: string } } { 37 | return { 38 | productCard: { 39 | tiles: '.sf-product-card', 40 | list: '.sf-product-card-horizontal' 41 | }, 42 | addToCardButton: { 43 | tiles: '.sf-product-card__add-button', 44 | list: '.sf-add-to-cart__button' 45 | } 46 | }; 47 | } 48 | 49 | viewIcon(view: View): Cypress.Chainable { 50 | return el(`${view}-icon`); 51 | } 52 | 53 | changeView(view: View): Cypress.Chainable { 54 | this.view = view; 55 | return this.viewIcon(view).click(); 56 | } 57 | 58 | product(name: string): Cypress.Chainable { 59 | return this.products.contains(name); 60 | } 61 | 62 | addToCart(name: string): Cypress.Chainable { 63 | return this.product(name).parents(this.selectors.productCard[this.view]) 64 | .find(this.selectors.addToCardButton[this.view]) 65 | .click(); 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /tests/e2e/integration/e2e-remove-from-cart.spec.ts: -------------------------------------------------------------------------------- 1 | import requests, { CreateCartResponse } from '../api/requests'; 2 | import page from '../pages/factory'; 3 | 4 | context('Remove from cart', () => { 5 | beforeEach(function () { 6 | cy.fixture('test-data/e2e-remove-from-cart').then((fixture) => { 7 | this.fixtures = { 8 | data: fixture 9 | }; 10 | }); 11 | }); 12 | 13 | it('Should remove all products from cart', function () { 14 | const data = this.fixtures.data[this.test.title]; 15 | requests.createCart().then((response: CreateCartResponse) => { 16 | requests.addToCart(response.body.data.cart.id, data.product, data.product.quantity); 17 | }); 18 | page.home.visit(); 19 | page.home.header.openCart(); 20 | page.components.cart.product(data.product.name).should('be.visible'); 21 | page.components.cart.removeProduct(data.product.name); 22 | page.components.cart.product(data.product.name).should('not.exist'); 23 | page.components.cart.yourCartIsEmptyHeading.should('be.visible'); 24 | }); 25 | 26 | it('Should remove single product from cart', function () { 27 | const data = this.fixtures.data[this.test.title]; 28 | requests.createCart().then((response: CreateCartResponse) => { 29 | data.products.forEach(product => { 30 | requests.addToCart(response.body.data.cart.id, product, product.quantity); 31 | }); 32 | }); 33 | page.home.visit(); 34 | page.home.header.openCart(); 35 | page.components.cart.product(data.productToRemove.name).should('be.visible'); 36 | page.components.cart.removeProduct(data.productToRemove.name); 37 | page.components.cart.product(data.productToRemove.name).should('not.exist'); 38 | data.expectedCart.forEach(product => { 39 | page.components.cart.product(product.name).should('be.visible'); 40 | }); 41 | }); 42 | 43 | }); 44 | -------------------------------------------------------------------------------- /components/RelatedProducts.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 51 | 52 | 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # TypeScript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | # parcel-bundler cache (https://parceljs.org/) 63 | .cache 64 | 65 | # next.js build output 66 | .next 67 | 68 | # nuxt.js build output 69 | .nuxt 70 | 71 | # theme 72 | _theme 73 | 74 | # Nuxt generate 75 | dist 76 | 77 | # vuepress build output 78 | .vuepress/dist 79 | 80 | # Serverless directories 81 | .serverless 82 | 83 | # IDE / Editor 84 | .idea 85 | 86 | # Service worker 87 | sw.* 88 | 89 | # Mac OSX 90 | .DS_Store 91 | 92 | # Vim swap files 93 | *.swp 94 | 95 | version 96 | 97 | # e2e reports 98 | tests/e2e/report.json 99 | tests/e2e/report 100 | # Edgio generated build directory 101 | .edgio 102 | -------------------------------------------------------------------------------- /components/HeaderNavigation.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 59 | 60 | 75 | -------------------------------------------------------------------------------- /tests/e2e/fixtures/test-data/e2e-place-order.json: -------------------------------------------------------------------------------- 1 | { 2 | "Should successfully place an order as a guest": { 3 | "customer": { 4 | "address": { 5 | "shipping": { 6 | "firstName": "John", 7 | "lastName": "Doe", 8 | "streetName": "1 VueStorefront Rd.", 9 | "apartment": "23", 10 | "city": "Los Angeles", 11 | "state": "California", 12 | "country": "United States", 13 | "postalCode": "90210", 14 | "phone": "+12345678910" 15 | }, 16 | "billing": { 17 | "firstName": "John", 18 | "lastName": "Doe", 19 | "streetName": "1 VueStorefront Rd.", 20 | "apartment": "23", 21 | "city": "Los Angeles", 22 | "state": "California", 23 | "country": "United States", 24 | "postalCode": "90210", 25 | "phone": "+12345678910" 26 | } 27 | } 28 | } 29 | }, 30 | "Should successfully place an order as a registered customer": { 31 | "customer": { 32 | "firstName": "John", 33 | "lastName": "Doe", 34 | "password": "P@ssw0rd", 35 | "address": { 36 | "shipping": { 37 | "firstName": "John", 38 | "lastName": "Doe", 39 | "streetName": "1 VueStorefront Rd.", 40 | "apartment": "23", 41 | "city": "Los Angeles", 42 | "state": "California", 43 | "country": "United States", 44 | "postalCode": "90210", 45 | "phone": "+12345678910" 46 | }, 47 | "billing": { 48 | "firstName": "John", 49 | "lastName": "Doe", 50 | "streetName": "1 VueStorefront Rd.", 51 | "apartment": "23", 52 | "city": "Los Angeles", 53 | "state": "California", 54 | "country": "United States", 55 | "postalCode": "90210", 56 | "phone": "+12345678910" 57 | } 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /components/Checkout/VsfPaymentProvider.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 62 | 63 | 76 | -------------------------------------------------------------------------------- /routes.ts: -------------------------------------------------------------------------------- 1 | // This file was added by edgio init. 2 | // You should commit this file to source control. 3 | 4 | import { Router, ResponseWriter } from "@edgio/core/router"; 5 | import { CacheOptions } from "@edgio/core/router/CacheOptions"; 6 | import { nuxtRoutes, renderNuxtPage } from "@edgio/nuxt"; 7 | import { decompressRequest } from "@edgio/apollo"; 8 | 9 | const HTML: CacheOptions = { 10 | edge: { 11 | maxAgeSeconds: 60 * 60 * 24, 12 | staleWhileRevalidateSeconds: 60 * 60 * 24, 13 | forcePrivateCaching: true 14 | }, 15 | browser: false 16 | }; 17 | 18 | const APICacheOptions: CacheOptions = { 19 | edge: { 20 | maxAgeSeconds: 60 * 60 * 24, 21 | staleWhileRevalidateSeconds: 60 * 60 * 24, 22 | forcePrivateCaching: true 23 | }, 24 | browser: { 25 | maxAgeSeconds: 0, 26 | serviceWorkerSeconds: 60 * 60 * 24 27 | } 28 | }; 29 | 30 | function cacheHTML({ cache, removeUpstreamResponseHeader }: ResponseWriter) { 31 | removeUpstreamResponseHeader("set-cookie"); 32 | cache(HTML); 33 | } 34 | 35 | function cacheAPI({ cache, removeUpstreamResponseHeader }: ResponseWriter) { 36 | removeUpstreamResponseHeader("set-cookie"); 37 | cache(API); 38 | } 39 | 40 | export default new Router() 41 | .match("/service-worker.js", ({ serviceWorker }) => { 42 | serviceWorker(".nuxt/dist/client/service-worker.js"); 43 | }) 44 | .get("/", cacheHTML) 45 | .get("/c/:slug*", cacheHTML) 46 | .get("/p/:slug*", cacheHTML) 47 | .post("/api/ct/getCategory", cacheAPI) 48 | .post("/api/ct/getProduct", cacheAPI) 49 | // @ts-ignore 50 | .post("/:env/graphql", ({ proxy }) => { 51 | proxy("api"); 52 | }) 53 | .get( 54 | { 55 | path: "/:env/graphql" 56 | }, 57 | // @ts-ignore 58 | ({ proxy, cache, removeUpstreamResponseHeader }) => { 59 | cache(APICacheOptions); 60 | proxy("api", { 61 | transformRequest: decompressRequest 62 | }); 63 | removeUpstreamResponseHeader("cache-control"); 64 | } 65 | ) 66 | .use(nuxtRoutes) 67 | .fallback(renderNuxtPage); 68 | -------------------------------------------------------------------------------- /tests/e2e/integration/e2e-product-page.spec.ts: -------------------------------------------------------------------------------- 1 | import page from '../pages/factory'; 2 | import intercept from '../utils/network'; 3 | 4 | context(['regression'], 'Product page', () => { 5 | beforeEach(function () { 6 | cy.fixture('test-data/e2e-product-page').then((fixture) => { 7 | this.fixtures = { 8 | data: fixture 9 | }; 10 | }); 11 | cy.clearLocalStorage(); 12 | }); 13 | 14 | it('Should contain all size options', function () { 15 | const data = this.fixtures.data[this.test.title]; 16 | page.product(data.product.id, data.product.slug).visit(); 17 | page.product().sizeOptions.then(options => { 18 | const productSizes = [...options].map(option => option.value); 19 | expect(productSizes).to.deep.eq(data.product.attributes.size); 20 | }); 21 | }); 22 | 23 | it('Should select correct size option', function () { 24 | const data = this.fixtures.data[this.test.title]; 25 | page.product(data.product.id, data.product.slug).visit(); 26 | page.product().sizeSelect.select(data.product.attributes.size); 27 | cy.url().should('contain', `size=${data.product.attributes.size}`); 28 | page.product().sizeSelect.should('have.value', data.product.attributes.size); 29 | }); 30 | 31 | it('Should add correct variant to cart', function() { 32 | const data = this.fixtures.data[this.test.title]; 33 | const getProductReq = intercept.getProduct(); 34 | page.product(data.product.id, data.product.slug).visit(); 35 | page.product().sizeSelect.select(data.product.attributes.size).then(() => { 36 | cy.wait(getProductReq); 37 | }); 38 | page.product().addToCartButton.click(); 39 | page.product().header.openCart(); 40 | page.components.cart.productProperties.should('be.visible').then(() => { 41 | page.components.cart.product().each((product) => { 42 | page.components.cart.productSizeProperty(product).should('contain', data.product.attributes.size); 43 | page.components.cart.productColorProperty(product).should('contain', data.product.attributes.color); 44 | }); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /tests/e2e/pages/components/cart-sidebar.ts: -------------------------------------------------------------------------------- 1 | import { el } from '../utils/element'; 2 | 3 | class Cart { 4 | 5 | product(name?: string): Cypress.Chainable { 6 | const product = el('collected-product'); 7 | return name === undefined ? product : product.contains(name).parents('[data-e2e="collected-product"]'); 8 | } 9 | 10 | get productName(): Cypress.Chainable { 11 | return this.product().find('.sf-collected-product__title'); 12 | } 13 | 14 | get goToCheckoutButton(): Cypress.Chainable { 15 | return el('go-to-checkout-btn'); 16 | } 17 | 18 | get productProperties(): Cypress.Chainable { 19 | return this.product().find('.collected-product__properties'); 20 | } 21 | 22 | get totalItems(): Cypress.Chainable { 23 | return el('sidebar-cart', '.cart-summary .sf-property__value'); 24 | } 25 | 26 | get yourCartIsEmptyHeading(): Cypress.Chainable { 27 | return el('sidebar-cart', 'h2').contains('Your cart is empty'); 28 | } 29 | 30 | propertyValue(collectedProduct: JQuery): Cypress.Chainable { 31 | return cy.wrap(collectedProduct).find('.sf-property__value'); 32 | } 33 | 34 | productSizeProperty(collectedProduct: JQuery): Cypress.Chainable { 35 | return this.propertyValue(collectedProduct).eq(0); 36 | } 37 | 38 | productColorProperty(collectedProduct: JQuery): Cypress.Chainable { 39 | return this.propertyValue(collectedProduct).eq(1); 40 | } 41 | 42 | removeProductButton(name?: string): Cypress.Chainable { 43 | return this.product(name).find('.sf-collected-product__remove'); 44 | } 45 | 46 | removeProduct(name?: string): Cypress.Chainable { 47 | return this.removeProductButton(name).first().click(); 48 | } 49 | 50 | increaseQtyButton(name?: string): Cypress.Chainable { 51 | return this.product(name).find('button').contains('+'); 52 | } 53 | 54 | decreaseQtyButton(name?: string): Cypress.Chainable { 55 | return this.product(name).find('button').contains('−'); 56 | } 57 | 58 | quantity(name?: string): Cypress.Chainable { 59 | return this.product(name).find('input'); 60 | } 61 | } 62 | 63 | export default new Cart(); 64 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "edgio:dev": "edgio dev", 4 | "edgio:build": "YALC=true edgio build", 5 | "edgio:deploy": "edgio deploy", 6 | "dev": "nuxt", 7 | "build": "nuxt build -m", 8 | "build:analyze": "nuxt build -a -m", 9 | "start": "nuxt start", 10 | "test": "jest", 11 | "test:e2e": "cypress open --config-file tests/e2e/cypress.json", 12 | "test:e2e:hl": "cypress run --headless --config-file tests/e2e/cypress.json", 13 | "test:e2e:generate:report": "yarn -s mochawesome-merge \"tests/e2e/report/*.json\" > \"tests/e2e/report.json\" && yarn -s marge tests/e2e/report.json -o \"tests/e2e/report\"" 14 | }, 15 | "name": "edgio-vue-storefront-commercetools-example", 16 | "description": "Vue Storefront with CommerceTools", 17 | "author": "Edgio", 18 | "version": "1.3.0", 19 | "license": "UNLICENSED", 20 | "publishConfig": { 21 | "access": "public" 22 | }, 23 | "dependencies": { 24 | "@nuxt/core": "^2.13.3", 25 | "@vue-storefront/commercetools": "^1.3.3", 26 | "@vue-storefront/middleware": "^2.4.3", 27 | "cookie-universal-nuxt": "^2.1.3", 28 | "dotenv": "^10.0.0", 29 | "nuxt-i18n": "^6.5.0" 30 | }, 31 | "devDependencies": { 32 | "@edgio/apollo": "^5.0.4", 33 | "@edgio/cli": "^5.0.4", 34 | "@edgio/core": "^5.0.4", 35 | "@edgio/devtools": "^5.0.4", 36 | "@edgio/nuxt": "^5.0.4", 37 | "@edgio/prefetch": "^5.0.4", 38 | "@edgio/vue": "^5.0.4", 39 | "@edgio/vue-storefront": "^5.0.4", 40 | "@nuxt/types": "^0.7.9", 41 | "@nuxtjs/pwa": "^3.2.2", 42 | "@storefront-ui/vue": "0.10.3", 43 | "@vue-storefront/nuxt": "~2.4.0", 44 | "@vue-storefront/nuxt-theme": "~2.4.0", 45 | "@vue/test-utils": "^1.0.0-beta.27", 46 | "awesome-phonenumber": "^2.51.2", 47 | "babel-jest": "^24.1.0", 48 | "core-js": "^2.6.5", 49 | "cypress": "^6.6.0", 50 | "cypress-pipe": "^2.0.0", 51 | "cypress-tags": "^0.0.20", 52 | "jest": "^24.1.0", 53 | "mochawesome": "^6.2.2", 54 | "mochawesome-merge": "^4.2.0", 55 | "mochawesome-report-generator": "^5.2.0", 56 | "nuxt": "^2.13.3", 57 | "vee-validate": "^3.2.3", 58 | "vue-jest": "^4.0.0-0", 59 | "vue-scrollto": "^2.17.1" 60 | }, 61 | "repository": "git@github.com:edgio-docs/edgio-vue-storefront-commercetools-example.git" 62 | } -------------------------------------------------------------------------------- /components/MobileStoreBanner.vue: -------------------------------------------------------------------------------- 1 | 34 | 49 | 82 | -------------------------------------------------------------------------------- /composables/useUiState.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import VueCompositionAPI, { reactive, computed } from '@vue/composition-api'; 3 | 4 | // We need to register it again because of Vue instance instantiation issues 5 | Vue.use(VueCompositionAPI); 6 | 7 | const state = reactive({ 8 | isCartSidebarOpen: false, 9 | isWishlistSidebarOpen: false, 10 | isLoginModalOpen: false, 11 | isCategoryGridView: true, 12 | isFilterSidebarOpen: false, 13 | isMobileMenuOpen: false 14 | }); 15 | 16 | const useUiState = () => { 17 | const isMobileMenuOpen = computed(() => state.isMobileMenuOpen); 18 | const toggleMobileMenu = () => { 19 | state.isMobileMenuOpen = !state.isMobileMenuOpen; 20 | }; 21 | 22 | const isCartSidebarOpen = computed(() => state.isCartSidebarOpen); 23 | const toggleCartSidebar = () => { 24 | if (state.isMobileMenuOpen) toggleMobileMenu(); 25 | state.isCartSidebarOpen = !state.isCartSidebarOpen; 26 | }; 27 | 28 | const isWishlistSidebarOpen = computed(() => state.isWishlistSidebarOpen); 29 | const toggleWishlistSidebar = () => { 30 | if (state.isMobileMenuOpen) toggleMobileMenu(); 31 | state.isWishlistSidebarOpen = !state.isWishlistSidebarOpen; 32 | }; 33 | 34 | const isLoginModalOpen = computed(() => state.isLoginModalOpen); 35 | const toggleLoginModal = () => { 36 | if (state.isMobileMenuOpen) toggleMobileMenu(); 37 | state.isLoginModalOpen = !state.isLoginModalOpen; 38 | }; 39 | 40 | const isCategoryGridView = computed(() => state.isCategoryGridView); 41 | const changeToCategoryGridView = () => { 42 | state.isCategoryGridView = true; 43 | }; 44 | const changeToCategoryListView = () => { 45 | state.isCategoryGridView = false; 46 | }; 47 | 48 | const isFilterSidebarOpen = computed(() => state.isFilterSidebarOpen); 49 | const toggleFilterSidebar = () => { 50 | state.isFilterSidebarOpen = !state.isFilterSidebarOpen; 51 | }; 52 | 53 | return { 54 | isCartSidebarOpen, 55 | isWishlistSidebarOpen, 56 | isLoginModalOpen, 57 | isCategoryGridView, 58 | isFilterSidebarOpen, 59 | isMobileMenuOpen, 60 | toggleCartSidebar, 61 | toggleWishlistSidebar, 62 | toggleLoginModal, 63 | changeToCategoryGridView, 64 | changeToCategoryListView, 65 | toggleFilterSidebar, 66 | toggleMobileMenu 67 | }; 68 | }; 69 | 70 | export default useUiState; 71 | -------------------------------------------------------------------------------- /components/BottomNavigation.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 66 | 71 | -------------------------------------------------------------------------------- /tests/e2e/integration/e2e-update-cart.spec.ts: -------------------------------------------------------------------------------- 1 | import requests, { CreateCartResponse } from '../api/requests'; 2 | import page from '../pages/factory'; 3 | import intercept from '../utils/network'; 4 | 5 | context(['regression'], 'Update cart', () => { 6 | beforeEach(function () { 7 | cy.fixture('test-data/e2e-update-cart').then((fixture) => { 8 | this.fixtures = { 9 | data: fixture 10 | }; 11 | }); 12 | }); 13 | 14 | it('Should increase product quantity', function () { 15 | const data = this.fixtures.data[this.test.title]; 16 | requests.createCart().then((response: CreateCartResponse) => { 17 | data.products.forEach(product => { 18 | requests.addToCart(response.body.data.cart.id, product, product.quantity); 19 | }); 20 | }); 21 | page.home.visit(); 22 | page.home.header.openCart(); 23 | page.components.cart.product(data.productToUpdate.name).should('be.visible'); 24 | const updateCartRequest = intercept.updateCartQuantity(); 25 | page.components.cart.increaseQtyButton(data.productToUpdate.name).click().then(() => { 26 | cy.wait(updateCartRequest); 27 | }); 28 | page.components.cart.productName.each((name, index) => { 29 | cy.wrap(name).should('contain', data.expectedCart[index].name); 30 | }); 31 | page.components.cart.quantity().each((input, index) => { 32 | cy.wrap(input).should('have.value', data.expectedCart[index].quantity); 33 | }); 34 | }); 35 | 36 | it('Should decrease product quantity', function () { 37 | const data = this.fixtures.data[this.test.title]; 38 | requests.createCart().then((response: CreateCartResponse) => { 39 | data.products.forEach(product => { 40 | requests.addToCart(response.body.data.cart.id, product, product.quantity); 41 | }); 42 | }); 43 | page.home.visit(); 44 | page.home.header.openCart(); 45 | page.components.cart.product(data.productToUpdate.name).should('be.visible'); 46 | const updateCartRequest = intercept.updateCartQuantity(); 47 | page.components.cart.decreaseQtyButton(data.productToUpdate.name).click().then(() => { 48 | cy.wait(updateCartRequest); 49 | }); 50 | page.components.cart.productName.each((name, index) => { 51 | cy.wrap(name).should('contain', data.expectedCart[index].name); 52 | }); 53 | page.components.cart.quantity().each((input, index) => { 54 | cy.wrap(input).should('have.value', data.expectedCart[index].quantity); 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /tests/e2e/fixtures/test-data/e2e-checkout-order-summary.json: -------------------------------------------------------------------------------- 1 | { 2 | "Should contain correct data in Order Summary": { 3 | "products": [{ 4 | "name": "Shirt Aspesi white M", 5 | "id": 1, 6 | "sku": "M0E20000000ED0W", 7 | "size": 34, 8 | "quantity": 3 9 | }, 10 | { 11 | "name": "Shirt Aspesi white M", 12 | "id": 1, 13 | "sku": "M0E20000000ED0X", 14 | "size": 36, 15 | "quantity": 1 16 | }, 17 | { 18 | "name": "T-Shirt Moschino Cheap And Chic black", 19 | "id": 1, 20 | "sku": "M0E20000000DLPH", 21 | "size": 34, 22 | "quantity": 1 23 | } 24 | ], 25 | "customer": { 26 | "address": { 27 | "shipping": { 28 | "firstName": "Jane", 29 | "lastName": "Doe", 30 | "streetName": "Vuestorefront Rd.", 31 | "apartment": "13/37", 32 | "city": "Los Angeles", 33 | "state": "California", 34 | "country": "US", 35 | "postalCode": "90001", 36 | "phone": "+12345678910" 37 | }, 38 | "billing": { 39 | "firstName": "Jane", 40 | "lastName": "Doe", 41 | "streetName": "Vuestorefront Rd.", 42 | "apartment": "13/37", 43 | "city": "Los Angeles", 44 | "state": "California", 45 | "country": "US", 46 | "postalCode": "90001", 47 | "phone": "+12345678910" 48 | } 49 | } 50 | }, 51 | "shippingMethod": "Express US", 52 | "expectedCartSummary": { 53 | "products": [{ 54 | "name": "Shirt Aspesi white M", 55 | "id": 1, 56 | "sku": "M0E20000000ED0W", 57 | "size": 34, 58 | "color": "white", 59 | "quantity": 3, 60 | "amount": "$359.10" 61 | }, 62 | { 63 | "name": "Shirt Aspesi white M", 64 | "id": 1, 65 | "sku": "M0E20000000ED0X", 66 | "size": 36, 67 | "color": "white", 68 | "quantity": 1, 69 | "amount": "$166.25" 70 | }, 71 | { 72 | "name": "T-Shirt Moschino Cheap And Chic black", 73 | "id": 1, 74 | "sku": "M0E20000000DLPH", 75 | "size": 34, 76 | "color": "black", 77 | "quantity": 1, 78 | "amount": "$372.50" 79 | } 80 | ], 81 | "discountedPrice": "$1,037.50", 82 | "specialPrice": "$897.85", 83 | "totalPrice": "$907.85" 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /components/Notification.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 39 | 40 | 95 | -------------------------------------------------------------------------------- /components/Checkout/UserShippingAddresses.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 72 | 73 | 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vue Storefront Layer0 Example 2 | 3 | This Vue Storefront app uses CommerceTools and is configured to deploy on Layer0. 4 | 5 | ## Demo 6 | 7 | https://layer0-docs-layer0-vue-storefront-commercetools-example-default.layer0.link/ 8 | 9 | ## Try It Now 10 | 11 | [![Deploy with Layer0](https://docs.layer0.co/button.svg)](https://app.layer0.co/deploy?repo=https://github.com/layer0-docs/layer0-vue-storefront-commercetools-example) 12 | 13 | ## Getting Started 14 | 15 | ### Clone This Repo 16 | 17 | Use `git clone https://github.com/layer0-docs/layer0-vue-storefront-commercetools-example.git` to get the files within this repository onto your local machine. 18 | 19 | ### Install dependencies 20 | 21 | On the command line, in the project root directory, run the following command: 22 | 23 | ```bash 24 | yarn install 25 | ``` 26 | 27 | ### Update CommerceTools credentials 28 | 29 | ### Run the Vue StoreFront app locally on Layer0 30 | 31 | In `middleware.config.js` update the CommerceTools API settings to reflect your own values. The values currently in there are for a test store run by Layer0. 32 | 33 | We recommend that you leverage a `.env` file to inject your values to the `middleware.config.js`. Create a `.env` file from the `.env-example` file. 34 | 35 | To create a new API Client, log into your CommerceTools instance and go to `Settings > Developer settings > Create new API Client`. Enter a name for the API Client and select the "Mobile & Single-page PWA client" template. Make sure to also check the "View > Stores" option. Sometimes the templates change the auto-checked items, so double check your permissions match that of the current middleware file. 36 | 37 | Run the Vue Storefront app with the command: 38 | 39 | ```bash 40 | yarn run layer0:dev 41 | ``` 42 | 43 | Load the site: http://127.0.0.1:3000 44 | 45 | ### Testing production build locally with Layer0 46 | 47 | You can do a production build of your app and test it locally using: 48 | 49 | ```bash 50 | layer0 build && layer0 run --production 51 | ``` 52 | 53 | Setting --production runs your app exactly as it will be uploaded to the Layer0 cloud using serverless-offline. 54 | 55 | ## Deploying to Layer0 56 | 57 | Deploying requires an account on Layer0. [Sign up here for free](https://app.layer0.co/signup). Once you have an account, you can deploy to Layer0 by running the following in the root folder of your project: 58 | 59 | ```bash 60 | layer0 deploy 61 | ``` 62 | 63 | Automate deployments using a [Github Action](https://docs.layer0.co/guides/deploying#section_github_actions). 64 | 65 | See [deploying](https://docs.layer0.co/guides/deploying) for more information. 66 | -------------------------------------------------------------------------------- /layouts/error.vue: -------------------------------------------------------------------------------- 1 | 24 | 34 | 92 | -------------------------------------------------------------------------------- /pages/MyAccount/MyNewsletter.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 54 | 55 | 100 | -------------------------------------------------------------------------------- /components/LocaleSelector.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 59 | 60 | 101 | -------------------------------------------------------------------------------- /pages/Checkout.vue: -------------------------------------------------------------------------------- 1 | 32 | 71 | 72 | 108 | -------------------------------------------------------------------------------- /layouts/default.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 54 | 55 | 118 | -------------------------------------------------------------------------------- /tests/e2e/fixtures/test-data/e2e-carts-merging.json: -------------------------------------------------------------------------------- 1 | { 2 | "Should merge guest cart with registered customer cart": { 3 | "customer": { 4 | "password": "Vu3S70r3fr0n7!" 5 | }, 6 | "products": [{ 7 | "name": "Shirt Aspesi white M", 8 | "id": 1, 9 | "sku": "M0E20000000ED0W", 10 | "quantity": 1, 11 | "size": 34 12 | }, 13 | { 14 | "name": "Shirt Aspesi white M", 15 | "id": 2, 16 | "sku": "M0E20000000ED0X", 17 | "quantity": 1, 18 | "size": 36 19 | }, 20 | { 21 | "name": "Shirt ”David” MU light blue", 22 | "id": 1, 23 | "sku": "M0E20000000DL5W", 24 | "quantity": 2, 25 | "size": 36 26 | } 27 | ], 28 | "expectedCart": [{ 29 | "name": "Shirt Aspesi white M", 30 | "quantity": 1, 31 | "size": 34, 32 | "color": "white" 33 | }, 34 | { 35 | "name": "Shirt Aspesi white M", 36 | "quantity": 1, 37 | "size": 36, 38 | "color": "white" 39 | }, 40 | { 41 | "name": "Shirt ”David” MU light blue", 42 | "quantity": 2, 43 | "size": 36, 44 | "color": "blue" 45 | } 46 | ] 47 | }, 48 | 49 | "Should merge guest cart with registered customer cart - products already in cart": { 50 | "customer": { 51 | "password": "Vu3S70r3fr0n7!" 52 | }, 53 | "products": { 54 | "guest": [{ 55 | "name": "Shirt Aspesi white M", 56 | "id": 1, 57 | "sku": "M0E20000000ED0W", 58 | "quantity": 1, 59 | "size": 34 60 | }, 61 | { 62 | "name": "Shirt Aspesi white M", 63 | "id": 2, 64 | "sku": "M0E20000000ED0X", 65 | "quantity": 1, 66 | "size": 36 67 | }, 68 | { 69 | "name": "Shirt ”David” MU light blue", 70 | "id": 1, 71 | "sku": "M0E20000000DL5W", 72 | "quantity": 2, 73 | "size": 36 74 | } 75 | ], 76 | "customer": [{ 77 | "name": "Sweater Polo Ralph Lauren pink", 78 | "id": 4, 79 | "sku": "M0E20000000E2X0", 80 | "quantity": 3, 81 | "size": "M" 82 | }] 83 | }, 84 | "expectedCart": [{ 85 | "name": "Sweater Polo Ralph Lauren pink", 86 | "quantity": 3, 87 | "size": "M", 88 | "color": "pink" 89 | }, { 90 | "name": "Shirt Aspesi white M", 91 | "quantity": 1, 92 | "size": 34, 93 | "color": "white" 94 | }, 95 | { 96 | "name": "Shirt Aspesi white M", 97 | "quantity": 1, 98 | "size": 36, 99 | "color": "white" 100 | }, 101 | { 102 | "name": "Shirt ”David” MU light blue", 103 | "quantity": 2, 104 | "size": 36, 105 | "color": "blue" 106 | } 107 | ] 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /tests/e2e/integration/e2e-checkout-order-summary.spec.ts: -------------------------------------------------------------------------------- 1 | import requests, { CreateCartResponse, GetShippingMethodsResponse } from '../api/requests'; 2 | import page from '../pages/factory'; 3 | 4 | context([], 'Checkout - Order Summary', () => { 5 | beforeEach(function () { 6 | cy.fixture('test-data/e2e-checkout-order-summary').then((fixture) => { 7 | this.fixtures = { 8 | data: fixture 9 | }; 10 | }); 11 | }); 12 | 13 | it('Should contain correct data in Order Summary', function () { 14 | const data = this.fixtures.data[this.test.title]; 15 | requests.createCart().then((response: CreateCartResponse) => { 16 | data.products.forEach((product) => { 17 | requests.addToCart(response.body.data.cart.id, product, product.quantity); 18 | }); 19 | requests.updateCart(response.body.data.cart.id, { addresses: { shipping: data.customer.address.shipping }}); 20 | requests.getShippingMethods(response.body.data.cart.id).then((res: GetShippingMethodsResponse) => { 21 | const shippingMethod = res.body.data.shippingMethods.find((method) => { 22 | return method.name === data.shippingMethod; 23 | }); 24 | requests.updateCart(response.body.data.cart.id, { shippingMethodId: shippingMethod.id }); 25 | }); 26 | requests.updateCart(response.body.data.cart.id, { addresses: { billing: data.customer.address.billing }}); 27 | }); 28 | page.checkout.payment.visit(); 29 | page.checkout.payment.productRow.each((row, index) => { 30 | cy.wrap(row).within(() => { 31 | page.checkout.payment.productTitleSku.within(() => { 32 | cy.contains(data.expectedCartSummary.products[index].name).should('be.visible'); 33 | }); 34 | page.checkout.payment.productTitleSku.within(() => { 35 | cy.contains(data.expectedCartSummary.products[index].sku).should('be.visible'); 36 | }); 37 | page.checkout.payment.productAttributes.within(() => { 38 | cy.contains(data.expectedCartSummary.products[index].size).should('be.visible'); 39 | }); 40 | page.checkout.payment.productAttributes.within(() => { 41 | cy.contains(data.expectedCartSummary.products[index].color).should('be.visible'); 42 | }); 43 | page.checkout.payment.productQuantity.within(() => { 44 | cy.contains(data.expectedCartSummary.products[index].quantity).should('be.visible'); 45 | }); 46 | page.checkout.payment.productPrice.within(() => { 47 | cy.contains(data.expectedCartSummary.products[index].amount).should('be.visible'); 48 | }); 49 | }); 50 | }); 51 | page.checkout.payment.discountedPrice.within(() => { 52 | cy.contains(data.expectedCartSummary.discountedPrice).should('be.visible'); 53 | }); 54 | page.checkout.payment.specialPrice.within(() => { 55 | cy.contains(data.expectedCartSummary.specialPrice).should('be.visible'); 56 | }); 57 | page.checkout.payment.totalPrice.within(() => { 58 | cy.contains(data.expectedCartSummary.totalPrice).should('be.visible'); 59 | }); 60 | }); 61 | 62 | }); 63 | -------------------------------------------------------------------------------- /components/Checkout/UserBillingAddresses.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 79 | 80 | 99 | -------------------------------------------------------------------------------- /tests/e2e/integration/e2e-place-order.spec.ts: -------------------------------------------------------------------------------- 1 | import page from '../pages/factory'; 2 | import generator from '../utils/data-generator'; 3 | import intercept from '../utils/network'; 4 | 5 | context('Order placement', () => { 6 | beforeEach(function () { 7 | cy.fixture('test-data/e2e-place-order').then((fixture) => { 8 | this.fixtures = { 9 | data: fixture 10 | }; 11 | }); 12 | }); 13 | 14 | it(['happypath', 'regression'], 'Should successfully place an order as a guest', function() { 15 | const data = this.fixtures.data[this.test.title]; 16 | const getProductReq = intercept.getProduct(); 17 | page.home.visit(); 18 | page.home.header.categories.first().click(); 19 | page.category().products.first().click().then(() => { 20 | cy.wait([getProductReq, getProductReq]); 21 | }); 22 | page.product().addToCartButton.click(); 23 | page.product().header.openCart(); 24 | page.components.cart.goToCheckoutButton.click(); 25 | page.checkout.shipping.heading.should('be.visible'); 26 | page.checkout.shipping.fillForm(data.customer); 27 | page.checkout.shipping.selectShippingButton.click(); 28 | page.checkout.shipping.shippingMethods.first().click(); 29 | page.checkout.shipping.continueToBillingButton.click(); 30 | page.checkout.billing.heading.should('be.visible'); 31 | page.checkout.billing.copyAddressLabel.click(); 32 | page.checkout.billing.continueToPaymentButton.click(); 33 | page.checkout.payment.paymentMethods.first().click(); 34 | page.checkout.payment.terms.click(); 35 | page.checkout.payment.makeAnOrderButton.click(); 36 | page.checkout.thankyou.heading.should('be.visible'); 37 | }); 38 | 39 | it(['happypath', 'regression'], 'Should successfully place an order as a registered customer', function() { 40 | const data = this.fixtures.data[this.test.title]; 41 | const getProductReq = intercept.getProduct(); 42 | data.customer.email = generator.email; 43 | page.home.visit(); 44 | page.home.header.openLoginModal(); 45 | page.components.loginModal.fillForm(data.customer); 46 | page.components.loginModal.iWantToCreateAccountCheckbox.click(); 47 | page.components.loginModal.submitButton.click(); 48 | page.home.header.categories.first().click(); 49 | page.category().products.first().click().then(() => { 50 | cy.wait([getProductReq, getProductReq]); 51 | }); 52 | page.product().addToCartButton.click(); 53 | page.product().header.openCart(); 54 | page.components.cart.goToCheckoutButton.click(); 55 | page.checkout.shipping.heading.should('be.visible'); 56 | page.checkout.shipping.addresses.first().click(); 57 | page.checkout.shipping.selectShippingButton.click(); 58 | page.checkout.shipping.shippingMethods.first().click(); 59 | page.checkout.shipping.continueToBillingButton.click(); 60 | page.checkout.billing.heading.should('be.visible'); 61 | page.checkout.billing.copyAddressLabel.click(); 62 | page.checkout.billing.continueToPaymentButton.click(); 63 | page.checkout.payment.paymentMethods.first().click(); 64 | page.checkout.payment.terms.click(); 65 | page.checkout.payment.makeAnOrderButton.click(); 66 | page.checkout.thankyou.heading.should('be.visible'); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /components/AppFooter.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 82 | 83 | 120 | -------------------------------------------------------------------------------- /components/InstagramFeed.vue: -------------------------------------------------------------------------------- 1 | 27 | 50 | 102 | -------------------------------------------------------------------------------- /components/MyAccount/PasswordResetForm.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 90 | 91 | 122 | -------------------------------------------------------------------------------- /pages/MyAccount/MyProfile.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 99 | 100 | 119 | -------------------------------------------------------------------------------- /tests/e2e/integration/e2e-carts-merging.spec.ts: -------------------------------------------------------------------------------- 1 | import requests, { CreateCartResponse } from '../api/requests'; 2 | import page from '../pages/factory'; 3 | import generator from '../utils/data-generator'; 4 | 5 | context(['regression'], 'Carts merging', () => { 6 | beforeEach(function () { 7 | cy.fixture('test-data/e2e-carts-merging').then((fixture) => { 8 | this.fixtures = { 9 | data: fixture 10 | }; 11 | }); 12 | }); 13 | 14 | it('Should merge guest cart with registered customer cart', function () { 15 | const data = this.fixtures.data[this.test.title]; 16 | data.customer.email = generator.email; 17 | requests.getMe(); 18 | requests.createCart().then((response: CreateCartResponse) => { 19 | data.products.forEach(product => { 20 | requests.addToCart(response.body.data.cart.id, product, product.quantity); 21 | }); 22 | }); 23 | requests.customerSignMeUp(data.customer); 24 | page.home.visit(); 25 | page.home.header.openCart(); 26 | page.components.cart.productName.each((name, index) => { 27 | cy.wrap(name).should('contain', data.expectedCart[index].name); 28 | }); 29 | page.components.cart.quantity().each((input, index) => { 30 | cy.wrap(input).should('have.value', data.expectedCart[index].quantity); 31 | }); 32 | page.components.cart.product().each((product, index) => { 33 | page.components.cart.productSizeProperty(product).should('contain', data.expectedCart[index].size); 34 | page.components.cart.productColorProperty(product).should('contain', data.expectedCart[index].color); 35 | }); 36 | 37 | }); 38 | it('Should merge guest cart with registered customer cart - products already in cart', function () { 39 | const data = this.fixtures.data[this.test.title]; 40 | data.customer.email = generator.email; 41 | requests.customerSignMeUp(data.customer); 42 | requests.createCart().then((response: CreateCartResponse) => { 43 | data.products.customer.forEach(product => { 44 | requests.addToCart(response.body.data.cart.id, product, product.quantity); 45 | }); 46 | cy.clearCookies(); 47 | }); 48 | requests.createCart().then((response: CreateCartResponse) => { 49 | data.products.guest.forEach(product => { 50 | requests.addToCart(response.body.data.cart.id, product, product.quantity); 51 | }); 52 | }); 53 | page.home.visit(); 54 | page.home.header.openLoginModal(); 55 | page.components.loginModal.loginToAccountButton.click(); 56 | page.components.loginModal.fillForm(data.customer); 57 | page.components.loginModal.loginBtn.click(); 58 | page.home.header.openCart(); 59 | page.components.cart.totalItems.should($ti => { 60 | const totalItems: number = data.expectedCart.reduce((total, product) => { 61 | return total + product.quantity; 62 | }, 0); 63 | expect($ti.text().trim()).to.be.equal(totalItems.toString()); 64 | }); 65 | page.components.cart.productName.each((name, index) => { 66 | cy.wrap(name).should('contain', data.expectedCart[index].name); 67 | }); 68 | page.components.cart.quantity().each((input, index) => { 69 | cy.wrap(input).should('have.value', data.expectedCart[index].quantity); 70 | }); 71 | page.components.cart.product().each((product, index) => { 72 | page.components.cart.productSizeProperty(product).should('contain', data.expectedCart[index].size); 73 | page.components.cart.productColorProperty(product).should('contain', data.expectedCart[index].color); 74 | }); 75 | }); 76 | 77 | }); 78 | -------------------------------------------------------------------------------- /components/MyAccount/ProfileUpdateForm.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 91 | 92 | 124 | -------------------------------------------------------------------------------- /composables/useUiHelpers/index.ts: -------------------------------------------------------------------------------- 1 | import { getCurrentInstance } from '@vue/composition-api'; 2 | import { Category } from '@vue-storefront/commercetools-api'; 3 | import { AgnosticFacet } from '@vue-storefront/core'; 4 | 5 | const nonFilters = ['page', 'sort', 'phrase', 'itemsPerPage']; 6 | 7 | const getInstance = () => { 8 | const vm = getCurrentInstance(); 9 | return vm.$root as any; 10 | }; 11 | 12 | const reduceFilters = (query) => (prev, curr) => { 13 | const makeArray = Array.isArray(query[curr]) || nonFilters.includes(curr); 14 | 15 | return { 16 | ...prev, 17 | [curr]: makeArray ? query[curr] : [query[curr]] 18 | }; 19 | }; 20 | 21 | const getFiltersDataFromUrl = (context, onlyFilters) => { 22 | const { query } = context.$router.history.current; 23 | 24 | return Object.keys(query) 25 | .filter(f => onlyFilters ? !nonFilters.includes(f) : nonFilters.includes(f)) 26 | .reduce(reduceFilters(query), {}); 27 | }; 28 | 29 | const useUiHelpers = () => { 30 | const instance = getInstance(); 31 | 32 | const getFacetsFromURL = () => { 33 | const { query, params } = instance.$router.history.current; 34 | const categorySlug = Object.keys(params).reduce((prev, curr) => params[curr] || prev, params.slug_1); 35 | 36 | return { 37 | rootCatSlug: params.slug_1, 38 | categorySlug, 39 | page: parseInt(query.page, 10) || 1, 40 | sort: query.sort || 'latest', 41 | filters: getFiltersDataFromUrl(instance, true), 42 | itemsPerPage: parseInt(query.itemsPerPage, 10) || 20, 43 | phrase: query.phrase 44 | }; 45 | }; 46 | 47 | const getSearchTermFromUrl = () => { 48 | const { query, params } = instance.$router.history.current; 49 | // hardcoded categorySlug for search results 50 | const categorySlug = 'women-clothing-jackets'; 51 | 52 | return { 53 | rootCatSlug: params.slug_1, 54 | categorySlug, 55 | page: parseInt(query.page, 10) || 1, 56 | sort: query.sort || 'latest', 57 | filters: getFiltersDataFromUrl(instance, true), 58 | itemsPerPage: parseInt(query.itemsPerPage, 10) || 20, 59 | phrase: query.phrase 60 | }; 61 | }; 62 | 63 | const getCatLink = (category: Category): string => { 64 | return `/c/${instance.$route.params.slug_1}/${category.slug}`; 65 | }; 66 | 67 | const changeSorting = (sort: string) => { 68 | const { query } = instance.$router.history.current; 69 | instance.$router.push({ query: { ...query, sort } }); 70 | }; 71 | 72 | const changeFilters = (filters: any) => { 73 | instance.$router.push({ 74 | query: { 75 | ...getFiltersDataFromUrl(instance, false), 76 | ...filters 77 | } 78 | }); 79 | }; 80 | 81 | const changeItemsPerPage = (itemsPerPage: number) => { 82 | instance.$router.push({ 83 | query: { 84 | ...getFiltersDataFromUrl(instance, false), 85 | itemsPerPage 86 | } 87 | }); 88 | }; 89 | 90 | const setTermForUrl = (term: string) => { 91 | instance.$router.push({ 92 | query: { 93 | ...getFiltersDataFromUrl(instance, false), 94 | phrase: term || undefined 95 | } 96 | }); 97 | }; 98 | 99 | const isFacetColor = (facet: AgnosticFacet): boolean => facet.id === 'color'; 100 | 101 | const isFacetCheckbox = (): boolean => false; 102 | 103 | return { 104 | getFacetsFromURL, 105 | getCatLink, 106 | changeSorting, 107 | changeFilters, 108 | changeItemsPerPage, 109 | setTermForUrl, 110 | isFacetColor, 111 | isFacetCheckbox, 112 | getSearchTermFromUrl 113 | }; 114 | }; 115 | 116 | export default useUiHelpers; 117 | -------------------------------------------------------------------------------- /tests/e2e/integration/e2e-checkout-shipping-validation.spec.ts: -------------------------------------------------------------------------------- 1 | import requests, { CreateCartResponse } from '../api/requests'; 2 | import page from '../pages/factory'; 3 | import generator from '../utils/data-generator'; 4 | 5 | context(['regression'], 'Checkout - Shipping', () => { 6 | beforeEach(function () { 7 | cy.fixture('test-data/e2e-checkout-shipping-validation').then((fixture) => { 8 | this.fixtures = { 9 | data: fixture 10 | }; 11 | }); 12 | }); 13 | 14 | it('Should successfully save address - guest customer', function () { 15 | const data = this.fixtures.data[this.test.title]; 16 | requests.createCart().then((response: CreateCartResponse) => { 17 | requests.addToCart(response.body.data.cart.id, data.product); 18 | }); 19 | page.checkout.shipping.visit(); 20 | page.checkout.shipping.fillForm(data.customer); 21 | page.checkout.shipping.selectShippingButton.click(); 22 | page.checkout.shipping.shippingMethods.contains(data.shippingMethod).click(); 23 | page.checkout.shipping.continueToBillingButton.click(); 24 | page.checkout.billing.heading.should('be.visible'); 25 | }); 26 | 27 | it('Should successfully save address - registered customer', function () { 28 | const data = this.fixtures.data[this.test.title]; 29 | data.customer.email = generator.email; 30 | requests.customerSignMeUp(data.customer); 31 | requests.createCart().then((response: CreateCartResponse) => { 32 | requests.addToCart(response.body.data.cart.id, data.product); 33 | }); 34 | page.checkout.shipping.visit(); 35 | page.checkout.shipping.addNewAddressButton.click(); 36 | page.checkout.shipping.fillForm(data.customer); 37 | page.checkout.shipping.selectShippingButton.click(); 38 | page.checkout.shipping.shippingMethods.contains(data.shippingMethod).click(); 39 | page.checkout.shipping.continueToBillingButton.click(); 40 | page.checkout.billing.heading.should('be.visible'); 41 | }); 42 | 43 | const requiredFields = [ 44 | 'First Name', 45 | 'Last Name', 46 | 'Street Name', 47 | 'Apartment', 48 | 'City', 49 | 'Postal Code', 50 | 'Phone' 51 | ]; 52 | 53 | requiredFields.forEach(requiredField => { 54 | it(`Should display an error - ${requiredField} empty`, function () { 55 | const data = this.fixtures.data[this.test.title]; 56 | requests.createCart().then((response: CreateCartResponse) => { 57 | requests.addToCart(response.body.data.cart.id, data.product); 58 | }); 59 | page.checkout.shipping.visit(); 60 | page.checkout.shipping.fillForm(data.customer); 61 | page.checkout.shipping.selectShippingButton.click(); 62 | page.checkout.shipping[Cypress._.camelCase(requiredField)].parent().within(() => { 63 | cy.get('input').then(($input) => { 64 | expect($input[0].validationMessage).to.be.eq(data.errorMessage); 65 | }); 66 | }); 67 | }); 68 | }); 69 | 70 | const requiredSelects = [ 71 | 'Country', 72 | 'State' 73 | ]; 74 | 75 | requiredSelects.forEach(requiredSelect => { 76 | it(`Should display an error - ${requiredSelect} empty`, function () { 77 | const data = this.fixtures.data[this.test.title]; 78 | requests.createCart().then((response: CreateCartResponse) => { 79 | requests.addToCart(response.body.data.cart.id, data.product); 80 | }); 81 | page.checkout.shipping.visit(); 82 | page.checkout.shipping.fillForm(data.customer); 83 | page.checkout.shipping.selectShippingButton.click(); 84 | page.checkout.shipping[Cypress._.camelCase(requiredSelect)].parent().within(() => { 85 | cy.contains(data.errorMessage).should('be.visible'); 86 | }); 87 | }); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /components/Checkout/VsfPaymentProviderMock.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 80 | 81 | 133 | -------------------------------------------------------------------------------- /tests/e2e/api/requests.ts: -------------------------------------------------------------------------------- 1 | import { Address, Customer, Product } from '../types/types'; 2 | 3 | export type CreateCartResponse = { 4 | body: { 5 | data: { 6 | cart: { 7 | id: string; 8 | } 9 | } 10 | } 11 | } 12 | 13 | export type GetShippingMethodsResponse = { 14 | body: { 15 | data: { 16 | shippingMethods: [{ 17 | id: string; 18 | name: string; 19 | }] 20 | } 21 | } 22 | } 23 | 24 | const requests = { 25 | 26 | addToCart(cartId: string, product: Product, quantity?: number): Cypress.Chainable { 27 | const options = { 28 | url: '/api/ct/addToCart', 29 | method: 'POST', 30 | headers: { 31 | Accept: 'application/json', 32 | 'Content-Type': 'application/json' 33 | }, 34 | body: [ 35 | {id: cartId, version: 1}, 36 | {id: product.id, sku: product.sku }, 37 | quantity ?? 1, 38 | null 39 | ] 40 | }; 41 | return cy.request(options); 42 | }, 43 | 44 | createCart(): Cypress.Chainable { 45 | const options = { 46 | url: '/api/ct/createCart', 47 | method: 'POST', 48 | headers: { 49 | Accept: 'application/json', 50 | 'Content-Type': 'application/json' 51 | }, 52 | body: [{}, null] 53 | }; 54 | return cy.request(options); 55 | }, 56 | 57 | customerSignMeUp(customer: Customer): Cypress.Chainable { 58 | const options = { 59 | url: '/api/ct/customerSignMeUp', 60 | method: 'POST', 61 | headers: { 62 | Accept: 'application/json', 63 | 'Content-Type': 'application/json' 64 | }, 65 | body: [ 66 | { 67 | email: customer.email, 68 | password: customer.password, 69 | firstName: customer.firstName, 70 | lastName: customer.lastName 71 | } 72 | ] 73 | }; 74 | return cy.request(options); 75 | }, 76 | 77 | getMe(): Cypress.Chainable { 78 | const options = { 79 | url: '/api/ct/getMe', 80 | method: 'POST', 81 | headers: { 82 | Accept: 'application/json', 83 | 'Content-Type': 'application/json' 84 | }, 85 | body: [ 86 | {customer: false}, null 87 | ] 88 | }; 89 | return cy.request(options); 90 | }, 91 | 92 | getShippingMethods(cartId: string): Cypress.Chainable { 93 | const options = { 94 | url: '/api/ct/getShippingMethods', 95 | method: 'POST', 96 | headers: { 97 | Accept: 'application/json', 98 | 'Content-Type': 'application/json' 99 | }, 100 | body: [ 101 | cartId 102 | ] 103 | }; 104 | return cy.request(options); 105 | }, 106 | 107 | updateCart(cartId: string, data?: { addresses?: { shipping?: Address, billing?: Address }, shippingMethodId?: string }): Cypress.Chainable { 108 | const actions = []; 109 | 110 | if (data.addresses !== undefined) { 111 | if (data.addresses.shipping !== undefined) actions.push({ 112 | setShippingAddress: { 113 | address: { 114 | ...data.addresses.shipping 115 | } 116 | } 117 | }); 118 | 119 | if (data.addresses.billing !== undefined) actions.push({ 120 | setBillingAddress: { 121 | address: { 122 | ...data.addresses.billing 123 | } 124 | } 125 | }); 126 | } 127 | 128 | if (data.shippingMethodId !== undefined) actions.push({ 129 | setShippingMethod: { 130 | shippingMethod: { 131 | id: data.shippingMethodId 132 | } 133 | } 134 | }); 135 | 136 | const options = { 137 | url: '/api/ct/updateCart', 138 | method: 'POST', 139 | headers: { 140 | Accept: 'application/json', 141 | 'Content-Type': 'application/json' 142 | }, 143 | body: [ 144 | { 145 | id: cartId, 146 | version: 1, 147 | actions: [ 148 | ...actions 149 | ] 150 | }, 151 | null 152 | ] 153 | }; 154 | return cy.request(options); 155 | } 156 | }; 157 | 158 | export default requests; 159 | -------------------------------------------------------------------------------- /mockedSearchProducts.json: -------------------------------------------------------------------------------- 1 | { 2 | "products": [ 3 | { 4 | "_id": "38143c0c-c9b0-448c-93cd-60eb90d8da57", 5 | "_slug": "aspesi-shirt-h805-white", 6 | "_name": "Shirt Aspesi white M", 7 | "images": [ 8 | { 9 | "url": "https://s3-eu-west-1.amazonaws.com/commercetools-maximilian/products/081223_1_large.jpg" 10 | } 11 | ], 12 | "price": { 13 | "value": { 14 | "centAmount": 16625 15 | } 16 | }, 17 | "_rating": { 18 | "averageRating": 4 19 | } 20 | }, 21 | { 22 | "_id": "9c1881eb-139d-4d29-bbb4-01b1ff51de03", 23 | "_slug": "moschino-tshirt-b02033717-black", 24 | "_name": "T-Shirt Moschino Cheap And Chic black", 25 | "images": [ 26 | { 27 | "url": "https://s3-eu-west-1.amazonaws.com/commercetools-maximilian/products/072750_1_large.jpg" 28 | } 29 | ], 30 | "price": { 31 | "value": { 32 | "centAmount": 37250 33 | } 34 | }, 35 | "_rating": { 36 | "averageRating": 2.5 37 | } 38 | }, 39 | { 40 | "_id": "38143c0c-c9b0-448c-93cd-60eb90d8da57", 41 | "_slug": "aspesi-shirt-h805-white", 42 | "_name": "Shirt Aspesi white M", 43 | "images": [ 44 | { 45 | "url": "https://s3-eu-west-1.amazonaws.com/commercetools-maximilian/products/081223_1_large.jpg" 46 | } 47 | ], 48 | "price": { 49 | "value": { 50 | "centAmount": 16625 51 | } 52 | }, 53 | "_rating": { 54 | "averageRating": 4 55 | } 56 | }, 57 | { 58 | "_id": "9c1881eb-139d-4d29-bbb4-01b1ff51de03", 59 | "_slug": "moschino-tshirt-b02033717-black", 60 | "_name": "T-Shirt Moschino Cheap And Chic black", 61 | "images": [ 62 | { 63 | "url": "https://s3-eu-west-1.amazonaws.com/commercetools-maximilian/products/072750_1_large.jpg" 64 | } 65 | ], 66 | "price": { 67 | "value": { 68 | "centAmount": 37250 69 | } 70 | }, 71 | "_rating": { 72 | "averageRating": 2.5 73 | } 74 | }, 75 | { 76 | "_id": "38143c0c-c9b0-448c-93cd-60eb90d8da57", 77 | "_slug": "aspesi-shirt-h805-white", 78 | "_name": "Shirt Aspesi white M", 79 | "images": [ 80 | { 81 | "url": "https://s3-eu-west-1.amazonaws.com/commercetools-maximilian/products/081223_1_large.jpg" 82 | } 83 | ], 84 | "price": { 85 | "value": { 86 | "centAmount": 16625 87 | } 88 | }, 89 | "_rating": { 90 | "averageRating": 4 91 | } 92 | }, 93 | { 94 | "_id": "9c1881eb-139d-4d29-bbb4-01b1ff51de03", 95 | "_slug": "moschino-tshirt-b02033717-black", 96 | "_name": "T-Shirt Moschino Cheap And Chic black", 97 | "images": [ 98 | { 99 | "url": "https://s3-eu-west-1.amazonaws.com/commercetools-maximilian/products/072750_1_large.jpg" 100 | } 101 | ], 102 | "price": { 103 | "value": { 104 | "centAmount": 37250 105 | } 106 | }, 107 | "_rating": { 108 | "averageRating": 2.5 109 | } 110 | }, 111 | { 112 | "_id": "38143c0c-c9b0-448c-93cd-60eb90d8da57", 113 | "_slug": "aspesi-shirt-h805-white", 114 | "_name": "Shirt Aspesi white M", 115 | "images": [ 116 | { 117 | "url": "https://s3-eu-west-1.amazonaws.com/commercetools-maximilian/products/081223_1_large.jpg" 118 | } 119 | ], 120 | "price": { 121 | "value": { 122 | "centAmount": 16625 123 | } 124 | }, 125 | "_rating": { 126 | "averageRating": 4 127 | } 128 | }, 129 | { 130 | "_id": "9c1881eb-139d-4d29-bbb4-01b1ff51de03", 131 | "_slug": "moschino-tshirt-b02033717-black", 132 | "_name": "T-Shirt Moschino Cheap And Chic black", 133 | "images": [ 134 | { 135 | "url": "https://s3-eu-west-1.amazonaws.com/commercetools-maximilian/products/072750_1_large.jpg" 136 | } 137 | ], 138 | "price": { 139 | "value": { 140 | "centAmount": 37250 141 | } 142 | }, 143 | "_rating": { 144 | "averageRating": 2.5 145 | } 146 | } 147 | ], 148 | "categories": [ 149 | { 150 | "label": "Men", 151 | "slug": "men" 152 | }, 153 | { 154 | "label": "Women", 155 | "slug": "women" 156 | }, 157 | { 158 | "label": "Sale", 159 | "slug": "sale" 160 | }, 161 | { 162 | "label": "New", 163 | "slug": "new" 164 | } 165 | ] 166 | } 167 | -------------------------------------------------------------------------------- /pages/MyAccount.vue: -------------------------------------------------------------------------------- 1 | 50 | 135 | 136 | 160 | -------------------------------------------------------------------------------- /tests/e2e/pages/checkout.ts: -------------------------------------------------------------------------------- 1 | import { Customer, Address } from '../types/types'; 2 | import Base from './base'; 3 | import { el } from './utils/element'; 4 | 5 | class Checkout extends Base { 6 | 7 | protected step = '' 8 | 9 | get addNewAddressButton(): Cypress.Chainable { 10 | return el(`${this.step}-add-new-address`); 11 | } 12 | 13 | get firstName(): Cypress.Chainable { 14 | return el(`${this.step}-firstName`, 'input'); 15 | } 16 | 17 | get lastName(): Cypress.Chainable { 18 | return el(`${this.step}-lastName`, 'input'); 19 | } 20 | 21 | get streetName(): Cypress.Chainable { 22 | return el(`${this.step}-streetName`, 'input'); 23 | } 24 | 25 | get apartment(): Cypress.Chainable { 26 | return el(`${this.step}-apartment`, 'input'); 27 | } 28 | 29 | get city(): Cypress.Chainable { 30 | return el(`${this.step}-city`, 'input'); 31 | } 32 | 33 | get state(): Cypress.Chainable { 34 | return el(`${this.step}-state`, 'select'); 35 | } 36 | 37 | get country(): Cypress.Chainable { 38 | return el(`${this.step}-country`, 'select'); 39 | } 40 | 41 | get postalCode(): Cypress.Chainable { 42 | return el(`${this.step}-zipcode`, 'input'); 43 | } 44 | 45 | get phone(): Cypress.Chainable { 46 | return el(`${this.step}-phone`, 'input'); 47 | } 48 | 49 | public fillForm(address: Address) { 50 | if (address.firstName !== undefined) this.firstName.clear().type(address.firstName); 51 | if (address.lastName !== undefined) this.lastName.clear().type(address.lastName); 52 | if (address.streetName !== undefined) this.streetName.clear().type(address.streetName); 53 | if (address.apartment !== undefined) { 54 | this.apartment.parent().click(); 55 | this.apartment.clear().type(address.apartment); 56 | } 57 | if (address.city !== undefined) this.city.clear().type(address.city); 58 | if (address.country !== undefined) this.country.select(address.country); 59 | if (address.state !== undefined) this.state.select(address.state); 60 | if (address.postalCode !== undefined) this.postalCode.clear().type(address.postalCode); 61 | if (address.phone !== undefined) this.phone.clear().type(address.phone); 62 | } 63 | } 64 | 65 | class Shipping extends Checkout { 66 | 67 | constructor() { 68 | super(); 69 | this.step = 'shipping'; 70 | } 71 | 72 | get path(): string { 73 | return '/checkout/shipping'; 74 | } 75 | 76 | get addresses(): Cypress.Chainable { 77 | return el('shipping-addresses', '.sf-radio label'); 78 | } 79 | 80 | get continueToBillingButton(): Cypress.Chainable { 81 | return el('continue-to-billing'); 82 | } 83 | 84 | get heading(): Cypress.Chainable { 85 | return el(`${this.step}-heading`); 86 | } 87 | 88 | get selectShippingButton(): Cypress.Chainable { 89 | return el('select-shipping'); 90 | } 91 | 92 | get shippingMethods(): Cypress.Chainable { 93 | return el('shipping-method-label'); 94 | } 95 | 96 | public fillForm(customer: Customer) { 97 | super.fillForm(customer.address.shipping); 98 | } 99 | 100 | } 101 | 102 | class Billing extends Checkout { 103 | 104 | constructor() { 105 | super(); 106 | this.step = 'billing'; 107 | } 108 | 109 | get path(): string { 110 | return '/checkout/billing'; 111 | } 112 | 113 | get continueToPaymentButton(): Cypress.Chainable { 114 | return el('continue-to-payment'); 115 | } 116 | 117 | get heading(): Cypress.Chainable { 118 | return el(`${this.step}-heading`); 119 | } 120 | 121 | get copyAddressLabel(): Cypress.Chainable { 122 | return el('copy-address', 'label'); 123 | } 124 | 125 | public fillForm(customer: Customer) { 126 | super.fillForm(customer.address.billing); 127 | } 128 | } 129 | 130 | class Payment extends Base { 131 | 132 | get path(): string { 133 | return '/checkout/payment'; 134 | } 135 | 136 | get heading(): Cypress.Chainable { 137 | return el('heading-payment'); 138 | } 139 | 140 | get makeAnOrderButton(): Cypress.Chainable { 141 | return el('make-an-order'); 142 | } 143 | 144 | get paymentMethods(): Cypress.Chainable { 145 | return el('payment-method'); 146 | } 147 | 148 | get terms(): Cypress.Chainable { 149 | return el('terms', 'label'); 150 | } 151 | 152 | get productRow(): Cypress.Chainable { 153 | return el('product-row'); 154 | } 155 | 156 | get productTitleSku(): Cypress.Chainable { 157 | return el('product-title-sku'); 158 | } 159 | 160 | get productAttributes(): Cypress.Chainable { 161 | return el('product-attributes'); 162 | } 163 | 164 | get productQuantity(): Cypress.Chainable { 165 | return el('product-quantity'); 166 | } 167 | 168 | get productPrice(): Cypress.Chainable { 169 | return el('product-price'); 170 | } 171 | 172 | get discountedPrice(): Cypress.Chainable { 173 | return cy.get('.discounted'); 174 | } 175 | 176 | get specialPrice(): Cypress.Chainable { 177 | return cy.get('.special-price'); 178 | } 179 | 180 | get totalPrice(): Cypress.Chainable { 181 | return cy.get('.property-total'); 182 | } 183 | } 184 | 185 | class ThankYou { 186 | get heading(): Cypress.Chainable { 187 | return el('thank-you-banner', 'h2'); 188 | } 189 | } 190 | 191 | export { 192 | Shipping, 193 | Billing, 194 | Payment, 195 | ThankYou 196 | }; 197 | -------------------------------------------------------------------------------- /pages/ResetPassword.vue: -------------------------------------------------------------------------------- 1 | 65 | 124 | 125 | 168 | -------------------------------------------------------------------------------- /components/StoreLocaleSelector.vue: -------------------------------------------------------------------------------- 1 | 55 | 56 | 124 | 125 | 187 | -------------------------------------------------------------------------------- /nuxt.config.js: -------------------------------------------------------------------------------- 1 | import theme from "./themeConfig"; 2 | 3 | let webpack; 4 | let isCloud = false; 5 | 6 | try { 7 | webpack = require("webpack"); 8 | } catch (e) { 9 | isCloud = true; 10 | } 11 | 12 | export default { 13 | mode: "universal", 14 | server: { 15 | port: 3000, 16 | host: "0.0.0.0" 17 | }, 18 | head: { 19 | title: process.env.npm_package_name || "", 20 | meta: [ 21 | { charset: "utf-8" }, 22 | { name: "viewport", content: "width=device-width, initial-scale=1" }, 23 | { 24 | hid: "description", 25 | name: "description", 26 | content: process.env.npm_package_description || "" 27 | } 28 | ], 29 | link: [ 30 | { rel: "icon", type: "image/x-icon", href: "/favicon.ico" }, 31 | { 32 | rel: "preconnect", 33 | href: "https://fonts.gstatic.com", 34 | crossorigin: "crossorigin" 35 | }, 36 | { 37 | rel: "preload", 38 | href: 39 | "https://fonts.googleapis.com/css?family=Raleway:300,400,400i,500,600,700|Roboto:300,300i,400,400i,500,700&display=swap", 40 | as: "style" 41 | }, 42 | { 43 | rel: "stylesheet", 44 | href: 45 | "https://fonts.googleapis.com/css?family=Raleway:300,400,400i,500,600,700|Roboto:300,300i,400,400i,500,700&display=swap", 46 | media: "print", 47 | onload: "this.media='all'", 48 | once: true 49 | } 50 | ], 51 | script: [] 52 | }, 53 | loading: { color: "#fff" }, 54 | router: { 55 | middleware: ["checkout"], 56 | scrollBehavior(_to, _from, savedPosition) { 57 | if (savedPosition) { 58 | return savedPosition; 59 | } else { 60 | return { x: 0, y: 0 }; 61 | } 62 | } 63 | }, 64 | buildModules: [ 65 | // to core 66 | "@nuxt/typescript-build", 67 | "@nuxtjs/style-resources", 68 | // to core soon 69 | "@nuxtjs/pwa", 70 | ["@edgio/nuxt/module", { edgioSourceMaps: true }], 71 | [ 72 | "@vue-storefront/nuxt", 73 | { 74 | coreDevelopment: true, 75 | useRawSource: { 76 | dev: ["@vue-storefront/commercetools", "@vue-storefront/core"], 77 | prod: ["@vue-storefront/commercetools", "@vue-storefront/core"] 78 | } 79 | } 80 | ], 81 | ["@vue-storefront/nuxt-theme"], 82 | [ 83 | "@vue-storefront/commercetools/nuxt", 84 | { 85 | i18n: { useNuxtI18nConfig: true } 86 | } 87 | ], 88 | "nuxt-i18n", 89 | "cookie-universal-nuxt", 90 | "vue-scrollto/nuxt" 91 | ], 92 | modules: ["@vue-storefront/middleware/nuxt"], 93 | i18n: { 94 | currency: "USD", 95 | country: "US", 96 | countries: [ 97 | { name: "US", label: "United States", states: ["California", "Nevada"] }, 98 | { name: "AT", label: "Austria" }, 99 | { name: "DE", label: "Germany" }, 100 | { name: "NL", label: "Netherlands" } 101 | ], 102 | currencies: [ 103 | { name: "EUR", label: "Euro" }, 104 | { name: "USD", label: "Dollar" } 105 | ], 106 | locales: [ 107 | { code: "en", label: "English", file: "en.js", iso: "en" }, 108 | { code: "de", label: "German", file: "de.js", iso: "de" } 109 | ], 110 | defaultLocale: "en", 111 | lazy: true, 112 | seo: true, 113 | langDir: "lang/", 114 | vueI18n: { 115 | fallbackLocale: "en", 116 | numberFormats: { 117 | en: { 118 | currency: { 119 | style: "currency", 120 | currency: "USD", 121 | currencyDisplay: "symbol" 122 | } 123 | }, 124 | de: { 125 | currency: { 126 | style: "currency", 127 | currency: "EUR", 128 | currencyDisplay: "symbol" 129 | } 130 | } 131 | } 132 | }, 133 | detectBrowserLanguage: { 134 | cookieKey: "vsf-locale" 135 | } 136 | }, 137 | styleResources: { 138 | scss: [ 139 | !isCloud && 140 | require.resolve("@storefront-ui/shared/styles/_helpers.scss", { 141 | paths: [process.cwd()] 142 | }) 143 | ] 144 | }, 145 | publicRuntimeConfig: { 146 | theme 147 | }, 148 | build: { 149 | babel: { 150 | plugins: [["@babel/plugin-proposal-private-methods", { loose: true }]] 151 | }, 152 | transpile: ["vee-validate/dist/rules"], 153 | plugins: [ 154 | !isCloud && 155 | new webpack.DefinePlugin({ 156 | "process.VERSION": JSON.stringify({ 157 | // eslint-disable-next-line global-require 158 | version: require("./package.json").version, 159 | lastCommit: process.env.LAST_COMMIT || "" 160 | }) 161 | }) 162 | ], 163 | extend(config, ctx) { 164 | if (ctx && ctx.isClient) { 165 | config.optimization = { 166 | ...config.optimization, 167 | mergeDuplicateChunks: true, 168 | splitChunks: { 169 | ...config.optimization.splitChunks, 170 | chunks: "all", 171 | automaticNameDelimiter: ".", 172 | maxSize: 128_000, 173 | maxInitialRequests: Number.POSITIVE_INFINITY, 174 | minSize: 0, 175 | maxAsyncRequests: 10, 176 | cacheGroups: { 177 | vendor: { 178 | test: /[/\\]node_modules[/\\]/, 179 | name: module => 180 | `${module.context 181 | .match(/[/\\]node_modules[/\\](.*?)([/\\]|$)/)[1] 182 | .replace(/[.@_]/gm, "")}` 183 | } 184 | } 185 | } 186 | }; 187 | } 188 | } 189 | } 190 | }; 191 | -------------------------------------------------------------------------------- /tests/e2e/integration/e2e-checkout-billing-validation.spec.ts: -------------------------------------------------------------------------------- 1 | import requests, { CreateCartResponse } from '../api/requests'; 2 | import page from '../pages/factory'; 3 | import generator from '../utils/data-generator'; 4 | 5 | context(['regression'], 'Checkout - Billing', () => { 6 | beforeEach(function () { 7 | cy.fixture('test-data/e2e-checkout-billing-validation').then((fixture) => { 8 | this.fixtures = { 9 | data: fixture 10 | }; 11 | }); 12 | }); 13 | 14 | it('Should successfully save address - guest customer', function () { 15 | const data = this.fixtures.data[this.test.title]; 16 | requests.createCart().then((response: CreateCartResponse) => { 17 | requests.addToCart(response.body.data.cart.id, data.product); 18 | requests.updateCart(response.body.data.cart.id, { addresses: { shipping: data.customer.address.shipping }}); 19 | }); 20 | page.checkout.shipping.visit(); 21 | page.checkout.shipping.selectShippingButton.click(); 22 | page.checkout.shipping.shippingMethods.contains(data.shippingMethod).click(); 23 | page.checkout.shipping.continueToBillingButton.click(); 24 | page.checkout.billing.heading.should('be.visible'); 25 | page.checkout.billing.fillForm(data.customer); 26 | page.checkout.billing.continueToPaymentButton.click(); 27 | page.checkout.payment.heading.should('be.visible'); 28 | }); 29 | 30 | it('Should successfully save address - registered customer', function () { 31 | const data = this.fixtures.data[this.test.title]; 32 | data.customer.email = generator.email; 33 | requests.customerSignMeUp(data.customer); 34 | requests.createCart().then((response: CreateCartResponse) => { 35 | requests.addToCart(response.body.data.cart.id, data.product); 36 | requests.updateCart(response.body.data.cart.id, { addresses: { shipping: data.customer.address.shipping }}); 37 | }); 38 | page.checkout.shipping.visit(); 39 | page.checkout.shipping.selectShippingButton.click(); 40 | page.checkout.shipping.shippingMethods.contains(data.shippingMethod).click(); 41 | page.checkout.shipping.continueToBillingButton.click(); 42 | page.checkout.billing.heading.should('be.visible'); 43 | page.checkout.billing.addNewAddressButton.click(); 44 | page.checkout.billing.fillForm(data.customer); 45 | page.checkout.billing.continueToPaymentButton.click(); 46 | page.checkout.payment.heading.should('be.visible'); 47 | }); 48 | 49 | const requiredFields = [ 50 | 'First Name', 51 | 'Last Name', 52 | 'Street Name', 53 | 'Apartment', 54 | 'City', 55 | 'Postal Code', 56 | 'Phone' 57 | ]; 58 | 59 | requiredFields.forEach(requiredField => { 60 | it(`Should display an error - ${requiredField} empty`, function () { 61 | const data = this.fixtures.data[this.test.title]; 62 | requests.createCart().then((response: CreateCartResponse) => { 63 | requests.addToCart(response.body.data.cart.id, data.product); 64 | requests.updateCart(response.body.data.cart.id, { addresses: { shipping: data.customer.address.shipping }}); 65 | }); 66 | page.checkout.shipping.visit(); 67 | page.checkout.shipping.selectShippingButton.click(); 68 | page.checkout.shipping.shippingMethods.contains(data.shippingMethod).click(); 69 | page.checkout.shipping.continueToBillingButton.click(); 70 | page.checkout.billing.heading.should('be.visible'); 71 | page.checkout.billing.fillForm(data.customer); 72 | page.checkout.billing.continueToPaymentButton.click(); 73 | page.checkout.billing[Cypress._.camelCase(requiredField)].parent().within(() => { 74 | cy.get('input').then(($input) => { 75 | expect($input[0].validationMessage).to.be.eq(data.errorMessage); 76 | }); 77 | }); 78 | }); 79 | }); 80 | 81 | const requiredSelects = [ 82 | 'Country', 83 | 'State' 84 | ]; 85 | 86 | requiredSelects.forEach(requiredSelect => { 87 | it(`Should display an error - ${requiredSelect} empty`, function () { 88 | const data = this.fixtures.data[this.test.title]; 89 | requests.createCart().then((response: CreateCartResponse) => { 90 | requests.addToCart(response.body.data.cart.id, data.product); 91 | requests.updateCart(response.body.data.cart.id, { addresses: { shipping: data.customer.address.shipping }}); 92 | }); 93 | page.checkout.shipping.visit(); 94 | page.checkout.shipping.selectShippingButton.click(); 95 | page.checkout.shipping.shippingMethods.contains(data.shippingMethod).click(); 96 | page.checkout.shipping.continueToBillingButton.click(); 97 | page.checkout.billing.heading.should('be.visible'); 98 | page.checkout.billing.fillForm(data.customer); 99 | page.checkout.billing.continueToPaymentButton.click(); 100 | page.checkout.billing[Cypress._.camelCase(requiredSelect)].parent().within(() => { 101 | cy.contains(data.errorMessage).should('be.visible'); 102 | }); 103 | }); 104 | }); 105 | 106 | it('Should copy shipping address', function () { 107 | const data = this.fixtures.data[this.test.title]; 108 | requests.createCart().then((response: CreateCartResponse) => { 109 | requests.addToCart(response.body.data.cart.id, data.product); 110 | requests.updateCart(response.body.data.cart.id, { addresses: { shipping: data.customer.address.shipping }}); 111 | }); 112 | page.checkout.shipping.visit(); 113 | page.checkout.shipping.selectShippingButton.click(); 114 | page.checkout.shipping.shippingMethods.contains(data.shippingMethod).click(); 115 | page.checkout.shipping.continueToBillingButton.click(); 116 | page.checkout.billing.heading.should('be.visible'); 117 | page.checkout.billing.copyAddressLabel.click(); 118 | for (const field in data.customer.address.shipping) { 119 | console.log(field); 120 | page.checkout.billing[field].should('have.value', data.customer.address.shipping[field]); 121 | } 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /pages/MyAccount/BillingDetails.vue: -------------------------------------------------------------------------------- 1 | 72 | 137 | 138 | 215 | -------------------------------------------------------------------------------- /pages/MyAccount/ShippingDetails.vue: -------------------------------------------------------------------------------- 1 | 72 | 137 | 138 | 214 | -------------------------------------------------------------------------------- /components/Checkout/CartPreview.vue: -------------------------------------------------------------------------------- 1 | 66 | 143 | 144 | 206 | --------------------------------------------------------------------------------