From a9461a1d3291ce1612fc23a12c88368e4de62fa7 Mon Sep 17 00:00:00 2001 From: versatilemage <104298679+versatilemage@users.noreply.github.com> Date: Fri, 3 May 2024 14:45:48 +0000 Subject: [PATCH] Initial commit Created from https://vercel.com/new --- .env.template | 23 + .eslintrc.js | 3 + .github/scripts/medusa-config.js | 117 + .gitignore | 57 + .prettierrc | 8 + .yarnrc.yml | 1 + LICENSE | 21 + README.md | 303 + cypress.json | 8 + e2e/.env.example | 19 + e2e/README.md | 89 + e2e/data/reset.ts | 106 + e2e/data/seed.ts | 102 + e2e/fixtures/account/account-page.ts | 45 + e2e/fixtures/account/addresses-page.ts | 42 + e2e/fixtures/account/index.ts | 47 + e2e/fixtures/account/login-page.ts | 26 + e2e/fixtures/account/modals/address-modal.ts | 40 + e2e/fixtures/account/order-page.ts | 80 + e2e/fixtures/account/orders-page.ts | 57 + e2e/fixtures/account/overview-page.ts | 46 + e2e/fixtures/account/profile-page.ts | 166 + e2e/fixtures/account/register-page.ts | 27 + e2e/fixtures/base/base-modal.ts | 22 + e2e/fixtures/base/base-page.ts | 33 + e2e/fixtures/base/cart-dropdown.ts | 51 + e2e/fixtures/base/nav-menu.ts | 55 + e2e/fixtures/base/search-modal.ts | 36 + e2e/fixtures/cart-page.ts | 119 + e2e/fixtures/category-page.ts | 48 + e2e/fixtures/checkout-page.ts | 295 + e2e/fixtures/index.ts | 54 + e2e/fixtures/modals/mobile-actions-modal.ts | 22 + e2e/fixtures/order-page.ts | 92 + e2e/fixtures/product-page.ts | 52 + e2e/fixtures/store-page.ts | 18 + e2e/index.ts | 6 + e2e/tests/authenticated/address.spec.ts | 258 + e2e/tests/authenticated/orders.spec.ts | 374 + e2e/tests/authenticated/profile.spec.ts | 243 + e2e/tests/global/public-setup.ts | 6 + e2e/tests/global/setup.ts | 25 + e2e/tests/global/teardown.ts | 7 + e2e/tests/public/cart.spec.ts | 202 + e2e/tests/public/checkout.spec.ts | 582 ++ e2e/tests/public/discount.spec.ts | 526 ++ e2e/tests/public/giftcard.spec.ts | 753 ++ e2e/tests/public/login.spec.ts | 70 + e2e/tests/public/register.spec.ts | 67 + e2e/tests/public/search.spec.ts | 71 + e2e/utils/index.ts | 14 + e2e/utils/locators.ts | 13 + netlify.toml | 2 + next-env.d.ts | 5 + next-sitemap.js | 19 + next.config.js | 34 + package.json | 71 + playwright.config.ts | 77 + postcss.config.js | 6 + public/favicon.ico | Bin 0 -> 25931 bytes .../(checkout)/checkout/page.tsx | 48 + src/app/[countryCode]/(checkout)/layout.tsx | 43 + .../[countryCode]/(checkout)/not-found.tsx | 19 + .../account/@dashboard/addresses/page.tsx | 37 + .../(main)/account/@dashboard/loading.tsx | 9 + .../@dashboard/orders/details/[id]/page.tsx | 32 + .../(main)/account/@dashboard/orders/page.tsx | 33 + .../(main)/account/@dashboard/page.tsx | 21 + .../account/@dashboard/profile/page.tsx | 52 + .../(main)/account/@login/page.tsx | 12 + .../[countryCode]/(main)/account/layout.tsx | 18 + .../[countryCode]/(main)/account/loading.tsx | 9 + src/app/[countryCode]/(main)/cart/loading.tsx | 5 + .../[countryCode]/(main)/cart/not-found.tsx | 21 + src/app/[countryCode]/(main)/cart/page.tsx | 47 + .../(main)/categories/[...category]/page.tsx | 86 + .../(main)/collections/[handle]/page.tsx | 81 + src/app/[countryCode]/(main)/layout.tsx | 20 + src/app/[countryCode]/(main)/not-found.tsx | 20 + .../(main)/order/confirmed/[id]/loading.tsx | 5 + .../(main)/order/confirmed/[id]/page.tsx | 39 + src/app/[countryCode]/(main)/page.tsx | 79 + .../(main)/products/[handle]/page.tsx | 106 + .../(main)/results/[query]/page.tsx | 42 + src/app/[countryCode]/(main)/search/page.tsx | 5 + src/app/[countryCode]/(main)/store/page.tsx | 31 + src/app/actions.ts | 40 + src/app/layout.tsx | 18 + src/app/not-found.tsx | 30 + src/app/opengraph-image.jpg | Bin 0 -> 234193 bytes src/app/twitter-image.jpg | Bin 0 -> 234193 bytes src/lib/config.ts | 13 + src/lib/constants.tsx | 57 + src/lib/context/modal-context.tsx | 34 + src/lib/data/index.ts | 757 ++ src/lib/hooks/use-in-view.tsx | 29 + src/lib/hooks/use-toggle-state.tsx | 46 + src/lib/search-client.ts | 25 + src/lib/util/compare-addresses.ts | 22 + src/lib/util/get-checkout-step.ts | 13 + src/lib/util/get-number-of-skeletons.ts | 25 + src/lib/util/get-precentage-diff.ts | 6 + src/lib/util/get-prices-by-price-set-id.ts | 41 + src/lib/util/get-product-price.ts | 98 + src/lib/util/isEmpty.ts | 11 + src/lib/util/medusa-error.ts | 22 + src/lib/util/only-unique.ts | 2 + src/lib/util/prices.ts | 259 + src/lib/util/repeat.ts | 5 + src/lib/util/sort-products.ts | 42 + src/lib/util/transform-product-preview.ts | 54 + src/middleware.ts | 142 + src/modules/account/actions.ts | 272 + .../account/components/account-info/index.tsx | 139 + .../account/components/account-nav/index.tsx | 194 + .../account/components/address-book/index.tsx | 27 + .../components/address-card/add-address.tsx | 153 + .../address-card/edit-address-modal.tsx | 233 + .../account/components/login/index.tsx | 60 + .../account/components/order-card/index.tsx | 86 + .../components/order-overview/index.tsx | 45 + .../account/components/overview/index.tsx | 136 + .../profile-billing-address/index.tsx | 187 + .../components/profile-email/index.tsx | 59 + .../account/components/profile-name/index.tsx | 63 + .../components/profile-password/index.tsx | 74 + .../components/profile-phone/index.tsx | 59 + .../account/components/register/index.tsx | 96 + .../account/templates/account-layout.tsx | 43 + .../account/templates/login-template.tsx | 27 + src/modules/cart/actions.ts | 200 + .../components/cart-item-select/index.tsx | 73 + .../components/empty-cart-message/index.tsx | 25 + src/modules/cart/components/item/index.tsx | 124 + .../cart/components/sign-in-prompt/index.tsx | 26 + src/modules/cart/templates/index.tsx | 52 + src/modules/cart/templates/items.tsx | 50 + src/modules/cart/templates/preview.tsx | 50 + src/modules/cart/templates/summary.tsx | 31 + src/modules/categories/templates/index.tsx | 79 + src/modules/checkout/actions.ts | 208 + .../components/address-select/index.tsx | 115 + .../checkout/components/addresses/index.tsx | 184 + .../components/billing_address/index.tsx | 137 + .../components/country-select/index.tsx | 50 + .../components/discount-code/index.tsx | 179 + .../components/error-message/index.tsx | 13 + .../components/payment-button/index.tsx | 288 + .../components/payment-container/index.tsx | 75 + .../components/payment-test/index.tsx | 12 + .../components/payment-wrapper/index.tsx | 63 + .../payment-wrapper/stripe-wrapper.tsx | 50 + .../checkout/components/payment/index.tsx | 280 + .../checkout/components/review/index.tsx | 60 + .../components/shipping-address/index.tsx | 193 + .../checkout/components/shipping/index.tsx | 192 + .../components/submit-button/index.tsx | 32 + .../templates/checkout-form/index.tsx | 68 + .../templates/checkout-summary/index.tsx | 44 + src/modules/collections/templates/index.tsx | 40 + .../common/components/cart-totals/index.tsx | 98 + .../common/components/checkbox/index.tsx | 43 + .../common/components/delete-button/index.tsx | 43 + .../common/components/divider/index.tsx | 9 + .../components/filter-radio-group/index.tsx | 68 + src/modules/common/components/input/index.tsx | 76 + .../components/interactive-link/index.tsx | 33 + .../components/line-item-options/index.tsx | 18 + .../components/line-item-price/index.tsx | 64 + .../components/line-item-unit-price/index.tsx | 62 + .../localized-client-link/index.tsx | 32 + src/modules/common/components/modal/index.tsx | 118 + .../common/components/native-select/index.tsx | 74 + src/modules/common/components/radio/index.tsx | 27 + src/modules/common/icons/back.tsx | 37 + src/modules/common/icons/bancontact.tsx | 26 + src/modules/common/icons/chevron-down.tsx | 30 + src/modules/common/icons/eye-off.tsx | 37 + src/modules/common/icons/eye.tsx | 37 + src/modules/common/icons/fast-delivery.tsx | 65 + src/modules/common/icons/ideal.tsx | 26 + src/modules/common/icons/map-pin.tsx | 37 + src/modules/common/icons/medusa.tsx | 27 + src/modules/common/icons/nextjs.tsx | 27 + src/modules/common/icons/package.tsx | 44 + src/modules/common/icons/paypal.tsx | 30 + .../common/icons/placeholder-image.tsx | 44 + src/modules/common/icons/refresh.tsx | 51 + src/modules/common/icons/spinner.tsx | 37 + src/modules/common/icons/trash.tsx | 51 + src/modules/common/icons/user.tsx | 37 + src/modules/common/icons/x.tsx | 37 + .../components/featured-products/index.tsx | 18 + .../featured-products/product-rail/index.tsx | 43 + src/modules/home/components/hero/index.tsx | 36 + .../layout/components/cart-button/index.tsx | 22 + .../layout/components/cart-dropdown/index.tsx | 222 + .../components/country-select/index.tsx | 124 + .../layout/components/medusa-cta/index.tsx | 21 + .../layout/components/side-menu/index.tsx | 103 + src/modules/layout/templates/footer/index.tsx | 152 + src/modules/layout/templates/index.tsx | 18 + src/modules/layout/templates/nav/index.tsx | 69 + src/modules/order/components/help/index.tsx | 25 + src/modules/order/components/item/index.tsx | 42 + src/modules/order/components/items/index.tsx | 36 + .../order/components/onboarding-cta/index.tsx | 28 + .../order/components/order-details/index.tsx | 54 + .../order/components/order-summary/index.tsx | 57 + .../components/payment-details/index.tsx | 58 + .../components/shipping-details/index.tsx | 66 + .../templates/order-completed-template.tsx | 47 + .../templates/order-details-template.tsx | 47 + .../components/image-gallery/index.tsx | 39 + .../components/mobile-actions/index.tsx | 205 + .../components/option-select/index.tsx | 58 + .../components/product-actions/index.tsx | 194 + .../product-onboarding-cta/index.tsx | 28 + .../components/product-preview/index.tsx | 55 + .../components/product-preview/price.tsx | 23 + .../components/product-price/index.tsx | 65 + .../components/product-tabs/accordion.tsx | 105 + .../components/product-tabs/index.tsx | 127 + .../components/related-products/index.tsx | 83 + .../products/components/thumbnail/index.tsx | 70 + src/modules/products/templates/index.tsx | 70 + .../product-actions-wrapper/index.tsx | 22 + .../products/templates/product-info/index.tsx | 33 + src/modules/search/actions.ts | 30 + src/modules/search/components/hit/index.tsx | 52 + src/modules/search/components/hits/index.tsx | 56 + .../components/search-box-wrapper/index.tsx | 93 + .../search/components/search-box/index.tsx | 91 + .../search/components/show-all/index.tsx | 33 + .../search/templates/search-modal/index.tsx | 83 + .../search-results-template/index.tsx | 63 + .../components/skeleton-button/index.tsx | 5 + .../components/skeleton-cart-item/index.tsx | 35 + .../components/skeleton-cart-totals/index.tsx | 30 + .../components/skeleton-code-form/index.tsx | 13 + .../components/skeleton-line-item/index.tsx | 35 + .../skeleton-order-confirmed-header/index.tsx | 14 + .../skeleton-order-information/index.tsx | 36 + .../components/skeleton-order-items/index.tsx | 43 + .../skeleton-order-summary/index.tsx | 15 + .../skeleton-product-preview/index.tsx | 15 + .../templates/skeleton-cart-page/index.tsx | 65 + .../skeleton-order-confirmed/index.tsx | 21 + .../templates/skeleton-product-grid/index.tsx | 16 + .../skeleton-related-products/index.tsx | 25 + .../store/components/pagination/index.tsx | 114 + .../components/refinement-list/index.tsx | 41 + .../refinement-list/sort-products/index.tsx | 47 + src/modules/store/templates/index.tsx | 39 + .../store/templates/paginated-products.tsx | 77 + src/styles/globals.css | 112 + src/types/global.ts | 58 + src/types/icon.ts | 4 + src/types/medusa.ts | 11 + store-config.js | 16 + store.config.json | 5 + tailwind.config.js | 162 + tsconfig.json | 42 + yarn.lock | 7664 +++++++++++++++++ 264 files changed, 26589 insertions(+) create mode 100644 .env.template create mode 100644 .eslintrc.js create mode 100644 .github/scripts/medusa-config.js create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 .yarnrc.yml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cypress.json create mode 100644 e2e/.env.example create mode 100644 e2e/README.md create mode 100644 e2e/data/reset.ts create mode 100644 e2e/data/seed.ts create mode 100644 e2e/fixtures/account/account-page.ts create mode 100644 e2e/fixtures/account/addresses-page.ts create mode 100644 e2e/fixtures/account/index.ts create mode 100644 e2e/fixtures/account/login-page.ts create mode 100644 e2e/fixtures/account/modals/address-modal.ts create mode 100644 e2e/fixtures/account/order-page.ts create mode 100644 e2e/fixtures/account/orders-page.ts create mode 100644 e2e/fixtures/account/overview-page.ts create mode 100644 e2e/fixtures/account/profile-page.ts create mode 100644 e2e/fixtures/account/register-page.ts create mode 100644 e2e/fixtures/base/base-modal.ts create mode 100644 e2e/fixtures/base/base-page.ts create mode 100644 e2e/fixtures/base/cart-dropdown.ts create mode 100644 e2e/fixtures/base/nav-menu.ts create mode 100644 e2e/fixtures/base/search-modal.ts create mode 100644 e2e/fixtures/cart-page.ts create mode 100644 e2e/fixtures/category-page.ts create mode 100644 e2e/fixtures/checkout-page.ts create mode 100644 e2e/fixtures/index.ts create mode 100644 e2e/fixtures/modals/mobile-actions-modal.ts create mode 100644 e2e/fixtures/order-page.ts create mode 100644 e2e/fixtures/product-page.ts create mode 100644 e2e/fixtures/store-page.ts create mode 100644 e2e/index.ts create mode 100644 e2e/tests/authenticated/address.spec.ts create mode 100644 e2e/tests/authenticated/orders.spec.ts create mode 100644 e2e/tests/authenticated/profile.spec.ts create mode 100644 e2e/tests/global/public-setup.ts create mode 100644 e2e/tests/global/setup.ts create mode 100644 e2e/tests/global/teardown.ts create mode 100644 e2e/tests/public/cart.spec.ts create mode 100644 e2e/tests/public/checkout.spec.ts create mode 100644 e2e/tests/public/discount.spec.ts create mode 100644 e2e/tests/public/giftcard.spec.ts create mode 100644 e2e/tests/public/login.spec.ts create mode 100644 e2e/tests/public/register.spec.ts create mode 100644 e2e/tests/public/search.spec.ts create mode 100644 e2e/utils/index.ts create mode 100644 e2e/utils/locators.ts create mode 100644 netlify.toml create mode 100644 next-env.d.ts create mode 100644 next-sitemap.js create mode 100644 next.config.js create mode 100644 package.json create mode 100644 playwright.config.ts create mode 100644 postcss.config.js create mode 100644 public/favicon.ico create mode 100644 src/app/[countryCode]/(checkout)/checkout/page.tsx create mode 100644 src/app/[countryCode]/(checkout)/layout.tsx create mode 100644 src/app/[countryCode]/(checkout)/not-found.tsx create mode 100644 src/app/[countryCode]/(main)/account/@dashboard/addresses/page.tsx create mode 100644 src/app/[countryCode]/(main)/account/@dashboard/loading.tsx create mode 100644 src/app/[countryCode]/(main)/account/@dashboard/orders/details/[id]/page.tsx create mode 100644 src/app/[countryCode]/(main)/account/@dashboard/orders/page.tsx create mode 100644 src/app/[countryCode]/(main)/account/@dashboard/page.tsx create mode 100644 src/app/[countryCode]/(main)/account/@dashboard/profile/page.tsx create mode 100644 src/app/[countryCode]/(main)/account/@login/page.tsx create mode 100644 src/app/[countryCode]/(main)/account/layout.tsx create mode 100644 src/app/[countryCode]/(main)/account/loading.tsx create mode 100644 src/app/[countryCode]/(main)/cart/loading.tsx create mode 100644 src/app/[countryCode]/(main)/cart/not-found.tsx create mode 100644 src/app/[countryCode]/(main)/cart/page.tsx create mode 100644 src/app/[countryCode]/(main)/categories/[...category]/page.tsx create mode 100644 src/app/[countryCode]/(main)/collections/[handle]/page.tsx create mode 100644 src/app/[countryCode]/(main)/layout.tsx create mode 100644 src/app/[countryCode]/(main)/not-found.tsx create mode 100644 src/app/[countryCode]/(main)/order/confirmed/[id]/loading.tsx create mode 100644 src/app/[countryCode]/(main)/order/confirmed/[id]/page.tsx create mode 100644 src/app/[countryCode]/(main)/page.tsx create mode 100644 src/app/[countryCode]/(main)/products/[handle]/page.tsx create mode 100644 src/app/[countryCode]/(main)/results/[query]/page.tsx create mode 100644 src/app/[countryCode]/(main)/search/page.tsx create mode 100644 src/app/[countryCode]/(main)/store/page.tsx create mode 100644 src/app/actions.ts create mode 100644 src/app/layout.tsx create mode 100644 src/app/not-found.tsx create mode 100644 src/app/opengraph-image.jpg create mode 100644 src/app/twitter-image.jpg create mode 100644 src/lib/config.ts create mode 100644 src/lib/constants.tsx create mode 100644 src/lib/context/modal-context.tsx create mode 100644 src/lib/data/index.ts create mode 100644 src/lib/hooks/use-in-view.tsx create mode 100644 src/lib/hooks/use-toggle-state.tsx create mode 100644 src/lib/search-client.ts create mode 100644 src/lib/util/compare-addresses.ts create mode 100644 src/lib/util/get-checkout-step.ts create mode 100644 src/lib/util/get-number-of-skeletons.ts create mode 100644 src/lib/util/get-precentage-diff.ts create mode 100644 src/lib/util/get-prices-by-price-set-id.ts create mode 100644 src/lib/util/get-product-price.ts create mode 100644 src/lib/util/isEmpty.ts create mode 100644 src/lib/util/medusa-error.ts create mode 100644 src/lib/util/only-unique.ts create mode 100644 src/lib/util/prices.ts create mode 100644 src/lib/util/repeat.ts create mode 100644 src/lib/util/sort-products.ts create mode 100644 src/lib/util/transform-product-preview.ts create mode 100644 src/middleware.ts create mode 100644 src/modules/account/actions.ts create mode 100644 src/modules/account/components/account-info/index.tsx create mode 100644 src/modules/account/components/account-nav/index.tsx create mode 100644 src/modules/account/components/address-book/index.tsx create mode 100644 src/modules/account/components/address-card/add-address.tsx create mode 100644 src/modules/account/components/address-card/edit-address-modal.tsx create mode 100644 src/modules/account/components/login/index.tsx create mode 100644 src/modules/account/components/order-card/index.tsx create mode 100644 src/modules/account/components/order-overview/index.tsx create mode 100644 src/modules/account/components/overview/index.tsx create mode 100644 src/modules/account/components/profile-billing-address/index.tsx create mode 100644 src/modules/account/components/profile-email/index.tsx create mode 100644 src/modules/account/components/profile-name/index.tsx create mode 100644 src/modules/account/components/profile-password/index.tsx create mode 100644 src/modules/account/components/profile-phone/index.tsx create mode 100644 src/modules/account/components/register/index.tsx create mode 100644 src/modules/account/templates/account-layout.tsx create mode 100644 src/modules/account/templates/login-template.tsx create mode 100644 src/modules/cart/actions.ts create mode 100644 src/modules/cart/components/cart-item-select/index.tsx create mode 100644 src/modules/cart/components/empty-cart-message/index.tsx create mode 100644 src/modules/cart/components/item/index.tsx create mode 100644 src/modules/cart/components/sign-in-prompt/index.tsx create mode 100644 src/modules/cart/templates/index.tsx create mode 100644 src/modules/cart/templates/items.tsx create mode 100644 src/modules/cart/templates/preview.tsx create mode 100644 src/modules/cart/templates/summary.tsx create mode 100644 src/modules/categories/templates/index.tsx create mode 100644 src/modules/checkout/actions.ts create mode 100644 src/modules/checkout/components/address-select/index.tsx create mode 100644 src/modules/checkout/components/addresses/index.tsx create mode 100644 src/modules/checkout/components/billing_address/index.tsx create mode 100644 src/modules/checkout/components/country-select/index.tsx create mode 100644 src/modules/checkout/components/discount-code/index.tsx create mode 100644 src/modules/checkout/components/error-message/index.tsx create mode 100644 src/modules/checkout/components/payment-button/index.tsx create mode 100644 src/modules/checkout/components/payment-container/index.tsx create mode 100644 src/modules/checkout/components/payment-test/index.tsx create mode 100644 src/modules/checkout/components/payment-wrapper/index.tsx create mode 100644 src/modules/checkout/components/payment-wrapper/stripe-wrapper.tsx create mode 100644 src/modules/checkout/components/payment/index.tsx create mode 100644 src/modules/checkout/components/review/index.tsx create mode 100644 src/modules/checkout/components/shipping-address/index.tsx create mode 100644 src/modules/checkout/components/shipping/index.tsx create mode 100644 src/modules/checkout/components/submit-button/index.tsx create mode 100644 src/modules/checkout/templates/checkout-form/index.tsx create mode 100644 src/modules/checkout/templates/checkout-summary/index.tsx create mode 100644 src/modules/collections/templates/index.tsx create mode 100644 src/modules/common/components/cart-totals/index.tsx create mode 100644 src/modules/common/components/checkbox/index.tsx create mode 100644 src/modules/common/components/delete-button/index.tsx create mode 100644 src/modules/common/components/divider/index.tsx create mode 100644 src/modules/common/components/filter-radio-group/index.tsx create mode 100644 src/modules/common/components/input/index.tsx create mode 100644 src/modules/common/components/interactive-link/index.tsx create mode 100644 src/modules/common/components/line-item-options/index.tsx create mode 100644 src/modules/common/components/line-item-price/index.tsx create mode 100644 src/modules/common/components/line-item-unit-price/index.tsx create mode 100644 src/modules/common/components/localized-client-link/index.tsx create mode 100644 src/modules/common/components/modal/index.tsx create mode 100644 src/modules/common/components/native-select/index.tsx create mode 100644 src/modules/common/components/radio/index.tsx create mode 100644 src/modules/common/icons/back.tsx create mode 100644 src/modules/common/icons/bancontact.tsx create mode 100644 src/modules/common/icons/chevron-down.tsx create mode 100644 src/modules/common/icons/eye-off.tsx create mode 100644 src/modules/common/icons/eye.tsx create mode 100644 src/modules/common/icons/fast-delivery.tsx create mode 100644 src/modules/common/icons/ideal.tsx create mode 100644 src/modules/common/icons/map-pin.tsx create mode 100644 src/modules/common/icons/medusa.tsx create mode 100644 src/modules/common/icons/nextjs.tsx create mode 100644 src/modules/common/icons/package.tsx create mode 100644 src/modules/common/icons/paypal.tsx create mode 100644 src/modules/common/icons/placeholder-image.tsx create mode 100644 src/modules/common/icons/refresh.tsx create mode 100644 src/modules/common/icons/spinner.tsx create mode 100644 src/modules/common/icons/trash.tsx create mode 100644 src/modules/common/icons/user.tsx create mode 100644 src/modules/common/icons/x.tsx create mode 100644 src/modules/home/components/featured-products/index.tsx create mode 100644 src/modules/home/components/featured-products/product-rail/index.tsx create mode 100644 src/modules/home/components/hero/index.tsx create mode 100644 src/modules/layout/components/cart-button/index.tsx create mode 100644 src/modules/layout/components/cart-dropdown/index.tsx create mode 100644 src/modules/layout/components/country-select/index.tsx create mode 100644 src/modules/layout/components/medusa-cta/index.tsx create mode 100644 src/modules/layout/components/side-menu/index.tsx create mode 100644 src/modules/layout/templates/footer/index.tsx create mode 100644 src/modules/layout/templates/index.tsx create mode 100644 src/modules/layout/templates/nav/index.tsx create mode 100644 src/modules/order/components/help/index.tsx create mode 100644 src/modules/order/components/item/index.tsx create mode 100644 src/modules/order/components/items/index.tsx create mode 100644 src/modules/order/components/onboarding-cta/index.tsx create mode 100644 src/modules/order/components/order-details/index.tsx create mode 100644 src/modules/order/components/order-summary/index.tsx create mode 100644 src/modules/order/components/payment-details/index.tsx create mode 100644 src/modules/order/components/shipping-details/index.tsx create mode 100644 src/modules/order/templates/order-completed-template.tsx create mode 100644 src/modules/order/templates/order-details-template.tsx create mode 100644 src/modules/products/components/image-gallery/index.tsx create mode 100644 src/modules/products/components/mobile-actions/index.tsx create mode 100644 src/modules/products/components/option-select/index.tsx create mode 100644 src/modules/products/components/product-actions/index.tsx create mode 100644 src/modules/products/components/product-onboarding-cta/index.tsx create mode 100644 src/modules/products/components/product-preview/index.tsx create mode 100644 src/modules/products/components/product-preview/price.tsx create mode 100644 src/modules/products/components/product-price/index.tsx create mode 100644 src/modules/products/components/product-tabs/accordion.tsx create mode 100644 src/modules/products/components/product-tabs/index.tsx create mode 100644 src/modules/products/components/related-products/index.tsx create mode 100644 src/modules/products/components/thumbnail/index.tsx create mode 100644 src/modules/products/templates/index.tsx create mode 100644 src/modules/products/templates/product-actions-wrapper/index.tsx create mode 100644 src/modules/products/templates/product-info/index.tsx create mode 100644 src/modules/search/actions.ts create mode 100644 src/modules/search/components/hit/index.tsx create mode 100644 src/modules/search/components/hits/index.tsx create mode 100644 src/modules/search/components/search-box-wrapper/index.tsx create mode 100644 src/modules/search/components/search-box/index.tsx create mode 100644 src/modules/search/components/show-all/index.tsx create mode 100644 src/modules/search/templates/search-modal/index.tsx create mode 100644 src/modules/search/templates/search-results-template/index.tsx create mode 100644 src/modules/skeletons/components/skeleton-button/index.tsx create mode 100644 src/modules/skeletons/components/skeleton-cart-item/index.tsx create mode 100644 src/modules/skeletons/components/skeleton-cart-totals/index.tsx create mode 100644 src/modules/skeletons/components/skeleton-code-form/index.tsx create mode 100644 src/modules/skeletons/components/skeleton-line-item/index.tsx create mode 100644 src/modules/skeletons/components/skeleton-order-confirmed-header/index.tsx create mode 100644 src/modules/skeletons/components/skeleton-order-information/index.tsx create mode 100644 src/modules/skeletons/components/skeleton-order-items/index.tsx create mode 100644 src/modules/skeletons/components/skeleton-order-summary/index.tsx create mode 100644 src/modules/skeletons/components/skeleton-product-preview/index.tsx create mode 100644 src/modules/skeletons/templates/skeleton-cart-page/index.tsx create mode 100644 src/modules/skeletons/templates/skeleton-order-confirmed/index.tsx create mode 100644 src/modules/skeletons/templates/skeleton-product-grid/index.tsx create mode 100644 src/modules/skeletons/templates/skeleton-related-products/index.tsx create mode 100644 src/modules/store/components/pagination/index.tsx create mode 100644 src/modules/store/components/refinement-list/index.tsx create mode 100644 src/modules/store/components/refinement-list/sort-products/index.tsx create mode 100644 src/modules/store/templates/index.tsx create mode 100644 src/modules/store/templates/paginated-products.tsx create mode 100644 src/styles/globals.css create mode 100644 src/types/global.ts create mode 100644 src/types/icon.ts create mode 100644 src/types/medusa.ts create mode 100644 store-config.js create mode 100644 store.config.json create mode 100644 tailwind.config.js create mode 100644 tsconfig.json create mode 100644 yarn.lock diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..5905cab --- /dev/null +++ b/.env.template @@ -0,0 +1,23 @@ +# Your Medusa backend, should be updated to where you are hosting your server. Remember to update CORS settings for your server. See – https://docs.medusajs.com/usage/configurations#admin_cors-and-store_cors +NEXT_PUBLIC_MEDUSA_BACKEND_URL=http://localhost:9000 + +# Your store URL, should be updated to where you are hosting your storefront. +NEXT_PUBLIC_BASE_URL=http://localhost:8000 + +# Your preferred default region. When middleware cannot determine the user region from the "x-vercel-country" header, the default region will be used. ISO-2 lowercase format. +NEXT_PUBLIC_DEFAULT_REGION=us + +# Your Stripe public key. See – https://docs.medusajs.com/add-plugins/stripe +NEXT_PUBLIC_STRIPE_KEY= + +# Your PayPal Client ID. See – https://docs.medusajs.com/add-plugins/paypal +NEXT_PUBLIC_PAYPAL_CLIENT_ID= + +# Your MeiliSearch / Algolia keys. See – https://docs.medusajs.com/add-plugins/meilisearch or https://docs.medusajs.com/add-plugins/algolia +NEXT_PUBLIC_SEARCH_APP_ID= +NEXT_PUBLIC_SEARCH_ENDPOINT=http://127.0.0.1:7700 +NEXT_PUBLIC_SEARCH_API_KEY= +NEXT_PUBLIC_INDEX_NAME=products + +# Your Next.js revalidation secret. See – https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#on-demand-revalidation +REVALIDATE_SECRET=supersecret diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..e050978 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: ["next/core-web-vitals"] +}; \ No newline at end of file diff --git a/.github/scripts/medusa-config.js b/.github/scripts/medusa-config.js new file mode 100644 index 0000000..5a2c690 --- /dev/null +++ b/.github/scripts/medusa-config.js @@ -0,0 +1,117 @@ +const dotenv = require("dotenv"); + +let ENV_FILE_NAME = ""; +switch (process.env.NODE_ENV) { + case "production": + ENV_FILE_NAME = ".env.production"; + break; + case "staging": + ENV_FILE_NAME = ".env.staging"; + break; + case "test": + ENV_FILE_NAME = ".env.test"; + break; + case "development": + default: + ENV_FILE_NAME = ".env"; + break; +} + +try { + dotenv.config({ path: process.cwd() + "/" + ENV_FILE_NAME }); +} catch (e) {} + +// CORS when consuming Medusa from admin +const ADMIN_CORS = + process.env.ADMIN_CORS || "http://localhost:7000,http://localhost:7001"; + +// CORS to avoid issues when consuming Medusa from a client +const STORE_CORS = process.env.STORE_CORS || "http://localhost:8000"; + +const DATABASE_URL = + process.env.DATABASE_URL || "postgres://medusa:password@localhost/medusa"; + +const REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379"; + +const plugins = [ + `medusa-fulfillment-manual`, + `medusa-payment-manual`, + { + resolve: `@medusajs/file-local`, + options: { + upload_dir: "uploads", + }, + }, + { + resolve: "@medusajs/admin", + /** @type {import('@medusajs/admin').PluginOptions} */ + options: { + autoRebuild: true, + develop: { + open: process.env.OPEN_BROWSER !== "false", + }, + }, + }, + { + resolve: `medusa-plugin-meilisearch`, + options: { + config: { + host: process.env.MEILISEARCH_HOST, + apiKey: process.env.MEILISEARCH_API_KEY, + }, + settings: { + products: { + indexSettings: { + searchableAttributes: [ + "title", + "description", + "variant_sku", + ], + displayedAttributes: [ + "id", + "title", + "description", + "variant_sku", + "thumbnail", + "handle", + ], + }, + primaryKey: "id", + }, + }, + }, + }, +]; + +const modules = { + /*eventBus: { + resolve: "@medusajs/event-bus-redis", + options: { + redisUrl: REDIS_URL + } + }, + cacheService: { + resolve: "@medusajs/cache-redis", + options: { + redisUrl: REDIS_URL + } + },*/ +}; + +/** @type {import('@medusajs/medusa').ConfigModule["projectConfig"]} */ +const projectConfig = { + jwtSecret: process.env.JWT_SECRET, + cookieSecret: process.env.COOKIE_SECRET, + store_cors: STORE_CORS, + database_url: DATABASE_URL, + admin_cors: ADMIN_CORS, + // Uncomment the following lines to enable REDIS + redis_url: REDIS_URL +}; + +/** @type {import('@medusajs/medusa').ConfigModule} */ +module.exports = { + projectConfig, + plugins, + modules, +}; diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e53e2ca --- /dev/null +++ b/.gitignore @@ -0,0 +1,57 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# IDEs +.idea +.vscode + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +node_modules + +.yarn +.swc +dump.rdb +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..82fce21 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "arrowParens": "always", + "semi": false, + "endOfLine": "auto", + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..3186f3f --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..94def62 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Medusa + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a2c8868 --- /dev/null +++ b/README.md @@ -0,0 +1,303 @@ +

+ + + + + Medusa logo + + +

+ +

+ Medusa Next.js Starter Template +

+ +

+Combine Medusa's modules for your commerce backend with the newest Next.js 14 features for a performant storefront.

+ +

+ + PRs welcome! + + + Discord Chat + + + Follow @medusajs + +

+ +### Prerequisites + +To use the [Next.js Starter Template](https://medusajs.com/nextjs-commerce/), you should have a Medusa server running locally on port 9000. +For a quick setup, run: + +```shell +npx create-medusa-app@latest +``` + +Check out [create-medusa-app docs](https://docs.medusajs.com/create-medusa-app) for more details and troubleshooting. + +# Overview + +The Medusa Next.js Starter is built with: + +- [Next.js](https://nextjs.org/) +- [Tailwind CSS](https://tailwindcss.com/) +- [Typescript](https://www.typescriptlang.org/) +- [Medusa](https://medusajs.com/) + +Features include: + +- Full ecommerce support: + - Product Detail Page + - Product Overview Page + - Search with Algolia / MeiliSearch + - Product Collections + - Cart + - Checkout with PayPal and Stripe + - User Accounts + - Order Details +- Full Next.js 14 support: + - App Router + - Next fetching/caching + - Server Components + - Server Actions + - Streaming + - Static Pre-Rendering + + +# Quickstart + +### Setting up the environment variables + +Navigate into your projects directory and get your environment variables ready: + +```shell +cd nextjs-starter-medusa/ +mv .env.template .env.local +``` + +### Install dependencies + +Use Yarn to install all dependencies. + +```shell +yarn +``` + +### Start developing + +You are now ready to start up your project. + +```shell +yarn dev +``` + +### Open the code and start customizing + +Your site is now running at http://localhost:8000! + +# Payment integrations + +By default this starter supports the following payment integrations + +- [Stripe](https://stripe.com/) +- [Paypal](https://www.paypal.com/) + +To enable the integrations you need to add the following to your `.env.local` file: + +```shell +NEXT_PUBLIC_STRIPE_KEY= +NEXT_PUBLIC_PAYPAL_CLIENT_ID= +``` + +You will also need to setup the integrations in your Medusa server. See the [Medusa documentation](https://docs.medusajs.com) for more information on how to configure [Stripe](https://docs.medusajs.com/add-plugins/stripe) and [PayPal](https://docs.medusajs.com/add-plugins/paypal) in your Medusa project. + +# Search integration + +This starter is configured to support using the `medusa-search-meilisearch` plugin out of the box. To enable search you will need to enable the feature flag in `./store.config.json`, which you do by changing the config to this: + +```javascript +{ + "features": { + // other features... + "search": true + } +} +``` + +Before you can search you will need to install the plugin in your Medusa server, for a written guide on how to do this – [see our documentation](https://docs.medusajs.com/add-plugins/meilisearch). + +The search components in this starter are developed with Algolia's `react-instant-search-hooks-web` library which should make it possible for you to seemlesly change your search provider to Algolia instead of MeiliSearch. + +To do this you will need to add `algoliasearch` to the project, by running + +```shell +yarn add algoliasearch +``` + +After this you will need to switch the current MeiliSearch `SearchClient` out with a Alogolia client. To do this update `@lib/search-client`. + +```ts +import algoliasearch from "algoliasearch/lite" + +const appId = process.env.NEXT_PUBLIC_SEARCH_APP_ID || "test_app_id" // You should add this to your environment variables + +const apiKey = process.env.NEXT_PUBLIC_SEARCH_API_KEY || "test_key" + +export const searchClient = algoliasearch(appId, apiKey) + +export const SEARCH_INDEX_NAME = + process.env.NEXT_PUBLIC_INDEX_NAME || "products" +``` + +Then, in `src/app/(main)/search/actions.ts`, remove the MeiliSearch code (line 10-16) and uncomment the Algolia code. + +```ts +"use server" + +import { searchClient, SEARCH_INDEX_NAME } from "@lib/search-client" + +/** + * Uses MeiliSearch or Algolia to search for a query + * @param {string} query - search query + */ +export async function search(query: string) { + const index = searchClient.initIndex(SEARCH_INDEX_NAME) + const { hits } = await index.search(query) + + return hits +} +``` + +After this you will need to set up Algolia with your Medusa server, and then you should be good to go. For a more thorough walkthrough of using Algolia with Medusa – [see our documentation](https://docs.medusajs.com/add-plugins/algolia), and the [documentation for using `react-instantsearch-hooks-web`](https://www.algolia.com/doc/guides/building-search-ui/getting-started/react-hooks/). + +## App structure + +For the new version, the main folder structure remains unchanged. The contents have changed quite a bit though. + +``` +. +└── src + ├── app + ├── lib + ├── modules + ├── styles + ├── types + └── middleware.ts + +``` + +### `/app` directory + +The app folder contains all Next.js App Router pages and layouts, and takes care of the routing. + +``` +. +└── [countryCode] + ├── (checkout) + └── checkout + └── (main) + ├── account + │ ├── addresses + │ └── orders + │ └── details + │ └── [id] + ├── cart + ├── categories + │ └── [...category] + ├── collections + │ └── [handle] + ├── order + │ └── confirmed + │ └── [id] + ├── products + │ └── [handle] + ├── results + │ └── [query] + ├── search + └── store +``` + +The app router folder structure represents the routes of the Starter. In this case, the structure is as follows: + +- The root directory is represented by the `[countryCode]` folder. This indicates a dynamic route based on the country code. The this will be populated by the countries you set up in your Medusa server. The param is then used to fetch region specific prices, languages, etc. +- Within the root directory, there two Route Groups: `(checkout)` and `(main)`. This is done because the checkout flow uses a different layout. All other parts of the app share the same layout and are in subdirectories of the `(main)` group. Route Groups do not affect the url. +- Each of these subdirectories may have further subdirectories. For instance, the `account` directory has `addresses` and `orders` subdirectories. The `orders` directory further has a `details` subdirectory, which itself has a dynamic `[id]` subdirectory. +- This nested structure allows for specific routing to various pages within the application. For example, a URL like `/account/orders/details/123` would correspond to the `account > orders > details > [id]` path in the router structure, with `123` being the dynamic `[id]`. + +This structure enables efficient routing and organization of different parts of the Starter. + +### `/lib` **directory** + +The lib directory contains all utilities like the Medusa JS client functions, util functions, config and constants. + +The most important file here is `/lib/data/index.ts`. This file defines various functions for interacting with the Medusa API, using the JS client. The functions cover a range of actions related to shopping carts, orders, shipping, authentication, customer management, regions, products, collections, and categories. It also includes utility functions for handling headers and errors, as well as some functions for sorting and transforming product data. + +These functions are used in different Server Actions. + +### `/modules` directory + +This is where all the components, templates and Server Actions are, grouped by section. Some subdirectories have an `actions.ts` file. These files contain all Server Actions relevant to that section of the app. + +### `/styles` directory + +`global.css` imports Tailwind classes and defines a couple of global CSS classes. Tailwind and Medusa UI classes are used for styling throughout the app. + +### `/types` directory + +Contains global TypeScript type defintions. + +### `middleware.ts` + +Next.js Middleware, which is basically an Edge function that runs before (almost) every request. In our case it enforces a `countryCode` in the url. So when a user visits any url on your storefront without a `countryCode` param, it will redirect the user to the url for the most relevant region. + +The region will be decided as follows: + +- When deployed on Vercel and you’re active in the user’s current country, it will use the country code from the `x-vercel-ip-country` header. +- Else, if you have defined a `NEXT_PUBLIC_DEFAULT_REGION` environment variable, it will redirect to that. +- Else, it will redirect the user to the first region it finds on your Medusa server. + +If you want to use the `countryCode` param in your code, there’s two ways to do that: + +1. On the server in any `page.tsx` - the `countryCode` is in the `params` object: + + ```tsx + export default async function Page({ + params: { countryCode }, + }: { + params: { countryCode: string } + }) { + const region = await getRegion(countryCode) + + // rest of code + ``` + +2. From client components, with the `useParam` hook: + + ```tsx + import { useParams } from "next/navigation" + + const Component = () => { + const { countryCode } = useParams() + + // rest of code + ``` + + +The middleware also sets a cookie based on the onboarding status of a user. This is related to the Medusa Admin onboarding flow, and may be safely removed in your production storefront. + +# Resources + +## Learn more about Medusa + +- [Website](https://www.medusajs.com/) +- [GitHub](https://github.com/medusajs) +- [Documentation](https://docs.medusajs.com/) + +## Learn more about Next.js + +- [Website](https://nextjs.org/) +- [GitHub](https://github.com/vercel/next.js) +- [Documentation](https://nextjs.org/docs) diff --git a/cypress.json b/cypress.json new file mode 100644 index 0000000..9e56598 --- /dev/null +++ b/cypress.json @@ -0,0 +1,8 @@ +{ + "baseUrl": "http://localhost:8000", + "env": { + "codeCoverage": { + "url": "/api/__coverage__" + } + } +} \ No newline at end of file diff --git a/e2e/.env.example b/e2e/.env.example new file mode 100644 index 0000000..6313e9b --- /dev/null +++ b/e2e/.env.example @@ -0,0 +1,19 @@ +# Need a superuser to reset database +PGHOST=localhost +PGPORT=5432 +PGUSER=postgres +PGPASSWORD=password +PGDATABASE=postgres + +# Test database config +TEST_POSTGRES_USER=test_medusa_user +TEST_POSTGRES_DATABASE=test_medusa_db +TEST_POSTGRES_DATABASE_TEMPLATE=test_medusa_db_template +TEST_POSTGRES_HOST=localhost +TEST_POSTGREST_PORT=5432 +PRODUCTION_POSTGRES_DATABASE=medusa_db + +# Backend server API +CLIENT_SERVER=http://localhost:9000 +MEDUSA_ADMIN_EMAIL=admin@medusa-test.com +MEDUSA_ADMIN_PASSWORD=supersecret \ No newline at end of file diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..9dac2c3 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,89 @@ +# About + +This folder contains an end to end testing suite written with playwright checking all of the main functionality provided by this template. Note it assumes you are using a postgres database on the backend and have configured a test database. This is required because the tests will **drop and recreate the test database** in order to ensure replicability between test runs. + +This test suite was built off of using the [medusa-starter-default](https://github.com/medusajs/medusa-starter-default) repository with the seed data from `data/seed.json`. + +# Setup + +## .env + +These tests have a number of dependent environment variables, with an example found in `.env.example`. You can setup your local environment by copying the example environment file + +```sh +cat e2e/.env.example >> .env +``` + +and configuring the `.env` file from there. There are more details below about what the test values correspond to and how to set them. But we mention that + +* `CLIENT_SERVER` - is the server the next server is listening on + +## Playwright + +In order to run these tests, make sure playwright and a playwright-enabled browser is installed. You can do this by running + +```sh +npx playwright install +``` + +## Database + +Note that **these tests drop and reset the database** after each test run. This means you will need to configure a separate test database based on your development or production database. We give some instructions for doing so, and enforce a rule which requires the test database to have the prefix `test_` in its name. + +### Environment variables + +- `TEST_POSTGRES_USER` - user for connecting to the test database, for example, `medusa` +- `TEST_POSTGRES_PASSWORD` - password for connecting to the test database, for example `my_secret_password` +- `TEST_POSTGRES_DATABASE` - name of the test database, must start with the prefix `test*`, for example `test_medusa_db` +- `TEST_POSTGRES_HOST` - optional - host for the postgres database, defaults to `localhost` +- `TEST_POSTGREST_PORT` - optional - host for the postgres +- `PRODUCTION_POSTGRES_DATABASE` - name of the production database, for example `medusa_db` + +in addition, there are environment variables for connecting to the database as a superuser, so we can efficiently reset the database. + +* `PGHOST` - host for the postgres instance +* `PGPORT` - port for the postgres instance +* `PGUSER` - superuser for the postgres instance +* `PGPASSWORD` - superuser password for the postgres instance +* `PGDATABASE` - database we connect to while updating the other databases + +### Test Database Failsafes + +There are a few failsafes to ensure the test and production databases don't get mixed up. This includes: + +- Ensuring the production database doesn't have the same name as the test database +- Ensuring the test database starts with the prefix `test_` + +Note running the test suite will trigger database drops and recreations of the test database. + +### Using a separate database + +If you need to run your project with a separate database, such as sqlite, MySQL, or something else, please refer to `seed/reset.ts` and implement your own `resetDatabase` function which can be run between test runs. + +# Running the test suite + +## Test environment + +Before running the test suite, make sure to start the backend server the medusa client is using. In addition, make sure to run in the nextjs template directory + +```sh +yarn build +``` + +so the project is built. + +## Calling the tests + +You can run the test suite in the base directory of the project with either + +```sh +yarn test-e2e +``` + +or + +```sh +npm run test-e2e +``` + +While the test suite is running, it is configured to automatically run the nextjs template during test execution. diff --git a/e2e/data/reset.ts b/e2e/data/reset.ts new file mode 100644 index 0000000..07c8644 --- /dev/null +++ b/e2e/data/reset.ts @@ -0,0 +1,106 @@ +import { Client } from "pg" + +async function getDatabaseClient() { + testEnvChecks() + const env = getEnv() + const client = new Client(env.superuser) + await client.connect() + return client +} + +function getEnv() { + return { + host: process.env.TEST_POSTGRES_HOST || "localhost", + port: process.env.TEST_POSTGRES_HOST + ? Number(process.env.TEST_POSTGRES_HOST) + : 5432, + user: process.env.TEST_POSTGRES_USER || "test_medusa_user", + testDatabase: process.env.TEST_POSTGRES_DATABASE || "test_medusa_db", + testDatabaseTemplate: + process.env.TEST_POSTGRES_DATABASE_TEMPLATE || "test_medusa_db_template", + productionDatabase: process.env.PRODUCTION_POSTGRES_DATABASE || "medusa_db", + superuser: { + host: process.env.PGHOST || "localhost", + port: process.env.PGPORT ? Number(process.env.PGPORT) : 5432, + user: process.env.PGUSER || "postgres", + password: process.env.PGPASSWORD || "password", + database: process.env.PGDATABASE || "postgres", + }, + } +} + +async function testEnvChecks() { + const env = getEnv() + if (!env.testDatabase.startsWith("test_")) { + const msg = + "Please make sure your test environment database name starts with test_" + console.error(msg) + throw new Error(msg) + } + if (env.testDatabase === env.productionDatabase) { + const msg = + "Please make sure your test environment database and production environment database names are not equal" + console.error(msg) + throw new Error(msg) + } +} + +async function createTemplateDatabase(client: Client) { + const { user, testDatabase, testDatabaseTemplate } = getEnv() + try { + // close current connections + await client.query(` + ALTER DATABASE ${testDatabase} WITH ALLOW_CONNECTIONS false; + SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname='${testDatabase}'; + `) + await client.query(` + CREATE DATABASE ${testDatabaseTemplate} WITH + OWNER=${user} + TEMPLATE=${testDatabase} + IS_TEMPLATE=true; + `) + } catch (e: any) { + // duplicate database code + if (e.code === "42P04") { + return + } + throw e + } +} + +async function createTestDatabase(client: Client) { + const { user, testDatabase, testDatabaseTemplate } = getEnv() + const deleteDatabase = `${testDatabase}_del` + // drop connections and alter database name + await client.query(` + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname='${testDatabase}'; + ALTER DATABASE ${testDatabase} + RENAME TO ${deleteDatabase}; + `) + await client.query(` + CREATE DATABASE ${testDatabase} + WITH OWNER ${user} + TEMPLATE=${testDatabaseTemplate}; + `) + await client.query(`DROP DATABASE ${deleteDatabase}`) +} + +export async function resetDatabase() { + const client = await getDatabaseClient() + await createTemplateDatabase(client) + await createTestDatabase(client) + await client.end() +} + +export async function dropTemplate() { + const client = await getDatabaseClient() + const env = getEnv() + await client.query( + `ALTER DATABASE ${env.testDatabaseTemplate} is_template false` + ) + await client.query(`DROP DATABASE ${env.testDatabaseTemplate}`) + await client.end() +} diff --git a/e2e/data/seed.ts b/e2e/data/seed.ts new file mode 100644 index 0000000..61c1cd0 --- /dev/null +++ b/e2e/data/seed.ts @@ -0,0 +1,102 @@ +import axios, { AxiosError, AxiosInstance } from "axios" + +axios.defaults.baseURL = process.env.CLIENT_SERVER || "http://localhost:9000" +let region = undefined as any + +export async function seedData() { + const axios = getOrInitAxios() + return { + user: await seedUser(), + } +} + +export async function seedUser(email?: string, password?: string) { + const user = { + first_name: "Test", + last_name: "User", + email: email || "test@example.com", + password: password || "password", + } + try { + await axios.post("/store/customers", user) + return user + } catch (e: unknown) { + if (e instanceof AxiosError) { + if (e.response && e.response.status) { + const status = e.response.status + // https://docs.medusajs.com/api/store#customers_postcustomers + if (status === 422) { + return user + } + } + throw e + } + } +} + +async function loadRegion(axios: AxiosInstance) { + const resp = await axios.get("/admin/regions") + region = resp.data.regions.filter((r: any) => r.currency_code === "usd")[0] +} + +async function getOrInitAxios(axios?: AxiosInstance) { + if (!axios) { + axios = await loginAdmin() + } + if (!region) { + await loadRegion(axios) + } + return axios +} + +export async function seedGiftcard(axios?: AxiosInstance) { + axios = await getOrInitAxios(axios) + const resp = await axios.post("/admin/gift-cards", { + region_id: region.id, + value: 10000, + }) + resp.data.gift_card.amount = resp.data.gift_card.value.toString() + return resp.data.gift_card as { + id: string + code: string + value: number + amount: string + balance: string + } +} + +export async function seedDiscount(axios?: AxiosInstance) { + axios = await getOrInitAxios(axios) + const amount = 2000 + const resp = await axios.post("/admin/discounts", { + code: "TEST_DISCOUNT_FIXED", + regions: [region.id], + rule: { + type: "fixed", + value: amount, + allocation: "total", + }, + }) + const discount = resp.data.discount + return { + id: discount.id, + code: discount.code, + rule_id: discount.rule_id, + amount, + } +} + +async function loginAdmin() { + const resp = await axios.post("/admin/auth/token", { + email: process.env.MEDUSA_ADMIN_EMAIL || "admin@medusa-test.com", + password: process.env.MEDUSA_ADMIN_PASSWORD || "supersecret", + }) + if (resp.status !== 200) { + throw { error: "must be able to log in user" } + } + return axios.create({ + headers: { + Authorization: `Bearer ${resp.data.access_token}`, + }, + }) +} diff --git a/e2e/fixtures/account/account-page.ts b/e2e/fixtures/account/account-page.ts new file mode 100644 index 0000000..14046cc --- /dev/null +++ b/e2e/fixtures/account/account-page.ts @@ -0,0 +1,45 @@ +import { Locator, Page } from "@playwright/test" +import { BasePage } from "../base/base-page" + +export class AccountPage extends BasePage { + container: Locator + accountNav: Locator + + overviewLink: Locator + profileLink: Locator + addressesLink: Locator + ordersLink: Locator + logoutLink: Locator + + mobileAccountNav: Locator + mobileAccountMainLink : Locator + mobileOverviewLink : Locator + mobileProfileLink : Locator + mobileAddressesLink : Locator + mobileOrdersLink : Locator + mobileLogoutLink : Locator + + constructor(page: Page) { + super(page) + this.container = page.getByTestId("account-page") + this.accountNav = this.container.getByTestId("account-nav") + this.overviewLink = this.accountNav.getByTestId("overview-link") + this.profileLink = this.accountNav.getByTestId("profile-link") + this.addressesLink = this.accountNav.getByTestId("addresses-link") + this.ordersLink = this.accountNav.getByTestId("orders-link") + this.logoutLink = this.accountNav.getByTestId("logout-button") + + this.mobileAccountNav = this.container.getByTestId("mobile-account-nav") + this.mobileAccountMainLink = this.mobileAccountNav.getByTestId("account-main-link") + this.mobileOverviewLink = this.mobileAccountNav.getByTestId("overview-link") + this.mobileProfileLink = this.mobileAccountNav.getByTestId("profile-link") + this.mobileAddressesLink = this.mobileAccountNav.getByTestId("addresses-link") + this.mobileOrdersLink = this.mobileAccountNav.getByTestId("orders-link") + this.mobileLogoutLink = this.mobileAccountNav.getByTestId("logout-button") + } + + async goto() { + await this.navMenu.navAccountLink.click() + await this.container.waitFor({ state: "visible" }) + } +} diff --git a/e2e/fixtures/account/addresses-page.ts b/e2e/fixtures/account/addresses-page.ts new file mode 100644 index 0000000..3a0734c --- /dev/null +++ b/e2e/fixtures/account/addresses-page.ts @@ -0,0 +1,42 @@ +import { Locator, Page } from "@playwright/test" +import { AccountPage } from "./account-page" +import { AddressModal } from "./modals/address-modal" + +export class AddressesPage extends AccountPage { + addAddressModal: AddressModal + editAddressModal: AddressModal + addressContainer: Locator + addressesWrapper: Locator + newAddressButton: Locator + + constructor(page: Page) { + super(page) + this.addAddressModal = new AddressModal(page, "add") + this.editAddressModal = new AddressModal(page, "edit") + this.addressContainer = this.container.getByTestId("address-container") + this.addressesWrapper = page.getByTestId("addresses-page-wrapper") + this.newAddressButton = this.container.getByTestId("add-address-button") + } + + getAddressContainer(text: string) { + const container = this.page + .getByTestId("address-container") + .filter({ hasText: text }) + return { + container, + editButton: container.getByTestId('address-edit-button'), + deleteButton: container.getByTestId("address-delete-button"), + name: container.getByTestId("address-name"), + company: container.getByTestId("address-company"), + address: container.getByTestId("address-address"), + postalCity: container.getByTestId("address-postal-city"), + provinceCountry: container.getByTestId("address-province-country"), + } + } + + async goto() { + await super.goto() + await this.addressesLink.click() + await this.addressesWrapper.waitFor({ state: "visible" }) + } +} diff --git a/e2e/fixtures/account/index.ts b/e2e/fixtures/account/index.ts new file mode 100644 index 0000000..982b0bd --- /dev/null +++ b/e2e/fixtures/account/index.ts @@ -0,0 +1,47 @@ +import { test as base } from "@playwright/test" +import { AddressesPage } from "./addresses-page" +import { LoginPage } from "./login-page" +import { OrderPage } from "./order-page" +import { OrdersPage } from "./orders-page" +import { OverviewPage } from "./overview-page" +import { ProfilePage } from "./profile-page" +import { RegisterPage } from "./register-page" + +export const accountFixtures = base.extend<{ + accountAddressesPage: AddressesPage + accountOrderPage: OrderPage + accountOrdersPage: OrdersPage + accountOverviewPage: OverviewPage + accountProfilePage: ProfilePage + loginPage: LoginPage + registerPage: RegisterPage +}>({ + accountAddressesPage: async ({ page }, use) => { + const addressesPage = new AddressesPage(page) + await use(addressesPage) + }, + accountOrderPage: async ({ page }, use) => { + const orderPage = new OrderPage(page) + await use(orderPage) + }, + accountOrdersPage: async ({ page }, use) => { + const ordersPage = new OrdersPage(page) + await use(ordersPage) + }, + accountOverviewPage: async ({ page }, use) => { + const overviewPage = new OverviewPage(page) + await use(overviewPage) + }, + accountProfilePage: async ({ page }, use) => { + const profilePage = new ProfilePage(page) + await use(profilePage) + }, + loginPage: async ({ page }, use) => { + const loginPage = new LoginPage(page) + await use(loginPage) + }, + registerPage: async ({ page }, use) => { + const registerPage = new RegisterPage(page) + await use(registerPage) + }, +}) diff --git a/e2e/fixtures/account/login-page.ts b/e2e/fixtures/account/login-page.ts new file mode 100644 index 0000000..8c0e70b --- /dev/null +++ b/e2e/fixtures/account/login-page.ts @@ -0,0 +1,26 @@ +import { Locator, Page } from "@playwright/test" +import { BasePage } from "../base/base-page" + +export class LoginPage extends BasePage { + container: Locator + emailInput: Locator + passwordInput: Locator + signInButton: Locator + registerButton: Locator + errorMessage: Locator + + constructor(page: Page) { + super(page) + this.container = page.getByTestId("login-page") + this.emailInput = this.container.getByTestId("email-input") + this.passwordInput = this.container.getByTestId("password-input") + this.signInButton = this.container.getByTestId("sign-in-button") + this.registerButton = this.container.getByTestId("register-button") + this.errorMessage = this.container.getByTestId("login-error-message") + } + + async goto() { + await this.page.goto("/account") + await this.container.waitFor({ state: "visible" }) + } +} diff --git a/e2e/fixtures/account/modals/address-modal.ts b/e2e/fixtures/account/modals/address-modal.ts new file mode 100644 index 0000000..6917bf1 --- /dev/null +++ b/e2e/fixtures/account/modals/address-modal.ts @@ -0,0 +1,40 @@ +import { Page, Locator } from "@playwright/test" +import { BaseModal } from "../../base/base-modal" + +export class AddressModal extends BaseModal { + saveButton: Locator + cancelButton: Locator + + firstNameInput: Locator + lastNameInput: Locator + companyInput: Locator + address1Input: Locator + address2Input: Locator + postalCodeInput: Locator + cityInput: Locator + stateInput: Locator + countrySelect: Locator + phoneInput: Locator + + constructor(page: Page, modalType: "add" | "edit") { + if (modalType === "add") { + super(page, page.getByTestId("add-address-modal")) + } else { + super(page, page.getByTestId("edit-address-modal")) + } + + this.saveButton = this.container.getByTestId("save-button") + this.cancelButton = this.container.getByTestId("cancel-button") + + this.firstNameInput = this.container.getByTestId("first-name-input") + this.lastNameInput = this.container.getByTestId("last-name-input") + this.companyInput = this.container.getByTestId("company-input") + this.address1Input = this.container.getByTestId("address-1-input") + this.address2Input = this.container.getByTestId("address-2-input") + this.postalCodeInput = this.container.getByTestId("postal-code-input") + this.cityInput = this.container.getByTestId("city-input") + this.stateInput = this.container.getByTestId("state-input") + this.countrySelect = this.container.getByTestId("country-select") + this.phoneInput = this.container.getByTestId("phone-input") + } +} diff --git a/e2e/fixtures/account/order-page.ts b/e2e/fixtures/account/order-page.ts new file mode 100644 index 0000000..87b0017 --- /dev/null +++ b/e2e/fixtures/account/order-page.ts @@ -0,0 +1,80 @@ +import { Locator, Page } from "@playwright/test" +import { AccountPage } from "./account-page" + +export class OrderPage extends AccountPage { + container: Locator + backToOverviewButton: Locator + orderEmail: Locator + orderDate: Locator + orderId: Locator + orderStatus: Locator + orderPaymentStatus: Locator + shippingAddressSummary: Locator + shippingContactSummary: Locator + shippingMethodSummary: Locator + paymentMethod: Locator + paymentAmount: Locator + productsTable: Locator + productRow: Locator + productTitle: Locator + productVariant: Locator + productQuantity: Locator + productOriginalPrice: Locator + productPrice: Locator + productUnitOriginalPrice: Locator + productUnitPrice: Locator + + constructor(page: Page) { + super(page) + this.container = page.getByTestId("order-details-container") + this.backToOverviewButton = page.getByTestId("back-to-overview-button") + this.orderEmail = this.container.getByTestId("order-email") + this.orderDate = this.container.getByTestId("order-date") + this.orderId = this.container.getByTestId("order-id") + this.orderStatus = this.container.getByTestId("order-status") + this.orderPaymentStatus = this.container.getByTestId("order-payment-status") + this.shippingAddressSummary = this.container.getByTestId( + "shipping-address-summary" + ) + this.shippingContactSummary = this.container.getByTestId( + "shipping-contact-summary" + ) + this.shippingMethodSummary = this.container.getByTestId( + "shipping-method-summary" + ) + this.paymentMethod = this.container.getByTestId("payment-method") + this.paymentAmount = this.container.getByTestId("payment-amount") + + this.productsTable = this.container.getByTestId("products-table") + this.productRow = this.container.getByTestId("product-row") + this.productTitle = this.container.getByTestId("product-title") + this.productVariant = this.container.getByTestId("product-variant") + this.productQuantity = this.container.getByTestId("product-quantity") + this.productOriginalPrice = this.container.getByTestId( + "product-original-price" + ) + this.productPrice = this.container.getByTestId("product-price") + this.productUnitOriginalPrice = this.container.getByTestId( + "product-unit-original-price" + ) + this.productUnitPrice = this.container.getByTestId("product-unit-price") + } + + async getProduct(title: string, variant: string) { + const productRow = this.productRow + .filter({ + hasText: title, + }) + .filter({ + hasText: `Variant: ${variant}`, + }) + return { + productRow, + name: productRow.getByTestId("product-name"), + variant: productRow.getByTestId("product-variant"), + quantity: productRow.getByTestId("product-quantity"), + price: productRow.getByTestId("product-unit-price"), + total: productRow.getByTestId("product-price"), + } + } +} diff --git a/e2e/fixtures/account/orders-page.ts b/e2e/fixtures/account/orders-page.ts new file mode 100644 index 0000000..3a45b55 --- /dev/null +++ b/e2e/fixtures/account/orders-page.ts @@ -0,0 +1,57 @@ +import { Locator, Page } from "@playwright/test" +import { AccountPage } from "./account-page" + +export class OrdersPage extends AccountPage { + ordersWrapper: Locator + noOrdersContainer: Locator + continueShoppingButton: Locator + orderCard: Locator + orderDisplayId: Locator + + constructor(page: Page) { + super(page) + this.ordersWrapper = page.getByTestId("orders-page-wrapper") + this.noOrdersContainer = page.getByTestId("no-orders-container") + this.continueShoppingButton = page.getByTestId("continue-shopping-button") + this.orderCard = page.getByTestId("order-card") + this.orderDisplayId = page.getByTestId("order-display-id") + + this.orderCard = page.getByTestId("order-card") + this.orderDisplayId = page.getByTestId("order-display-id") + } + + async getOrderById(orderId: string) { + const orderIdLocator = this.page + .getByTestId("order-display-id") + .filter({ + hasText: orderId, + }) + .first() + const card = this.orderCard.filter({ has: orderIdLocator }).first() + const items = (await card.getByTestId("order-item").all()).map( + (orderItem) => { + return { + item: orderItem, + title: orderItem.getByTestId("item-title"), + quantity: orderItem.getByTestId("item-quantity"), + } + } + ) + return { + card, + displayId: card.getByTestId("order-display-id"), + createdAt: card.getByTestId("order-created-at"), + orderId: card.getByTestId("order-display-id"), + amount: card.getByTestId("order-amount"), + detailsLink: card.getByTestId("order-details-link"), + itemsLocator: card.getByTestId("order-item"), + items, + } + } + + async goto() { + await super.goto() + await this.ordersLink.click() + await this.ordersWrapper.waitFor({ state: "visible" }) + } +} diff --git a/e2e/fixtures/account/overview-page.ts b/e2e/fixtures/account/overview-page.ts new file mode 100644 index 0000000..92b6ce2 --- /dev/null +++ b/e2e/fixtures/account/overview-page.ts @@ -0,0 +1,46 @@ +import { Locator, Page } from "@playwright/test" +import { AccountPage } from "./account-page" + +export class OverviewPage extends AccountPage { + welcomeMessage: Locator + customerEmail: Locator + profileCompletion: Locator + addressesCount: Locator + noOrdersMessage: Locator + ordersWrapper: Locator + orderWrapper: Locator + overviewWrapper: Locator + + constructor(page: Page) { + super(page) + this.overviewWrapper = this.container.getByTestId("overview-page-wrapper") + this.welcomeMessage = this.container.getByTestId("welcome-message") + this.customerEmail = this.container.getByTestId("customer-email") + this.profileCompletion = this.container.getByTestId( + "customer-profile-completion" + ) + this.addressesCount = this.container.getByTestId("addresses-count") + this.noOrdersMessage = this.container.getByTestId("no-orders-message") + this.ordersWrapper = this.container.getByTestId("orders-wrapper") + this.orderWrapper = this.container.getByTestId("order-wrapper") + } + + async getOrder(orderId: string) { + const order = this.ordersWrapper.locator( + `[data-testid="order-wrapper"][data-value="${orderId}"]` + ) + return { + locator: order, + id: await order.getAttribute("value"), + createdDate: await order.getByTestId("order-created-date"), + displayId: await order.getByTestId("order-id").getAttribute("value"), + amount: await order.getByTestId("order-amount").textContent(), + openButton: order.getByTestId("open-order-button"), + } + } + + async goto() { + await this.navMenu.navAccountLink.click() + await this.container.waitFor({ state: "visible" }) + } +} diff --git a/e2e/fixtures/account/profile-page.ts b/e2e/fixtures/account/profile-page.ts new file mode 100644 index 0000000..3b49e48 --- /dev/null +++ b/e2e/fixtures/account/profile-page.ts @@ -0,0 +1,166 @@ +import { Locator, Page } from "@playwright/test" +import { AccountPage } from "./account-page" +import { camelCase } from "lodash" + +export class ProfilePage extends AccountPage { + profileWrapper: Locator + accountNameEditor: Locator + accountEmailEditor: Locator + accountPhoneEditor: Locator + accountPasswordEditor: Locator + accountBillingAddressEditor: Locator + + nameEditButton: Locator + emailEditButton: Locator + phoneEditButton: Locator + passwordEditButton: Locator + billingAddressEditButton: Locator + + nameSaveButton: Locator + emailSaveButton: Locator + phoneSaveButton: Locator + passwordSaveButton: Locator + billingAddressSaveButton: Locator + + savedName: Locator + savedEmail: Locator + savedPhone: Locator + savedPassword: Locator + savedBillingAddress: Locator + + nameSuccessMessage: Locator + emailSuccessMessage: Locator + phoneSuccessMessage: Locator + passwordSuccessMessage: Locator + billingAddressSuccessMessage: Locator + + nameErrorMessage: Locator + emailErrorMessage: Locator + phoneErrorMessage: Locator + passwordErrorMessage: Locator + billingAddressErrorMessage: Locator + + emailInput: Locator + firstNameInput: Locator + lastNameInput: Locator + + phoneInput: Locator + + oldPasswordInput: Locator + newPasswordInput: Locator + confirmPasswordInput: Locator + + billingAddress1Input: Locator + billingAddress2Input: Locator + billingCityInput: Locator + billingCompanyInput: Locator + billingFirstNameInput: Locator + billingLastNameInput: Locator + billingPostcalCodeInput: Locator + billingProvinceInput: Locator + billingCountryCodeSelect: Locator + + constructor(page: Page) { + super(page) + this.profileWrapper = page.getByTestId("profile-page-wrapper") + this.accountNameEditor = this.container.getByTestId("account-name-editor") + this.accountEmailEditor = this.container.getByTestId("account-email-editor") + this.accountPhoneEditor = this.container.getByTestId("account-phone-editor") + this.accountPasswordEditor = this.container.getByTestId( + "account-password-editor" + ) + this.accountBillingAddressEditor = this.container.getByTestId( + "account-billing-address-editor" + ) + + this.nameEditButton = this.accountNameEditor.getByTestId("edit-button") + this.emailEditButton = this.accountEmailEditor.getByTestId("edit-button") + this.phoneEditButton = this.accountPhoneEditor.getByTestId("edit-button") + this.passwordEditButton = + this.accountPasswordEditor.getByTestId("edit-button") + this.billingAddressEditButton = + this.accountBillingAddressEditor.getByTestId("edit-button") + + this.nameSaveButton = this.accountNameEditor.getByTestId("save-button") + this.emailSaveButton = this.accountEmailEditor.getByTestId("save-button") + this.phoneSaveButton = this.accountPhoneEditor.getByTestId("save-button") + this.passwordSaveButton = + this.accountPasswordEditor.getByTestId("save-button") + this.billingAddressSaveButton = + this.accountBillingAddressEditor.getByTestId("save-button") + + this.savedName = this.accountNameEditor.getByTestId("current-info") + this.savedEmail = this.accountEmailEditor.getByTestId("current-info") + this.savedPhone = this.accountPhoneEditor.getByTestId("current-info") + this.savedPassword = this.accountPasswordEditor.getByTestId("current-info") + this.savedBillingAddress = + this.accountBillingAddressEditor.getByTestId("current-info") + this.nameSuccessMessage = + this.accountNameEditor.getByTestId("success-message") + this.emailSuccessMessage = + this.accountEmailEditor.getByTestId("success-message") + this.phoneSuccessMessage = + this.accountPhoneEditor.getByTestId("success-message") + this.passwordSuccessMessage = + this.accountPasswordEditor.getByTestId("success-message") + this.billingAddressSuccessMessage = + this.accountBillingAddressEditor.getByTestId("success-message") + this.nameErrorMessage = this.accountNameEditor.getByTestId("error-message") + this.emailErrorMessage = + this.accountEmailEditor.getByTestId("error-message") + this.phoneErrorMessage = + this.accountPhoneEditor.getByTestId("error-message") + this.passwordErrorMessage = + this.accountPasswordEditor.getByTestId("error-message") + this.billingAddressErrorMessage = + this.accountBillingAddressEditor.getByTestId("error-message") + + this.firstNameInput = page.getByTestId("first-name-input") + this.lastNameInput = page.getByTestId("last-name-input") + this.emailInput = page.getByTestId("email-input") + this.phoneInput = page.getByTestId("phone-input") + this.oldPasswordInput = page.getByTestId("old-password-input") + this.newPasswordInput = page.getByTestId("new-password-input") + this.confirmPasswordInput = page.getByTestId("confirm-password-input") + + this.billingAddress1Input = page.getByTestId("billing-address-1-input") + this.billingAddress2Input = page.getByTestId("billing-address-2-input") + this.billingCityInput = page.getByTestId("billing-city-input") + this.billingCompanyInput = page.getByTestId("billing-company-input") + this.billingFirstNameInput = page.getByTestId("billing-first-name-input") + this.billingLastNameInput = page.getByTestId("billing-last-name-input") + this.billingPostcalCodeInput = page.getByTestId( + "billing-postcal-code-input" + ) + this.billingProvinceInput = page.getByTestId("billing-province-input") + this.billingCountryCodeSelect = page.getByTestId( + "billing-country-code-select" + ) + } + + async getEditorInputs(editor: Locator) { + const editButton = editor.getByTestId("edit-button") + if ((await editButton.getAttribute("active")) !== "true") { + await editButton.click() + } + // get all the inputs + const inputs = editor.locator( + '[data-testid]:not([data-testid="edit-button"])' + ) + const o = { + editButton, + } as { [k: string]: Locator } + for (const input of await inputs.all()) { + const testId = (await input.getAttribute("data-testid")) as string + const key = camelCase(testId) + o[key] = input + } + return o + } + + async goto() { + super.goto() + await this.profileLink.click() + await this.profileWrapper.waitFor({ state: "visible" }) + } +} diff --git a/e2e/fixtures/account/register-page.ts b/e2e/fixtures/account/register-page.ts new file mode 100644 index 0000000..44df574 --- /dev/null +++ b/e2e/fixtures/account/register-page.ts @@ -0,0 +1,27 @@ +import { Locator, Page } from "@playwright/test" +import { BasePage } from "../base/base-page" + +export class RegisterPage extends BasePage { + container: Locator + firstNameInput: Locator + lastNameInput: Locator + emailInput: Locator + phoneInput: Locator + passwordInput: Locator + registerButton: Locator + registerError: Locator + loginLink: Locator + + constructor(page: Page) { + super(page) + this.container = page.getByTestId("register-page") + this.firstNameInput = this.container.getByTestId("first-name-input") + this.lastNameInput = this.container.getByTestId("last-name-input") + this.emailInput = this.container.getByTestId("email-input") + this.phoneInput = this.container.getByTestId("phone-input") + this.passwordInput = this.container.getByTestId("password-input") + this.registerButton = this.container.getByTestId("register-button") + this.registerError = this.container.getByTestId("register-error") + this.loginLink = this.container.getByTestId("login-link") + } +} diff --git a/e2e/fixtures/base/base-modal.ts b/e2e/fixtures/base/base-modal.ts new file mode 100644 index 0000000..76df70e --- /dev/null +++ b/e2e/fixtures/base/base-modal.ts @@ -0,0 +1,22 @@ +import { Page, Locator } from "@playwright/test" + +export class BaseModal { + page: Page + container: Locator + closeButton: Locator + + constructor(page: Page, container: Locator) { + this.page = page + this.container = container + this.closeButton = this.container.getByTestId("close-modal-button") + } + + async close() { + const button = this.container.getByTestId("close-modal-button") + await button.click() + } + + async isOpen() { + return await this.container.isVisible() + } +} diff --git a/e2e/fixtures/base/base-page.ts b/e2e/fixtures/base/base-page.ts new file mode 100644 index 0000000..2040fa7 --- /dev/null +++ b/e2e/fixtures/base/base-page.ts @@ -0,0 +1,33 @@ +import { CartDropdown } from "./cart-dropdown" +import { NavMenu } from "./nav-menu" +import { Page, Locator } from "@playwright/test" +import { SearchModal } from "./search-modal" + +export class BasePage { + page: Page + navMenu: NavMenu + cartDropdown: CartDropdown + searchModal: SearchModal + accountLink: Locator + cartLink: Locator + searchLink: Locator + storeLink: Locator + categoriesList: Locator + + constructor(page: Page) { + this.page = page + this.navMenu = new NavMenu(page) + this.cartDropdown = new CartDropdown(page) + this.searchModal = new SearchModal(page) + this.accountLink = page.getByTestId("nav-account-link") + this.cartLink = page.getByTestId("nav-cart-link") + this.storeLink = page.getByTestId("nav-store-link") + this.searchLink = page.getByTestId("nav-search-link") + this.categoriesList = page.getByTestId("footer-categories") + } + + async clickCategoryLink(category: string) { + const link = this.categoriesList.getByTestId("category-link") + await link.click() + } +} diff --git a/e2e/fixtures/base/cart-dropdown.ts b/e2e/fixtures/base/cart-dropdown.ts new file mode 100644 index 0000000..40a1440 --- /dev/null +++ b/e2e/fixtures/base/cart-dropdown.ts @@ -0,0 +1,51 @@ +import { Locator, Page } from "@playwright/test" + +export class CartDropdown { + page: Page + navCartLink: Locator + cartDropdown: Locator + cartSubtotal: Locator + goToCartButton: Locator + + constructor(page: Page) { + this.page = page + this.navCartLink = page.getByTestId("nav-cart-link") + this.cartDropdown = page.getByTestId("nav-cart-dropdown") + this.cartSubtotal = this.cartDropdown.getByTestId("cart-subtotal") + this.goToCartButton = this.cartDropdown.getByTestId("go-to-cart-button") + } + + async displayCart() { + await this.navCartLink.hover() + } + + async close() { + if (await this.cartDropdown.isVisible()) { + const box = await this.cartDropdown.boundingBox() + if (!box) { + return + } + await this.page.mouse.move(box.x + box.width / 4, box.y + box.height / 4) + await this.page.mouse.move(5, 10) + } + } + + async getCartItem(name: string, variant: string) { + const cartItem = this.cartDropdown + .getByTestId("cart-item") + .filter({ + hasText: name, + }) + .filter({ + hasText: `Variant: ${variant}`, + }) + return { + locator: cartItem, + productLink: cartItem.getByTestId("product-link"), + removeButton: cartItem.getByTestId("cart-item-remove-button"), + name, + quantity: cartItem.getByTestId("cart-item-quantity"), + variant: cartItem.getByTestId("cart-item-variant"), + } + } +} diff --git a/e2e/fixtures/base/nav-menu.ts b/e2e/fixtures/base/nav-menu.ts new file mode 100644 index 0000000..d17af5b --- /dev/null +++ b/e2e/fixtures/base/nav-menu.ts @@ -0,0 +1,55 @@ +import { Locator, Page } from "@playwright/test" + +export class NavMenu { + page: Page + navMenuButton: Locator + navMenu: Locator + navAccountLink: Locator + homeLink: Locator + storeLink: Locator + searchLink: Locator + accountLink: Locator + cartLink: Locator + closeButton: Locator + shippingToLink: Locator + shippingToMenu: Locator + + constructor(page: Page) { + this.page = page + this.navMenuButton = page.getByTestId("nav-menu-button") + this.navMenu = page.getByTestId("nav-menu-popup") + this.navAccountLink = page.getByTestId("nav-account-link") + this.homeLink = this.navMenu.getByTestId("home-link") + this.storeLink = this.navMenu.getByTestId("store-link") + this.searchLink = this.navMenu.getByTestId("search-link") + this.accountLink = this.navMenu.getByTestId("account-link") + this.cartLink = this.navMenu.getByTestId("nav-cart-link") + this.closeButton = this.navMenu.getByTestId("close-menu-button") + this.shippingToLink = this.navMenu.getByTestId("shipping-to-button") + this.shippingToMenu = this.navMenu.getByTestId("shipping-to-choices") + } + + async selectShippingCountry(country: string) { + if (!(await this.navMenu.isVisible())) { + throw { + error: + `You cannot call ` + + `NavMenu.selectShippingCountry("${country}") without having the ` + + `navMenu visible first!`, + } + } + const countryLink = this.navMenu.getByTestId( + `select-${country.toLowerCase()}-choice` + ) + await this.shippingToLink.hover() + await this.shippingToMenu.waitFor({ + state: "visible", + }) + await countryLink.click() + } + + async open() { + await this.navMenuButton.click() + await this.navMenu.waitFor({ state: "visible" }) + } +} diff --git a/e2e/fixtures/base/search-modal.ts b/e2e/fixtures/base/search-modal.ts new file mode 100644 index 0000000..2724dcb --- /dev/null +++ b/e2e/fixtures/base/search-modal.ts @@ -0,0 +1,36 @@ +import { Page, Locator } from "@playwright/test" +import { BaseModal } from "./base-modal" +import { NavMenu } from "./nav-menu" + +export class SearchModal extends BaseModal { + searchInput: Locator + searchResults: Locator + noSearchResultsContainer: Locator + searchResult: Locator + searchResultTitle: Locator + + constructor(page: Page) { + super(page, page.getByTestId("search-modal-container")) + this.searchInput = this.container.getByTestId("search-input") + this.searchResults = this.container.getByTestId("search-results") + this.noSearchResultsContainer = this.container.getByTestId( + "no-search-results-container" + ) + this.searchResult = this.container.getByTestId("search-result") + this.searchResultTitle = this.container.getByTestId("search-result-title") + } + + async open() { + const menu = new NavMenu(this.page) + await menu.open() + await menu.searchLink.click() + await this.container.waitFor({ state: "visible" }) + } + + async close() { + const viewport = this.page.viewportSize() + const y = viewport ? viewport.height / 2 : 100 + await this.page.mouse.click(1, y, { clickCount: 2, delay: 100 }) + await this.container.waitFor({ state: "hidden" }) + } +} diff --git a/e2e/fixtures/cart-page.ts b/e2e/fixtures/cart-page.ts new file mode 100644 index 0000000..07b4514 --- /dev/null +++ b/e2e/fixtures/cart-page.ts @@ -0,0 +1,119 @@ +import { Locator, Page } from "@playwright/test" +import { BasePage } from "./base/base-page" + +export class CartPage extends BasePage { + container: Locator + emptyCartMessage: Locator + signInButton: Locator + productRow: Locator + productTitle: Locator + productVariant: Locator + productDeleteButton: Locator + productQuantitySelect: Locator + discountButton: Locator + discountInput: Locator + discountApplyButton: Locator + discountErrorMessage: Locator + discountRow: Locator + giftCardRow: Locator + giftCardCode: Locator + giftCardAmount: Locator + giftCardRemoveButton: Locator + cartSubtotal: Locator + cartDiscount: Locator + cartGiftCardAmount: Locator + cartShipping: Locator + cartTaxes: Locator + cartTotal: Locator + checkoutButton: Locator + + constructor(page: Page) { + super(page) + this.container = page.getByTestId("cart-container") + this.emptyCartMessage = this.container.getByTestId("empty-cart-message") + this.signInButton = this.container.getByTestId("sign-in-button") + this.productRow = this.container.getByTestId("product-row") + this.productTitle = this.container.getByTestId("product-title") + this.productVariant = this.container.getByTestId("product-variant") + this.productDeleteButton = this.container.getByTestId( + "product-delete-button" + ) + this.productQuantitySelect = this.container.getByTestId( + "product-quantity-select" + ) + this.checkoutButton = this.container.getByTestId("checkout-button") + this.discountButton = this.container.getByTestId("add-discount-button") + this.discountInput = this.container.getByTestId("discount-input") + this.discountApplyButton = this.container.getByTestId( + "discount-apply-button" + ) + this.discountErrorMessage = this.container.getByTestId( + "discount-error-message" + ) + this.discountRow = this.container.getByTestId("discount-row") + this.giftCardRow = this.container.getByTestId("gift-card") + this.giftCardCode = this.container.getByTestId("gift-card-code") + this.giftCardAmount = this.container.getByTestId("gift-card-amount") + this.giftCardRemoveButton = this.container.getByTestId( + "remove-gift-card-button" + ) + this.cartSubtotal = this.container.getByTestId("cart-subtotal") + this.cartDiscount = this.container.getByTestId("cart-discount") + this.cartGiftCardAmount = this.container.getByTestId( + "cart-gift-card-amount" + ) + this.cartShipping = this.container.getByTestId("cart-shipping") + this.cartTaxes = this.container.getByTestId("cart-taxes") + this.cartTotal = this.container.getByTestId("cart-total") + } + + async getProduct(title: string, variant: string) { + const productRow = this.productRow + .filter({ + hasText: title, + }) + .filter({ + hasText: `Variant: ${variant}`, + }) + return { + productRow, + title: productRow.getByTestId("product-title"), + variant: productRow.getByTestId("product-variant"), + deleteButton: productRow.getByTestId("delete-button"), + quantitySelect: productRow.getByTestId("product-select-button"), + price: productRow.getByTestId("product-unit-price"), + total: productRow.getByTestId("product-price"), + } + } + + async getGiftCard(code: string) { + const giftCardRow = this.giftCardRow.filter({ + hasText: code, + }) + const amount = giftCardRow.getByTestId("gift-card-amount") + return { + locator: giftCardRow, + code: giftCardRow.getByTestId("gift-card-code"), + amount, + amountValue: await amount.getAttribute("data-value"), + removeButton: giftCardRow.getByTestId("remove-gift-card-button"), + } + } + + async getDiscount(code: string) { + const discount = this.discountRow + const amount = discount.getByTestId("discount-amount") + return { + locator: discount, + code: discount.getByTestId("discount-code"), + amount, + amountValue: await amount.getAttribute("data-value"), + removeButton: discount.getByTestId("remove-discount-button"), + } + } + + async goto() { + await this.cartLink.click({ clickCount: 2 }) + await this.container.waitFor({ state: "visible" }) + } +} diff --git a/e2e/fixtures/category-page.ts b/e2e/fixtures/category-page.ts new file mode 100644 index 0000000..6ca67cb --- /dev/null +++ b/e2e/fixtures/category-page.ts @@ -0,0 +1,48 @@ +import { Locator, Page } from "@playwright/test" +import { BasePage } from "./base/base-page" + +export class CategoryPage extends BasePage { + container: Locator + sortByContainer: Locator + + pageTitle: Locator + pagination: Locator + productsListLoader: Locator + productsList: Locator + productWrapper: Locator + + constructor(page: Page) { + super(page) + this.container = page.getByTestId("category-container") + this.pageTitle = page.getByTestId("category-page-title") + this.sortByContainer = page.getByTestId("sort-by-container") + this.productsListLoader = this.container.getByTestId("products-list-loader") + this.productsList = this.container.getByTestId("products-list") + this.productWrapper = this.productsList.getByTestId("product-wrapper") + this.pagination = this.container.getByTestId("product-pagination") + } + + async getProduct(name: string) { + const product = this.productWrapper.filter({ hasText: name }) + return { + locator: product, + title: product.getByTestId("product-title"), + price: product.getByTestId("price"), + originalPrice: product.getByTestId("original-price"), + } + } + + async sortBy(sortString: string) { + const link = this.sortByContainer.getByTestId("sort-by-link").filter({ + hasText: sortString, + }) + await link.click() + // wait for page change + await this.page.waitForFunction((linkElement) => { + if (!linkElement) { + return true + } + return linkElement.dataset.active === "true" + }, await link.elementHandle()) + } +} diff --git a/e2e/fixtures/checkout-page.ts b/e2e/fixtures/checkout-page.ts new file mode 100644 index 0000000..22f99d5 --- /dev/null +++ b/e2e/fixtures/checkout-page.ts @@ -0,0 +1,295 @@ +import { ElementHandle, Locator, Page } from "@playwright/test" +import { BasePage } from "./base/base-page" + +export class CheckoutPage extends BasePage { + backToCartLink: Locator + storeLink: Locator + container: Locator + editAddressButton: Locator + editDeliveryButton: Locator + editPaymentButton: Locator + + shippingAddressSelect: Locator + shippingAddressOptions: Locator + shippingAddressOption: Locator + + billingAddressCheckbox: Locator + billingAddressInput: Locator + billingCityInput: Locator + billingCompanyInput: Locator + billingFirstNameInput: Locator + billingLastNameInput: Locator + billingPhoneInput: Locator + billingPostalInput: Locator + billingProvinceInput: Locator + shippingAddressInput: Locator + shippingCityInput: Locator + shippingCompanyInput: Locator + shippingEmailInput: Locator + shippingFirstNameInput: Locator + shippingLastNameInput: Locator + shippingPhoneInput: Locator + shippingPostalCodeInput: Locator + shippingProvinceInput: Locator + + billingCountrySelect: Locator + shippingCountrySelect: Locator + + shippingAddressSummary: Locator + shippingContactSummary: Locator + billingAddressSummary: Locator + + submitAddressButton: Locator + addressErrorMessage: Locator + + deliveryOptionsContainer: Locator + deliveryOptionRadio: Locator + deliveryOptionErrorMessage: Locator + submitDeliveryOptionButton: Locator + deliveryOptionSummary: Locator + + paymentMethodSummary: Locator + paymentDetailsSummary: Locator + paymentMethodErrorMessage: Locator + stripePaymentErrorMessage: Locator + paypalPaymentErrorMessage: Locator + manualPaymentErrorMessage: Locator + submitPaymentButton: Locator + submitOrderButton: Locator + + discountButton: Locator + discountInput: Locator + discountApplyButton: Locator + discountErrorMessage: Locator + discountRow: Locator + giftCardRow: Locator + giftCardCode: Locator + giftCardAmount: Locator + giftCardRemoveButton: Locator + cartSubtotal: Locator + cartDiscount: Locator + cartGiftCardAmount: Locator + cartShipping: Locator + cartTaxes: Locator + cartTotal: Locator + itemsTable: Locator + itemRow: Locator + itemTitle: Locator + itemVariant: Locator + itemQuantity: Locator + itemOriginalPrice: Locator + itemReducedPrice: Locator + itemUnitOriginalPrice: Locator + itemUnitReducedPrice: Locator + + constructor(page: Page) { + super(page) + this.backToCartLink = page.getByTestId("back-to-cart-link") + this.storeLink = page.getByTestId("store-link") + this.container = page.getByTestId("checkout-container") + + this.editAddressButton = this.container.getByTestId("edit-address-button") + this.editDeliveryButton = this.container.getByTestId("edit-delivery-button") + this.editPaymentButton = this.container.getByTestId("edit-payment-button") + + this.shippingAddressSelect = this.container.getByTestId( + "shipping-address-select" + ) + this.shippingAddressOptions = this.container.getByTestId( + "shipping-address-options" + ) + this.shippingAddressOption = this.container.getByTestId( + "shipping-address-option" + ) + this.billingAddressCheckbox = this.container.getByTestId( + "billing-address-checkbox" + ) + this.billingAddressInput = this.container.getByTestId( + "billing-address-input" + ) + this.billingCityInput = this.container.getByTestId("billing-city-input") + this.billingCompanyInput = this.container.getByTestId( + "billing-company-input" + ) + this.billingFirstNameInput = this.container.getByTestId( + "billing-first-name-input" + ) + this.billingLastNameInput = this.container.getByTestId( + "billing-last-name-input" + ) + this.billingPhoneInput = this.container.getByTestId("billing-phone-input") + this.billingPostalInput = this.container.getByTestId("billing-postal-input") + this.billingProvinceInput = this.container.getByTestId( + "billing-province-input" + ) + this.shippingAddressInput = this.container.getByTestId( + "shipping-address-input" + ) + this.shippingCityInput = this.container.getByTestId("shipping-city-input") + this.shippingCompanyInput = this.container.getByTestId( + "shipping-company-input" + ) + this.shippingEmailInput = this.container.getByTestId("shipping-email-input") + this.shippingFirstNameInput = this.container.getByTestId( + "shipping-first-name-input" + ) + this.shippingLastNameInput = this.container.getByTestId( + "shipping-last-name-input" + ) + this.shippingPhoneInput = this.container.getByTestId("shipping-phone-input") + this.shippingPostalCodeInput = this.container.getByTestId( + "shipping-postal-code-input" + ) + this.shippingProvinceInput = this.container.getByTestId( + "shipping-province-input" + ) + + this.billingCountrySelect = this.container.getByTestId( + "billing-country-select" + ) + this.shippingCountrySelect = this.container.getByTestId( + "shipping-country-select" + ) + + this.shippingAddressSummary = this.container.getByTestId( + "shipping-address-summary" + ) + this.shippingContactSummary = this.container.getByTestId( + "shipping-contact-summary" + ) + this.billingAddressSummary = this.container.getByTestId( + "billing-address-summary" + ) + + this.submitAddressButton = this.container.getByTestId( + "submit-address-button" + ) + this.addressErrorMessage = this.container.getByTestId( + "address-error-message" + ) + + this.deliveryOptionsContainer = this.container.getByTestId( + "delivery-options-container" + ) + this.deliveryOptionRadio = this.container.getByTestId( + "delivery-option-radio" + ) + this.deliveryOptionErrorMessage = this.container.getByTestId( + "delivery-option-error-message" + ) + this.submitDeliveryOptionButton = this.container.getByTestId( + "submit-delivery-option-button" + ) + this.deliveryOptionSummary = this.container.getByTestId( + "delivery-option-summary" + ) + + this.paymentMethodSummary = this.container.getByTestId( + "payment-method-summary" + ) + this.paymentDetailsSummary = this.container.getByTestId( + "payment-details-summary" + ) + this.paymentMethodErrorMessage = this.container.getByTestId( + "payment-method-error-message" + ) + this.submitPaymentButton = this.container.getByTestId( + "submit-payment-button" + ) + this.stripePaymentErrorMessage = this.container.getByTestId( + "stripe-payment-error-message" + ) + this.paypalPaymentErrorMessage = this.container.getByTestId( + "paypal-payment-error-message" + ) + this.manualPaymentErrorMessage = this.container.getByTestId( + "manual-payment-error-message" + ) + this.submitOrderButton = this.container.getByTestId("submit-order-button") + + this.discountButton = this.container.getByTestId("add-discount-button") + this.discountInput = this.container.getByTestId("discount-input") + this.discountApplyButton = this.container.getByTestId( + "discount-apply-button" + ) + this.discountErrorMessage = this.container.getByTestId( + "discount-error-message" + ) + this.discountRow = this.container.getByTestId("discount-row") + this.giftCardRow = this.container.getByTestId("gift-card") + this.giftCardCode = this.container.getByTestId("gift-card-code") + this.giftCardAmount = this.container.getByTestId("gift-card-amount") + this.giftCardRemoveButton = this.container.getByTestId( + "remove-gift-card-button" + ) + this.cartSubtotal = this.container.getByTestId("cart-subtotal") + this.cartDiscount = this.container.getByTestId("cart-discount") + this.cartGiftCardAmount = this.container.getByTestId( + "cart-gift-card-amount" + ) + this.cartShipping = this.container.getByTestId("cart-shipping") + this.cartTaxes = this.container.getByTestId("cart-taxes") + this.cartTotal = this.container.getByTestId("cart-total") + this.itemsTable = this.container.getByTestId("items-table") + this.itemRow = this.container.getByTestId("item-row") + this.itemTitle = this.container.getByTestId("item-title") + this.itemVariant = this.container.getByTestId("item-variant") + this.itemQuantity = this.container.getByTestId("item-quantity") + this.itemOriginalPrice = this.container.getByTestId("item-original-price") + this.itemReducedPrice = this.container.getByTestId("item-reduced-price") + this.itemUnitOriginalPrice = this.container.getByTestId( + "item-unit-original-price" + ) + this.itemUnitReducedPrice = this.container.getByTestId( + "item-unit-reduced-price" + ) + } + + async selectSavedAddress(address: string) { + await this.shippingAddressSelect.click() + const addressOption = this.shippingAddressOption.filter({ + hasText: address, + }) + await addressOption.getByTestId("shipping-address-radio").click() + + const selectHandle = await this.shippingAddressSelect.elementHandle() + await this.page.waitForFunction( + (opts) => { + const select = opts[0] + const choice = opts[1] + return (select.textContent || "").includes(choice) + }, + [selectHandle, address] as [ElementHandle, string] + ) + } + + async selectDeliveryOption(option: string) { + await this.deliveryOptionRadio.filter({ hasText: option }).click() + } + + async getGiftCard(code: string) { + const giftCardRow = this.giftCardRow.filter({ + hasText: code, + }) + const amount = giftCardRow.getByTestId("gift-card-amount") + return { + locator: giftCardRow, + code: giftCardRow.getByTestId("gift-card-code"), + amount, + amountValue: await amount.getAttribute("data-value"), + removeButton: giftCardRow.getByTestId("remove-gift-card-button"), + } + } + + async getDiscount(code: string) { + const discount = this.discountRow + const amount = discount.getByTestId("discount-amount") + return { + locator: discount, + code: discount.getByTestId("discount-code"), + amount, + amountValue: await amount.getAttribute("data-value"), + removeButton: discount.getByTestId("remove-discount-button"), + } + } +} diff --git a/e2e/fixtures/index.ts b/e2e/fixtures/index.ts new file mode 100644 index 0000000..ff87f33 --- /dev/null +++ b/e2e/fixtures/index.ts @@ -0,0 +1,54 @@ +import { test as base, Page } from "@playwright/test" +import { resetDatabase } from "../data/reset" +import { CartPage } from "./cart-page" +import { CategoryPage } from "./category-page" +import { CheckoutPage } from "./checkout-page" +import { OrderPage } from "./order-page" +import { ProductPage } from "./product-page" +import { StorePage } from "./store-page" + +export const fixtures = base.extend<{ + resetDatabaseFixture: void + cartPage: CartPage + categoryPage: CategoryPage + checkoutPage: CheckoutPage + orderPage: OrderPage + productPage: ProductPage + storePage: StorePage +}>({ + page: async ({ page }, use) => { + await page.goto("/") + use(page) + }, + resetDatabaseFixture: [ + async function ({}, use) { + await resetDatabase() + await use() + }, + { auto: true, timeout: 10000 }, + ], + cartPage: async ({ page }, use) => { + const cartPage = new CartPage(page) + await use(cartPage) + }, + categoryPage: async ({ page }, use) => { + const categoryPage = new CategoryPage(page) + await use(categoryPage) + }, + checkoutPage: async ({ page }, use) => { + const checkoutPage = new CheckoutPage(page) + await use(checkoutPage) + }, + orderPage: async ({ page }, use) => { + const orderPage = new OrderPage(page) + await use(orderPage) + }, + productPage: async ({ page }, use) => { + const productPage = new ProductPage(page) + await use(productPage) + }, + storePage: async ({ page }, use) => { + const storePage = new StorePage(page) + await use(storePage) + }, +}) diff --git a/e2e/fixtures/modals/mobile-actions-modal.ts b/e2e/fixtures/modals/mobile-actions-modal.ts new file mode 100644 index 0000000..e4aa040 --- /dev/null +++ b/e2e/fixtures/modals/mobile-actions-modal.ts @@ -0,0 +1,22 @@ +import { Page, Locator } from "@playwright/test" +import { BaseModal } from "../base/base-modal" + +export class MobileActionsModal extends BaseModal { + optionButton: Locator + + constructor(page: Page) { + super(page, page.getByTestId("mobile-actions-modal")) + this.optionButton = this.container.getByTestId("option-button") + } + + getOption(option: string) { + return this.optionButton.filter({ + hasText: option, + }) + } + + async selectOption(option: string) { + const optionButton = this.getOption(option) + await optionButton.click() + } +} diff --git a/e2e/fixtures/order-page.ts b/e2e/fixtures/order-page.ts new file mode 100644 index 0000000..b5e8153 --- /dev/null +++ b/e2e/fixtures/order-page.ts @@ -0,0 +1,92 @@ +import { Locator, Page } from "@playwright/test" +import { BasePage } from "./base/base-page" + +export class OrderPage extends BasePage { + container: Locator + cartSubtotal: Locator + cartDiscount: Locator + cartGiftCardAmount: Locator + cartShipping: Locator + cartTaxes: Locator + cartTotal: Locator + orderEmail: Locator + orderDate: Locator + orderId: Locator + orderStatus: Locator + orderPaymentStatus: Locator + shippingAddressSummary: Locator + shippingContactSummary: Locator + shippingMethodSummary: Locator + paymentMethod: Locator + paymentAmount: Locator + productsTable: Locator + productRow: Locator + productTitle: Locator + productVariant: Locator + productQuantity: Locator + productOriginalPrice: Locator + productPrice: Locator + productUnitOriginalPrice: Locator + productUnitPrice: Locator + + constructor(page: Page) { + super(page) + this.container = page.getByTestId("order-complete-container") + this.orderEmail = this.container.getByTestId("order-email") + this.orderDate = this.container.getByTestId("order-date") + this.orderId = this.container.getByTestId("order-id") + this.orderStatus = this.container.getByTestId("order-status") + this.cartSubtotal = this.container.getByTestId("cart-subtotal") + this.cartDiscount = this.container.getByTestId("cart-discount") + this.cartGiftCardAmount = this.container.getByTestId( + "cart-gift-card-amount" + ) + this.cartShipping = this.container.getByTestId("cart-shipping") + this.cartTaxes = this.container.getByTestId("cart-taxes") + this.cartTotal = this.container.getByTestId("cart-total") + this.orderPaymentStatus = this.container.getByTestId("order-payment-status") + this.shippingAddressSummary = this.container.getByTestId( + "shipping-address-summary" + ) + this.shippingContactSummary = this.container.getByTestId( + "shipping-contact-summary" + ) + this.shippingMethodSummary = this.container.getByTestId( + "shipping-method-summary" + ) + this.paymentMethod = this.container.getByTestId("payment-method") + this.paymentAmount = this.container.getByTestId("payment-amount") + + this.productsTable = this.container.getByTestId("products-table") + this.productRow = this.container.getByTestId("product-row") + this.productTitle = this.container.getByTestId("product-title") + this.productVariant = this.container.getByTestId("product-variant") + this.productQuantity = this.container.getByTestId("product-quantity") + this.productOriginalPrice = this.container.getByTestId( + "product-original-price" + ) + this.productPrice = this.container.getByTestId("product-price") + this.productUnitOriginalPrice = this.container.getByTestId( + "product-unit-original-price" + ) + this.productUnitPrice = this.container.getByTestId("product-unit-price") + } + + async getProduct(title: string, variant: string) { + const productRow = this.productRow + .filter({ + hasText: title, + }) + .filter({ + hasText: `Variant: ${variant}`, + }) + return { + productRow, + name: productRow.getByTestId("product-name"), + variant: productRow.getByTestId("product-variant"), + quantity: productRow.getByTestId("product-quantity"), + price: productRow.getByTestId("product-unit-price"), + total: productRow.getByTestId("product-price"), + } + } +} diff --git a/e2e/fixtures/product-page.ts b/e2e/fixtures/product-page.ts new file mode 100644 index 0000000..66b79ba --- /dev/null +++ b/e2e/fixtures/product-page.ts @@ -0,0 +1,52 @@ +import { Locator, Page } from "@playwright/test" +import { BasePage } from "./base/base-page" +import { MobileActionsModal } from "./modals/mobile-actions-modal" + +export class ProductPage extends BasePage { + mobileActionsModal: MobileActionsModal + + container: Locator + productTitle: Locator + productDescription: Locator + productOptions: Locator + productPrice: Locator + addProductButton: Locator + mobileActionsContainer: Locator + mobileTitle: Locator + mobileActionsButton: Locator + mobileAddToCartButton: Locator + + constructor(page: Page) { + super(page) + + this.mobileActionsModal = new MobileActionsModal(page) + + this.container = page.getByTestId("product-container") + this.productTitle = this.container.getByTestId("product-title") + this.productDescription = this.container.getByTestId("product-description") + this.productOptions = this.container.getByTestId("product-options") + this.productPrice = this.container.getByTestId("product-price") + this.addProductButton = this.container.getByTestId("add-product-button") + this.mobileActionsContainer = page.getByTestId("mobile-actions") + this.mobileTitle = this.mobileActionsContainer.getByTestId("mobile-title") + this.mobileAddToCartButton = this.mobileActionsContainer.getByTestId( + "mobile-actions-button" + ) + this.mobileActionsButton = this.mobileActionsContainer.getByTestId( + "mobile-actions-select" + ) + } + + async clickAddProduct() { + await this.addProductButton.click() + await this.cartDropdown.cartDropdown.waitFor({ state: "visible" }) + } + + async selectOption(option: string) { + await this.page.mouse.move(0, 0) // hides the checkout container + const optionButton = this.productOptions + .getByTestId("option-button") + .filter({ hasText: option }) + await optionButton.click({ clickCount: 2 }) + } +} diff --git a/e2e/fixtures/store-page.ts b/e2e/fixtures/store-page.ts new file mode 100644 index 0000000..e69864c --- /dev/null +++ b/e2e/fixtures/store-page.ts @@ -0,0 +1,18 @@ +import { Locator, Page } from "@playwright/test" +import { CategoryPage } from "./category-page" + +export class StorePage extends CategoryPage { + pageTitle: Locator + + constructor(page: Page) { + super(page) + this.pageTitle = page.getByTestId("store-page-title") + } + + async goto() { + await this.navMenu.open() + await this.navMenu.storeLink.click() + await this.pageTitle.waitFor({ state: "visible" }) + await this.productsListLoader.waitFor({ state: "hidden" }) + } +} \ No newline at end of file diff --git a/e2e/index.ts b/e2e/index.ts new file mode 100644 index 0000000..f633993 --- /dev/null +++ b/e2e/index.ts @@ -0,0 +1,6 @@ +import { mergeTests } from "@playwright/test" +import { fixtures } from "./fixtures" +import { accountFixtures } from "./fixtures/account" + +export const test = mergeTests(fixtures, accountFixtures) +export { expect } from "@playwright/test" diff --git a/e2e/tests/authenticated/address.spec.ts b/e2e/tests/authenticated/address.spec.ts new file mode 100644 index 0000000..c19ecca --- /dev/null +++ b/e2e/tests/authenticated/address.spec.ts @@ -0,0 +1,258 @@ +import { AddressesPage } from "../../fixtures/account/addresses-page" +import { test, expect } from "../../index" +import { getSelectedOptionText } from "../../utils/locators" + +test.describe("Addresses tests", () => { + test("Creating a new address is displayed during checkout", async ({ + accountAddressesPage: addressesPage, + cartPage, + checkoutPage, + productPage, + storePage, + }) => { + await test.step("Navigate to the new address modal", async () => { + await addressesPage.goto() + await addressesPage.newAddressButton.click() + await addressesPage.addAddressModal.container.waitFor({ state: "visible" }) + }) + + await test.step("Inputs and saves the new address", async () => { + const modal = addressesPage.addAddressModal + await modal.firstNameInput.fill("First") + await modal.lastNameInput.fill("Last") + await modal.companyInput.fill("FirstCorp") + await modal.address1Input.fill("123 Fake Street") + await modal.address2Input.fill("Apt 1") + await modal.postalCodeInput.fill("11111") + await modal.cityInput.fill("City") + await modal.stateInput.fill("Colorado") + await modal.countrySelect.selectOption({ + label: "United States", + }) + await modal.phoneInput.fill("1112223333") + await modal.saveButton.click() + await modal.container.waitFor({ state: "hidden" }) + }) + + await test.step("Navigate to a product page and add a product to the cart", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatshirt") + await product.locator.highlight() + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + await productPage.selectOption("M") + await productPage.addProductButton.click() + await productPage.cartDropdown.navCartLink.click() + await productPage.cartDropdown.goToCartButton.click() + await cartPage.container.waitFor({ state: "visible" }) + await cartPage.checkoutButton.click() + await checkoutPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Verify the address is correct in the checkout process", async () => { + await checkoutPage.selectSavedAddress("123 Fake Street") + await expect(checkoutPage.shippingFirstNameInput).toHaveValue("First") + await expect(checkoutPage.shippingLastNameInput).toHaveValue("Last") + await expect(checkoutPage.shippingCompanyInput).toHaveValue("FirstCorp") + await expect(checkoutPage.shippingAddressInput).toHaveValue( + "123 Fake Street" + ) + await expect(checkoutPage.shippingPostalCodeInput).toHaveValue("11111") + await expect(checkoutPage.shippingCityInput).toHaveValue("City") + await expect(checkoutPage.shippingProvinceInput).toHaveValue("Colorado") + expect( + await getSelectedOptionText( + checkoutPage.page, + checkoutPage.shippingCountrySelect + ) + ).toContain("United States") + }) + }) + + test("Performing all the CRUD actions for an address", async ({ + accountAddressesPage: addressesPage, + }) => { + await test.step("Navigate to the new address modal", async () => { + await addressesPage.goto() + await addressesPage.newAddressButton.click() + await addressesPage.addAddressModal.container.waitFor({ state: "visible" }) + }) + + await test.step("Input and save a new address", async () => { + const { addAddressModal } = addressesPage + await addAddressModal.firstNameInput.fill("First") + await addAddressModal.lastNameInput.fill("Last") + await addAddressModal.companyInput.fill("MyCorp") + await addAddressModal.address1Input.fill("123 Fake Street") + await addAddressModal.address2Input.fill("Apt 1") + await addAddressModal.postalCodeInput.fill("80010") + await addAddressModal.cityInput.fill("Denver") + await addAddressModal.stateInput.fill("Colorado") + await addAddressModal.countrySelect.selectOption({ label: "United States" }) + await addAddressModal.phoneInput.fill("3031112222") + await addAddressModal.saveButton.click() + await addAddressModal.container.waitFor({ state: "hidden" }) + }) + + let addressContainer: ReturnType + await test.step("Make sure the address container was appended to the page", async () => { + addressContainer = addressesPage.getAddressContainer("First Last") + await expect(addressContainer.name).toHaveText("First Last") + await expect(addressContainer.company).toHaveText("MyCorp") + await expect(addressContainer.address).toContainText("123 Fake Street") + await expect(addressContainer.address).toContainText("Apt 1") + await expect(addressContainer.postalCity).toContainText("80010, Denver") + await expect(addressContainer.provinceCountry).toContainText("Colorado, US") + }) + + await test.step("Refresh the page and assert address was saved", async () => { + await addressesPage.page.reload() + addressContainer = addressesPage.getAddressContainer("First Last") + await expect(addressContainer.name).toHaveText("First Last") + await expect(addressContainer.company).toHaveText("MyCorp") + await expect(addressContainer.address).toContainText("123 Fake Street") + await expect(addressContainer.address).toContainText("Apt 1") + await expect(addressContainer.postalCity).toContainText("80010, Denver") + await expect(addressContainer.provinceCountry).toContainText("Colorado, US") + }) + + await test.step("Edit the address", async () => { + await addressContainer.editButton.click() + await addressesPage.editAddressModal.container.waitFor({ state: "visible" }) + await addressesPage.editAddressModal.firstNameInput.fill("Second") + await addressesPage.editAddressModal.lastNameInput.fill("Final") + await addressesPage.editAddressModal.companyInput.fill("MeCorp") + await addressesPage.editAddressModal.address1Input.fill("123 Spark Street") + await addressesPage.editAddressModal.address2Input.fill("Unit 3") + await addressesPage.editAddressModal.postalCodeInput.fill("80011") + await addressesPage.editAddressModal.cityInput.fill("Broomfield") + await addressesPage.editAddressModal.stateInput.fill("CO") + await addressesPage.editAddressModal.countrySelect.selectOption({ + label: "Canada", + }) + await addressesPage.editAddressModal.phoneInput.fill("3032223333") + await addressesPage.editAddressModal.saveButton.click() + await addressesPage.editAddressModal.container.waitFor({ state: "hidden" }) + }) + + await test.step("Make sure edits were saved on the addressContainer", async () => { + addressContainer = addressesPage.getAddressContainer("Second Final") + await expect(addressContainer.name).toContainText("Second Final") + await expect(addressContainer.company).toContainText("MeCorp") + await expect(addressContainer.address).toContainText("123 Spark Street, Unit 3") + await expect(addressContainer.postalCity).toContainText("80011, Broomfield") + await expect(addressContainer.provinceCountry).toContainText("CO, CA") + }) + + await test.step("Refresh the page and assert edits were saved", async () => { + await addressesPage.page.reload() + await expect(addressContainer.name).toContainText("Second Final") + await expect(addressContainer.company).toContainText("MeCorp") + await expect(addressContainer.address).toContainText("123 Spark Street, Unit 3") + await expect(addressContainer.postalCity).toContainText("80011, Broomfield") + await expect(addressContainer.provinceCountry).toContainText("CO, CA") + }) + + await test.step("Delete the address", async () => { + await addressContainer.deleteButton.click() + await addressContainer.container.waitFor({ state: "hidden" }) + await addressesPage.page.reload() + await expect(addressContainer.container).not.toBeVisible() + }) + + await test.step("Ensure address remains deleted after refresh", async () => { + await addressesPage.page.reload() + await expect(addressContainer.container).not.toBeVisible() + }) + }) + + test.skip("Attempt to create duplicate addresses on the address page", async ({ + accountAddressesPage: addressesPage + }) => { + await test.step("navigate to the new address modal", async () => { + await addressesPage.goto() + await addressesPage.newAddressButton.click() + await addressesPage.addAddressModal.container.waitFor({ state: "visible" }) + }) + + await test.step("Input and save a new address", async () => { + await addressesPage.addAddressModal.firstNameInput.fill("First") + await addressesPage.addAddressModal.lastNameInput.fill("Last") + await addressesPage.addAddressModal.companyInput.fill("MyCorp") + await addressesPage.addAddressModal.address1Input.fill("123 Fake Street") + await addressesPage.addAddressModal.address2Input.fill("Apt 1") + await addressesPage.addAddressModal.postalCodeInput.fill("80010") + await addressesPage.addAddressModal.cityInput.fill("Denver") + await addressesPage.addAddressModal.stateInput.fill("Colorado") + await addressesPage.addAddressModal.countrySelect.selectOption({ + label: "United States", + }) + await addressesPage.addAddressModal.phoneInput.fill("3031112222") + await addressesPage.addAddressModal.saveButton.click() + await addressesPage.addAddressModal.container.waitFor({ state: "hidden" }) + }) + + await test.step("Attempt to create the same address", async () => { + await addressesPage.newAddressButton.click() + await addressesPage.addAddressModal.container.waitFor({ state: "visible" }) + await addressesPage.addAddressModal.firstNameInput.fill("First") + await addressesPage.addAddressModal.lastNameInput.fill("Last") + await addressesPage.addAddressModal.companyInput.fill("MyCorp") + await addressesPage.addAddressModal.address1Input.fill("123 Fake Street") + await addressesPage.addAddressModal.address2Input.fill("Apt 1") + await addressesPage.addAddressModal.postalCodeInput.fill("80010") + await addressesPage.addAddressModal.cityInput.fill("Denver") + await addressesPage.addAddressModal.stateInput.fill("Colorado") + await addressesPage.addAddressModal.countrySelect.selectOption({ + label: "United States", + }) + await addressesPage.addAddressModal.phoneInput.fill("3031112222") + await addressesPage.addAddressModal.saveButton.click() + }) + + await test.step("Validate error state", async () => { + + }) + }) + + test("Creating multiple tests works correctly", async ({ + accountAddressesPage: addressesPage, + }) => { + test.slow() + await test.step("Navigate to the new address modal", async () => { + await addressesPage.goto() + }) + + let addressContainer: ReturnType + for (let i = 0; i < 10; i++) { + await test.step("Open up the new address modal", async () => { + await addressesPage.newAddressButton.click() + await addressesPage.addAddressModal.container.waitFor({ state: "visible" }) + }) + await test.step("Input and save a new address", async () => { + const { addAddressModal } = addressesPage + await addAddressModal.firstNameInput.fill(`First-${i}`) + await addAddressModal.lastNameInput.fill(`Last-${i}`) + await addAddressModal.companyInput.fill(`MyCorp-${i}`) + await addAddressModal.address1Input.fill(`123 Fake Street-${i}`) + await addAddressModal.address2Input.fill("Apt 1") + await addAddressModal.postalCodeInput.fill("80010") + await addAddressModal.cityInput.fill("Denver") + await addAddressModal.stateInput.fill("Colorado") + await addAddressModal.countrySelect.selectOption({ label: "United States" }) + await addAddressModal.phoneInput.fill("3031112222") + await addAddressModal.saveButton.click() + await addAddressModal.container.waitFor({ state: "hidden" }) + }) + await test.step("Make sure the address container was appended to the page", async () => { + addressContainer = addressesPage.getAddressContainer(`First-${i} Last-${i}`) + await expect(addressContainer.name).toHaveText(`First-${i} Last-${i}`) + await expect(addressContainer.company).toHaveText(`MyCorp-${i}`) + await expect(addressContainer.address).toContainText(`123 Fake Street-${i}`) + await expect(addressContainer.address).toContainText("Apt 1") + await expect(addressContainer.postalCity).toContainText("80010, Denver") + await expect(addressContainer.provinceCountry).toContainText("Colorado, US") + }) + } + }) +}) \ No newline at end of file diff --git a/e2e/tests/authenticated/orders.spec.ts b/e2e/tests/authenticated/orders.spec.ts new file mode 100644 index 0000000..a8e9cbd --- /dev/null +++ b/e2e/tests/authenticated/orders.spec.ts @@ -0,0 +1,374 @@ +import { test, expect } from "../../index" + +test.describe("Account orders page tests", async () => { + test.beforeEach(async ({ accountAddressesPage }) => { + await accountAddressesPage.goto() + await accountAddressesPage.newAddressButton.click() + await test.step("Add default address", async () => { + const modal = accountAddressesPage.addAddressModal + await modal.container.waitFor({ state: "visible" }) + await modal.firstNameInput.fill("First") + await modal.lastNameInput.fill("Last") + await modal.companyInput.fill("FirstCorp") + await modal.address1Input.fill("123 Fake Street") + await modal.address2Input.fill("Apt 1") + await modal.postalCodeInput.fill("11111") + await modal.cityInput.fill("City") + await modal.stateInput.fill("Colorado") + await modal.countrySelect.selectOption({ + label: "United States", + }) + await modal.phoneInput.fill("1112223333") + await modal.saveButton.click() + await modal.container.waitFor({ state: "hidden" }) + }) + }) + + test("Verify account orders page displays empty container", async ({ + accountOrdersPage, + }) => { + await accountOrdersPage.goto() + await expect(accountOrdersPage.noOrdersContainer).toBeVisible() + }) + + test("Order shows up after checkout flow", async ({ + accountOrdersPage, + accountOrderPage, + cartPage, + checkoutPage, + orderPage: publicOrderPage, + productPage, + storePage, + }) => { + await test.step("Navigate to a product page", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatshirt") + await product.locator.highlight() + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Add the product to the cart and goto checkout", async () => { + await productPage.selectOption("M") + await productPage.clickAddProduct() + await productPage.cartDropdown.navCartLink.click() + await productPage.cartDropdown.goToCartButton.click() + await cartPage.container.waitFor({ state: "visible" }) + await cartPage.checkoutButton.click() + await checkoutPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Enter in the first step of the checkout process", async () => { + await checkoutPage.selectSavedAddress("123 Fake Street") + await checkoutPage.shippingEmailInput.fill("test@example.com") + await checkoutPage.shippingPhoneInput.fill("3031112222") + await checkoutPage.submitAddressButton.click() + await checkoutPage.deliveryOptionsContainer.waitFor({ state: "visible" }) + }) + + await test.step("Complete the rest of the payment process", async () => { + await checkoutPage.selectDeliveryOption("FakeEx Standard") + await checkoutPage.submitDeliveryOptionButton.click() + await checkoutPage.submitPaymentButton.click() + await checkoutPage.submitOrderButton.click() + await publicOrderPage.container.waitFor({ state: "visible" }) + }) + + let orderId = "" + await test.step("Verify the order page information is correct", async () => { + orderId = (await publicOrderPage.orderId.textContent()) || "" + + await test.step("Verify the products ordered are correct", async () => { + const product = await publicOrderPage.getProduct("Sweatshirt", "M") + await expect(product.name).toContainText("Sweatshirt") + await expect(product.variant).toContainText("M") + await expect(product.quantity).toContainText("1") + }) + + await test.step("Verify the shipping info is correct", async () => { + const address = publicOrderPage.shippingAddressSummary + await expect(address).toContainText("First") + await expect(address).toContainText("Last") + await expect(address).toContainText("123 Fake Street") + await expect(address).toContainText("11111") + await expect(address).toContainText("City") + await expect(address).toContainText("US") + + const contact = publicOrderPage.shippingContactSummary + await expect(contact).toContainText("test@example.com") + await expect(contact).toContainText("3031112222") + + const method = publicOrderPage.shippingMethodSummary + await expect(method).toContainText("FakeEx Standard") + }) + }) + + await test.step("Verify the account orders page displays a result", async () => { + await accountOrdersPage.goto() + const order = await accountOrdersPage.getOrderById(orderId) + expect(order.items.length).toBe(1) + expect(order.items[0].title).toContainText("Sweatshirt") + expect(order.items[0].quantity).toHaveText("1") + await order.detailsLink.click() + await accountOrderPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Verify the order page displays the correct information", async () => { + await test.step("Verify the order id is correct", async () => { + await expect(accountOrderPage.orderId).toHaveText(orderId) + }) + + await test.step("Verify the products ordered are correct", async () => { + const product = await accountOrderPage.getProduct("Sweatshirt", "M") + await expect(product.name).toContainText("Sweatshirt") + await expect(product.variant).toContainText("M") + await expect(product.quantity).toContainText("1") + }) + + await test.step("Verify the shipping info is correct", async () => { + const address = accountOrderPage.shippingAddressSummary + await expect(address).toContainText("First") + await expect(address).toContainText("Last") + await expect(address).toContainText("123 Fake Street") + await expect(address).toContainText("11111") + await expect(address).toContainText("City") + await expect(address).toContainText("US") + + const contact = accountOrderPage.shippingContactSummary + await contact.highlight() + await expect(contact.getByText("test@example.com")).toBeVisible() + await expect(contact.getByText("3031112222")).toBeVisible() + + const method = accountOrderPage.shippingMethodSummary + await method.highlight() + await expect(method).toContainText("FakeEx Standard") + }) + }) + + await test.step("Navigate back to the orders page, verifying back button works", async () => { + await accountOrderPage.backToOverviewButton.click() + await accountOrdersPage.container.waitFor({ state: "visible" }) + }) + }) + + test("Order preserves item count, and variants", async ({ + accountOrdersPage, + accountOrderPage, + cartPage, + checkoutPage, + orderPage: publicOrderPage, + productPage, + storePage, + }) => { + await test.step("Add first batch or products to the cart", async () => { + await test.step("Navigate to the sweatshirt product page", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatshirt") + await product.locator.highlight() + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Add the product to the cart and goto checkout", async () => { + await productPage.selectOption("M") + await productPage.clickAddProduct() + await productPage.cartDropdown.close() + await productPage.selectOption("M") + await productPage.clickAddProduct() + await productPage.cartDropdown.close() + }) + }) + + await test.step("Add second batch of products to the cart", async () => { + await test.step("Navigate to the sweatshirt product page", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatpants") + await product.locator.highlight() + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Add the product to the cart and goto checkout", async () => { + await productPage.selectOption("S") + await productPage.clickAddProduct() + await productPage.cartDropdown.close() + await productPage.selectOption("M") + await productPage.clickAddProduct() + }) + + await test.step("Navigate to the checkout process", async () => { + await productPage.cartDropdown.goToCartButton.click() + await productPage.cartDropdown.close() + await cartPage.container.waitFor({ state: "visible" }) + await cartPage.checkoutButton.click() + await checkoutPage.container.waitFor({ state: "visible" }) + }) + }) + + let orderId = "" + await test.step("Checkout process", async () => { + await test.step("Enter in the first step of the checkout process", async () => { + await checkoutPage.selectSavedAddress("123 Fake Street") + await checkoutPage.shippingEmailInput.fill("test@example.com") + await checkoutPage.shippingPhoneInput.fill("3031112222") + await checkoutPage.submitAddressButton.click() + await checkoutPage.deliveryOptionsContainer.waitFor({ + state: "visible", + }) + }) + + await test.step("Complete the rest of the payment process", async () => { + await checkoutPage.selectDeliveryOption("FakeEx Standard") + await checkoutPage.submitDeliveryOptionButton.click() + await checkoutPage.submitPaymentButton.click() + await checkoutPage.submitOrderButton.click() + await publicOrderPage.container.waitFor({ state: "visible" }) + orderId = (await publicOrderPage.orderId.textContent()) || "" + }) + }) + + await test.step("Verify the order page information is correct", async () => { + await test.step("Navigate to the account orders page, verify information, and navigate to the order page", async () => { + await accountOrdersPage.goto() + const order = await accountOrdersPage.getOrderById(orderId) + expect(order.itemsLocator).toHaveCount(3) + expect( + order.itemsLocator.filter({ hasText: "Sweatpants" }) + ).toHaveCount(2) + expect( + order.itemsLocator.filter({ hasText: "Sweatshirt" }) + ).toHaveCount(1) + await order.detailsLink.click() + await accountOrderPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Verify information on the order page", async () => { + const sweatshirt = await accountOrderPage.getProduct("Sweatshirt", "M") + await expect(sweatshirt.name).toContainText("Sweatshirt") + await expect(sweatshirt.variant).toContainText("M") + await expect(sweatshirt.quantity).toContainText("2") + + const smallSweatpants = await accountOrderPage.getProduct( + "Sweatpants", + "S" + ) + await expect(smallSweatpants.name).toContainText("Sweatpants") + await expect(smallSweatpants.variant).toContainText("S") + await expect(smallSweatpants.quantity).toContainText("1") + + const mediumSweatpants = await accountOrderPage.getProduct( + "Sweatpants", + "M" + ) + await expect(mediumSweatpants.name).toContainText("Sweatpants") + await expect(mediumSweatpants.variant).toContainText("M") + await expect(mediumSweatpants.quantity).toContainText("1") + }) + }) + }) + + test("Multiple orders are stored correctly", async ({ + accountOrdersPage, + cartPage, + checkoutPage, + orderPage: publicOrderPage, + productPage, + storePage, + }) => { + let firstOrderId = "" + let secondOrderId = "" + await test.step("Make the first order", async () => { + await test.step("Navigate to a product page", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatshirt") + await product.locator.highlight() + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Add the product to the cart and goto checkout", async () => { + await productPage.selectOption("M") + await productPage.clickAddProduct() + await productPage.cartDropdown.navCartLink.click() + await productPage.cartDropdown.goToCartButton.click() + await cartPage.container.waitFor({ state: "visible" }) + await cartPage.checkoutButton.click() + await checkoutPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Enter in the first step of the checkout process", async () => { + await checkoutPage.selectSavedAddress("123 Fake Street") + await checkoutPage.shippingEmailInput.fill("test@example.com") + await checkoutPage.shippingPhoneInput.fill("3031112222") + await checkoutPage.submitAddressButton.click() + await checkoutPage.deliveryOptionsContainer.waitFor({ + state: "visible", + }) + }) + + await test.step("Complete the rest of the payment process", async () => { + await checkoutPage.selectDeliveryOption("FakeEx Standard") + await checkoutPage.submitDeliveryOptionButton.click() + await checkoutPage.submitPaymentButton.click() + await checkoutPage.submitOrderButton.click() + await publicOrderPage.container.waitFor({ state: "visible" }) + firstOrderId = (await publicOrderPage.orderId.textContent()) || "" + }) + }) + + await test.step("Make the second order", async () => { + await test.step("Navigate to a product page", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatpants") + await product.locator.highlight() + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Add the product to the cart and goto checkout", async () => { + await productPage.selectOption("S") + await productPage.clickAddProduct() + await productPage.cartDropdown.navCartLink.click() + await productPage.cartDropdown.goToCartButton.click() + await cartPage.container.waitFor({ state: "visible" }) + await cartPage.checkoutButton.click() + await checkoutPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Enter in the first step of the checkout process", async () => { + await checkoutPage.selectSavedAddress("123 Fake Street") + await checkoutPage.shippingEmailInput.fill("test@example.com") + await checkoutPage.shippingPhoneInput.fill("3031112222") + await checkoutPage.submitAddressButton.click() + await checkoutPage.deliveryOptionsContainer.waitFor({ + state: "visible", + }) + }) + + await test.step("Complete the rest of the payment process", async () => { + await checkoutPage.selectDeliveryOption("FakeEx Standard") + await checkoutPage.submitDeliveryOptionButton.click() + await checkoutPage.submitPaymentButton.click() + await checkoutPage.submitOrderButton.click() + await publicOrderPage.container.waitFor({ state: "visible" }) + secondOrderId = (await publicOrderPage.orderId.textContent()) || "" + }) + }) + + await test.step("Verify there are distinct orders on the orders page", async () => { + await accountOrdersPage.goto() + await test.step("Verify the first order info", async () => { + const order = await accountOrdersPage.getOrderById(firstOrderId) + await expect(order.itemsLocator).toHaveCount(1) + await expect(order.items[0].title).toContainText("Sweatshirt") + await expect(order.items[0].quantity).toHaveText("1") + }) + await test.step("Verify the second order info", async () => { + const order = await accountOrdersPage.getOrderById(secondOrderId) + await expect(order.itemsLocator).toHaveCount(1) + await expect(order.items[0].title).toContainText("Sweatpants") + await expect(order.items[0].quantity).toHaveText("1") + }) + }) + }) +}) diff --git a/e2e/tests/authenticated/profile.spec.ts b/e2e/tests/authenticated/profile.spec.ts new file mode 100644 index 0000000..f73b534 --- /dev/null +++ b/e2e/tests/authenticated/profile.spec.ts @@ -0,0 +1,243 @@ +import { test, expect } from "../../index" + +test.describe("Account profile tests", () => { + test("Profile completed update flow", async ({ + accountOverviewPage: overviewPage, + accountProfilePage: profilePage, + }) => { + await overviewPage.goto() + await expect(overviewPage.profileCompletion).toHaveText("50%") + + await test.step("navigate to the profile page", async () => { + await profilePage.profileLink.click() + await expect(profilePage.profileWrapper).toBeVisible() + }) + + await test.step("update the saved profile phone number", async () => { + await expect(profilePage.savedPhone).toHaveText("null") + await profilePage.phoneEditButton.click() + await profilePage.phoneInput.fill("8888888888") + await profilePage.phoneSaveButton.click() + await expect(profilePage.phoneSuccessMessage).toBeVisible() + await expect(profilePage.savedPhone).toHaveText("8888888888") + }) + + await test.step("verify the profile completion state and go back to the profile page", async () => { + await profilePage.overviewLink.click() + await expect(overviewPage.profileCompletion).toHaveText("75%") + + await profilePage.profileLink.click() + await expect(profilePage.profileWrapper).toBeVisible() + }) + + await test.step("enter in the billing address", async () => { + await expect(profilePage.savedBillingAddress).toContainText( + "No billing address" + ) + await profilePage.billingAddressEditButton.click() + await profilePage.billingFirstNameInput.fill("First") + await profilePage.billingLastNameInput.fill("Last") + await profilePage.billingAddress1Input.fill("123 Fake Street") + await profilePage.billingPostcalCodeInput.fill("11111") + await profilePage.billingCityInput.fill("Springdale") + await profilePage.billingProvinceInput.fill("IL") + await profilePage.billingCountryCodeSelect.selectOption({ + label: "United States", + }) + await profilePage.billingAddressSaveButton.click() + await expect(profilePage.billingAddressSuccessMessage).toBeVisible() + }) + + await test.step("profile completion state", async () => { + await profilePage.overviewLink.click() + await expect(overviewPage.profileCompletion).toHaveText("100%") + + await profilePage.goto() + await expect(profilePage.savedBillingAddress).toContainText("First Last") + await expect(profilePage.savedBillingAddress).toContainText( + "123 Fake Street" + ) + await expect(profilePage.savedBillingAddress).toContainText( + "11111, Springdale" + ) + await expect(profilePage.savedBillingAddress).toContainText( + "United States" + ) + }) + }) + + test("Profile changes persist across page refreshes and logouts", async ({ + page, + loginPage, + accountOverviewPage: overviewPage, + accountProfilePage: profilePage, + }) => { + await overviewPage.goto() + await expect(overviewPage.profileCompletion).toHaveText("50%") + + await test.step("navigate to the profile page", async () => { + await profilePage.profileLink.click() + await expect(profilePage.profileWrapper).toBeVisible() + }) + + await test.step("update the first and last name", async () => { + await profilePage.nameEditButton.click() + await profilePage.firstNameInput.fill("FirstNew") + await profilePage.lastNameInput.fill("LastNew") + await profilePage.nameSaveButton.click() + await profilePage.nameSuccessMessage.waitFor({ state: "visible" }) + }) + + await test.step("update the saved profile phone number", async () => { + await expect(profilePage.savedPhone).toHaveText("null") + await profilePage.phoneEditButton.click() + await profilePage.phoneInput.fill("8888888888") + await profilePage.phoneSaveButton.click() + await expect(profilePage.phoneSuccessMessage).toBeVisible() + await expect(profilePage.savedPhone).toHaveText("8888888888") + }) + + await test.step("enter in the billing address", async () => { + await expect(profilePage.savedBillingAddress).toContainText( + "No billing address" + ) + await profilePage.billingAddressEditButton.click() + await profilePage.billingFirstNameInput.fill("First") + await profilePage.billingLastNameInput.fill("Last") + await profilePage.billingAddress1Input.fill("123 Fake Street") + await profilePage.billingPostcalCodeInput.fill("11111") + await profilePage.billingCityInput.fill("Springdale") + await profilePage.billingProvinceInput.fill("IL") + await profilePage.billingCountryCodeSelect.selectOption({ + label: "United States", + }) + await profilePage.billingAddressSaveButton.click() + await expect(profilePage.billingAddressSuccessMessage).toBeVisible() + }) + + await test.step("Refresh page and verify information saved is still there", async () => { + await page.reload() + await expect(profilePage.savedName).toContainText("FirstNew") + await expect(profilePage.savedName).toContainText("LastNew") + await expect(profilePage.savedPhone).toContainText("8888888888") + + await expect(profilePage.savedBillingAddress).toContainText("First Last") + await expect(profilePage.savedBillingAddress).toContainText( + "123 Fake Street" + ) + await expect(profilePage.savedBillingAddress).toContainText( + "11111, Springdale" + ) + await expect(profilePage.savedBillingAddress).toContainText( + "United States" + ) + }) + + await test.step("Log out and log back in", async () => { + await profilePage.logoutLink.click() + await expect(loginPage.container).toBeVisible() + await loginPage.emailInput.fill("test@example.com") + await loginPage.passwordInput.fill("password") + await loginPage.signInButton.click() + await overviewPage.overviewWrapper.waitFor({ state: "visible" }) + await overviewPage.profileLink.click() + await profilePage.profileWrapper.waitFor({ state: "visible" }) + }) + + await test.step("Verify the saved profile information is correct", async () => { + await expect(profilePage.savedName).toContainText("FirstNew") + await expect(profilePage.savedName).toContainText("LastNew") + await expect(profilePage.savedPhone).toContainText("8888888888") + + await expect(profilePage.savedBillingAddress).toContainText("First Last") + await expect(profilePage.savedBillingAddress).toContainText( + "123 Fake Street" + ) + await expect(profilePage.savedBillingAddress).toContainText( + "11111, Springdale" + ) + await expect(profilePage.savedBillingAddress).toContainText( + "United States" + ) + }) + }) + + test("Verifies password changes work correctly", async ({ + loginPage, + accountProfilePage: profilePage, + accountOverviewPage: overviewPage, + }) => { + await test.step("Navigate to the account Profile page", async () => { + await overviewPage.goto() + await profilePage.profileLink.click() + }) + + await test.step("Update the password", async () => { + await profilePage.passwordEditButton.click() + await profilePage.oldPasswordInput.fill("password") + await profilePage.newPasswordInput.fill("updated-password") + await profilePage.confirmPasswordInput.fill("updated-password") + await profilePage.passwordSaveButton.click() + await expect(profilePage.passwordSuccessMessage).toBeVisible() + }) + + await test.step("logout and log back in", async () => { + await profilePage.logoutLink.click() + await expect(loginPage.container).toBeVisible() + await loginPage.emailInput.fill("test@example.com") + await loginPage.passwordInput.fill("updated-password") + await loginPage.signInButton.click() + await expect(overviewPage.container).toBeVisible() + }) + }) + + test("Check if changing email address updates user correctly", async ({ + loginPage, + accountProfilePage: profilePage, + accountOverviewPage: accountPage, + }) => { + await test.step("Update the user email", async () => { + await accountPage.goto() + await accountPage.welcomeMessage.waitFor({ state: "visible" }) + await accountPage.profileLink.click() + await profilePage.profileWrapper.waitFor({ state: "visible" }) + await profilePage.emailEditButton.click() + await profilePage.emailInput.fill("test-111@example.com") + await profilePage.emailSaveButton.click() + await profilePage.emailSuccessMessage.waitFor({ state: "visible" }) + }) + + await test.step("Try logging in again with the old email", async () => { + await profilePage.logoutLink.click() + await loginPage.container.waitFor({ state: "visible" }) + await loginPage.emailInput.fill("test@example.com") + await loginPage.passwordInput.fill("password") + await loginPage.signInButton.click() + await loginPage.errorMessage.waitFor({ state: "visible" }) + }) + + await test.step("Login with the new email", async () => { + await loginPage.emailInput.fill("test-111@example.com") + await loginPage.signInButton.click() + await accountPage.welcomeMessage.waitFor({ state: "visible" }) + }) + + await test.step("Set the email back to test@example.com", async () => { + await accountPage.profileLink.click() + await profilePage.profileWrapper.waitFor({ state: "visible" }) + await profilePage.emailEditButton.click() + await profilePage.emailInput.fill("test@example.com") + await profilePage.emailSaveButton.click() + await profilePage.emailSuccessMessage.waitFor({ state: "visible" }) + }) + + await test.step("Try logging out and logging in with the first email", async () => { + await profilePage.logoutLink.click() + await loginPage.container.waitFor({ state: "visible" }) + await loginPage.emailInput.fill("test@example.com") + await loginPage.passwordInput.fill("password") + await loginPage.signInButton.click() + await accountPage.welcomeMessage.waitFor({ state: "visible" }) + }) + }) +}) diff --git a/e2e/tests/global/public-setup.ts b/e2e/tests/global/public-setup.ts new file mode 100644 index 0000000..e65645a --- /dev/null +++ b/e2e/tests/global/public-setup.ts @@ -0,0 +1,6 @@ +import { test as setup } from "@playwright/test" +import { seedData } from "../../data/seed" + +setup("Seed data", async () => { + await seedData() +}) diff --git a/e2e/tests/global/setup.ts b/e2e/tests/global/setup.ts new file mode 100644 index 0000000..336858c --- /dev/null +++ b/e2e/tests/global/setup.ts @@ -0,0 +1,25 @@ +import { test as setup } from "@playwright/test" +import { seedData } from "../../data/seed" +import { OverviewPage as AccountOverviewPage } from "../../fixtures/account/overview-page" +import { LoginPage } from "../../fixtures/account/login-page" +import { STORAGE_STATE } from "../../../playwright.config" + +setup( + "Seed data and create session for authenticated user", + async ({ page }) => { + const seed = await seedData() + const user = seed.user + + const loginPage = new LoginPage(page) + const accountPage = new AccountOverviewPage(page) + await loginPage.goto() + await loginPage.emailInput.fill(user?.email!) + await loginPage.passwordInput.fill(user?.password!) + await loginPage.signInButton.click() + await accountPage.welcomeMessage.waitFor({ state: "visible" }) + + await page.context().storageState({ + path: STORAGE_STATE, + }) + } +) diff --git a/e2e/tests/global/teardown.ts b/e2e/tests/global/teardown.ts new file mode 100644 index 0000000..9e5b50d --- /dev/null +++ b/e2e/tests/global/teardown.ts @@ -0,0 +1,7 @@ +import { test as teardown } from "@playwright/test" +import { dropTemplate, resetDatabase } from "../../data/reset" + +teardown("Reset the database and the drop the template database", async () => { + await resetDatabase() + await dropTemplate() +}) diff --git a/e2e/tests/public/cart.spec.ts b/e2e/tests/public/cart.spec.ts new file mode 100644 index 0000000..edaaaf6 --- /dev/null +++ b/e2e/tests/public/cart.spec.ts @@ -0,0 +1,202 @@ +/* +Test List +- login from the sign in page redirects you page to the cart +*/ +import { test, expect } from "../../index" +import { compareFloats, getFloatValue } from "../../utils" + +test.describe("Cart tests", async () => { + test("Ensure adding multiple items from a product page adjusts the cart accordingly", async ({ + page, + cartPage, + productPage, + storePage, + }) => { + // Assuming we have access to our page objects here + const cartDropdown = cartPage.cartDropdown + + await test.step("Navigate to the product page", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatshirt") + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Add the small size to the cart and verify the data", async () => { + await productPage.selectOption("S") + await productPage.addProductButton.click() + await expect(cartDropdown.navCartLink).toContainText("(1)") + const cartItem = await cartDropdown.getCartItem("Sweatshirt", "S") + await expect(cartItem.locator).toBeVisible() + await expect(cartItem.variant).toContainText("S") + await expect(cartItem.quantity).toContainText("1") + await cartDropdown.goToCartButton.click() + await cartDropdown.close() + await cartPage.container.waitFor({ state: "visible" }) + const productInCart = await cartPage.getProduct("Sweatshirt", "S") + await expect(productInCart.productRow).toBeVisible() + await expect(productInCart.quantitySelect).toHaveValue("1") + await page.goBack() + await productPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Add the small size to the cart again and verify the data", async () => { + await productPage.selectOption("S") + await productPage.addProductButton.click() + await expect(cartDropdown.navCartLink).toContainText("(2)") + const cartItem = await cartDropdown.getCartItem("Sweatshirt", "S") + await expect(cartItem.locator).toBeVisible() + await expect(cartItem.variant).toContainText("S") + await expect(cartItem.quantity).toContainText("2") + await cartDropdown.goToCartButton.click() + await cartDropdown.close() + await cartPage.container.waitFor({ state: "visible" }) + const productInCart = await cartPage.getProduct("Sweatshirt", "S") + await expect(productInCart.productRow).toBeVisible() + await expect(productInCart.quantitySelect).toHaveValue("2") + await page.goBack() + await productPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Add the medium size to the cart and verify the data", async () => { + await productPage.selectOption("M") + await productPage.addProductButton.click() + await expect(cartDropdown.navCartLink).toContainText("(3)") + const mediumCartItem = await cartDropdown.getCartItem("Sweatshirt", "M") + await expect(mediumCartItem.locator).toBeVisible() + await expect(mediumCartItem.variant).toContainText("M") + await expect(mediumCartItem.quantity).toContainText("1") + await cartDropdown.goToCartButton.click() + await cartDropdown.close() + await cartPage.container.waitFor({ state: "visible" }) + const mediumProductInCart = await cartPage.getProduct("Sweatshirt", "M") + await expect(mediumProductInCart.productRow).toBeVisible() + await expect(mediumProductInCart.quantitySelect).toHaveValue("1") + const smallProductInCart = await cartPage.getProduct("Sweatshirt", "S") + await expect(smallProductInCart.productRow).toBeVisible() + await expect(smallProductInCart.quantitySelect).toHaveValue("2") + }) + }) + + test("Ensure adding two products into the cart and verify the quantities", async ({ + cartPage, + productPage, + storePage, + }) => { + const cartDropdown = cartPage.cartDropdown + + await test.step("Navigate to the product page - go to the store page and click on the Sweatshirt product", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatshirt") + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Add the small sweatshirt to the cart", async () => { + await productPage.selectOption("S") + await productPage.addProductButton.click() + await expect(cartDropdown.navCartLink).toContainText("(1)") + const sweatshirtItem = await cartDropdown.getCartItem("Sweatshirt", "S") + await expect(sweatshirtItem.locator).toBeVisible() + await expect(sweatshirtItem.variant).toHaveText("Variant: S") + await expect(sweatshirtItem.quantity).toContainText("1") + await cartDropdown.close() + }) + + await test.step("Navigate to another product - Sweatpants", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatpants") + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Add the small sweatpants to the cart", async () => { + await productPage.selectOption("S") + await productPage.addProductButton.click() + await expect(cartDropdown.navCartLink).toContainText("(2)") + const sweatpantsItem = await cartDropdown.getCartItem("Sweatpants", "S") + await expect(sweatpantsItem.locator).toBeVisible() + await expect(sweatpantsItem.variant).toHaveText("Variant: S") + await expect(sweatpantsItem.quantity).toContainText("1") + const sweatshirtItem = await cartDropdown.getCartItem("Sweatshirt", "S") + await expect(sweatshirtItem.locator).toBeVisible() + await expect(sweatshirtItem.quantity).toContainText("1") + await cartDropdown.goToCartButton.click() + await cartDropdown.close() + await cartPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Verify the quantities in the cart", async () => { + const sweatpantsProduct = await cartPage.getProduct("Sweatpants", "S") + await expect(sweatpantsProduct.productRow).toBeVisible() + await expect(sweatpantsProduct.quantitySelect).toHaveValue("1") + const sweatshirtProduct = await cartPage.getProduct("Sweatshirt", "S") + await expect(sweatshirtProduct.productRow).toBeVisible() + await expect(sweatshirtProduct.quantitySelect).toHaveValue("1") + }) + }) + + test("Verify the prices carries over to checkout", async ({ + cartPage, + productPage, + storePage, + }) => { + await test.step("Navigate to the product page - go to the store page and click on the Hoodie product", async () => { + await storePage.goto() + const product = await storePage.getProduct("Hoodie") + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + let hoodieSmallPrice = 0 + let hoodieMediumPrice = 0 + await test.step("Add the hoodie to the cart", async () => { + await productPage.selectOption("S") + hoodieSmallPrice = getFloatValue( + (await productPage.productPrice.getAttribute("data-value")) || "0" + ) + await productPage.clickAddProduct() + await productPage.cartDropdown.close() + await productPage.selectOption("M") + hoodieMediumPrice = getFloatValue( + (await productPage.productPrice.getAttribute("data-value")) || "0" + ) + await productPage.clickAddProduct() + + await productPage.cartDropdown.close() + }) + + await test.step("Navigate to another product - Longsleeve", async () => { + await storePage.goto() + const product = await storePage.getProduct("Longsleeve") + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + let longsleeveSmallPrice = 0 + await test.step("Add the small longsleeve to the cart", async () => { + await productPage.selectOption("S") + longsleeveSmallPrice = getFloatValue( + (await productPage.productPrice.getAttribute("data-value")) || "0" + ) + await productPage.clickAddProduct() + await productPage.cartDropdown.close() + await productPage.selectOption("S") + await productPage.clickAddProduct() + await productPage.selectOption("S") + await productPage.clickAddProduct() + await productPage.cartDropdown.goToCartButton.click() + await productPage.cartDropdown.close() + await cartPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Verify the price in the cart is the expected value", async () => { + const total = getFloatValue( + (await cartPage.cartSubtotal.getAttribute("data-value")) || "0" + ) + const calculatedTotal = + 3 * longsleeveSmallPrice + hoodieSmallPrice + hoodieMediumPrice + expect(compareFloats(total, calculatedTotal)).toBe(0) + }) + }) +}) diff --git a/e2e/tests/public/checkout.spec.ts b/e2e/tests/public/checkout.spec.ts new file mode 100644 index 0000000..7211ec0 --- /dev/null +++ b/e2e/tests/public/checkout.spec.ts @@ -0,0 +1,582 @@ +import { test, expect } from "../../index" +import { compareFloats, getFloatValue } from "../../utils" + +test.describe("Checkout flow tests", async () => { + test("Default checkout flow", async ({ + cartPage, + checkoutPage, + orderPage, + productPage, + storePage, + }) => { + await test.step("Navigate to a product page", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatshirt") + await product.locator.highlight() + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Add the product to the cart and goto checkout", async () => { + await productPage.selectOption("M") + await productPage.clickAddProduct() + await productPage.cartDropdown.navCartLink.click() + await productPage.cartDropdown.goToCartButton.click() + await cartPage.container.waitFor({ state: "visible" }) + await cartPage.checkoutButton.click() + await checkoutPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Enter in the first step of the checkout process", async () => { + await test.step("Enter in the shipping address info", async () => { + await checkoutPage.shippingFirstNameInput.fill("First") + await checkoutPage.shippingLastNameInput.fill("Last") + await checkoutPage.shippingCompanyInput.fill("MyCorp") + await checkoutPage.shippingAddressInput.fill("123 Fake street") + await checkoutPage.shippingPostalCodeInput.fill("80010") + await checkoutPage.shippingCityInput.fill("Denver") + await checkoutPage.shippingProvinceInput.fill("Colorado") + await checkoutPage.shippingCountrySelect.selectOption("United States") + }) + + await test.step("Enter in the contact info and open the billing info form", async () => { + await checkoutPage.shippingEmailInput.fill("test@example.com") + await checkoutPage.shippingPhoneInput.fill("3031112222") + await checkoutPage.billingAddressCheckbox.uncheck() + }) + + await test.step("Enter in the billing address info", async () => { + await checkoutPage.billingFirstNameInput.fill("First") + await checkoutPage.billingLastNameInput.fill("Last") + await checkoutPage.billingCompanyInput.fill("MyCorp") + await checkoutPage.billingAddressInput.fill("123 Fake street") + await checkoutPage.billingPostalInput.fill("80010") + await checkoutPage.billingCityInput.fill("Denver") + await checkoutPage.billingProvinceInput.fill("Colorado") + await checkoutPage.billingCountrySelect.selectOption("United States") + await checkoutPage.submitAddressButton.click() + }) + }) + + await test.step("Complete the rest of the payment process", async () => { + await checkoutPage.selectDeliveryOption("FakeEx Standard") + await checkoutPage.submitDeliveryOptionButton.click() + await checkoutPage.submitPaymentButton.click() + await checkoutPage.submitOrderButton.click() + await orderPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Verify the products ordered are correct", async () => { + const product = await orderPage.getProduct("Sweatshirt", "M") + await expect(product.name).toContainText("Sweatshirt") + await expect(product.variant).toContainText("M") + await expect(product.quantity).toContainText("1") + }) + + await test.step("Verify the shipping info is correct", async () => { + const address = orderPage.shippingAddressSummary + await expect(address).toContainText("First") + await expect(address).toContainText("Last") + await expect(address).toContainText("123 Fake street") + await expect(address).toContainText("80010") + await expect(address).toContainText("Denver") + await expect(address).toContainText("US") + + const contact = orderPage.shippingContactSummary + await expect(contact).toContainText("test@example.com") + await expect(contact).toContainText("3031112222") + + const method = orderPage.shippingMethodSummary + await expect(method).toContainText("FakeEx Standard") + }) + }) + + test("Editing checkout steps works as expected", async ({ + cartPage, + checkoutPage, + productPage, + storePage, + }) => { + await test.step("Navigate to a product page", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatshirt") + await product.locator.highlight() + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Add the product to the cart and goto checkout", async () => { + await productPage.selectOption("M") + await productPage.clickAddProduct() + await productPage.cartDropdown.navCartLink.click() + await productPage.cartDropdown.goToCartButton.click() + await cartPage.container.waitFor({ state: "visible" }) + await cartPage.checkoutButton.click() + await checkoutPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Enter in the first step of the checkout process", async () => { + await test.step("Enter in the shipping address info", async () => { + await checkoutPage.shippingFirstNameInput.fill("First") + await checkoutPage.shippingLastNameInput.fill("Last") + await checkoutPage.shippingCompanyInput.fill("MyCorp") + await checkoutPage.shippingAddressInput.fill("123 Fake street") + await checkoutPage.shippingPostalCodeInput.fill("80010") + await checkoutPage.shippingCityInput.fill("Denver") + await checkoutPage.shippingProvinceInput.fill("Colorado") + await checkoutPage.shippingCountrySelect.selectOption("United States") + }) + + await test.step("Enter in the contact info and open the billing info form", async () => { + await checkoutPage.shippingEmailInput.fill("test@example.com") + await checkoutPage.shippingPhoneInput.fill("3031112222") + await checkoutPage.billingAddressCheckbox.uncheck() + }) + + await test.step("Enter in the billing address info", async () => { + await checkoutPage.billingFirstNameInput.fill("First") + await checkoutPage.billingLastNameInput.fill("Last") + await checkoutPage.billingCompanyInput.fill("MyCorp") + await checkoutPage.billingAddressInput.fill("123 Fake street") + await checkoutPage.billingPostalInput.fill("80010") + await checkoutPage.billingCityInput.fill("Denver") + await checkoutPage.billingProvinceInput.fill("Colorado") + await checkoutPage.billingCountrySelect.selectOption("United States") + await checkoutPage.submitAddressButton.click() + }) + }) + + await test.step("Submit the delivery and payment options", async () => { + await checkoutPage.selectDeliveryOption("FakeEx Standard") + await checkoutPage.submitDeliveryOptionButton.click() + await checkoutPage.submitPaymentButton.click() + }) + + await test.step("Edit the shipping info", async () => { + await checkoutPage.editAddressButton.click() + await test.step("Edit the shipping address", async () => { + await checkoutPage.shippingFirstNameInput.fill("First1") + await checkoutPage.shippingLastNameInput.fill("Last1") + await checkoutPage.shippingCompanyInput.fill("MeCorp") + await checkoutPage.shippingAddressInput.fill("123 Fake Road") + await checkoutPage.shippingPostalCodeInput.fill("80011") + await checkoutPage.shippingCityInput.fill("Donver") + await checkoutPage.shippingProvinceInput.fill("CO") + await checkoutPage.shippingCountrySelect.selectOption("Canada") + }) + + await test.step("Edit the shipping contact info", async () => { + await checkoutPage.shippingEmailInput.fill("tester@example.com") + await checkoutPage.shippingPhoneInput.fill("3231112222") + }) + + await test.step("Edit the billing info", async () => { + await checkoutPage.billingFirstNameInput.fill("Farst") + await checkoutPage.billingLastNameInput.fill("List") + await checkoutPage.billingCompanyInput.fill("MistCorp") + await checkoutPage.billingAddressInput.fill("321 Fake street") + await checkoutPage.billingPostalInput.fill("80110") + await checkoutPage.billingCityInput.fill("Denvur") + await checkoutPage.billingProvinceInput.fill("AB") + await checkoutPage.billingCountrySelect.selectOption("Canada") + }) + await checkoutPage.submitAddressButton.click() + }) + + await test.step("Make sure the edits are reflected in the container", async () => { + await test.step("Check shipping address summary", async () => { + const shippingColumn = checkoutPage.shippingAddressSummary + await expect(shippingColumn).toContainText("First1") + await expect(shippingColumn).toContainText("Last1") + await expect(shippingColumn).toContainText("123 Fake Road") + await expect(shippingColumn).toContainText("80011") + await expect(shippingColumn).toContainText("Donver") + await expect(shippingColumn).toContainText("CA") + }) + + await test.step("Check shipping contact summary", async () => { + const contactColumn = checkoutPage.shippingContactSummary + await expect(contactColumn).toContainText("tester@example.com") + await expect(contactColumn).toContainText("3231112222") + }) + + await test.step("Check billing summary", async () => { + const billingColumn = checkoutPage.billingAddressSummary + await expect(billingColumn).toContainText("Farst") + await expect(billingColumn).toContainText("List") + await expect(billingColumn).toContainText("321 Fake street") + await expect(billingColumn).toContainText("Denvur") + await expect(billingColumn).toContainText("CA") + }) + }) + }) + + test("Shipping info saved is filled back into the forms after clicking edit", async ({ + cartPage, + checkoutPage, + productPage, + storePage, + }) => { + await test.step("Navigate to a product page", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatshirt") + await product.locator.highlight() + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Add the product to the cart and goto checkout", async () => { + await productPage.selectOption("M") + await productPage.clickAddProduct() + await productPage.cartDropdown.navCartLink.click() + await productPage.cartDropdown.goToCartButton.click() + await cartPage.container.waitFor({ state: "visible" }) + await cartPage.checkoutButton.click() + await checkoutPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Enter in the first step of the checkout process", async () => { + await test.step("Enter in the shipping address info", async () => { + await checkoutPage.shippingFirstNameInput.fill("First") + await checkoutPage.shippingLastNameInput.fill("Last") + await checkoutPage.shippingCompanyInput.fill("MyCorp") + await checkoutPage.shippingAddressInput.fill("123 Fake street") + await checkoutPage.shippingPostalCodeInput.fill("80010") + await checkoutPage.shippingCityInput.fill("Denver") + await checkoutPage.shippingProvinceInput.fill("Colorado") + await checkoutPage.shippingCountrySelect.selectOption("United States") + }) + + await test.step("Enter in the contact info and open the billing info form", async () => { + await checkoutPage.shippingEmailInput.fill("test@example.com") + await checkoutPage.shippingPhoneInput.fill("3031112222") + await checkoutPage.billingAddressCheckbox.uncheck() + }) + + await test.step("Enter in the billing address info", async () => { + await checkoutPage.billingFirstNameInput.fill("First") + await checkoutPage.billingLastNameInput.fill("Last") + await checkoutPage.billingCompanyInput.fill("MyCorp") + await checkoutPage.billingAddressInput.fill("123 Fake street") + await checkoutPage.billingPostalInput.fill("80010") + await checkoutPage.billingCityInput.fill("Denver") + await checkoutPage.billingProvinceInput.fill("Colorado") + await checkoutPage.billingCountrySelect.selectOption("United States") + await checkoutPage.submitAddressButton.click() + }) + }) + + await test.step("Click the edit address form and ensure the fields are filled correctly", async () => { + await checkoutPage.editAddressButton.click() + await test.step("Check the shipping address", async () => { + await expect(checkoutPage.shippingFirstNameInput).toHaveValue("First") + await expect(checkoutPage.shippingLastNameInput).toHaveValue("Last") + await expect(checkoutPage.shippingCompanyInput).toHaveValue("MyCorp") + await expect(checkoutPage.shippingAddressInput).toHaveValue( + "123 Fake street" + ) + await expect(checkoutPage.shippingPostalCodeInput).toHaveValue("80010") + await expect(checkoutPage.shippingCityInput).toHaveValue("Denver") + await expect(checkoutPage.shippingProvinceInput).toHaveValue("Colorado") + await expect(checkoutPage.shippingCountrySelect).toHaveValue("us") + }) + + await test.step("Check the shipping contact", async () => { + await expect(checkoutPage.shippingEmailInput).toHaveValue( + "test@example.com" + ) + await expect(checkoutPage.shippingPhoneInput).toHaveValue("3031112222") + }) + + await test.step("Check the billing address", async () => { + await expect(checkoutPage.billingFirstNameInput).toHaveValue("First") + await expect(checkoutPage.billingLastNameInput).toHaveValue("Last") + await expect(checkoutPage.billingCompanyInput).toHaveValue("MyCorp") + await expect(checkoutPage.billingAddressInput).toHaveValue( + "123 Fake street" + ) + await expect(checkoutPage.billingPostalInput).toHaveValue("80010") + await expect(checkoutPage.billingCityInput).toHaveValue("Denver") + await expect(checkoutPage.billingProvinceInput).toHaveValue("Colorado") + await expect(checkoutPage.billingCountrySelect).toHaveValue("us") + }) + }) + + await test.step("Set the billing info to the same as checked and perform checks", async () => { + await checkoutPage.billingAddressCheckbox.check() + await checkoutPage.submitAddressButton.click() + await checkoutPage.editAddressButton.click() + await expect(checkoutPage.billingAddressCheckbox).toBeChecked() + }) + }) + + test("Shipping info in the checkout page is correctly reflected in the summary", async ({ + cartPage, + checkoutPage, + productPage, + storePage, + }) => { + await test.step("Navigate to a product page", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatshirt") + await product.locator.highlight() + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Add the product to the cart and goto checkout", async () => { + await productPage.selectOption("M") + await productPage.clickAddProduct() + await productPage.cartDropdown.navCartLink.click() + await productPage.cartDropdown.goToCartButton.click() + await cartPage.container.waitFor({ state: "visible" }) + await cartPage.checkoutButton.click() + await checkoutPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Enter in the first step of the checkout process", async () => { + await test.step("Enter in the shipping address info", async () => { + await checkoutPage.shippingFirstNameInput.fill("First") + await checkoutPage.shippingLastNameInput.fill("Last") + await checkoutPage.shippingCompanyInput.fill("MyCorp") + await checkoutPage.shippingAddressInput.fill("123 Fake street") + await checkoutPage.shippingPostalCodeInput.fill("80010") + await checkoutPage.shippingCityInput.fill("Denver") + await checkoutPage.shippingProvinceInput.fill("Colorado") + await checkoutPage.shippingCountrySelect.selectOption("United States") + }) + + await test.step("Enter in the contact info and open the billing info form", async () => { + await checkoutPage.shippingEmailInput.fill("test@example.com") + await checkoutPage.shippingPhoneInput.fill("3031112222") + await checkoutPage.billingAddressCheckbox.uncheck() + }) + + await test.step("Enter in the billing address info", async () => { + await checkoutPage.billingFirstNameInput.fill("First") + await checkoutPage.billingLastNameInput.fill("Last") + await checkoutPage.billingCompanyInput.fill("MyCorp") + await checkoutPage.billingAddressInput.fill("123 Fake street") + await checkoutPage.billingPostalInput.fill("80010") + await checkoutPage.billingCityInput.fill("Denver") + await checkoutPage.billingProvinceInput.fill("Colorado") + await checkoutPage.billingCountrySelect.selectOption("United States") + await checkoutPage.submitAddressButton.click() + }) + }) + + await test.step("Ensure the shipping column reflects the entered data", async () => { + const shippingColumn = checkoutPage.shippingAddressSummary + await expect(shippingColumn).toContainText("First") + await expect(shippingColumn).toContainText("Last") + await expect(shippingColumn).toContainText("123 Fake street") + await expect(shippingColumn).toContainText("80010") + await expect(shippingColumn).toContainText("Denver") + await expect(shippingColumn).toContainText("US") + }) + + await test.step("Ensure the contact column reflects the entered data", async () => { + const contactColumn = checkoutPage.shippingContactSummary + await expect(contactColumn).toContainText("test@example.com") + await expect(contactColumn).toContainText("3031112222") + }) + + await test.step("Ensure the billing column reflects the entered data", async () => { + const billingColumn = checkoutPage.billingAddressSummary + await expect(billingColumn).toContainText("First") + await expect(billingColumn).toContainText("Last") + await expect(billingColumn).toContainText("123 Fake street") + await expect(billingColumn).toContainText("Denver") + await expect(billingColumn).toContainText("US") + }) + + await test.step("Edit the billing info so it is the same as the billing address", async () => { + await checkoutPage.editAddressButton.click() + await checkoutPage.billingAddressCheckbox.check() + await checkoutPage.submitAddressButton.click() + const billingColumn = checkoutPage.billingAddressSummary + await expect(billingColumn).toContainText("are the same.") + }) + }) + + test("Entering checkout, leaving, then returning takes you back to the correct checkout spot", async ({ + cartPage, + checkoutPage, + productPage, + storePage, + }) => { + await test.step("Navigate to a product page", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatshirt") + await product.locator.highlight() + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Add the product to the cart and goto checkout", async () => { + await productPage.selectOption("M") + await productPage.clickAddProduct() + await productPage.cartDropdown.navCartLink.click() + await productPage.cartDropdown.goToCartButton.click() + await cartPage.container.waitFor({ state: "visible" }) + await cartPage.checkoutButton.click() + await checkoutPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Navigate away and back to the checkout page", async () => { + await checkoutPage.backToCartLink.click() + await cartPage.checkoutButton.click() + await checkoutPage.container.waitFor({ state: "visible" }) + await expect(checkoutPage.submitAddressButton).toBeVisible() + }) + + await test.step("Enter in the first step of the checkout process", async () => { + await test.step("Enter in the shipping address info", async () => { + await checkoutPage.shippingFirstNameInput.fill("First") + await checkoutPage.shippingLastNameInput.fill("Last") + await checkoutPage.shippingCompanyInput.fill("MyCorp") + await checkoutPage.shippingAddressInput.fill("123 Fake street") + await checkoutPage.shippingPostalCodeInput.fill("80010") + await checkoutPage.shippingCityInput.fill("Denver") + await checkoutPage.shippingProvinceInput.fill("Colorado") + await checkoutPage.shippingCountrySelect.selectOption("United States") + }) + + await test.step("Enter in the contact info and open the billing info form", async () => { + await checkoutPage.shippingEmailInput.fill("test@example.com") + await checkoutPage.shippingPhoneInput.fill("3031112222") + await checkoutPage.billingAddressCheckbox.uncheck() + }) + + await test.step("Enter in the billing address info", async () => { + await checkoutPage.billingFirstNameInput.fill("First") + await checkoutPage.billingLastNameInput.fill("Last") + await checkoutPage.billingCompanyInput.fill("MyCorp") + await checkoutPage.billingAddressInput.fill("123 Fake street") + await checkoutPage.billingPostalInput.fill("80010") + await checkoutPage.billingCityInput.fill("Denver") + await checkoutPage.billingProvinceInput.fill("Colorado") + await checkoutPage.billingCountrySelect.selectOption("United States") + }) + await checkoutPage.submitAddressButton.click() + await checkoutPage.deliveryOptionRadio + .first() + .waitFor({ state: "visible" }) + }) + + await test.step("Navigate away and back to the checkout page", async () => { + await checkoutPage.backToCartLink.click() + await cartPage.checkoutButton.click() + await checkoutPage.container.waitFor({ state: "visible" }) + await expect(checkoutPage.submitDeliveryOptionButton).toBeVisible() + }) + + await test.step("Submit the delivery choice and navigate back and forth", async () => { + await checkoutPage.selectDeliveryOption("FakeEx Standard") + await checkoutPage.submitDeliveryOptionButton.click() + await checkoutPage.submitPaymentButton.waitFor({ state: "visible" }) + await checkoutPage.backToCartLink.click() + await cartPage.checkoutButton.click() + await checkoutPage.container.waitFor({ state: "visible" }) + await expect(checkoutPage.submitPaymentButton).toBeVisible() + }) + + await test.step("Submit the payment info and navigate back and forth", async () => { + await checkoutPage.submitPaymentButton.click() + await checkoutPage.submitOrderButton.waitFor({ state: "visible" }) + await checkoutPage.backToCartLink.click() + await cartPage.checkoutButton.click() + await checkoutPage.container.waitFor({ state: "visible" }) + await expect(checkoutPage.submitPaymentButton).toBeVisible() + }) + + await test.step("Click edit on the shipping info and navigate back and forth", async () => { + await checkoutPage.editAddressButton.click() + await checkoutPage.backToCartLink.click() + await cartPage.checkoutButton.click() + await checkoutPage.container.waitFor({ state: "visible" }) + await expect(checkoutPage.submitPaymentButton).toBeVisible() + }) + + await test.step("Click edit on the shipping choice and navigate back and forth", async () => { + await checkoutPage.editDeliveryButton.click() + await checkoutPage.backToCartLink.click() + await cartPage.checkoutButton.click() + await checkoutPage.container.waitFor({ state: "visible" }) + await expect(checkoutPage.submitPaymentButton).toBeVisible() + }) + }) + + test("Verify the prices carries over to checkout", async ({ + cartPage, + checkoutPage, + productPage, + storePage, + }) => { + await test.step("Navigate to the product page - go to the store page and click on the Sweatshirt product", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatshirt") + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + let sweatshirtSmallPrice = 0 + let sweatshirtMediumPrice = 0 + await test.step("Add the sweatshirts to the cart", async () => { + await productPage.selectOption("S") + sweatshirtSmallPrice = getFloatValue( + (await productPage.productPrice.getAttribute("data-value")) || "0" + ) + await productPage.clickAddProduct() + await productPage.cartDropdown.close() + await productPage.selectOption("M") + sweatshirtMediumPrice = getFloatValue( + (await productPage.productPrice.getAttribute("data-value")) || "0" + ) + await productPage.clickAddProduct() + await productPage.cartDropdown.close() + }) + + await test.step("Navigate to another product - Sweatpants", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatpants") + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + let sweatpantsSmallPrice = 0 + await test.step("Add the small sweatpants to the cart", async () => { + await productPage.selectOption("S") + sweatpantsSmallPrice = getFloatValue( + (await productPage.productPrice.getAttribute("data-value")) || "0" + ) + await productPage.clickAddProduct() + await productPage.cartDropdown.close() + await productPage.selectOption("S") + await productPage.clickAddProduct() + await productPage.cartDropdown.goToCartButton.click() + await productPage.cartDropdown.close() + await cartPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Verify the price in the cart is the expected value", async () => { + const total = getFloatValue( + (await cartPage.cartSubtotal.getAttribute("data-value")) || "0" + ) + const calculatedTotal = + 2 * sweatpantsSmallPrice + sweatshirtSmallPrice + sweatshirtMediumPrice + expect(compareFloats(total, calculatedTotal)).toBe(0) + await cartPage.checkoutButton.click() + await checkoutPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Go to checkout and verify the price in the checkout is the expected value", async () => { + const total = getFloatValue( + (await checkoutPage.cartSubtotal.getAttribute("data-value")) || "0" + ) + const calculatedTotal = + 2 * sweatpantsSmallPrice + sweatshirtSmallPrice + sweatpantsSmallPrice + expect(compareFloats(total, calculatedTotal)).toBe(0) + }) + }) +}) diff --git a/e2e/tests/public/discount.spec.ts b/e2e/tests/public/discount.spec.ts new file mode 100644 index 0000000..014366f --- /dev/null +++ b/e2e/tests/public/discount.spec.ts @@ -0,0 +1,526 @@ +import { seedDiscount, seedUser } from "../../data/seed" +import { test, expect } from "../../index" + +test.describe("Discount tests", async () => { + let discount = { + id: "", + code: "", + rule_id: "", + amount: 0, + } + test.beforeEach(async () => { + discount = await seedDiscount() + }) + + test("Make sure discount works during transaction", async ({ + cartPage, + checkoutPage, + orderPage, + productPage, + storePage, + }) => { + let cartSubtotal = 0 + await test.step("Go through purchasing process, upto the cart page", async () => { + await test.step("Navigate to a product page", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatshirt") + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Add the product to the cart and goto checkout", async () => { + await productPage.selectOption("M") + await productPage.clickAddProduct() + await productPage.cartDropdown.navCartLink.click() + await productPage.cartDropdown.goToCartButton.click() + await cartPage.container.waitFor({ state: "visible" }) + await cartPage.cartDropdown.close() + cartSubtotal = Number( + (await cartPage.cartTotal.getAttribute("data-value")) || "" + ) + }) + await test.step("Navigate to the checkout page", async () => { + await cartPage.checkoutButton.click() + await checkoutPage.container.waitFor({ state: "visible" }) + }) + }) + + await test.step("Enter in the discount and assert value works", async () => { + await checkoutPage.discountButton.click() + await expect(checkoutPage.discountInput).toBeVisible() + await checkoutPage.discountInput.fill(discount.code) + await checkoutPage.discountApplyButton.click() + const paymentDiscount = await checkoutPage.getDiscount(discount.code) + await expect(paymentDiscount.locator).toBeVisible() + await expect(paymentDiscount.code).toHaveText(discount.code) + expect(paymentDiscount.amountValue).toBe(discount.amount.toString()) + expect(await checkoutPage.cartTotal.getAttribute("data-value")).toBe( + (cartSubtotal - discount.amount).toString() + ) + }) + + let shippingTotal = 0 + await test.step("Go through checkout process", async () => { + await test.step("Enter in the first step of the checkout process", async () => { + await test.step("Enter in the shipping address info", async () => { + await checkoutPage.shippingFirstNameInput.fill("First") + await checkoutPage.shippingLastNameInput.fill("Last") + await checkoutPage.shippingCompanyInput.fill("MyCorp") + await checkoutPage.shippingAddressInput.fill("123 Fake street") + await checkoutPage.shippingPostalCodeInput.fill("80010") + await checkoutPage.shippingCityInput.fill("Denver") + await checkoutPage.shippingProvinceInput.fill("Colorado") + await checkoutPage.shippingCountrySelect.selectOption("United States") + }) + + await test.step("Enter in the contact info and open the billing info form", async () => { + await checkoutPage.shippingEmailInput.fill("test@example.com") + await checkoutPage.shippingPhoneInput.fill("3031112222") + await checkoutPage.submitAddressButton.click() + }) + }) + + await test.step("Complete the rest of the payment process", async () => { + await checkoutPage.selectDeliveryOption("FakeEx Standard") + await checkoutPage.submitDeliveryOptionButton.click() + shippingTotal = Number( + (await checkoutPage.cartShipping.getAttribute("data-value")) || "0" + ) + await checkoutPage.submitPaymentButton.click() + }) + + await test.step("Make sure the cart total is the expected value after selecting shipping", async () => { + expect(await checkoutPage.cartTotal.getAttribute("data-value")).toBe( + (cartSubtotal - discount.amount + shippingTotal).toString() + ) + }) + + await test.step("Finish completing the order", async () => { + await checkoutPage.submitOrderButton.click() + await orderPage.container.waitFor({ state: "visible" }) + }) + }) + const cartTotal = Number(cartSubtotal) + Number(shippingTotal) + + await test.step("Assert the order page shows the total was 0", async () => { + expect(await orderPage.cartTotal.getAttribute("data-value")).toBe( + (cartTotal - discount.amount).toString() + ) + expect(await orderPage.cartSubtotal.getAttribute("data-value")).toBe( + cartSubtotal.toString() + ) + expect(await orderPage.cartDiscount.getAttribute("data-value")).toBe( + discount.amount.toString() + ) + }) + }) + + test("Make sure discount can be used when entered in from cart", async ({ + cartPage, + checkoutPage, + orderPage, + productPage, + storePage, + }) => { + let cartSubtotal = 0 + await test.step("Go through purchasing process, upto the cart page", async () => { + await test.step("Navigate to a product page", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatshirt") + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Add the product to the cart and goto checkout", async () => { + await productPage.selectOption("M") + await productPage.clickAddProduct() + await productPage.cartDropdown.navCartLink.click() + await productPage.cartDropdown.goToCartButton.click() + await cartPage.container.waitFor({ state: "visible" }) + await cartPage.cartDropdown.close() + cartSubtotal = Number( + (await cartPage.cartTotal.getAttribute("data-value")) || "" + ) + }) + }) + + await test.step("Enter in the discount and assert value works", async () => { + await cartPage.discountButton.click() + await expect(cartPage.discountInput).toBeVisible() + await cartPage.discountInput.fill(discount.code) + await cartPage.discountApplyButton.click() + const paymentDiscount = await cartPage.getDiscount(discount.code) + await expect(paymentDiscount.locator).toBeVisible() + await expect(paymentDiscount.code).toHaveText(discount.code) + expect(paymentDiscount.amountValue).toBe(discount.amount.toString()) + expect(await cartPage.cartTotal.getAttribute("data-value")).toBe( + (cartSubtotal - discount.amount).toString() + ) + }) + + await test.step("Go to checkout and assert the value is still discounted", async () => { + await cartPage.checkoutButton.click() + await checkoutPage.container.waitFor({ state: "visible" }) + expect(await checkoutPage.cartTotal.getAttribute("data-value")).toBe( + (cartSubtotal - discount.amount).toString() + ) + }) + + let shippingTotal = 0 + await test.step("Go through checkout process", async () => { + await test.step("Enter in the first step of the checkout process", async () => { + await test.step("Enter in the shipping address info", async () => { + await checkoutPage.shippingFirstNameInput.fill("First") + await checkoutPage.shippingLastNameInput.fill("Last") + await checkoutPage.shippingCompanyInput.fill("MyCorp") + await checkoutPage.shippingAddressInput.fill("123 Fake street") + await checkoutPage.shippingPostalCodeInput.fill("80010") + await checkoutPage.shippingCityInput.fill("Denver") + await checkoutPage.shippingProvinceInput.fill("Colorado") + await checkoutPage.shippingCountrySelect.selectOption("United States") + }) + + await test.step("Enter in the contact info and open the billing info form", async () => { + await checkoutPage.shippingEmailInput.fill("test@example.com") + await checkoutPage.shippingPhoneInput.fill("3031112222") + await checkoutPage.submitAddressButton.click() + }) + }) + + await test.step("Complete the rest of the payment process", async () => { + await checkoutPage.selectDeliveryOption("FakeEx Standard") + await checkoutPage.submitDeliveryOptionButton.click() + shippingTotal = Number( + (await checkoutPage.cartShipping.getAttribute("data-value")) || "0" + ) + await checkoutPage.submitPaymentButton.click() + await checkoutPage.submitOrderButton.click() + await orderPage.container.waitFor({ state: "visible" }) + }) + }) + const cartTotal = Number(cartSubtotal) + Number(shippingTotal) + + await test.step("Assert the order page shows the total was 0", async () => { + expect(await orderPage.cartTotal.getAttribute("data-value")).toBe( + (cartTotal - discount.amount).toString() + ) + expect(await orderPage.cartSubtotal.getAttribute("data-value")).toBe( + cartSubtotal.toString() + ) + expect(await orderPage.cartDiscount.getAttribute("data-value")).toBe( + discount.amount.toString() + ) + }) + }) + + test("Ensure adding and removing a discout does not impact checkout amount", async ({ + cartPage, + checkoutPage, + orderPage, + productPage, + storePage, + }) => { + let cartSubtotal = 0 + await test.step("Go through purchasing process, upto the cart page", async () => { + await test.step("Navigate to a product page", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatshirt") + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Add the product to the cart and goto checkout", async () => { + await productPage.selectOption("M") + await productPage.clickAddProduct() + await productPage.cartDropdown.navCartLink.click() + await productPage.cartDropdown.goToCartButton.click() + await cartPage.container.waitFor({ state: "visible" }) + await cartPage.cartDropdown.close() + cartSubtotal = Number( + (await cartPage.cartTotal.getAttribute("data-value")) || "" + ) + }) + }) + + await test.step("Enter in the discount and assert value works", async () => { + await cartPage.discountButton.click() + await expect(cartPage.discountInput).toBeVisible() + await cartPage.discountInput.fill(discount.code) + await cartPage.discountApplyButton.click() + const paymentDiscount = await cartPage.getDiscount(discount.code) + await expect(paymentDiscount.locator).toBeVisible() + await expect(paymentDiscount.code).toHaveText(discount.code) + expect(paymentDiscount.amountValue).toBe(discount.amount.toString()) + expect(await cartPage.cartTotal.getAttribute("data-value")).toBe( + (cartSubtotal - discount.amount).toString() + ) + }) + + await test.step("Go to checkout and assert the value is still discounted", async () => { + await cartPage.checkoutButton.click() + await checkoutPage.container.waitFor({ state: "visible" }) + expect(await checkoutPage.cartTotal.getAttribute("data-value")).toBe( + (cartSubtotal - discount.amount).toString() + ) + const paymentDiscount = await checkoutPage.getDiscount(discount.code) + await paymentDiscount.removeButton.click() + await expect(paymentDiscount.locator).not.toBeVisible() + expect(await checkoutPage.cartTotal.getAttribute("data-value")).not.toBe( + (cartSubtotal - discount.amount).toString() + ) + }) + + let shippingTotal = "" + await test.step("Go through checkout process", async () => { + await test.step("Enter in the first step of the checkout process", async () => { + await test.step("Enter in the shipping address info", async () => { + await checkoutPage.shippingFirstNameInput.fill("First") + await checkoutPage.shippingLastNameInput.fill("Last") + await checkoutPage.shippingCompanyInput.fill("MyCorp") + await checkoutPage.shippingAddressInput.fill("123 Fake street") + await checkoutPage.shippingPostalCodeInput.fill("80010") + await checkoutPage.shippingCityInput.fill("Denver") + await checkoutPage.shippingProvinceInput.fill("Colorado") + await checkoutPage.shippingCountrySelect.selectOption("United States") + }) + + await test.step("Enter in the contact info and open the billing info form", async () => { + await checkoutPage.shippingEmailInput.fill("test@example.com") + await checkoutPage.shippingPhoneInput.fill("3031112222") + await checkoutPage.submitAddressButton.click() + }) + }) + + await test.step("Complete the rest of the payment process", async () => { + await checkoutPage.selectDeliveryOption("FakeEx Standard") + await checkoutPage.submitDeliveryOptionButton.click() + shippingTotal = + (await checkoutPage.cartShipping.getAttribute("data-value")) || "" + await checkoutPage.submitPaymentButton.click() + await checkoutPage.submitOrderButton.click() + await orderPage.container.waitFor({ state: "visible" }) + }) + }) + const cartTotal = (Number(cartSubtotal) + Number(shippingTotal)).toString() + + await test.step("Assert the order page shows the total was not discounted", async () => { + expect(await orderPage.cartTotal.getAttribute("data-value")).toBe( + cartTotal + ) + expect(await orderPage.cartSubtotal.getAttribute("data-value")).toBe( + cartSubtotal.toString() + ) + }) + }) + + test("Make sure a fake discount displays an error message on the cart page", async ({ + cartPage, + productPage, + storePage, + }) => { + await test.step("Go through purchasing process, upto the cart page", async () => { + await test.step("Navigate to a product page", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatshirt") + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Add the product to the cart and goto checkout", async () => { + await productPage.selectOption("M") + await productPage.clickAddProduct() + await productPage.cartDropdown.navCartLink.click() + await productPage.cartDropdown.goToCartButton.click() + await cartPage.container.waitFor({ state: "visible" }) + await cartPage.cartDropdown.close() + }) + }) + await test.step("Enter in the fake discount", async () => { + await cartPage.discountButton.click() + await expect(cartPage.discountInput).toBeVisible() + await cartPage.discountInput.fill("__FAKE_DISCOUNT_DNE_1111111") + await cartPage.discountApplyButton.click() + await expect(cartPage.discountErrorMessage).toBeVisible() + }) + }) + + test("Make sure a fake discount displays an error message on the checkout page", async ({ + cartPage, + checkoutPage, + productPage, + storePage, + }) => { + await test.step("Go through purchasing process, upto the cart page", async () => { + await test.step("Navigate to a product page", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatshirt") + await product.locator.highlight() + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Add the product to the cart and goto checkout", async () => { + await productPage.selectOption("M") + await productPage.clickAddProduct() + await productPage.cartDropdown.navCartLink.click() + await productPage.cartDropdown.goToCartButton.click() + await cartPage.container.waitFor({ state: "visible" }) + await cartPage.cartDropdown.close() + await cartPage.checkoutButton.click() + await checkoutPage.container.waitFor({ state: "visible" }) + }) + }) + await test.step("Enter in the fake discount", async () => { + await checkoutPage.discountButton.click() + await expect(checkoutPage.discountInput).toBeVisible() + await checkoutPage.discountInput.fill("__FAKE_DISCOUNT_DNE_1111111") + await checkoutPage.discountApplyButton.click() + await expect(checkoutPage.discountErrorMessage).toBeVisible() + }) + }) + + test("Adding a discount and then accessing the cart at a later point keeps the discount amount", async ({ + cartPage, + checkoutPage, + productPage, + storePage, + }) => { + let cartSubtotal = 0 + await test.step("Go through purchasing process, upto the cart page", async () => { + await test.step("Navigate to a product page", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatshirt") + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Add the product to the cart and goto checkout", async () => { + await productPage.selectOption("M") + await productPage.clickAddProduct() + await productPage.cartDropdown.navCartLink.click() + await productPage.cartDropdown.goToCartButton.click() + await cartPage.container.waitFor({ state: "visible" }) + await cartPage.cartDropdown.close() + cartSubtotal = Number( + (await cartPage.cartTotal.getAttribute("data-value")) || "" + ) + }) + }) + + await test.step("Enter in the giftcard and assert value works", async () => { + await cartPage.discountButton.click() + await cartPage.discountInput.fill(discount.code) + await cartPage.discountApplyButton.click() + const paymentDiscount = await cartPage.getDiscount(discount.code) + expect(paymentDiscount.amountValue).toBe(discount.amount.toString()) + expect(await cartPage.cartTotal.getAttribute("data-value")).toBe( + (cartSubtotal - discount.amount).toString() + ) + }) + + await test.step("Navigate away from the cart page and return to it", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatpants") + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + await cartPage.goto() + await cartPage.cartDropdown.close() + }) + + await test.step("Verify the giftcard is still on the cart page", async () => { + const paymentDiscount = await cartPage.getDiscount(discount.code) + await expect(paymentDiscount.locator).toBeVisible() + await expect(paymentDiscount.code).toContainText(discount.code) + expect(await cartPage.cartTotal.getAttribute("data-value")).toBe( + (cartSubtotal - discount.amount).toString() + ) + }) + + await test.step("Verify the giftcard is still on the checkout page", async () => { + await cartPage.checkoutButton.click() + await checkoutPage.container.waitFor({ state: "visible" }) + const paymentDiscount = await checkoutPage.getDiscount(discount.code) + await expect(paymentDiscount.locator).toBeVisible() + await expect(paymentDiscount.code).toContainText(discount.code) + expect(await checkoutPage.cartTotal.getAttribute("data-value")).toBe( + (cartSubtotal - discount.amount).toString() + ) + expect(paymentDiscount.amountValue).toBe(discount.amount.toString()) + }) + }) + + test("Adding a discount and then adding another item to the cart keeps the discount", async ({ + cartPage, + checkoutPage, + productPage, + storePage, + }) => { + let cartSubtotal = 0 + await test.step("Go through purchasing process, upto the cart page", async () => { + await test.step("Navigate to a product page", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatshirt") + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Add the product to the cart and goto checkout", async () => { + await productPage.selectOption("M") + await productPage.clickAddProduct() + await productPage.cartDropdown.navCartLink.click() + await productPage.cartDropdown.goToCartButton.click() + await cartPage.container.waitFor({ state: "visible" }) + await cartPage.cartDropdown.close() + cartSubtotal = Number( + (await cartPage.cartTotal.getAttribute("data-value")) || "" + ) + }) + }) + + await test.step("Enter in the giftcard and assert value works", async () => { + await cartPage.discountButton.click() + await cartPage.discountInput.fill(discount.code) + await cartPage.discountApplyButton.click() + const paymentDiscount = await cartPage.getDiscount(discount.code) + expect(paymentDiscount.amountValue).toBe(discount.amount.toString()) + expect(await cartPage.cartTotal.getAttribute("data-value")).toBe( + (cartSubtotal - discount.amount).toString() + ) + }) + + await test.step("Navigate away from the cart page and return to it", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatpants") + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + await productPage.selectOption("XL") + await productPage.clickAddProduct() + await productPage.cartDropdown.close() + await cartPage.goto() + cartSubtotal = Number( + (await cartPage.cartSubtotal.getAttribute("data-value")) || "" + ) + }) + + await test.step("Verify the giftcard is still on the cart page", async () => { + const paymentDiscount = await cartPage.getDiscount(discount.code) + await expect(paymentDiscount.locator).toBeVisible() + await expect(paymentDiscount.code).toContainText(discount.code) + expect(await cartPage.cartTotal.getAttribute("data-value")).toBe( + (cartSubtotal - discount.amount).toString() + ) + }) + + await test.step("Verify the giftcard is still on the checkout page", async () => { + await cartPage.checkoutButton.click() + await checkoutPage.container.waitFor({ state: "visible" }) + const paymentDiscount = await checkoutPage.getDiscount(discount.code) + await expect(paymentDiscount.locator).toBeVisible() + await expect(paymentDiscount.code).toContainText(discount.code) + expect(await checkoutPage.cartTotal.getAttribute("data-value")).toBe( + (cartSubtotal - discount.amount).toString() + ) + expect(paymentDiscount.amountValue).toBe(discount.amount.toString()) + }) + }) +}) diff --git a/e2e/tests/public/giftcard.spec.ts b/e2e/tests/public/giftcard.spec.ts new file mode 100644 index 0000000..fa18bf5 --- /dev/null +++ b/e2e/tests/public/giftcard.spec.ts @@ -0,0 +1,753 @@ +import { first } from "lodash" +import { seedGiftcard, seedUser } from "../../data/seed" +import { test, expect } from "../../index" + +test.describe("Gift card tests", async () => { + let giftcard = { + id: "", + code: "", + value: 0, + amount: "0", + balance: "", + } + test.beforeEach(async () => { + giftcard = await seedGiftcard() + }) + + test("Make sure giftcard can be used to pay for transaction", async ({ + cartPage, + checkoutPage, + orderPage, + productPage, + storePage, + }) => { + let cartSubtotal = "" + await test.step("Go through purchasing process, upto the cart page", async () => { + await test.step("Navigate to a product page", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatshirt") + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Add the product to the cart and goto checkout", async () => { + await productPage.selectOption("M") + await productPage.clickAddProduct() + await productPage.cartDropdown.navCartLink.click() + await productPage.cartDropdown.goToCartButton.click() + await cartPage.container.waitFor({ state: "visible" }) + await cartPage.cartDropdown.close() + cartSubtotal = + (await cartPage.cartTotal.getAttribute("data-value")) || "" + }) + await test.step("Navigate to the checkout page", async () => { + await cartPage.checkoutButton.click() + await checkoutPage.container.waitFor({ state: "visible" }) + }) + }) + + await test.step("Enter in the giftcard and assert value works", async () => { + await checkoutPage.discountButton.click() + await expect(checkoutPage.discountInput).toBeVisible() + await checkoutPage.discountInput.fill(giftcard.code) + await checkoutPage.discountApplyButton.click() + const paymentGiftcard = await checkoutPage.getGiftCard(giftcard.code) + await expect(paymentGiftcard.locator).toBeVisible() + await expect(paymentGiftcard.code).toHaveText(giftcard.code) + expect(paymentGiftcard.amountValue).toBe(giftcard.amount) + expect(await checkoutPage.cartTotal.getAttribute("data-value")).toBe("0") + }) + + let shippingTotal = "" + await test.step("Go through checkout process", async () => { + await test.step("Enter in the first step of the checkout process", async () => { + await test.step("Enter in the shipping address info", async () => { + await checkoutPage.shippingFirstNameInput.fill("First") + await checkoutPage.shippingLastNameInput.fill("Last") + await checkoutPage.shippingCompanyInput.fill("MyCorp") + await checkoutPage.shippingAddressInput.fill("123 Fake street") + await checkoutPage.shippingPostalCodeInput.fill("80010") + await checkoutPage.shippingCityInput.fill("Denver") + await checkoutPage.shippingProvinceInput.fill("Colorado") + await checkoutPage.shippingCountrySelect.selectOption("United States") + }) + + await test.step("Enter in the contact info and open the billing info form", async () => { + await checkoutPage.shippingEmailInput.fill("test@example.com") + await checkoutPage.shippingPhoneInput.fill("3031112222") + await checkoutPage.submitAddressButton.click() + }) + }) + + await test.step("Complete the rest of the payment process", async () => { + await checkoutPage.selectDeliveryOption("FakeEx Standard") + await checkoutPage.submitDeliveryOptionButton.click() + shippingTotal = + (await checkoutPage.cartShipping.getAttribute("data-value")) || "" + await checkoutPage.submitPaymentButton.click() + }) + + await test.step("Make sure the giftcard still has the total as zero after selecting shipping", async () => { + expect(await checkoutPage.cartTotal.getAttribute("data-value")).toBe( + "0" + ) + }) + + await test.step("Finish completing the order", async () => { + await checkoutPage.submitOrderButton.click() + await orderPage.container.waitFor({ state: "visible" }) + }) + }) + const cartTotal = (Number(cartSubtotal) + Number(shippingTotal)).toString() + + await test.step("Assert the order page shows the total was 0", async () => { + expect(await orderPage.cartTotal.getAttribute("data-value")).toBe("0") + expect(await orderPage.cartSubtotal.getAttribute("data-value")).toBe( + cartSubtotal + ) + expect( + await orderPage.cartGiftCardAmount.getAttribute("data-value") + ).toBe(cartTotal) + }) + }) + + test("Make sure giftcard can be used when entered in from cart", async ({ + cartPage, + checkoutPage, + orderPage, + productPage, + storePage, + }) => { + let cartSubtotal = "" + await test.step("Go through purchasing process, upto the cart page", async () => { + await test.step("Navigate to a product page", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatshirt") + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Add the product to the cart and goto checkout", async () => { + await productPage.selectOption("M") + await productPage.clickAddProduct() + await productPage.cartDropdown.navCartLink.click() + await productPage.cartDropdown.goToCartButton.click() + await cartPage.container.waitFor({ state: "visible" }) + await cartPage.cartDropdown.close() + cartSubtotal = + (await cartPage.cartTotal.getAttribute("data-value")) || "" + }) + }) + + await test.step("Enter in the giftcard and assert value works", async () => { + await cartPage.discountButton.click() + await expect(cartPage.discountInput).toBeVisible() + await cartPage.discountInput.fill(giftcard.code) + await cartPage.discountApplyButton.click() + const paymentGiftcard = await cartPage.getGiftCard(giftcard.code) + await expect(paymentGiftcard.locator).toBeVisible() + await expect(paymentGiftcard.code).toHaveText(giftcard.code) + expect(paymentGiftcard.amountValue).toBe(giftcard.amount) + expect(await cartPage.cartTotal.getAttribute("data-value")).toBe("0") + }) + + await test.step("Go to checkout and assert the value is still 0", async () => { + await cartPage.checkoutButton.click() + await checkoutPage.container.waitFor({ state: "visible" }) + expect(await checkoutPage.cartTotal.getAttribute("data-value")).toBe("0") + }) + + let shippingTotal = "" + await test.step("Go through checkout process", async () => { + await test.step("Enter in the first step of the checkout process", async () => { + await test.step("Enter in the shipping address info", async () => { + await checkoutPage.shippingFirstNameInput.fill("First") + await checkoutPage.shippingLastNameInput.fill("Last") + await checkoutPage.shippingCompanyInput.fill("MyCorp") + await checkoutPage.shippingAddressInput.fill("123 Fake street") + await checkoutPage.shippingPostalCodeInput.fill("80010") + await checkoutPage.shippingCityInput.fill("Denver") + await checkoutPage.shippingProvinceInput.fill("Colorado") + await checkoutPage.shippingCountrySelect.selectOption("United States") + }) + + await test.step("Enter in the contact info and open the billing info form", async () => { + await checkoutPage.shippingEmailInput.fill("test@example.com") + await checkoutPage.shippingPhoneInput.fill("3031112222") + await checkoutPage.submitAddressButton.click() + }) + }) + + await test.step("Complete the rest of the payment process", async () => { + await checkoutPage.selectDeliveryOption("FakeEx Standard") + await checkoutPage.submitDeliveryOptionButton.click() + shippingTotal = + (await checkoutPage.cartShipping.getAttribute("data-value")) || "" + await checkoutPage.submitPaymentButton.click() + await checkoutPage.submitOrderButton.click() + await orderPage.container.waitFor({ state: "visible" }) + }) + }) + const cartTotal = (Number(cartSubtotal) + Number(shippingTotal)).toString() + + await test.step("Assert the order page shows the total was 0", async () => { + expect(await orderPage.cartTotal.getAttribute("data-value")).toBe("0") + expect(await orderPage.cartSubtotal.getAttribute("data-value")).toBe( + cartSubtotal + ) + expect( + await orderPage.cartGiftCardAmount.getAttribute("data-value") + ).toBe(cartTotal) + }) + }) + + test("Ensure adding and removing a giftcard does not impact checkout amount", async ({ + cartPage, + checkoutPage, + orderPage, + productPage, + storePage, + }) => { + let cartSubtotal = "" + await test.step("Go through purchasing process, upto the cart page", async () => { + await test.step("Navigate to a product page", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatshirt") + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Add the product to the cart and goto checkout", async () => { + await productPage.selectOption("M") + await productPage.clickAddProduct() + await productPage.cartDropdown.navCartLink.click() + await productPage.cartDropdown.goToCartButton.click() + await cartPage.container.waitFor({ state: "visible" }) + await cartPage.cartDropdown.close() + cartSubtotal = + (await cartPage.cartTotal.getAttribute("data-value")) || "" + }) + }) + + await test.step("Enter in the giftcard and assert value works", async () => { + await cartPage.discountButton.click() + await expect(cartPage.discountInput).toBeVisible() + await cartPage.discountInput.fill(giftcard.code) + await cartPage.discountApplyButton.click() + const paymentGiftcard = await cartPage.getGiftCard(giftcard.code) + await expect(paymentGiftcard.locator).toBeVisible() + await expect(paymentGiftcard.code).toHaveText(giftcard.code) + expect(paymentGiftcard.amountValue).toBe(giftcard.amount) + expect(await cartPage.cartTotal.getAttribute("data-value")).toBe("0") + }) + + await test.step("Go to checkout and assert the value is still 0", async () => { + await cartPage.checkoutButton.click() + await checkoutPage.container.waitFor({ state: "visible" }) + expect(await checkoutPage.cartTotal.getAttribute("data-value")).toBe("0") + const paymentGiftcard = await checkoutPage.getGiftCard(giftcard.code) + await paymentGiftcard.removeButton.click() + await expect(paymentGiftcard.locator).not.toBeVisible() + expect(await checkoutPage.cartTotal.getAttribute("data-value")).not.toBe( + "0" + ) + }) + + let shippingTotal = "" + await test.step("Go through checkout process", async () => { + await test.step("Enter in the first step of the checkout process", async () => { + await test.step("Enter in the shipping address info", async () => { + await checkoutPage.shippingFirstNameInput.fill("First") + await checkoutPage.shippingLastNameInput.fill("Last") + await checkoutPage.shippingCompanyInput.fill("MyCorp") + await checkoutPage.shippingAddressInput.fill("123 Fake street") + await checkoutPage.shippingPostalCodeInput.fill("80010") + await checkoutPage.shippingCityInput.fill("Denver") + await checkoutPage.shippingProvinceInput.fill("Colorado") + await checkoutPage.shippingCountrySelect.selectOption("United States") + }) + + await test.step("Enter in the contact info and open the billing info form", async () => { + await checkoutPage.shippingEmailInput.fill("test@example.com") + await checkoutPage.shippingPhoneInput.fill("3031112222") + await checkoutPage.submitAddressButton.click() + }) + }) + + await test.step("Complete the rest of the payment process", async () => { + await checkoutPage.selectDeliveryOption("FakeEx Standard") + await checkoutPage.submitDeliveryOptionButton.click() + shippingTotal = + (await checkoutPage.cartShipping.getAttribute("data-value")) || "" + await checkoutPage.submitPaymentButton.click() + await checkoutPage.submitOrderButton.click() + await orderPage.container.waitFor({ state: "visible" }) + }) + }) + const cartTotal = (Number(cartSubtotal) + Number(shippingTotal)).toString() + + await test.step("Assert the order page shows the total was 0", async () => { + expect(await orderPage.cartTotal.getAttribute("data-value")).toBe( + cartTotal + ) + expect(await orderPage.cartSubtotal.getAttribute("data-value")).toBe( + cartSubtotal + ) + }) + }) + + test("Make sure a fake gift card displays an error message on the cart page", async ({ + cartPage, + productPage, + storePage, + }) => { + await test.step("Go through purchasing process, upto the cart page", async () => { + await test.step("Navigate to a product page", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatshirt") + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Add the product to the cart and goto checkout", async () => { + await productPage.selectOption("M") + await productPage.clickAddProduct() + await productPage.cartDropdown.navCartLink.click() + await productPage.cartDropdown.goToCartButton.click() + await cartPage.container.waitFor({ state: "visible" }) + await cartPage.cartDropdown.close() + }) + }) + await test.step("Enter in the fake giftcard", async () => { + await cartPage.discountButton.click() + await expect(cartPage.discountInput).toBeVisible() + await cartPage.discountInput.fill("__FAKE_GIFT_CARD_DNE_1111111") + await cartPage.discountApplyButton.click() + await expect(cartPage.discountErrorMessage).toBeVisible() + }) + }) + + test("Make sure a fake gift card displays an error message on the checkout page", async ({ + cartPage, + checkoutPage, + productPage, + storePage, + }) => { + await test.step("Go through purchasing process, upto the cart page", async () => { + await test.step("Navigate to a product page", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatshirt") + await product.locator.highlight() + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Add the product to the cart and goto checkout", async () => { + await productPage.selectOption("M") + await productPage.clickAddProduct() + await productPage.cartDropdown.navCartLink.click() + await productPage.cartDropdown.goToCartButton.click() + await cartPage.container.waitFor({ state: "visible" }) + await cartPage.cartDropdown.close() + await cartPage.checkoutButton.click() + await checkoutPage.container.waitFor({ state: "visible" }) + }) + }) + await test.step("Enter in the fake giftcard", async () => { + await checkoutPage.discountButton.click() + await expect(checkoutPage.discountInput).toBeVisible() + await checkoutPage.discountInput.fill("__FAKE_GIFT_CARD_DNE_1111111") + await checkoutPage.discountApplyButton.click() + await expect(checkoutPage.discountErrorMessage).toBeVisible() + }) + }) + + test("Adding a giftcard and then accessing the cart at a later point keeps the giftcard amount", async ({ + cartPage, + checkoutPage, + productPage, + storePage, + }) => { + await test.step("Go through purchasing process, upto the cart page", async () => { + await test.step("Navigate to a product page", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatshirt") + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Add the product to the cart and goto checkout", async () => { + await productPage.selectOption("M") + await productPage.clickAddProduct() + await productPage.cartDropdown.navCartLink.click() + await productPage.cartDropdown.goToCartButton.click() + await cartPage.container.waitFor({ state: "visible" }) + await cartPage.cartDropdown.close() + }) + }) + + await test.step("Enter in the giftcard and assert value works", async () => { + await cartPage.discountButton.click() + await cartPage.discountInput.fill(giftcard.code) + await cartPage.discountApplyButton.click() + const paymentGiftcard = await cartPage.getGiftCard(giftcard.code) + expect(paymentGiftcard.amountValue).toBe(giftcard.amount) + expect(await cartPage.cartTotal.getAttribute("data-value")).toBe("0") + }) + + await test.step("Navigate away from the cart page and return to it", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatpants") + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + await cartPage.goto() + }) + + await test.step("Verify the giftcard is still on the cart page", async () => { + const paymentGiftcard = await cartPage.getGiftCard(giftcard.code) + await expect(paymentGiftcard.locator).toBeVisible() + await expect(paymentGiftcard.code).toContainText(giftcard.code) + expect(await cartPage.cartTotal.getAttribute("data-value")).toBe("0") + }) + + await test.step("Verify the giftcard is still on the checkout page", async () => { + await cartPage.checkoutButton.click() + await checkoutPage.container.waitFor({ state: "visible" }) + const paymentGiftcard = await checkoutPage.getGiftCard(giftcard.code) + await expect(paymentGiftcard.locator).toBeVisible() + await expect(paymentGiftcard.code).toContainText(giftcard.code) + expect(await checkoutPage.cartTotal.getAttribute("data-value")).toBe("0") + expect(paymentGiftcard.amountValue).toBe(giftcard.amount) + }) + }) + + test("Adding a giftcard and then adding another item to the cart keeps the giftcard", async ({ + cartPage, + checkoutPage, + productPage, + storePage, + }) => { + await test.step("Go through purchasing process, upto the cart page", async () => { + await test.step("Navigate to a product page", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatshirt") + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Add the product to the cart and goto checkout", async () => { + await productPage.selectOption("M") + await productPage.clickAddProduct() + await productPage.cartDropdown.navCartLink.click() + await productPage.cartDropdown.goToCartButton.click() + await cartPage.container.waitFor({ state: "visible" }) + await cartPage.cartDropdown.close() + }) + }) + + await test.step("Enter in the giftcard and assert value works", async () => { + await cartPage.discountButton.click() + await cartPage.discountInput.fill(giftcard.code) + await cartPage.discountApplyButton.click() + const paymentGiftcard = await cartPage.getGiftCard(giftcard.code) + expect(paymentGiftcard.amountValue).toBe(giftcard.amount) + expect(await cartPage.cartTotal.getAttribute("data-value")).toBe("0") + }) + + await test.step("Navigate away from the cart page and return to it", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatpants") + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + await productPage.selectOption("XL") + await productPage.clickAddProduct() + await productPage.cartDropdown.close() + await cartPage.goto() + }) + + await test.step("Verify the giftcard is still on the cart page", async () => { + const paymentGiftcard = await cartPage.getGiftCard(giftcard.code) + await expect(paymentGiftcard.locator).toBeVisible() + await expect(paymentGiftcard.code).toContainText(giftcard.code) + expect(await cartPage.cartTotal.getAttribute("data-value")).toBe("0") + }) + + await test.step("Verify the giftcard is still on the checkout page", async () => { + await cartPage.checkoutButton.click() + await checkoutPage.container.waitFor({ state: "visible" }) + const paymentGiftcard = await checkoutPage.getGiftCard(giftcard.code) + await expect(paymentGiftcard.locator).toBeVisible() + await expect(paymentGiftcard.code).toContainText(giftcard.code) + expect(await checkoutPage.cartTotal.getAttribute("data-value")).toBe("0") + expect(paymentGiftcard.amountValue).toBe(giftcard.amount) + }) + }) + + test("Applying a giftcard, deleting cookies, and then reapplying the giftcard works", async ({ + cartPage, + productPage, + storePage, + }) => { + await test.step("Go through purchasing process, upto the cart page", async () => { + await test.step("Navigate to a product page", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatshirt") + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Add the product to the cart and goto checkout", async () => { + await productPage.selectOption("M") + await productPage.clickAddProduct() + await productPage.cartDropdown.navCartLink.click() + await productPage.cartDropdown.goToCartButton.click() + await cartPage.container.waitFor({ state: "visible" }) + await cartPage.cartDropdown.close() + }) + }) + + await test.step("Enter in the giftcard and assert value works", async () => { + await cartPage.discountButton.click() + await cartPage.discountInput.fill(giftcard.code) + await cartPage.discountApplyButton.click() + const paymentGiftcard = await cartPage.getGiftCard(giftcard.code) + expect(paymentGiftcard.amountValue).toBe(giftcard.amount) + expect(await cartPage.cartTotal.getAttribute("data-value")).toBe("0") + }) + + await test.step("Navigate away from the cart page and delete cookies", async () => { + const context = storePage.page.context() + await context.clearCookies() + await storePage.page.reload() + await storePage.goto() + }) + + await test.step("Recreate the cart", async () => { + await test.step("Navigate to a product page", async () => { + const product = await storePage.getProduct("Sweatshirt") + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Add the product to the cart and goto checkout", async () => { + await productPage.selectOption("M") + await productPage.clickAddProduct() + await productPage.cartDropdown.navCartLink.click() + await productPage.cartDropdown.goToCartButton.click() + await cartPage.container.waitFor({ state: "visible" }) + await cartPage.cartDropdown.close() + }) + }) + await test.step("Re-enter in the giftcard and assert value works", async () => { + await cartPage.discountButton.click() + await cartPage.discountInput.fill(giftcard.code) + await cartPage.discountApplyButton.click() + const paymentGiftcard = await cartPage.getGiftCard(giftcard.code) + expect(paymentGiftcard.amountValue).toBe(giftcard.amount) + expect(await cartPage.cartTotal.getAttribute("data-value")).toBe("0") + }) + }) + + test("Gift card balance works as expected across transactions", async ({ + cartPage, + checkoutPage, + orderPage, + productPage, + storePage, + }) => { + let firstTransactionTotal = 0 + await test.step("Complete first transaction using the giftcard", async () => { + let cartSubtotal = "" + await test.step("Go through purchasing process, upto the cart page", async () => { + await test.step("Navigate to a product page", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatshirt") + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Add the product to the cart and goto checkout", async () => { + await productPage.selectOption("M") + await productPage.clickAddProduct() + await productPage.cartDropdown.navCartLink.click() + await productPage.cartDropdown.goToCartButton.click() + await cartPage.container.waitFor({ state: "visible" }) + await cartPage.cartDropdown.close() + cartSubtotal = + (await cartPage.cartTotal.getAttribute("data-value")) || "" + }) + await test.step("Navigate to the checkout page", async () => { + await cartPage.checkoutButton.click() + await checkoutPage.container.waitFor({ state: "visible" }) + }) + }) + + await test.step("Enter in the giftcard and assert value works", async () => { + await checkoutPage.discountButton.click() + await expect(checkoutPage.discountInput).toBeVisible() + await checkoutPage.discountInput.fill(giftcard.code) + await checkoutPage.discountApplyButton.click() + const paymentGiftcard = await checkoutPage.getGiftCard(giftcard.code) + await expect(paymentGiftcard.locator).toBeVisible() + await expect(paymentGiftcard.code).toHaveText(giftcard.code) + expect(paymentGiftcard.amountValue).toBe(giftcard.amount) + expect(await checkoutPage.cartTotal.getAttribute("data-value")).toBe( + "0" + ) + }) + + let shippingTotal = "" + await test.step("Go through checkout process", async () => { + await test.step("Enter in the first step of the checkout process", async () => { + await test.step("Enter in the shipping address info", async () => { + await checkoutPage.shippingFirstNameInput.fill("First") + await checkoutPage.shippingLastNameInput.fill("Last") + await checkoutPage.shippingCompanyInput.fill("MyCorp") + await checkoutPage.shippingAddressInput.fill("123 Fake street") + await checkoutPage.shippingPostalCodeInput.fill("80010") + await checkoutPage.shippingCityInput.fill("Denver") + await checkoutPage.shippingProvinceInput.fill("Colorado") + await checkoutPage.shippingCountrySelect.selectOption( + "United States" + ) + }) + + await test.step("Enter in the contact info and open the billing info form", async () => { + await checkoutPage.shippingEmailInput.fill("test@example.com") + await checkoutPage.shippingPhoneInput.fill("3031112222") + await checkoutPage.submitAddressButton.click() + }) + }) + + await test.step("Complete the rest of the payment process", async () => { + await checkoutPage.selectDeliveryOption("FakeEx Standard") + await checkoutPage.submitDeliveryOptionButton.click() + shippingTotal = + (await checkoutPage.cartShipping.getAttribute("data-value")) || "" + await checkoutPage.submitPaymentButton.click() + }) + + await test.step("Make sure the giftcard still has the total as zero after selecting shipping", async () => { + expect(await checkoutPage.cartTotal.getAttribute("data-value")).toBe( + "0" + ) + }) + + await test.step("Finish completing the order", async () => { + await checkoutPage.submitOrderButton.click() + await orderPage.container.waitFor({ state: "visible" }) + }) + }) + const cartTotal = Number(cartSubtotal) + Number(shippingTotal) + firstTransactionTotal = cartTotal + + await test.step("Assert the order page shows the total was 0", async () => { + expect(await orderPage.cartTotal.getAttribute("data-value")).toBe("0") + expect(await orderPage.cartSubtotal.getAttribute("data-value")).toBe( + cartSubtotal + ) + expect( + await orderPage.cartGiftCardAmount.getAttribute("data-value") + ).toBe(cartTotal.toString()) + }) + }) + await test.step("Setup the second transaction with the same giftcard", async () => { + let cartSubtotal = "" + await test.step("Go through purchasing process, upto the cart page", async () => { + await test.step("Navigate to a product page", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatshirt") + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + }) + + await test.step("Add the product to the cart and goto checkout", async () => { + await productPage.selectOption("M") + await productPage.clickAddProduct() + await productPage.cartDropdown.navCartLink.click() + await productPage.cartDropdown.goToCartButton.click() + await cartPage.container.waitFor({ state: "visible" }) + await cartPage.cartDropdown.close() + cartSubtotal = + (await cartPage.cartTotal.getAttribute("data-value")) || "" + }) + await test.step("Navigate to the checkout page", async () => { + await cartPage.checkoutButton.click() + await checkoutPage.container.waitFor({ state: "visible" }) + }) + }) + + await test.step("Enter in the giftcard and assert value works", async () => { + await checkoutPage.discountButton.click() + await expect(checkoutPage.discountInput).toBeVisible() + await checkoutPage.discountInput.fill(giftcard.code) + await checkoutPage.discountApplyButton.click() + const paymentGiftcard = await checkoutPage.getGiftCard(giftcard.code) + await expect(paymentGiftcard.locator).toBeVisible() + await expect(paymentGiftcard.code).toHaveText(giftcard.code) + expect(paymentGiftcard.amountValue).toBe( + (giftcard.value - firstTransactionTotal).toString() + ) + expect(await checkoutPage.cartTotal.getAttribute("data-value")).toBe( + "0" + ) + }) + + let shippingTotal = "" + await test.step("Go through checkout process", async () => { + await test.step("Enter in the first step of the checkout process", async () => { + await test.step("Enter in the shipping address info", async () => { + await checkoutPage.shippingFirstNameInput.fill("First") + await checkoutPage.shippingLastNameInput.fill("Last") + await checkoutPage.shippingCompanyInput.fill("MyCorp") + await checkoutPage.shippingAddressInput.fill("123 Fake street") + await checkoutPage.shippingPostalCodeInput.fill("80010") + await checkoutPage.shippingCityInput.fill("Denver") + await checkoutPage.shippingProvinceInput.fill("Colorado") + await checkoutPage.shippingCountrySelect.selectOption( + "United States" + ) + }) + + await test.step("Enter in the contact info and open the billing info form", async () => { + await checkoutPage.shippingEmailInput.fill("test@example.com") + await checkoutPage.shippingPhoneInput.fill("3031112222") + await checkoutPage.submitAddressButton.click() + }) + }) + + await test.step("Complete the rest of the payment process", async () => { + await checkoutPage.selectDeliveryOption("FakeEx Standard") + await checkoutPage.submitDeliveryOptionButton.click() + shippingTotal = + (await checkoutPage.cartShipping.getAttribute("data-value")) || "" + await checkoutPage.submitPaymentButton.click() + }) + + await test.step("Make sure the giftcard still has the total as zero after selecting shipping", async () => { + expect(await checkoutPage.cartTotal.getAttribute("data-value")).toBe( + "0" + ) + }) + + await test.step("Finish completing the order", async () => { + await checkoutPage.submitOrderButton.click() + await orderPage.container.waitFor({ state: "visible" }) + }) + }) + const cartTotal = ( + Number(cartSubtotal) + Number(shippingTotal) + ).toString() + + await test.step("Assert the order page shows the total was 0", async () => { + expect(await orderPage.cartTotal.getAttribute("data-value")).toBe("0") + expect(await orderPage.cartSubtotal.getAttribute("data-value")).toBe( + cartSubtotal + ) + expect( + await orderPage.cartGiftCardAmount.getAttribute("data-value") + ).toBe(cartTotal) + }) + }) + }) +}) diff --git a/e2e/tests/public/login.spec.ts b/e2e/tests/public/login.spec.ts new file mode 100644 index 0000000..1584d64 --- /dev/null +++ b/e2e/tests/public/login.spec.ts @@ -0,0 +1,70 @@ +import { test, expect } from "../../index" + +test.describe("Login Page functionality", async () => { + test("access login page from nav menu and submit (partially) empty form", async ({ + loginPage, + }) => { + await loginPage.accountLink.click() + await loginPage.container.waitFor({ state: "visible" }) + await loginPage.signInButton.click() + await expect(loginPage.emailInput).toBeFocused() + + await loginPage.emailInput.fill("test-dne@example.com") + await loginPage.signInButton.click() + await expect(loginPage.passwordInput).toBeFocused() + }) + + test("enter incorrect creds and verify error message", async ({ + loginPage, + }) => { + await loginPage.accountLink.click() + await loginPage.container.waitFor({ state: "visible" }) + await loginPage.emailInput.fill("test-dne@example.com") + await loginPage.passwordInput.fill("password") + await loginPage.signInButton.click() + await expect(loginPage.errorMessage).toBeVisible() + }) + + test("enter different incorrect creds and verify error message", async ({ + loginPage, + }) => { + await loginPage.accountLink.click() + await loginPage.container.waitFor({ state: "visible" }) + await loginPage.emailInput.fill("test@example.com") + await loginPage.passwordInput.fill("passwrong") + await loginPage.signInButton.click() + await expect(loginPage.errorMessage).toBeVisible() + }) + + test("successful login redirects to account page", async ({ + accountOverviewPage, + loginPage, + }) => { + await loginPage.accountLink.click() + await loginPage.container.waitFor({ state: "visible" }) + await loginPage.emailInput.fill("test@example.com") + await loginPage.passwordInput.fill("password") + await loginPage.signInButton.click() + await expect(accountOverviewPage.welcomeMessage).toBeVisible() + }) + + test("logging out works correctly", async ({ + page, + accountOverviewPage, + loginPage, + }) => { + await loginPage.accountLink.click() + await loginPage.container.waitFor({ state: "visible" }) + await loginPage.emailInput.fill("test@example.com") + await loginPage.passwordInput.fill("password") + await loginPage.signInButton.click() + await expect(accountOverviewPage.welcomeMessage).toBeVisible() + + await accountOverviewPage.logoutLink.highlight() + await accountOverviewPage.logoutLink.click() + await loginPage.container.waitFor({ state: "visible" }) + + await loginPage.accountLink.click() + await loginPage.container.waitFor({ state: "visible" }) + }) +}) diff --git a/e2e/tests/public/register.spec.ts b/e2e/tests/public/register.spec.ts new file mode 100644 index 0000000..e4e02b8 --- /dev/null +++ b/e2e/tests/public/register.spec.ts @@ -0,0 +1,67 @@ +import { test, expect } from "../../index" + +test.describe("User registration functionality", async () => { + test("registration with existing user shows error message", async ({ + loginPage, + registerPage, + }) => { + await loginPage.accountLink.click() + await registerPage.container.isVisible() + await loginPage.registerButton.click() + + await registerPage.firstNameInput.fill("first") + await registerPage.lastNameInput.fill("last") + await registerPage.emailInput.fill("test@example.com") + await registerPage.passwordInput.fill("password") + await registerPage.registerButton.click() + + await expect(registerPage.registerError).toBeVisible() + }) + + test("registration with empty form data highlights corresponding input", async ({ + accountOverviewPage, + loginPage, + registerPage, + }) => { + await loginPage.accountLink.click() + await registerPage.container.isVisible() + await loginPage.registerButton.click() + + await registerPage.registerButton.click() + await expect(registerPage.firstNameInput).toBeFocused() + await registerPage.firstNameInput.fill("first") + + await registerPage.registerButton.click() + await expect(registerPage.lastNameInput).toBeFocused() + await registerPage.lastNameInput.fill("last") + + await registerPage.registerButton.click() + await expect(registerPage.emailInput).toBeFocused() + await registerPage.emailInput.fill("test-reg-new@example.com") + + await registerPage.registerButton.click() + await expect(registerPage.passwordInput).toBeFocused() + await registerPage.passwordInput.fill("password") + + await registerPage.registerButton.click() + await expect(accountOverviewPage.welcomeMessage).toBeVisible() + }) + + test("successful registration and navigation to account overview", async ({ + loginPage, + registerPage, + accountOverviewPage, + }) => { + await loginPage.accountLink.click() + await registerPage.container.isVisible() + await loginPage.registerButton.click() + + await registerPage.firstNameInput.fill("first") + await registerPage.lastNameInput.fill("last") + await registerPage.emailInput.fill("test-reg@example.com") + await registerPage.passwordInput.fill("password") + await registerPage.registerButton.click() + + await expect(accountOverviewPage.welcomeMessage).toBeVisible() + }) +}) diff --git a/e2e/tests/public/search.spec.ts b/e2e/tests/public/search.spec.ts new file mode 100644 index 0000000..c52b46d --- /dev/null +++ b/e2e/tests/public/search.spec.ts @@ -0,0 +1,71 @@ +import { test, expect } from "../../index" + +test.describe("Search tests", async () => { + test("Searching for a specific product returns the correct product page", async ({ + productPage, + }) => { + const searchModal = productPage.searchModal + await searchModal.open() + await searchModal.searchInput.fill("Sweatshirt") + await searchModal.searchResult + .filter({ hasText: "Sweatshirt" }) + .first() + .click() + await productPage.container.waitFor({ state: "visible" }) + await expect(productPage.productTitle).toContainText("Sweatshirt") + }) + + test("An erroneous search returns an empty result", async ({ + productPage, + }) => { + const searchModal = productPage.searchModal + await searchModal.open() + await searchModal.searchInput.fill("Does Not Sweatshirt") + await expect(searchModal.noSearchResultsContainer).toBeVisible() + }) + + test("User can search after an empty search result", async ({ + productPage, + }) => { + const searchModal = productPage.searchModal + + await searchModal.open() + await searchModal.searchInput.fill("Does Not Sweatshirt") + await expect(searchModal.noSearchResultsContainer).toBeVisible() + + await searchModal.searchInput.fill("Sweat") + await expect(searchModal.searchResults).toBeVisible() + await expect(searchModal.searchResult.first()).toBeVisible() + }) + + test("Closing the search page returns user back to their current page", async ({ + storePage, + productPage, + loginPage, + }) => { + const searchModal = storePage.searchModal + await test.step("Navigate to the store page and open and close search modal", async () => { + await storePage.goto() + await searchModal.open() + await searchModal.close() + await expect(storePage.container).toBeVisible() + }) + + await test.step("Navigate to the product page and open and close search modal", async () => { + await storePage.goto() + const product = await storePage.getProduct("Sweatshirt") + await product.locator.click() + await productPage.container.waitFor({ state: "visible" }) + await searchModal.open() + await searchModal.close() + await expect(productPage.container).toBeVisible() + }) + + await test.step("Navigate to the login page and open and close search modal", async () => { + await loginPage.goto() + await searchModal.open() + await searchModal.close() + await expect(loginPage.container).toBeVisible() + }) + }) +}) diff --git a/e2e/utils/index.ts b/e2e/utils/index.ts new file mode 100644 index 0000000..b7f9f4b --- /dev/null +++ b/e2e/utils/index.ts @@ -0,0 +1,14 @@ +export function getFloatValue(s: string) { + return parseFloat(parseFloat(s).toFixed(2)) +} + +export function compareFloats(f1: number, f2: number) { + const diff = f1 - f2 + if (Math.abs(diff) < 0.01) { + return 0 + } else if (diff < 0) { + return -1 + } else { + return 1 + } +} diff --git a/e2e/utils/locators.ts b/e2e/utils/locators.ts new file mode 100644 index 0000000..f87c936 --- /dev/null +++ b/e2e/utils/locators.ts @@ -0,0 +1,13 @@ +import { Page, Locator} from '@playwright/test' + +export async function getSelectedOptionText(page: Page, select: Locator) { + const handle = await select.elementHandle() + return await page.evaluate( + (opts) => { + if (!opts || !opts[0]) { return "" } + const select = opts[0] as HTMLSelectElement + return select.options[select.selectedIndex].textContent + }, + [handle] + ) +} diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..3ee8acb --- /dev/null +++ b/netlify.toml @@ -0,0 +1,2 @@ +[template.environment] +NEXT_PUBLIC_MEDUSA_BACKEND_URL="URL of your Medusa Server" diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/next-sitemap.js b/next-sitemap.js new file mode 100644 index 0000000..3ede526 --- /dev/null +++ b/next-sitemap.js @@ -0,0 +1,19 @@ +const excludedPaths = ["/checkout", "/account/*"] + +module.exports = { + siteUrl: process.env.NEXT_PUBLIC_VERCEL_URL, + generateRobotsTxt: true, + exclude: excludedPaths + ["/[sitemap]"], + robotsTxtOptions: { + policies: [ + { + userAgent: "*", + allow: "/", + }, + { + userAgent: "*", + disallow: excludedPaths, + }, + ], + }, +} diff --git a/next.config.js b/next.config.js new file mode 100644 index 0000000..3955381 --- /dev/null +++ b/next.config.js @@ -0,0 +1,34 @@ +const { withStoreConfig } = require("./store-config") +const store = require("./store.config.json") + +/** + * @type {import('next').NextConfig} + */ +const nextConfig = withStoreConfig({ + features: store.features, + reactStrictMode: true, + images: { + remotePatterns: [ + { + protocol: "http", + hostname: "localhost", + }, + { + protocol: "https", + hostname: "medusa-public-images.s3.eu-west-1.amazonaws.com", + }, + { + protocol: "https", + hostname: "medusa-server-testing.s3.amazonaws.com", + }, + { + protocol: "https", + hostname: "medusa-server-testing.s3.us-east-1.amazonaws.com", + }, + ], + }, +}) + +console.log("next.config.js", JSON.stringify(module.exports, null, 2)) + +module.exports = nextConfig diff --git a/package.json b/package.json new file mode 100644 index 0000000..4b53d54 --- /dev/null +++ b/package.json @@ -0,0 +1,71 @@ +{ + "name": "medusa-next", + "version": "1.0.3", + "private": true, + "author": "Kasper Fabricius Kristensen & Victor Gerbrands (https://www.medusajs.com)", + "description": "Next.js Starter to be used with Medusa server", + "keywords": [ + "medusa-storefront" + ], + "scripts": { + "dev": "next dev -p 8000", + "build": "next build", + "start": "next start -p 8000", + "lint": "next lint", + "analyze": "ANALYZE=true next build", + "test-e2e": "playwright test e2e" + }, + "resolutions": { + "webpack": "^5", + "@types/react": "17.0.40" + }, + "dependencies": { + "@headlessui/react": "^1.6.1", + "@hookform/error-message": "^2.0.0", + "@medusajs/link-modules": "^0.2.3", + "@medusajs/medusa-js": "^6.1.7", + "@medusajs/modules-sdk": "^1.12.3", + "@medusajs/pricing": "^0.1.4", + "@medusajs/product": "^0.3.4", + "@medusajs/ui": "^2.2.0", + "@meilisearch/instant-meilisearch": "^0.7.1", + "@paypal/paypal-js": "^5.0.6", + "@paypal/react-paypal-js": "^7.8.1", + "@stripe/react-stripe-js": "^1.7.2", + "@stripe/stripe-js": "^1.29.0", + "algoliasearch": "^4.20.0", + "axios": "^1.6.7", + "lodash": "^4.17.21", + "medusa-react": "^9.0.0", + "next": "^14.0.0", + "pg": "^8.11.3", + "react": "^18.2.0", + "react-country-flag": "^3.0.2", + "react-dom": "^18.2.0", + "react-instantsearch-hooks-web": "^6.29.0", + "react-intersection-observer": "^9.3.4", + "tailwindcss-radix": "^2.8.0", + "webpack": "^5" + }, + "devDependencies": { + "@babel/core": "^7.17.5", + "@medusajs/client-types": "^0.2.2", + "@medusajs/medusa": "^1.18.0", + "@medusajs/ui-preset": "^1.0.2", + "@playwright/test": "^1.41.1", + "@types/lodash": "^4.14.195", + "@types/node": "17.0.21", + "@types/pg": "^8.11.0", + "@types/react": "^18.2.42", + "@types/react-dom": "^18.2.18", + "@types/react-instantsearch-dom": "^6.12.3", + "autoprefixer": "^10.4.2", + "babel-loader": "^8.2.3", + "eslint": "8.10.0", + "eslint-config-next": "^13.4.5", + "postcss": "^8.4.8", + "prettier": "^2.8.8", + "tailwindcss": "^3.0.23", + "typescript": "^5.3.2" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..ffca4f0 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,77 @@ +import { defineConfig, devices } from "@playwright/test" +import path from "path" +import "dotenv/config.js" + +export const STORAGE_STATE = path.join(__dirname, "playwright/.auth/user.json") + +export default defineConfig({ + testDir: "./e2e", + /* Run tests in files in parallel */ + fullyParallel: false, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: 1, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: "html", + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: process.env.NEXT_PUBLIC_BASE_URL, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "retain-on-failure", + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: "setup", + testMatch: /global\/setup\.ts/, + teardown: "cleanup test database", + }, + { + name: "public setup", + testMatch: /global\/public-setup\.ts/, + teardown: "cleanup test database", + }, + { + name: "cleanup test database", + testMatch: /global\/teardown\.ts/, + }, + { + name: "chromium auth", + dependencies: ["setup"], + testIgnore: "public/*.spec.ts", + use: { ...devices["Desktop Chrome"], storageState: STORAGE_STATE }, + }, + + { + name: "chromium public", + dependencies: ["public setup"], + testMatch: "public/*.spec.ts", + use: { ...devices["Desktop Chrome"] }, + }, + + /* + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + + { + name: "webkit", + use: { ...devices["Desktop Safari"] }, + }, + */ + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'yarn start', + url: process.env.NEXT_PUBLIC_BASE_URL, + // reuseExistingServer: !process.env.CI, + }, +}) diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 zcmeHv30#a{`}aL_*G&7qml|y<+KVaDM2m#dVr!KsA!#An?kSQM(q<_dDNCpjEux83 zLb9Z^XxbDl(w>%i@8hT6>)&Gu{h#Oeyszu?xtw#Zb1mO{pgX9699l+Qppw7jXaYf~-84xW z)w4x8?=youko|}Vr~(D$UXIbiXABHh`p1?nn8Po~fxRJv}|0e(BPs|G`(TT%kKVJAdg5*Z|x0leQq0 zkdUBvb#>9F()jo|T~kx@OM8$9wzs~t2l;K=woNssA3l6|sx2r3+kdfVW@e^8e*E}v zA1y5{bRi+3Z`uD3{F7LgFJDdvm;nJilkzDku>BwXH(8ItVCXk*-lSJnR?-2UN%hJ){&rlvg`CDTj z)Bzo!3v7Ou#83zEDEFcKt(f1E0~=rqeEbTnMvWR#{+9pg%7G8y>u1OVRUSoox-ovF z2Ydma(;=YuBY(eI|04{hXzZD6_f(v~H;C~y5=DhAC{MMS>2fm~1H_t2$56pc$NH8( z5bH|<)71dV-_oCHIrzrT`2s-5w_+2CM0$95I6X8p^r!gHp+j_gd;9O<1~CEQQGS8) zS9Qh3#p&JM-G8rHekNmKVewU;pJRcTAog68KYo^dRo}(M>36U4Us zfgYWSiHZL3;lpWT=zNAW>Dh#mB!_@Lg%$ms8N-;aPqMn+C2HqZgz&9~Eu z4|Kp<`$q)Uw1R?y(~S>ePdonHxpV1#eSP1B;Ogo+-Pk}6#0GsZZ5!||ev2MGdh}_m z{DeR7?0-1^zVs&`AV6Vt;r3`I`OI_wgs*w=eO%_#7Kepl{B@xiyCANc(l zzIyd4y|c6PXWq9-|KM8(zIk8LPk(>a)zyFWjhT!$HJ$qX1vo@d25W<fvZQ2zUz5WRc(UnFMKHwe1| zWmlB1qdbiA(C0jmnV<}GfbKtmcu^2*P^O?MBLZKt|As~ge8&AAO~2K@zbXelK|4T<{|y4`raF{=72kC2Kn(L4YyenWgrPiv z@^mr$t{#X5VuIMeL!7Ab6_kG$&#&5p*Z{+?5U|TZ`B!7llpVmp@skYz&n^8QfPJzL z0G6K_OJM9x+Wu2gfN45phANGt{7=C>i34CV{Xqlx(fWpeAoj^N0Biu`w+MVcCUyU* zDZuzO0>4Z6fbu^T_arWW5n!E45vX8N=bxTVeFoep_G#VmNlQzAI_KTIc{6>c+04vr zx@W}zE5JNSU>!THJ{J=cqjz+4{L4A{Ob9$ZJ*S1?Ggg3klFp!+Y1@K+pK1DqI|_gq z5ZDXVpge8-cs!o|;K73#YXZ3AShj50wBvuq3NTOZ`M&qtjj#GOFfgExjg8Gn8>Vq5 z`85n+9|!iLCZF5$HJ$Iu($dm?8~-ofu}tEc+-pyke=3!im#6pk_Wo8IA|fJwD&~~F zc16osQ)EBo58U7XDuMexaPRjU@h8tXe%S{fA0NH3vGJFhuyyO!Uyl2^&EOpX{9As0 zWj+P>{@}jxH)8|r;2HdupP!vie{sJ28b&bo!8`D^x}TE$%zXNb^X1p@0PJ86`dZyj z%ce7*{^oo+6%&~I!8hQy-vQ7E)0t0ybH4l%KltWOo~8cO`T=157JqL(oq_rC%ea&4 z2NcTJe-HgFjNg-gZ$6!Y`SMHrlj}Etf7?r!zQTPPSv}{so2e>Fjs1{gzk~LGeesX%r(Lh6rbhSo_n)@@G-FTQy93;l#E)hgP@d_SGvyCp0~o(Y;Ee8{ zdVUDbHm5`2taPUOY^MAGOw*>=s7=Gst=D+p+2yON!0%Hk` zz5mAhyT4lS*T3LS^WSxUy86q&GnoHxzQ6vm8)VS}_zuqG?+3td68_x;etQAdu@sc6 zQJ&5|4(I?~3d-QOAODHpZ=hlSg(lBZ!JZWCtHHSj`0Wh93-Uk)_S%zsJ~aD>{`A0~ z9{AG(e|q3g5B%wYKRxiL2Y$8(4w6bzchKuloQW#e&S3n+P- z8!ds-%f;TJ1>)v)##>gd{PdS2Oc3VaR`fr=`O8QIO(6(N!A?pr5C#6fc~Ge@N%Vvu zaoAX2&(a6eWy_q&UwOhU)|P3J0Qc%OdhzW=F4D|pt0E4osw;%<%Dn58hAWD^XnZD= z>9~H(3bmLtxpF?a7su6J7M*x1By7YSUbxGi)Ot0P77`}P3{)&5Un{KD?`-e?r21!4vTTnN(4Y6Lin?UkSM z`MXCTC1@4A4~mvz%Rh2&EwY))LeoT=*`tMoqcEXI>TZU9WTP#l?uFv+@Dn~b(>xh2 z;>B?;Tz2SR&KVb>vGiBSB`@U7VIWFSo=LDSb9F{GF^DbmWAfpms8Sx9OX4CnBJca3 zlj9(x!dIjN?OG1X4l*imJNvRCk}F%!?SOfiOq5y^mZW)jFL@a|r-@d#f7 z2gmU8L3IZq0ynIws=}~m^#@&C%J6QFo~Mo4V`>v7MI-_!EBMMtb%_M&kvAaN)@ZVw z+`toz&WG#HkWDjnZE!6nk{e-oFdL^$YnbOCN}JC&{$#$O27@|Tn-skXr)2ml2~O!5 zX+gYoxhoc7qoU?C^3~&!U?kRFtnSEecWuH0B0OvLodgUAi}8p1 zrO6RSXHH}DMc$&|?D004DiOVMHV8kXCP@7NKB zgaZq^^O<7PoKEp72kby@W0Z!Y*Ay{&vfg#C&gG@YVR9g?FEocMUi1gSN$+V+ayF45{a zuDZDTN}mS|;BO%gEf}pjBfN2-gIrU#G5~cucA;dokXW89%>AyXJJI z9X4UlIWA|ZYHgbI z5?oFk@A=Ik7lrEQPDH!H+b`7_Y~aDb_qa=B2^Y&Ow41cU=4WDd40dp5(QS-WMN-=Y z9g;6_-JdNU;|6cPwf$ak*aJIcwL@1n$#l~zi{c{EW?T;DaW*E8DYq?Umtz{nJ&w-M zEMyTDrC&9K$d|kZe2#ws6)L=7K+{ zQw{XnV6UC$6-rW0emqm8wJoeZK)wJIcV?dST}Z;G0Arq{dVDu0&4kd%N!3F1*;*pW zR&qUiFzK=@44#QGw7k1`3t_d8&*kBV->O##t|tonFc2YWrL7_eqg+=+k;!F-`^b8> z#KWCE8%u4k@EprxqiV$VmmtiWxDLgnGu$Vs<8rppV5EajBXL4nyyZM$SWVm!wnCj-B!Wjqj5-5dNXukI2$$|Bu3Lrw}z65Lc=1G z^-#WuQOj$hwNGG?*CM_TO8Bg-1+qc>J7k5c51U8g?ZU5n?HYor;~JIjoWH-G>AoUP ztrWWLbRNqIjW#RT*WqZgPJXU7C)VaW5}MiijYbABmzoru6EmQ*N8cVK7a3|aOB#O& zBl8JY2WKfmj;h#Q!pN%9o@VNLv{OUL?rixHwOZuvX7{IJ{(EdPpuVFoQqIOa7giLVkBOKL@^smUA!tZ1CKRK}#SSM)iQHk)*R~?M!qkCruaS!#oIL1c z?J;U~&FfH#*98^G?i}pA{ z9Jg36t4=%6mhY(quYq*vSxptes9qy|7xSlH?G=S@>u>Ebe;|LVhs~@+06N<4CViBk zUiY$thvX;>Tby6z9Y1edAMQaiH zm^r3v#$Q#2T=X>bsY#D%s!bhs^M9PMAcHbCc0FMHV{u-dwlL;a1eJ63v5U*?Q_8JO zT#50!RD619#j_Uf))0ooADz~*9&lN!bBDRUgE>Vud-i5ck%vT=r^yD*^?Mp@Q^v+V zG#-?gKlr}Eeqifb{|So?HM&g91P8|av8hQoCmQXkd?7wIJwb z_^v8bbg`SAn{I*4bH$u(RZ6*xUhuA~hc=8czK8SHEKTzSxgbwi~9(OqJB&gwb^l4+m`k*Q;_?>Y-APi1{k zAHQ)P)G)f|AyjSgcCFps)Fh6Bca*Xznq36!pV6Az&m{O8$wGFD? zY&O*3*J0;_EqM#jh6^gMQKpXV?#1?>$ml1xvh8nSN>-?H=V;nJIwB07YX$e6vLxH( zqYwQ>qxwR(i4f)DLd)-$P>T-no_c!LsN@)8`e;W@)-Hj0>nJ-}Kla4-ZdPJzI&Mce zv)V_j;(3ERN3_@I$N<^|4Lf`B;8n+bX@bHbcZTopEmDI*Jfl)-pFDvo6svPRoo@(x z);_{lY<;);XzT`dBFpRmGrr}z5u1=pC^S-{ce6iXQlLGcItwJ^mZx{m$&DA_oEZ)B{_bYPq-HA zcH8WGoBG(aBU_j)vEy+_71T34@4dmSg!|M8Vf92Zj6WH7Q7t#OHQqWgFE3ARt+%!T z?oLovLVlnf?2c7pTc)~cc^($_8nyKwsN`RA-23ed3sdj(ys%pjjM+9JrctL;dy8a( z@en&CQmnV(()bu|Y%G1-4a(6x{aLytn$T-;(&{QIJB9vMox11U-1HpD@d(QkaJdEb zG{)+6Dos_L+O3NpWo^=gR?evp|CqEG?L&Ut#D*KLaRFOgOEK(Kq1@!EGcTfo+%A&I z=dLbB+d$u{sh?u)xP{PF8L%;YPPW53+@{>5W=Jt#wQpN;0_HYdw1{ksf_XhO4#2F= zyPx6Lx2<92L-;L5PD`zn6zwIH`Jk($?Qw({erA$^bC;q33hv!d!>%wRhj# zal^hk+WGNg;rJtb-EB(?czvOM=H7dl=vblBwAv>}%1@{}mnpUznfq1cE^sgsL0*4I zJ##!*B?=vI_OEVis5o+_IwMIRrpQyT_Sq~ZU%oY7c5JMIADzpD!Upz9h@iWg_>>~j zOLS;wp^i$-E?4<_cp?RiS%Rd?i;f*mOz=~(&3lo<=@(nR!_Rqiprh@weZlL!t#NCc zO!QTcInq|%#>OVgobj{~ixEUec`E25zJ~*DofsQdzIa@5^nOXj2T;8O`l--(QyU^$t?TGY^7#&FQ+2SS3B#qK*k3`ye?8jUYSajE5iBbJls75CCc(m3dk{t?- zopcER9{Z?TC)mk~gpi^kbbu>b-+a{m#8-y2^p$ka4n60w;Sc2}HMf<8JUvhCL0B&Btk)T`ctE$*qNW8L$`7!r^9T+>=<=2qaq-;ll2{`{Rg zc5a0ZUI$oG&j-qVOuKa=*v4aY#IsoM+1|c4Z)<}lEDvy;5huB@1RJPquU2U*U-;gu z=En2m+qjBzR#DEJDO`WU)hdd{Vj%^0V*KoyZ|5lzV87&g_j~NCjwv0uQVqXOb*QrQ zy|Qn`hxx(58c70$E;L(X0uZZ72M1!6oeg)(cdKO ze0gDaTz+ohR-#d)NbAH4x{I(21yjwvBQfmpLu$)|m{XolbgF!pmsqJ#D}(ylp6uC> z{bqtcI#hT#HW=wl7>p!38sKsJ`r8}lt-q%Keqy%u(xk=yiIJiUw6|5IvkS+#?JTBl z8H5(Q?l#wzazujH!8o>1xtn8#_w+397*_cy8!pQGP%K(Ga3pAjsaTbbXJlQF_+m+-UpUUent@xM zg%jqLUExj~o^vQ3Gl*>wh=_gOr2*|U64_iXb+-111aH}$TjeajM+I20xw(((>fej-@CIz4S1pi$(#}P7`4({6QS2CaQS4NPENDp>sAqD z$bH4KGzXGffkJ7R>V>)>tC)uax{UsN*dbeNC*v}#8Y#OWYwL4t$ePR?VTyIs!wea+ z5Urmc)X|^`MG~*dS6pGSbU+gPJoq*^a=_>$n4|P^w$sMBBy@f*Z^Jg6?n5?oId6f{ z$LW4M|4m502z0t7g<#Bx%X;9<=)smFolV&(V^(7Cv2-sxbxopQ!)*#ZRhTBpx1)Fc zNm1T%bONzv6@#|dz(w02AH8OXe>kQ#1FMCzO}2J_mST)+ExmBr9cva-@?;wnmWMOk z{3_~EX_xadgJGv&H@zK_8{(x84`}+c?oSBX*Ge3VdfTt&F}yCpFP?CpW+BE^cWY0^ zb&uBN!Ja3UzYHK-CTyA5=L zEMW{l3Usky#ly=7px648W31UNV@K)&Ub&zP1c7%)`{);I4b0Q<)B}3;NMG2JH=X$U zfIW4)4n9ZM`-yRj67I)YSLDK)qfUJ_ij}a#aZN~9EXrh8eZY2&=uY%2N0UFF7<~%M zsB8=erOWZ>Ct_#^tHZ|*q`H;A)5;ycw*IcmVxi8_0Xk}aJA^ath+E;xg!x+As(M#0=)3!NJR6H&9+zd#iP(m0PIW8$ z1Y^VX`>jm`W!=WpF*{ioM?C9`yOR>@0q=u7o>BP-eSHqCgMDj!2anwH?s%i2p+Q7D zzszIf5XJpE)IG4;d_(La-xenmF(tgAxK`Y4sQ}BSJEPs6N_U2vI{8=0C_F?@7<(G; zo$~G=8p+076G;`}>{MQ>t>7cm=zGtfbdDXm6||jUU|?X?CaE?(<6bKDYKeHlz}DA8 zXT={X=yp_R;HfJ9h%?eWvQ!dRgz&Su*JfNt!Wu>|XfU&68iRikRrHRW|ZxzRR^`eIGt zIeiDgVS>IeExKVRWW8-=A=yA`}`)ZkWBrZD`hpWIxBGkh&f#ijr449~m`j6{4jiJ*C!oVA8ZC?$1RM#K(_b zL9TW)kN*Y4%^-qPpMP7d4)o?Nk#>aoYHT(*g)qmRUb?**F@pnNiy6Fv9rEiUqD(^O zzyS?nBrX63BTRYduaG(0VVG2yJRe%o&rVrLjbxTaAFTd8s;<<@Qs>u(<193R8>}2_ zuwp{7;H2a*X7_jryzriZXMg?bTuegABb^87@SsKkr2)0Gyiax8KQWstw^v#ix45EVrcEhr>!NMhprl$InQMzjSFH54x5k9qHc`@9uKQzvL4ihcq{^B zPrVR=o_ic%Y>6&rMN)hTZsI7I<3&`#(nl+3y3ys9A~&^=4?PL&nd8)`OfG#n zwAMN$1&>K++c{^|7<4P=2y(B{jJsQ0a#U;HTo4ZmWZYvI{+s;Td{Yzem%0*k#)vjpB zia;J&>}ICate44SFYY3vEelqStQWFihx%^vQ@Do(sOy7yR2@WNv7Y9I^yL=nZr3mb zXKV5t@=?-Sk|b{XMhA7ZGB@2hqsx}4xwCW!in#C zI@}scZlr3-NFJ@NFaJlhyfcw{k^vvtGl`N9xSo**rDW4S}i zM9{fMPWo%4wYDG~BZ18BD+}h|GQKc-g^{++3MY>}W_uq7jGHx{mwE9fZiPCoxN$+7 zrODGGJrOkcPQUB(FD5aoS4g~7#6NR^ma7-!>mHuJfY5kTe6PpNNKC9GGRiu^L31uG z$7v`*JknQHsYB!Tm_W{a32TM099djW%5e+j0Ve_ct}IM>XLF1Ap+YvcrLV=|CKo6S zb+9Nl3_YdKP6%Cxy@6TxZ>;4&nTneadr z_ES90ydCev)LV!dN=#(*f}|ZORFdvkYBni^aLbUk>BajeWIOcmHP#8S)*2U~QKI%S zyrLmtPqb&TphJ;>yAxri#;{uyk`JJqODDw%(Z=2`1uc}br^V%>j!gS)D*q*f_-qf8&D;W1dJgQMlaH5er zN2U<%Smb7==vE}dDI8K7cKz!vs^73o9f>2sgiTzWcwY|BMYHH5%Vn7#kiw&eItCqa zIkR2~Q}>X=Ar8W|^Ms41Fm8o6IB2_j60eOeBB1Br!boW7JnoeX6Gs)?7rW0^5psc- zjS16yb>dFn>KPOF;imD}e!enuIniFzv}n$m2#gCCv4jM#ArwlzZ$7@9&XkFxZ4n!V zj3dyiwW4Ki2QG{@i>yuZXQizw_OkZI^-3otXC{!(lUpJF33gI60ak;Uqitp74|B6I zgg{b=Iz}WkhCGj1M=hu4#Aw173YxIVbISaoc z-nLZC*6Tgivd5V`K%GxhBsp@SUU60-rfc$=wb>zdJzXS&-5(NRRodFk;Kxk!S(O(a0e7oY=E( zAyS;Ow?6Q&XA+cnkCb{28_1N8H#?J!*$MmIwLq^*T_9-z^&UE@A(z9oGYtFy6EZef LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/src/app/[countryCode]/(checkout)/checkout/page.tsx b/src/app/[countryCode]/(checkout)/checkout/page.tsx new file mode 100644 index 0000000..a6009a1 --- /dev/null +++ b/src/app/[countryCode]/(checkout)/checkout/page.tsx @@ -0,0 +1,48 @@ +import { Metadata } from "next" +import { cookies } from "next/headers" +import { notFound } from "next/navigation" +import { LineItem } from "@medusajs/medusa" + +import { enrichLineItems } from "@modules/cart/actions" +import Wrapper from "@modules/checkout/components/payment-wrapper" +import CheckoutForm from "@modules/checkout/templates/checkout-form" +import CheckoutSummary from "@modules/checkout/templates/checkout-summary" +import { getCart } from "@lib/data" + +export const metadata: Metadata = { + title: "Checkout", +} + +const fetchCart = async () => { + const cartId = cookies().get("_medusa_cart_id")?.value + + if (!cartId) { + return notFound() + } + + const cart = await getCart(cartId).then((cart) => cart) + + if (cart?.items.length) { + const enrichedItems = await enrichLineItems(cart?.items, cart?.region_id) + cart.items = enrichedItems as LineItem[] + } + + return cart +} + +export default async function Checkout() { + const cart = await fetchCart() + + if (!cart) { + return notFound() + } + + return ( +
+ + + + +
+ ) +} diff --git a/src/app/[countryCode]/(checkout)/layout.tsx b/src/app/[countryCode]/(checkout)/layout.tsx new file mode 100644 index 0000000..53793db --- /dev/null +++ b/src/app/[countryCode]/(checkout)/layout.tsx @@ -0,0 +1,43 @@ +import LocalizedClientLink from "@modules/common/components/localized-client-link" +import ChevronDown from "@modules/common/icons/chevron-down" +import MedusaCTA from "@modules/layout/components/medusa-cta" + +export default function CheckoutLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( +
+
+ +
+
{children}
+
+ +
+
+ ) +} diff --git a/src/app/[countryCode]/(checkout)/not-found.tsx b/src/app/[countryCode]/(checkout)/not-found.tsx new file mode 100644 index 0000000..838c968 --- /dev/null +++ b/src/app/[countryCode]/(checkout)/not-found.tsx @@ -0,0 +1,19 @@ +import InteractiveLink from "@modules/common/components/interactive-link" +import { Metadata } from "next" + +export const metadata: Metadata = { + title: "404", + description: "Something went wrong", +} + +export default async function NotFound() { + return ( +
+

Page not found

+

+ The page you tried to access does not exist. +

+ Go to frontpage +
+ ) +} diff --git a/src/app/[countryCode]/(main)/account/@dashboard/addresses/page.tsx b/src/app/[countryCode]/(main)/account/@dashboard/addresses/page.tsx new file mode 100644 index 0000000..e312572 --- /dev/null +++ b/src/app/[countryCode]/(main)/account/@dashboard/addresses/page.tsx @@ -0,0 +1,37 @@ +import { Metadata } from "next" +import { notFound } from "next/navigation" + +import AddressBook from "@modules/account/components/address-book" + +import { getCustomer, getRegion } from "@lib/data" + +import { headers } from "next/headers" + +export const metadata: Metadata = { + title: "Addresses", + description: "View your addresses", +} + +export default async function Addresses() { + const nextHeaders = headers() + const countryCode = nextHeaders.get("next-url")?.split("/")[1] || "" + const customer = await getCustomer() + const region = await getRegion(countryCode) + + if (!customer || !region) { + notFound() + } + + return ( +
+
+

Shipping Addresses

+

+ View and update your shipping addresses, you can add as many as you + like. Saving your addresses will make them available during checkout. +

+
+ +
+ ) +} diff --git a/src/app/[countryCode]/(main)/account/@dashboard/loading.tsx b/src/app/[countryCode]/(main)/account/@dashboard/loading.tsx new file mode 100644 index 0000000..7691095 --- /dev/null +++ b/src/app/[countryCode]/(main)/account/@dashboard/loading.tsx @@ -0,0 +1,9 @@ +import Spinner from "@modules/common/icons/spinner" + +export default function Loading() { + return ( +
+ +
+ ) +} diff --git a/src/app/[countryCode]/(main)/account/@dashboard/orders/details/[id]/page.tsx b/src/app/[countryCode]/(main)/account/@dashboard/orders/details/[id]/page.tsx new file mode 100644 index 0000000..6232120 --- /dev/null +++ b/src/app/[countryCode]/(main)/account/@dashboard/orders/details/[id]/page.tsx @@ -0,0 +1,32 @@ +import { Metadata } from "next" +import { notFound } from "next/navigation" + +import { retrieveOrder } from "@lib/data" +import OrderDetailsTemplate from "@modules/order/templates/order-details-template" + +type Props = { + params: { id: string } +} + +export async function generateMetadata({ params }: Props): Promise { + const order = await retrieveOrder(params.id).catch(() => null) + + if (!order) { + notFound() + } + + return { + title: `Order #${order.display_id}`, + description: `View your order`, + } +} + +export default async function OrderDetailPage({ params }: Props) { + const order = await retrieveOrder(params.id).catch(() => null) + + if (!order) { + notFound() + } + + return +} diff --git a/src/app/[countryCode]/(main)/account/@dashboard/orders/page.tsx b/src/app/[countryCode]/(main)/account/@dashboard/orders/page.tsx new file mode 100644 index 0000000..7e5c082 --- /dev/null +++ b/src/app/[countryCode]/(main)/account/@dashboard/orders/page.tsx @@ -0,0 +1,33 @@ +import { Metadata } from "next" + +import OrderOverview from "@modules/account/components/order-overview" +import { listCustomerOrders } from "@lib/data" +import { notFound } from "next/navigation" + +export const metadata: Metadata = { + title: "Orders", + description: "Overview of your previous orders.", +} + +export default async function Orders() { + const orders = await listCustomerOrders() + + if (!orders) { + notFound() + } + + return ( +
+
+

Orders

+

+ View your previous orders and their status. You can also create + returns or exchanges for your orders if needed. +

+
+
+ +
+
+ ) +} diff --git a/src/app/[countryCode]/(main)/account/@dashboard/page.tsx b/src/app/[countryCode]/(main)/account/@dashboard/page.tsx new file mode 100644 index 0000000..b90dd6b --- /dev/null +++ b/src/app/[countryCode]/(main)/account/@dashboard/page.tsx @@ -0,0 +1,21 @@ +import { Metadata } from "next" + +import { getCustomer, listCustomerOrders } from "@lib/data" +import Overview from "@modules/account/components/overview" +import { notFound } from "next/navigation" + +export const metadata: Metadata = { + title: "Account", + description: "Overview of your account activity.", +} + +export default async function OverviewTemplate() { + const customer = await getCustomer().catch(() => null) + const orders = (await listCustomerOrders().catch(() => null)) || null + + if (!customer) { + notFound() + } + + return +} diff --git a/src/app/[countryCode]/(main)/account/@dashboard/profile/page.tsx b/src/app/[countryCode]/(main)/account/@dashboard/profile/page.tsx new file mode 100644 index 0000000..5804371 --- /dev/null +++ b/src/app/[countryCode]/(main)/account/@dashboard/profile/page.tsx @@ -0,0 +1,52 @@ +import { Metadata } from "next" + +import ProfilePhone from "@modules/account//components/profile-phone" +import ProfileBillingAddress from "@modules/account/components/profile-billing-address" +import ProfileEmail from "@modules/account/components/profile-email" +import ProfileName from "@modules/account/components/profile-name" +import ProfilePassword from "@modules/account/components/profile-password" + +import { getCustomer, listRegions } from "@lib/data" +import { notFound } from "next/navigation" + +export const metadata: Metadata = { + title: "Profile", + description: "View and edit your Medusa Store profile.", +} + +export default async function Profile() { + const customer = await getCustomer() + const regions = await listRegions() + + if (!customer || !regions) { + notFound() + } + + return ( +
+
+

Profile

+

+ View and update your profile information, including your name, email, + and phone number. You can also update your billing address, or change + your password. +

+
+
+ + + + + + + + + +
+
+ ) +} + +const Divider = () => { + return
+} diff --git a/src/app/[countryCode]/(main)/account/@login/page.tsx b/src/app/[countryCode]/(main)/account/@login/page.tsx new file mode 100644 index 0000000..848e212 --- /dev/null +++ b/src/app/[countryCode]/(main)/account/@login/page.tsx @@ -0,0 +1,12 @@ +import { Metadata } from "next" + +import LoginTemplate from "@modules/account/templates/login-template" + +export const metadata: Metadata = { + title: "Sign in", + description: "Sign in to your Medusa Store account.", +} + +export default function Login() { + return +} diff --git a/src/app/[countryCode]/(main)/account/layout.tsx b/src/app/[countryCode]/(main)/account/layout.tsx new file mode 100644 index 0000000..2be4b05 --- /dev/null +++ b/src/app/[countryCode]/(main)/account/layout.tsx @@ -0,0 +1,18 @@ +import { getCustomer } from "@lib/data" +import AccountLayout from "@modules/account/templates/account-layout" + +export default async function AccountPageLayout({ + dashboard, + login, +}: { + dashboard?: React.ReactNode + login?: React.ReactNode +}) { + const customer = await getCustomer().catch(() => null) + + return ( + + {customer ? dashboard : login} + + ) +} diff --git a/src/app/[countryCode]/(main)/account/loading.tsx b/src/app/[countryCode]/(main)/account/loading.tsx new file mode 100644 index 0000000..7691095 --- /dev/null +++ b/src/app/[countryCode]/(main)/account/loading.tsx @@ -0,0 +1,9 @@ +import Spinner from "@modules/common/icons/spinner" + +export default function Loading() { + return ( +
+ +
+ ) +} diff --git a/src/app/[countryCode]/(main)/cart/loading.tsx b/src/app/[countryCode]/(main)/cart/loading.tsx new file mode 100644 index 0000000..e7b6de3 --- /dev/null +++ b/src/app/[countryCode]/(main)/cart/loading.tsx @@ -0,0 +1,5 @@ +import SkeletonCartPage from "@modules/skeletons/templates/skeleton-cart-page" + +export default function Loading() { + return +} diff --git a/src/app/[countryCode]/(main)/cart/not-found.tsx b/src/app/[countryCode]/(main)/cart/not-found.tsx new file mode 100644 index 0000000..91af293 --- /dev/null +++ b/src/app/[countryCode]/(main)/cart/not-found.tsx @@ -0,0 +1,21 @@ +import { Metadata } from "next" + +import InteractiveLink from "@modules/common/components/interactive-link" + +export const metadata: Metadata = { + title: "404", + description: "Something went wrong", +} + +export default function NotFound() { + return ( +
+

Page not found

+

+ The cart you tried to access does not exist. Clear your cookies and try + again. +

+ Go to frontpage +
+ ) +} diff --git a/src/app/[countryCode]/(main)/cart/page.tsx b/src/app/[countryCode]/(main)/cart/page.tsx new file mode 100644 index 0000000..8b3e2e7 --- /dev/null +++ b/src/app/[countryCode]/(main)/cart/page.tsx @@ -0,0 +1,47 @@ +import { LineItem } from "@medusajs/medusa" +import { Metadata } from "next" +import { cookies } from "next/headers" + +import CartTemplate from "@modules/cart/templates" + +import { enrichLineItems } from "@modules/cart/actions" +import { getCheckoutStep } from "@lib/util/get-checkout-step" +import { CartWithCheckoutStep } from "types/global" +import { getCart, getCustomer } from "@lib/data" + +export const metadata: Metadata = { + title: "Cart", + description: "View your cart", +} + +const fetchCart = async () => { + const cartId = cookies().get("_medusa_cart_id")?.value + + if (!cartId) { + return null + } + + const cart = await getCart(cartId).then( + (cart) => cart as CartWithCheckoutStep + ) + + if (!cart) { + return null + } + + if (cart?.items.length) { + const enrichedItems = await enrichLineItems(cart?.items, cart?.region_id) + cart.items = enrichedItems as LineItem[] + } + + cart.checkout_step = cart && getCheckoutStep(cart) + + return cart +} + +export default async function Cart() { + const cart = await fetchCart() + const customer = await getCustomer() + + return +} diff --git a/src/app/[countryCode]/(main)/categories/[...category]/page.tsx b/src/app/[countryCode]/(main)/categories/[...category]/page.tsx new file mode 100644 index 0000000..74fd658 --- /dev/null +++ b/src/app/[countryCode]/(main)/categories/[...category]/page.tsx @@ -0,0 +1,86 @@ +import { Metadata } from "next" +import { notFound } from "next/navigation" + +import { getCategoryByHandle, listCategories, listRegions } from "@lib/data" +import CategoryTemplate from "@modules/categories/templates" +import { SortOptions } from "@modules/store/components/refinement-list/sort-products" + +type Props = { + params: { category: string[]; countryCode: string } + searchParams: { + sortBy?: SortOptions + page?: string + } +} + +export async function generateStaticParams() { + const product_categories = await listCategories() + + if (!product_categories) { + return [] + } + + const countryCodes = await listRegions().then((regions) => + regions?.map((r) => r.countries.map((c) => c.iso_2)).flat() + ) + + const categoryHandles = product_categories.map((category) => category.handle) + + const staticParams = countryCodes + ?.map((countryCode) => + categoryHandles.map((handle) => ({ + countryCode, + category: [handle], + })) + ) + .flat() + + return staticParams +} + +export async function generateMetadata({ params }: Props): Promise { + try { + const { product_categories } = await getCategoryByHandle( + params.category + ).then((product_categories) => product_categories) + + const title = product_categories + .map((category) => category.name) + .join(" | ") + + const description = + product_categories[product_categories.length - 1].description ?? + `${title} category.` + + return { + title: `${title} | Medusa Store`, + description, + alternates: { + canonical: `${params.category.join("/")}`, + }, + } + } catch (error) { + notFound() + } +} + +export default async function CategoryPage({ params, searchParams }: Props) { + const { sortBy, page } = searchParams + + const { product_categories } = await getCategoryByHandle( + params.category + ).then((product_categories) => product_categories) + + if (!product_categories) { + notFound() + } + + return ( + + ) +} diff --git a/src/app/[countryCode]/(main)/collections/[handle]/page.tsx b/src/app/[countryCode]/(main)/collections/[handle]/page.tsx new file mode 100644 index 0000000..8d29729 --- /dev/null +++ b/src/app/[countryCode]/(main)/collections/[handle]/page.tsx @@ -0,0 +1,81 @@ +import { Metadata } from "next" +import { notFound } from "next/navigation" + +import { + getCollectionByHandle, + getCollectionsList, + listRegions, +} from "@lib/data" +import CollectionTemplate from "@modules/collections/templates" +import { SortOptions } from "@modules/store/components/refinement-list/sort-products" + +type Props = { + params: { handle: string; countryCode: string } + searchParams: { + page?: string + sortBy?: SortOptions + } +} + +export const PRODUCT_LIMIT = 12 + +export async function generateStaticParams() { + const { collections } = await getCollectionsList() + + if (!collections) { + return [] + } + + const countryCodes = await listRegions().then((regions) => + regions?.map((r) => r.countries.map((c) => c.iso_2)).flat() + ) + + const collectionHandles = collections.map((collection) => collection.handle) + + const staticParams = countryCodes + ?.map((countryCode) => + collectionHandles.map((handle) => ({ + countryCode, + handle, + })) + ) + .flat() + + return staticParams +} + +export async function generateMetadata({ params }: Props): Promise { + const collection = await getCollectionByHandle(params.handle) + + if (!collection) { + notFound() + } + + const metadata = { + title: `${collection.title} | Medusa Store`, + description: `${collection.title} collection`, + } as Metadata + + return metadata +} + +export default async function CollectionPage({ params, searchParams }: Props) { + const { sortBy, page } = searchParams + + const collection = await getCollectionByHandle(params.handle).then( + (collection) => collection + ) + + if (!collection) { + notFound() + } + + return ( + + ) +} diff --git a/src/app/[countryCode]/(main)/layout.tsx b/src/app/[countryCode]/(main)/layout.tsx new file mode 100644 index 0000000..481ff5d --- /dev/null +++ b/src/app/[countryCode]/(main)/layout.tsx @@ -0,0 +1,20 @@ +import { Metadata } from "next" + +import Footer from "@modules/layout/templates/footer" +import Nav from "@modules/layout/templates/nav" + +const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || "https://localhost:8000" + +export const metadata: Metadata = { + metadataBase: new URL(BASE_URL), +} + +export default async function PageLayout(props: { children: React.ReactNode }) { + return ( + <> +