From 827f7e0ae09f478bad41be963323dc11a8bb04eb Mon Sep 17 00:00:00 2001 From: Colin Cole Date: Tue, 3 Jan 2023 14:20:48 -0500 Subject: [PATCH 1/4] merge frontend + nginx into backend --- .env.template | 37 +- .github/workflows/frontend.yml | 50 + .github/workflows/nginx.yml | 50 + .github/workflows/release.yml | 2 + .gitignore | 7 + README.md | 127 +- docker-compose.yml | 35 +- services/frontend/.prettierignore | 5 + services/frontend/.prettierrc | 6 + services/frontend/Dockerfile | 5 + services/frontend/package.json | 28 + .../packages/commerce/.prettierignore | 2 + .../frontend/packages/commerce/.prettierrc | 6 + services/frontend/packages/commerce/README.md | 334 + services/frontend/packages/commerce/fixup.mjs | 5 + .../packages/commerce/new-provider.md | 237 + .../frontend/packages/commerce/package.json | 83 + .../commerce/src/api/endpoints/cart.ts | 60 + .../src/api/endpoints/catalog/products.ts | 31 + .../commerce/src/api/endpoints/checkout.ts | 49 + .../src/api/endpoints/customer/address.ts | 65 + .../src/api/endpoints/customer/card.ts | 65 + .../src/api/endpoints/customer/index.ts | 36 + .../commerce/src/api/endpoints/login.ts | 36 + .../commerce/src/api/endpoints/logout.ts | 35 + .../commerce/src/api/endpoints/signup.ts | 36 + .../commerce/src/api/endpoints/wishlist.ts | 58 + .../packages/commerce/src/api/index.ts | 184 + .../packages/commerce/src/api/operations.ts | 177 + .../packages/commerce/src/api/utils/errors.ts | 22 + .../src/api/utils/is-allowed-method.ts | 30 + .../src/api/utils/is-allowed-operation.ts | 19 + .../packages/commerce/src/api/utils/types.ts | 49 + .../packages/commerce/src/auth/use-login.tsx | 20 + .../packages/commerce/src/auth/use-logout.tsx | 20 + .../packages/commerce/src/auth/use-signup.tsx | 20 + .../commerce/src/cart/use-add-item.tsx | 20 + .../packages/commerce/src/cart/use-cart.tsx | 32 + .../commerce/src/cart/use-remove-item.tsx | 20 + .../commerce/src/cart/use-update-item.tsx | 20 + .../commerce/src/checkout/use-checkout.ts | 34 + .../src/checkout/use-submit-checkout.tsx | 23 + .../frontend/packages/commerce/src/config.cjs | 35 + .../src/customer/address/use-add-item.tsx | 21 + .../src/customer/address/use-addresses.tsx | 34 + .../src/customer/address/use-remove-item.tsx | 21 + .../src/customer/address/use-update-item.tsx | 21 + .../src/customer/card/use-add-item.tsx | 21 + .../commerce/src/customer/card/use-cards.tsx | 34 + .../src/customer/card/use-remove-item.tsx | 21 + .../src/customer/card/use-update-item.tsx | 21 + .../commerce/src/customer/use-customer.tsx | 20 + .../frontend/packages/commerce/src/index.tsx | 123 + .../commerce/src/product/use-price.tsx | 64 + .../commerce/src/product/use-search.tsx | 20 + .../packages/commerce/src/types/cart.ts | 177 + .../packages/commerce/src/types/checkout.ts | 57 + .../packages/commerce/src/types/common.ts | 16 + .../commerce/src/types/customer/address.ts | 111 + .../commerce/src/types/customer/card.ts | 102 + .../commerce/src/types/customer/index.ts | 25 + .../packages/commerce/src/types/index.ts | 25 + .../packages/commerce/src/types/login.ts | 29 + .../packages/commerce/src/types/logout.ts | 17 + .../packages/commerce/src/types/page.ts | 28 + .../packages/commerce/src/types/product.ts | 99 + .../packages/commerce/src/types/signup.ts | 26 + .../packages/commerce/src/types/site.ts | 20 + .../packages/commerce/src/types/wishlist.ts | 60 + .../commerce/src/utils/default-fetcher.ts | 12 + .../commerce/src/utils/define-property.ts | 37 + .../packages/commerce/src/utils/errors.ts | 48 + .../packages/commerce/src/utils/types.ts | 147 + .../packages/commerce/src/utils/use-data.tsx | 78 + .../packages/commerce/src/utils/use-hook.ts | 50 + .../packages/commerce/src/wishlist/index.ts | 3 + .../commerce/src/wishlist/use-add-item.tsx | 20 + .../commerce/src/wishlist/use-remove-item.tsx | 20 + .../commerce/src/wishlist/use-wishlist.tsx | 20 + .../frontend/packages/commerce/taskfile.js | 20 + .../frontend/packages/commerce/tsconfig.json | 21 + .../frontend/packages/local/.env.template | 1 + .../frontend/packages/local/.prettierignore | 2 + services/frontend/packages/local/.prettierrc | 6 + services/frontend/packages/local/README.md | 1 + services/frontend/packages/local/package.json | 79 + .../local/src/api/endpoints/cart/index.ts | 1 + .../local/src/api/endpoints/catalog/index.ts | 1 + .../src/api/endpoints/catalog/products.ts | 1 + .../local/src/api/endpoints/checkout/index.ts | 1 + .../src/api/endpoints/customer/address.ts | 1 + .../local/src/api/endpoints/customer/card.ts | 1 + .../local/src/api/endpoints/customer/index.ts | 1 + .../local/src/api/endpoints/login/index.ts | 1 + .../local/src/api/endpoints/logout/index.ts | 1 + .../local/src/api/endpoints/signup/index.ts | 1 + .../src/api/endpoints/wishlist/index.tsx | 1 + .../frontend/packages/local/src/api/index.ts | 42 + .../local/src/api/operations/get-all-pages.ts | 19 + .../api/operations/get-all-product-paths.ts | 15 + .../src/api/operations/get-all-products.ts | 25 + .../api/operations/get-customer-wishlist.ts | 6 + .../local/src/api/operations/get-page.ts | 13 + .../local/src/api/operations/get-product.ts | 26 + .../local/src/api/operations/get-site-info.ts | 43 + .../local/src/api/operations/index.ts | 6 + .../local/src/api/utils/fetch-local.ts | 34 + .../packages/local/src/api/utils/fetch.ts | 3 + .../frontend/packages/local/src/auth/index.ts | 3 + .../packages/local/src/auth/use-login.tsx | 16 + .../packages/local/src/auth/use-logout.tsx | 17 + .../packages/local/src/auth/use-signup.tsx | 19 + .../frontend/packages/local/src/cart/index.ts | 4 + .../packages/local/src/cart/use-add-item.tsx | 17 + .../packages/local/src/cart/use-cart.tsx | 42 + .../local/src/cart/use-remove-item.tsx | 20 + .../local/src/cart/use-update-item.tsx | 20 + .../local/src/checkout/use-checkout.tsx | 16 + .../packages/local/src/commerce.config.json | 10 + .../src/customer/address/use-add-item.tsx | 17 + .../local/src/customer/card/use-add-item.tsx | 17 + .../packages/local/src/customer/index.ts | 1 + .../local/src/customer/use-customer.tsx | 17 + .../frontend/packages/local/src/data.json | 235 + .../frontend/packages/local/src/fetcher.ts | 11 + .../frontend/packages/local/src/index.tsx | 12 + .../packages/local/src/next.config.cjs | 8 + .../packages/local/src/product/index.ts | 2 + .../packages/local/src/product/use-price.tsx | 2 + .../packages/local/src/product/use-search.tsx | 17 + .../frontend/packages/local/src/provider.ts | 22 + .../local/src/wishlist/use-add-item.tsx | 13 + .../local/src/wishlist/use-remove-item.tsx | 17 + .../local/src/wishlist/use-wishlist.tsx | 43 + services/frontend/packages/local/taskfile.js | 20 + .../frontend/packages/local/tsconfig.json | 21 + .../frontend/packages/spree/.env.template | 25 + .../frontend/packages/spree/.prettierignore | 2 + services/frontend/packages/spree/.prettierrc | 6 + .../spree/README-assets/screenshots.png | Bin 0 -> 117099 bytes services/frontend/packages/spree/README.md | 33 + services/frontend/packages/spree/package.json | 83 + .../spree/src/api/endpoints/cart/index.ts | 1 + .../spree/src/api/endpoints/catalog/index.ts | 1 + .../src/api/endpoints/catalog/products.ts | 1 + .../api/endpoints/checkout/get-checkout.ts | 44 + .../spree/src/api/endpoints/checkout/index.ts | 22 + .../src/api/endpoints/customer/address.ts | 1 + .../spree/src/api/endpoints/customer/card.ts | 1 + .../spree/src/api/endpoints/customer/index.ts | 1 + .../spree/src/api/endpoints/login/index.ts | 1 + .../spree/src/api/endpoints/logout/index.ts | 1 + .../spree/src/api/endpoints/signup/index.ts | 1 + .../src/api/endpoints/wishlist/index.tsx | 1 + .../frontend/packages/spree/src/api/index.ts | 45 + .../spree/src/api/operations/get-all-pages.ts | 82 + .../api/operations/get-all-product-paths.ts | 97 + .../src/api/operations/get-all-products.ts | 92 + .../api/operations/get-customer-wishlist.ts | 6 + .../spree/src/api/operations/get-page.ts | 81 + .../spree/src/api/operations/get-product.ts | 90 + .../spree/src/api/operations/get-site-info.ts | 138 + .../spree/src/api/operations/index.ts | 6 + .../spree/src/api/utils/create-api-fetch.ts | 86 + .../packages/spree/src/api/utils/fetch.ts | 3 + .../frontend/packages/spree/src/auth/index.ts | 3 + .../packages/spree/src/auth/use-login.tsx | 86 + .../packages/spree/src/auth/use-logout.tsx | 81 + .../packages/spree/src/auth/use-signup.tsx | 96 + .../frontend/packages/spree/src/cart/index.ts | 4 + .../packages/spree/src/cart/use-add-item.tsx | 118 + .../packages/spree/src/cart/use-cart.tsx | 123 + .../spree/src/cart/use-remove-item.tsx | 119 + .../spree/src/cart/use-update-item.tsx | 148 + .../spree/src/checkout/use-checkout.tsx | 19 + .../packages/spree/src/commerce.config.json | 10 + .../src/customer/address/use-add-item.tsx | 18 + .../spree/src/customer/card/use-add-item.tsx | 19 + .../packages/spree/src/customer/index.ts | 1 + .../spree/src/customer/use-customer.tsx | 83 + .../spree/src/errors/AccessTokenError.ts | 1 + .../spree/src/errors/MisconfigurationError.ts | 1 + .../errors/MissingConfigurationValueError.ts | 1 + .../src/errors/MissingLineItemVariantError.ts | 1 + .../src/errors/MissingOptionValueError.ts | 1 + .../src/errors/MissingPrimaryVariantError.ts | 1 + .../spree/src/errors/MissingProductError.ts | 1 + .../src/errors/MissingSlugVariableError.ts | 1 + .../spree/src/errors/MissingVariantError.ts | 1 + .../spree/src/errors/RefreshTokenError.ts | 1 + .../src/errors/SpreeResponseContentError.ts | 1 + .../SpreeSdkMethodFromEndpointPathError.ts | 1 + .../src/errors/TokensNotRejectedError.ts | 1 + .../src/errors/UserTokenResponseParseError.ts | 1 + .../frontend/packages/spree/src/fetcher.ts | 123 + .../frontend/packages/spree/src/index.tsx | 49 + .../packages/spree/src/isomorphic-config.ts | 83 + .../packages/spree/src/next.config.cjs | 16 + .../packages/spree/src/product/index.ts | 2 + .../packages/spree/src/product/use-price.tsx | 2 + .../packages/spree/src/product/use-search.tsx | 104 + .../frontend/packages/spree/src/provider.ts | 35 + .../packages/spree/src/types/index.ts | 164 + .../convert-spree-error-to-graph-ql-error.ts | 52 + .../utils/create-customized-fetch-fetcher.ts | 109 + .../spree/src/utils/create-empty-cart.ts | 22 + .../utils/create-get-absolute-image-url.ts | 26 + .../spree/src/utils/expand-options.ts | 103 + .../utils/force-isomorphic-config-values.ts | 43 + .../packages/spree/src/utils/get-image-url.ts | 44 + .../spree/src/utils/get-media-gallery.ts | 25 + .../spree/src/utils/get-product-path.ts | 7 + ...get-spree-sdk-method-from-endpoint-path.ts | 61 + .../spree/src/utils/handle-token-errors.ts | 14 + .../spree/src/utils/is-json-content-type.ts | 5 + .../packages/spree/src/utils/is-server.ts | 1 + .../packages/spree/src/utils/login.ts | 58 + .../utils/normalizations/normalize-cart.ts | 211 + .../utils/normalizations/normalize-page.ts | 42 + .../utils/normalizations/normalize-product.ts | 240 + .../utils/normalizations/normalize-user.ts | 16 + .../normalizations/normalize-wishlist.ts | 68 + .../utils/pretty-print-spree-sdk-errors.ts | 21 + .../spree/src/utils/require-config.ts | 16 + .../spree/src/utils/sort-option-types.ts | 11 + .../spree/src/utils/tokens/cart-token.ts | 21 + .../tokens/ensure-fresh-user-access-token.ts | 51 + .../spree/src/utils/tokens/ensure-itoken.ts | 25 + .../spree/src/utils/tokens/is-logged-in.ts | 9 + .../src/utils/tokens/revoke-user-tokens.ts | 49 + .../src/utils/tokens/user-token-response.ts | 58 + .../validate-all-products-taxonomy-id.ts | 13 + .../validations/validate-cookie-expire.ts | 21 + .../validate-images-option-filter.ts | 15 + .../validations/validate-images-quality.ts | 23 + .../utils/validations/validate-images-size.ts | 13 + .../validate-placeholder-image-url.ts | 15 + .../validate-products-prerender-count.ts | 21 + .../packages/spree/src/wishlist/index.ts | 3 + .../spree/src/wishlist/use-add-item.tsx | 88 + .../spree/src/wishlist/use-remove-item.tsx | 75 + .../spree/src/wishlist/use-wishlist.tsx | 93 + services/frontend/packages/spree/taskfile.js | 20 + .../frontend/packages/spree/tsconfig.json | 21 + .../frontend/packages/swell/.env.template | 7 + .../frontend/packages/swell/.prettierignore | 2 + services/frontend/packages/swell/.prettierrc | 6 + services/frontend/packages/swell/package.json | 83 + services/frontend/packages/swell/schema.d.ts | 5002 +++++++++ .../frontend/packages/swell/schema.graphql | 9631 +++++++++++++++++ .../packages/swell/src/api/cart/index.ts | 1 + .../packages/swell/src/api/catalog/index.ts | 1 + .../swell/src/api/catalog/products.ts | 1 + .../packages/swell/src/api/customer.ts | 1 + .../packages/swell/src/api/customers/index.ts | 1 + .../swell/src/api/customers/logout.ts | 1 + .../swell/src/api/customers/signup.ts | 1 + .../packages/swell/src/api/endpoints/cart.ts | 1 + .../src/api/endpoints/catalog/products.ts | 1 + .../swell/src/api/endpoints/checkout/index.ts | 30 + .../src/api/endpoints/customer/address.ts | 1 + .../swell/src/api/endpoints/customer/card.ts | 1 + .../swell/src/api/endpoints/customer/index.ts | 1 + .../packages/swell/src/api/endpoints/login.ts | 1 + .../swell/src/api/endpoints/logout.ts | 1 + .../swell/src/api/endpoints/signup.ts | 1 + .../swell/src/api/endpoints/wishlist.ts | 1 + .../frontend/packages/swell/src/api/index.ts | 53 + .../swell/src/api/operations/get-all-pages.ts | 44 + .../api/operations/get-all-product-paths.ts | 51 + .../src/api/operations/get-all-products.ts | 43 + .../swell/src/api/operations/get-page.ts | 57 + .../swell/src/api/operations/get-product.ts | 33 + .../swell/src/api/operations/get-site-info.ts | 37 + .../swell/src/api/operations/login.ts | 46 + .../swell/src/api/utils/fetch-swell-api.ts | 7 + .../packages/swell/src/api/utils/fetch.ts | 2 + .../swell/src/api/utils/is-allowed-method.ts | 28 + .../packages/swell/src/api/wishlist/index.tsx | 2 + .../packages/swell/src/auth/use-login.tsx | 76 + .../packages/swell/src/auth/use-logout.tsx | 39 + .../packages/swell/src/auth/use-signup.tsx | 61 + .../frontend/packages/swell/src/cart/index.ts | 3 + .../packages/swell/src/cart/use-add-item.tsx | 61 + .../packages/swell/src/cart/use-cart.tsx | 39 + .../swell/src/cart/use-remove-item.tsx | 57 + .../swell/src/cart/use-update-item.tsx | 101 + .../swell/src/cart/utils/checkout-create.ts | 28 + .../swell/src/cart/utils/checkout-to-cart.ts | 26 + .../packages/swell/src/cart/utils/index.ts | 2 + .../swell/src/checkout/use-checkout.tsx | 16 + .../packages/swell/src/commerce.config.json | 6 + services/frontend/packages/swell/src/const.ts | 11 + .../src/customer/address/use-add-item.tsx | 17 + .../swell/src/customer/card/use-add-item.tsx | 17 + .../packages/swell/src/customer/index.ts | 1 + .../swell/src/customer/use-customer.tsx | 31 + .../frontend/packages/swell/src/fetcher.ts | 26 + .../frontend/packages/swell/src/index.tsx | 12 + .../packages/swell/src/next.config.cjs | 8 + .../packages/swell/src/product/index.ts | 2 + .../packages/swell/src/product/use-price.tsx | 2 + .../packages/swell/src/product/use-search.tsx | 61 + .../frontend/packages/swell/src/provider.ts | 30 + services/frontend/packages/swell/src/swell.ts | 7 + services/frontend/packages/swell/src/types.ts | 112 + .../frontend/packages/swell/src/types/cart.ts | 1 + .../packages/swell/src/types/checkout.ts | 1 + .../packages/swell/src/types/common.ts | 1 + .../packages/swell/src/types/customer.ts | 1 + .../packages/swell/src/types/index.ts | 25 + .../packages/swell/src/types/login.ts | 11 + .../packages/swell/src/types/logout.ts | 1 + .../frontend/packages/swell/src/types/page.ts | 1 + .../packages/swell/src/types/product.ts | 1 + .../packages/swell/src/types/signup.ts | 1 + .../frontend/packages/swell/src/types/site.ts | 1 + .../packages/swell/src/types/wishlist.ts | 1 + .../swell/src/utils/customer-token.ts | 21 + .../swell/src/utils/get-categories.ts | 16 + .../swell/src/utils/get-checkout-id.ts | 8 + .../swell/src/utils/get-search-variables.ts | 27 + .../swell/src/utils/get-sort-variables.ts | 32 + .../packages/swell/src/utils/get-vendors.ts | 27 + .../swell/src/utils/handle-fetch-response.ts | 19 + .../packages/swell/src/utils/handle-login.ts | 39 + .../packages/swell/src/utils/index.ts | 9 + .../packages/swell/src/utils/normalize.ts | 226 + .../packages/swell/src/utils/storage.ts | 13 + .../swell/src/wishlist/use-add-item.tsx | 13 + .../swell/src/wishlist/use-remove-item.tsx | 17 + .../swell/src/wishlist/use-wishlist.tsx | 46 + services/frontend/packages/swell/taskfile.js | 20 + .../frontend/packages/swell/tsconfig.json | 21 + .../frontend/packages/taskr-swc/.prettierrc | 6 + .../frontend/packages/taskr-swc/package.json | 16 + .../packages/taskr-swc/taskfile-swc.js | 123 + services/frontend/site/.eslintrc | 6 + services/frontend/site/.gitignore | 35 + services/frontend/site/.prettierignore | 3 + services/frontend/site/.prettierrc | 6 + services/frontend/site/assets/base.css | 121 + services/frontend/site/assets/chrome-bug.css | 12 + services/frontend/site/assets/components.css | 3 + services/frontend/site/assets/main.css | 7 + services/frontend/site/commerce-config.js | 97 + services/frontend/site/commerce.config.json | 9 + .../site/components/auth/ForgotPassword.tsx | 78 + .../site/components/auth/LoginView.tsx | 103 + .../site/components/auth/SignUpView.tsx | 114 + .../frontend/site/components/auth/index.ts | 3 + .../cart/CartItem/CartItem.module.css | 32 + .../components/cart/CartItem/CartItem.tsx | 159 + .../site/components/cart/CartItem/index.ts | 1 + .../CartSidebarView.module.css | 11 + .../cart/CartSidebarView/CartSidebarView.tsx | 131 + .../components/cart/CartSidebarView/index.ts | 1 + .../frontend/site/components/cart/index.ts | 2 + .../CheckoutSidebarView.module.css | 29 + .../CheckoutSidebarView.tsx | 211 + .../checkout/CheckoutSidebarView/index.ts | 1 + .../OrderConfirmView.module.css | 4 + .../OrderConfirmView/OrderConfirmView.tsx | 20 + .../checkout/OrderConfirmView/index.ts | 1 + .../PaymentMethodView.module.css | 17 + .../PaymentMethodView/PaymentMethodView.tsx | 132 + .../checkout/PaymentMethodView/index.ts | 1 + .../PaymentWidget/PaymentWidget.module.css | 4 + .../checkout/PaymentWidget/PaymentWidget.tsx | 28 + .../checkout/PaymentWidget/index.ts | 1 + .../ShippingView/ShippingView.module.css | 21 + .../checkout/ShippingView/ShippingView.tsx | 119 + .../components/checkout/ShippingView/index.ts | 1 + .../ShippingWidget/ShippingWidget.module.css | 4 + .../ShippingWidget/ShippingWidget.tsx | 31 + .../checkout/ShippingWidget/index.ts | 1 + .../site/components/checkout/context.tsx | 111 + .../frontend/site/components/common/Ad/Ad.tsx | 57 + .../site/components/common/Ad/index.ts | 1 + .../site/components/common/Avatar/Avatar.tsx | 24 + .../site/components/common/Avatar/index.ts | 1 + .../components/common/Discount/Discount.tsx | 45 + .../site/components/common/Discount/index.ts | 1 + .../common/FeatureBar/FeatureBar.module.css | 6 + .../common/FeatureBar/FeatureBar.tsx | 39 + .../components/common/FeatureBar/index.ts | 1 + .../common/Footer/Footer.module.css | 13 + .../site/components/common/Footer/Footer.tsx | 103 + .../site/components/common/Footer/index.ts | 1 + .../site/components/common/Head/Head.tsx | 17 + .../site/components/common/Head/index.ts | 1 + .../HomeAllProductsGrid.module.css | 23 + .../HomeAllProductsGrid.tsx | 73 + .../common/HomeAllProductsGrid/index.ts | 1 + .../common/I18nWidget/I18nWidget.module.css | 48 + .../common/I18nWidget/I18nWidget.tsx | 101 + .../components/common/I18nWidget/index.ts | 1 + .../common/Layout/Layout.module.css | 4 + .../site/components/common/Layout/Layout.tsx | 130 + .../site/components/common/Layout/index.ts | 1 + .../common/Navbar/Navbar.module.css | 35 + .../site/components/common/Navbar/Navbar.tsx | 58 + .../components/common/Navbar/NavbarRoot.tsx | 33 + .../site/components/common/Navbar/index.ts | 1 + .../site/components/common/SEO/SEO.tsx | 157 + .../site/components/common/SEO/index.ts | 1 + .../common/Searchbar/Searchbar.module.css | 29 + .../components/common/Searchbar/Searchbar.tsx | 60 + .../site/components/common/Searchbar/index.ts | 1 + .../SidebarLayout/SidebarLayout.module.css | 20 + .../common/SidebarLayout/SidebarLayout.tsx | 50 + .../components/common/SidebarLayout/index.ts | 1 + .../CustomerMenuContent.module.css | 31 + .../CustomerMenuContent.tsx | 86 + .../UserNav/CustomerMenuContent/index.ts | 1 + .../MenuSidebarView.module.css | 7 + .../MenuSidebarView/MenuSidebarView.tsx | 42 + .../common/UserNav/MenuSidebarView/index.ts | 5 + .../common/UserNav/UserNav.module.css | 59 + .../components/common/UserNav/UserNav.tsx | 109 + .../site/components/common/UserNav/index.ts | 3 + .../frontend/site/components/common/index.ts | 10 + .../site/components/icons/ArrowLeft.tsx | 27 + .../site/components/icons/ArrowRight.tsx | 28 + .../frontend/site/components/icons/Bag.tsx | 33 + .../frontend/site/components/icons/Check.tsx | 21 + .../site/components/icons/ChevronDown.tsx | 20 + .../site/components/icons/ChevronLeft.tsx | 20 + .../site/components/icons/ChevronRight.tsx | 20 + .../site/components/icons/ChevronUp.tsx | 20 + .../site/components/icons/CreditCard.tsx | 21 + .../frontend/site/components/icons/Cross.tsx | 21 + .../site/components/icons/DoubleChevron.tsx | 22 + .../frontend/site/components/icons/Github.tsx | 20 + .../frontend/site/components/icons/Heart.tsx | 22 + .../frontend/site/components/icons/Info.tsx | 22 + .../frontend/site/components/icons/MapPin.tsx | 20 + .../frontend/site/components/icons/Menu.tsx | 21 + .../frontend/site/components/icons/Minus.tsx | 15 + .../frontend/site/components/icons/Moon.tsx | 20 + .../frontend/site/components/icons/Plus.tsx | 22 + .../frontend/site/components/icons/Star.tsx | 16 + .../frontend/site/components/icons/Sun.tsx | 28 + .../frontend/site/components/icons/Trash.tsx | 43 + .../frontend/site/components/icons/Vercel.tsx | 40 + .../frontend/site/components/icons/index.ts | 23 + .../product/ProductCard/ProductCard-v2.tsx | 147 + .../ProductCard/ProductCard.module.css | 92 + .../product/ProductCard/ProductCard.tsx | 138 + .../components/product/ProductCard/index.ts | 1 + .../product/ProductOptions/ProductOptions.tsx | 52 + .../product/ProductOptions/index.ts | 1 + .../ProductSidebar/ProductSidebar.module.css | 84 + .../product/ProductSidebar/ProductSidebar.tsx | 97 + .../product/ProductSidebar/index.ts | 1 + .../ProductSlider/ProductSlider.module.css | 57 + .../product/ProductSlider/ProductSlider.tsx | 129 + .../components/product/ProductSlider/index.ts | 1 + .../ProductSliderControl.module.css | 29 + .../ProductSliderControl.tsx | 30 + .../product/ProductSliderControl/index.ts | 1 + .../product/ProductTag/ProductTag.module.css | 32 + .../product/ProductTag/ProductTag.tsx | 36 + .../components/product/ProductTag/index.ts | 1 + .../ProductView/ProductView.module.css | 59 + .../product/ProductView/ProductView.tsx | 113 + .../components/product/ProductView/index.ts | 1 + .../product/Swatch/Swatch.module.css | 54 + .../site/components/product/Swatch/Swatch.tsx | 62 + .../site/components/product/Swatch/index.ts | 1 + .../site/components/product/helpers.ts | 32 + .../frontend/site/components/product/index.ts | 5 + services/frontend/site/components/search.tsx | 439 + .../components/ui/Button/Button.module.css | 57 + .../site/components/ui/Button/Button.tsx | 75 + .../site/components/ui/Button/index.ts | 2 + .../ui/Collapse/Collapse.module.css | 25 + .../site/components/ui/Collapse/Collapse.tsx | 46 + .../site/components/ui/Collapse/index.ts | 2 + .../components/ui/Container/Container.tsx | 27 + .../site/components/ui/Container/index.ts | 1 + .../ui/Dropdown/Dropdown.module.css | 32 + .../site/components/ui/Dropdown/Dropdown.tsx | 22 + .../site/components/ui/Grid/Grid.module.css | 135 + .../frontend/site/components/ui/Grid/Grid.tsx | 34 + .../frontend/site/components/ui/Grid/index.ts | 1 + .../site/components/ui/Hero/Hero.module.css | 30 + .../frontend/site/components/ui/Hero/Hero.tsx | 33 + .../frontend/site/components/ui/Hero/index.ts | 1 + .../site/components/ui/Input/Input.module.css | 7 + .../site/components/ui/Input/Input.tsx | 37 + .../site/components/ui/Input/index.ts | 1 + .../frontend/site/components/ui/Link/Link.tsx | 11 + .../frontend/site/components/ui/Link/index.ts | 1 + .../ui/LoadingDots/LoadingDots.module.css | 33 + .../components/ui/LoadingDots/LoadingDots.tsx | 13 + .../site/components/ui/LoadingDots/index.ts | 1 + .../frontend/site/components/ui/Logo/Logo.tsx | 9 + .../frontend/site/components/ui/Logo/index.ts | 1 + .../components/ui/Marquee/Marquee.module.css | 22 + .../site/components/ui/Marquee/Marquee.tsx | 39 + .../site/components/ui/Marquee/index.ts | 1 + .../site/components/ui/Modal/Modal.module.css | 17 + .../site/components/ui/Modal/Modal.tsx | 55 + .../site/components/ui/Modal/index.ts | 1 + .../ui/Quantity/Quantity.module.css | 27 + .../site/components/ui/Quantity/Quantity.tsx | 62 + .../site/components/ui/Quantity/index.ts | 2 + .../frontend/site/components/ui/README.md | 3 + .../components/ui/Rating/Rating.module.css | 0 .../site/components/ui/Rating/Rating.tsx | 25 + .../site/components/ui/Rating/index.ts | 2 + .../components/ui/Sidebar/Sidebar.module.css | 14 + .../site/components/ui/Sidebar/Sidebar.tsx | 57 + .../site/components/ui/Sidebar/index.ts | 1 + .../ui/Skeleton/Skeleton.module.css | 48 + .../site/components/ui/Skeleton/Skeleton.tsx | 57 + .../site/components/ui/Skeleton/index.ts | 1 + .../site/components/ui/Text/Text.module.css | 75 + .../frontend/site/components/ui/Text/Text.tsx | 70 + .../frontend/site/components/ui/Text/index.ts | 1 + .../frontend/site/components/ui/context.tsx | 216 + services/frontend/site/components/ui/index.ts | 17 + .../WishlistButton/WishlistButton.module.css | 33 + .../WishlistButton/WishlistButton.tsx | 84 + .../wishlist/WishlistButton/index.ts | 1 + .../WishlistCard/WishlistCard.module.css | 38 + .../wishlist/WishlistCard/WishlistCard.tsx | 108 + .../components/wishlist/WishlistCard/index.ts | 1 + .../site/components/wishlist/index.ts | 2 + services/frontend/site/config/seo_meta.json | 25 + services/frontend/site/config/user_data.json | 602 ++ services/frontend/site/global.d.ts | 2 + services/frontend/site/lib/api/commerce.ts | 3 + .../site/lib/click-outside/click-outside.tsx | 83 + .../site/lib/click-outside/has-parent.js | 5 + .../frontend/site/lib/click-outside/index.ts | 1 + .../site/lib/click-outside/is-in-dom.js | 3 + services/frontend/site/lib/colors.ts | 206 + services/frontend/site/lib/focus-trap.tsx | 67 + services/frontend/site/lib/get-slug.ts | 5 + .../site/lib/hooks/useAcceptCookies.ts | 24 + .../frontend/site/lib/hooks/useUserAvatar.ts | 26 + services/frontend/site/lib/range-map.ts | 7 + services/frontend/site/lib/search-props.tsx | 27 + services/frontend/site/lib/search.tsx | 52 + services/frontend/site/lib/to-pixels.ts | 13 + services/frontend/site/lib/usage-warns.ts | 26 + services/frontend/site/next-env.d.ts | 5 + services/frontend/site/next.config.js | 44 + services/frontend/site/package.json | 100 + services/frontend/site/pages/404.tsx | 35 + services/frontend/site/pages/[...pages].tsx | 87 + services/frontend/site/pages/_app.tsx | 88 + services/frontend/site/pages/_document.tsx | 17 + services/frontend/site/pages/api/cart.ts | 4 + .../site/pages/api/catalog/products.ts | 4 + services/frontend/site/pages/api/checkout.ts | 4 + .../site/pages/api/customer/address.ts | 4 + .../frontend/site/pages/api/customer/card.ts | 4 + .../frontend/site/pages/api/customer/index.ts | 4 + services/frontend/site/pages/api/login.ts | 4 + services/frontend/site/pages/api/logout.ts | 4 + services/frontend/site/pages/api/signup.ts | 4 + services/frontend/site/pages/api/wishlist.ts | 4 + services/frontend/site/pages/cart.tsx | 192 + services/frontend/site/pages/index.tsx | 78 + services/frontend/site/pages/orders.tsx | 42 + .../frontend/site/pages/product/[slug].tsx | 89 + services/frontend/site/pages/profile.tsx | 52 + services/frontend/site/pages/search.tsx | 9 + .../frontend/site/pages/search/[category].tsx | 16 + .../site/pages/search/designers/[name].tsx | 16 + .../search/designers/[name]/[category].tsx | 16 + services/frontend/site/pages/wishlist.tsx | 82 + services/frontend/site/postcss.config.js | 20 + .../site/public/assets/drop-shirt-0.png | Bin 0 -> 157663 bytes .../site/public/assets/drop-shirt-1.png | Bin 0 -> 260517 bytes .../site/public/assets/drop-shirt-2.png | Bin 0 -> 244812 bytes .../site/public/assets/drop-shirt.png | Bin 0 -> 157663 bytes .../public/assets/lightweight-jacket-0.png | Bin 0 -> 507021 bytes .../public/assets/lightweight-jacket-1.png | Bin 0 -> 476841 bytes .../public/assets/lightweight-jacket-2.png | Bin 0 -> 353198 bytes .../frontend/site/public/assets/t-shirt-0.png | Bin 0 -> 415845 bytes .../frontend/site/public/assets/t-shirt-1.png | Bin 0 -> 337401 bytes .../frontend/site/public/assets/t-shirt-2.png | Bin 0 -> 429830 bytes .../frontend/site/public/assets/t-shirt-3.png | Bin 0 -> 666930 bytes .../frontend/site/public/assets/t-shirt-4.png | Bin 0 -> 364452 bytes services/frontend/site/public/bg-products.svg | 7 + services/frontend/site/public/card.png | Bin 0 -> 6286 bytes services/frontend/site/public/cursor-left.png | Bin 0 -> 1162 bytes .../frontend/site/public/cursor-right.png | Bin 0 -> 1165 bytes services/frontend/site/public/favicon.ico | Bin 0 -> 535 bytes services/frontend/site/public/flag-en-us.svg | 1 + services/frontend/site/public/flag-es-ar.svg | 20 + services/frontend/site/public/flag-es-co.svg | 10 + services/frontend/site/public/flag-es.svg | 1 + .../frontend/site/public/icon-144x144.png | Bin 0 -> 4150 bytes .../frontend/site/public/icon-192x192.png | Bin 0 -> 6030 bytes .../frontend/site/public/icon-512x512.png | Bin 0 -> 10832 bytes services/frontend/site/public/icon.png | Bin 0 -> 1058 bytes .../site/public/product-img-placeholder.svg | 7 + .../frontend/site/public/site.webmanifest | 22 + .../frontend/site/public/slider-arrows.png | Bin 0 -> 1739 bytes services/frontend/site/public/vercel.svg | 9 + services/frontend/site/tailwind.config.js | 58 + services/frontend/site/tsconfig.json | 32 + services/frontend/turbo.json | 38 + services/frontend/yarn.lock | 4754 ++++++++ services/nginx/Dockerfile | 7 + services/nginx/default.conf | 15 + services/nginx/status.conf | 24 + 612 files changed, 40301 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/frontend.yml create mode 100644 .github/workflows/nginx.yml create mode 100644 services/frontend/.prettierignore create mode 100644 services/frontend/.prettierrc create mode 100644 services/frontend/Dockerfile create mode 100644 services/frontend/package.json create mode 100644 services/frontend/packages/commerce/.prettierignore create mode 100644 services/frontend/packages/commerce/.prettierrc create mode 100644 services/frontend/packages/commerce/README.md create mode 100755 services/frontend/packages/commerce/fixup.mjs create mode 100644 services/frontend/packages/commerce/new-provider.md create mode 100644 services/frontend/packages/commerce/package.json create mode 100644 services/frontend/packages/commerce/src/api/endpoints/cart.ts create mode 100644 services/frontend/packages/commerce/src/api/endpoints/catalog/products.ts create mode 100644 services/frontend/packages/commerce/src/api/endpoints/checkout.ts create mode 100644 services/frontend/packages/commerce/src/api/endpoints/customer/address.ts create mode 100644 services/frontend/packages/commerce/src/api/endpoints/customer/card.ts create mode 100644 services/frontend/packages/commerce/src/api/endpoints/customer/index.ts create mode 100644 services/frontend/packages/commerce/src/api/endpoints/login.ts create mode 100644 services/frontend/packages/commerce/src/api/endpoints/logout.ts create mode 100644 services/frontend/packages/commerce/src/api/endpoints/signup.ts create mode 100644 services/frontend/packages/commerce/src/api/endpoints/wishlist.ts create mode 100644 services/frontend/packages/commerce/src/api/index.ts create mode 100644 services/frontend/packages/commerce/src/api/operations.ts create mode 100644 services/frontend/packages/commerce/src/api/utils/errors.ts create mode 100644 services/frontend/packages/commerce/src/api/utils/is-allowed-method.ts create mode 100644 services/frontend/packages/commerce/src/api/utils/is-allowed-operation.ts create mode 100644 services/frontend/packages/commerce/src/api/utils/types.ts create mode 100644 services/frontend/packages/commerce/src/auth/use-login.tsx create mode 100644 services/frontend/packages/commerce/src/auth/use-logout.tsx create mode 100644 services/frontend/packages/commerce/src/auth/use-signup.tsx create mode 100644 services/frontend/packages/commerce/src/cart/use-add-item.tsx create mode 100644 services/frontend/packages/commerce/src/cart/use-cart.tsx create mode 100644 services/frontend/packages/commerce/src/cart/use-remove-item.tsx create mode 100644 services/frontend/packages/commerce/src/cart/use-update-item.tsx create mode 100644 services/frontend/packages/commerce/src/checkout/use-checkout.ts create mode 100644 services/frontend/packages/commerce/src/checkout/use-submit-checkout.tsx create mode 100644 services/frontend/packages/commerce/src/config.cjs create mode 100644 services/frontend/packages/commerce/src/customer/address/use-add-item.tsx create mode 100644 services/frontend/packages/commerce/src/customer/address/use-addresses.tsx create mode 100644 services/frontend/packages/commerce/src/customer/address/use-remove-item.tsx create mode 100644 services/frontend/packages/commerce/src/customer/address/use-update-item.tsx create mode 100644 services/frontend/packages/commerce/src/customer/card/use-add-item.tsx create mode 100644 services/frontend/packages/commerce/src/customer/card/use-cards.tsx create mode 100644 services/frontend/packages/commerce/src/customer/card/use-remove-item.tsx create mode 100644 services/frontend/packages/commerce/src/customer/card/use-update-item.tsx create mode 100644 services/frontend/packages/commerce/src/customer/use-customer.tsx create mode 100644 services/frontend/packages/commerce/src/index.tsx create mode 100644 services/frontend/packages/commerce/src/product/use-price.tsx create mode 100644 services/frontend/packages/commerce/src/product/use-search.tsx create mode 100644 services/frontend/packages/commerce/src/types/cart.ts create mode 100644 services/frontend/packages/commerce/src/types/checkout.ts create mode 100644 services/frontend/packages/commerce/src/types/common.ts create mode 100644 services/frontend/packages/commerce/src/types/customer/address.ts create mode 100644 services/frontend/packages/commerce/src/types/customer/card.ts create mode 100644 services/frontend/packages/commerce/src/types/customer/index.ts create mode 100644 services/frontend/packages/commerce/src/types/index.ts create mode 100644 services/frontend/packages/commerce/src/types/login.ts create mode 100644 services/frontend/packages/commerce/src/types/logout.ts create mode 100644 services/frontend/packages/commerce/src/types/page.ts create mode 100644 services/frontend/packages/commerce/src/types/product.ts create mode 100644 services/frontend/packages/commerce/src/types/signup.ts create mode 100644 services/frontend/packages/commerce/src/types/site.ts create mode 100644 services/frontend/packages/commerce/src/types/wishlist.ts create mode 100644 services/frontend/packages/commerce/src/utils/default-fetcher.ts create mode 100644 services/frontend/packages/commerce/src/utils/define-property.ts create mode 100644 services/frontend/packages/commerce/src/utils/errors.ts create mode 100644 services/frontend/packages/commerce/src/utils/types.ts create mode 100644 services/frontend/packages/commerce/src/utils/use-data.tsx create mode 100644 services/frontend/packages/commerce/src/utils/use-hook.ts create mode 100644 services/frontend/packages/commerce/src/wishlist/index.ts create mode 100644 services/frontend/packages/commerce/src/wishlist/use-add-item.tsx create mode 100644 services/frontend/packages/commerce/src/wishlist/use-remove-item.tsx create mode 100644 services/frontend/packages/commerce/src/wishlist/use-wishlist.tsx create mode 100644 services/frontend/packages/commerce/taskfile.js create mode 100644 services/frontend/packages/commerce/tsconfig.json create mode 100644 services/frontend/packages/local/.env.template create mode 100644 services/frontend/packages/local/.prettierignore create mode 100644 services/frontend/packages/local/.prettierrc create mode 100644 services/frontend/packages/local/README.md create mode 100644 services/frontend/packages/local/package.json create mode 100644 services/frontend/packages/local/src/api/endpoints/cart/index.ts create mode 100644 services/frontend/packages/local/src/api/endpoints/catalog/index.ts create mode 100644 services/frontend/packages/local/src/api/endpoints/catalog/products.ts create mode 100644 services/frontend/packages/local/src/api/endpoints/checkout/index.ts create mode 100644 services/frontend/packages/local/src/api/endpoints/customer/address.ts create mode 100644 services/frontend/packages/local/src/api/endpoints/customer/card.ts create mode 100644 services/frontend/packages/local/src/api/endpoints/customer/index.ts create mode 100644 services/frontend/packages/local/src/api/endpoints/login/index.ts create mode 100644 services/frontend/packages/local/src/api/endpoints/logout/index.ts create mode 100644 services/frontend/packages/local/src/api/endpoints/signup/index.ts create mode 100644 services/frontend/packages/local/src/api/endpoints/wishlist/index.tsx create mode 100644 services/frontend/packages/local/src/api/index.ts create mode 100644 services/frontend/packages/local/src/api/operations/get-all-pages.ts create mode 100644 services/frontend/packages/local/src/api/operations/get-all-product-paths.ts create mode 100644 services/frontend/packages/local/src/api/operations/get-all-products.ts create mode 100644 services/frontend/packages/local/src/api/operations/get-customer-wishlist.ts create mode 100644 services/frontend/packages/local/src/api/operations/get-page.ts create mode 100644 services/frontend/packages/local/src/api/operations/get-product.ts create mode 100644 services/frontend/packages/local/src/api/operations/get-site-info.ts create mode 100644 services/frontend/packages/local/src/api/operations/index.ts create mode 100644 services/frontend/packages/local/src/api/utils/fetch-local.ts create mode 100644 services/frontend/packages/local/src/api/utils/fetch.ts create mode 100644 services/frontend/packages/local/src/auth/index.ts create mode 100644 services/frontend/packages/local/src/auth/use-login.tsx create mode 100644 services/frontend/packages/local/src/auth/use-logout.tsx create mode 100644 services/frontend/packages/local/src/auth/use-signup.tsx create mode 100644 services/frontend/packages/local/src/cart/index.ts create mode 100644 services/frontend/packages/local/src/cart/use-add-item.tsx create mode 100644 services/frontend/packages/local/src/cart/use-cart.tsx create mode 100644 services/frontend/packages/local/src/cart/use-remove-item.tsx create mode 100644 services/frontend/packages/local/src/cart/use-update-item.tsx create mode 100644 services/frontend/packages/local/src/checkout/use-checkout.tsx create mode 100644 services/frontend/packages/local/src/commerce.config.json create mode 100644 services/frontend/packages/local/src/customer/address/use-add-item.tsx create mode 100644 services/frontend/packages/local/src/customer/card/use-add-item.tsx create mode 100644 services/frontend/packages/local/src/customer/index.ts create mode 100644 services/frontend/packages/local/src/customer/use-customer.tsx create mode 100644 services/frontend/packages/local/src/data.json create mode 100644 services/frontend/packages/local/src/fetcher.ts create mode 100644 services/frontend/packages/local/src/index.tsx create mode 100644 services/frontend/packages/local/src/next.config.cjs create mode 100644 services/frontend/packages/local/src/product/index.ts create mode 100644 services/frontend/packages/local/src/product/use-price.tsx create mode 100644 services/frontend/packages/local/src/product/use-search.tsx create mode 100644 services/frontend/packages/local/src/provider.ts create mode 100644 services/frontend/packages/local/src/wishlist/use-add-item.tsx create mode 100644 services/frontend/packages/local/src/wishlist/use-remove-item.tsx create mode 100644 services/frontend/packages/local/src/wishlist/use-wishlist.tsx create mode 100644 services/frontend/packages/local/taskfile.js create mode 100644 services/frontend/packages/local/tsconfig.json create mode 100644 services/frontend/packages/spree/.env.template create mode 100644 services/frontend/packages/spree/.prettierignore create mode 100644 services/frontend/packages/spree/.prettierrc create mode 100644 services/frontend/packages/spree/README-assets/screenshots.png create mode 100644 services/frontend/packages/spree/README.md create mode 100644 services/frontend/packages/spree/package.json create mode 100644 services/frontend/packages/spree/src/api/endpoints/cart/index.ts create mode 100644 services/frontend/packages/spree/src/api/endpoints/catalog/index.ts create mode 100644 services/frontend/packages/spree/src/api/endpoints/catalog/products.ts create mode 100644 services/frontend/packages/spree/src/api/endpoints/checkout/get-checkout.ts create mode 100644 services/frontend/packages/spree/src/api/endpoints/checkout/index.ts create mode 100644 services/frontend/packages/spree/src/api/endpoints/customer/address.ts create mode 100644 services/frontend/packages/spree/src/api/endpoints/customer/card.ts create mode 100644 services/frontend/packages/spree/src/api/endpoints/customer/index.ts create mode 100644 services/frontend/packages/spree/src/api/endpoints/login/index.ts create mode 100644 services/frontend/packages/spree/src/api/endpoints/logout/index.ts create mode 100644 services/frontend/packages/spree/src/api/endpoints/signup/index.ts create mode 100644 services/frontend/packages/spree/src/api/endpoints/wishlist/index.tsx create mode 100644 services/frontend/packages/spree/src/api/index.ts create mode 100644 services/frontend/packages/spree/src/api/operations/get-all-pages.ts create mode 100644 services/frontend/packages/spree/src/api/operations/get-all-product-paths.ts create mode 100644 services/frontend/packages/spree/src/api/operations/get-all-products.ts create mode 100644 services/frontend/packages/spree/src/api/operations/get-customer-wishlist.ts create mode 100644 services/frontend/packages/spree/src/api/operations/get-page.ts create mode 100644 services/frontend/packages/spree/src/api/operations/get-product.ts create mode 100644 services/frontend/packages/spree/src/api/operations/get-site-info.ts create mode 100644 services/frontend/packages/spree/src/api/operations/index.ts create mode 100644 services/frontend/packages/spree/src/api/utils/create-api-fetch.ts create mode 100644 services/frontend/packages/spree/src/api/utils/fetch.ts create mode 100644 services/frontend/packages/spree/src/auth/index.ts create mode 100644 services/frontend/packages/spree/src/auth/use-login.tsx create mode 100644 services/frontend/packages/spree/src/auth/use-logout.tsx create mode 100644 services/frontend/packages/spree/src/auth/use-signup.tsx create mode 100644 services/frontend/packages/spree/src/cart/index.ts create mode 100644 services/frontend/packages/spree/src/cart/use-add-item.tsx create mode 100644 services/frontend/packages/spree/src/cart/use-cart.tsx create mode 100644 services/frontend/packages/spree/src/cart/use-remove-item.tsx create mode 100644 services/frontend/packages/spree/src/cart/use-update-item.tsx create mode 100644 services/frontend/packages/spree/src/checkout/use-checkout.tsx create mode 100644 services/frontend/packages/spree/src/commerce.config.json create mode 100644 services/frontend/packages/spree/src/customer/address/use-add-item.tsx create mode 100644 services/frontend/packages/spree/src/customer/card/use-add-item.tsx create mode 100644 services/frontend/packages/spree/src/customer/index.ts create mode 100644 services/frontend/packages/spree/src/customer/use-customer.tsx create mode 100644 services/frontend/packages/spree/src/errors/AccessTokenError.ts create mode 100644 services/frontend/packages/spree/src/errors/MisconfigurationError.ts create mode 100644 services/frontend/packages/spree/src/errors/MissingConfigurationValueError.ts create mode 100644 services/frontend/packages/spree/src/errors/MissingLineItemVariantError.ts create mode 100644 services/frontend/packages/spree/src/errors/MissingOptionValueError.ts create mode 100644 services/frontend/packages/spree/src/errors/MissingPrimaryVariantError.ts create mode 100644 services/frontend/packages/spree/src/errors/MissingProductError.ts create mode 100644 services/frontend/packages/spree/src/errors/MissingSlugVariableError.ts create mode 100644 services/frontend/packages/spree/src/errors/MissingVariantError.ts create mode 100644 services/frontend/packages/spree/src/errors/RefreshTokenError.ts create mode 100644 services/frontend/packages/spree/src/errors/SpreeResponseContentError.ts create mode 100644 services/frontend/packages/spree/src/errors/SpreeSdkMethodFromEndpointPathError.ts create mode 100644 services/frontend/packages/spree/src/errors/TokensNotRejectedError.ts create mode 100644 services/frontend/packages/spree/src/errors/UserTokenResponseParseError.ts create mode 100644 services/frontend/packages/spree/src/fetcher.ts create mode 100644 services/frontend/packages/spree/src/index.tsx create mode 100644 services/frontend/packages/spree/src/isomorphic-config.ts create mode 100644 services/frontend/packages/spree/src/next.config.cjs create mode 100644 services/frontend/packages/spree/src/product/index.ts create mode 100644 services/frontend/packages/spree/src/product/use-price.tsx create mode 100644 services/frontend/packages/spree/src/product/use-search.tsx create mode 100644 services/frontend/packages/spree/src/provider.ts create mode 100644 services/frontend/packages/spree/src/types/index.ts create mode 100644 services/frontend/packages/spree/src/utils/convert-spree-error-to-graph-ql-error.ts create mode 100644 services/frontend/packages/spree/src/utils/create-customized-fetch-fetcher.ts create mode 100644 services/frontend/packages/spree/src/utils/create-empty-cart.ts create mode 100644 services/frontend/packages/spree/src/utils/create-get-absolute-image-url.ts create mode 100644 services/frontend/packages/spree/src/utils/expand-options.ts create mode 100644 services/frontend/packages/spree/src/utils/force-isomorphic-config-values.ts create mode 100644 services/frontend/packages/spree/src/utils/get-image-url.ts create mode 100644 services/frontend/packages/spree/src/utils/get-media-gallery.ts create mode 100644 services/frontend/packages/spree/src/utils/get-product-path.ts create mode 100644 services/frontend/packages/spree/src/utils/get-spree-sdk-method-from-endpoint-path.ts create mode 100644 services/frontend/packages/spree/src/utils/handle-token-errors.ts create mode 100644 services/frontend/packages/spree/src/utils/is-json-content-type.ts create mode 100644 services/frontend/packages/spree/src/utils/is-server.ts create mode 100644 services/frontend/packages/spree/src/utils/login.ts create mode 100644 services/frontend/packages/spree/src/utils/normalizations/normalize-cart.ts create mode 100644 services/frontend/packages/spree/src/utils/normalizations/normalize-page.ts create mode 100644 services/frontend/packages/spree/src/utils/normalizations/normalize-product.ts create mode 100644 services/frontend/packages/spree/src/utils/normalizations/normalize-user.ts create mode 100644 services/frontend/packages/spree/src/utils/normalizations/normalize-wishlist.ts create mode 100644 services/frontend/packages/spree/src/utils/pretty-print-spree-sdk-errors.ts create mode 100644 services/frontend/packages/spree/src/utils/require-config.ts create mode 100644 services/frontend/packages/spree/src/utils/sort-option-types.ts create mode 100644 services/frontend/packages/spree/src/utils/tokens/cart-token.ts create mode 100644 services/frontend/packages/spree/src/utils/tokens/ensure-fresh-user-access-token.ts create mode 100644 services/frontend/packages/spree/src/utils/tokens/ensure-itoken.ts create mode 100644 services/frontend/packages/spree/src/utils/tokens/is-logged-in.ts create mode 100644 services/frontend/packages/spree/src/utils/tokens/revoke-user-tokens.ts create mode 100644 services/frontend/packages/spree/src/utils/tokens/user-token-response.ts create mode 100644 services/frontend/packages/spree/src/utils/validations/validate-all-products-taxonomy-id.ts create mode 100644 services/frontend/packages/spree/src/utils/validations/validate-cookie-expire.ts create mode 100644 services/frontend/packages/spree/src/utils/validations/validate-images-option-filter.ts create mode 100644 services/frontend/packages/spree/src/utils/validations/validate-images-quality.ts create mode 100644 services/frontend/packages/spree/src/utils/validations/validate-images-size.ts create mode 100644 services/frontend/packages/spree/src/utils/validations/validate-placeholder-image-url.ts create mode 100644 services/frontend/packages/spree/src/utils/validations/validate-products-prerender-count.ts create mode 100644 services/frontend/packages/spree/src/wishlist/index.ts create mode 100644 services/frontend/packages/spree/src/wishlist/use-add-item.tsx create mode 100644 services/frontend/packages/spree/src/wishlist/use-remove-item.tsx create mode 100644 services/frontend/packages/spree/src/wishlist/use-wishlist.tsx create mode 100644 services/frontend/packages/spree/taskfile.js create mode 100644 services/frontend/packages/spree/tsconfig.json create mode 100644 services/frontend/packages/swell/.env.template create mode 100644 services/frontend/packages/swell/.prettierignore create mode 100644 services/frontend/packages/swell/.prettierrc create mode 100644 services/frontend/packages/swell/package.json create mode 100644 services/frontend/packages/swell/schema.d.ts create mode 100644 services/frontend/packages/swell/schema.graphql create mode 100644 services/frontend/packages/swell/src/api/cart/index.ts create mode 100644 services/frontend/packages/swell/src/api/catalog/index.ts create mode 100644 services/frontend/packages/swell/src/api/catalog/products.ts create mode 100644 services/frontend/packages/swell/src/api/customer.ts create mode 100644 services/frontend/packages/swell/src/api/customers/index.ts create mode 100644 services/frontend/packages/swell/src/api/customers/logout.ts create mode 100644 services/frontend/packages/swell/src/api/customers/signup.ts create mode 100644 services/frontend/packages/swell/src/api/endpoints/cart.ts create mode 100644 services/frontend/packages/swell/src/api/endpoints/catalog/products.ts create mode 100644 services/frontend/packages/swell/src/api/endpoints/checkout/index.ts create mode 100644 services/frontend/packages/swell/src/api/endpoints/customer/address.ts create mode 100644 services/frontend/packages/swell/src/api/endpoints/customer/card.ts create mode 100644 services/frontend/packages/swell/src/api/endpoints/customer/index.ts create mode 100644 services/frontend/packages/swell/src/api/endpoints/login.ts create mode 100644 services/frontend/packages/swell/src/api/endpoints/logout.ts create mode 100644 services/frontend/packages/swell/src/api/endpoints/signup.ts create mode 100644 services/frontend/packages/swell/src/api/endpoints/wishlist.ts create mode 100644 services/frontend/packages/swell/src/api/index.ts create mode 100644 services/frontend/packages/swell/src/api/operations/get-all-pages.ts create mode 100644 services/frontend/packages/swell/src/api/operations/get-all-product-paths.ts create mode 100644 services/frontend/packages/swell/src/api/operations/get-all-products.ts create mode 100644 services/frontend/packages/swell/src/api/operations/get-page.ts create mode 100644 services/frontend/packages/swell/src/api/operations/get-product.ts create mode 100644 services/frontend/packages/swell/src/api/operations/get-site-info.ts create mode 100644 services/frontend/packages/swell/src/api/operations/login.ts create mode 100644 services/frontend/packages/swell/src/api/utils/fetch-swell-api.ts create mode 100644 services/frontend/packages/swell/src/api/utils/fetch.ts create mode 100644 services/frontend/packages/swell/src/api/utils/is-allowed-method.ts create mode 100644 services/frontend/packages/swell/src/api/wishlist/index.tsx create mode 100644 services/frontend/packages/swell/src/auth/use-login.tsx create mode 100644 services/frontend/packages/swell/src/auth/use-logout.tsx create mode 100644 services/frontend/packages/swell/src/auth/use-signup.tsx create mode 100644 services/frontend/packages/swell/src/cart/index.ts create mode 100644 services/frontend/packages/swell/src/cart/use-add-item.tsx create mode 100644 services/frontend/packages/swell/src/cart/use-cart.tsx create mode 100644 services/frontend/packages/swell/src/cart/use-remove-item.tsx create mode 100644 services/frontend/packages/swell/src/cart/use-update-item.tsx create mode 100644 services/frontend/packages/swell/src/cart/utils/checkout-create.ts create mode 100644 services/frontend/packages/swell/src/cart/utils/checkout-to-cart.ts create mode 100644 services/frontend/packages/swell/src/cart/utils/index.ts create mode 100644 services/frontend/packages/swell/src/checkout/use-checkout.tsx create mode 100644 services/frontend/packages/swell/src/commerce.config.json create mode 100644 services/frontend/packages/swell/src/const.ts create mode 100644 services/frontend/packages/swell/src/customer/address/use-add-item.tsx create mode 100644 services/frontend/packages/swell/src/customer/card/use-add-item.tsx create mode 100644 services/frontend/packages/swell/src/customer/index.ts create mode 100644 services/frontend/packages/swell/src/customer/use-customer.tsx create mode 100644 services/frontend/packages/swell/src/fetcher.ts create mode 100644 services/frontend/packages/swell/src/index.tsx create mode 100644 services/frontend/packages/swell/src/next.config.cjs create mode 100644 services/frontend/packages/swell/src/product/index.ts create mode 100644 services/frontend/packages/swell/src/product/use-price.tsx create mode 100644 services/frontend/packages/swell/src/product/use-search.tsx create mode 100644 services/frontend/packages/swell/src/provider.ts create mode 100644 services/frontend/packages/swell/src/swell.ts create mode 100644 services/frontend/packages/swell/src/types.ts create mode 100644 services/frontend/packages/swell/src/types/cart.ts create mode 100644 services/frontend/packages/swell/src/types/checkout.ts create mode 100644 services/frontend/packages/swell/src/types/common.ts create mode 100644 services/frontend/packages/swell/src/types/customer.ts create mode 100644 services/frontend/packages/swell/src/types/index.ts create mode 100644 services/frontend/packages/swell/src/types/login.ts create mode 100644 services/frontend/packages/swell/src/types/logout.ts create mode 100644 services/frontend/packages/swell/src/types/page.ts create mode 100644 services/frontend/packages/swell/src/types/product.ts create mode 100644 services/frontend/packages/swell/src/types/signup.ts create mode 100644 services/frontend/packages/swell/src/types/site.ts create mode 100644 services/frontend/packages/swell/src/types/wishlist.ts create mode 100644 services/frontend/packages/swell/src/utils/customer-token.ts create mode 100644 services/frontend/packages/swell/src/utils/get-categories.ts create mode 100644 services/frontend/packages/swell/src/utils/get-checkout-id.ts create mode 100644 services/frontend/packages/swell/src/utils/get-search-variables.ts create mode 100644 services/frontend/packages/swell/src/utils/get-sort-variables.ts create mode 100644 services/frontend/packages/swell/src/utils/get-vendors.ts create mode 100644 services/frontend/packages/swell/src/utils/handle-fetch-response.ts create mode 100644 services/frontend/packages/swell/src/utils/handle-login.ts create mode 100644 services/frontend/packages/swell/src/utils/index.ts create mode 100644 services/frontend/packages/swell/src/utils/normalize.ts create mode 100644 services/frontend/packages/swell/src/utils/storage.ts create mode 100644 services/frontend/packages/swell/src/wishlist/use-add-item.tsx create mode 100644 services/frontend/packages/swell/src/wishlist/use-remove-item.tsx create mode 100644 services/frontend/packages/swell/src/wishlist/use-wishlist.tsx create mode 100644 services/frontend/packages/swell/taskfile.js create mode 100644 services/frontend/packages/swell/tsconfig.json create mode 100644 services/frontend/packages/taskr-swc/.prettierrc create mode 100644 services/frontend/packages/taskr-swc/package.json create mode 100644 services/frontend/packages/taskr-swc/taskfile-swc.js create mode 100644 services/frontend/site/.eslintrc create mode 100644 services/frontend/site/.gitignore create mode 100644 services/frontend/site/.prettierignore create mode 100644 services/frontend/site/.prettierrc create mode 100644 services/frontend/site/assets/base.css create mode 100644 services/frontend/site/assets/chrome-bug.css create mode 100644 services/frontend/site/assets/components.css create mode 100644 services/frontend/site/assets/main.css create mode 100644 services/frontend/site/commerce-config.js create mode 100644 services/frontend/site/commerce.config.json create mode 100644 services/frontend/site/components/auth/ForgotPassword.tsx create mode 100644 services/frontend/site/components/auth/LoginView.tsx create mode 100644 services/frontend/site/components/auth/SignUpView.tsx create mode 100644 services/frontend/site/components/auth/index.ts create mode 100644 services/frontend/site/components/cart/CartItem/CartItem.module.css create mode 100644 services/frontend/site/components/cart/CartItem/CartItem.tsx create mode 100644 services/frontend/site/components/cart/CartItem/index.ts create mode 100644 services/frontend/site/components/cart/CartSidebarView/CartSidebarView.module.css create mode 100644 services/frontend/site/components/cart/CartSidebarView/CartSidebarView.tsx create mode 100644 services/frontend/site/components/cart/CartSidebarView/index.ts create mode 100644 services/frontend/site/components/cart/index.ts create mode 100644 services/frontend/site/components/checkout/CheckoutSidebarView/CheckoutSidebarView.module.css create mode 100644 services/frontend/site/components/checkout/CheckoutSidebarView/CheckoutSidebarView.tsx create mode 100644 services/frontend/site/components/checkout/CheckoutSidebarView/index.ts create mode 100644 services/frontend/site/components/checkout/OrderConfirmView/OrderConfirmView.module.css create mode 100644 services/frontend/site/components/checkout/OrderConfirmView/OrderConfirmView.tsx create mode 100644 services/frontend/site/components/checkout/OrderConfirmView/index.ts create mode 100644 services/frontend/site/components/checkout/PaymentMethodView/PaymentMethodView.module.css create mode 100644 services/frontend/site/components/checkout/PaymentMethodView/PaymentMethodView.tsx create mode 100644 services/frontend/site/components/checkout/PaymentMethodView/index.ts create mode 100644 services/frontend/site/components/checkout/PaymentWidget/PaymentWidget.module.css create mode 100644 services/frontend/site/components/checkout/PaymentWidget/PaymentWidget.tsx create mode 100644 services/frontend/site/components/checkout/PaymentWidget/index.ts create mode 100644 services/frontend/site/components/checkout/ShippingView/ShippingView.module.css create mode 100644 services/frontend/site/components/checkout/ShippingView/ShippingView.tsx create mode 100644 services/frontend/site/components/checkout/ShippingView/index.ts create mode 100644 services/frontend/site/components/checkout/ShippingWidget/ShippingWidget.module.css create mode 100644 services/frontend/site/components/checkout/ShippingWidget/ShippingWidget.tsx create mode 100644 services/frontend/site/components/checkout/ShippingWidget/index.ts create mode 100644 services/frontend/site/components/checkout/context.tsx create mode 100644 services/frontend/site/components/common/Ad/Ad.tsx create mode 100644 services/frontend/site/components/common/Ad/index.ts create mode 100644 services/frontend/site/components/common/Avatar/Avatar.tsx create mode 100644 services/frontend/site/components/common/Avatar/index.ts create mode 100644 services/frontend/site/components/common/Discount/Discount.tsx create mode 100644 services/frontend/site/components/common/Discount/index.ts create mode 100644 services/frontend/site/components/common/FeatureBar/FeatureBar.module.css create mode 100644 services/frontend/site/components/common/FeatureBar/FeatureBar.tsx create mode 100644 services/frontend/site/components/common/FeatureBar/index.ts create mode 100644 services/frontend/site/components/common/Footer/Footer.module.css create mode 100644 services/frontend/site/components/common/Footer/Footer.tsx create mode 100644 services/frontend/site/components/common/Footer/index.ts create mode 100644 services/frontend/site/components/common/Head/Head.tsx create mode 100644 services/frontend/site/components/common/Head/index.ts create mode 100644 services/frontend/site/components/common/HomeAllProductsGrid/HomeAllProductsGrid.module.css create mode 100644 services/frontend/site/components/common/HomeAllProductsGrid/HomeAllProductsGrid.tsx create mode 100644 services/frontend/site/components/common/HomeAllProductsGrid/index.ts create mode 100644 services/frontend/site/components/common/I18nWidget/I18nWidget.module.css create mode 100644 services/frontend/site/components/common/I18nWidget/I18nWidget.tsx create mode 100644 services/frontend/site/components/common/I18nWidget/index.ts create mode 100644 services/frontend/site/components/common/Layout/Layout.module.css create mode 100644 services/frontend/site/components/common/Layout/Layout.tsx create mode 100644 services/frontend/site/components/common/Layout/index.ts create mode 100644 services/frontend/site/components/common/Navbar/Navbar.module.css create mode 100644 services/frontend/site/components/common/Navbar/Navbar.tsx create mode 100644 services/frontend/site/components/common/Navbar/NavbarRoot.tsx create mode 100644 services/frontend/site/components/common/Navbar/index.ts create mode 100644 services/frontend/site/components/common/SEO/SEO.tsx create mode 100644 services/frontend/site/components/common/SEO/index.ts create mode 100644 services/frontend/site/components/common/Searchbar/Searchbar.module.css create mode 100644 services/frontend/site/components/common/Searchbar/Searchbar.tsx create mode 100644 services/frontend/site/components/common/Searchbar/index.ts create mode 100644 services/frontend/site/components/common/SidebarLayout/SidebarLayout.module.css create mode 100644 services/frontend/site/components/common/SidebarLayout/SidebarLayout.tsx create mode 100644 services/frontend/site/components/common/SidebarLayout/index.ts create mode 100644 services/frontend/site/components/common/UserNav/CustomerMenuContent/CustomerMenuContent.module.css create mode 100644 services/frontend/site/components/common/UserNav/CustomerMenuContent/CustomerMenuContent.tsx create mode 100644 services/frontend/site/components/common/UserNav/CustomerMenuContent/index.ts create mode 100644 services/frontend/site/components/common/UserNav/MenuSidebarView/MenuSidebarView.module.css create mode 100644 services/frontend/site/components/common/UserNav/MenuSidebarView/MenuSidebarView.tsx create mode 100644 services/frontend/site/components/common/UserNav/MenuSidebarView/index.ts create mode 100644 services/frontend/site/components/common/UserNav/UserNav.module.css create mode 100644 services/frontend/site/components/common/UserNav/UserNav.tsx create mode 100644 services/frontend/site/components/common/UserNav/index.ts create mode 100644 services/frontend/site/components/common/index.ts create mode 100644 services/frontend/site/components/icons/ArrowLeft.tsx create mode 100644 services/frontend/site/components/icons/ArrowRight.tsx create mode 100644 services/frontend/site/components/icons/Bag.tsx create mode 100644 services/frontend/site/components/icons/Check.tsx create mode 100644 services/frontend/site/components/icons/ChevronDown.tsx create mode 100644 services/frontend/site/components/icons/ChevronLeft.tsx create mode 100644 services/frontend/site/components/icons/ChevronRight.tsx create mode 100644 services/frontend/site/components/icons/ChevronUp.tsx create mode 100644 services/frontend/site/components/icons/CreditCard.tsx create mode 100644 services/frontend/site/components/icons/Cross.tsx create mode 100644 services/frontend/site/components/icons/DoubleChevron.tsx create mode 100644 services/frontend/site/components/icons/Github.tsx create mode 100644 services/frontend/site/components/icons/Heart.tsx create mode 100644 services/frontend/site/components/icons/Info.tsx create mode 100644 services/frontend/site/components/icons/MapPin.tsx create mode 100644 services/frontend/site/components/icons/Menu.tsx create mode 100644 services/frontend/site/components/icons/Minus.tsx create mode 100644 services/frontend/site/components/icons/Moon.tsx create mode 100644 services/frontend/site/components/icons/Plus.tsx create mode 100644 services/frontend/site/components/icons/Star.tsx create mode 100644 services/frontend/site/components/icons/Sun.tsx create mode 100644 services/frontend/site/components/icons/Trash.tsx create mode 100644 services/frontend/site/components/icons/Vercel.tsx create mode 100644 services/frontend/site/components/icons/index.ts create mode 100644 services/frontend/site/components/product/ProductCard/ProductCard-v2.tsx create mode 100644 services/frontend/site/components/product/ProductCard/ProductCard.module.css create mode 100644 services/frontend/site/components/product/ProductCard/ProductCard.tsx create mode 100644 services/frontend/site/components/product/ProductCard/index.ts create mode 100644 services/frontend/site/components/product/ProductOptions/ProductOptions.tsx create mode 100644 services/frontend/site/components/product/ProductOptions/index.ts create mode 100644 services/frontend/site/components/product/ProductSidebar/ProductSidebar.module.css create mode 100644 services/frontend/site/components/product/ProductSidebar/ProductSidebar.tsx create mode 100644 services/frontend/site/components/product/ProductSidebar/index.ts create mode 100644 services/frontend/site/components/product/ProductSlider/ProductSlider.module.css create mode 100644 services/frontend/site/components/product/ProductSlider/ProductSlider.tsx create mode 100644 services/frontend/site/components/product/ProductSlider/index.ts create mode 100644 services/frontend/site/components/product/ProductSliderControl/ProductSliderControl.module.css create mode 100644 services/frontend/site/components/product/ProductSliderControl/ProductSliderControl.tsx create mode 100644 services/frontend/site/components/product/ProductSliderControl/index.ts create mode 100644 services/frontend/site/components/product/ProductTag/ProductTag.module.css create mode 100644 services/frontend/site/components/product/ProductTag/ProductTag.tsx create mode 100644 services/frontend/site/components/product/ProductTag/index.ts create mode 100644 services/frontend/site/components/product/ProductView/ProductView.module.css create mode 100644 services/frontend/site/components/product/ProductView/ProductView.tsx create mode 100644 services/frontend/site/components/product/ProductView/index.ts create mode 100644 services/frontend/site/components/product/Swatch/Swatch.module.css create mode 100644 services/frontend/site/components/product/Swatch/Swatch.tsx create mode 100644 services/frontend/site/components/product/Swatch/index.ts create mode 100644 services/frontend/site/components/product/helpers.ts create mode 100644 services/frontend/site/components/product/index.ts create mode 100644 services/frontend/site/components/search.tsx create mode 100644 services/frontend/site/components/ui/Button/Button.module.css create mode 100644 services/frontend/site/components/ui/Button/Button.tsx create mode 100644 services/frontend/site/components/ui/Button/index.ts create mode 100644 services/frontend/site/components/ui/Collapse/Collapse.module.css create mode 100644 services/frontend/site/components/ui/Collapse/Collapse.tsx create mode 100644 services/frontend/site/components/ui/Collapse/index.ts create mode 100644 services/frontend/site/components/ui/Container/Container.tsx create mode 100644 services/frontend/site/components/ui/Container/index.ts create mode 100644 services/frontend/site/components/ui/Dropdown/Dropdown.module.css create mode 100644 services/frontend/site/components/ui/Dropdown/Dropdown.tsx create mode 100644 services/frontend/site/components/ui/Grid/Grid.module.css create mode 100644 services/frontend/site/components/ui/Grid/Grid.tsx create mode 100644 services/frontend/site/components/ui/Grid/index.ts create mode 100644 services/frontend/site/components/ui/Hero/Hero.module.css create mode 100644 services/frontend/site/components/ui/Hero/Hero.tsx create mode 100644 services/frontend/site/components/ui/Hero/index.ts create mode 100644 services/frontend/site/components/ui/Input/Input.module.css create mode 100644 services/frontend/site/components/ui/Input/Input.tsx create mode 100644 services/frontend/site/components/ui/Input/index.ts create mode 100644 services/frontend/site/components/ui/Link/Link.tsx create mode 100644 services/frontend/site/components/ui/Link/index.ts create mode 100644 services/frontend/site/components/ui/LoadingDots/LoadingDots.module.css create mode 100644 services/frontend/site/components/ui/LoadingDots/LoadingDots.tsx create mode 100644 services/frontend/site/components/ui/LoadingDots/index.ts create mode 100644 services/frontend/site/components/ui/Logo/Logo.tsx create mode 100644 services/frontend/site/components/ui/Logo/index.ts create mode 100644 services/frontend/site/components/ui/Marquee/Marquee.module.css create mode 100644 services/frontend/site/components/ui/Marquee/Marquee.tsx create mode 100644 services/frontend/site/components/ui/Marquee/index.ts create mode 100644 services/frontend/site/components/ui/Modal/Modal.module.css create mode 100644 services/frontend/site/components/ui/Modal/Modal.tsx create mode 100644 services/frontend/site/components/ui/Modal/index.ts create mode 100644 services/frontend/site/components/ui/Quantity/Quantity.module.css create mode 100644 services/frontend/site/components/ui/Quantity/Quantity.tsx create mode 100644 services/frontend/site/components/ui/Quantity/index.ts create mode 100644 services/frontend/site/components/ui/README.md create mode 100644 services/frontend/site/components/ui/Rating/Rating.module.css create mode 100644 services/frontend/site/components/ui/Rating/Rating.tsx create mode 100644 services/frontend/site/components/ui/Rating/index.ts create mode 100644 services/frontend/site/components/ui/Sidebar/Sidebar.module.css create mode 100644 services/frontend/site/components/ui/Sidebar/Sidebar.tsx create mode 100644 services/frontend/site/components/ui/Sidebar/index.ts create mode 100644 services/frontend/site/components/ui/Skeleton/Skeleton.module.css create mode 100644 services/frontend/site/components/ui/Skeleton/Skeleton.tsx create mode 100644 services/frontend/site/components/ui/Skeleton/index.ts create mode 100644 services/frontend/site/components/ui/Text/Text.module.css create mode 100644 services/frontend/site/components/ui/Text/Text.tsx create mode 100644 services/frontend/site/components/ui/Text/index.ts create mode 100644 services/frontend/site/components/ui/context.tsx create mode 100644 services/frontend/site/components/ui/index.ts create mode 100644 services/frontend/site/components/wishlist/WishlistButton/WishlistButton.module.css create mode 100644 services/frontend/site/components/wishlist/WishlistButton/WishlistButton.tsx create mode 100644 services/frontend/site/components/wishlist/WishlistButton/index.ts create mode 100644 services/frontend/site/components/wishlist/WishlistCard/WishlistCard.module.css create mode 100644 services/frontend/site/components/wishlist/WishlistCard/WishlistCard.tsx create mode 100644 services/frontend/site/components/wishlist/WishlistCard/index.ts create mode 100644 services/frontend/site/components/wishlist/index.ts create mode 100644 services/frontend/site/config/seo_meta.json create mode 100644 services/frontend/site/config/user_data.json create mode 100644 services/frontend/site/global.d.ts create mode 100644 services/frontend/site/lib/api/commerce.ts create mode 100644 services/frontend/site/lib/click-outside/click-outside.tsx create mode 100644 services/frontend/site/lib/click-outside/has-parent.js create mode 100644 services/frontend/site/lib/click-outside/index.ts create mode 100644 services/frontend/site/lib/click-outside/is-in-dom.js create mode 100644 services/frontend/site/lib/colors.ts create mode 100644 services/frontend/site/lib/focus-trap.tsx create mode 100644 services/frontend/site/lib/get-slug.ts create mode 100644 services/frontend/site/lib/hooks/useAcceptCookies.ts create mode 100644 services/frontend/site/lib/hooks/useUserAvatar.ts create mode 100644 services/frontend/site/lib/range-map.ts create mode 100644 services/frontend/site/lib/search-props.tsx create mode 100644 services/frontend/site/lib/search.tsx create mode 100644 services/frontend/site/lib/to-pixels.ts create mode 100644 services/frontend/site/lib/usage-warns.ts create mode 100644 services/frontend/site/next-env.d.ts create mode 100644 services/frontend/site/next.config.js create mode 100644 services/frontend/site/package.json create mode 100644 services/frontend/site/pages/404.tsx create mode 100644 services/frontend/site/pages/[...pages].tsx create mode 100644 services/frontend/site/pages/_app.tsx create mode 100644 services/frontend/site/pages/_document.tsx create mode 100644 services/frontend/site/pages/api/cart.ts create mode 100644 services/frontend/site/pages/api/catalog/products.ts create mode 100644 services/frontend/site/pages/api/checkout.ts create mode 100644 services/frontend/site/pages/api/customer/address.ts create mode 100644 services/frontend/site/pages/api/customer/card.ts create mode 100644 services/frontend/site/pages/api/customer/index.ts create mode 100644 services/frontend/site/pages/api/login.ts create mode 100644 services/frontend/site/pages/api/logout.ts create mode 100644 services/frontend/site/pages/api/signup.ts create mode 100644 services/frontend/site/pages/api/wishlist.ts create mode 100644 services/frontend/site/pages/cart.tsx create mode 100644 services/frontend/site/pages/index.tsx create mode 100644 services/frontend/site/pages/orders.tsx create mode 100644 services/frontend/site/pages/product/[slug].tsx create mode 100644 services/frontend/site/pages/profile.tsx create mode 100644 services/frontend/site/pages/search.tsx create mode 100644 services/frontend/site/pages/search/[category].tsx create mode 100644 services/frontend/site/pages/search/designers/[name].tsx create mode 100644 services/frontend/site/pages/search/designers/[name]/[category].tsx create mode 100644 services/frontend/site/pages/wishlist.tsx create mode 100644 services/frontend/site/postcss.config.js create mode 100644 services/frontend/site/public/assets/drop-shirt-0.png create mode 100644 services/frontend/site/public/assets/drop-shirt-1.png create mode 100644 services/frontend/site/public/assets/drop-shirt-2.png create mode 100644 services/frontend/site/public/assets/drop-shirt.png create mode 100644 services/frontend/site/public/assets/lightweight-jacket-0.png create mode 100644 services/frontend/site/public/assets/lightweight-jacket-1.png create mode 100644 services/frontend/site/public/assets/lightweight-jacket-2.png create mode 100644 services/frontend/site/public/assets/t-shirt-0.png create mode 100644 services/frontend/site/public/assets/t-shirt-1.png create mode 100644 services/frontend/site/public/assets/t-shirt-2.png create mode 100644 services/frontend/site/public/assets/t-shirt-3.png create mode 100644 services/frontend/site/public/assets/t-shirt-4.png create mode 100644 services/frontend/site/public/bg-products.svg create mode 100644 services/frontend/site/public/card.png create mode 100644 services/frontend/site/public/cursor-left.png create mode 100644 services/frontend/site/public/cursor-right.png create mode 100644 services/frontend/site/public/favicon.ico create mode 100644 services/frontend/site/public/flag-en-us.svg create mode 100644 services/frontend/site/public/flag-es-ar.svg create mode 100644 services/frontend/site/public/flag-es-co.svg create mode 100644 services/frontend/site/public/flag-es.svg create mode 100644 services/frontend/site/public/icon-144x144.png create mode 100644 services/frontend/site/public/icon-192x192.png create mode 100644 services/frontend/site/public/icon-512x512.png create mode 100644 services/frontend/site/public/icon.png create mode 100644 services/frontend/site/public/product-img-placeholder.svg create mode 100644 services/frontend/site/public/site.webmanifest create mode 100644 services/frontend/site/public/slider-arrows.png create mode 100644 services/frontend/site/public/vercel.svg create mode 100644 services/frontend/site/tailwind.config.js create mode 100644 services/frontend/site/tsconfig.json create mode 100644 services/frontend/turbo.json create mode 100644 services/frontend/yarn.lock create mode 100644 services/nginx/Dockerfile create mode 100644 services/nginx/default.conf create mode 100644 services/nginx/status.conf diff --git a/.env.template b/.env.template index 76808e88..c75e0354 100644 --- a/.env.template +++ b/.env.template @@ -11,4 +11,39 @@ ATTACK_HOST=nginx ATTACK_PORT=80 ATTACK_SSH_INTERVAL=0 ATTACK_GOBUSTER_INTERVAL=0 -ATTACK_HYDRA_INTERVAL=0 \ No newline at end of file +ATTACK_HYDRA_INTERVAL=0 + + +COMMERCE_PROVIDER=@vercel/commerce-spree +# NEXT_PUBLIC_* are exposed to the web browser and the server # +NEXT_PUBLIC_SPREE_API_HOST=http://web:4000 +NEXT_PUBLIC_SPREE_CLIENT_HOST=http://localhost:4000 +NEXT_PUBLIC_SPREE_DEFAULT_LOCALE=en-us +NEXT_PUBLIC_SPREE_CART_COOKIE_NAME=spree_cart_token +# cookie expire in days # +NEXT_PUBLIC_SPREE_CART_COOKIE_EXPIRE=7 +NEXT_PUBLIC_SPREE_USER_COOKIE_NAME=spree_user_token +NEXT_PUBLIC_SPREE_USER_COOKIE_EXPIRE=7 +NEXT_PUBLIC_SPREE_IMAGE_HOST=http://localhost:4000 +NEXT_PUBLIC_SPREE_ALLOWED_IMAGE_DOMAIN=localhost +NEXT_PUBLIC_SPREE_CATEGORIES_TAXONOMY_PERMALINK=/t/categories +NEXT_PUBLIC_SPREE_BRANDS_TAXONOMY_PERMALINK=/t/brands +NEXT_PUBLIC_SPREE_ALL_PRODUCTS_TAXONOMY_ID=false +NEXT_PUBLIC_SPREE_SHOW_SINGLE_VARIANT_OPTIONS=false +NEXT_PUBLIC_SPREE_LAST_UPDATED_PRODUCTS_PRERENDER_COUNT=10 +NEXT_PUBLIC_SPREE_PRODUCT_PLACEHOLDER_IMAGE_URL=/product-img-placeholder.svg +NEXT_PUBLIC_SPREE_LINE_ITEM_PLACEHOLDER_IMAGE_URL=/product-img-placeholder.svg +NEXT_PUBLIC_SPREE_IMAGES_OPTION_FILTER=false +NEXT_PUBLIC_SPREE_IMAGES_SIZE=1000x1000 +NEXT_PUBLIC_SPREE_IMAGES_QUALITY=100 +NEXT_PUBLIC_SPREE_LOGIN_AFTER_SIGNUP=true +NEXT_PUBLIC_ADS_PORT=7676 +NEXT_PUBLIC_DISCOUNTS_PORT=2814 +NEXT_PUBLIC_ADS_ROUTE="http://localhost" +NEXT_PUBLIC_DISCOUNTS_ROUTE="http://localhost" +NEXT_PUBLIC_DD_APPLICATION_ID="" +NEXT_PUBLIC_DD_CLIENT_TOKEN="" +NEXT_PUBLIC_DD_SITE="datadoghq.com" +NEXT_PUBLIC_DD_SERVICE="frontend" +NEXT_PUBLIC_DD_VERSION="1.0.0" +NEXT_PUBLIC_DD_ENV="development" \ No newline at end of file diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml new file mode 100644 index 00000000..b0430e62 --- /dev/null +++ b/.github/workflows/frontend.yml @@ -0,0 +1,50 @@ +name: Frontend + +on: + push: + branches: [ main ] + paths: + - services/frontend/** + workflow_dispatch: + branches: [ main ] + +defaults: + run: + working-directory: frontend + +jobs: + + build: + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-1 + + - name: Login to ECR + id: login-ecr + uses: docker/login-action@v1 + with: + registry: public.ecr.aws + username: ${{ secrets.AWS_ACCESS_KEY_ID }} + password: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + + - name: Build and push + uses: docker/build-push-action@v2 + with: + context: ./services/frontend + platforms: linux/amd64 + push: true + tags: ${{ secrets.PUBLIC_ECR_REGISTRY }}/storedog/frontend:latest + diff --git a/.github/workflows/nginx.yml b/.github/workflows/nginx.yml new file mode 100644 index 00000000..97a15c17 --- /dev/null +++ b/.github/workflows/nginx.yml @@ -0,0 +1,50 @@ +name: Nginx + +on: + push: + branches: [ main ] + paths: + - services/nginx/** + workflow_dispatch: + branches: [ main ] + +defaults: + run: + working-directory: nginx + +jobs: + + build: + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-1 + + - name: Login to ECR + id: login-ecr + uses: docker/login-action@v1 + with: + registry: public.ecr.aws + username: ${{ secrets.AWS_ACCESS_KEY_ID }} + password: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + + - name: Build and push + uses: docker/build-push-action@v2 + with: + context: ./services/nginx + platforms: linux/amd64 + push: true + tags: ${{ secrets.PUBLIC_ECR_REGISTRY }}/storedog/nginx:latest + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1f127af2..018fdc55 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,6 +42,8 @@ jobs: ${{ secrets.PUBLIC_ECR_REGISTRY }}/storedog/ads-java ${{ secrets.PUBLIC_ECR_REGISTRY }}/storedog/attackbox ${{ secrets.PUBLIC_ECR_REGISTRY }}/storedog/auth + ${{ secrets.PUBLIC_ECR_REGISTRY }}/storedog/nginx + ${{ secrets.PUBLIC_ECR_REGISTRY }}/storedog/frontend ) for i in "${IMAGES[@]}" diff --git a/.gitignore b/.gitignore index 71f0d9e7..923a100b 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,10 @@ yarn-debug.log* /yarn-error.log /app/assets/builds/* !/app/assets/builds/.keep + +# local env files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local diff --git a/README.md b/README.md index ef18974a..c5270cbb 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,28 @@ -# Storedog Backend +# Storedog + +This a dockerized [Spree Commerce](https://spreecommerce.org) application consumed by a NextJS frontend. -This a dockerized [Spree Commerce](https://spreecommerce.org) application consumed by [Storedog - Frontend](https://github.com/DataDog/storedog-frontend). ## Local development -**1.** Before starting the containers, you will need to define the required env vars. Run the following command to copy the env var template to the `.env` file: +**1.** Before starting the containers, you will need to define the required env vars. Run the following command to copy the env var template: -`cp .env.template .env && cp .env.template ./deploy/docker-compose/.env` +`cp .env.template .env && cp .env.template ./deploy/docker-compose/.env && cp .env.template ./services/frontend/site/.env.local` -Then, open the `.env` file and enter the values for the variables. The default values should all work except for the empty `DD_API_KEY`, which is required to run the DD agent. +**2.** +Open the `.env` file under the project root and enter the values for the variables. The default values should all work except for the empty `DD_API_KEY`, which is required to run the DD agent. -**2a.** To start the backend containers using the local build context, run: -`docker-compose up` +**3.** +Open the `./services/frontend/site/.env.local` file and enter the values for the variables. The default values should all work except for the empty `NEXT_PUBLIC_DD_APPLICATION_KEY` and `NEXT_PUBLIC_CLIENT_TOKEN`, which are required to enable RUM. + +**4.** Start the app: + +## Image publication +Images are stored in our public ECR repo `public.ecr.aws/x2b9z2t7`. On PR merges, only the affected services will be pushed to the ECR repo, using the `latest` tag. For example, if you only made changes to the `backend` service, then only the `backend` Github workflow will trigger and publish `public.ecr.aws/x2b9z2t7/storedog/backend:latest`. -**2b.** To start the backend containers using the published images in ECR, run: -`docker-compose -f ./deploy/docker-compose/docker-compose.yml -p storedog-backend up` +Separately, we tag and publish *all* images when a new release is created with the corresponding release tag e.g. `public.ecr.aws/x2b9z2t7/storedog/backend:1.0.1`. New releases are made on an ad-hoc basis, depending on the recent features that are added. -To build the frontend, please see the README in the [Storedog - Frontend](https://github.com/DataDog/storedog-frontend) repo. +# Backend +`docker-compose up` ## Database rebuild The current database is based off sample data provided by the Spree starter kit. To create a new `.sql` dump file, run the following command while the application is running. @@ -58,7 +65,101 @@ Username: spree@example.com Password: spree123 ``` -## Image publication -Images are stored in our public ECR repo `public.ecr.aws/x2b9z2t7`. On PR merges, only the affected services will be pushed to the ECR repo, using the `latest` tag. For example, if you only made changes to the `backend` service, then only the `backend` Github workflow will trigger and publish `public.ecr.aws/x2b9z2t7/storedog/backend:latest`. -Separately, we tag and publish *all* images when a new release is created with the corresponding release tag e.g. `public.ecr.aws/x2b9z2t7/storedog/backend:1.0.1`. New releases are made on an ad-hoc basis, depending on the recent features that are added. \ No newline at end of file +# Frontend + +## Considerations + +- `packages/commerce` contains all types, helpers and functions to be used as base to build a new **provider**. +- **Providers** live under `packages`'s root folder and they will extend Next.js Commerce types and functionality (`packages/commerce`). +- We have a **Features API** to ensure feature parity between the UI and the Provider. The UI should update accordingly and no extra code should be bundled. All extra configuration for features will live under `features` in `commerce.config.json` and if needed it can also be accessed programatically. +- Each **provider** should add its corresponding `next.config.js` and `commerce.config.json` adding specific data related to the provider. For example in case of BigCommerce, the images CDN and additional API routes. + +## Configuration + +### Enable RUM + +To enable RUM, generate a new RUM application in DD and then set the `NEXT_PUBLIC_DD_APPLICATION_KEY` and `NEXT_PUBLIC_CLIENT_TOKEN` values in `./site/.env.local`. Then start the app, click around the site, and you should start to see RUM metrics populating in DD. + +### Features + +Every provider defines the features that it supports under `packages/{provider}/src/commerce.config.json` + +#### Features Available + +The following features can be enabled or disabled. This means that the UI will remove all code related to the feature. +For example: Turning `cart` off will disable Cart capabilities. + +- cart +- search +- wishlist +- customerAuth +- customCheckout + +#### How to turn Features on and off + +> NOTE: The selected provider should support the feature that you are toggling. (This means that you can't turn wishlist on if the provider doesn't support this functionality out the box) + +- Open `site/commerce.config.json` +- You'll see a config file like this: + ```json + { + "features": { + "wishlist": false, + "customCheckout": true + } + } + ``` +- Turn `wishlist` on by setting `wishlist` to `true`. +- Run the app and the wishlist functionality should be back on. + +## Troubleshoot + +
+When run locally I get `Error: Cannot find module '...@vercel/commerce/dist/config'` + +```bash +commerce/site +❯ yarn dev +yarn run v1.22.17 +$ next dev +ready - started server on 0.0.0.0:3000, url: http://localhost:3000 +info - Loaded env from /commerce/site/.env.local +error - Failed to load next.config.js, see more info here https://nextjs.org/docs/messages/next-config-error +Error: Cannot find module '/Users/dom/work/vercel/commerce/node_modules/@vercel/commerce/dist/config.cjs' + at createEsmNotFoundErr (node:internal/modules/cjs/loader:960:15) + at finalizeEsmResolution (node:internal/modules/cjs/loader:953:15) + at resolveExports (node:internal/modules/cjs/loader:482:14) + at Function.Module._findPath (node:internal/modules/cjs/loader:522:31) + at Function.Module._resolveFilename (node:internal/modules/cjs/loader:919:27) + at Function.mod._resolveFilename (/Users/dom/work/vercel/commerce/node_modules/next/dist/build/webpack/require-hook.js:179:28) + at Function.Module._load (node:internal/modules/cjs/loader:778:27) + at Module.require (node:internal/modules/cjs/loader:1005:19) + at require (node:internal/modules/cjs/helpers:102:18) + at Object. (/Users/dom/work/vercel/commerce/site/commerce-config.js:9:14) { + code: 'MODULE_NOT_FOUND', + path: '/Users/dom/work/vercel/commerce/node_modules/@vercel/commerce/package.json' +} +error Command failed with exit code 1. +info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command. +``` + +The error usually occurs when running yarn dev inside of the `/site/` folder after installing a fresh repository. + +In order to fix this, run `yarn dev` in the monorepo root folder first. + +> Using `yarn dev` from the root is recommended for developing, which will run watch mode on all packages. + +
+ +
+When run locally I get `Error: Spree API cannot be reached'` + +The error usually occurs when the backend containers are not yet fully healthy, but the frontend has already started making API requests. + +In the docker logs output for storedog-backend, check to see if the backend has fully started. You should see the following log for the `web` container: +``` +web_1 | [1] * Listening on http://0.0.0.0:4000 +``` + +
\ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 81ead31a..06d15bbe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,29 @@ version: '3.7' services: + frontend: + build: + context: ./services/frontend + command: yarn dev + depends_on: + - worker + volumes: + - "./services/frontend/site:/storedog-app/site" + ports: + - 3000:3000 + networks: + - storedog-net + nginx: + build: + context: ./services/nginx + restart: always + ports: + - "80:80" + depends_on: + - frontend + labels: + com.datadoghq.ad.logs: '[{"source": "nginx", "service": "nginx"}]' + networks: + - storedog-net postgres: image: postgres:13-alpine restart: always @@ -8,13 +32,13 @@ services: volumes: - 'postgres:/var/lib/postgresql/data' - ./services/backend/db/restore:/docker-entrypoint-initdb.d - networks: - - storedog-net labels: com.datadoghq.ad.check_names: '["postgres"]' com.datadoghq.ad.init_configs: '[{}]' com.datadoghq.ad.instances: '[{"host":"%%host%%", "port":5432,"username":"datadog","password":"datadog"}]' com.datadoghq.ad.logs: '[{"source":"postgresql","service":"postgresql"}]' + networks: + - storedog-net redis: image: redis:6.2-alpine volumes: @@ -39,7 +63,6 @@ services: DB_PORT: 5432 DISABLE_SPRING: 1 DD_APPSEC_ENABLED: 1 - networks: - storedog-net worker: @@ -181,11 +204,11 @@ services: - ATTACK_SSH_INTERVAL - ATTACK_HOST - ATTACK_PORT - networks: - - storedog-net depends_on: - web - discounts + networks: + - storedog-net volumes: redis: @@ -197,4 +220,4 @@ networks: driver: bridge ipam: config: - - subnet: 172.43.0.0/16 + - subnet: 172.43.0.0/16 \ No newline at end of file diff --git a/services/frontend/.prettierignore b/services/frontend/.prettierignore new file mode 100644 index 00000000..1c8b279c --- /dev/null +++ b/services/frontend/.prettierignore @@ -0,0 +1,5 @@ +# Every package defines its prettier config +node_modules +dist +.next +public diff --git a/services/frontend/.prettierrc b/services/frontend/.prettierrc new file mode 100644 index 00000000..e1076edf --- /dev/null +++ b/services/frontend/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": false, + "singleQuote": true, + "tabWidth": 2, + "useTabs": false +} diff --git a/services/frontend/Dockerfile b/services/frontend/Dockerfile new file mode 100644 index 00000000..eca48707 --- /dev/null +++ b/services/frontend/Dockerfile @@ -0,0 +1,5 @@ +FROM node:16.18.0 as builder +WORKDIR /storedog-app +COPY . . +EXPOSE 3000 +RUN ["yarn","install"] \ No newline at end of file diff --git a/services/frontend/package.json b/services/frontend/package.json new file mode 100644 index 00000000..c010a303 --- /dev/null +++ b/services/frontend/package.json @@ -0,0 +1,28 @@ +{ + "name": "storedog", + "license": "MIT", + "private": true, + "workspaces": [ + "site", + "packages/*" + ], + "scripts": { + "build": "turbo run build", + "dev": "turbo run dev", + "start": "turbo run start", + "types": "turbo run types", + "prettier-fix": "prettier --write .", + "docker-build": "turbo run build && turbo run start" + }, + "devDependencies": { + "husky": "^7.0.4", + "prettier": "^2.5.1", + "turbo": "^1.2.16" + }, + "husky": { + "hooks": { + "pre-commit": "turbo run lint" + } + }, + "packageManager": "yarn@1.22.17" +} diff --git a/services/frontend/packages/commerce/.prettierignore b/services/frontend/packages/commerce/.prettierignore new file mode 100644 index 00000000..f06235c4 --- /dev/null +++ b/services/frontend/packages/commerce/.prettierignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/services/frontend/packages/commerce/.prettierrc b/services/frontend/packages/commerce/.prettierrc new file mode 100644 index 00000000..e1076edf --- /dev/null +++ b/services/frontend/packages/commerce/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": false, + "singleQuote": true, + "tabWidth": 2, + "useTabs": false +} diff --git a/services/frontend/packages/commerce/README.md b/services/frontend/packages/commerce/README.md new file mode 100644 index 00000000..ecdebb8c --- /dev/null +++ b/services/frontend/packages/commerce/README.md @@ -0,0 +1,334 @@ +# Commerce Framework + +- [Commerce Framework](#commerce-framework) + - [Commerce Hooks](#commerce-hooks) + - [CommerceProvider](#commerceprovider) + - [Authentication Hooks](#authentication-hooks) + - [useSignup](#usesignup) + - [useLogin](#uselogin) + - [useLogout](#uselogout) + - [Customer Hooks](#customer-hooks) + - [useCustomer](#usecustomer) + - [Product Hooks](#product-hooks) + - [usePrice](#useprice) + - [useSearch](#usesearch) + - [Cart Hooks](#cart-hooks) + - [useCart](#usecart) + - [useAddItem](#useadditem) + - [useUpdateItem](#useupdateitem) + - [useRemoveItem](#useremoveitem) + - [Wishlist Hooks](#wishlist-hooks) + - [Commerce API](#commerce-api) + - [More](#more) + +The commerce framework ships multiple hooks and a Node.js API, both using an underlying headless e-commerce platform, which we call commerce providers. + +The core features are: + +- Code splitted hooks for data fetching using [SWR](https://swr.vercel.app/), and to handle common user actions +- A Node.js API for initial data population, static generation of content and for creating the API endpoints that connect to the hooks, if required. + +> 👩‍🔬 If you would like to contribute a new provider, check the docs for [Adding a new Commerce Provider](./new-provider.md). + +> 🚧 The core commerce framework is under active development, new features and updates will be continuously added over time. Breaking changes are expected while we finish the API. + +## Commerce Hooks + +A commerce hook is a [React hook](https://reactjs.org/docs/hooks-intro.html) that's connected to a commerce provider. They focus on user actions and data fetching of data that wasn't statically generated. + +Data fetching hooks use [SWR](https://swr.vercel.app/) underneath and you're welcome to use any of its [return values](https://swr.vercel.app/docs/options#return-values) and [options](https://swr.vercel.app/docs/options#options). For example, using the `useCustomer` hook: + +```jsx +const { data, isLoading, error } = useCustomer({ + swrOptions: { + revalidateOnFocus: true, + }, +}) +``` + +### CommerceProvider + +This component adds the provider config and handlers to the context of your React tree for it's children. You can optionally pass the `locale` to it: + +```jsx +import { CommerceProvider } from '@framework' + +const App = ({ locale = 'en-US', children }) => { + return {children} +} +``` + +## Authentication Hooks + +### useSignup + +Returns a _signup_ function that can be used to sign up the current visitor: + +```jsx +import useSignup from '@framework/auth/use-signup' + +const SignupView = () => { + const signup = useSignup() + + const handleSignup = async () => { + await signup({ + email, + firstName, + lastName, + password, + }) + } + + return
{children}
+} +``` + +### useLogin + +Returns a _login_ function that can be used to sign in the current visitor into an existing customer: + +```jsx +import useLogin from '@framework/auth/use-login' + +const LoginView = () => { + const login = useLogin() + const handleLogin = async () => { + await login({ + email, + password, + }) + } + + return
{children}
+} +``` + +### useLogout + +Returns a _logout_ function that signs out the current customer when called. + +```jsx +import useLogout from '@framework/auth/use-logout' + +const LogoutButton = () => { + const logout = useLogout() + return ( + + ) +} +``` + +## Customer Hooks + +### useCustomer + +Fetches and returns the data of the signed in customer: + +```jsx +import useCustomer from '@framework/customer/use-customer' + +const Profile = () => { + const { data, isLoading, error } = useCustomer() + + if (isLoading) return

Loading...

+ if (error) return

{error.message}

+ if (!data) return null + + return
Hello, {data.firstName}
+} +``` + +## Product Hooks + +### usePrice + +Helper hook to format price according to the commerce locale and currency code. It also handles discounts: + +```jsx +import useCart from '@framework/cart/use-cart' +import usePrice from '@framework/product/use-price' + +// ... +const { data } = useCart() +const { price, discount, basePrice } = usePrice( + data && { + amount: data.subtotalPrice, + currencyCode: data.currency.code, + // If `baseAmount` is used, a discount will be calculated + // baseAmount: number, + } +) +// ... +``` + +### useSearch + +Fetches and returns the products that match a set of filters: + +```jsx +import useSearch from '@framework/product/use-search' + +const SearchPage = ({ searchString, category, brand, sortStr }) => { + const { data } = useSearch({ + search: searchString || '', + categoryId: category?.entityId, + brandId: brand?.entityId, + sort: sortStr, + }) + + return ( + + {data.products.map((product) => ( + + ))} + + ) +} +``` + +## Cart Hooks + +### useCart + +Fetches and returns the data of the current cart: + +```jsx +import useCart from '@framework/cart/use-cart' + +const CartTotal = () => { + const { data, isLoading, isEmpty, error } = useCart() + + if (isLoading) return

Loading...

+ if (error) return

{error.message}

+ if (isEmpty) return

The cart is empty

+ + return

The cart total is {data.totalPrice}

+} +``` + +### useAddItem + +Returns a function that adds a new item to the cart when called, if this is the first item it will create the cart: + +```jsx +import { useAddItem } from '@framework/cart' + +const AddToCartButton = ({ productId, variantId }) => { + const addItem = useAddItem() + + const addToCart = async () => { + await addItem({ + productId, + variantId, + }) + } + + return +} +``` + +### useUpdateItem + +Returns a function that updates a current item in the cart when called, usually the quantity. + +```jsx +import { useUpdateItem } from '@framework/cart' + +const CartItemQuantity = ({ item }) => { + const [quantity, setQuantity] = useState(item.quantity) + const updateItem = useUpdateItem({ item }) + + const updateQuantity = async (e) => { + const val = e.target.value + + setQuantity(val) + await updateItem({ quantity: val }) + } + + return ( + + ) +} +``` + +If the `quantity` is lower than 1 the item will be removed from the cart. + +### useRemoveItem + +Returns a function that removes an item in the cart when called: + +```jsx +import { useRemoveItem } from '@framework/cart' + +const RemoveButton = ({ item }) => { + const removeItem = useRemoveItem() + const handleRemove = async () => { + await removeItem(item) + } + + return +} +``` + +## Wishlist Hooks + +Wishlist hooks work just like [cart hooks](#cart-hooks). Feel free to check how those work first. + +The example below shows how to use the `useWishlist`, `useAddItem` and `useRemoveItem` hooks: + +```jsx +import { useWishlist, useAddItem, useRemoveItem } from '@framework/wishlist' + +const WishlistButton = ({ productId, variant }) => { + const addItem = useAddItem() + const removeItem = useRemoveItem() + const { data, isLoading, isEmpty, error } = useWishlist() + + if (isLoading) return

Loading...

+ if (error) return

{error.message}

+ if (isEmpty) return

The wihslist is empty

+ + const { data: customer } = useCustomer() + const itemInWishlist = data?.items?.find( + (item) => item.product_id === productId && item.variant_id === variant.id + ) + + const handleWishlistChange = async (e) => { + e.preventDefault() + if (!customer) return + + if (itemInWishlist) { + await removeItem({ id: itemInWishlist.id }) + } else { + await addItem({ + productId, + variantId: variant.id, + }) + } + } + + return ( + + ) +} +``` + +## Commerce API + +While commerce hooks focus on client side data fetching and interactions, the commerce API focuses on static content generation for pages and API endpoints in a Node.js context. + +> The commerce API is currently going through a refactor in https://github.com/vercel/commerce/pull/252 - We'll update the docs once the API is released. + +## More + +Feel free to read through the source for more usage, and check the commerce vercel demo and commerce repo for usage examples: ([demo.vercel.store](https://demo.vercel.store/)) ([repo](https://github.com/vercel/commerce)) diff --git a/services/frontend/packages/commerce/fixup.mjs b/services/frontend/packages/commerce/fixup.mjs new file mode 100755 index 00000000..b276f5c1 --- /dev/null +++ b/services/frontend/packages/commerce/fixup.mjs @@ -0,0 +1,5 @@ +import { readFile, writeFile } from 'fs/promises' + +const packageJson = await readFile('package.json', 'utf8') + +writeFile('dist/package.json', packageJson, 'utf8') diff --git a/services/frontend/packages/commerce/new-provider.md b/services/frontend/packages/commerce/new-provider.md new file mode 100644 index 00000000..c7507617 --- /dev/null +++ b/services/frontend/packages/commerce/new-provider.md @@ -0,0 +1,237 @@ +# Adding a new Commerce Provider + +🔔 New providers are on hold [until we have a new API for commerce](https://github.com/vercel/commerce/pull/252) 🔔 + +A commerce provider is a headless e-commerce platform that integrates with the [Commerce Framework](./README.md). Right now we have the following providers: + +- Local ([packages/local](../local)) +- Shopify ([packages/shopify](../shopify)) +- Swell ([packages/swell](../swell)) +- BigCommerce ([packages/bigcommerce](../bigcommerce)) +- Vendure ([packages/vendure](../vendure)) +- Saleor ([packages/saleor](../saleor)) +- OrderCloud ([packages/ordercloud](../ordercloud)) +- Spree ([packages/spree](../spree)) +- Kibo Commerce ([packages/kibocommerce](../kibocommerce)) +- Commerce.js ([packages/commercejs](../commercejs)) +- SFCC - SalesForce Cloud Commerce ([packages/sfcc](../sfcc)) + +Adding a commerce provider means adding a new folder in `packages` with a folder structure like the next one: + +- `src` + - `api` + - index.ts + - `product` + - usePrice + - useSearch + - getProduct + - getAllProducts + - `wishlist` + - useWishlist + - useAddItem + - useRemoveItem + - `auth` + - useLogin + - useLogout + - useSignup + - `customer` + - useCustomer + - getCustomerId + - getCustomerWistlist + - `cart` + - useCart + - useAddItem + - useRemoveItem + - useUpdateItem + - `index.ts` + - `provider.ts` + - `commerce.config.json` + - `next.config.cjs` +- `package.json` +- `tsconfig.json` +- `env.template` +- `README.md` + +`provider.ts` exports a provider object with handlers for the [Commerce Hooks](./README.md#commerce-hooks) and `api/index.ts` exports a Node.js provider for the [Commerce API](./README.md#commerce-api) + +> **Important:** We use TypeScript for every provider and expect its usage for every new one. + +The app imports from the provider directly instead of the core commerce folder (`packages/commerce`), but all providers are interchangeable and to achieve it every provider always has to implement the core types and helpers. + +## Updating the list of known providers + +Open [/site/commerce-config.js](/site/commerce-config.js) and add the provider name to the list in `PROVIDERS`. + +Then, open [/site/.env.template](/site/.env.template) and add the provider name to the list there too. + +## Adding the provider hooks + +Using BigCommerce as an example. The first thing to do is export a `CommerceProvider` component that includes a `provider` object with all the handlers that can be used for hooks: + +```tsx +import { getCommerceProvider, useCommerce as useCoreCommerce } from '@vercel/commerce' +import { bigcommerceProvider, BigcommerceProvider } from './provider' + +export { bigcommerceProvider } +export type { BigcommerceProvider } + +export const CommerceProvider = getCommerceProvider(bigcommerceProvider) + +export const useCommerce = () => useCoreCommerce() +``` + +The exported types and components extend from the core ones exported by `@vercel/commerce`, which refers to `packages/commerce`. + +The `bigcommerceProvider` object looks like this: + +```tsx +import { handler as useCart } from './cart/use-cart' +import { handler as useAddItem } from './cart/use-add-item' +import { handler as useUpdateItem } from './cart/use-update-item' +import { handler as useRemoveItem } from './cart/use-remove-item' + +import { handler as useWishlist } from './wishlist/use-wishlist' +import { handler as useWishlistAddItem } from './wishlist/use-add-item' +import { handler as useWishlistRemoveItem } from './wishlist/use-remove-item' + +import { handler as useCustomer } from './customer/use-customer' +import { handler as useSearch } from './product/use-search' + +import { handler as useLogin } from './auth/use-login' +import { handler as useLogout } from './auth/use-logout' +import { handler as useSignup } from './auth/use-signup' + +import fetcher from './fetcher' + +export const bigcommerceProvider = { + locale: 'en-us', + cartCookie: 'bc_cartId', + fetcher, + cart: { useCart, useAddItem, useUpdateItem, useRemoveItem }, + wishlist: { + useWishlist, + useAddItem: useWishlistAddItem, + useRemoveItem: useWishlistRemoveItem, + }, + customer: { useCustomer }, + products: { useSearch }, + auth: { useLogin, useLogout, useSignup }, +} + +export type BigcommerceProvider = typeof bigcommerceProvider +``` + +The provider object, in this case `bigcommerceProvider`, has to match the `Provider` type defined in [packages/commerce](./src/index.tsx). + +A hook handler, like `useCart`, looks like this: + +```tsx +import { useMemo } from 'react' +import { SWRHook } from '@vercel/commerce/utils/types' +import useCart, { UseCart } from '@vercel/commerce/cart/use-cart' +import type { GetCartHook } from '@vercel/commerce/types/cart' + +export default useCart as UseCart + +export const handler: SWRHook = { + fetchOptions: { + url: '/api/cart', + method: 'GET', + }, + useHook: + ({ useData }) => + (input) => { + const response = useData({ + swrOptions: { revalidateOnFocus: false, ...input?.swrOptions }, + }) + + return useMemo( + () => + Object.create(response, { + isEmpty: { + get() { + return (response.data?.lineItems.length ?? 0) <= 0 + }, + enumerable: true, + }, + }), + [response] + ) + }, +} +``` + +In the case of data fetching hooks like `useCart` each handler has to implement the `SWRHook` type that's defined in the core types. For mutations it's the `MutationHook`, e.g for `useAddItem`: + +```tsx +import { useCallback } from 'react' +import type { MutationHook } from '@vercel/commerce/utils/types' +import { CommerceError } from '@vercel/commerce/utils/errors' +import useAddItem, { UseAddItem } from '@vercel/commerce/cart/use-add-item' +import type { AddItemHook } from '@vercel/commerce/types/cart' +import useCart from './use-cart' + +export default useAddItem as UseAddItem + +export const handler: MutationHook = { + fetchOptions: { + url: '/api/cart', + method: 'POST', + }, + async fetcher({ input: item, options, fetch }) { + if ( + item.quantity && + (!Number.isInteger(item.quantity) || item.quantity! < 1) + ) { + throw new CommerceError({ + message: 'The item quantity has to be a valid integer greater than 0', + }) + } + + const data = await fetch({ + ...options, + body: { item }, + }) + + return data + }, + useHook: + ({ fetch }) => + () => { + const { mutate } = useCart() + + return useCallback( + async function addItem(input) { + const data = await fetch({ input }) + await mutate(data, false) + return data + }, + [fetch, mutate] + ) + }, +} +``` + +## Showing progress and features +When creating a PR for a new provider, include this list in the PR description and mark the progress as you push so we can organize the code review. Not all points are required (but advised) so make sure to keep the list up to date. + +**Status** + +* [ ] CommerceProvider +* [ ] Schema & TS types +* [ ] API Operations - Get all collections +* [ ] API Operations - Get all pages +* [ ] API Operations - Get all products +* [ ] API Operations - Get page +* [ ] API Operations - Get product +* [ ] API Operations - Get Shop Info (categories and vendors working — `vendors` query still a WIP PR on Reaction) +* [ ] Hook - Add Item +* [ ] Hook - Remove Item +* [ ] Hook - Update Item +* [ ] Hook - Get Cart (account-tied carts working, anonymous carts working, cart reconciliation working) +* [ ] Auth (based on a WIP PR on Reaction - still need to implement refresh tokens) +* [ ] Customer information +* [ ] Product attributes - Size, Colors +* [ ] Custom checkout +* [ ] Typing (in progress) +* [ ] Tests diff --git a/services/frontend/packages/commerce/package.json b/services/frontend/packages/commerce/package.json new file mode 100644 index 00000000..17343a8b --- /dev/null +++ b/services/frontend/packages/commerce/package.json @@ -0,0 +1,83 @@ +{ + "name": "@vercel/commerce", + "version": "0.0.1", + "license": "MIT", + "scripts": { + "release": "taskr release", + "build": "taskr build", + "dev": "taskr", + "types": "tsc --emitDeclarationOnly", + "prettier-fix": "prettier --write ." + }, + "sideEffects": false, + "type": "module", + "exports": { + ".": "./dist/index.js", + "./*": [ + "./dist/*.js", + "./dist/*/index.js" + ], + "./config": "./dist/config.cjs" + }, + "typesVersions": { + "*": { + "*": [ + "src/*", + "src/*/index" + ], + "config": [ + "dist/config.d.cts" + ] + } + }, + "files": [ + "dist" + ], + "publishConfig": { + "typesVersions": { + "*": { + "*": [ + "dist/*.d.ts", + "dist/*/index.d.ts" + ], + "config": [ + "dist/config.d.cts" + ] + } + } + }, + "dependencies": { + "@vercel/fetch": "^6.1.1", + "deepmerge": "^4.2.2", + "import-cwd": "^3.0.0", + "js-cookie": "^3.0.1", + "swr": "^1.2.0" + }, + "peerDependencies": { + "next": "^12", + "react": "^17", + "react-dom": "^17" + }, + "devDependencies": { + "@taskr/clear": "^1.1.0", + "@taskr/esnext": "^1.1.0", + "@taskr/watch": "^1.1.0", + "@types/js-cookie": "^3.0.1", + "@types/node": "^17.0.8", + "@types/react": "^17.0.38", + "lint-staged": "^12.1.7", + "next": "^12.0.8", + "prettier": "^2.5.1", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "taskr": "^1.1.0", + "taskr-swc": "^0.0.1", + "typescript": "^4.5.4" + }, + "lint-staged": { + "**/*.{js,jsx,ts,tsx,json}": [ + "prettier --write", + "git add" + ] + } +} diff --git a/services/frontend/packages/commerce/src/api/endpoints/cart.ts b/services/frontend/packages/commerce/src/api/endpoints/cart.ts new file mode 100644 index 00000000..abd5df49 --- /dev/null +++ b/services/frontend/packages/commerce/src/api/endpoints/cart.ts @@ -0,0 +1,60 @@ +import type { CartSchema } from '../../types/cart' +import { CommerceAPIError } from '../utils/errors' +import isAllowedOperation from '../utils/is-allowed-operation' +import type { GetAPISchema } from '..' + +const cartEndpoint: GetAPISchema>['endpoint']['handler'] = + async (ctx) => { + const { req, res, handlers, config } = ctx + + if ( + !isAllowedOperation(req, res, { + GET: handlers['getCart'], + POST: handlers['addItem'], + PUT: handlers['updateItem'], + DELETE: handlers['removeItem'], + }) + ) { + return + } + + const { cookies } = req + const cartId = cookies[config.cartCookie] + + try { + // Return current cart info + if (req.method === 'GET') { + const body = { cartId } + return await handlers['getCart']({ ...ctx, body }) + } + + // Create or add an item to the cart + if (req.method === 'POST') { + const body = { ...req.body, cartId } + return await handlers['addItem']({ ...ctx, body }) + } + + // Update item in cart + if (req.method === 'PUT') { + const body = { ...req.body, cartId } + return await handlers['updateItem']({ ...ctx, body }) + } + + // Remove an item from the cart + if (req.method === 'DELETE') { + const body = { ...req.body, cartId } + return await handlers['removeItem']({ ...ctx, body }) + } + } catch (error) { + console.error(error) + + const message = + error instanceof CommerceAPIError + ? 'An unexpected error ocurred with the Commerce API' + : 'An unexpected error ocurred' + + res.status(500).json({ data: null, errors: [{ message }] }) + } + } + +export default cartEndpoint diff --git a/services/frontend/packages/commerce/src/api/endpoints/catalog/products.ts b/services/frontend/packages/commerce/src/api/endpoints/catalog/products.ts new file mode 100644 index 00000000..d2a4794b --- /dev/null +++ b/services/frontend/packages/commerce/src/api/endpoints/catalog/products.ts @@ -0,0 +1,31 @@ +import type { ProductsSchema } from '../../../types/product' +import { CommerceAPIError } from '../../utils/errors' +import isAllowedOperation from '../../utils/is-allowed-operation' +import type { GetAPISchema } from '../..' + +const productsEndpoint: GetAPISchema< + any, + ProductsSchema +>['endpoint']['handler'] = async (ctx) => { + const { req, res, handlers } = ctx + + if (!isAllowedOperation(req, res, { GET: handlers['getProducts'] })) { + return + } + + try { + const body = req.query + return await handlers['getProducts']({ ...ctx, body }) + } catch (error) { + console.error(error) + + const message = + error instanceof CommerceAPIError + ? 'An unexpected error ocurred with the Commerce API' + : 'An unexpected error ocurred' + + res.status(500).json({ data: null, errors: [{ message }] }) + } +} + +export default productsEndpoint diff --git a/services/frontend/packages/commerce/src/api/endpoints/checkout.ts b/services/frontend/packages/commerce/src/api/endpoints/checkout.ts new file mode 100644 index 00000000..0168e706 --- /dev/null +++ b/services/frontend/packages/commerce/src/api/endpoints/checkout.ts @@ -0,0 +1,49 @@ +import type { CheckoutSchema } from '../../types/checkout' +import type { GetAPISchema } from '..' + +import { CommerceAPIError } from '../utils/errors' +import isAllowedOperation from '../utils/is-allowed-operation' + +const checkoutEndpoint: GetAPISchema< + any, + CheckoutSchema +>['endpoint']['handler'] = async (ctx) => { + const { req, res, handlers, config } = ctx + + if ( + !isAllowedOperation(req, res, { + GET: handlers['getCheckout'], + POST: handlers['submitCheckout'], + }) + ) { + return + } + + const { cookies } = req + const cartId = cookies[config.cartCookie] + + try { + // Create checkout + if (req.method === 'GET') { + const body = { ...req.body, cartId } + return await handlers['getCheckout']({ ...ctx, body }) + } + + // Create checkout + if (req.method === 'POST' && handlers['submitCheckout']) { + const body = { ...req.body, cartId } + return await handlers['submitCheckout']({ ...ctx, body }) + } + } catch (error) { + console.error(error) + + const message = + error instanceof CommerceAPIError + ? 'An unexpected error ocurred with the Commerce API' + : 'An unexpected error ocurred' + + res.status(500).json({ data: null, errors: [{ message }] }) + } +} + +export default checkoutEndpoint diff --git a/services/frontend/packages/commerce/src/api/endpoints/customer/address.ts b/services/frontend/packages/commerce/src/api/endpoints/customer/address.ts new file mode 100644 index 00000000..d5ede697 --- /dev/null +++ b/services/frontend/packages/commerce/src/api/endpoints/customer/address.ts @@ -0,0 +1,65 @@ +import type { CustomerAddressSchema } from '../../../types/customer/address' +import type { GetAPISchema } from '../..' + +import { CommerceAPIError } from '../../utils/errors' +import isAllowedOperation from '../../utils/is-allowed-operation' + +const customerShippingEndpoint: GetAPISchema< + any, + CustomerAddressSchema +>['endpoint']['handler'] = async (ctx) => { + const { req, res, handlers, config } = ctx + + if ( + !isAllowedOperation(req, res, { + GET: handlers['getAddresses'], + POST: handlers['addItem'], + PUT: handlers['updateItem'], + DELETE: handlers['removeItem'], + }) + ) { + return + } + + const { cookies } = req + + // Cart id might be usefull for anonymous shopping + const cartId = cookies[config.cartCookie] + + try { + // Return customer addresses + if (req.method === 'GET') { + const body = { cartId } + return await handlers['getAddresses']({ ...ctx, body }) + } + + // Create or add an item to customer addresses list + if (req.method === 'POST') { + const body = { ...req.body, cartId } + return await handlers['addItem']({ ...ctx, body }) + } + + // Update item in customer addresses list + if (req.method === 'PUT') { + const body = { ...req.body, cartId } + return await handlers['updateItem']({ ...ctx, body }) + } + + // Remove an item from customer addresses list + if (req.method === 'DELETE') { + const body = { ...req.body, cartId } + return await handlers['removeItem']({ ...ctx, body }) + } + } catch (error) { + console.error(error) + + const message = + error instanceof CommerceAPIError + ? 'An unexpected error ocurred with the Commerce API' + : 'An unexpected error ocurred' + + res.status(500).json({ data: null, errors: [{ message }] }) + } +} + +export default customerShippingEndpoint diff --git a/services/frontend/packages/commerce/src/api/endpoints/customer/card.ts b/services/frontend/packages/commerce/src/api/endpoints/customer/card.ts new file mode 100644 index 00000000..ad268cbb --- /dev/null +++ b/services/frontend/packages/commerce/src/api/endpoints/customer/card.ts @@ -0,0 +1,65 @@ +import type { CustomerCardSchema } from '../../../types/customer/card' +import type { GetAPISchema } from '../..' + +import { CommerceAPIError } from '../../utils/errors' +import isAllowedOperation from '../../utils/is-allowed-operation' + +const customerCardEndpoint: GetAPISchema< + any, + CustomerCardSchema +>['endpoint']['handler'] = async (ctx) => { + const { req, res, handlers, config } = ctx + + if ( + !isAllowedOperation(req, res, { + GET: handlers['getCards'], + POST: handlers['addItem'], + PUT: handlers['updateItem'], + DELETE: handlers['removeItem'], + }) + ) { + return + } + + const { cookies } = req + + // Cart id might be usefull for anonymous shopping + const cartId = cookies[config.cartCookie] + + try { + // Create or add a card + if (req.method === 'GET') { + const body = { ...req.body } + return await handlers['getCards']({ ...ctx, body }) + } + + // Create or add an item to customer cards + if (req.method === 'POST') { + const body = { ...req.body, cartId } + return await handlers['addItem']({ ...ctx, body }) + } + + // Update item in customer cards + if (req.method === 'PUT') { + const body = { ...req.body, cartId } + return await handlers['updateItem']({ ...ctx, body }) + } + + // Remove an item from customer cards + if (req.method === 'DELETE') { + const body = { ...req.body, cartId } + return await handlers['removeItem']({ ...ctx, body }) + } + } catch (error) { + console.error(error) + + const message = + error instanceof CommerceAPIError + ? 'An unexpected error ocurred with the Commerce API' + : 'An unexpected error ocurred' + + res.status(500).json({ data: null, errors: [{ message }] }) + } +} + +export default customerCardEndpoint diff --git a/services/frontend/packages/commerce/src/api/endpoints/customer/index.ts b/services/frontend/packages/commerce/src/api/endpoints/customer/index.ts new file mode 100644 index 00000000..eb2a048b --- /dev/null +++ b/services/frontend/packages/commerce/src/api/endpoints/customer/index.ts @@ -0,0 +1,36 @@ +import type { CustomerSchema } from '../../../types/customer' +import type { GetAPISchema } from '../..' + +import { CommerceAPIError } from '../../utils/errors' +import isAllowedOperation from '../../utils/is-allowed-operation' + +const customerEndpoint: GetAPISchema< + any, + CustomerSchema +>['endpoint']['handler'] = async (ctx) => { + const { req, res, handlers } = ctx + + if ( + !isAllowedOperation(req, res, { + GET: handlers['getLoggedInCustomer'], + }) + ) { + return + } + + try { + const body = null + return await handlers['getLoggedInCustomer']({ ...ctx, body }) + } catch (error) { + console.error(error) + + const message = + error instanceof CommerceAPIError + ? 'An unexpected error ocurred with the Commerce API' + : 'An unexpected error ocurred' + + res.status(500).json({ data: null, errors: [{ message }] }) + } +} + +export default customerEndpoint diff --git a/services/frontend/packages/commerce/src/api/endpoints/login.ts b/services/frontend/packages/commerce/src/api/endpoints/login.ts new file mode 100644 index 00000000..6f69629b --- /dev/null +++ b/services/frontend/packages/commerce/src/api/endpoints/login.ts @@ -0,0 +1,36 @@ +import type { LoginSchema } from '../../types/login' +import { CommerceAPIError } from '../utils/errors' +import isAllowedOperation from '../utils/is-allowed-operation' +import type { GetAPISchema } from '..' + +const loginEndpoint: GetAPISchema< + any, + LoginSchema +>['endpoint']['handler'] = async (ctx) => { + const { req, res, handlers } = ctx + + if ( + !isAllowedOperation(req, res, { + POST: handlers['login'], + GET: handlers['login'], + }) + ) { + return + } + + try { + const body = req.body ?? {} + return await handlers['login']({ ...ctx, body }) + } catch (error) { + console.error(error) + + const message = + error instanceof CommerceAPIError + ? 'An unexpected error ocurred with the Commerce API' + : 'An unexpected error ocurred' + + res.status(500).json({ data: null, errors: [{ message }] }) + } +} + +export default loginEndpoint diff --git a/services/frontend/packages/commerce/src/api/endpoints/logout.ts b/services/frontend/packages/commerce/src/api/endpoints/logout.ts new file mode 100644 index 00000000..c2640eac --- /dev/null +++ b/services/frontend/packages/commerce/src/api/endpoints/logout.ts @@ -0,0 +1,35 @@ +import type { LogoutSchema } from '../../types/logout' +import { CommerceAPIError } from '../utils/errors' +import isAllowedOperation from '../utils/is-allowed-operation' +import type { GetAPISchema } from '..' + +const logoutEndpoint: GetAPISchema['endpoint']['handler'] = + async (ctx) => { + const { req, res, handlers } = ctx + + if ( + !isAllowedOperation(req, res, { + GET: handlers['logout'], + }) + ) { + return + } + + try { + const redirectTo = req.query.redirect_to + const body = typeof redirectTo === 'string' ? { redirectTo } : {} + + return await handlers['logout']({ ...ctx, body }) + } catch (error) { + console.error(error) + + const message = + error instanceof CommerceAPIError + ? 'An unexpected error ocurred with the Commerce API' + : 'An unexpected error ocurred' + + res.status(500).json({ data: null, errors: [{ message }] }) + } + } + +export default logoutEndpoint diff --git a/services/frontend/packages/commerce/src/api/endpoints/signup.ts b/services/frontend/packages/commerce/src/api/endpoints/signup.ts new file mode 100644 index 00000000..78c4cf58 --- /dev/null +++ b/services/frontend/packages/commerce/src/api/endpoints/signup.ts @@ -0,0 +1,36 @@ +import type { SignupSchema } from '../../types/signup' +import { CommerceAPIError } from '../utils/errors' +import isAllowedOperation from '../utils/is-allowed-operation' +import type { GetAPISchema } from '..' + +const signupEndpoint: GetAPISchema['endpoint']['handler'] = + async (ctx) => { + const { req, res, handlers, config } = ctx + + if ( + !isAllowedOperation(req, res, { + POST: handlers['signup'], + }) + ) { + return + } + + const { cookies } = req + const cartId = cookies[config.cartCookie] + + try { + const body = { ...req.body, cartId } + return await handlers['signup']({ ...ctx, body }) + } catch (error) { + console.error(error) + + const message = + error instanceof CommerceAPIError + ? 'An unexpected error ocurred with the Commerce API' + : 'An unexpected error ocurred' + + res.status(500).json({ data: null, errors: [{ message }] }) + } + } + +export default signupEndpoint diff --git a/services/frontend/packages/commerce/src/api/endpoints/wishlist.ts b/services/frontend/packages/commerce/src/api/endpoints/wishlist.ts new file mode 100644 index 00000000..233ac529 --- /dev/null +++ b/services/frontend/packages/commerce/src/api/endpoints/wishlist.ts @@ -0,0 +1,58 @@ +import type { WishlistSchema } from '../../types/wishlist' +import { CommerceAPIError } from '../utils/errors' +import isAllowedOperation from '../utils/is-allowed-operation' +import type { GetAPISchema } from '..' + +const wishlistEndpoint: GetAPISchema< + any, + WishlistSchema +>['endpoint']['handler'] = async (ctx) => { + const { req, res, handlers, config } = ctx + + if ( + !isAllowedOperation(req, res, { + GET: handlers['getWishlist'], + POST: handlers['addItem'], + DELETE: handlers['removeItem'], + }) + ) { + return + } + + const { cookies } = req + const customerToken = cookies[config.customerCookie] + + try { + // Return current wishlist info + if (req.method === 'GET') { + const body = { + customerToken, + includeProducts: req.query.products === '1', + } + return await handlers['getWishlist']({ ...ctx, body }) + } + + // Add an item to the wishlist + if (req.method === 'POST') { + const body = { ...req.body, customerToken } + return await handlers['addItem']({ ...ctx, body }) + } + + // Remove an item from the wishlist + if (req.method === 'DELETE') { + const body = { ...req.body, customerToken } + return await handlers['removeItem']({ ...ctx, body }) + } + } catch (error) { + console.error(error) + + const message = + error instanceof CommerceAPIError + ? 'An unexpected error ocurred with the Commerce API' + : 'An unexpected error ocurred' + + res.status(500).json({ data: null, errors: [{ message }] }) + } +} + +export default wishlistEndpoint diff --git a/services/frontend/packages/commerce/src/api/index.ts b/services/frontend/packages/commerce/src/api/index.ts new file mode 100644 index 00000000..6914b936 --- /dev/null +++ b/services/frontend/packages/commerce/src/api/index.ts @@ -0,0 +1,184 @@ +import type { NextApiHandler } from 'next' +import type { FetchOptions, Response } from '@vercel/fetch' +import type { APIEndpoint, APIHandler } from './utils/types' +import type { CartSchema } from '../types/cart' +import type { CustomerSchema } from '../types/customer' +import type { LoginSchema } from '../types/login' +import type { LogoutSchema } from '../types/logout' +import type { SignupSchema } from '../types/signup' +import type { ProductsSchema } from '../types/product' +import type { WishlistSchema } from '../types/wishlist' +import type { CheckoutSchema } from '../types/checkout' +import type { CustomerCardSchema } from '../types/customer/card' +import type { CustomerAddressSchema } from '../types/customer/address' +import { + defaultOperations, + OPERATIONS, + AllOperations, + APIOperations, +} from './operations' + +export type APISchemas = + | CartSchema + | CustomerSchema + | LoginSchema + | LogoutSchema + | SignupSchema + | ProductsSchema + | WishlistSchema + | CheckoutSchema + | CustomerCardSchema + | CustomerAddressSchema + +export type GetAPISchema< + C extends CommerceAPI, + S extends APISchemas = APISchemas +> = { + schema: S + endpoint: EndpointContext +} + +export type EndpointContext< + C extends CommerceAPI, + E extends EndpointSchemaBase +> = { + handler: Endpoint + handlers: EndpointHandlers +} + +export type EndpointSchemaBase = { + options: {} + handlers: { + [k: string]: { data?: any; body?: any } + } +} + +export type Endpoint< + C extends CommerceAPI, + E extends EndpointSchemaBase +> = APIEndpoint, any, E['options']> + +export type EndpointHandlers< + C extends CommerceAPI, + E extends EndpointSchemaBase +> = { + [H in keyof E['handlers']]: APIHandler< + C, + EndpointHandlers, + NonNullable['data'], + NonNullable['body'], + E['options'] + > +} + +export type APIProvider = { + config: CommerceAPIConfig + operations: APIOperations +} + +export type CommerceAPI

= + CommerceAPICore

& AllOperations

+ +export class CommerceAPICore

{ + constructor(readonly provider: P) {} + + getConfig(userConfig: Partial = {}): P['config'] { + return Object.entries(userConfig).reduce( + (cfg, [key, value]) => Object.assign(cfg, { [key]: value }), + { ...this.provider.config } + ) + } + + setConfig(newConfig: Partial) { + Object.assign(this.provider.config, newConfig) + } +} + +export function getCommerceApi

( + customProvider: P +): CommerceAPI

{ + const commerce = Object.assign( + new CommerceAPICore(customProvider), + defaultOperations as AllOperations

+ ) + const ops = customProvider.operations + + OPERATIONS.forEach((k) => { + const op = ops[k] + if (op) { + commerce[k] = op({ commerce }) as AllOperations

[typeof k] + } + }) + + return commerce +} + +export function getEndpoint< + P extends APIProvider, + T extends GetAPISchema +>( + commerce: CommerceAPI

, + context: T['endpoint'] & { + config?: P['config'] + options?: T['schema']['endpoint']['options'] + } +): NextApiHandler { + const cfg = commerce.getConfig(context.config) + + return function apiHandler(req, res) { + return context.handler({ + req, + res, + commerce, + config: cfg, + handlers: context.handlers, + options: context.options ?? {}, + }) + } +} + +export const createEndpoint = + >(endpoint: API['endpoint']) => +

( + commerce: CommerceAPI

, + context?: Partial & { + config?: P['config'] + options?: API['schema']['endpoint']['options'] + } + ): NextApiHandler => { + return getEndpoint(commerce, { ...endpoint, ...context }) + } + +export interface CommerceAPIConfig { + locale?: string + locales?: string[] + commerceUrl: string + apiToken: string + cartCookie: string + cartCookieMaxAge: number + customerCookie: string + fetch( + query: string, + queryData?: CommerceAPIFetchOptions, + fetchOptions?: FetchOptions + ): Promise> +} + +export type GraphQLFetcher< + Data extends GraphQLFetcherResult = GraphQLFetcherResult, + Variables = any +> = ( + query: string, + queryData?: CommerceAPIFetchOptions, + fetchOptions?: FetchOptions +) => Promise + +export interface GraphQLFetcherResult { + data: Data + res: Response +} + +export interface CommerceAPIFetchOptions { + variables?: Variables + preview?: boolean +} diff --git a/services/frontend/packages/commerce/src/api/operations.ts b/services/frontend/packages/commerce/src/api/operations.ts new file mode 100644 index 00000000..2910a2d8 --- /dev/null +++ b/services/frontend/packages/commerce/src/api/operations.ts @@ -0,0 +1,177 @@ +import type { ServerResponse } from 'http' +import type { LoginOperation } from '../types/login' +import type { GetAllPagesOperation, GetPageOperation } from '../types/page' +import type { GetSiteInfoOperation } from '../types/site' +import type { GetCustomerWishlistOperation } from '../types/wishlist' +import type { + GetAllProductPathsOperation, + GetAllProductsOperation, + GetProductOperation, +} from '../types/product' +import type { APIProvider, CommerceAPI } from '.' + +const noop = () => { + throw new Error('Not implemented') +} + +export const OPERATIONS = [ + 'login', + 'getAllPages', + 'getPage', + 'getSiteInfo', + 'getCustomerWishlist', + 'getAllProductPaths', + 'getAllProducts', + 'getProduct', +] as const + +export const defaultOperations = OPERATIONS.reduce((ops, k) => { + ops[k] = noop + return ops +}, {} as { [K in AllowedOperations]: typeof noop }) + +export type AllowedOperations = typeof OPERATIONS[number] + +export type Operations

= { + login: { + (opts: { + variables: T['variables'] + config?: P['config'] + res: ServerResponse + }): Promise + + ( + opts: { + variables: T['variables'] + config?: P['config'] + res: ServerResponse + } & OperationOptions + ): Promise + } + + getAllPages: { + (opts?: { + config?: P['config'] + preview?: boolean + }): Promise + + ( + opts: { + config?: P['config'] + preview?: boolean + } & OperationOptions + ): Promise + } + + getPage: { + (opts: { + variables: T['variables'] + config?: P['config'] + preview?: boolean + }): Promise + + ( + opts: { + variables: T['variables'] + config?: P['config'] + preview?: boolean + } & OperationOptions + ): Promise + } + + getSiteInfo: { + (opts: { + config?: P['config'] + preview?: boolean + }): Promise + + ( + opts: { + config?: P['config'] + preview?: boolean + } & OperationOptions + ): Promise + } + + getCustomerWishlist: { + (opts: { + variables: T['variables'] + config?: P['config'] + includeProducts?: boolean + }): Promise + + ( + opts: { + variables: T['variables'] + config?: P['config'] + includeProducts?: boolean + } & OperationOptions + ): Promise + } + + getAllProductPaths: { + (opts: { + variables?: T['variables'] + config?: P['config'] + }): Promise + + ( + opts: { + variables?: T['variables'] + config?: P['config'] + } & OperationOptions + ): Promise + } + + getAllProducts: { + (opts: { + variables?: T['variables'] + config?: P['config'] + preview?: boolean + }): Promise + + ( + opts: { + variables?: T['variables'] + config?: P['config'] + preview?: boolean + } & OperationOptions + ): Promise + } + + getProduct: { + (opts: { + variables: T['variables'] + config?: P['config'] + preview?: boolean + }): Promise + + ( + opts: { + variables: T['variables'] + config?: P['config'] + preview?: boolean + } & OperationOptions + ): Promise + } +} + +export type APIOperations

= { + [K in keyof Operations

]?: (ctx: OperationContext

) => Operations

[K] +} + +export type AllOperations

= { + [K in keyof APIOperations

]-?: P['operations'][K] extends ( + ...args: any + ) => any + ? ReturnType + : typeof noop +} + +export type OperationContext

= { + commerce: CommerceAPI

+} + +export type OperationOptions = + | { query: string; url?: never } + | { query?: never; url: string } diff --git a/services/frontend/packages/commerce/src/api/utils/errors.ts b/services/frontend/packages/commerce/src/api/utils/errors.ts new file mode 100644 index 00000000..6f9ecce0 --- /dev/null +++ b/services/frontend/packages/commerce/src/api/utils/errors.ts @@ -0,0 +1,22 @@ +import type { Response } from '@vercel/fetch' + +export class CommerceAPIError extends Error { + status: number + res: Response + data: any + + constructor(msg: string, res: Response, data?: any) { + super(msg) + this.name = 'CommerceApiError' + this.status = res.status + this.res = res + this.data = data + } +} + +export class CommerceNetworkError extends Error { + constructor(msg: string) { + super(msg) + this.name = 'CommerceNetworkError' + } +} diff --git a/services/frontend/packages/commerce/src/api/utils/is-allowed-method.ts b/services/frontend/packages/commerce/src/api/utils/is-allowed-method.ts new file mode 100644 index 00000000..51c37e22 --- /dev/null +++ b/services/frontend/packages/commerce/src/api/utils/is-allowed-method.ts @@ -0,0 +1,30 @@ +import type { NextApiRequest, NextApiResponse } from 'next' + +export type HTTP_METHODS = 'OPTIONS' | 'GET' | 'POST' | 'PUT' | 'DELETE' + +export default function isAllowedMethod( + req: NextApiRequest, + res: NextApiResponse, + allowedMethods: HTTP_METHODS[] +) { + const methods = allowedMethods.includes('OPTIONS') + ? allowedMethods + : [...allowedMethods, 'OPTIONS'] + + if (!req.method || !methods.includes(req.method)) { + res.status(405) + res.setHeader('Allow', methods.join(', ')) + res.end() + return false + } + + if (req.method === 'OPTIONS') { + res.status(200) + res.setHeader('Allow', methods.join(', ')) + res.setHeader('Content-Length', '0') + res.end() + return false + } + + return true +} diff --git a/services/frontend/packages/commerce/src/api/utils/is-allowed-operation.ts b/services/frontend/packages/commerce/src/api/utils/is-allowed-operation.ts new file mode 100644 index 00000000..f507781b --- /dev/null +++ b/services/frontend/packages/commerce/src/api/utils/is-allowed-operation.ts @@ -0,0 +1,19 @@ +import type { NextApiRequest, NextApiResponse } from 'next' +import isAllowedMethod, { HTTP_METHODS } from './is-allowed-method' +import { APIHandler } from './types' + +export default function isAllowedOperation( + req: NextApiRequest, + res: NextApiResponse, + allowedOperations: { [k in HTTP_METHODS]?: APIHandler } +) { + const methods = Object.keys(allowedOperations) as HTTP_METHODS[] + const allowedMethods = methods.reduce((arr, method) => { + if (allowedOperations[method]) { + arr.push(method) + } + return arr + }, []) + + return isAllowedMethod(req, res, allowedMethods) +} diff --git a/services/frontend/packages/commerce/src/api/utils/types.ts b/services/frontend/packages/commerce/src/api/utils/types.ts new file mode 100644 index 00000000..27a95df4 --- /dev/null +++ b/services/frontend/packages/commerce/src/api/utils/types.ts @@ -0,0 +1,49 @@ +import type { NextApiRequest, NextApiResponse } from 'next' +import type { CommerceAPI } from '..' + +export type ErrorData = { message: string; code?: string } + +export type APIResponse = + | { data: Data; errors?: ErrorData[] } + // If `data` doesn't include `null`, then `null` is only allowed on errors + | (Data extends null + ? { data: null; errors?: ErrorData[] } + : { data: null; errors: ErrorData[] }) + +export type APIHandlerContext< + C extends CommerceAPI, + H extends APIHandlers = {}, + Data = any, + Options extends {} = {} +> = { + req: NextApiRequest + res: NextApiResponse> + commerce: C + config: C['provider']['config'] + handlers: H + /** + * Custom configs that may be used by a particular handler + */ + options: Options +} + +export type APIHandler< + C extends CommerceAPI, + H extends APIHandlers = {}, + Data = any, + Body = any, + Options extends {} = {} +> = ( + context: APIHandlerContext & { body: Body } +) => void | Promise + +export type APIHandlers = { + [k: string]: APIHandler +} + +export type APIEndpoint< + C extends CommerceAPI = CommerceAPI, + H extends APIHandlers = {}, + Data = any, + Options extends {} = {} +> = (context: APIHandlerContext) => void | Promise diff --git a/services/frontend/packages/commerce/src/auth/use-login.tsx b/services/frontend/packages/commerce/src/auth/use-login.tsx new file mode 100644 index 00000000..67fb429d --- /dev/null +++ b/services/frontend/packages/commerce/src/auth/use-login.tsx @@ -0,0 +1,20 @@ +import { useHook, useMutationHook } from '../utils/use-hook' +import { mutationFetcher } from '../utils/default-fetcher' +import type { MutationHook, HookFetcherFn } from '../utils/types' +import type { LoginHook } from '../types/login' +import type { Provider } from '..' + +export type UseLogin< + H extends MutationHook> = MutationHook +> = ReturnType + +export const fetcher: HookFetcherFn = mutationFetcher + +const fn = (provider: Provider) => provider.auth?.useLogin! + +const useLogin: UseLogin = (...args) => { + const hook = useHook(fn) + return useMutationHook({ fetcher, ...hook })(...args) +} + +export default useLogin diff --git a/services/frontend/packages/commerce/src/auth/use-logout.tsx b/services/frontend/packages/commerce/src/auth/use-logout.tsx new file mode 100644 index 00000000..6ca16dec --- /dev/null +++ b/services/frontend/packages/commerce/src/auth/use-logout.tsx @@ -0,0 +1,20 @@ +import { useHook, useMutationHook } from '../utils/use-hook' +import { mutationFetcher } from '../utils/default-fetcher' +import type { HookFetcherFn, MutationHook } from '../utils/types' +import type { LogoutHook } from '../types/logout' +import type { Provider } from '..' + +export type UseLogout< + H extends MutationHook> = MutationHook +> = ReturnType + +export const fetcher: HookFetcherFn = mutationFetcher + +const fn = (provider: Provider) => provider.auth?.useLogout! + +const useLogout: UseLogout = (...args) => { + const hook = useHook(fn) + return useMutationHook({ fetcher, ...hook })(...args) +} + +export default useLogout diff --git a/services/frontend/packages/commerce/src/auth/use-signup.tsx b/services/frontend/packages/commerce/src/auth/use-signup.tsx new file mode 100644 index 00000000..2f846fad --- /dev/null +++ b/services/frontend/packages/commerce/src/auth/use-signup.tsx @@ -0,0 +1,20 @@ +import { useHook, useMutationHook } from '../utils/use-hook' +import { mutationFetcher } from '../utils/default-fetcher' +import type { HookFetcherFn, MutationHook } from '../utils/types' +import type { SignupHook } from '../types/signup' +import type { Provider } from '..' + +export type UseSignup< + H extends MutationHook> = MutationHook +> = ReturnType + +export const fetcher: HookFetcherFn = mutationFetcher + +const fn = (provider: Provider) => provider.auth?.useSignup! + +const useSignup: UseSignup = (...args) => { + const hook = useHook(fn) + return useMutationHook({ fetcher, ...hook })(...args) +} + +export default useSignup diff --git a/services/frontend/packages/commerce/src/cart/use-add-item.tsx b/services/frontend/packages/commerce/src/cart/use-add-item.tsx new file mode 100644 index 00000000..f4072c76 --- /dev/null +++ b/services/frontend/packages/commerce/src/cart/use-add-item.tsx @@ -0,0 +1,20 @@ +import { useHook, useMutationHook } from '../utils/use-hook' +import { mutationFetcher } from '../utils/default-fetcher' +import type { HookFetcherFn, MutationHook } from '../utils/types' +import type { AddItemHook } from '../types/cart' +import type { Provider } from '..' + +export type UseAddItem< + H extends MutationHook> = MutationHook +> = ReturnType + +export const fetcher: HookFetcherFn = mutationFetcher + +const fn = (provider: Provider) => provider.cart?.useAddItem! + +const useAddItem: UseAddItem = (...args) => { + const hook = useHook(fn) + return useMutationHook({ fetcher, ...hook })(...args) +} + +export default useAddItem diff --git a/services/frontend/packages/commerce/src/cart/use-cart.tsx b/services/frontend/packages/commerce/src/cart/use-cart.tsx new file mode 100644 index 00000000..cfce59e3 --- /dev/null +++ b/services/frontend/packages/commerce/src/cart/use-cart.tsx @@ -0,0 +1,32 @@ +import Cookies from 'js-cookie' +import { useHook, useSWRHook } from '../utils/use-hook' +import type { SWRHook, HookFetcherFn } from '../utils/types' +import type { GetCartHook } from '../types/cart' +import { Provider, useCommerce } from '..' + +export type UseCart< + H extends SWRHook> = SWRHook +> = ReturnType + +export const fetcher: HookFetcherFn = async ({ + options, + input: { cartId }, + fetch, +}) => { + return cartId ? await fetch(options) : null +} + +const fn = (provider: Provider) => provider.cart?.useCart! + +const useCart: UseCart = (input) => { + const hook = useHook(fn) + const { cartCookie } = useCommerce() + const fetcherFn = hook.fetcher ?? fetcher + const wrapper: typeof fetcher = (context) => { + context.input.cartId = Cookies.get(cartCookie) + return fetcherFn(context) + } + return useSWRHook({ ...hook, fetcher: wrapper })(input) +} + +export default useCart diff --git a/services/frontend/packages/commerce/src/cart/use-remove-item.tsx b/services/frontend/packages/commerce/src/cart/use-remove-item.tsx new file mode 100644 index 00000000..f2bb43ff --- /dev/null +++ b/services/frontend/packages/commerce/src/cart/use-remove-item.tsx @@ -0,0 +1,20 @@ +import { useHook, useMutationHook } from '../utils/use-hook' +import { mutationFetcher } from '../utils/default-fetcher' +import type { HookFetcherFn, MutationHook } from '../utils/types' +import type { RemoveItemHook } from '../types/cart' +import type { Provider } from '..' + +export type UseRemoveItem< + H extends MutationHook> = MutationHook +> = ReturnType + +export const fetcher: HookFetcherFn = mutationFetcher + +const fn = (provider: Provider) => provider.cart?.useRemoveItem! + +const useRemoveItem: UseRemoveItem = (input) => { + const hook = useHook(fn) + return useMutationHook({ fetcher, ...hook })(input) +} + +export default useRemoveItem diff --git a/services/frontend/packages/commerce/src/cart/use-update-item.tsx b/services/frontend/packages/commerce/src/cart/use-update-item.tsx new file mode 100644 index 00000000..2527732e --- /dev/null +++ b/services/frontend/packages/commerce/src/cart/use-update-item.tsx @@ -0,0 +1,20 @@ +import { useHook, useMutationHook } from '../utils/use-hook' +import { mutationFetcher } from '../utils/default-fetcher' +import type { HookFetcherFn, MutationHook } from '../utils/types' +import type { UpdateItemHook } from '../types/cart' +import type { Provider } from '..' + +export type UseUpdateItem< + H extends MutationHook> = MutationHook +> = ReturnType + +export const fetcher: HookFetcherFn = mutationFetcher + +const fn = (provider: Provider) => provider.cart?.useUpdateItem! + +const useUpdateItem: UseUpdateItem = (input) => { + const hook = useHook(fn) + return useMutationHook({ fetcher, ...hook })(input) +} + +export default useUpdateItem diff --git a/services/frontend/packages/commerce/src/checkout/use-checkout.ts b/services/frontend/packages/commerce/src/checkout/use-checkout.ts new file mode 100644 index 00000000..0fe74cb2 --- /dev/null +++ b/services/frontend/packages/commerce/src/checkout/use-checkout.ts @@ -0,0 +1,34 @@ +import type { SWRHook, HookFetcherFn } from '../utils/types' +import type { GetCheckoutHook } from '../types/checkout' + +import Cookies from 'js-cookie' + +import { useHook, useSWRHook } from '../utils/use-hook' +import { Provider, useCommerce } from '..' + +export type UseCheckout< + H extends SWRHook> = SWRHook +> = ReturnType + +export const fetcher: HookFetcherFn = async ({ + options, + input: { cartId }, + fetch, +}) => { + return cartId ? await fetch(options) : null +} + +const fn = (provider: Provider) => provider.checkout?.useCheckout! + +const useCheckout: UseCheckout = (input) => { + const hook = useHook(fn) + const { cartCookie } = useCommerce() + const fetcherFn = hook.fetcher ?? fetcher + const wrapper: typeof fetcher = (context) => { + context.input.cartId = Cookies.get(cartCookie) + return fetcherFn(context) + } + return useSWRHook({ ...hook, fetcher: wrapper })(input) +} + +export default useCheckout diff --git a/services/frontend/packages/commerce/src/checkout/use-submit-checkout.tsx b/services/frontend/packages/commerce/src/checkout/use-submit-checkout.tsx new file mode 100644 index 00000000..a5d86500 --- /dev/null +++ b/services/frontend/packages/commerce/src/checkout/use-submit-checkout.tsx @@ -0,0 +1,23 @@ +import type { HookFetcherFn, MutationHook } from '../utils/types' +import type { SubmitCheckoutHook } from '../types/checkout' +import type { Provider } from '..' + +import { useHook, useMutationHook } from '../utils/use-hook' +import { mutationFetcher } from '../utils/default-fetcher' + +export type UseSubmitCheckout< + H extends MutationHook< + SubmitCheckoutHook + > = MutationHook +> = ReturnType + +export const fetcher: HookFetcherFn = mutationFetcher + +const fn = (provider: Provider) => provider.checkout?.useSubmitCheckout! + +const useSubmitCheckout: UseSubmitCheckout = (...args) => { + const hook = useHook(fn) + return useMutationHook({ fetcher, ...hook })(...args) +} + +export default useSubmitCheckout diff --git a/services/frontend/packages/commerce/src/config.cjs b/services/frontend/packages/commerce/src/config.cjs new file mode 100644 index 00000000..3f1ac9ff --- /dev/null +++ b/services/frontend/packages/commerce/src/config.cjs @@ -0,0 +1,35 @@ +/** + * This file is expected to be used in next.config.js only + */ + +const path = require('path') +const merge = require('deepmerge') +const importCwd = require('import-cwd') + +function withCommerceConfig(nextConfig = {}) { + const commerce = nextConfig.commerce || {} + const { provider } = commerce + + if (!provider) { + throw new Error( + `The commerce provider is missing, please add a valid provider name` + ) + } + + const commerceNextConfig = importCwd(path.posix.join(provider, 'next.config')) + const config = merge(nextConfig, commerceNextConfig) + const features = merge( + config.commerce.features, + config.commerce[provider]?.features ?? {} + ) + + config.env = config.env || {} + + Object.entries(features).forEach(([k, v]) => { + if (v) config.env[`COMMERCE_${k.toUpperCase()}_ENABLED`] = true + }) + + return config +} + +module.exports = { withCommerceConfig } diff --git a/services/frontend/packages/commerce/src/customer/address/use-add-item.tsx b/services/frontend/packages/commerce/src/customer/address/use-add-item.tsx new file mode 100644 index 00000000..94c45142 --- /dev/null +++ b/services/frontend/packages/commerce/src/customer/address/use-add-item.tsx @@ -0,0 +1,21 @@ +import type { HookFetcherFn, MutationHook } from '../../utils/types' +import type { AddItemHook } from '../../types/customer/address' +import type { Provider } from '../..' + +import { useHook, useMutationHook } from '../../utils/use-hook' +import { mutationFetcher } from '../../utils/default-fetcher' + +export type UseAddItem< + H extends MutationHook> = MutationHook +> = ReturnType + +export const fetcher: HookFetcherFn = mutationFetcher + +const fn = (provider: Provider) => provider.customer?.address?.useAddItem! + +const useAddItem: UseAddItem = (...args) => { + const hook = useHook(fn) + return useMutationHook({ fetcher, ...hook })(...args) +} + +export default useAddItem diff --git a/services/frontend/packages/commerce/src/customer/address/use-addresses.tsx b/services/frontend/packages/commerce/src/customer/address/use-addresses.tsx new file mode 100644 index 00000000..7fc12924 --- /dev/null +++ b/services/frontend/packages/commerce/src/customer/address/use-addresses.tsx @@ -0,0 +1,34 @@ +import type { SWRHook, HookFetcherFn } from '../../utils/types' +import type { GetAddressesHook } from '../../types/customer/address' + +import Cookies from 'js-cookie' + +import { useHook, useSWRHook } from '../../utils/use-hook' +import { Provider, useCommerce } from '../..' + +export type UseAddresses< + H extends SWRHook> = SWRHook +> = ReturnType + +export const fetcher: HookFetcherFn = async ({ + options, + input: { cartId }, + fetch, +}) => { + return cartId ? await fetch(options) : null +} + +const fn = (provider: Provider) => provider.customer?.address?.useAddresses! + +const useAddresses: UseAddresses = (input) => { + const hook = useHook(fn) + const { cartCookie } = useCommerce() + const fetcherFn = hook.fetcher ?? fetcher + const wrapper: typeof fetcher = (context) => { + context.input.cartId = Cookies.get(cartCookie) + return fetcherFn(context) + } + return useSWRHook({ ...hook, fetcher: wrapper })(input) +} + +export default useAddresses diff --git a/services/frontend/packages/commerce/src/customer/address/use-remove-item.tsx b/services/frontend/packages/commerce/src/customer/address/use-remove-item.tsx new file mode 100644 index 00000000..820a65da --- /dev/null +++ b/services/frontend/packages/commerce/src/customer/address/use-remove-item.tsx @@ -0,0 +1,21 @@ +import type { HookFetcherFn, MutationHook } from '../../utils/types' +import type { RemoveItemHook } from '../../types/customer/address' +import type { Provider } from '../..' + +import { useHook, useMutationHook } from '../../utils/use-hook' +import { mutationFetcher } from '../../utils/default-fetcher' + +export type UseRemoveItem< + H extends MutationHook> = MutationHook +> = ReturnType + +export const fetcher: HookFetcherFn = mutationFetcher + +const fn = (provider: Provider) => provider.customer?.address?.useRemoveItem! + +const useRemoveItem: UseRemoveItem = (input) => { + const hook = useHook(fn) + return useMutationHook({ fetcher, ...hook })(input) +} + +export default useRemoveItem diff --git a/services/frontend/packages/commerce/src/customer/address/use-update-item.tsx b/services/frontend/packages/commerce/src/customer/address/use-update-item.tsx new file mode 100644 index 00000000..d0588229 --- /dev/null +++ b/services/frontend/packages/commerce/src/customer/address/use-update-item.tsx @@ -0,0 +1,21 @@ +import type { HookFetcherFn, MutationHook } from '../../utils/types' +import type { UpdateItemHook } from '../../types/customer/address' +import type { Provider } from '../..' + +import { useHook, useMutationHook } from '../../utils/use-hook' +import { mutationFetcher } from '../../utils/default-fetcher' + +export type UseUpdateItem< + H extends MutationHook> = MutationHook +> = ReturnType + +export const fetcher: HookFetcherFn = mutationFetcher + +const fn = (provider: Provider) => provider.customer?.address?.useUpdateItem! + +const useUpdateItem: UseUpdateItem = (input) => { + const hook = useHook(fn) + return useMutationHook({ fetcher, ...hook })(input) +} + +export default useUpdateItem diff --git a/services/frontend/packages/commerce/src/customer/card/use-add-item.tsx b/services/frontend/packages/commerce/src/customer/card/use-add-item.tsx new file mode 100644 index 00000000..7b4ffdb1 --- /dev/null +++ b/services/frontend/packages/commerce/src/customer/card/use-add-item.tsx @@ -0,0 +1,21 @@ +import type { HookFetcherFn, MutationHook } from '../../utils/types' +import type { AddItemHook } from '../../types/customer/card' +import type { Provider } from '../..' + +import { useHook, useMutationHook } from '../../utils/use-hook' +import { mutationFetcher } from '../../utils/default-fetcher' + +export type UseAddItem< + H extends MutationHook> = MutationHook +> = ReturnType + +export const fetcher: HookFetcherFn = mutationFetcher + +const fn = (provider: Provider) => provider.customer?.card?.useAddItem! + +const useAddItem: UseAddItem = (...args) => { + const hook = useHook(fn) + return useMutationHook({ fetcher, ...hook })(...args) +} + +export default useAddItem diff --git a/services/frontend/packages/commerce/src/customer/card/use-cards.tsx b/services/frontend/packages/commerce/src/customer/card/use-cards.tsx new file mode 100644 index 00000000..57099504 --- /dev/null +++ b/services/frontend/packages/commerce/src/customer/card/use-cards.tsx @@ -0,0 +1,34 @@ +import type { SWRHook, HookFetcherFn } from '../../utils/types' +import type { GetCardsHook } from '../../types/customer/card' + +import Cookies from 'js-cookie' + +import { useHook, useSWRHook } from '../../utils/use-hook' +import { Provider, useCommerce } from '../..' + +export type UseCards< + H extends SWRHook> = SWRHook +> = ReturnType + +export const fetcher: HookFetcherFn = async ({ + options, + input: { cartId }, + fetch, +}) => { + return cartId ? await fetch(options) : null +} + +const fn = (provider: Provider) => provider.customer?.card?.useCards! + +const useCards: UseCards = (input) => { + const hook = useHook(fn) + const { cartCookie } = useCommerce() + const fetcherFn = hook.fetcher ?? fetcher + const wrapper: typeof fetcher = (context) => { + context.input.cartId = Cookies.get(cartCookie) + return fetcherFn(context) + } + return useSWRHook({ ...hook, fetcher: wrapper })(input) +} + +export default useCards diff --git a/services/frontend/packages/commerce/src/customer/card/use-remove-item.tsx b/services/frontend/packages/commerce/src/customer/card/use-remove-item.tsx new file mode 100644 index 00000000..1d85fa63 --- /dev/null +++ b/services/frontend/packages/commerce/src/customer/card/use-remove-item.tsx @@ -0,0 +1,21 @@ +import type { HookFetcherFn, MutationHook } from '../../utils/types' +import type { RemoveItemHook } from '../../types/customer/card' +import type { Provider } from '../..' + +import { useHook, useMutationHook } from '../../utils/use-hook' +import { mutationFetcher } from '../../utils/default-fetcher' + +export type UseRemoveItem< + H extends MutationHook> = MutationHook +> = ReturnType + +export const fetcher: HookFetcherFn = mutationFetcher + +const fn = (provider: Provider) => provider.customer?.card?.useRemoveItem! + +const useRemoveItem: UseRemoveItem = (input) => { + const hook = useHook(fn) + return useMutationHook({ fetcher, ...hook })(input) +} + +export default useRemoveItem diff --git a/services/frontend/packages/commerce/src/customer/card/use-update-item.tsx b/services/frontend/packages/commerce/src/customer/card/use-update-item.tsx new file mode 100644 index 00000000..cd8837d7 --- /dev/null +++ b/services/frontend/packages/commerce/src/customer/card/use-update-item.tsx @@ -0,0 +1,21 @@ +import type { HookFetcherFn, MutationHook } from '../../utils/types' +import type { UpdateItemHook } from '../../types/customer/card' +import type { Provider } from '../..' + +import { useHook, useMutationHook } from '../../utils/use-hook' +import { mutationFetcher } from '../../utils/default-fetcher' + +export type UseUpdateItem< + H extends MutationHook> = MutationHook +> = ReturnType + +export const fetcher: HookFetcherFn = mutationFetcher + +const fn = (provider: Provider) => provider?.customer?.card?.useUpdateItem! + +const useUpdateItem: UseUpdateItem = (input) => { + const hook = useHook(fn) + return useMutationHook({ fetcher, ...hook })(input) +} + +export default useUpdateItem diff --git a/services/frontend/packages/commerce/src/customer/use-customer.tsx b/services/frontend/packages/commerce/src/customer/use-customer.tsx new file mode 100644 index 00000000..bbeeb326 --- /dev/null +++ b/services/frontend/packages/commerce/src/customer/use-customer.tsx @@ -0,0 +1,20 @@ +import { useHook, useSWRHook } from '../utils/use-hook' +import { SWRFetcher } from '../utils/default-fetcher' +import type { CustomerHook } from '../types/customer' +import type { HookFetcherFn, SWRHook } from '../utils/types' +import type { Provider } from '..' + +export type UseCustomer< + H extends SWRHook> = SWRHook +> = ReturnType + +export const fetcher: HookFetcherFn = SWRFetcher + +const fn = (provider: Provider) => provider.customer?.useCustomer! + +const useCustomer: UseCustomer = (input) => { + const hook = useHook(fn) + return useSWRHook({ fetcher, ...hook })(input) +} + +export default useCustomer diff --git a/services/frontend/packages/commerce/src/index.tsx b/services/frontend/packages/commerce/src/index.tsx new file mode 100644 index 00000000..8450f1e3 --- /dev/null +++ b/services/frontend/packages/commerce/src/index.tsx @@ -0,0 +1,123 @@ +import { + ReactNode, + MutableRefObject, + createContext, + useContext, + useMemo, + useRef, +} from 'react' + +import type { + Customer, + Wishlist, + Cart, + Product, + Signup, + Login, + Logout, + Checkout, +} from './types' + +import type { Fetcher, SWRHook, MutationHook } from './utils/types' + +const Commerce = createContext | {}>({}) + +export type Provider = CommerceConfig & { + fetcher: Fetcher + cart?: { + useCart?: SWRHook + useAddItem?: MutationHook + useUpdateItem?: MutationHook + useRemoveItem?: MutationHook + } + checkout?: { + useCheckout?: SWRHook + useSubmitCheckout?: MutationHook + } + wishlist?: { + useWishlist?: SWRHook + useAddItem?: MutationHook + useRemoveItem?: MutationHook + } + customer?: { + useCustomer?: SWRHook + card?: { + useCards?: SWRHook + useAddItem?: MutationHook + useUpdateItem?: MutationHook + useRemoveItem?: MutationHook + } + address?: { + useAddresses?: SWRHook + useAddItem?: MutationHook + useUpdateItem?: MutationHook + useRemoveItem?: MutationHook + } + } + products?: { + useSearch?: SWRHook + } + auth?: { + useSignup?: MutationHook + useLogin?: MutationHook + useLogout?: MutationHook + } +} + +export type CommerceConfig = { + locale: string + cartCookie: string +} + +export type CommerceContextValue

= { + providerRef: MutableRefObject

+ fetcherRef: MutableRefObject +} & CommerceConfig + +export type CommerceProps

= { + children?: ReactNode + provider: P +} + +/** + * These are the properties every provider should allow when implementing + * the core commerce provider + */ +export type CommerceProviderProps = { + children?: ReactNode +} & Partial + +export function CoreCommerceProvider

({ + provider, + children, +}: CommerceProps

) { + const providerRef = useRef(provider) + // TODO: Remove the fetcherRef + const fetcherRef = useRef(provider.fetcher) + // If the parent re-renders this provider will re-render every + // consumer unless we memoize the config + const { locale, cartCookie } = providerRef.current + const cfg = useMemo( + () => ({ providerRef, fetcherRef, locale, cartCookie }), + [locale, cartCookie] + ) + + return {children} +} + +export function getCommerceProvider

(provider: P) { + return function CommerceProvider({ + children, + ...props + }: CommerceProviderProps) { + return ( + + {children} + + ) + } +} + +export function useCommerce

() { + return useContext(Commerce) as CommerceContextValue

+} diff --git a/services/frontend/packages/commerce/src/product/use-price.tsx b/services/frontend/packages/commerce/src/product/use-price.tsx new file mode 100644 index 00000000..9c09e348 --- /dev/null +++ b/services/frontend/packages/commerce/src/product/use-price.tsx @@ -0,0 +1,64 @@ +import { useMemo } from 'react' +import { useCommerce } from '..' + +export function formatPrice({ + amount, + currencyCode, + locale, +}: { + amount: number + currencyCode: string + locale: string +}) { + const formatCurrency = new Intl.NumberFormat(locale, { + style: 'currency', + currency: currencyCode, + }) + + return formatCurrency.format(amount) +} + +export function formatVariantPrice({ + amount, + baseAmount, + currencyCode, + locale, +}: { + baseAmount: number + amount: number + currencyCode: string + locale: string +}) { + const hasDiscount = baseAmount > amount + const formatDiscount = new Intl.NumberFormat(locale, { style: 'percent' }) + const discount = hasDiscount + ? formatDiscount.format((baseAmount - amount) / baseAmount) + : null + + const price = formatPrice({ amount, currencyCode, locale }) + const basePrice = hasDiscount + ? formatPrice({ amount: baseAmount, currencyCode, locale }) + : null + + return { price, basePrice, discount } +} + +export default function usePrice( + data?: { + amount: number + baseAmount?: number + currencyCode: string + } | null +) { + const { amount, baseAmount, currencyCode } = data ?? {} + const { locale } = useCommerce() + const value = useMemo(() => { + if (typeof amount !== 'number' || !currencyCode) return '' + + return baseAmount + ? formatVariantPrice({ amount, baseAmount, currencyCode, locale }) + : formatPrice({ amount, currencyCode, locale }) + }, [amount, baseAmount, currencyCode]) + + return typeof value === 'string' ? { price: value } : value +} diff --git a/services/frontend/packages/commerce/src/product/use-search.tsx b/services/frontend/packages/commerce/src/product/use-search.tsx new file mode 100644 index 00000000..342b49e6 --- /dev/null +++ b/services/frontend/packages/commerce/src/product/use-search.tsx @@ -0,0 +1,20 @@ +import { useHook, useSWRHook } from '../utils/use-hook' +import { SWRFetcher } from '../utils/default-fetcher' +import type { HookFetcherFn, SWRHook } from '../utils/types' +import type { SearchProductsHook } from '../types/product' +import type { Provider } from '..' + +export type UseSearch< + H extends SWRHook> = SWRHook +> = ReturnType + +export const fetcher: HookFetcherFn = SWRFetcher + +const fn = (provider: Provider) => provider.products?.useSearch! + +const useSearch: UseSearch = (input) => { + const hook = useHook(fn) + return useSWRHook({ fetcher, ...hook })(input) +} + +export default useSearch diff --git a/services/frontend/packages/commerce/src/types/cart.ts b/services/frontend/packages/commerce/src/types/cart.ts new file mode 100644 index 00000000..e4af878d --- /dev/null +++ b/services/frontend/packages/commerce/src/types/cart.ts @@ -0,0 +1,177 @@ +import type { Discount, Measurement, Image } from './common' + +export type SelectedOption = { + // The option's id. + id?: string + // The product option’s name. + name: string + /// The product option’s value. + value: string +} + +export type LineItem = { + id: string + variantId: string + productId: string + name: string + quantity: number + discounts: Discount[] + // A human-friendly unique string automatically generated from the product’s name + path: string + variant: ProductVariant + options?: SelectedOption[] +} + +export type ProductVariant = { + id: string + // The SKU (stock keeping unit) associated with the product variant. + sku: string + // The product variant’s title, or the product's name. + name: string + // Whether a customer needs to provide a shipping address when placing + // an order for the product variant. + requiresShipping: boolean + // The product variant’s price after all discounts are applied. + price: number + // Product variant’s price, as quoted by the manufacturer/distributor. + listPrice: number + // Image associated with the product variant. Falls back to the product image + // if no image is available. + image?: Image + // Indicates whether this product variant is in stock. + isInStock?: boolean + // Indicates if the product variant is available for sale. + availableForSale?: boolean + // The variant's weight. If a weight was not explicitly specified on the + // variant this will be the product's weight. + weight?: Measurement + // The variant's height. If a height was not explicitly specified on the + // variant, this will be the product's height. + height?: Measurement + // The variant's width. If a width was not explicitly specified on the + // variant, this will be the product's width. + width?: Measurement + // The variant's depth. If a depth was not explicitly specified on the + // variant, this will be the product's depth. + depth?: Measurement +} + +// Shopping cart, a.k.a Checkout +export type Cart = { + id: string + // ID of the customer to which the cart belongs. + customerId?: string + // The email assigned to this cart + email?: string + // The date and time when the cart was created. + createdAt: string + // The currency used for this cart + currency: { code: string } + // Specifies if taxes are included in the line items. + taxesIncluded: boolean + lineItems: LineItem[] + // The sum of all the prices of all the items in the cart. + // Duties, taxes, shipping and discounts excluded. + lineItemsSubtotalPrice: number + // Price of the cart before duties, shipping and taxes. + subtotalPrice: number + // The sum of all the prices of all the items in the cart. + // Duties, taxes and discounts included. + totalPrice: number + // Discounts that have been applied on the cart. + discounts?: Discount[] +} + +/** + * Base cart item body used for cart mutations + */ +export type CartItemBody = { + variantId: string + productId?: string + quantity?: number +} + +/** + * Hooks schema + */ + +export type CartTypes = { + cart?: Cart + item: LineItem + itemBody: CartItemBody +} + +export type CartHooks = { + getCart: GetCartHook + addItem: AddItemHook + updateItem: UpdateItemHook + removeItem: RemoveItemHook +} + +export type GetCartHook = { + data: T['cart'] | null + input: {} + fetcherInput: { cartId?: string } + swrState: { isEmpty: boolean } +} + +export type AddItemHook = { + data: T['cart'] + input?: T['itemBody'] + fetcherInput: T['itemBody'] + body: { item: T['itemBody'] } + actionInput: T['itemBody'] +} + +export type UpdateItemHook = { + data: T['cart'] | null + input: { item?: T['item']; wait?: number } + fetcherInput: { itemId: string; item: T['itemBody'] } + body: { itemId: string; item: T['itemBody'] } + actionInput: T['itemBody'] & { id: string } +} + +export type RemoveItemHook = { + data: T['cart'] | null + input: { item?: T['item'] } + fetcherInput: { itemId: string } + body: { itemId: string } + actionInput: { id: string } +} + +/** + * API Schema + */ + +export type CartSchema = { + endpoint: { + options: {} + handlers: CartHandlers + } +} + +export type CartHandlers = { + getCart: GetCartHandler + addItem: AddItemHandler + updateItem: UpdateItemHandler + removeItem: RemoveItemHandler +} + +export type GetCartHandler = GetCartHook & { + body: { cartId?: string } +} + +export type AddItemHandler = AddItemHook & { + body: { cartId: string } +} + +export type UpdateItemHandler = + UpdateItemHook & { + data: T['cart'] + body: { cartId: string } + } + +export type RemoveItemHandler = + RemoveItemHook & { + body: { cartId: string } + } diff --git a/services/frontend/packages/commerce/src/types/checkout.ts b/services/frontend/packages/commerce/src/types/checkout.ts new file mode 100644 index 00000000..417604fd --- /dev/null +++ b/services/frontend/packages/commerce/src/types/checkout.ts @@ -0,0 +1,57 @@ +import type { UseSubmitCheckout } from '../checkout/use-submit-checkout' +import type { Address, AddressFields } from './customer/address' +import type { Card, CardFields } from './customer/card' + +// Index +export type Checkout = any + +export type CheckoutTypes = { + card?: Card | CardFields + address?: Address | AddressFields + checkout?: Checkout + hasPayment?: boolean + hasShipping?: boolean +} + +export type SubmitCheckoutHook = { + data: T + input?: T + fetcherInput: T + body: { item: T } + actionInput: T +} + +export type GetCheckoutHook = { + data: T['checkout'] | null + input: {} + fetcherInput: { cartId?: string } + swrState: { isEmpty: boolean } + mutations: { submit: UseSubmitCheckout } +} + +export type CheckoutHooks = { + submitCheckout?: SubmitCheckoutHook + getCheckout: GetCheckoutHook +} + +export type GetCheckoutHandler = + GetCheckoutHook & { + body: { cartId: string } + } + +export type SubmitCheckoutHandler = + SubmitCheckoutHook & { + body: { cartId: string } + } + +export type CheckoutHandlers = { + getCheckout: GetCheckoutHandler + submitCheckout?: SubmitCheckoutHandler +} + +export type CheckoutSchema = { + endpoint: { + options: {} + handlers: CheckoutHandlers + } +} diff --git a/services/frontend/packages/commerce/src/types/common.ts b/services/frontend/packages/commerce/src/types/common.ts new file mode 100644 index 00000000..06908c46 --- /dev/null +++ b/services/frontend/packages/commerce/src/types/common.ts @@ -0,0 +1,16 @@ +export type Discount = { + // The value of the discount, can be an amount or percentage + value: number +} + +export type Measurement = { + value: number + unit: 'KILOGRAMS' | 'GRAMS' | 'POUNDS' | 'OUNCES' +} + +export type Image = { + url: string + altText?: string + width?: number + height?: number +} diff --git a/services/frontend/packages/commerce/src/types/customer/address.ts b/services/frontend/packages/commerce/src/types/customer/address.ts new file mode 100644 index 00000000..8dc6ffc0 --- /dev/null +++ b/services/frontend/packages/commerce/src/types/customer/address.ts @@ -0,0 +1,111 @@ +export interface Address { + id: string + mask: string +} + +export interface AddressFields { + type: string + firstName: string + lastName: string + company: string + streetNumber: string + apartments: string + zipCode: string + city: string + country: string +} + +export type CustomerAddressTypes = { + address?: Address + fields: AddressFields +} + +export type GetAddressesHook< + T extends CustomerAddressTypes = CustomerAddressTypes +> = { + data: T['address'][] | null + input: {} + fetcherInput: { cartId?: string } + swrState: { isEmpty: boolean } +} + +export type AddItemHook = + { + data: T['address'] + input?: T['fields'] + fetcherInput: T['fields'] + body: { item: T['fields'] } + actionInput: T['fields'] + } + +export type UpdateItemHook< + T extends CustomerAddressTypes = CustomerAddressTypes +> = { + data: T['address'] | null + input: { item?: T['fields']; wait?: number } + fetcherInput: { itemId: string; item: T['fields'] } + body: { itemId: string; item: T['fields'] } + actionInput: T['fields'] & { id: string } +} + +export type RemoveItemHook< + T extends CustomerAddressTypes = CustomerAddressTypes +> = { + data: T['address'] | null + input: { item?: T['address'] } + fetcherInput: { itemId: string } + body: { itemId: string } + actionInput: { id: string } +} + +export type CustomerAddressHooks< + T extends CustomerAddressTypes = CustomerAddressTypes +> = { + getAddresses: GetAddressesHook + addItem: AddItemHook + updateItem: UpdateItemHook + removeItem: RemoveItemHook +} + +export type AddressHandler< + T extends CustomerAddressTypes = CustomerAddressTypes +> = GetAddressesHook & { + body: { cartId?: string } +} + +export type AddItemHandler< + T extends CustomerAddressTypes = CustomerAddressTypes +> = AddItemHook & { + body: { cartId: string } +} + +export type UpdateItemHandler< + T extends CustomerAddressTypes = CustomerAddressTypes +> = UpdateItemHook & { + data: T['address'] + body: { cartId: string } +} + +export type RemoveItemHandler< + T extends CustomerAddressTypes = CustomerAddressTypes +> = RemoveItemHook & { + body: { cartId: string } +} + +export type CustomerAddressHandlers< + T extends CustomerAddressTypes = CustomerAddressTypes +> = { + getAddresses: GetAddressesHook + addItem: AddItemHandler + updateItem: UpdateItemHandler + removeItem: RemoveItemHandler +} + +export type CustomerAddressSchema< + T extends CustomerAddressTypes = CustomerAddressTypes +> = { + endpoint: { + options: {} + handlers: CustomerAddressHandlers + } +} diff --git a/services/frontend/packages/commerce/src/types/customer/card.ts b/services/frontend/packages/commerce/src/types/customer/card.ts new file mode 100644 index 00000000..e9b220dc --- /dev/null +++ b/services/frontend/packages/commerce/src/types/customer/card.ts @@ -0,0 +1,102 @@ +export interface Card { + id: string + mask: string + provider: string +} + +export interface CardFields { + cardHolder: string + cardNumber: string + cardExpireDate: string + cardCvc: string + firstName: string + lastName: string + company: string + streetNumber: string + zipCode: string + city: string + country: string +} + +export type CustomerCardTypes = { + card?: Card + fields: CardFields +} + +export type GetCardsHook = { + data: T['card'][] | null + input: {} + fetcherInput: { cartId?: string } + swrState: { isEmpty: boolean } +} + +export type AddItemHook = { + data: T['card'] + input?: T['fields'] + fetcherInput: T['fields'] + body: { item: T['fields'] } + actionInput: T['fields'] +} + +export type UpdateItemHook = { + data: T['card'] | null + input: { item?: T['fields']; wait?: number } + fetcherInput: { itemId: string; item: T['fields'] } + body: { itemId: string; item: T['fields'] } + actionInput: T['fields'] & { id: string } +} + +export type RemoveItemHook = { + data: T['card'] | null + input: { item?: T['card'] } + fetcherInput: { itemId: string } + body: { itemId: string } + actionInput: { id: string } +} + +export type CustomerCardHooks = + { + getCards: GetCardsHook + addItem: AddItemHook + updateItem: UpdateItemHook + removeItem: RemoveItemHook + } + +export type CardsHandler = + GetCardsHook & { + body: { cartId?: string } + } + +export type AddItemHandler = + AddItemHook & { + body: { cartId: string } + } + +export type UpdateItemHandler = + UpdateItemHook & { + data: T['card'] + body: { cartId: string } + } + +export type RemoveItemHandler = + RemoveItemHook & { + body: { cartId: string } + } + +export type CustomerCardHandlers< + T extends CustomerCardTypes = CustomerCardTypes +> = { + getCards: GetCardsHook + addItem: AddItemHandler + updateItem: UpdateItemHandler + removeItem: RemoveItemHandler +} + +export type CustomerCardSchema< + T extends CustomerCardTypes = CustomerCardTypes +> = { + endpoint: { + options: {} + handlers: CustomerCardHandlers + } +} diff --git a/services/frontend/packages/commerce/src/types/customer/index.ts b/services/frontend/packages/commerce/src/types/customer/index.ts new file mode 100644 index 00000000..f0b210f6 --- /dev/null +++ b/services/frontend/packages/commerce/src/types/customer/index.ts @@ -0,0 +1,25 @@ +export * as Card from './card' +export * as Address from './address' + +// TODO: define this type +export type Customer = any + +export type CustomerTypes = { + customer: Customer +} + +export type CustomerHook = { + data: T['customer'] | null + fetchData: { customer: T['customer'] } | null +} + +export type CustomerSchema = { + endpoint: { + options: {} + handlers: { + getLoggedInCustomer: { + data: { customer: T['customer'] } | null + } + } + } +} diff --git a/services/frontend/packages/commerce/src/types/index.ts b/services/frontend/packages/commerce/src/types/index.ts new file mode 100644 index 00000000..7ab0b7f6 --- /dev/null +++ b/services/frontend/packages/commerce/src/types/index.ts @@ -0,0 +1,25 @@ +import * as Cart from './cart' +import * as Checkout from './checkout' +import * as Common from './common' +import * as Customer from './customer' +import * as Login from './login' +import * as Logout from './logout' +import * as Page from './page' +import * as Product from './product' +import * as Signup from './signup' +import * as Site from './site' +import * as Wishlist from './wishlist' + +export type { + Cart, + Checkout, + Common, + Customer, + Login, + Logout, + Page, + Product, + Signup, + Site, + Wishlist, +} diff --git a/services/frontend/packages/commerce/src/types/login.ts b/services/frontend/packages/commerce/src/types/login.ts new file mode 100644 index 00000000..b6ef228e --- /dev/null +++ b/services/frontend/packages/commerce/src/types/login.ts @@ -0,0 +1,29 @@ +export type LoginBody = { + email: string + password: string +} + +export type LoginTypes = { + body: LoginBody +} + +export type LoginHook = { + data: null + actionInput: LoginBody + fetcherInput: LoginBody + body: T['body'] +} + +export type LoginSchema = { + endpoint: { + options: {} + handlers: { + login: LoginHook + } + } +} + +export type LoginOperation = { + data: { result?: string } + variables: unknown +} diff --git a/services/frontend/packages/commerce/src/types/logout.ts b/services/frontend/packages/commerce/src/types/logout.ts new file mode 100644 index 00000000..a7240052 --- /dev/null +++ b/services/frontend/packages/commerce/src/types/logout.ts @@ -0,0 +1,17 @@ +export type LogoutTypes = { + body: { redirectTo?: string } +} + +export type LogoutHook = { + data: null + body: T['body'] +} + +export type LogoutSchema = { + endpoint: { + options: {} + handlers: { + logout: LogoutHook + } + } +} diff --git a/services/frontend/packages/commerce/src/types/page.ts b/services/frontend/packages/commerce/src/types/page.ts new file mode 100644 index 00000000..89f82c1a --- /dev/null +++ b/services/frontend/packages/commerce/src/types/page.ts @@ -0,0 +1,28 @@ +// TODO: define this type +export type Page = { + // ID of the Web page. + id: string + // Page name, as displayed on the storefront. + name: string + // Relative URL on the storefront for this page. + url?: string + // HTML or variable that populates this page’s `` element, in default/desktop view. Required in POST if page type is `raw`. + body: string + // If true, this page appears in the storefront’s navigation menu. + is_visible?: boolean + // Order in which this page should display on the storefront. (Lower integers specify earlier display.) + sort_order?: number +} + +export type PageTypes = { + page: Page +} + +export type GetAllPagesOperation = { + data: { pages: T['page'][] } +} + +export type GetPageOperation = { + data: { page?: T['page'] } + variables: { id: string } +} diff --git a/services/frontend/packages/commerce/src/types/product.ts b/services/frontend/packages/commerce/src/types/product.ts new file mode 100644 index 00000000..fb48ba00 --- /dev/null +++ b/services/frontend/packages/commerce/src/types/product.ts @@ -0,0 +1,99 @@ +export type ProductImage = { + url: string + alt?: string +} + +export type ProductPrice = { + value: number + currencyCode?: 'USD' | 'EUR' | 'ARS' | 'GBP' | string + retailPrice?: number + salePrice?: number + listPrice?: number + extendedSalePrice?: number + extendedListPrice?: number +} + +export type ProductOption = { + __typename?: 'MultipleChoiceOption' + id: string + displayName: string + values: ProductOptionValues[] +} + +export type ProductOptionValues = { + label: string + hexColors?: string[] +} + +export type ProductVariant = { + id: string | number + options: ProductOption[] + availableForSale?: boolean +} + +export type Product = { + id: string + name: string + description: string + descriptionHtml?: string + sku?: string + slug?: string + path?: string + images: ProductImage[] + variants: ProductVariant[] + price: ProductPrice + options: ProductOption[] + vendor?: string +} + +export type SearchProductsBody = { + search?: string + categoryId?: string | number + brandId?: string | number + sort?: string + locale?: string +} + +export type ProductTypes = { + product: Product + searchBody: SearchProductsBody +} + +export type SearchProductsHook = { + data: { + products: T['product'][] + found: boolean + } + body: T['searchBody'] + input: T['searchBody'] + fetcherInput: T['searchBody'] +} + +export type ProductsSchema = { + endpoint: { + options: {} + handlers: { + getProducts: SearchProductsHook + } + } +} + +export type GetAllProductPathsOperation = + { + data: { products: Pick[] } + variables: { first?: number } + } + +export type GetAllProductsOperation = { + data: { products: T['product'][] } + variables: { + relevance?: 'featured' | 'best_selling' | 'newest' + ids?: string[] + first?: number + } +} + +export type GetProductOperation = { + data: { product?: T['product'] } + variables: { path: string; slug?: never } | { path?: never; slug: string } +} diff --git a/services/frontend/packages/commerce/src/types/signup.ts b/services/frontend/packages/commerce/src/types/signup.ts new file mode 100644 index 00000000..4e23da6c --- /dev/null +++ b/services/frontend/packages/commerce/src/types/signup.ts @@ -0,0 +1,26 @@ +export type SignupBody = { + firstName: string + lastName: string + email: string + password: string +} + +export type SignupTypes = { + body: SignupBody +} + +export type SignupHook = { + data: null + body: T['body'] + actionInput: T['body'] + fetcherInput: T['body'] +} + +export type SignupSchema = { + endpoint: { + options: {} + handlers: { + signup: SignupHook + } + } +} diff --git a/services/frontend/packages/commerce/src/types/site.ts b/services/frontend/packages/commerce/src/types/site.ts new file mode 100644 index 00000000..73c7dddd --- /dev/null +++ b/services/frontend/packages/commerce/src/types/site.ts @@ -0,0 +1,20 @@ +export type Category = { + id: string + name: string + slug: string + path: string +} + +export type Brand = any + +export type SiteTypes = { + category: Category + brand: Brand +} + +export type GetSiteInfoOperation = { + data: { + categories: T['category'][] + brands: T['brand'][] + } +} diff --git a/services/frontend/packages/commerce/src/types/wishlist.ts b/services/frontend/packages/commerce/src/types/wishlist.ts new file mode 100644 index 00000000..b3759849 --- /dev/null +++ b/services/frontend/packages/commerce/src/types/wishlist.ts @@ -0,0 +1,60 @@ +// TODO: define this type +export type Wishlist = any + +export type WishlistItemBody = { + variantId: string | number + productId: string +} + +export type WishlistTypes = { + wishlist: Wishlist + itemBody: WishlistItemBody +} + +export type GetWishlistHook = { + data: T['wishlist'] | null + body: { includeProducts?: boolean } + input: { includeProducts?: boolean } + fetcherInput: { customerId: string; includeProducts?: boolean } + swrState: { isEmpty: boolean } +} + +export type AddItemHook = { + data: T['wishlist'] + body: { item: T['itemBody'] } + fetcherInput: { item: T['itemBody'] } + actionInput: T['itemBody'] +} + +export type RemoveItemHook = { + data: T['wishlist'] | null + body: { itemId: string } + fetcherInput: { itemId: string } + actionInput: { id: string } + input: { wishlist?: { includeProducts?: boolean } } +} + +export type WishlistSchema = { + endpoint: { + options: {} + handlers: { + getWishlist: GetWishlistHook & { + data: T['wishlist'] | null + body: { customerToken?: string } + } + addItem: AddItemHook & { + body: { customerToken?: string } + } + removeItem: RemoveItemHook & { + body: { customerToken?: string } + } + } + } +} + +export type GetCustomerWishlistOperation< + T extends WishlistTypes = WishlistTypes +> = { + data: { wishlist?: T['wishlist'] } + variables: { customerId: string } +} diff --git a/services/frontend/packages/commerce/src/utils/default-fetcher.ts b/services/frontend/packages/commerce/src/utils/default-fetcher.ts new file mode 100644 index 00000000..53312fc9 --- /dev/null +++ b/services/frontend/packages/commerce/src/utils/default-fetcher.ts @@ -0,0 +1,12 @@ +import type { HookFetcherFn } from './types' + +export const SWRFetcher: HookFetcherFn = ({ options, fetch }) => + fetch(options) + +export const mutationFetcher: HookFetcherFn = ({ + input, + options, + fetch, +}) => fetch({ ...options, body: input }) + +export default SWRFetcher diff --git a/services/frontend/packages/commerce/src/utils/define-property.ts b/services/frontend/packages/commerce/src/utils/define-property.ts new file mode 100644 index 00000000..e8973522 --- /dev/null +++ b/services/frontend/packages/commerce/src/utils/define-property.ts @@ -0,0 +1,37 @@ +// Taken from https://fettblog.eu/typescript-assertion-signatures/ + +type InferValue = Desc extends { + get(): any + value: any +} + ? never + : Desc extends { value: infer T } + ? Record + : Desc extends { get(): infer T } + ? Record + : never + +type DefineProperty< + Prop extends PropertyKey, + Desc extends PropertyDescriptor +> = Desc extends { writable: any; set(val: any): any } + ? never + : Desc extends { writable: any; get(): any } + ? never + : Desc extends { writable: false } + ? Readonly> + : Desc extends { writable: true } + ? InferValue + : Readonly> + +export default function defineProperty< + Obj extends object, + Key extends PropertyKey, + PDesc extends PropertyDescriptor +>( + obj: Obj, + prop: Key, + val: PDesc +): asserts obj is Obj & DefineProperty { + Object.defineProperty(obj, prop, val) +} diff --git a/services/frontend/packages/commerce/src/utils/errors.ts b/services/frontend/packages/commerce/src/utils/errors.ts new file mode 100644 index 00000000..f4ab9fb9 --- /dev/null +++ b/services/frontend/packages/commerce/src/utils/errors.ts @@ -0,0 +1,48 @@ +export type ErrorData = { + message: string + code?: string +} + +export type ErrorProps = { + code?: string +} & ( + | { message: string; errors?: never } + | { message?: never; errors: ErrorData[] } +) + +export class CommerceError extends Error { + code?: string + errors: ErrorData[] + + constructor({ message, code, errors }: ErrorProps) { + const error: ErrorData = message + ? { message, ...(code ? { code } : {}) } + : errors![0] + + super(error.message) + this.errors = message ? [error] : errors! + + if (error.code) this.code = error.code + } +} + +// Used for errors that come from a bad implementation of the hooks +export class ValidationError extends CommerceError { + constructor(options: ErrorProps) { + super(options) + this.code = 'validation_error' + } +} + +export class FetcherError extends CommerceError { + status: number + + constructor( + options: { + status: number + } & ErrorProps + ) { + super(options) + this.status = options.status + } +} diff --git a/services/frontend/packages/commerce/src/utils/types.ts b/services/frontend/packages/commerce/src/utils/types.ts new file mode 100644 index 00000000..317fea16 --- /dev/null +++ b/services/frontend/packages/commerce/src/utils/types.ts @@ -0,0 +1,147 @@ +import type { SWRConfiguration } from 'swr' +import type { CommerceError } from './errors' +import type { ResponseState } from './use-data' + +/** + * Returns the properties in T with the properties in type K, overriding properties defined in T + */ +export type Override = Omit & K + +/** + * Returns the properties in T with the properties in type K changed from optional to required + */ +export type PickRequired = Omit & { + [P in K]-?: NonNullable +} + +/** + * Core fetcher added by CommerceProvider + */ +export type Fetcher = ( + options: FetcherOptions +) => T | Promise + +export type FetcherOptions = { + url?: string + query?: string + method?: string + variables?: any + body?: Body +} + +export type HookFetcher = ( + options: HookFetcherOptions | null, + input: Input, + fetch: (options: FetcherOptions) => Promise +) => Data | Promise + +export type HookFetcherFn = ( + context: HookFetcherContext +) => H['data'] | Promise + +export type HookFetcherContext = { + options: HookFetcherOptions + input: H['fetcherInput'] + fetch: < + T = H['fetchData'] extends {} | null ? H['fetchData'] : any, + B = H['body'] + >( + options: FetcherOptions + ) => Promise +} + +export type HookFetcherOptions = { method?: string } & ( + | { query: string; url?: string } + | { query?: string; url: string } +) + +export type HookInputValue = string | number | boolean | undefined + +export type HookSWRInput = [string, HookInputValue][] + +export type HookFetchInput = { [k: string]: HookInputValue } + +export type HookFunction< + Input extends { [k: string]: unknown } | undefined, + T +> = keyof Input extends never + ? () => T + : Partial extends Input + ? (input?: Input) => T + : (input: Input) => T + +export type HookSchemaBase = { + // Data obj returned by the hook + data: any + // Input expected by the hook + input?: {} + // Input expected before doing a fetch operation (aka fetch handler) + fetcherInput?: {} + // Body object expected by the fetch operation + body?: {} + // Data returned by the fetch operation + fetchData?: any +} + +export type SWRHookSchemaBase = HookSchemaBase & { + // Custom state added to the response object of SWR + swrState?: {} + // Instances of MutationSchemaBase that the hook returns for better DX + mutations?: Record['useHook']>> +} + +export type MutationSchemaBase = HookSchemaBase & { + // Input expected by the action returned by the hook + actionInput?: {} +} + +/** + * Generates a SWR hook handler based on the schema of a hook + */ +export type SWRHook = { + useHook( + context: SWRHookContext + ): HookFunction< + H['input'] & { swrOptions?: SwrOptions }, + ResponseState & H['swrState'] & H['mutations'] + > + fetchOptions: HookFetcherOptions + fetcher?: HookFetcherFn +} + +export type SWRHookContext = { + useData(context?: { + input?: HookFetchInput | HookSWRInput + swrOptions?: SwrOptions + }): ResponseState +} + +/** + * Generates a mutation hook handler based on the schema of a hook + */ +export type MutationHook = { + useHook( + context: MutationHookContext + ): HookFunction< + H['input'], + HookFunction> + > + fetchOptions: HookFetcherOptions + fetcher?: HookFetcherFn +} + +export type MutationHookContext = { + fetch: keyof H['fetcherInput'] extends never + ? () => H['data'] | Promise + : Partial extends H['fetcherInput'] + ? (context?: { + input?: H['fetcherInput'] + }) => H['data'] | Promise + : (context: { input: H['fetcherInput'] }) => H['data'] | Promise +} + +export type SwrOptions = SWRConfiguration< + Data, + CommerceError, + HookFetcher +> diff --git a/services/frontend/packages/commerce/src/utils/use-data.tsx b/services/frontend/packages/commerce/src/utils/use-data.tsx new file mode 100644 index 00000000..fedd14e5 --- /dev/null +++ b/services/frontend/packages/commerce/src/utils/use-data.tsx @@ -0,0 +1,78 @@ +import useSWR, { SWRResponse } from 'swr' +import type { + HookSWRInput, + HookFetchInput, + HookFetcherOptions, + HookFetcherFn, + Fetcher, + SwrOptions, + SWRHookSchemaBase, +} from './types' +import defineProperty from './define-property' +import { CommerceError } from './errors' + +export type ResponseState = SWRResponse & { + isLoading: boolean +} + +export type UseData = ( + options: { + fetchOptions: HookFetcherOptions + fetcher: HookFetcherFn + }, + input: HookFetchInput | HookSWRInput, + fetcherFn: Fetcher, + swrOptions?: SwrOptions +) => ResponseState + +const useData: UseData = (options, input, fetcherFn, swrOptions) => { + const hookInput = Array.isArray(input) ? input : Object.entries(input) + const fetcher = async ( + url: string, + query?: string, + method?: string, + ...args: any[] + ) => { + try { + return await options.fetcher({ + options: { url, query, method }, + // Transform the input array into an object + input: args.reduce((obj, val, i) => { + obj[hookInput[i][0]!] = val + return obj + }, {}), + fetch: fetcherFn, + }) + } catch (error) { + // SWR will not log errors, but any error that's not an instance + // of CommerceError is not welcomed by this hook + if (!(error instanceof CommerceError)) { + console.error(error) + } + throw error + } + } + const response = useSWR( + () => { + const opts = options.fetchOptions + return opts + ? [opts.url, opts.query, opts.method, ...hookInput.map((e) => e[1])] + : null + }, + fetcher, + swrOptions + ) + + if (!('isLoading' in response)) { + defineProperty(response, 'isLoading', { + get() { + return response.data === undefined + }, + enumerable: true, + }) + } + + return response as typeof response & { isLoading: boolean } +} + +export default useData diff --git a/services/frontend/packages/commerce/src/utils/use-hook.ts b/services/frontend/packages/commerce/src/utils/use-hook.ts new file mode 100644 index 00000000..1bf0779c --- /dev/null +++ b/services/frontend/packages/commerce/src/utils/use-hook.ts @@ -0,0 +1,50 @@ +import { useCallback } from 'react' +import { Provider, useCommerce } from '..' +import type { MutationHook, PickRequired, SWRHook } from './types' +import useData from './use-data' + +export function useFetcher() { + const { providerRef, fetcherRef } = useCommerce() + return providerRef.current.fetcher ?? fetcherRef.current +} + +export function useHook< + P extends Provider, + H extends MutationHook | SWRHook +>(fn: (provider: P) => H) { + const { providerRef } = useCommerce

() + const provider = providerRef.current + return fn(provider) +} + +export function useSWRHook>( + hook: PickRequired +) { + const fetcher = useFetcher() + + return hook.useHook({ + useData(ctx) { + const response = useData(hook, ctx?.input ?? [], fetcher, ctx?.swrOptions) + return response + }, + }) +} + +export function useMutationHook>( + hook: PickRequired +) { + const fetcher = useFetcher() + + return hook.useHook({ + fetch: useCallback( + ({ input } = {}) => { + return hook.fetcher({ + input, + options: hook.fetchOptions, + fetch: fetcher, + }) + }, + [fetcher, hook.fetchOptions] + ), + }) +} diff --git a/services/frontend/packages/commerce/src/wishlist/index.ts b/services/frontend/packages/commerce/src/wishlist/index.ts new file mode 100644 index 00000000..241af3c7 --- /dev/null +++ b/services/frontend/packages/commerce/src/wishlist/index.ts @@ -0,0 +1,3 @@ +export { default as useAddItem } from './use-add-item' +export { default as useWishlist } from './use-wishlist' +export { default as useRemoveItem } from './use-remove-item' diff --git a/services/frontend/packages/commerce/src/wishlist/use-add-item.tsx b/services/frontend/packages/commerce/src/wishlist/use-add-item.tsx new file mode 100644 index 00000000..f464be1c --- /dev/null +++ b/services/frontend/packages/commerce/src/wishlist/use-add-item.tsx @@ -0,0 +1,20 @@ +import { useHook, useMutationHook } from '../utils/use-hook' +import { mutationFetcher } from '../utils/default-fetcher' +import type { MutationHook } from '../utils/types' +import type { AddItemHook } from '../types/wishlist' +import type { Provider } from '..' + +export type UseAddItem< + H extends MutationHook> = MutationHook +> = ReturnType + +export const fetcher = mutationFetcher + +const fn = (provider: Provider) => provider.wishlist?.useAddItem! + +const useAddItem: UseAddItem = (...args) => { + const hook = useHook(fn) + return useMutationHook({ fetcher, ...hook })(...args) +} + +export default useAddItem diff --git a/services/frontend/packages/commerce/src/wishlist/use-remove-item.tsx b/services/frontend/packages/commerce/src/wishlist/use-remove-item.tsx new file mode 100644 index 00000000..4419c17a --- /dev/null +++ b/services/frontend/packages/commerce/src/wishlist/use-remove-item.tsx @@ -0,0 +1,20 @@ +import { useHook, useMutationHook } from '../utils/use-hook' +import { mutationFetcher } from '../utils/default-fetcher' +import type { HookFetcherFn, MutationHook } from '../utils/types' +import type { RemoveItemHook } from '../types/wishlist' +import type { Provider } from '..' + +export type UseRemoveItem< + H extends MutationHook> = MutationHook +> = ReturnType + +export const fetcher: HookFetcherFn = mutationFetcher + +const fn = (provider: Provider) => provider.wishlist?.useRemoveItem! + +const useRemoveItem: UseRemoveItem = (...args) => { + const hook = useHook(fn) + return useMutationHook({ fetcher, ...hook })(...args) +} + +export default useRemoveItem diff --git a/services/frontend/packages/commerce/src/wishlist/use-wishlist.tsx b/services/frontend/packages/commerce/src/wishlist/use-wishlist.tsx new file mode 100644 index 00000000..672203f7 --- /dev/null +++ b/services/frontend/packages/commerce/src/wishlist/use-wishlist.tsx @@ -0,0 +1,20 @@ +import { useHook, useSWRHook } from '../utils/use-hook' +import { SWRFetcher } from '../utils/default-fetcher' +import type { HookFetcherFn, SWRHook } from '../utils/types' +import type { GetWishlistHook } from '../types/wishlist' +import type { Provider } from '..' + +export type UseWishlist< + H extends SWRHook> = SWRHook +> = ReturnType + +export const fetcher: HookFetcherFn = SWRFetcher + +const fn = (provider: Provider) => provider.wishlist?.useWishlist! + +const useWishlist: UseWishlist = (...args) => { + const hook = useHook(fn) + return useSWRHook({ fetcher, ...hook })(...args) +} + +export default useWishlist diff --git a/services/frontend/packages/commerce/taskfile.js b/services/frontend/packages/commerce/taskfile.js new file mode 100644 index 00000000..39b1b2a8 --- /dev/null +++ b/services/frontend/packages/commerce/taskfile.js @@ -0,0 +1,20 @@ +export async function build(task, opts) { + await task + .source('src/**/*.+(ts|tsx|js)') + .swc({ dev: opts.dev, outDir: 'dist', baseUrl: 'src' }) + .target('dist') + .source('src/**/*.+(cjs|json)') + .target('dist') + task.$.log('Compiled src files') +} + +export async function release(task) { + await task.clear('dist').start('build') +} + +export default async function dev(task) { + const opts = { dev: true } + await task.clear('dist') + await task.start('build', opts) + await task.watch('src/**/*.+(ts|tsx|js|cjs|json)', 'build', opts) +} diff --git a/services/frontend/packages/commerce/tsconfig.json b/services/frontend/packages/commerce/tsconfig.json new file mode 100644 index 00000000..cd04ab2f --- /dev/null +++ b/services/frontend/packages/commerce/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "outDir": "dist", + "baseUrl": "src", + "lib": ["dom", "dom.iterable", "esnext"], + "declaration": true, + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "incremental": true, + "jsx": "react-jsx" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/services/frontend/packages/local/.env.template b/services/frontend/packages/local/.env.template new file mode 100644 index 00000000..a6272a10 --- /dev/null +++ b/services/frontend/packages/local/.env.template @@ -0,0 +1 @@ +COMMERCE_PROVIDER=@vercel/commerce-local \ No newline at end of file diff --git a/services/frontend/packages/local/.prettierignore b/services/frontend/packages/local/.prettierignore new file mode 100644 index 00000000..f06235c4 --- /dev/null +++ b/services/frontend/packages/local/.prettierignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/services/frontend/packages/local/.prettierrc b/services/frontend/packages/local/.prettierrc new file mode 100644 index 00000000..e1076edf --- /dev/null +++ b/services/frontend/packages/local/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": false, + "singleQuote": true, + "tabWidth": 2, + "useTabs": false +} diff --git a/services/frontend/packages/local/README.md b/services/frontend/packages/local/README.md new file mode 100644 index 00000000..a3bc1db3 --- /dev/null +++ b/services/frontend/packages/local/README.md @@ -0,0 +1 @@ +# Next.js Local Provider diff --git a/services/frontend/packages/local/package.json b/services/frontend/packages/local/package.json new file mode 100644 index 00000000..3ec3e69a --- /dev/null +++ b/services/frontend/packages/local/package.json @@ -0,0 +1,79 @@ +{ + "name": "@vercel/commerce-local", + "version": "0.0.1", + "license": "MIT", + "scripts": { + "release": "taskr release", + "build": "taskr build", + "dev": "taskr", + "types": "tsc --emitDeclarationOnly", + "prettier-fix": "prettier --write ." + }, + "sideEffects": false, + "type": "module", + "exports": { + ".": "./dist/index.js", + "./*": [ + "./dist/*.js", + "./dist/*/index.js" + ], + "./next.config": "./dist/next.config.cjs" + }, + "typesVersions": { + "*": { + "*": [ + "src/*", + "src/*/index" + ], + "next.config": [ + "dist/next.config.d.cts" + ] + } + }, + "files": [ + "dist" + ], + "publishConfig": { + "typesVersions": { + "*": { + "*": [ + "dist/*.d.ts", + "dist/*/index.d.ts" + ], + "next.config": [ + "dist/next.config.d.cts" + ] + } + } + }, + "dependencies": { + "@vercel/commerce": "^0.0.1", + "@vercel/fetch": "^6.1.1" + }, + "peerDependencies": { + "next": "^12", + "react": "^17", + "react-dom": "^17" + }, + "devDependencies": { + "@taskr/clear": "^1.1.0", + "@taskr/esnext": "^1.1.0", + "@taskr/watch": "^1.1.0", + "@types/node": "^17.0.8", + "@types/react": "^17.0.38", + "lint-staged": "^12.1.7", + "next": "^12.0.8", + "prettier": "^2.5.1", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "taskr": "^1.1.0", + "taskr-swc": "^0.0.1", + "typescript": "^4.5.4" + }, + "lint-staged": { + "**/*.{js,jsx,ts,tsx,json}": [ + "prettier --write", + "git add" + ] + } +} diff --git a/services/frontend/packages/local/src/api/endpoints/cart/index.ts b/services/frontend/packages/local/src/api/endpoints/cart/index.ts new file mode 100644 index 00000000..491bf0ac --- /dev/null +++ b/services/frontend/packages/local/src/api/endpoints/cart/index.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/services/frontend/packages/local/src/api/endpoints/catalog/index.ts b/services/frontend/packages/local/src/api/endpoints/catalog/index.ts new file mode 100644 index 00000000..491bf0ac --- /dev/null +++ b/services/frontend/packages/local/src/api/endpoints/catalog/index.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/services/frontend/packages/local/src/api/endpoints/catalog/products.ts b/services/frontend/packages/local/src/api/endpoints/catalog/products.ts new file mode 100644 index 00000000..491bf0ac --- /dev/null +++ b/services/frontend/packages/local/src/api/endpoints/catalog/products.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/services/frontend/packages/local/src/api/endpoints/checkout/index.ts b/services/frontend/packages/local/src/api/endpoints/checkout/index.ts new file mode 100644 index 00000000..491bf0ac --- /dev/null +++ b/services/frontend/packages/local/src/api/endpoints/checkout/index.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/services/frontend/packages/local/src/api/endpoints/customer/address.ts b/services/frontend/packages/local/src/api/endpoints/customer/address.ts new file mode 100644 index 00000000..491bf0ac --- /dev/null +++ b/services/frontend/packages/local/src/api/endpoints/customer/address.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/services/frontend/packages/local/src/api/endpoints/customer/card.ts b/services/frontend/packages/local/src/api/endpoints/customer/card.ts new file mode 100644 index 00000000..491bf0ac --- /dev/null +++ b/services/frontend/packages/local/src/api/endpoints/customer/card.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/services/frontend/packages/local/src/api/endpoints/customer/index.ts b/services/frontend/packages/local/src/api/endpoints/customer/index.ts new file mode 100644 index 00000000..491bf0ac --- /dev/null +++ b/services/frontend/packages/local/src/api/endpoints/customer/index.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/services/frontend/packages/local/src/api/endpoints/login/index.ts b/services/frontend/packages/local/src/api/endpoints/login/index.ts new file mode 100644 index 00000000..491bf0ac --- /dev/null +++ b/services/frontend/packages/local/src/api/endpoints/login/index.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/services/frontend/packages/local/src/api/endpoints/logout/index.ts b/services/frontend/packages/local/src/api/endpoints/logout/index.ts new file mode 100644 index 00000000..491bf0ac --- /dev/null +++ b/services/frontend/packages/local/src/api/endpoints/logout/index.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/services/frontend/packages/local/src/api/endpoints/signup/index.ts b/services/frontend/packages/local/src/api/endpoints/signup/index.ts new file mode 100644 index 00000000..491bf0ac --- /dev/null +++ b/services/frontend/packages/local/src/api/endpoints/signup/index.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/services/frontend/packages/local/src/api/endpoints/wishlist/index.tsx b/services/frontend/packages/local/src/api/endpoints/wishlist/index.tsx new file mode 100644 index 00000000..491bf0ac --- /dev/null +++ b/services/frontend/packages/local/src/api/endpoints/wishlist/index.tsx @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/services/frontend/packages/local/src/api/index.ts b/services/frontend/packages/local/src/api/index.ts new file mode 100644 index 00000000..3b24a304 --- /dev/null +++ b/services/frontend/packages/local/src/api/index.ts @@ -0,0 +1,42 @@ +import type { CommerceAPI, CommerceAPIConfig } from '@vercel/commerce/api' +import { getCommerceApi as commerceApi } from '@vercel/commerce/api' +import createFetcher from './utils/fetch-local' + +import getAllPages from './operations/get-all-pages' +import getPage from './operations/get-page' +import getSiteInfo from './operations/get-site-info' +import getCustomerWishlist from './operations/get-customer-wishlist' +import getAllProductPaths from './operations/get-all-product-paths' +import getAllProducts from './operations/get-all-products' +import getProduct from './operations/get-product' + +export interface LocalConfig extends CommerceAPIConfig {} +const config: LocalConfig = { + commerceUrl: '', + apiToken: '', + cartCookie: '', + customerCookie: '', + cartCookieMaxAge: 2592000, + fetch: createFetcher(() => getCommerceApi().getConfig()), +} + +const operations = { + getAllPages, + getPage, + getSiteInfo, + getCustomerWishlist, + getAllProductPaths, + getAllProducts, + getProduct, +} + +export const provider = { config, operations } + +export type Provider = typeof provider +export type LocalAPI

= CommerceAPI

+ +export function getCommerceApi

( + customProvider: P = provider as any +): LocalAPI

{ + return commerceApi(customProvider as any) +} diff --git a/services/frontend/packages/local/src/api/operations/get-all-pages.ts b/services/frontend/packages/local/src/api/operations/get-all-pages.ts new file mode 100644 index 00000000..b258fe70 --- /dev/null +++ b/services/frontend/packages/local/src/api/operations/get-all-pages.ts @@ -0,0 +1,19 @@ +export type Page = { url: string } +export type GetAllPagesResult = { pages: Page[] } +import type { LocalConfig } from '../index' + +export default function getAllPagesOperation() { + function getAllPages({ + config, + preview, + }: { + url?: string + config?: Partial + preview?: boolean + }): Promise { + return Promise.resolve({ + pages: [], + }) + } + return getAllPages +} diff --git a/services/frontend/packages/local/src/api/operations/get-all-product-paths.ts b/services/frontend/packages/local/src/api/operations/get-all-product-paths.ts new file mode 100644 index 00000000..fff24e79 --- /dev/null +++ b/services/frontend/packages/local/src/api/operations/get-all-product-paths.ts @@ -0,0 +1,15 @@ +import data from '../../data.json' + +export type GetAllProductPathsResult = { + products: Array<{ path: string }> +} + +export default function getAllProductPathsOperation() { + function getAllProductPaths(): Promise { + return Promise.resolve({ + products: data.products.map(({ path }) => ({ path })), + }) + } + + return getAllProductPaths +} diff --git a/services/frontend/packages/local/src/api/operations/get-all-products.ts b/services/frontend/packages/local/src/api/operations/get-all-products.ts new file mode 100644 index 00000000..2d11950c --- /dev/null +++ b/services/frontend/packages/local/src/api/operations/get-all-products.ts @@ -0,0 +1,25 @@ +import { Product } from '@vercel/commerce/types/product' +import { GetAllProductsOperation } from '@vercel/commerce/types/product' +import type { OperationContext } from '@vercel/commerce/api/operations' +import type { LocalConfig, Provider } from '../index' +import data from '../../data.json' + +export default function getAllProductsOperation({ + commerce, +}: OperationContext) { + async function getAllProducts({ + query = '', + variables, + config, + }: { + query?: string + variables?: T['variables'] + config?: Partial + preview?: boolean + } = {}): Promise<{ products: Product[] | any[] }> { + return { + products: data.products, + } + } + return getAllProducts +} diff --git a/services/frontend/packages/local/src/api/operations/get-customer-wishlist.ts b/services/frontend/packages/local/src/api/operations/get-customer-wishlist.ts new file mode 100644 index 00000000..8c34b9e8 --- /dev/null +++ b/services/frontend/packages/local/src/api/operations/get-customer-wishlist.ts @@ -0,0 +1,6 @@ +export default function getCustomerWishlistOperation() { + function getCustomerWishlist(): any { + return { wishlist: {} } + } + return getCustomerWishlist +} diff --git a/services/frontend/packages/local/src/api/operations/get-page.ts b/services/frontend/packages/local/src/api/operations/get-page.ts new file mode 100644 index 00000000..b0cfdf58 --- /dev/null +++ b/services/frontend/packages/local/src/api/operations/get-page.ts @@ -0,0 +1,13 @@ +export type Page = any +export type GetPageResult = { page?: Page } + +export type PageVariables = { + id: number +} + +export default function getPageOperation() { + function getPage(): Promise { + return Promise.resolve({}) + } + return getPage +} diff --git a/services/frontend/packages/local/src/api/operations/get-product.ts b/services/frontend/packages/local/src/api/operations/get-product.ts new file mode 100644 index 00000000..b77be3ac --- /dev/null +++ b/services/frontend/packages/local/src/api/operations/get-product.ts @@ -0,0 +1,26 @@ +import type { LocalConfig } from '../index' +import { Product } from '@vercel/commerce/types/product' +import { GetProductOperation } from '@vercel/commerce/types/product' +import data from '../../data.json' +import type { OperationContext } from '@vercel/commerce/api/operations' + +export default function getProductOperation({ + commerce, +}: OperationContext) { + async function getProduct({ + query = '', + variables, + config, + }: { + query?: string + variables?: T['variables'] + config?: Partial + preview?: boolean + } = {}): Promise { + return { + product: data.products.find(({ slug }) => slug === variables!.slug), + } + } + + return getProduct +} diff --git a/services/frontend/packages/local/src/api/operations/get-site-info.ts b/services/frontend/packages/local/src/api/operations/get-site-info.ts new file mode 100644 index 00000000..c07a479c --- /dev/null +++ b/services/frontend/packages/local/src/api/operations/get-site-info.ts @@ -0,0 +1,43 @@ +import { OperationContext } from '@vercel/commerce/api/operations' +import { Category } from '@vercel/commerce/types/site' +import { LocalConfig } from '../index' + +export type GetSiteInfoResult< + T extends { categories: any[]; brands: any[] } = { + categories: Category[] + brands: any[] + } +> = T + +export default function getSiteInfoOperation({}: OperationContext) { + function getSiteInfo({ + query, + variables, + config: cfg, + }: { + query?: string + variables?: any + config?: Partial + preview?: boolean + } = {}): Promise { + return Promise.resolve({ + categories: [ + { + id: 'new-arrivals', + name: 'New Arrivals', + slug: 'new-arrivals', + path: '/new-arrivals', + }, + { + id: 'featured', + name: 'Featured', + slug: 'featured', + path: '/featured', + }, + ], + brands: [], + }) + } + + return getSiteInfo +} diff --git a/services/frontend/packages/local/src/api/operations/index.ts b/services/frontend/packages/local/src/api/operations/index.ts new file mode 100644 index 00000000..086fdf83 --- /dev/null +++ b/services/frontend/packages/local/src/api/operations/index.ts @@ -0,0 +1,6 @@ +export { default as getPage } from './get-page' +export { default as getSiteInfo } from './get-site-info' +export { default as getAllPages } from './get-all-pages' +export { default as getProduct } from './get-product' +export { default as getAllProducts } from './get-all-products' +export { default as getAllProductPaths } from './get-all-product-paths' diff --git a/services/frontend/packages/local/src/api/utils/fetch-local.ts b/services/frontend/packages/local/src/api/utils/fetch-local.ts new file mode 100644 index 00000000..ae84fff8 --- /dev/null +++ b/services/frontend/packages/local/src/api/utils/fetch-local.ts @@ -0,0 +1,34 @@ +import { FetcherError } from '@vercel/commerce/utils/errors' +import type { GraphQLFetcher } from '@vercel/commerce/api' +import type { LocalConfig } from '../index' +import fetch from './fetch' + +const fetchGraphqlApi: (getConfig: () => LocalConfig) => GraphQLFetcher = + (getConfig) => + async (query: string, { variables, preview } = {}, fetchOptions) => { + const config = getConfig() + const res = await fetch(config.commerceUrl, { + ...fetchOptions, + method: 'POST', + headers: { + ...fetchOptions?.headers, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query, + variables, + }), + }) + + const json = await res.json() + if (json.errors) { + throw new FetcherError({ + errors: json.errors ?? [{ message: 'Failed to fetch for API' }], + status: res.status, + }) + } + + return { data: json.data, res } + } + +export default fetchGraphqlApi diff --git a/services/frontend/packages/local/src/api/utils/fetch.ts b/services/frontend/packages/local/src/api/utils/fetch.ts new file mode 100644 index 00000000..9d9fff3e --- /dev/null +++ b/services/frontend/packages/local/src/api/utils/fetch.ts @@ -0,0 +1,3 @@ +import zeitFetch from '@vercel/fetch' + +export default zeitFetch() diff --git a/services/frontend/packages/local/src/auth/index.ts b/services/frontend/packages/local/src/auth/index.ts new file mode 100644 index 00000000..36e757a8 --- /dev/null +++ b/services/frontend/packages/local/src/auth/index.ts @@ -0,0 +1,3 @@ +export { default as useLogin } from './use-login' +export { default as useLogout } from './use-logout' +export { default as useSignup } from './use-signup' diff --git a/services/frontend/packages/local/src/auth/use-login.tsx b/services/frontend/packages/local/src/auth/use-login.tsx new file mode 100644 index 00000000..20e3ed22 --- /dev/null +++ b/services/frontend/packages/local/src/auth/use-login.tsx @@ -0,0 +1,16 @@ +import { MutationHook } from '@vercel/commerce/utils/types' +import useLogin, { UseLogin } from '@vercel/commerce/auth/use-login' + +export default useLogin as UseLogin + +export const handler: MutationHook = { + fetchOptions: { + query: '', + }, + async fetcher() { + return null + }, + useHook: () => () => { + return async function () {} + }, +} diff --git a/services/frontend/packages/local/src/auth/use-logout.tsx b/services/frontend/packages/local/src/auth/use-logout.tsx new file mode 100644 index 00000000..4e74908f --- /dev/null +++ b/services/frontend/packages/local/src/auth/use-logout.tsx @@ -0,0 +1,17 @@ +import { MutationHook } from '@vercel/commerce/utils/types' +import useLogout, { UseLogout } from '@vercel/commerce/auth/use-logout' + +export default useLogout as UseLogout + +export const handler: MutationHook = { + fetchOptions: { + query: '', + }, + async fetcher() { + return null + }, + useHook: + ({ fetch }) => + () => + async () => {}, +} diff --git a/services/frontend/packages/local/src/auth/use-signup.tsx b/services/frontend/packages/local/src/auth/use-signup.tsx new file mode 100644 index 00000000..e4881140 --- /dev/null +++ b/services/frontend/packages/local/src/auth/use-signup.tsx @@ -0,0 +1,19 @@ +import { useCallback } from 'react' +import useCustomer from '../customer/use-customer' +import { MutationHook } from '@vercel/commerce/utils/types' +import useSignup, { UseSignup } from '@vercel/commerce/auth/use-signup' + +export default useSignup as UseSignup + +export const handler: MutationHook = { + fetchOptions: { + query: '', + }, + async fetcher() { + return null + }, + useHook: + ({ fetch }) => + () => + () => {}, +} diff --git a/services/frontend/packages/local/src/cart/index.ts b/services/frontend/packages/local/src/cart/index.ts new file mode 100644 index 00000000..3b8ba990 --- /dev/null +++ b/services/frontend/packages/local/src/cart/index.ts @@ -0,0 +1,4 @@ +export { default as useCart } from './use-cart' +export { default as useAddItem } from './use-add-item' +export { default as useRemoveItem } from './use-remove-item' +export { default as useUpdateItem } from './use-update-item' diff --git a/services/frontend/packages/local/src/cart/use-add-item.tsx b/services/frontend/packages/local/src/cart/use-add-item.tsx new file mode 100644 index 00000000..2be6e0aa --- /dev/null +++ b/services/frontend/packages/local/src/cart/use-add-item.tsx @@ -0,0 +1,17 @@ +import useAddItem, { UseAddItem } from '@vercel/commerce/cart/use-add-item' +import { MutationHook } from '@vercel/commerce/utils/types' + +export default useAddItem as UseAddItem +export const handler: MutationHook = { + fetchOptions: { + query: '', + }, + async fetcher({ input, options, fetch }) {}, + useHook: + ({ fetch }) => + () => { + return async function addItem() { + return {} + } + }, +} diff --git a/services/frontend/packages/local/src/cart/use-cart.tsx b/services/frontend/packages/local/src/cart/use-cart.tsx new file mode 100644 index 00000000..8f92de3c --- /dev/null +++ b/services/frontend/packages/local/src/cart/use-cart.tsx @@ -0,0 +1,42 @@ +import { useMemo } from 'react' +import { SWRHook } from '@vercel/commerce/utils/types' +import useCart, { UseCart } from '@vercel/commerce/cart/use-cart' + +export default useCart as UseCart + +export const handler: SWRHook = { + fetchOptions: { + query: '', + }, + async fetcher() { + return { + id: '', + createdAt: '', + currency: { code: '' }, + taxesIncluded: '', + lineItems: [], + lineItemsSubtotalPrice: '', + subtotalPrice: 0, + totalPrice: 0, + } + }, + useHook: + ({ useData }) => + (input) => { + return useMemo( + () => + Object.create( + {}, + { + isEmpty: { + get() { + return true + }, + enumerable: true, + }, + } + ), + [] + ) + }, +} diff --git a/services/frontend/packages/local/src/cart/use-remove-item.tsx b/services/frontend/packages/local/src/cart/use-remove-item.tsx new file mode 100644 index 00000000..92d52c99 --- /dev/null +++ b/services/frontend/packages/local/src/cart/use-remove-item.tsx @@ -0,0 +1,20 @@ +import { MutationHook } from '@vercel/commerce/utils/types' +import useRemoveItem, { + UseRemoveItem, +} from '@vercel/commerce/cart/use-remove-item' + +export default useRemoveItem as UseRemoveItem + +export const handler: MutationHook = { + fetchOptions: { + query: '', + }, + async fetcher({ input, options, fetch }) {}, + useHook: + ({ fetch }) => + () => { + return async function removeItem(input) { + return {} + } + }, +} diff --git a/services/frontend/packages/local/src/cart/use-update-item.tsx b/services/frontend/packages/local/src/cart/use-update-item.tsx new file mode 100644 index 00000000..950f422e --- /dev/null +++ b/services/frontend/packages/local/src/cart/use-update-item.tsx @@ -0,0 +1,20 @@ +import { MutationHook } from '@vercel/commerce/utils/types' +import useUpdateItem, { + UseUpdateItem, +} from '@vercel/commerce/cart/use-update-item' + +export default useUpdateItem as UseUpdateItem + +export const handler: MutationHook = { + fetchOptions: { + query: '', + }, + async fetcher({ input, options, fetch }) {}, + useHook: + ({ fetch }) => + () => { + return async function addItem() { + return {} + } + }, +} diff --git a/services/frontend/packages/local/src/checkout/use-checkout.tsx b/services/frontend/packages/local/src/checkout/use-checkout.tsx new file mode 100644 index 00000000..76997be7 --- /dev/null +++ b/services/frontend/packages/local/src/checkout/use-checkout.tsx @@ -0,0 +1,16 @@ +import { SWRHook } from '@vercel/commerce/utils/types' +import useCheckout, { + UseCheckout, +} from '@vercel/commerce/checkout/use-checkout' + +export default useCheckout as UseCheckout + +export const handler: SWRHook = { + fetchOptions: { + query: '', + }, + async fetcher({ input, options, fetch }) {}, + useHook: + ({ useData }) => + async (input) => ({}), +} diff --git a/services/frontend/packages/local/src/commerce.config.json b/services/frontend/packages/local/src/commerce.config.json new file mode 100644 index 00000000..0e77dd14 --- /dev/null +++ b/services/frontend/packages/local/src/commerce.config.json @@ -0,0 +1,10 @@ +{ + "provider": "local", + "features": { + "wishlist": false, + "cart": false, + "search": false, + "customerAuth": false, + "customCheckout": false + } +} diff --git a/services/frontend/packages/local/src/customer/address/use-add-item.tsx b/services/frontend/packages/local/src/customer/address/use-add-item.tsx new file mode 100644 index 00000000..4f85c847 --- /dev/null +++ b/services/frontend/packages/local/src/customer/address/use-add-item.tsx @@ -0,0 +1,17 @@ +import useAddItem, { + UseAddItem, +} from '@vercel/commerce/customer/address/use-add-item' +import { MutationHook } from '@vercel/commerce/utils/types' + +export default useAddItem as UseAddItem + +export const handler: MutationHook = { + fetchOptions: { + query: '', + }, + async fetcher({ input, options, fetch }) {}, + useHook: + ({ fetch }) => + () => + async () => ({}), +} diff --git a/services/frontend/packages/local/src/customer/card/use-add-item.tsx b/services/frontend/packages/local/src/customer/card/use-add-item.tsx new file mode 100644 index 00000000..77d149ef --- /dev/null +++ b/services/frontend/packages/local/src/customer/card/use-add-item.tsx @@ -0,0 +1,17 @@ +import useAddItem, { + UseAddItem, +} from '@vercel/commerce/customer/card/use-add-item' +import { MutationHook } from '@vercel/commerce/utils/types' + +export default useAddItem as UseAddItem + +export const handler: MutationHook = { + fetchOptions: { + query: '', + }, + async fetcher({ input, options, fetch }) {}, + useHook: + ({ fetch }) => + () => + async () => ({}), +} diff --git a/services/frontend/packages/local/src/customer/index.ts b/services/frontend/packages/local/src/customer/index.ts new file mode 100644 index 00000000..6c903ecc --- /dev/null +++ b/services/frontend/packages/local/src/customer/index.ts @@ -0,0 +1 @@ +export { default as useCustomer } from './use-customer' diff --git a/services/frontend/packages/local/src/customer/use-customer.tsx b/services/frontend/packages/local/src/customer/use-customer.tsx new file mode 100644 index 00000000..04c48943 --- /dev/null +++ b/services/frontend/packages/local/src/customer/use-customer.tsx @@ -0,0 +1,17 @@ +import { SWRHook } from '@vercel/commerce/utils/types' +import useCustomer, { + UseCustomer, +} from '@vercel/commerce/customer/use-customer' + +export default useCustomer as UseCustomer +export const handler: SWRHook = { + fetchOptions: { + query: '', + }, + async fetcher({ input, options, fetch }) {}, + useHook: () => () => { + return async function addItem() { + return {} + } + }, +} diff --git a/services/frontend/packages/local/src/data.json b/services/frontend/packages/local/src/data.json new file mode 100644 index 00000000..18c8ee71 --- /dev/null +++ b/services/frontend/packages/local/src/data.json @@ -0,0 +1,235 @@ +{ + "products": [ + { + "id": "Z2lkOi8vc2hvcGlmeS9Qcm9kdWN0LzU0NDczMjUwMjQ0MjA=", + "name": "New Short Sleeve T-Shirt", + "vendor": "Next.js", + "path": "/new-short-sleeve-t-shirt", + "slug": "new-short-sleeve-t-shirt", + "price": { "value": 25, "currencyCode": "USD" }, + "descriptionHtml": "

Show off your love for Next.js and Vercel with this unique, limited edition t-shirt. This design is part of a limited run, numbered drop at the June 2021 Next.js Conf. It features a unique, handcrafted triangle design. Get it while supplies last – only 200 of these shirts will be made! All proceeds will be donated to charity.

", + "images": [ + { + "url": "/assets/drop-shirt-0.png", + "altText": "Shirt", + "width": 1000, + "height": 1000 + }, + { + "url": "/assets/drop-shirt-1.png", + "altText": "Shirt", + "width": 1000, + "height": 1000 + }, + { + "url": "/assets/drop-shirt-2.png", + "altText": "Shirt", + "width": 1000, + "height": 1000 + } + ], + "variants": [ + { + "id": "Z2lkOi8vc2hvcGlmeS9Qcm9kdWN0LzU0NDczMjUwMjQ0MjAss=", + "options": [ + { + "__typename": "MultipleChoiceOption", + "id": "asd", + "displayName": "Size", + "values": [ + { + "label": "XL" + } + ] + } + ] + } + ], + "options": [ + { + "id": "option-color", + "displayName": "Color", + "values": [ + { + "label": "color", + "hexColors": ["#222"] + } + ] + }, + { + "id": "option-size", + "displayName": "Size", + "values": [ + { + "label": "S" + }, + { + "label": "M" + }, + { + "label": "L" + } + ] + } + ] + }, + { + "id": "Z2lkOi8vc2hvcGlmeS9Qcm9ksdWN0LzU0NDczMjUwMjQ0MjA=", + "name": "Lightweight Jacket", + "vendor": "Next.js", + "path": "/lightweight-jacket", + "slug": "lightweight-jacket", + "price": { "value": 249.99, "currencyCode": "USD" }, + "descriptionHtml": "

Show off your love for Next.js and Vercel with this unique, limited edition t-shirt. This design is part of a limited run, numbered drop at the June 2021 Next.js Conf. It features a unique, handcrafted triangle design. Get it while supplies last – only 200 of these shirts will be made! All proceeds will be donated to charity.

", + "images": [ + { + "url": "/assets/lightweight-jacket-0.png", + "altText": "Lightweight Jacket", + "width": 1000, + "height": 1000 + }, + { + "url": "/assets/lightweight-jacket-1.png", + "altText": "Lightweight Jacket", + "width": 1000, + "height": 1000 + }, + { + "url": "/assets/lightweight-jacket-2.png", + "altText": "Lightweight Jacket", + "width": 1000, + "height": 1000 + } + ], + "variants": [ + { + "id": "Z2lkOid8vc2hvcGlmeS9Qcm9kdWN0LzU0NDczMjUwMjQ0MjAss=", + "options": [ + { + "__typename": "MultipleChoiceOption", + "id": "asd", + "displayName": "Size", + "values": [ + { + "label": "XL" + } + ] + } + ] + } + ], + "options": [ + { + "id": "option-color", + "displayName": "Color", + "values": [ + { + "label": "color", + "hexColors": ["#222"] + } + ] + }, + { + "id": "option-size", + "displayName": "Size", + "values": [ + { + "label": "S" + }, + { + "label": "M" + }, + { + "label": "L" + } + ] + } + ] + }, + { + "id": "Z2lkOis8vc2hvcGlmsddeS9Qcm9kdWN0LzU0NDczMjUwMjQ0MjA=", + "name": "Shirt", + "vendor": "Next.js", + "path": "/shirt", + "slug": "shirt", + "price": { "value": 25, "currencyCode": "USD" }, + "descriptionHtml": "

Show off your love for Next.js and Vercel with this unique, limited edition t-shirt. This design is part of a limited run, numbered drop at the June 2021 Next.js Conf. It features a unique, handcrafted triangle design. Get it while supplies last – only 200 of these shirts will be made! All proceeds will be donated to charity.

", + "images": [ + { + "url": "/assets/t-shirt-0.png", + "altText": "Shirt", + "width": 1000, + "height": 1000 + }, + { + "url": "/assets/t-shirt-1.png", + "altText": "Shirt", + "width": 1000, + "height": 1000 + }, + { + "url": "/assets/t-shirt-2.png", + "altText": "Shirt", + "width": 1000, + "height": 1000 + }, + { + "url": "/assets/t-shirt-3.png", + "altText": "Shirt", + "width": 1000, + "height": 1000 + }, + { + "url": "/assets/t-shirt-4.png", + "altText": "Shirt", + "width": 1000, + "height": 1000 + } + ], + "variants": [ + { + "id": "Z2lkOi8vc2hvcGlmeS9Qcms9kdWN0LzU0NDczMjUwMjQ0MjAss=", + "options": [ + { + "__typename": "MultipleChoiceOption", + "id": "asd", + "displayName": "Size", + "values": [ + { + "label": "XL" + } + ] + } + ] + } + ], + "options": [ + { + "id": "option-color", + "displayName": "Color", + "values": [ + { + "label": "color", + "hexColors": ["#222"] + } + ] + }, + { + "id": "option-size", + "displayName": "Size", + "values": [ + { + "label": "S" + }, + { + "label": "M" + }, + { + "label": "L" + } + ] + } + ] + } + ] +} diff --git a/services/frontend/packages/local/src/fetcher.ts b/services/frontend/packages/local/src/fetcher.ts new file mode 100644 index 00000000..27f3ceb0 --- /dev/null +++ b/services/frontend/packages/local/src/fetcher.ts @@ -0,0 +1,11 @@ +import { Fetcher } from '@vercel/commerce/utils/types' + +export const fetcher: Fetcher = async () => { + console.log('FETCHER') + const res = await fetch('./data.json') + if (res.ok) { + const { data } = await res.json() + return data + } + throw res +} diff --git a/services/frontend/packages/local/src/index.tsx b/services/frontend/packages/local/src/index.tsx new file mode 100644 index 00000000..ae3df810 --- /dev/null +++ b/services/frontend/packages/local/src/index.tsx @@ -0,0 +1,12 @@ +import { + getCommerceProvider, + useCommerce as useCoreCommerce, +} from '@vercel/commerce' +import { localProvider, LocalProvider } from './provider' + +export { localProvider } +export type { LocalProvider } + +export const CommerceProvider = getCommerceProvider(localProvider) + +export const useCommerce = () => useCoreCommerce() diff --git a/services/frontend/packages/local/src/next.config.cjs b/services/frontend/packages/local/src/next.config.cjs new file mode 100644 index 00000000..ce46b706 --- /dev/null +++ b/services/frontend/packages/local/src/next.config.cjs @@ -0,0 +1,8 @@ +const commerce = require('./commerce.config.json') + +module.exports = { + commerce, + images: { + domains: ['localhost'], + }, +} diff --git a/services/frontend/packages/local/src/product/index.ts b/services/frontend/packages/local/src/product/index.ts new file mode 100644 index 00000000..426a3edc --- /dev/null +++ b/services/frontend/packages/local/src/product/index.ts @@ -0,0 +1,2 @@ +export { default as usePrice } from './use-price' +export { default as useSearch } from './use-search' diff --git a/services/frontend/packages/local/src/product/use-price.tsx b/services/frontend/packages/local/src/product/use-price.tsx new file mode 100644 index 00000000..fd42d703 --- /dev/null +++ b/services/frontend/packages/local/src/product/use-price.tsx @@ -0,0 +1,2 @@ +export * from '@vercel/commerce/product/use-price' +export { default } from '@vercel/commerce/product/use-price' diff --git a/services/frontend/packages/local/src/product/use-search.tsx b/services/frontend/packages/local/src/product/use-search.tsx new file mode 100644 index 00000000..b84889cc --- /dev/null +++ b/services/frontend/packages/local/src/product/use-search.tsx @@ -0,0 +1,17 @@ +import { SWRHook } from '@vercel/commerce/utils/types' +import useSearch, { UseSearch } from '@vercel/commerce/product/use-search' +export default useSearch as UseSearch + +export const handler: SWRHook = { + fetchOptions: { + query: '', + }, + async fetcher({ input, options, fetch }) {}, + useHook: () => () => { + return { + data: { + products: [], + }, + } + }, +} diff --git a/services/frontend/packages/local/src/provider.ts b/services/frontend/packages/local/src/provider.ts new file mode 100644 index 00000000..53dc7f57 --- /dev/null +++ b/services/frontend/packages/local/src/provider.ts @@ -0,0 +1,22 @@ +import { fetcher } from './fetcher' +import { handler as useCart } from './cart/use-cart' +import { handler as useAddItem } from './cart/use-add-item' +import { handler as useUpdateItem } from './cart/use-update-item' +import { handler as useRemoveItem } from './cart/use-remove-item' +import { handler as useCustomer } from './customer/use-customer' +import { handler as useSearch } from './product/use-search' +import { handler as useLogin } from './auth/use-login' +import { handler as useLogout } from './auth/use-logout' +import { handler as useSignup } from './auth/use-signup' + +export const localProvider = { + locale: 'en-us', + cartCookie: 'session', + fetcher: fetcher, + cart: { useCart, useAddItem, useUpdateItem, useRemoveItem }, + customer: { useCustomer }, + products: { useSearch }, + auth: { useLogin, useLogout, useSignup }, +} + +export type LocalProvider = typeof localProvider diff --git a/services/frontend/packages/local/src/wishlist/use-add-item.tsx b/services/frontend/packages/local/src/wishlist/use-add-item.tsx new file mode 100644 index 00000000..75f067c3 --- /dev/null +++ b/services/frontend/packages/local/src/wishlist/use-add-item.tsx @@ -0,0 +1,13 @@ +import { useCallback } from 'react' + +export function emptyHook() { + const useEmptyHook = async (options = {}) => { + return useCallback(async function () { + return Promise.resolve() + }, []) + } + + return useEmptyHook +} + +export default emptyHook diff --git a/services/frontend/packages/local/src/wishlist/use-remove-item.tsx b/services/frontend/packages/local/src/wishlist/use-remove-item.tsx new file mode 100644 index 00000000..a2d3a8a0 --- /dev/null +++ b/services/frontend/packages/local/src/wishlist/use-remove-item.tsx @@ -0,0 +1,17 @@ +import { useCallback } from 'react' + +type Options = { + includeProducts?: boolean +} + +export function emptyHook(options?: Options) { + const useEmptyHook = async ({ id }: { id: string | number }) => { + return useCallback(async function () { + return Promise.resolve() + }, []) + } + + return useEmptyHook +} + +export default emptyHook diff --git a/services/frontend/packages/local/src/wishlist/use-wishlist.tsx b/services/frontend/packages/local/src/wishlist/use-wishlist.tsx new file mode 100644 index 00000000..b2785d46 --- /dev/null +++ b/services/frontend/packages/local/src/wishlist/use-wishlist.tsx @@ -0,0 +1,43 @@ +import { HookFetcher } from '@vercel/commerce/utils/types' +import type { Product } from '@vercel/commerce/types/product' + +const defaultOpts = {} + +export type Wishlist = { + items: [ + { + product_id: number + variant_id: number + id: number + product: Product + } + ] +} + +export interface UseWishlistOptions { + includeProducts?: boolean +} + +export interface UseWishlistInput extends UseWishlistOptions { + customerId?: number +} + +export const fetcher: HookFetcher = () => { + return null +} + +export function extendHook( + customFetcher: typeof fetcher, + // swrOptions?: SwrOptions + swrOptions?: any +) { + const useWishlist = ({ includeProducts }: UseWishlistOptions = {}) => { + return { data: null } + } + + useWishlist.extend = extendHook + + return useWishlist +} + +export default extendHook(fetcher) diff --git a/services/frontend/packages/local/taskfile.js b/services/frontend/packages/local/taskfile.js new file mode 100644 index 00000000..39b1b2a8 --- /dev/null +++ b/services/frontend/packages/local/taskfile.js @@ -0,0 +1,20 @@ +export async function build(task, opts) { + await task + .source('src/**/*.+(ts|tsx|js)') + .swc({ dev: opts.dev, outDir: 'dist', baseUrl: 'src' }) + .target('dist') + .source('src/**/*.+(cjs|json)') + .target('dist') + task.$.log('Compiled src files') +} + +export async function release(task) { + await task.clear('dist').start('build') +} + +export default async function dev(task) { + const opts = { dev: true } + await task.clear('dist') + await task.start('build', opts) + await task.watch('src/**/*.+(ts|tsx|js|cjs|json)', 'build', opts) +} diff --git a/services/frontend/packages/local/tsconfig.json b/services/frontend/packages/local/tsconfig.json new file mode 100644 index 00000000..cd04ab2f --- /dev/null +++ b/services/frontend/packages/local/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "outDir": "dist", + "baseUrl": "src", + "lib": ["dom", "dom.iterable", "esnext"], + "declaration": true, + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "incremental": true, + "jsx": "react-jsx" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/services/frontend/packages/spree/.env.template b/services/frontend/packages/spree/.env.template new file mode 100644 index 00000000..36aae078 --- /dev/null +++ b/services/frontend/packages/spree/.env.template @@ -0,0 +1,25 @@ +# Template to be used for creating .env* files (.env, .env.local etc.) in the project's root directory. + +COMMERCE_PROVIDER=@vercel/commerce-spree + +{# - NEXT_PUBLIC_* are exposed to the web browser and the server #} +NEXT_PUBLIC_SPREE_API_HOST=http://localhost:4000 +NEXT_PUBLIC_SPREE_DEFAULT_LOCALE=en-us +NEXT_PUBLIC_SPREE_CART_COOKIE_NAME=spree_cart_token +{# -- cookie expire in days #} +NEXT_PUBLIC_SPREE_CART_COOKIE_EXPIRE=7 +NEXT_PUBLIC_SPREE_USER_COOKIE_NAME=spree_user_token +NEXT_PUBLIC_SPREE_USER_COOKIE_EXPIRE=7 +NEXT_PUBLIC_SPREE_IMAGE_HOST=http://localhost:4000 +NEXT_PUBLIC_SPREE_ALLOWED_IMAGE_DOMAIN=localhost +NEXT_PUBLIC_SPREE_CATEGORIES_TAXONOMY_PERMALINK=categories +NEXT_PUBLIC_SPREE_BRANDS_TAXONOMY_PERMALINK=brands +NEXT_PUBLIC_SPREE_ALL_PRODUCTS_TAXONOMY_ID=false +NEXT_PUBLIC_SPREE_SHOW_SINGLE_VARIANT_OPTIONS=false +NEXT_PUBLIC_SPREE_LAST_UPDATED_PRODUCTS_PRERENDER_COUNT=10 +NEXT_PUBLIC_SPREE_PRODUCT_PLACEHOLDER_IMAGE_URL=/product-img-placeholder.svg +NEXT_PUBLIC_SPREE_LINE_ITEM_PLACEHOLDER_IMAGE_URL=/product-img-placeholder.svg +NEXT_PUBLIC_SPREE_IMAGES_OPTION_FILTER=false +NEXT_PUBLIC_SPREE_IMAGES_SIZE=1000x1000 +NEXT_PUBLIC_SPREE_IMAGES_QUALITY=100 +NEXT_PUBLIC_SPREE_LOGIN_AFTER_SIGNUP=true diff --git a/services/frontend/packages/spree/.prettierignore b/services/frontend/packages/spree/.prettierignore new file mode 100644 index 00000000..f06235c4 --- /dev/null +++ b/services/frontend/packages/spree/.prettierignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/services/frontend/packages/spree/.prettierrc b/services/frontend/packages/spree/.prettierrc new file mode 100644 index 00000000..e1076edf --- /dev/null +++ b/services/frontend/packages/spree/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": false, + "singleQuote": true, + "tabWidth": 2, + "useTabs": false +} diff --git a/services/frontend/packages/spree/README-assets/screenshots.png b/services/frontend/packages/spree/README-assets/screenshots.png new file mode 100644 index 0000000000000000000000000000000000000000..93c133e06d4038d15d9a42ff0667328338085684 GIT binary patch literal 117099 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

6OhHIR@}zzq{xTSt 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 { + try { + const html = ` + + + + + + Checkout + + +

+ + + ` + + response.status(200) + response.setHeader('Content-Type', 'text/html') + response.write(html) + response.end() + } catch (error) { + console.error(error) + + const message = 'An unexpected error ocurred' + + response.status(500).json({ data: null, errors: [{ message }] }) + } +} + +export default getCheckout diff --git a/services/frontend/packages/spree/src/api/endpoints/checkout/index.ts b/services/frontend/packages/spree/src/api/endpoints/checkout/index.ts new file mode 100644 index 00000000..2be2a5de --- /dev/null +++ b/services/frontend/packages/spree/src/api/endpoints/checkout/index.ts @@ -0,0 +1,22 @@ +import { createEndpoint } from '@vercel/commerce/api' +import type { GetAPISchema, CommerceAPI } from '@vercel/commerce/api' +import checkoutEndpoint from '@vercel/commerce/api/endpoints/checkout' +import type { CheckoutSchema } from '@vercel/commerce/types/checkout' +import getCheckout from './get-checkout' +import type { SpreeApiProvider } from '../..' + +export type CheckoutAPI = GetAPISchema< + CommerceAPI, + CheckoutSchema +> + +export type CheckoutEndpoint = CheckoutAPI['endpoint'] + +export const handlers: CheckoutEndpoint['handlers'] = { getCheckout } + +const checkoutApi = createEndpoint({ + handler: checkoutEndpoint, + handlers, +}) + +export default checkoutApi diff --git a/services/frontend/packages/spree/src/api/endpoints/customer/address.ts b/services/frontend/packages/spree/src/api/endpoints/customer/address.ts new file mode 100644 index 00000000..491bf0ac --- /dev/null +++ b/services/frontend/packages/spree/src/api/endpoints/customer/address.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/services/frontend/packages/spree/src/api/endpoints/customer/card.ts b/services/frontend/packages/spree/src/api/endpoints/customer/card.ts new file mode 100644 index 00000000..491bf0ac --- /dev/null +++ b/services/frontend/packages/spree/src/api/endpoints/customer/card.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/services/frontend/packages/spree/src/api/endpoints/customer/index.ts b/services/frontend/packages/spree/src/api/endpoints/customer/index.ts new file mode 100644 index 00000000..491bf0ac --- /dev/null +++ b/services/frontend/packages/spree/src/api/endpoints/customer/index.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/services/frontend/packages/spree/src/api/endpoints/login/index.ts b/services/frontend/packages/spree/src/api/endpoints/login/index.ts new file mode 100644 index 00000000..491bf0ac --- /dev/null +++ b/services/frontend/packages/spree/src/api/endpoints/login/index.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/services/frontend/packages/spree/src/api/endpoints/logout/index.ts b/services/frontend/packages/spree/src/api/endpoints/logout/index.ts new file mode 100644 index 00000000..491bf0ac --- /dev/null +++ b/services/frontend/packages/spree/src/api/endpoints/logout/index.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/services/frontend/packages/spree/src/api/endpoints/signup/index.ts b/services/frontend/packages/spree/src/api/endpoints/signup/index.ts new file mode 100644 index 00000000..491bf0ac --- /dev/null +++ b/services/frontend/packages/spree/src/api/endpoints/signup/index.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/services/frontend/packages/spree/src/api/endpoints/wishlist/index.tsx b/services/frontend/packages/spree/src/api/endpoints/wishlist/index.tsx new file mode 100644 index 00000000..491bf0ac --- /dev/null +++ b/services/frontend/packages/spree/src/api/endpoints/wishlist/index.tsx @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/services/frontend/packages/spree/src/api/index.ts b/services/frontend/packages/spree/src/api/index.ts new file mode 100644 index 00000000..3971ed54 --- /dev/null +++ b/services/frontend/packages/spree/src/api/index.ts @@ -0,0 +1,45 @@ +import type { CommerceAPI, CommerceAPIConfig } from '@vercel/commerce/api' +import { getCommerceApi as commerceApi } from '@vercel/commerce/api' +import createApiFetch from './utils/create-api-fetch' + +import getAllPages from './operations/get-all-pages' +import getPage from './operations/get-page' +import getSiteInfo from './operations/get-site-info' +import getCustomerWishlist from './operations/get-customer-wishlist' +import getAllProductPaths from './operations/get-all-product-paths' +import getAllProducts from './operations/get-all-products' +import getProduct from './operations/get-product' + +export interface SpreeApiConfig extends CommerceAPIConfig {} + +const config: SpreeApiConfig = { + commerceUrl: '', + apiToken: '', + cartCookie: '', + customerCookie: '', + cartCookieMaxAge: 2592000, + fetch: createApiFetch(() => getCommerceApi().getConfig()), +} + +const operations = { + getAllPages, + getPage, + getSiteInfo, + getCustomerWishlist, + getAllProductPaths, + getAllProducts, + getProduct, +} + +export const provider = { config, operations } + +export type SpreeApiProvider = typeof provider + +export type SpreeApi

= + CommerceAPI

+ +export function getCommerceApi

( + customProvider: P = provider as any +): SpreeApi

{ + return commerceApi(customProvider) +} diff --git a/services/frontend/packages/spree/src/api/operations/get-all-pages.ts b/services/frontend/packages/spree/src/api/operations/get-all-pages.ts new file mode 100644 index 00000000..b5fae62b --- /dev/null +++ b/services/frontend/packages/spree/src/api/operations/get-all-pages.ts @@ -0,0 +1,82 @@ +import type { + OperationContext, + OperationOptions, +} from '@vercel/commerce/api/operations' +import type { GetAllPagesOperation, Page } from '@vercel/commerce/types/page' +import { requireConfigValue } from '../../isomorphic-config' +import normalizePage from '../../utils/normalizations/normalize-page' +import type { IPages } from '@spree/storefront-api-v2-sdk/types/interfaces/Page' +import type { SpreeSdkVariables } from '../../types' +import type { SpreeApiConfig, SpreeApiProvider } from '../index' + +export default function getAllPagesOperation({ + commerce, +}: OperationContext) { + async function getAllPages(options?: { + config?: Partial + preview?: boolean + }): Promise + + async function getAllPages( + opts: { + config?: Partial + preview?: boolean + } & OperationOptions + ): Promise + + async function getAllPages({ + config: userConfig, + preview, + query, + url, + }: { + url?: string + config?: Partial + preview?: boolean + query?: string + } = {}): Promise { + console.info( + 'getAllPages called. Configuration: ', + 'query: ', + query, + 'userConfig: ', + userConfig, + 'preview: ', + preview, + 'url: ', + url + ) + + const config = commerce.getConfig(userConfig) + const { fetch: apiFetch } = config + + const variables: SpreeSdkVariables = { + methodPath: 'pages.list', + arguments: [ + { + per_page: 500, + filter: { + locale_eq: + config.locale || (requireConfigValue('defaultLocale') as string), + }, + }, + ], + } + + const { data: spreeSuccessResponse } = await apiFetch< + IPages, + SpreeSdkVariables + >('__UNUSED__', { + variables, + }) + + const normalizedPages: Page[] = spreeSuccessResponse.data.map( + (spreePage) => + normalizePage(spreeSuccessResponse, spreePage, config.locales || []) + ) + + return { pages: normalizedPages } + } + + return getAllPages +} diff --git a/services/frontend/packages/spree/src/api/operations/get-all-product-paths.ts b/services/frontend/packages/spree/src/api/operations/get-all-product-paths.ts new file mode 100644 index 00000000..424f46c4 --- /dev/null +++ b/services/frontend/packages/spree/src/api/operations/get-all-product-paths.ts @@ -0,0 +1,97 @@ +import type { + OperationContext, + OperationOptions, +} from '@vercel/commerce/api/operations' +import type { Product } from '@vercel/commerce/types/product' +import type { GetAllProductPathsOperation } from '@vercel/commerce/types/product' +import { requireConfigValue } from '../../isomorphic-config' +import type { IProductsSlugs, SpreeSdkVariables } from '../../types' +import getProductPath from '../../utils/get-product-path' +import type { SpreeApiConfig, SpreeApiProvider } from '..' + +const imagesSize = requireConfigValue('imagesSize') as string +const imagesQuality = requireConfigValue('imagesQuality') as number + +export default function getAllProductPathsOperation({ + commerce, +}: OperationContext) { + async function getAllProductPaths< + T extends GetAllProductPathsOperation + >(opts?: { + variables?: T['variables'] + config?: Partial + }): Promise + + async function getAllProductPaths( + opts: { + variables?: T['variables'] + config?: Partial + } & OperationOptions + ): Promise + + async function getAllProductPaths({ + query, + variables: getAllProductPathsVariables = {}, + config: userConfig, + }: { + query?: string + variables?: T['variables'] + config?: Partial + } = {}): Promise { + console.info( + 'getAllProductPaths called. Configuration: ', + 'query: ', + query, + 'getAllProductPathsVariables: ', + getAllProductPathsVariables, + 'config: ', + userConfig + ) + + const productsCount = requireConfigValue( + 'lastUpdatedProductsPrerenderCount' + ) + + if (productsCount === 0) { + return { + products: [], + } + } + + const variables: SpreeSdkVariables = { + methodPath: 'products.list', + arguments: [ + {}, + { + fields: { + product: 'slug', + }, + per_page: productsCount, + image_transformation: { + quality: imagesQuality, + size: imagesSize, + }, + }, + ], + } + + const config = commerce.getConfig(userConfig) + const { fetch: apiFetch } = config // TODO: Send config.locale to Spree. + + const { data: spreeSuccessResponse } = await apiFetch< + IProductsSlugs, + SpreeSdkVariables + >('__UNUSED__', { + variables, + }) + + const normalizedProductsPaths: Pick[] = + spreeSuccessResponse.data.map((spreeProduct) => ({ + path: getProductPath(spreeProduct), + })) + + return { products: normalizedProductsPaths } + } + + return getAllProductPaths +} diff --git a/services/frontend/packages/spree/src/api/operations/get-all-products.ts b/services/frontend/packages/spree/src/api/operations/get-all-products.ts new file mode 100644 index 00000000..2b718d92 --- /dev/null +++ b/services/frontend/packages/spree/src/api/operations/get-all-products.ts @@ -0,0 +1,92 @@ +import type { Product } from '@vercel/commerce/types/product' +import type { GetAllProductsOperation } from '@vercel/commerce/types/product' +import type { + OperationContext, + OperationOptions, +} from '@vercel/commerce/api/operations' +import type { IProducts } from '@spree/storefront-api-v2-sdk/types/interfaces/Product' +import type { SpreeApiConfig, SpreeApiProvider } from '../index' +import type { SpreeSdkVariables } from '../../types' +import normalizeProduct from '../../utils/normalizations/normalize-product' +import { requireConfigValue } from '../../isomorphic-config' + +const imagesSize = requireConfigValue('imagesSize') as string +const imagesQuality = requireConfigValue('imagesQuality') as number + +export default function getAllProductsOperation({ + commerce, +}: OperationContext) { + async function getAllProducts(opts?: { + variables?: T['variables'] + config?: Partial + preview?: boolean + }): Promise + + async function getAllProducts( + opts: { + variables?: T['variables'] + config?: Partial + preview?: boolean + } & OperationOptions + ): Promise + + async function getAllProducts({ + variables: getAllProductsVariables = {}, + config: userConfig, + }: { + variables?: T['variables'] + config?: Partial + } = {}): Promise<{ products: Product[] }> { + console.info( + 'getAllProducts called. Configuration: ', + 'getAllProductsVariables: ', + getAllProductsVariables, + 'config: ', + userConfig + ) + + const defaultProductsTaxonomyId = requireConfigValue( + 'allProductsTaxonomyId' + ) as string | false + + const first = getAllProductsVariables.first + const filter = !defaultProductsTaxonomyId + ? {} + : { filter: { taxons: defaultProductsTaxonomyId }, sort: '-updated_at' } + + const variables: SpreeSdkVariables = { + methodPath: 'products.list', + arguments: [ + {}, + { + include: + 'primary_variant,variants,images,option_types,variants.option_values', + per_page: first, + ...filter, + image_transformation: { + quality: imagesQuality, + size: imagesSize, + }, + }, + ], + } + + const config = commerce.getConfig(userConfig) + const { fetch: apiFetch } = config // TODO: Send config.locale to Spree. + + const { data: spreeSuccessResponse } = await apiFetch< + IProducts, + SpreeSdkVariables + >('__UNUSED__', { + variables, + }) + + const normalizedProducts: Product[] = spreeSuccessResponse.data.map( + (spreeProduct) => normalizeProduct(spreeSuccessResponse, spreeProduct) + ) + + return { products: normalizedProducts } + } + + return getAllProducts +} diff --git a/services/frontend/packages/spree/src/api/operations/get-customer-wishlist.ts b/services/frontend/packages/spree/src/api/operations/get-customer-wishlist.ts new file mode 100644 index 00000000..8c34b9e8 --- /dev/null +++ b/services/frontend/packages/spree/src/api/operations/get-customer-wishlist.ts @@ -0,0 +1,6 @@ +export default function getCustomerWishlistOperation() { + function getCustomerWishlist(): any { + return { wishlist: {} } + } + return getCustomerWishlist +} diff --git a/services/frontend/packages/spree/src/api/operations/get-page.ts b/services/frontend/packages/spree/src/api/operations/get-page.ts new file mode 100644 index 00000000..711870e7 --- /dev/null +++ b/services/frontend/packages/spree/src/api/operations/get-page.ts @@ -0,0 +1,81 @@ +import type { + OperationContext, + OperationOptions, +} from '@vercel/commerce/api/operations' +import type { GetPageOperation } from '@vercel/commerce/types/page' +import type { SpreeSdkVariables } from '../../types' +import type { SpreeApiConfig, SpreeApiProvider } from '..' +import type { IPage } from '@spree/storefront-api-v2-sdk/types/interfaces/Page' +import normalizePage from '../../utils/normalizations/normalize-page' + +export type Page = any +export type GetPageResult = { page?: Page } + +export type PageVariables = { + id: number +} + +export default function getPageOperation({ + commerce, +}: OperationContext) { + async function getPage(opts: { + variables: T['variables'] + config?: Partial + preview?: boolean + }): Promise + + async function getPage( + opts: { + variables: T['variables'] + config?: Partial + preview?: boolean + } & OperationOptions + ): Promise + + async function getPage({ + url, + config: userConfig, + preview, + variables: getPageVariables, + }: { + url?: string + variables: T['variables'] + config?: Partial + preview?: boolean + }): Promise { + console.info( + 'getPage called. Configuration: ', + 'userConfig: ', + userConfig, + 'preview: ', + preview, + 'url: ', + url + ) + + const config = commerce.getConfig(userConfig) + const { fetch: apiFetch } = config + + const variables: SpreeSdkVariables = { + methodPath: 'pages.show', + arguments: [getPageVariables.id], + } + + const { data: spreeSuccessResponse } = await apiFetch< + IPage, + SpreeSdkVariables + >('__UNUSED__', { + variables, + }) + + const normalizedPage: Page = normalizePage( + spreeSuccessResponse, + spreeSuccessResponse.data, + config.locales || [] + ) + + return { page: normalizedPage } + } + + return getPage +} diff --git a/services/frontend/packages/spree/src/api/operations/get-product.ts b/services/frontend/packages/spree/src/api/operations/get-product.ts new file mode 100644 index 00000000..bd93791d --- /dev/null +++ b/services/frontend/packages/spree/src/api/operations/get-product.ts @@ -0,0 +1,90 @@ +import type { SpreeApiConfig, SpreeApiProvider } from '../index' +import type { GetProductOperation } from '@vercel/commerce/types/product' +import type { + OperationContext, + OperationOptions, +} from '@vercel/commerce/api/operations' +import type { IProduct } from '@spree/storefront-api-v2-sdk/types/interfaces/Product' +import type { SpreeSdkVariables } from '../../types' +import MissingSlugVariableError from '../../errors/MissingSlugVariableError' +import normalizeProduct from '../../utils/normalizations/normalize-product' +import { requireConfigValue } from '../../isomorphic-config' + +const imagesSize = requireConfigValue('imagesSize') as string +const imagesQuality = requireConfigValue('imagesQuality') as number + +export default function getProductOperation({ + commerce, +}: OperationContext) { + async function getProduct(opts: { + variables: T['variables'] + config?: Partial + preview?: boolean + }): Promise + + async function getProduct( + opts: { + variables: T['variables'] + config?: Partial + preview?: boolean + } & OperationOptions + ): Promise + + async function getProduct({ + query = '', + variables: getProductVariables, + config: userConfig, + }: { + query?: string + variables?: T['variables'] + config?: Partial + preview?: boolean + }): Promise { + console.log( + 'getProduct called. Configuration: ', + 'getProductVariables: ', + getProductVariables, + 'config: ', + userConfig + ) + + if (!getProductVariables?.slug) { + throw new MissingSlugVariableError() + } + + const variables: SpreeSdkVariables = { + methodPath: 'products.show', + arguments: [ + getProductVariables.slug, + {}, + { + include: + 'primary_variant,variants,images,option_types,variants.option_values', + image_transformation: { + quality: imagesQuality, + size: imagesSize, + }, + }, + ], + } + + const config = commerce.getConfig(userConfig) + const { fetch: apiFetch } = config // TODO: Send config.locale to Spree. + + const { data: spreeSuccessResponse } = await apiFetch< + IProduct, + SpreeSdkVariables + >('__UNUSED__', { + variables, + }) + + return { + product: normalizeProduct( + spreeSuccessResponse, + spreeSuccessResponse.data + ), + } + } + + return getProduct +} diff --git a/services/frontend/packages/spree/src/api/operations/get-site-info.ts b/services/frontend/packages/spree/src/api/operations/get-site-info.ts new file mode 100644 index 00000000..13b11e4a --- /dev/null +++ b/services/frontend/packages/spree/src/api/operations/get-site-info.ts @@ -0,0 +1,138 @@ +import type { + OperationContext, + OperationOptions, +} from '@vercel/commerce/api/operations' +import type { + Category, + GetSiteInfoOperation, +} from '@vercel/commerce/types/site' +import type { + ITaxons, + TaxonAttr, +} from '@spree/storefront-api-v2-sdk/types/interfaces/Taxon' +import { requireConfigValue } from '../../isomorphic-config' +import type { SpreeSdkVariables } from '../../types' +import type { SpreeApiConfig, SpreeApiProvider } from '..' + +const taxonsSort = (spreeTaxon1: TaxonAttr, spreeTaxon2: TaxonAttr): number => { + const { left: left1, right: right1 } = spreeTaxon1.attributes + const { left: left2, right: right2 } = spreeTaxon2.attributes + + if (right1 < left2) { + return -1 + } + + if (right2 < left1) { + return 1 + } + + return 0 +} + +export type GetSiteInfoResult< + T extends { categories: any[]; brands: any[] } = { + categories: Category[] + brands: any[] + } +> = T + +export default function getSiteInfoOperation({ + commerce, +}: OperationContext) { + async function getSiteInfo(opts?: { + config?: Partial + preview?: boolean + }): Promise + + async function getSiteInfo( + opts: { + config?: Partial + preview?: boolean + } & OperationOptions + ): Promise + + async function getSiteInfo({ + query, + variables: getSiteInfoVariables = {}, + config: userConfig, + }: { + query?: string + variables?: any + config?: Partial + preview?: boolean + } = {}): Promise { + console.info( + 'getSiteInfo called. Configuration: ', + 'query: ', + query, + 'getSiteInfoVariables ', + getSiteInfoVariables, + 'config: ', + userConfig + ) + + const createVariables = (parentPermalink: string): SpreeSdkVariables => ({ + methodPath: 'taxons.list', + arguments: [ + { + filter: { + parent_permalink: parentPermalink, + }, + }, + ], + }) + + const config = commerce.getConfig(userConfig) + const { fetch: apiFetch } = config // TODO: Send config.locale to Spree. + + const { data: spreeCategoriesSuccessResponse } = await apiFetch< + ITaxons, + SpreeSdkVariables + >('__UNUSED__', { + variables: createVariables( + requireConfigValue('categoriesTaxonomyPermalink') as string + ), + }) + + const { data: spreeBrandsSuccessResponse } = await apiFetch< + ITaxons, + SpreeSdkVariables + >('__UNUSED__', { + variables: createVariables( + requireConfigValue('brandsTaxonomyPermalink') as string + ), + }) + + const normalizedCategories: GetSiteInfoOperation['data']['categories'] = + spreeCategoriesSuccessResponse.data + .sort(taxonsSort) + .map((spreeTaxon: TaxonAttr) => { + return { + id: spreeTaxon.id, + name: spreeTaxon.attributes.name, + slug: spreeTaxon.id, + path: spreeTaxon.id, + } + }) + + const normalizedBrands: GetSiteInfoOperation['data']['brands'] = + spreeBrandsSuccessResponse.data + .sort(taxonsSort) + .map((spreeTaxon: TaxonAttr) => { + return { + node: { + entityId: spreeTaxon.id, + path: `brands/${spreeTaxon.id}`, + name: spreeTaxon.attributes.name, + }, + } + }) + + return { + categories: normalizedCategories, + brands: normalizedBrands, + } + } + + return getSiteInfo +} diff --git a/services/frontend/packages/spree/src/api/operations/index.ts b/services/frontend/packages/spree/src/api/operations/index.ts new file mode 100644 index 00000000..086fdf83 --- /dev/null +++ b/services/frontend/packages/spree/src/api/operations/index.ts @@ -0,0 +1,6 @@ +export { default as getPage } from './get-page' +export { default as getSiteInfo } from './get-site-info' +export { default as getAllPages } from './get-all-pages' +export { default as getProduct } from './get-product' +export { default as getAllProducts } from './get-all-products' +export { default as getAllProductPaths } from './get-all-product-paths' diff --git a/services/frontend/packages/spree/src/api/utils/create-api-fetch.ts b/services/frontend/packages/spree/src/api/utils/create-api-fetch.ts new file mode 100644 index 00000000..b26a2fb1 --- /dev/null +++ b/services/frontend/packages/spree/src/api/utils/create-api-fetch.ts @@ -0,0 +1,86 @@ +import { SpreeApiConfig } from '..' +import { errors, makeClient } from '@spree/storefront-api-v2-sdk' +import { requireConfigValue } from '../../isomorphic-config' +import convertSpreeErrorToGraphQlError from '../../utils/convert-spree-error-to-graph-ql-error' +import type { ResultResponse } from '@spree/storefront-api-v2-sdk/types/interfaces/ResultResponse' +import getSpreeSdkMethodFromEndpointPath from '../../utils/get-spree-sdk-method-from-endpoint-path' +import SpreeSdkMethodFromEndpointPathError from '../../errors/SpreeSdkMethodFromEndpointPathError' +import { GraphQLFetcher, GraphQLFetcherResult } from '@vercel/commerce/api' +import createCustomizedFetchFetcher, { + fetchResponseKey, +} from '../../utils/create-customized-fetch-fetcher' +import fetch, { Request } from 'node-fetch' +import type { SpreeSdkResponseWithRawResponse } from '../../types' +import prettyPrintSpreeSdkErrors from '../../utils/pretty-print-spree-sdk-errors' + +export type CreateApiFetch = ( + getConfig: () => SpreeApiConfig +) => GraphQLFetcher, any> + +// TODO: GraphQLFetcher, any> should be GraphQLFetcher, SpreeSdkVariables>. +// But CommerceAPIConfig['fetch'] cannot be extended from Variables = any to SpreeSdkVariables. + +const createApiFetch: CreateApiFetch = (_getConfig) => { + const client = makeClient({ + host: requireConfigValue('apiHost') as string, + createFetcher: (fetcherOptions) => { + return createCustomizedFetchFetcher({ + fetch, + requestConstructor: Request, + ...fetcherOptions, + }) + }, + }) + + return async (url, queryData = {}, fetchOptions = {}) => { + console.log( + 'apiFetch called. query = ', + 'url = ', + url, + 'queryData = ', + queryData, + 'fetchOptions = ', + fetchOptions + ) + + const { variables } = queryData + + if (!variables) { + throw new SpreeSdkMethodFromEndpointPathError( + `Required SpreeSdkVariables not provided.` + ) + } + + const storeResponse: ResultResponse = + await getSpreeSdkMethodFromEndpointPath( + client, + variables.methodPath + )(...variables.arguments) + + if (storeResponse.isSuccess()) { + const data = storeResponse.success() + const rawFetchResponse = data[fetchResponseKey] + + return { + data, + res: rawFetchResponse, + } + } + + const storeResponseError = storeResponse.fail() + + if (storeResponseError instanceof errors.SpreeError) { + console.error( + `Request to spree resulted in an error:\n\n${prettyPrintSpreeSdkErrors( + storeResponse.fail() + )}` + ) + + throw convertSpreeErrorToGraphQlError(storeResponseError) + } + + throw storeResponseError + } +} + +export default createApiFetch diff --git a/services/frontend/packages/spree/src/api/utils/fetch.ts b/services/frontend/packages/spree/src/api/utils/fetch.ts new file mode 100644 index 00000000..26f9ab67 --- /dev/null +++ b/services/frontend/packages/spree/src/api/utils/fetch.ts @@ -0,0 +1,3 @@ +import vercelFetch from '@vercel/fetch' + +export default vercelFetch() diff --git a/services/frontend/packages/spree/src/auth/index.ts b/services/frontend/packages/spree/src/auth/index.ts new file mode 100644 index 00000000..36e757a8 --- /dev/null +++ b/services/frontend/packages/spree/src/auth/index.ts @@ -0,0 +1,3 @@ +export { default as useLogin } from './use-login' +export { default as useLogout } from './use-logout' +export { default as useSignup } from './use-signup' diff --git a/services/frontend/packages/spree/src/auth/use-login.tsx b/services/frontend/packages/spree/src/auth/use-login.tsx new file mode 100644 index 00000000..b23b8a64 --- /dev/null +++ b/services/frontend/packages/spree/src/auth/use-login.tsx @@ -0,0 +1,86 @@ +import { useCallback } from 'react' +import type { MutationHook } from '@vercel/commerce/utils/types' +import useLogin, { UseLogin } from '@vercel/commerce/auth/use-login' +import type { LoginHook } from '@vercel/commerce/types/login' +import type { AuthTokenAttr } from '@spree/storefront-api-v2-sdk/types/interfaces/Authentication' +import { FetcherError, ValidationError } from '@vercel/commerce/utils/errors' +import useCustomer from '../customer/use-customer' +import useCart from '../cart/use-cart' +import useWishlist from '../wishlist/use-wishlist' +import login from '../utils/login' + +export default useLogin as UseLogin + +export const handler: MutationHook = { + // Provide fetchOptions for SWR cache key + fetchOptions: { + url: 'authentication', + query: 'getToken', + }, + async fetcher({ input, options, fetch }) { + console.info( + 'useLogin fetcher called. Configuration: ', + 'input: ', + input, + 'options: ', + options + ) + + const { email, password } = input + + if (!email || !password) { + throw new ValidationError({ + message: 'Email and password need to be provided.', + }) + } + + const getTokenParameters: AuthTokenAttr = { + username: email, + password, + } + + try { + await login(fetch, getTokenParameters, false) + + return null + } catch (getTokenError) { + if ( + getTokenError instanceof FetcherError && + getTokenError.status === 400 + ) { + // Change the error message to be more user friendly. + throw new FetcherError({ + status: getTokenError.status, + message: 'The email or password is invalid.', + code: getTokenError.code, + }) + } + + throw getTokenError + } + }, + useHook: ({ fetch }) => { + const useWrappedHook: ReturnType< + MutationHook['useHook'] + > = () => { + const customer = useCustomer() + const cart = useCart() + const wishlist = useWishlist() + + return useCallback( + async function login(input) { + const data = await fetch({ input }) + + await customer.mutate() + await cart.mutate() + await wishlist.mutate() + + return data + }, + [customer, cart, wishlist] + ) + } + + return useWrappedHook + }, +} diff --git a/services/frontend/packages/spree/src/auth/use-logout.tsx b/services/frontend/packages/spree/src/auth/use-logout.tsx new file mode 100644 index 00000000..8b220212 --- /dev/null +++ b/services/frontend/packages/spree/src/auth/use-logout.tsx @@ -0,0 +1,81 @@ +import { MutationHook } from '@vercel/commerce/utils/types' +import useLogout, { UseLogout } from '@vercel/commerce/auth/use-logout' +import type { LogoutHook } from '@vercel/commerce/types/logout' +import { useCallback } from 'react' +import useCustomer from '../customer/use-customer' +import useCart from '../cart/use-cart' +import useWishlist from '../wishlist/use-wishlist' +import { + ensureUserTokenResponse, + removeUserTokenResponse, +} from '../utils/tokens/user-token-response' +import revokeUserTokens from '../utils/tokens/revoke-user-tokens' +import TokensNotRejectedError from '../errors/TokensNotRejectedError' + +export default useLogout as UseLogout + +export const handler: MutationHook = { + // Provide fetchOptions for SWR cache key + fetchOptions: { + url: 'authentication', + query: 'revokeToken', + }, + async fetcher({ input, options, fetch }) { + console.info( + 'useLogout fetcher called. Configuration: ', + 'input: ', + input, + 'options: ', + options + ) + + const userToken = ensureUserTokenResponse() + + if (userToken) { + try { + // Revoke any tokens associated with the logged in user. + await revokeUserTokens(fetch, { + accessToken: userToken.access_token, + refreshToken: userToken.refresh_token, + }) + } catch (revokeUserTokenError) { + // Squash token revocation errors and rethrow anything else. + if (!(revokeUserTokenError instanceof TokensNotRejectedError)) { + throw revokeUserTokenError + } + } + + // Whether token revocation succeeded or not, remove them from local storage. + removeUserTokenResponse() + } + + return null + }, + useHook: ({ fetch }) => { + const useWrappedHook: ReturnType< + MutationHook['useHook'] + > = () => { + const customer = useCustomer({ + swrOptions: { isPaused: () => true }, + }) + const cart = useCart({ + swrOptions: { isPaused: () => true }, + }) + const wishlist = useWishlist({ + swrOptions: { isPaused: () => true }, + }) + + return useCallback(async () => { + const data = await fetch() + + await customer.mutate(null, false) + await cart.mutate(null, false) + await wishlist.mutate(null, false) + + return data + }, [customer, cart, wishlist]) + } + + return useWrappedHook + }, +} diff --git a/services/frontend/packages/spree/src/auth/use-signup.tsx b/services/frontend/packages/spree/src/auth/use-signup.tsx new file mode 100644 index 00000000..7dd8879e --- /dev/null +++ b/services/frontend/packages/spree/src/auth/use-signup.tsx @@ -0,0 +1,96 @@ +import { useCallback } from 'react' +import type { GraphQLFetcherResult } from '@vercel/commerce/api' +import type { MutationHook } from '@vercel/commerce/utils/types' +import useSignup, { UseSignup } from '@vercel/commerce/auth/use-signup' +import type { SignupHook } from '@vercel/commerce/types/signup' +import { ValidationError } from '@vercel/commerce/utils/errors' +import type { IAccount } from '@spree/storefront-api-v2-sdk/types/interfaces/Account' +import type { AuthTokenAttr } from '@spree/storefront-api-v2-sdk/types/interfaces/Authentication' +import useCustomer from '../customer/use-customer' +import useCart from '../cart/use-cart' +import useWishlist from '../wishlist/use-wishlist' +import login from '../utils/login' +import { requireConfigValue } from '../isomorphic-config' + +export default useSignup as UseSignup + +export const handler: MutationHook = { + // Provide fetchOptions for SWR cache key + fetchOptions: { + url: 'account', + query: 'create', + }, + async fetcher({ input, options, fetch }) { + console.info( + 'useSignup fetcher called. Configuration: ', + 'input: ', + input, + 'options: ', + options + ) + + const { email, password } = input + + if (!email || !password) { + throw new ValidationError({ + message: 'Email and password need to be provided.', + }) + } + + // TODO: Replace any with specific type from Spree SDK + // once it's added to the SDK. + const createAccountParameters: any = { + user: { + email, + password, + // The stock NJC interface doesn't have a + // password confirmation field, so just copy password. + passwordConfirmation: password, + }, + } + + // Create the user account. + await fetch>({ + variables: { + methodPath: 'account.create', + arguments: [createAccountParameters], + }, + }) + + const getTokenParameters: AuthTokenAttr = { + username: email, + password, + } + + // Login immediately after the account is created. + if (requireConfigValue('loginAfterSignup')) { + await login(fetch, getTokenParameters, true) + } + + return null + }, + useHook: ({ fetch }) => { + const useWrappedHook: ReturnType< + MutationHook['useHook'] + > = () => { + const customer = useCustomer() + const cart = useCart() + const wishlist = useWishlist() + + return useCallback( + async (input) => { + const data = await fetch({ input }) + + await customer.mutate() + await cart.mutate() + await wishlist.mutate() + + return data + }, + [customer, cart, wishlist] + ) + } + + return useWrappedHook + }, +} diff --git a/services/frontend/packages/spree/src/cart/index.ts b/services/frontend/packages/spree/src/cart/index.ts new file mode 100644 index 00000000..3b8ba990 --- /dev/null +++ b/services/frontend/packages/spree/src/cart/index.ts @@ -0,0 +1,4 @@ +export { default as useCart } from './use-cart' +export { default as useAddItem } from './use-add-item' +export { default as useRemoveItem } from './use-remove-item' +export { default as useUpdateItem } from './use-update-item' diff --git a/services/frontend/packages/spree/src/cart/use-add-item.tsx b/services/frontend/packages/spree/src/cart/use-add-item.tsx new file mode 100644 index 00000000..5f7d1f4d --- /dev/null +++ b/services/frontend/packages/spree/src/cart/use-add-item.tsx @@ -0,0 +1,118 @@ +import useAddItem from '@vercel/commerce/cart/use-add-item' +import type { UseAddItem } from '@vercel/commerce/cart/use-add-item' +import type { MutationHook } from '@vercel/commerce/utils/types' +import { useCallback } from 'react' +import useCart from './use-cart' +import type { AddItemHook } from '@vercel/commerce/types/cart' +import normalizeCart from '../utils/normalizations/normalize-cart' +import type { GraphQLFetcherResult } from '@vercel/commerce/api' +import type { IOrder } from '@spree/storefront-api-v2-sdk/types/interfaces/Order' +import type { IToken } from '@spree/storefront-api-v2-sdk/types/interfaces/Token' +import type { AddItem } from '@spree/storefront-api-v2-sdk/types/interfaces/endpoints/CartClass' +import { setCartToken } from '../utils/tokens/cart-token' +import ensureIToken from '../utils/tokens/ensure-itoken' +import createEmptyCart from '../utils/create-empty-cart' +import { FetcherError } from '@vercel/commerce/utils/errors' +import isLoggedIn from '../utils/tokens/is-logged-in' + +export default useAddItem as UseAddItem + +export const handler: MutationHook = { + // Provide fetchOptions for SWR cache key + fetchOptions: { + url: 'cart', + query: 'addItem', + }, + async fetcher({ input, options, fetch }) { + console.info( + 'useAddItem fetcher called. Configuration: ', + 'input: ', + input, + 'options: ', + options + ) + + const { quantity, productId, variantId } = input + + const safeQuantity = quantity ?? 1 + + let token: IToken | undefined = ensureIToken() + + const addItemParameters: AddItem = { + variant_id: variantId, + quantity: safeQuantity, + include: [ + 'line_items', + 'line_items.variant', + 'line_items.variant.product', + 'line_items.variant.product.images', + 'line_items.variant.images', + 'line_items.variant.option_values', + 'line_items.variant.product.option_types', + ].join(','), + } + + if (!token) { + const { data: spreeCartCreateSuccessResponse } = await createEmptyCart( + fetch + ) + + setCartToken(spreeCartCreateSuccessResponse.data.attributes.token) + token = ensureIToken() + } + + try { + const { data: spreeSuccessResponse } = await fetch< + GraphQLFetcherResult + >({ + variables: { + methodPath: 'cart.addItem', + arguments: [token, addItemParameters], + }, + }) + + return normalizeCart(spreeSuccessResponse, spreeSuccessResponse.data) + } catch (addItemError) { + if (addItemError instanceof FetcherError && addItemError.status === 404) { + const { data: spreeRetroactiveCartCreateSuccessResponse } = + await createEmptyCart(fetch) + + if (!isLoggedIn()) { + setCartToken( + spreeRetroactiveCartCreateSuccessResponse.data.attributes.token + ) + } + + // Return an empty cart. The user has to add the item again. + // This is going to be a rare situation. + + return normalizeCart( + spreeRetroactiveCartCreateSuccessResponse, + spreeRetroactiveCartCreateSuccessResponse.data + ) + } + + throw addItemError + } + }, + useHook: ({ fetch }) => { + const useWrappedHook: ReturnType< + MutationHook['useHook'] + > = () => { + const { mutate } = useCart() + + return useCallback( + async (input) => { + const data = await fetch({ input }) + + await mutate(data, false) + + return data + }, + [mutate] + ) + } + + return useWrappedHook + }, +} diff --git a/services/frontend/packages/spree/src/cart/use-cart.tsx b/services/frontend/packages/spree/src/cart/use-cart.tsx new file mode 100644 index 00000000..699a023e --- /dev/null +++ b/services/frontend/packages/spree/src/cart/use-cart.tsx @@ -0,0 +1,123 @@ +import { useMemo } from 'react' +import type { SWRHook } from '@vercel/commerce/utils/types' +import useCart from '@vercel/commerce/cart/use-cart' +import type { UseCart } from '@vercel/commerce/cart/use-cart' +import type { GetCartHook } from '@vercel/commerce/types/cart' +import normalizeCart from '../utils/normalizations/normalize-cart' +import type { GraphQLFetcherResult } from '@vercel/commerce/api' +import type { IOrder } from '@spree/storefront-api-v2-sdk/types/interfaces/Order' +import type { IToken } from '@spree/storefront-api-v2-sdk/types/interfaces/Token' +import { FetcherError } from '@vercel/commerce/utils/errors' +import { setCartToken } from '../utils/tokens/cart-token' +import ensureIToken from '../utils/tokens/ensure-itoken' +import isLoggedIn from '../utils/tokens/is-logged-in' +import createEmptyCart from '../utils/create-empty-cart' +import { requireConfigValue } from '../isomorphic-config' + +const imagesSize = requireConfigValue('imagesSize') as string +const imagesQuality = requireConfigValue('imagesQuality') as number + +export default useCart as UseCart + +// This handler avoids calling /api/cart. +// There doesn't seem to be a good reason to call it. +// So far, only bigcommerce uses it. +export const handler: SWRHook = { + // Provide fetchOptions for SWR cache key + fetchOptions: { + url: 'cart', + query: 'show', + }, + async fetcher({ input, options, fetch }) { + console.info( + 'useCart fetcher called. Configuration: ', + 'input: ', + input, + 'options: ', + options + ) + + let spreeCartResponse: IOrder | null + + const token: IToken | undefined = ensureIToken() + + if (!token) { + spreeCartResponse = null + } else { + try { + const { data: spreeCartShowSuccessResponse } = await fetch< + GraphQLFetcherResult + >({ + variables: { + methodPath: 'cart.show', + arguments: [ + token, + { + include: [ + 'line_items', + 'line_items.variant', + 'line_items.variant.product', + 'line_items.variant.product.images', + 'line_items.variant.images', + 'line_items.variant.option_values', + 'line_items.variant.product.option_types', + ].join(','), + image_transformation: { + quality: imagesQuality, + size: imagesSize, + }, + }, + ], + }, + }) + + spreeCartResponse = spreeCartShowSuccessResponse + } catch (fetchCartError) { + if ( + !(fetchCartError instanceof FetcherError) || + fetchCartError.status !== 404 + ) { + throw fetchCartError + } + + spreeCartResponse = null + } + } + + if (!spreeCartResponse || spreeCartResponse?.data.attributes.completed_at) { + const { data: spreeCartCreateSuccessResponse } = await createEmptyCart( + fetch + ) + + spreeCartResponse = spreeCartCreateSuccessResponse + + if (!isLoggedIn()) { + setCartToken(spreeCartResponse.data.attributes.token) + } + } + + return normalizeCart(spreeCartResponse, spreeCartResponse.data) + }, + useHook: ({ useData }) => { + const useWrappedHook: ReturnType['useHook']> = ( + input + ) => { + const response = useData({ + swrOptions: { revalidateOnFocus: false, ...input?.swrOptions }, + }) + + return useMemo(() => { + return Object.create(response, { + isEmpty: { + get() { + return (response.data?.lineItems.length ?? 0) === 0 + }, + enumerable: true, + }, + }) + }, [response]) + } + + return useWrappedHook + }, +} diff --git a/services/frontend/packages/spree/src/cart/use-remove-item.tsx b/services/frontend/packages/spree/src/cart/use-remove-item.tsx new file mode 100644 index 00000000..6bbecd32 --- /dev/null +++ b/services/frontend/packages/spree/src/cart/use-remove-item.tsx @@ -0,0 +1,119 @@ +import type { MutationHook } from '@vercel/commerce/utils/types' +import useRemoveItem from '@vercel/commerce/cart/use-remove-item' +import type { UseRemoveItem } from '@vercel/commerce/cart/use-remove-item' +import type { RemoveItemHook } from '@vercel/commerce/types/cart' +import useCart from './use-cart' +import { useCallback } from 'react' +import normalizeCart from '../utils/normalizations/normalize-cart' +import type { IOrder } from '@spree/storefront-api-v2-sdk/types/interfaces/Order' +import type { GraphQLFetcherResult } from '@vercel/commerce/api' +import type { IQuery } from '@spree/storefront-api-v2-sdk/types/interfaces/Query' +import type { IToken } from '@spree/storefront-api-v2-sdk/types/interfaces/Token' +import ensureIToken from '../utils/tokens/ensure-itoken' +import createEmptyCart from '../utils/create-empty-cart' +import { setCartToken } from '../utils/tokens/cart-token' +import { FetcherError } from '@vercel/commerce/utils/errors' +import isLoggedIn from '../utils/tokens/is-logged-in' + +export default useRemoveItem as UseRemoveItem + +export const handler: MutationHook = { + // Provide fetchOptions for SWR cache key + fetchOptions: { + url: 'cart', + query: 'removeItem', + }, + async fetcher({ input, options, fetch }) { + console.info( + 'useRemoveItem fetcher called. Configuration: ', + 'input: ', + input, + 'options: ', + options + ) + + const { itemId: lineItemId } = input + + let token: IToken | undefined = ensureIToken() + + if (!token) { + const { data: spreeCartCreateSuccessResponse } = await createEmptyCart( + fetch + ) + + setCartToken(spreeCartCreateSuccessResponse.data.attributes.token) + token = ensureIToken() + } + + const removeItemParameters: IQuery = { + include: [ + 'line_items', + 'line_items.variant', + 'line_items.variant.product', + 'line_items.variant.product.images', + 'line_items.variant.images', + 'line_items.variant.option_values', + 'line_items.variant.product.option_types', + ].join(','), + } + + try { + const { data: spreeSuccessResponse } = await fetch< + GraphQLFetcherResult + >({ + variables: { + methodPath: 'cart.removeItem', + arguments: [token, lineItemId, removeItemParameters], + }, + }) + + return normalizeCart(spreeSuccessResponse, spreeSuccessResponse.data) + } catch (removeItemError) { + if ( + removeItemError instanceof FetcherError && + removeItemError.status === 404 + ) { + const { data: spreeRetroactiveCartCreateSuccessResponse } = + await createEmptyCart(fetch) + + if (!isLoggedIn()) { + setCartToken( + spreeRetroactiveCartCreateSuccessResponse.data.attributes.token + ) + } + + // Return an empty cart. This is going to be a rare situation. + + return normalizeCart( + spreeRetroactiveCartCreateSuccessResponse, + spreeRetroactiveCartCreateSuccessResponse.data + ) + } + + throw removeItemError + } + }, + useHook: ({ fetch }) => { + const useWrappedHook: ReturnType< + MutationHook['useHook'] + > = () => { + const { mutate } = useCart() + + return useCallback( + async (input) => { + const data = await fetch({ input: { itemId: input.id } }) + + // Upon calling cart.removeItem, Spree returns the old version of the cart, + // with the already removed line item. Invalidate the useCart mutation + // to fetch the cart again. + await mutate(data, true) + + return data + }, + [mutate] + ) + } + + return useWrappedHook + }, +} diff --git a/services/frontend/packages/spree/src/cart/use-update-item.tsx b/services/frontend/packages/spree/src/cart/use-update-item.tsx new file mode 100644 index 00000000..679d68ac --- /dev/null +++ b/services/frontend/packages/spree/src/cart/use-update-item.tsx @@ -0,0 +1,148 @@ +import type { MutationHook } from '@vercel/commerce/utils/types' +import useUpdateItem, { + UseUpdateItem, +} from '@vercel/commerce/cart/use-update-item' +import type { UpdateItemHook } from '@vercel/commerce/types/cart' +import useCart from './use-cart' +import { useMemo } from 'react' +import { FetcherError, ValidationError } from '@vercel/commerce/utils/errors' +import type { IToken } from '@spree/storefront-api-v2-sdk/types/interfaces/Token' +import type { SetQuantity } from '@spree/storefront-api-v2-sdk/types/interfaces/endpoints/CartClass' +import type { GraphQLFetcherResult } from '@vercel/commerce/api' +import type { IOrder } from '@spree/storefront-api-v2-sdk/types/interfaces/Order' +import normalizeCart from '../utils/normalizations/normalize-cart' +import debounce from 'lodash.debounce' +import ensureIToken from '../utils/tokens/ensure-itoken' +import createEmptyCart from '../utils/create-empty-cart' +import { setCartToken } from '../utils/tokens/cart-token' +import isLoggedIn from '../utils/tokens/is-logged-in' + +export default useUpdateItem as UseUpdateItem + +export const handler: MutationHook = { + // Provide fetchOptions for SWR cache key + fetchOptions: { + url: 'cart', + query: 'setQuantity', + }, + async fetcher({ input, options, fetch }) { + console.info( + 'useRemoveItem fetcher called. Configuration: ', + 'input: ', + input, + 'options: ', + options + ) + + const { itemId, item } = input + + if (!item.quantity) { + throw new ValidationError({ + message: 'Line item quantity needs to be provided.', + }) + } + + let token: IToken | undefined = ensureIToken() + + if (!token) { + const { data: spreeCartCreateSuccessResponse } = await createEmptyCart( + fetch + ) + + setCartToken(spreeCartCreateSuccessResponse.data.attributes.token) + token = ensureIToken() + } + + try { + const setQuantityParameters: SetQuantity = { + line_item_id: itemId, + quantity: item.quantity, + include: [ + 'line_items', + 'line_items.variant', + 'line_items.variant.product', + 'line_items.variant.product.images', + 'line_items.variant.images', + 'line_items.variant.option_values', + 'line_items.variant.product.option_types', + ].join(','), + } + + const { data: spreeSuccessResponse } = await fetch< + GraphQLFetcherResult + >({ + variables: { + methodPath: 'cart.setQuantity', + arguments: [token, setQuantityParameters], + }, + }) + + return normalizeCart(spreeSuccessResponse, spreeSuccessResponse.data) + } catch (updateItemError) { + if ( + updateItemError instanceof FetcherError && + updateItemError.status === 404 + ) { + const { data: spreeRetroactiveCartCreateSuccessResponse } = + await createEmptyCart(fetch) + + if (!isLoggedIn()) { + setCartToken( + spreeRetroactiveCartCreateSuccessResponse.data.attributes.token + ) + } + + // Return an empty cart. The user has to update the item again. + // This is going to be a rare situation. + + return normalizeCart( + spreeRetroactiveCartCreateSuccessResponse, + spreeRetroactiveCartCreateSuccessResponse.data + ) + } + + throw updateItemError + } + }, + useHook: ({ fetch }) => { + const useWrappedHook: ReturnType< + MutationHook['useHook'] + > = (context) => { + const { mutate } = useCart() + + return useMemo( + () => + debounce(async (input: UpdateItemHook['actionInput']) => { + const itemId = context?.item?.id + const productId = input.productId ?? context?.item?.productId + const variantId = input.variantId ?? context?.item?.variantId + const quantity = input.quantity + + if (!itemId || !productId || !variantId) { + throw new ValidationError({ + message: 'Invalid input used for this operation', + }) + } + + const data = await fetch({ + input: { + item: { + productId, + variantId, + quantity, + }, + itemId, + }, + }) + + await mutate(data, false) + + return data + }, context?.wait ?? 500), + [mutate, context] + ) + } + + return useWrappedHook + }, +} diff --git a/services/frontend/packages/spree/src/checkout/use-checkout.tsx b/services/frontend/packages/spree/src/checkout/use-checkout.tsx new file mode 100644 index 00000000..bd8e3ac7 --- /dev/null +++ b/services/frontend/packages/spree/src/checkout/use-checkout.tsx @@ -0,0 +1,19 @@ +import { SWRHook } from '@vercel/commerce/utils/types' +import useCheckout, { + UseCheckout, +} from '@vercel/commerce/checkout/use-checkout' + +export default useCheckout as UseCheckout + +export const handler: SWRHook = { + // Provide fetchOptions for SWR cache key + fetchOptions: { + // TODO: Revise url and query + url: 'checkout', + query: 'show', + }, + async fetcher({ input, options, fetch }) {}, + useHook: + ({ useData }) => + async (input) => ({}), +} diff --git a/services/frontend/packages/spree/src/commerce.config.json b/services/frontend/packages/spree/src/commerce.config.json new file mode 100644 index 00000000..a4bc9004 --- /dev/null +++ b/services/frontend/packages/spree/src/commerce.config.json @@ -0,0 +1,10 @@ +{ + "provider": "spree", + "features": { + "wishlist": true, + "cart": true, + "search": true, + "customerAuth": true, + "customCheckout": true + } +} diff --git a/services/frontend/packages/spree/src/customer/address/use-add-item.tsx b/services/frontend/packages/spree/src/customer/address/use-add-item.tsx new file mode 100644 index 00000000..63296809 --- /dev/null +++ b/services/frontend/packages/spree/src/customer/address/use-add-item.tsx @@ -0,0 +1,18 @@ +import useAddItem from '@vercel/commerce/customer/address/use-add-item' +import type { UseAddItem } from '@vercel/commerce/customer/address/use-add-item' +import type { MutationHook } from '@vercel/commerce/utils/types' + +export default useAddItem as UseAddItem + +export const handler: MutationHook = { + // Provide fetchOptions for SWR cache key + fetchOptions: { + url: 'account', + query: 'createAddress', + }, + async fetcher({ input, options, fetch }) {}, + useHook: + ({ fetch }) => + () => + async () => ({}), +} diff --git a/services/frontend/packages/spree/src/customer/card/use-add-item.tsx b/services/frontend/packages/spree/src/customer/card/use-add-item.tsx new file mode 100644 index 00000000..40281145 --- /dev/null +++ b/services/frontend/packages/spree/src/customer/card/use-add-item.tsx @@ -0,0 +1,19 @@ +import useAddItem from '@vercel/commerce/customer/address/use-add-item' +import type { UseAddItem } from '@vercel/commerce/customer/address/use-add-item' +import type { MutationHook } from '@vercel/commerce/utils/types' + +export default useAddItem as UseAddItem + +export const handler: MutationHook = { + // Provide fetchOptions for SWR cache key + fetchOptions: { + // TODO: Revise url and query + url: 'checkout', + query: 'addPayment', + }, + async fetcher({ input, options, fetch }) {}, + useHook: + ({ fetch }) => + () => + async () => ({}), +} diff --git a/services/frontend/packages/spree/src/customer/index.ts b/services/frontend/packages/spree/src/customer/index.ts new file mode 100644 index 00000000..6c903ecc --- /dev/null +++ b/services/frontend/packages/spree/src/customer/index.ts @@ -0,0 +1 @@ +export { default as useCustomer } from './use-customer' diff --git a/services/frontend/packages/spree/src/customer/use-customer.tsx b/services/frontend/packages/spree/src/customer/use-customer.tsx new file mode 100644 index 00000000..56f330d2 --- /dev/null +++ b/services/frontend/packages/spree/src/customer/use-customer.tsx @@ -0,0 +1,83 @@ +import type { SWRHook } from '@vercel/commerce/utils/types' +import useCustomer from '@vercel/commerce/customer/use-customer' +import type { UseCustomer } from '@vercel/commerce/customer/use-customer' +import type { CustomerHook } from '@vercel/commerce/types/customer' +import type { IToken } from '@spree/storefront-api-v2-sdk/types/interfaces/Token' +import type { GraphQLFetcherResult } from '@vercel/commerce/api' +import type { IAccount } from '@spree/storefront-api-v2-sdk/types/interfaces/Account' +import { FetcherError } from '@vercel/commerce/utils/errors' +import normalizeUser from '../utils/normalizations/normalize-user' +import isLoggedIn from '../utils/tokens/is-logged-in' +import ensureIToken from '../utils/tokens/ensure-itoken' + +export default useCustomer as UseCustomer + +export const handler: SWRHook = { + // Provide fetchOptions for SWR cache key + fetchOptions: { + url: 'account', + query: 'get', + }, + async fetcher({ input, options, fetch }) { + console.info( + 'useCustomer fetcher called. Configuration: ', + 'input: ', + input, + 'options: ', + options + ) + + if (!isLoggedIn()) { + return null + } + + const token: IToken | undefined = ensureIToken() + + if (!token) { + return null + } + + try { + const { data: spreeAccountInfoSuccessResponse } = await fetch< + GraphQLFetcherResult + >({ + variables: { + methodPath: 'account.accountInfo', + arguments: [token], + }, + }) + + const spreeUser = spreeAccountInfoSuccessResponse.data + + const normalizedUser = normalizeUser( + spreeAccountInfoSuccessResponse, + spreeUser + ) + + return normalizedUser + } catch (fetchUserError) { + if ( + !(fetchUserError instanceof FetcherError) || + fetchUserError.status !== 404 + ) { + throw fetchUserError + } + + return null + } + }, + useHook: ({ useData }) => { + const useWrappedHook: ReturnType['useHook']> = ( + input + ) => { + return useData({ + swrOptions: { + revalidateOnFocus: false, + ...input?.swrOptions, + }, + }) + } + + return useWrappedHook + }, +} diff --git a/services/frontend/packages/spree/src/errors/AccessTokenError.ts b/services/frontend/packages/spree/src/errors/AccessTokenError.ts new file mode 100644 index 00000000..4c79c0be --- /dev/null +++ b/services/frontend/packages/spree/src/errors/AccessTokenError.ts @@ -0,0 +1 @@ +export default class AccessTokenError extends Error {} diff --git a/services/frontend/packages/spree/src/errors/MisconfigurationError.ts b/services/frontend/packages/spree/src/errors/MisconfigurationError.ts new file mode 100644 index 00000000..0717ae40 --- /dev/null +++ b/services/frontend/packages/spree/src/errors/MisconfigurationError.ts @@ -0,0 +1 @@ +export default class MisconfigurationError extends Error {} diff --git a/services/frontend/packages/spree/src/errors/MissingConfigurationValueError.ts b/services/frontend/packages/spree/src/errors/MissingConfigurationValueError.ts new file mode 100644 index 00000000..02b497bf --- /dev/null +++ b/services/frontend/packages/spree/src/errors/MissingConfigurationValueError.ts @@ -0,0 +1 @@ +export default class MissingConfigurationValueError extends Error {} diff --git a/services/frontend/packages/spree/src/errors/MissingLineItemVariantError.ts b/services/frontend/packages/spree/src/errors/MissingLineItemVariantError.ts new file mode 100644 index 00000000..d9bee080 --- /dev/null +++ b/services/frontend/packages/spree/src/errors/MissingLineItemVariantError.ts @@ -0,0 +1 @@ +export default class MissingLineItemVariantError extends Error {} diff --git a/services/frontend/packages/spree/src/errors/MissingOptionValueError.ts b/services/frontend/packages/spree/src/errors/MissingOptionValueError.ts new file mode 100644 index 00000000..04457ac5 --- /dev/null +++ b/services/frontend/packages/spree/src/errors/MissingOptionValueError.ts @@ -0,0 +1 @@ +export default class MissingOptionValueError extends Error {} diff --git a/services/frontend/packages/spree/src/errors/MissingPrimaryVariantError.ts b/services/frontend/packages/spree/src/errors/MissingPrimaryVariantError.ts new file mode 100644 index 00000000..f9af41b0 --- /dev/null +++ b/services/frontend/packages/spree/src/errors/MissingPrimaryVariantError.ts @@ -0,0 +1 @@ +export default class MissingPrimaryVariantError extends Error {} diff --git a/services/frontend/packages/spree/src/errors/MissingProductError.ts b/services/frontend/packages/spree/src/errors/MissingProductError.ts new file mode 100644 index 00000000..3098be68 --- /dev/null +++ b/services/frontend/packages/spree/src/errors/MissingProductError.ts @@ -0,0 +1 @@ +export default class MissingProductError extends Error {} diff --git a/services/frontend/packages/spree/src/errors/MissingSlugVariableError.ts b/services/frontend/packages/spree/src/errors/MissingSlugVariableError.ts new file mode 100644 index 00000000..09b9d2e2 --- /dev/null +++ b/services/frontend/packages/spree/src/errors/MissingSlugVariableError.ts @@ -0,0 +1 @@ +export default class MissingSlugVariableError extends Error {} diff --git a/services/frontend/packages/spree/src/errors/MissingVariantError.ts b/services/frontend/packages/spree/src/errors/MissingVariantError.ts new file mode 100644 index 00000000..5ed9e0ed --- /dev/null +++ b/services/frontend/packages/spree/src/errors/MissingVariantError.ts @@ -0,0 +1 @@ +export default class MissingVariantError extends Error {} diff --git a/services/frontend/packages/spree/src/errors/RefreshTokenError.ts b/services/frontend/packages/spree/src/errors/RefreshTokenError.ts new file mode 100644 index 00000000..a79365bb --- /dev/null +++ b/services/frontend/packages/spree/src/errors/RefreshTokenError.ts @@ -0,0 +1 @@ +export default class RefreshTokenError extends Error {} diff --git a/services/frontend/packages/spree/src/errors/SpreeResponseContentError.ts b/services/frontend/packages/spree/src/errors/SpreeResponseContentError.ts new file mode 100644 index 00000000..19c10cf2 --- /dev/null +++ b/services/frontend/packages/spree/src/errors/SpreeResponseContentError.ts @@ -0,0 +1 @@ +export default class SpreeResponseContentError extends Error {} diff --git a/services/frontend/packages/spree/src/errors/SpreeSdkMethodFromEndpointPathError.ts b/services/frontend/packages/spree/src/errors/SpreeSdkMethodFromEndpointPathError.ts new file mode 100644 index 00000000..bf15aada --- /dev/null +++ b/services/frontend/packages/spree/src/errors/SpreeSdkMethodFromEndpointPathError.ts @@ -0,0 +1 @@ +export default class SpreeSdkMethodFromEndpointPathError extends Error {} diff --git a/services/frontend/packages/spree/src/errors/TokensNotRejectedError.ts b/services/frontend/packages/spree/src/errors/TokensNotRejectedError.ts new file mode 100644 index 00000000..245f6641 --- /dev/null +++ b/services/frontend/packages/spree/src/errors/TokensNotRejectedError.ts @@ -0,0 +1 @@ +export default class TokensNotRejectedError extends Error {} diff --git a/services/frontend/packages/spree/src/errors/UserTokenResponseParseError.ts b/services/frontend/packages/spree/src/errors/UserTokenResponseParseError.ts new file mode 100644 index 00000000..9631971c --- /dev/null +++ b/services/frontend/packages/spree/src/errors/UserTokenResponseParseError.ts @@ -0,0 +1 @@ +export default class UserTokenResponseParseError extends Error {} diff --git a/services/frontend/packages/spree/src/fetcher.ts b/services/frontend/packages/spree/src/fetcher.ts new file mode 100644 index 00000000..89c5e715 --- /dev/null +++ b/services/frontend/packages/spree/src/fetcher.ts @@ -0,0 +1,123 @@ +import type { Fetcher } from '@vercel/commerce/utils/types' +import convertSpreeErrorToGraphQlError from './utils/convert-spree-error-to-graph-ql-error' +import { makeClient, errors } from '@spree/storefront-api-v2-sdk' +import type { ResultResponse } from '@spree/storefront-api-v2-sdk/types/interfaces/ResultResponse' +import type { GraphQLFetcherResult } from '@vercel/commerce/api' +import { requireConfigValue } from './isomorphic-config' +import getSpreeSdkMethodFromEndpointPath from './utils/get-spree-sdk-method-from-endpoint-path' +import SpreeSdkMethodFromEndpointPathError from './errors/SpreeSdkMethodFromEndpointPathError' +import type { + FetcherVariables, + SpreeSdkResponse, + SpreeSdkResponseWithRawResponse, +} from './types' +import createCustomizedFetchFetcher, { + fetchResponseKey, +} from './utils/create-customized-fetch-fetcher' +import ensureFreshUserAccessToken from './utils/tokens/ensure-fresh-user-access-token' +import RefreshTokenError from './errors/RefreshTokenError' +import prettyPrintSpreeSdkErrors from './utils/pretty-print-spree-sdk-errors' + +const client = makeClient({ + host: requireConfigValue('clientHost') as string, + createFetcher: (fetcherOptions) => { + return createCustomizedFetchFetcher({ + fetch: globalThis.fetch, + requestConstructor: globalThis.Request, + ...fetcherOptions, + }) + }, +}) + +const normalizeSpreeSuccessResponse = ( + storeResponse: ResultResponse +): GraphQLFetcherResult => { + const data = storeResponse.success() + const rawFetchResponse = data[fetchResponseKey] + + return { + data, + res: rawFetchResponse, + } +} + +const fetcher: Fetcher> = async ( + requestOptions +) => { + const { url, method, variables, query } = requestOptions + + console.log( + 'Fetcher called. Configuration: ', + 'url = ', + url, + 'requestOptions = ', + requestOptions + ) + + if (!variables) { + throw new SpreeSdkMethodFromEndpointPathError( + `Required FetcherVariables not provided.` + ) + } + + const { + methodPath, + arguments: args, + refreshExpiredAccessToken = true, + replayUnauthorizedRequest = true, + } = variables as FetcherVariables + + if (refreshExpiredAccessToken) { + await ensureFreshUserAccessToken(client) + } + + const spreeSdkMethod = getSpreeSdkMethodFromEndpointPath(client, methodPath) + + const storeResponse: ResultResponse = + await spreeSdkMethod(...args) + + if (storeResponse.isSuccess()) { + return normalizeSpreeSuccessResponse(storeResponse) + } + + const storeResponseError = storeResponse.fail() + + if ( + storeResponseError instanceof errors.SpreeError && + storeResponseError.serverResponse.status === 401 && + replayUnauthorizedRequest + ) { + console.info( + 'Request ended with 401. Replaying request after refreshing the user token.' + ) + + await ensureFreshUserAccessToken(client) + + const replayedStoreResponse: ResultResponse = + await spreeSdkMethod(...args) + + if (replayedStoreResponse.isSuccess()) { + return normalizeSpreeSuccessResponse(replayedStoreResponse) + } + + console.warn('Replaying the request failed', replayedStoreResponse.fail()) + + throw new RefreshTokenError( + 'Could not authorize request with current access token.' + ) + } + + if (storeResponseError instanceof errors.SpreeError) { + console.error( + `Request to spree resulted in an error:\n\n${prettyPrintSpreeSdkErrors( + storeResponse.fail() + )}` + ) + + throw convertSpreeErrorToGraphQlError(storeResponseError) + } + + throw storeResponseError +} + +export default fetcher diff --git a/services/frontend/packages/spree/src/index.tsx b/services/frontend/packages/spree/src/index.tsx new file mode 100644 index 00000000..9291e2b1 --- /dev/null +++ b/services/frontend/packages/spree/src/index.tsx @@ -0,0 +1,49 @@ +import type { ComponentType, FunctionComponent } from 'react' +import { + Provider, + CommerceProviderProps, + CoreCommerceProvider, + useCommerce as useCoreCommerce, +} from '@vercel/commerce' +import { spreeProvider } from './provider' +import type { SpreeProvider } from './provider' +import { SWRConfig } from 'swr' +import handleTokenErrors from './utils/handle-token-errors' +import useLogout from '@vercel/commerce/auth/use-logout' + +export { spreeProvider } +export type { SpreeProvider } + +export const WithTokenErrorsHandling: FunctionComponent = ({ children }) => { + const logout = useLogout() + + return ( + { + handleTokenErrors(error, () => void logout()) + }, + }} + > + {children} + + ) +} + +export const getCommerceProvider =

(provider: P) => { + return function CommerceProvider({ + children, + ...props + }: CommerceProviderProps) { + return ( + + {children} + + ) + } +} + +export const CommerceProvider = + getCommerceProvider(spreeProvider) + +export const useCommerce = () => useCoreCommerce() diff --git a/services/frontend/packages/spree/src/isomorphic-config.ts b/services/frontend/packages/spree/src/isomorphic-config.ts new file mode 100644 index 00000000..9a89dce7 --- /dev/null +++ b/services/frontend/packages/spree/src/isomorphic-config.ts @@ -0,0 +1,83 @@ +import forceIsomorphicConfigValues from './utils/force-isomorphic-config-values' +import requireConfig from './utils/require-config' +import validateAllProductsTaxonomyId from './utils/validations/validate-all-products-taxonomy-id' +import validateCookieExpire from './utils/validations/validate-cookie-expire' +import validateImagesOptionFilter from './utils/validations/validate-images-option-filter' +import validatePlaceholderImageUrl from './utils/validations/validate-placeholder-image-url' +import validateProductsPrerenderCount from './utils/validations/validate-products-prerender-count' +import validateImagesSize from './utils/validations/validate-images-size' +import validateImagesQuality from './utils/validations/validate-images-quality' + +const isomorphicConfig = { + apiHost: process.env.NEXT_PUBLIC_SPREE_API_HOST, + clientHost: process.env.NEXT_PUBLIC_SPREE_CLIENT_HOST, + defaultLocale: process.env.NEXT_PUBLIC_SPREE_DEFAULT_LOCALE, + cartCookieName: process.env.NEXT_PUBLIC_SPREE_CART_COOKIE_NAME, + cartCookieExpire: validateCookieExpire( + process.env.NEXT_PUBLIC_SPREE_CART_COOKIE_EXPIRE + ), + userCookieName: process.env.NEXT_PUBLIC_SPREE_USER_COOKIE_NAME, + userCookieExpire: validateCookieExpire( + process.env.NEXT_PUBLIC_SPREE_CART_COOKIE_EXPIRE + ), + imageHost: process.env.NEXT_PUBLIC_SPREE_IMAGE_HOST, + categoriesTaxonomyPermalink: + process.env.NEXT_PUBLIC_SPREE_CATEGORIES_TAXONOMY_PERMALINK, + brandsTaxonomyPermalink: + process.env.NEXT_PUBLIC_SPREE_BRANDS_TAXONOMY_PERMALINK, + allProductsTaxonomyId: validateAllProductsTaxonomyId( + process.env.NEXT_PUBLIC_SPREE_ALL_PRODUCTS_TAXONOMY_ID + ), + showSingleVariantOptions: + process.env.NEXT_PUBLIC_SPREE_SHOW_SINGLE_VARIANT_OPTIONS === 'true', + lastUpdatedProductsPrerenderCount: validateProductsPrerenderCount( + process.env.NEXT_PUBLIC_SPREE_LAST_UPDATED_PRODUCTS_PRERENDER_COUNT + ), + productPlaceholderImageUrl: validatePlaceholderImageUrl( + process.env.NEXT_PUBLIC_SPREE_PRODUCT_PLACEHOLDER_IMAGE_URL + ), + lineItemPlaceholderImageUrl: validatePlaceholderImageUrl( + process.env.NEXT_PUBLIC_SPREE_LINE_ITEM_PLACEHOLDER_IMAGE_URL + ), + imagesOptionFilter: validateImagesOptionFilter( + process.env.NEXT_PUBLIC_SPREE_IMAGES_OPTION_FILTER + ), + imagesSize: validateImagesSize(process.env.NEXT_PUBLIC_SPREE_IMAGES_SIZE), + imagesQuality: validateImagesQuality( + process.env.NEXT_PUBLIC_SPREE_IMAGES_QUALITY + ), + loginAfterSignup: process.env.NEXT_PUBLIC_SPREE_LOGIN_AFTER_SIGNUP === 'true', +} + +export default forceIsomorphicConfigValues( + isomorphicConfig, + [], + [ + 'apiHost', + 'clientHost', + 'defaultLocale', + 'cartCookieName', + 'cartCookieExpire', + 'userCookieName', + 'userCookieExpire', + 'imageHost', + 'categoriesTaxonomyPermalink', + 'brandsTaxonomyPermalink', + 'allProductsTaxonomyId', + 'showSingleVariantOptions', + 'lastUpdatedProductsPrerenderCount', + 'productPlaceholderImageUrl', + 'lineItemPlaceholderImageUrl', + 'imagesOptionFilter', + 'imagesSize', + 'imagesQuality', + 'loginAfterSignup', + ] +) + +type IsomorphicConfig = typeof isomorphicConfig + +const requireConfigValue = (key: keyof IsomorphicConfig) => + requireConfig(isomorphicConfig, key) + +export { requireConfigValue } diff --git a/services/frontend/packages/spree/src/next.config.cjs b/services/frontend/packages/spree/src/next.config.cjs new file mode 100644 index 00000000..0aaa87e0 --- /dev/null +++ b/services/frontend/packages/spree/src/next.config.cjs @@ -0,0 +1,16 @@ +const commerce = require('./commerce.config.json') + +module.exports = { + commerce, + images: { + domains: [process.env.NEXT_PUBLIC_SPREE_ALLOWED_IMAGE_DOMAIN], + }, + rewrites() { + return [ + { + source: '/checkout', + destination: '/api/checkout', + }, + ] + }, +} diff --git a/services/frontend/packages/spree/src/product/index.ts b/services/frontend/packages/spree/src/product/index.ts new file mode 100644 index 00000000..426a3edc --- /dev/null +++ b/services/frontend/packages/spree/src/product/index.ts @@ -0,0 +1,2 @@ +export { default as usePrice } from './use-price' +export { default as useSearch } from './use-search' diff --git a/services/frontend/packages/spree/src/product/use-price.tsx b/services/frontend/packages/spree/src/product/use-price.tsx new file mode 100644 index 00000000..fd42d703 --- /dev/null +++ b/services/frontend/packages/spree/src/product/use-price.tsx @@ -0,0 +1,2 @@ +export * from '@vercel/commerce/product/use-price' +export { default } from '@vercel/commerce/product/use-price' diff --git a/services/frontend/packages/spree/src/product/use-search.tsx b/services/frontend/packages/spree/src/product/use-search.tsx new file mode 100644 index 00000000..7de5d825 --- /dev/null +++ b/services/frontend/packages/spree/src/product/use-search.tsx @@ -0,0 +1,104 @@ +import type { SWRHook } from '@vercel/commerce/utils/types' +import useSearch from '@vercel/commerce/product/use-search' +import type { + Product, + SearchProductsHook, +} from '@vercel/commerce/types/product' +import type { UseSearch } from '@vercel/commerce/product/use-search' +import normalizeProduct from '../utils/normalizations/normalize-product' +import type { GraphQLFetcherResult } from '@vercel/commerce/api' +import { IProducts } from '@spree/storefront-api-v2-sdk/types/interfaces/Product' +import { requireConfigValue } from '../isomorphic-config' + +const imagesSize = requireConfigValue('imagesSize') as string +const imagesQuality = requireConfigValue('imagesQuality') as number + +const nextToSpreeSortMap: { [key: string]: string } = { + 'trending-desc': 'available_on', + 'latest-desc': 'updated_at', + 'price-asc': 'price', + 'price-desc': '-price', +} + +export const handler: SWRHook = { + // Provide fetchOptions for SWR cache key + fetchOptions: { + url: 'products', + query: 'list', + }, + async fetcher({ input, options, fetch }) { + // This method is only needed if the options need to be modified before calling the generic fetcher (created in createFetcher). + + console.info( + 'useSearch fetcher called. Configuration: ', + 'input: ', + input, + 'options: ', + options + ) + + const taxons = [input.categoryId, input.brandId].filter(Boolean) + + const filter = { + filter: { + ...(taxons.length > 0 ? { taxons: taxons.join(',') } : {}), + ...(input.search ? { name: input.search } : {}), + }, + } + + const sort = input.sort ? { sort: nextToSpreeSortMap[input.sort] } : {} + + const { data: spreeSuccessResponse } = await fetch< + GraphQLFetcherResult + >({ + variables: { + methodPath: 'products.list', + arguments: [ + {}, + { + include: + 'primary_variant,variants,images,option_types,variants.option_values', + per_page: 50, + ...filter, + ...sort, + image_transformation: { + quality: imagesQuality, + size: imagesSize, + }, + }, + ], + }, + }) + + const normalizedProducts: Product[] = spreeSuccessResponse.data.map( + (spreeProduct) => normalizeProduct(spreeSuccessResponse, spreeProduct) + ) + + const found = spreeSuccessResponse.data.length > 0 + + return { products: normalizedProducts, found } + }, + useHook: ({ useData }) => { + const useWrappedHook: ReturnType['useHook']> = ( + input = {} + ) => { + return useData({ + input: [ + ['search', input.search], + ['categoryId', input.categoryId], + ['brandId', input.brandId], + ['sort', input.sort], + ], + swrOptions: { + revalidateOnFocus: false, + // revalidateOnFocus: false means do not fetch products again when website is refocused in the web browser. + ...input.swrOptions, + }, + }) + } + + return useWrappedHook + }, +} + +export default useSearch as UseSearch diff --git a/services/frontend/packages/spree/src/provider.ts b/services/frontend/packages/spree/src/provider.ts new file mode 100644 index 00000000..de6ddb20 --- /dev/null +++ b/services/frontend/packages/spree/src/provider.ts @@ -0,0 +1,35 @@ +import fetcher from './fetcher' +import { handler as useCart } from './cart/use-cart' +import { handler as useAddItem } from './cart/use-add-item' +import { handler as useUpdateItem } from './cart/use-update-item' +import { handler as useRemoveItem } from './cart/use-remove-item' +import { handler as useCustomer } from './customer/use-customer' +import { handler as useSearch } from './product/use-search' +import { handler as useLogin } from './auth/use-login' +import { handler as useLogout } from './auth/use-logout' +import { handler as useSignup } from './auth/use-signup' +import { handler as useCheckout } from './checkout/use-checkout' +import { handler as useWishlist } from './wishlist/use-wishlist' +import { handler as useWishlistAddItem } from './wishlist/use-add-item' +import { handler as useWishlistRemoveItem } from './wishlist/use-remove-item' +import { requireConfigValue } from './isomorphic-config' + +const spreeProvider = { + locale: requireConfigValue('defaultLocale') as string, + cartCookie: requireConfigValue('cartCookieName') as string, + fetcher, + cart: { useCart, useAddItem, useUpdateItem, useRemoveItem }, + customer: { useCustomer }, + products: { useSearch }, + auth: { useLogin, useLogout, useSignup }, + checkout: { useCheckout }, + wishlist: { + useWishlist, + useAddItem: useWishlistAddItem, + useRemoveItem: useWishlistRemoveItem, + }, +} + +export { spreeProvider } + +export type SpreeProvider = typeof spreeProvider diff --git a/services/frontend/packages/spree/src/types/index.ts b/services/frontend/packages/spree/src/types/index.ts new file mode 100644 index 00000000..74ddeb52 --- /dev/null +++ b/services/frontend/packages/spree/src/types/index.ts @@ -0,0 +1,164 @@ +import type { fetchResponseKey } from '../utils/create-customized-fetch-fetcher' +import type { + JsonApiDocument, + JsonApiListResponse, + JsonApiSingleResponse, +} from '@spree/storefront-api-v2-sdk/types/interfaces/JsonApi' +import type { ResultResponse } from '@spree/storefront-api-v2-sdk/types/interfaces/ResultResponse' +import type { Response } from '@vercel/fetch' +import type { ProductOption, Product } from '@vercel/commerce/types/product' +import type { + AddItemHook, + RemoveItemHook, + WishlistItemBody, + WishlistTypes, +} from '@vercel/commerce/types/wishlist' + +export type UnknownObjectValues = Record + +export type NonUndefined = T extends undefined ? never : T + +export type ValueOf = T[keyof T] + +export type SpreeSdkResponse = JsonApiSingleResponse | JsonApiListResponse + +export type SpreeSdkResponseWithRawResponse = SpreeSdkResponse & { + [fetchResponseKey]: Response +} + +export type SpreeSdkResultResponseSuccessType = SpreeSdkResponseWithRawResponse + +export type SpreeSdkMethodReturnType< + ResultResponseSuccessType extends SpreeSdkResultResponseSuccessType = SpreeSdkResultResponseSuccessType +> = Promise> + +export type SpreeSdkMethod< + ResultResponseSuccessType extends SpreeSdkResultResponseSuccessType = SpreeSdkResultResponseSuccessType +> = (...args: any[]) => SpreeSdkMethodReturnType + +export type SpreeSdkVariables = { + methodPath: string + arguments: any[] +} + +export type FetcherVariables = SpreeSdkVariables & { + refreshExpiredAccessToken: boolean + replayUnauthorizedRequest: boolean +} + +export interface ImageStyle { + url: string + width: string + height: string + size: string +} + +export interface SpreeProductImage extends JsonApiDocument { + attributes: { + position: number + alt: string + original_url: string + transformed_url: string | null + styles: ImageStyle[] + } +} + +export interface OptionTypeAttr extends JsonApiDocument { + attributes: { + name: string + presentation: string + position: number + created_at: string + updated_at: string + filterable: boolean + } +} + +export interface LineItemAttr extends JsonApiDocument { + attributes: { + name: string + quantity: number + slug: string + options_text: string + price: string + currency: string + display_price: string + total: string + display_total: string + adjustment_total: string + display_adjustment_total: string + additional_tax_total: string + display_additional_tax_total: string + discounted_amount: string + display_discounted_amount: string + pre_tax_amount: string + display_pre_tax_amount: string + promo_total: string + display_promo_total: string + included_tax_total: string + display_inluded_tax_total: string + } +} + +export interface VariantAttr extends JsonApiDocument { + attributes: { + sku: string + price: string + currency: string + display_price: string + weight: string + height: string + width: string + depth: string + is_master: boolean + options_text: string + purchasable: boolean + in_stock: boolean + backorderable: boolean + } +} + +export interface ProductSlugAttr extends JsonApiDocument { + attributes: { + slug: string + } +} +export interface IProductsSlugs extends JsonApiListResponse { + data: ProductSlugAttr[] +} + +export type ExpandedProductOption = ProductOption & { position: number } + +export type UserOAuthTokens = { + refreshToken: string + accessToken: string +} + +// TODO: ExplicitCommerceWishlist is a temporary type +// derived from tsx views. It will be removed once +// Wishlist in @vercel/commerce/types/wishlist is updated +// to a more specific type than `any`. +export type ExplicitCommerceWishlist = { + id: string + token: string + items: { + id: string + product_id: number + variant_id: number + product: Product + }[] +} + +export type ExplicitWishlistAddItemHook = AddItemHook< + WishlistTypes & { + wishlist: ExplicitCommerceWishlist + itemBody: WishlistItemBody & { + wishlistToken?: string + } + } +> + +export type ExplicitWishlistRemoveItemHook = RemoveItemHook & { + fetcherInput: { wishlistToken?: string } + body: { wishlistToken?: string } +} diff --git a/services/frontend/packages/spree/src/utils/convert-spree-error-to-graph-ql-error.ts b/services/frontend/packages/spree/src/utils/convert-spree-error-to-graph-ql-error.ts new file mode 100644 index 00000000..f35a00da --- /dev/null +++ b/services/frontend/packages/spree/src/utils/convert-spree-error-to-graph-ql-error.ts @@ -0,0 +1,52 @@ +import { FetcherError } from '@vercel/commerce/utils/errors' +import { errors } from '@spree/storefront-api-v2-sdk' + +const convertSpreeErrorToGraphQlError = ( + error: errors.SpreeError +): FetcherError => { + if (error instanceof errors.ExpandedSpreeError) { + // Assuming error.errors[key] is a list of strings. + + if ('base' in error.errors) { + const baseErrorMessage = error.errors.base as unknown as string + + return new FetcherError({ + status: error.serverResponse.status, + message: baseErrorMessage, + }) + } + + const fetcherErrors = Object.keys(error.errors).map((sdkErrorKey) => { + const errors = error.errors[sdkErrorKey] as string[] + + // Naively assume sdkErrorKey is a label. Capitalize it for a better + // out-of-the-box experience. + const capitalizedSdkErrorKey = sdkErrorKey.replace(/^\w/, (firstChar) => + firstChar.toUpperCase() + ) + + return { + message: `${capitalizedSdkErrorKey} ${errors.join(', ')}`, + } + }) + + return new FetcherError({ + status: error.serverResponse.status, + errors: fetcherErrors, + }) + } + + if (error instanceof errors.BasicSpreeError) { + return new FetcherError({ + status: error.serverResponse.status, + message: error.summary, + }) + } + + return new FetcherError({ + status: error.serverResponse.status, + message: error.message, + }) +} + +export default convertSpreeErrorToGraphQlError diff --git a/services/frontend/packages/spree/src/utils/create-customized-fetch-fetcher.ts b/services/frontend/packages/spree/src/utils/create-customized-fetch-fetcher.ts new file mode 100644 index 00000000..f0821c19 --- /dev/null +++ b/services/frontend/packages/spree/src/utils/create-customized-fetch-fetcher.ts @@ -0,0 +1,109 @@ +import { + errors, + request as spreeSdkRequestHelpers, +} from '@spree/storefront-api-v2-sdk' +import type { CreateCustomizedFetchFetcher } from '@spree/storefront-api-v2-sdk/types/interfaces/CreateCustomizedFetchFetcher' +import isJsonContentType from './is-json-content-type' + +export const fetchResponseKey = Symbol('fetch-response-key') + +const createCustomizedFetchFetcher: CreateCustomizedFetchFetcher = ( + fetcherOptions +) => { + const { FetchError } = errors + const sharedHeaders = { + 'Content-Type': 'application/json', + } + + const { host, fetch, requestConstructor } = fetcherOptions + + return { + fetch: async (fetchOptions) => { + // This fetcher always returns request equal null, + // because @vercel/fetch doesn't accept a Request object as argument + // and it's not used by NJC anyway. + try { + const { url, params, method, headers, responseParsing } = fetchOptions + const absoluteUrl = new URL(url, host) + let payload + + switch (method.toUpperCase()) { + case 'PUT': + case 'POST': + case 'DELETE': + case 'PATCH': + payload = { body: JSON.stringify(params) } + break + default: + payload = null + absoluteUrl.search = + spreeSdkRequestHelpers.objectToQuerystring(params) + } + + const request: Request = new requestConstructor( + absoluteUrl.toString(), + { + method: method.toUpperCase(), + headers: { ...sharedHeaders, ...headers }, + ...payload, + } + ) + + try { + console.info( + `Calling the Spree API: ${request.method} ${request.url}` + ) + + const response: Response = await fetch(request) + const responseContentType = response.headers.get('content-type') + let data + + if (responseParsing === 'automatic') { + if (responseContentType && isJsonContentType(responseContentType)) { + data = await response.json() + } else { + data = await response.text() + } + } else if (responseParsing === 'text') { + data = await response.text() + } else if (responseParsing === 'json') { + data = await response.json() + } else if (responseParsing === 'stream') { + data = await response.body + } + + if (!response.ok) { + // Use the "traditional" approach and reject non 2xx responses. + throw new FetchError(response, request, data) + } + + data[fetchResponseKey] = response + + return { data } + } catch (error) { + if (error instanceof FetchError) { + throw error + } + + if (!(error instanceof Error)) { + throw error + } + + throw new FetchError(null, request, null, error.message) + } + } catch (error) { + if (error instanceof FetchError) { + throw error + } + + if (!(error instanceof Error)) { + throw error + } + + throw new FetchError(null, null, null, error.message) + } + }, + } +} + +export default createCustomizedFetchFetcher diff --git a/services/frontend/packages/spree/src/utils/create-empty-cart.ts b/services/frontend/packages/spree/src/utils/create-empty-cart.ts new file mode 100644 index 00000000..efbd687f --- /dev/null +++ b/services/frontend/packages/spree/src/utils/create-empty-cart.ts @@ -0,0 +1,22 @@ +import type { GraphQLFetcherResult } from '@vercel/commerce/api' +import type { HookFetcherContext } from '@vercel/commerce/utils/types' +import type { IOrder } from '@spree/storefront-api-v2-sdk/types/interfaces/Order' +import type { IToken } from '@spree/storefront-api-v2-sdk/types/interfaces/Token' +import ensureIToken from './tokens/ensure-itoken' + +const createEmptyCart = ( + fetch: HookFetcherContext<{ + data: any + }>['fetch'] +): Promise> => { + const token: IToken | undefined = ensureIToken() + + return fetch>({ + variables: { + methodPath: 'cart.create', + arguments: [token], + }, + }) +} + +export default createEmptyCart diff --git a/services/frontend/packages/spree/src/utils/create-get-absolute-image-url.ts b/services/frontend/packages/spree/src/utils/create-get-absolute-image-url.ts new file mode 100644 index 00000000..6e9e3260 --- /dev/null +++ b/services/frontend/packages/spree/src/utils/create-get-absolute-image-url.ts @@ -0,0 +1,26 @@ +import { SpreeProductImage } from '../types' +import getImageUrl from './get-image-url' + +const createGetAbsoluteImageUrl = + (host: string, useOriginalImageSize: boolean = true) => + ( + image: SpreeProductImage, + minWidth: number, + minHeight: number + ): string | null => { + let url + + if (useOriginalImageSize) { + url = image.attributes.transformed_url || null + } else { + url = getImageUrl(image, minWidth, minHeight) + } + + if (url === null) { + return null + } + + return `${host}${url}` + } + +export default createGetAbsoluteImageUrl diff --git a/services/frontend/packages/spree/src/utils/expand-options.ts b/services/frontend/packages/spree/src/utils/expand-options.ts new file mode 100644 index 00000000..382ea3b5 --- /dev/null +++ b/services/frontend/packages/spree/src/utils/expand-options.ts @@ -0,0 +1,103 @@ +import type { ProductOptionValues } from '@vercel/commerce/types/product' +import type { + JsonApiDocument, + JsonApiResponse, +} from '@spree/storefront-api-v2-sdk/types/interfaces/JsonApi' +import { jsonApi } from '@spree/storefront-api-v2-sdk' +import type { RelationType } from '@spree/storefront-api-v2-sdk/types/interfaces/Relationships' +import SpreeResponseContentError from '../errors/SpreeResponseContentError' +import type { OptionTypeAttr, ExpandedProductOption } from '../types' +import sortOptionsByPosition from '../utils/sort-option-types' + +const isColorProductOption = (productOption: ExpandedProductOption) => { + return productOption.displayName === 'Color' +} + +const expandOptions = ( + spreeSuccessResponse: JsonApiResponse, + spreeOptionValue: JsonApiDocument, + accumulatedOptions: ExpandedProductOption[] +): ExpandedProductOption[] => { + const spreeOptionTypeIdentifier = spreeOptionValue.relationships.option_type + .data as RelationType + + const existingOptionIndex = accumulatedOptions.findIndex( + (option) => option.id == spreeOptionTypeIdentifier.id + ) + + let option: ExpandedProductOption + + if (existingOptionIndex === -1) { + const spreeOptionType = jsonApi.findDocument( + spreeSuccessResponse, + spreeOptionTypeIdentifier + ) + + if (!spreeOptionType) { + throw new SpreeResponseContentError( + `Option type with id ${spreeOptionTypeIdentifier.id} not found.` + ) + } + + option = { + __typename: 'MultipleChoiceOption', + id: spreeOptionType.id, + displayName: spreeOptionType.attributes.presentation, + position: spreeOptionType.attributes.position, + values: [], + } + } else { + const existingOption = accumulatedOptions[existingOptionIndex] + + option = existingOption + } + + let optionValue: ProductOptionValues + + const label = isColorProductOption(option) + ? spreeOptionValue.attributes.name + : spreeOptionValue.attributes.presentation + + const productOptionValueExists = option.values.some( + (optionValue: ProductOptionValues) => optionValue.label === label + ) + + if (!productOptionValueExists) { + if (isColorProductOption(option)) { + optionValue = { + label, + hexColors: [spreeOptionValue.attributes.presentation], + } + } else { + optionValue = { + label, + } + } + + if (existingOptionIndex === -1) { + return [ + ...accumulatedOptions, + { + ...option, + values: [optionValue], + }, + ] + } + + const expandedOptionValues = [...option.values, optionValue] + const expandedOptions = [...accumulatedOptions] + + expandedOptions[existingOptionIndex] = { + ...option, + values: expandedOptionValues, + } + + const sortedOptions = sortOptionsByPosition(expandedOptions) + + return sortedOptions + } + + return accumulatedOptions +} + +export default expandOptions diff --git a/services/frontend/packages/spree/src/utils/force-isomorphic-config-values.ts b/services/frontend/packages/spree/src/utils/force-isomorphic-config-values.ts new file mode 100644 index 00000000..630b6859 --- /dev/null +++ b/services/frontend/packages/spree/src/utils/force-isomorphic-config-values.ts @@ -0,0 +1,43 @@ +import type { NonUndefined, UnknownObjectValues } from '../types' +import MisconfigurationError from '../errors/MisconfigurationError' +import isServer from './is-server' + +const generateMisconfigurationErrorMessage = ( + keys: Array +) => `${keys.join(', ')} must have a value before running the Framework.` + +const forceIsomorphicConfigValues = < + X extends keyof T, + T extends UnknownObjectValues, + H extends Record> +>( + config: T, + requiredServerKeys: string[], + requiredPublicKeys: X[] +) => { + if (isServer) { + const missingServerConfigValues = requiredServerKeys.filter( + (requiredServerKey) => typeof config[requiredServerKey] === 'undefined' + ) + + if (missingServerConfigValues.length > 0) { + throw new MisconfigurationError( + generateMisconfigurationErrorMessage(missingServerConfigValues) + ) + } + } + + const missingPublicConfigValues = requiredPublicKeys.filter( + (requiredPublicKey) => typeof config[requiredPublicKey] === 'undefined' + ) + + if (missingPublicConfigValues.length > 0) { + throw new MisconfigurationError( + generateMisconfigurationErrorMessage(missingPublicConfigValues) + ) + } + + return config as T & H +} + +export default forceIsomorphicConfigValues diff --git a/services/frontend/packages/spree/src/utils/get-image-url.ts b/services/frontend/packages/spree/src/utils/get-image-url.ts new file mode 100644 index 00000000..8594f5c3 --- /dev/null +++ b/services/frontend/packages/spree/src/utils/get-image-url.ts @@ -0,0 +1,44 @@ +// Based on https://github.com/spark-solutions/spree2vuestorefront/blob/d88d85ae1bcd2ec99b13b81cd2e3c25600a0216e/src/utils/index.ts + +import type { ImageStyle, SpreeProductImage } from '../types' + +const getImageUrl = ( + image: SpreeProductImage, + minWidth: number, + _: number +): string | null => { + // every image is still resized in vue-storefront-api, no matter what getImageUrl returns + if (image) { + const { + attributes: { styles }, + } = image + const bestStyleIndex = styles.reduce( + (bSIndex: number | null, style: ImageStyle, styleIndex: number) => { + // assuming all images are the same dimensions, just scaled + if (bSIndex === null) { + return 0 + } + const bestStyle = styles[bSIndex] + const widthDiff = +bestStyle.width - minWidth + const minWidthDiff = +style.width - minWidth + if (widthDiff < 0 && minWidthDiff > 0) { + return styleIndex + } + if (widthDiff > 0 && minWidthDiff < 0) { + return bSIndex + } + return Math.abs(widthDiff) < Math.abs(minWidthDiff) + ? bSIndex + : styleIndex + }, + null + ) + + if (bestStyleIndex !== null) { + return styles[bestStyleIndex].url + } + } + return null +} + +export default getImageUrl diff --git a/services/frontend/packages/spree/src/utils/get-media-gallery.ts b/services/frontend/packages/spree/src/utils/get-media-gallery.ts new file mode 100644 index 00000000..dd2dacb2 --- /dev/null +++ b/services/frontend/packages/spree/src/utils/get-media-gallery.ts @@ -0,0 +1,25 @@ +// Based on https://github.com/spark-solutions/spree2vuestorefront/blob/d88d85ae1bcd2ec99b13b81cd2e3c25600a0216e/src/utils/index.ts + +import type { ProductImage } from '@vercel/commerce/types/product' +import type { SpreeProductImage } from '../types' + +const getMediaGallery = ( + images: SpreeProductImage[], + getImageUrl: ( + image: SpreeProductImage, + minWidth: number, + minHeight: number + ) => string | null +) => { + return images.reduce((productImages, _, imageIndex) => { + const url = getImageUrl(images[imageIndex], 800, 800) + + if (url) { + return [...productImages, { url }] + } + + return productImages + }, []) +} + +export default getMediaGallery diff --git a/services/frontend/packages/spree/src/utils/get-product-path.ts b/services/frontend/packages/spree/src/utils/get-product-path.ts new file mode 100644 index 00000000..6749a4a3 --- /dev/null +++ b/services/frontend/packages/spree/src/utils/get-product-path.ts @@ -0,0 +1,7 @@ +import type { ProductSlugAttr } from '../types' + +const getProductPath = (partialSpreeProduct: ProductSlugAttr) => { + return `/${partialSpreeProduct.attributes.slug}` +} + +export default getProductPath diff --git a/services/frontend/packages/spree/src/utils/get-spree-sdk-method-from-endpoint-path.ts b/services/frontend/packages/spree/src/utils/get-spree-sdk-method-from-endpoint-path.ts new file mode 100644 index 00000000..9b87daad --- /dev/null +++ b/services/frontend/packages/spree/src/utils/get-spree-sdk-method-from-endpoint-path.ts @@ -0,0 +1,61 @@ +import type { Client } from '@spree/storefront-api-v2-sdk' +import SpreeSdkMethodFromEndpointPathError from '../errors/SpreeSdkMethodFromEndpointPathError' +import type { + SpreeSdkMethod, + SpreeSdkResultResponseSuccessType, +} from '../types' + +const getSpreeSdkMethodFromEndpointPath = < + ExactSpreeSdkClientType extends Client, + ResultResponseSuccessType extends SpreeSdkResultResponseSuccessType = SpreeSdkResultResponseSuccessType +>( + client: ExactSpreeSdkClientType, + path: string +): SpreeSdkMethod => { + const pathParts = path.split('.') + const reachedPath: string[] = [] + let node = >client + + console.log(`Looking for ${path} in Spree Sdk.`) + + while (reachedPath.length < pathParts.length - 1) { + const checkedPathPart = pathParts[reachedPath.length] + const checkedNode = node[checkedPathPart] + + console.log(`Checking part ${checkedPathPart}.`) + + if (typeof checkedNode !== 'object') { + throw new SpreeSdkMethodFromEndpointPathError( + `Couldn't reach ${path}. Farthest path reached was: ${reachedPath.join( + '.' + )}.` + ) + } + + if (checkedNode === null) { + throw new SpreeSdkMethodFromEndpointPathError( + `Path ${path} doesn't exist.` + ) + } + + node = >checkedNode + reachedPath.push(checkedPathPart) + } + + const foundEndpointMethod = node[pathParts[reachedPath.length]] + + if ( + reachedPath.length !== pathParts.length - 1 || + typeof foundEndpointMethod !== 'function' + ) { + throw new SpreeSdkMethodFromEndpointPathError( + `Couldn't reach ${path}. Farthest path reached was: ${reachedPath.join( + '.' + )}.` + ) + } + + return foundEndpointMethod.bind(node) +} + +export default getSpreeSdkMethodFromEndpointPath diff --git a/services/frontend/packages/spree/src/utils/handle-token-errors.ts b/services/frontend/packages/spree/src/utils/handle-token-errors.ts new file mode 100644 index 00000000..a5d49fde --- /dev/null +++ b/services/frontend/packages/spree/src/utils/handle-token-errors.ts @@ -0,0 +1,14 @@ +import AccessTokenError from '../errors/AccessTokenError' +import RefreshTokenError from '../errors/RefreshTokenError' + +const handleTokenErrors = (error: unknown, action: () => void): boolean => { + if (error instanceof AccessTokenError || error instanceof RefreshTokenError) { + action() + + return true + } + + return false +} + +export default handleTokenErrors diff --git a/services/frontend/packages/spree/src/utils/is-json-content-type.ts b/services/frontend/packages/spree/src/utils/is-json-content-type.ts new file mode 100644 index 00000000..fd82d65f --- /dev/null +++ b/services/frontend/packages/spree/src/utils/is-json-content-type.ts @@ -0,0 +1,5 @@ +const isJsonContentType = (contentType: string): boolean => + contentType.includes('application/json') || + contentType.includes('application/vnd.api+json') + +export default isJsonContentType diff --git a/services/frontend/packages/spree/src/utils/is-server.ts b/services/frontend/packages/spree/src/utils/is-server.ts new file mode 100644 index 00000000..4544a488 --- /dev/null +++ b/services/frontend/packages/spree/src/utils/is-server.ts @@ -0,0 +1 @@ +export default typeof window === 'undefined' diff --git a/services/frontend/packages/spree/src/utils/login.ts b/services/frontend/packages/spree/src/utils/login.ts new file mode 100644 index 00000000..13555664 --- /dev/null +++ b/services/frontend/packages/spree/src/utils/login.ts @@ -0,0 +1,58 @@ +import type { GraphQLFetcherResult } from '@vercel/commerce/api' +import type { HookFetcherContext } from '@vercel/commerce/utils/types' +import type { AuthTokenAttr } from '@spree/storefront-api-v2-sdk/types/interfaces/Authentication' +import type { AssociateCart } from '@spree/storefront-api-v2-sdk/types/interfaces/endpoints/CartClass' +import type { IOrder } from '@spree/storefront-api-v2-sdk/types/interfaces/Order' +import type { + IOAuthToken, + IToken, +} from '@spree/storefront-api-v2-sdk/types/interfaces/Token' +import { getCartToken, removeCartToken } from './tokens/cart-token' +import { setUserTokenResponse } from './tokens/user-token-response' + +const login = async ( + fetch: HookFetcherContext<{ + data: any + }>['fetch'], + getTokenParameters: AuthTokenAttr, + associateGuestCart: boolean +): Promise => { + const { data: spreeGetTokenSuccessResponse } = await fetch< + GraphQLFetcherResult + >({ + variables: { + methodPath: 'authentication.getToken', + arguments: [getTokenParameters], + }, + }) + + setUserTokenResponse(spreeGetTokenSuccessResponse) + + if (associateGuestCart) { + const cartToken = getCartToken() + + if (cartToken) { + // If the user had a cart as guest still use its contents + // after logging in. + const accessToken = spreeGetTokenSuccessResponse.access_token + const token: IToken = { bearerToken: accessToken } + + const associateGuestCartParameters: AssociateCart = { + guest_order_token: cartToken, + } + + await fetch>({ + variables: { + methodPath: 'cart.associateGuestCart', + arguments: [token, associateGuestCartParameters], + }, + }) + + // We no longer need the guest cart token, so let's remove it. + } + } + + removeCartToken() +} + +export default login diff --git a/services/frontend/packages/spree/src/utils/normalizations/normalize-cart.ts b/services/frontend/packages/spree/src/utils/normalizations/normalize-cart.ts new file mode 100644 index 00000000..c5597fd8 --- /dev/null +++ b/services/frontend/packages/spree/src/utils/normalizations/normalize-cart.ts @@ -0,0 +1,211 @@ +import type { + Cart, + LineItem, + ProductVariant, + SelectedOption, +} from '@vercel/commerce/types/cart' +import MissingLineItemVariantError from '../../errors/MissingLineItemVariantError' +import { requireConfigValue } from '../../isomorphic-config' +import type { OrderAttr } from '@spree/storefront-api-v2-sdk/types/interfaces/Order' +import type { ProductAttr } from '@spree/storefront-api-v2-sdk/types/interfaces/Product' +import type { Image } from '@vercel/commerce/types/common' +import { jsonApi } from '@spree/storefront-api-v2-sdk' +import createGetAbsoluteImageUrl from '../create-get-absolute-image-url' +import getMediaGallery from '../get-media-gallery' +import type { + LineItemAttr, + OptionTypeAttr, + SpreeProductImage, + SpreeSdkResponse, + VariantAttr, +} from '../../types' + +const placeholderImage = requireConfigValue('lineItemPlaceholderImageUrl') as + | string + | false + +const isColorProductOption = (productOptionType: OptionTypeAttr) => { + return productOptionType.attributes.presentation === 'Color' +} + +const normalizeVariant = ( + spreeSuccessResponse: SpreeSdkResponse, + spreeVariant: VariantAttr +): ProductVariant => { + const spreeProduct = jsonApi.findSingleRelationshipDocument( + spreeSuccessResponse, + spreeVariant, + 'product' + ) + + if (spreeProduct === null) { + throw new MissingLineItemVariantError( + `Couldn't find product for variant with id ${spreeVariant.id}.` + ) + } + + const spreeVariantImageRecords = + jsonApi.findRelationshipDocuments( + spreeSuccessResponse, + spreeVariant, + 'images' + ) + + let lineItemImage + + const variantImage = getMediaGallery( + spreeVariantImageRecords, + createGetAbsoluteImageUrl(requireConfigValue('imageHost') as string) + )[0] + + if (variantImage) { + lineItemImage = variantImage + } else { + const spreeProductImageRecords = + jsonApi.findRelationshipDocuments( + spreeSuccessResponse, + spreeProduct, + 'images' + ) + + const productImage = getMediaGallery( + spreeProductImageRecords, + createGetAbsoluteImageUrl(requireConfigValue('imageHost') as string) + )[0] + + lineItemImage = productImage + } + + const image: Image = + lineItemImage ?? + (placeholderImage === false ? undefined : { url: placeholderImage }) + + return { + id: spreeVariant.id, + sku: spreeVariant.attributes.sku, + name: spreeProduct.attributes.name, + requiresShipping: true, + price: parseFloat(spreeVariant.attributes.price), + listPrice: parseFloat(spreeVariant.attributes.price), + image, + isInStock: spreeVariant.attributes.in_stock, + availableForSale: spreeVariant.attributes.purchasable, + ...(spreeVariant.attributes.weight === '0.0' + ? {} + : { + weight: { + value: parseFloat(spreeVariant.attributes.weight), + unit: 'KILOGRAMS', + }, + }), + // TODO: Add height, width and depth when Measurement type allows distance measurements. + } +} + +const normalizeLineItem = ( + spreeSuccessResponse: SpreeSdkResponse, + spreeLineItem: LineItemAttr +): LineItem => { + const variant = jsonApi.findSingleRelationshipDocument( + spreeSuccessResponse, + spreeLineItem, + 'variant' + ) + + if (variant === null) { + throw new MissingLineItemVariantError( + `Couldn't find variant for line item with id ${spreeLineItem.id}.` + ) + } + + const product = jsonApi.findSingleRelationshipDocument( + spreeSuccessResponse, + variant, + 'product' + ) + + if (product === null) { + throw new MissingLineItemVariantError( + `Couldn't find product for variant with id ${variant.id}.` + ) + } + + // CartItem.tsx expects path without a '/' prefix unlike pages/product/[slug].tsx and others. + const path = `${product.attributes.slug}` + + const spreeOptionValues = jsonApi.findRelationshipDocuments( + spreeSuccessResponse, + variant, + 'option_values' + ) + + const options: SelectedOption[] = spreeOptionValues.map( + (spreeOptionValue) => { + const spreeOptionType = + jsonApi.findSingleRelationshipDocument( + spreeSuccessResponse, + spreeOptionValue, + 'option_type' + ) + + if (spreeOptionType === null) { + throw new MissingLineItemVariantError( + `Couldn't find option type of option value with id ${spreeOptionValue.id}.` + ) + } + + const label = isColorProductOption(spreeOptionType) + ? spreeOptionValue.attributes.name + : spreeOptionValue.attributes.presentation + + return { + id: spreeOptionValue.id, + name: spreeOptionType.attributes.presentation, + value: label, + } + } + ) + + return { + id: spreeLineItem.id, + variantId: variant.id, + productId: product.id, + name: spreeLineItem.attributes.name, + quantity: spreeLineItem.attributes.quantity, + discounts: [], // TODO: Implement when the template starts displaying them. + path, + variant: normalizeVariant(spreeSuccessResponse, variant), + options, + } +} + +const normalizeCart = ( + spreeSuccessResponse: SpreeSdkResponse, + spreeCart: OrderAttr +): Cart => { + const lineItems = jsonApi + .findRelationshipDocuments( + spreeSuccessResponse, + spreeCart, + 'line_items' + ) + .map((lineItem) => normalizeLineItem(spreeSuccessResponse, lineItem)) + + return { + id: spreeCart.id, + createdAt: spreeCart.attributes.created_at.toString(), + currency: { code: spreeCart.attributes.currency }, + taxesIncluded: true, + lineItems, + lineItemsSubtotalPrice: parseFloat(spreeCart.attributes.item_total), + subtotalPrice: parseFloat(spreeCart.attributes.item_total), + totalPrice: parseFloat(spreeCart.attributes.total), + customerId: spreeCart.attributes.token, + email: spreeCart.attributes.email, + discounts: [], // TODO: Implement when the template starts displaying them. + } +} + +export { normalizeLineItem } + +export default normalizeCart diff --git a/services/frontend/packages/spree/src/utils/normalizations/normalize-page.ts b/services/frontend/packages/spree/src/utils/normalizations/normalize-page.ts new file mode 100644 index 00000000..a2464ffd --- /dev/null +++ b/services/frontend/packages/spree/src/utils/normalizations/normalize-page.ts @@ -0,0 +1,42 @@ +import { Page } from '@vercel/commerce/types/page' +import type { PageAttr } from '@spree/storefront-api-v2-sdk/types/interfaces/Page' +import { SpreeSdkResponse } from '../../types' + +const normalizePage = ( + _spreeSuccessResponse: SpreeSdkResponse, + spreePage: PageAttr, + commerceLocales: string[] +): Page => { + // If the locale returned by Spree is not available, search + // for a similar one. + + const spreeLocale = spreePage.attributes.locale + let usedCommerceLocale: string + + if (commerceLocales.includes(spreeLocale)) { + usedCommerceLocale = spreeLocale + } else { + const genericSpreeLocale = spreeLocale.split('-')[0] + + const foundExactGenericLocale = commerceLocales.includes(genericSpreeLocale) + + if (foundExactGenericLocale) { + usedCommerceLocale = genericSpreeLocale + } else { + const foundSimilarLocale = commerceLocales.find((locale) => { + return locale.split('-')[0] === genericSpreeLocale + }) + + usedCommerceLocale = foundSimilarLocale || spreeLocale + } + } + + return { + id: spreePage.id, + name: spreePage.attributes.title, + url: `/${usedCommerceLocale}/${spreePage.attributes.slug}`, + body: spreePage.attributes.content, + } +} + +export default normalizePage diff --git a/services/frontend/packages/spree/src/utils/normalizations/normalize-product.ts b/services/frontend/packages/spree/src/utils/normalizations/normalize-product.ts new file mode 100644 index 00000000..6965f019 --- /dev/null +++ b/services/frontend/packages/spree/src/utils/normalizations/normalize-product.ts @@ -0,0 +1,240 @@ +import type { + Product, + ProductImage, + ProductPrice, + ProductVariant, +} from '@vercel/commerce/types/product' +import type { ProductAttr } from '@spree/storefront-api-v2-sdk/types/interfaces/Product' +import type { RelationType } from '@spree/storefront-api-v2-sdk/types/interfaces/Relationships' +import { jsonApi } from '@spree/storefront-api-v2-sdk' +import { JsonApiDocument } from '@spree/storefront-api-v2-sdk/types/interfaces/JsonApi' +import { requireConfigValue } from '../../isomorphic-config' +import createGetAbsoluteImageUrl from '../create-get-absolute-image-url' +import expandOptions from '../expand-options' +import getMediaGallery from '../get-media-gallery' +import getProductPath from '../get-product-path' +import MissingPrimaryVariantError from '../../errors/MissingPrimaryVariantError' +import MissingOptionValueError from '../../errors/MissingOptionValueError' +import type { + ExpandedProductOption, + SpreeSdkResponse, + VariantAttr, +} from '../../types' + +const placeholderImage = requireConfigValue('productPlaceholderImageUrl') as + | string + | false + +const imagesOptionFilter = requireConfigValue('imagesOptionFilter') as + | string + | false + +const normalizeProduct = ( + spreeSuccessResponse: SpreeSdkResponse, + spreeProduct: ProductAttr +): Product => { + const spreePrimaryVariant = + jsonApi.findSingleRelationshipDocument( + spreeSuccessResponse, + spreeProduct, + 'primary_variant' + ) + + if (spreePrimaryVariant === null) { + throw new MissingPrimaryVariantError( + `Couldn't find primary variant for product with id ${spreeProduct.id}.` + ) + } + + const sku = spreePrimaryVariant.attributes.sku + + const price: ProductPrice = { + value: parseFloat(spreeProduct.attributes.price), + currencyCode: spreeProduct.attributes.currency, + } + + const hasNonMasterVariants = + (spreeProduct.relationships.variants.data as RelationType[]).length > 1 + + const showOptions = + (requireConfigValue('showSingleVariantOptions') as boolean) || + hasNonMasterVariants + + let options: ExpandedProductOption[] = [] + + const spreeVariantRecords = jsonApi.findRelationshipDocuments( + spreeSuccessResponse, + spreeProduct, + 'variants' + ) + + // Use variants with option values if available. Fall back to + // Spree primary_variant if no explicit variants are present. + const spreeOptionsVariantsOrPrimary = + spreeVariantRecords.length === 0 + ? [spreePrimaryVariant] + : spreeVariantRecords + + const variants: ProductVariant[] = spreeOptionsVariantsOrPrimary.map( + (spreeVariantRecord) => { + let variantOptions: ExpandedProductOption[] = [] + + if (showOptions) { + const spreeOptionValues = jsonApi.findRelationshipDocuments( + spreeSuccessResponse, + spreeVariantRecord, + 'option_values' + ) + + // Only include options which are used by variants. + + spreeOptionValues.forEach((spreeOptionValue) => { + variantOptions = expandOptions( + spreeSuccessResponse, + spreeOptionValue, + variantOptions + ) + + options = expandOptions( + spreeSuccessResponse, + spreeOptionValue, + options + ) + }) + } + + return { + id: spreeVariantRecord.id, + options: variantOptions, + } + } + ) + + const spreePrimaryVariantImageRecords = jsonApi.findRelationshipDocuments( + spreeSuccessResponse, + spreePrimaryVariant, + 'images' + ) + + let spreeVariantImageRecords: JsonApiDocument[] + + if (imagesOptionFilter === false) { + spreeVariantImageRecords = spreeVariantRecords.reduce( + (accumulatedImageRecords, spreeVariantRecord) => { + return [ + ...accumulatedImageRecords, + ...jsonApi.findRelationshipDocuments( + spreeSuccessResponse, + spreeVariantRecord, + 'images' + ), + ] + }, + [] + ) + } else { + const spreeOptionTypes = jsonApi.findRelationshipDocuments( + spreeSuccessResponse, + spreeProduct, + 'option_types' + ) + + const imagesFilterOptionType = spreeOptionTypes.find( + (spreeOptionType) => + spreeOptionType.attributes.name === imagesOptionFilter + ) + + if (!imagesFilterOptionType) { + console.warn( + `Couldn't find option type having name ${imagesOptionFilter} for product with id ${spreeProduct.id}.` + + ' Showing no images for this product.' + ) + + spreeVariantImageRecords = [] + } else { + const imagesOptionTypeFilterId = imagesFilterOptionType.id + const includedOptionValuesImagesIds: string[] = [] + + spreeVariantImageRecords = spreeVariantRecords.reduce( + (accumulatedImageRecords, spreeVariantRecord) => { + const spreeVariantOptionValuesIdentifiers: RelationType[] = + spreeVariantRecord.relationships.option_values.data + + const spreeOptionValueOfFilterTypeIdentifier = + spreeVariantOptionValuesIdentifiers.find( + (spreeVariantOptionValuesIdentifier: RelationType) => + imagesFilterOptionType.relationships.option_values.data.some( + (filterOptionTypeValueIdentifier: RelationType) => + filterOptionTypeValueIdentifier.id === + spreeVariantOptionValuesIdentifier.id + ) + ) + + if (!spreeOptionValueOfFilterTypeIdentifier) { + throw new MissingOptionValueError( + `Couldn't find option value related to option type with id ${imagesOptionTypeFilterId}.` + ) + } + + const optionValueImagesAlreadyIncluded = + includedOptionValuesImagesIds.includes( + spreeOptionValueOfFilterTypeIdentifier.id + ) + + if (optionValueImagesAlreadyIncluded) { + return accumulatedImageRecords + } + + includedOptionValuesImagesIds.push( + spreeOptionValueOfFilterTypeIdentifier.id + ) + + return [ + ...accumulatedImageRecords, + ...jsonApi.findRelationshipDocuments( + spreeSuccessResponse, + spreeVariantRecord, + 'images' + ), + ] + }, + [] + ) + } + } + + const spreeImageRecords = [ + ...spreePrimaryVariantImageRecords, + ...spreeVariantImageRecords, + ] + + const productImages = getMediaGallery( + spreeImageRecords, + createGetAbsoluteImageUrl(requireConfigValue('imageHost') as string) + ) + + const images: ProductImage[] = + productImages.length === 0 + ? placeholderImage === false + ? [] + : [{ url: placeholderImage }] + : productImages + + const slug = spreeProduct.attributes.slug + const path = getProductPath(spreeProduct) + + return { + id: spreeProduct.id, + name: spreeProduct.attributes.name, + description: spreeProduct.attributes.description, + images, + variants, + options, + price, + slug, + path, + sku, + } +} + +export default normalizeProduct diff --git a/services/frontend/packages/spree/src/utils/normalizations/normalize-user.ts b/services/frontend/packages/spree/src/utils/normalizations/normalize-user.ts new file mode 100644 index 00000000..8b738fba --- /dev/null +++ b/services/frontend/packages/spree/src/utils/normalizations/normalize-user.ts @@ -0,0 +1,16 @@ +import type { Customer } from '@vercel/commerce/types/customer' +import type { AccountAttr } from '@spree/storefront-api-v2-sdk/types/interfaces/Account' +import type { SpreeSdkResponse } from '../../types' + +const normalizeUser = ( + _spreeSuccessResponse: SpreeSdkResponse, + spreeUser: AccountAttr +): Customer => { + const email = spreeUser.attributes.email + + return { + email, + } +} + +export default normalizeUser diff --git a/services/frontend/packages/spree/src/utils/normalizations/normalize-wishlist.ts b/services/frontend/packages/spree/src/utils/normalizations/normalize-wishlist.ts new file mode 100644 index 00000000..c9cfee2d --- /dev/null +++ b/services/frontend/packages/spree/src/utils/normalizations/normalize-wishlist.ts @@ -0,0 +1,68 @@ +import MissingProductError from '../../errors/MissingProductError' +import MissingVariantError from '../../errors/MissingVariantError' +import { jsonApi } from '@spree/storefront-api-v2-sdk' +import type { ProductAttr } from '@spree/storefront-api-v2-sdk/types/interfaces/Product' +import type { WishedItemAttr } from '@spree/storefront-api-v2-sdk/types/interfaces/WishedItem' +import type { WishlistAttr } from '@spree/storefront-api-v2-sdk/types/interfaces/Wishlist' +import type { + ExplicitCommerceWishlist, + SpreeSdkResponse, + VariantAttr, +} from '../../types' +import normalizeProduct from './normalize-product' + +const normalizeWishlist = ( + spreeSuccessResponse: SpreeSdkResponse, + spreeWishlist: WishlistAttr +): ExplicitCommerceWishlist => { + const spreeWishedItems = jsonApi.findRelationshipDocuments( + spreeSuccessResponse, + spreeWishlist, + 'wished_items' + ) + + const items: ExplicitCommerceWishlist['items'] = spreeWishedItems.map( + (spreeWishedItem) => { + const spreeWishedVariant = + jsonApi.findSingleRelationshipDocument( + spreeSuccessResponse, + spreeWishedItem, + 'variant' + ) + + if (spreeWishedVariant === null) { + throw new MissingVariantError( + `Couldn't find variant for wished item with id ${spreeWishedItem.id}.` + ) + } + + const spreeWishedProduct = + jsonApi.findSingleRelationshipDocument( + spreeSuccessResponse, + spreeWishedVariant, + 'product' + ) + + if (spreeWishedProduct === null) { + throw new MissingProductError( + `Couldn't find product for variant with id ${spreeWishedVariant.id}.` + ) + } + + return { + id: spreeWishedItem.id, + product_id: parseInt(spreeWishedProduct.id, 10), + variant_id: parseInt(spreeWishedVariant.id, 10), + product: normalizeProduct(spreeSuccessResponse, spreeWishedProduct), + } + } + ) + + return { + id: spreeWishlist.id, + token: spreeWishlist.attributes.token, + items, + } +} + +export default normalizeWishlist diff --git a/services/frontend/packages/spree/src/utils/pretty-print-spree-sdk-errors.ts b/services/frontend/packages/spree/src/utils/pretty-print-spree-sdk-errors.ts new file mode 100644 index 00000000..79174204 --- /dev/null +++ b/services/frontend/packages/spree/src/utils/pretty-print-spree-sdk-errors.ts @@ -0,0 +1,21 @@ +import { errors } from '@spree/storefront-api-v2-sdk' + +const prettyPrintSpreeSdkErrors = (error: errors.SpreeSDKError): string => { + let prettyOutput = `Name: ${error.name}\nMessage: ${error.message}` + + if (error instanceof errors.BasicSpreeError) { + prettyOutput += `\nSpree summary: ${error.summary}` + + if (error instanceof errors.ExpandedSpreeError) { + prettyOutput += `\nSpree validation errors:\n${JSON.stringify( + error.errors, + null, + 2 + )}` + } + } + + return prettyOutput +} + +export default prettyPrintSpreeSdkErrors diff --git a/services/frontend/packages/spree/src/utils/require-config.ts b/services/frontend/packages/spree/src/utils/require-config.ts new file mode 100644 index 00000000..92b7916c --- /dev/null +++ b/services/frontend/packages/spree/src/utils/require-config.ts @@ -0,0 +1,16 @@ +import MissingConfigurationValueError from '../errors/MissingConfigurationValueError' +import type { NonUndefined, ValueOf } from '../types' + +const requireConfig = (isomorphicConfig: T, key: keyof T) => { + const valueUnderKey = isomorphicConfig[key] + + if (typeof valueUnderKey === 'undefined') { + throw new MissingConfigurationValueError( + `Value for configuration key ${key} was undefined.` + ) + } + + return valueUnderKey as NonUndefined> +} + +export default requireConfig diff --git a/services/frontend/packages/spree/src/utils/sort-option-types.ts b/services/frontend/packages/spree/src/utils/sort-option-types.ts new file mode 100644 index 00000000..bac632e0 --- /dev/null +++ b/services/frontend/packages/spree/src/utils/sort-option-types.ts @@ -0,0 +1,11 @@ +import type { ExpandedProductOption } from '../types' + +const sortOptionsByPosition = ( + options: ExpandedProductOption[] +): ExpandedProductOption[] => { + return options.sort((firstOption, secondOption) => { + return firstOption.position - secondOption.position + }) +} + +export default sortOptionsByPosition diff --git a/services/frontend/packages/spree/src/utils/tokens/cart-token.ts b/services/frontend/packages/spree/src/utils/tokens/cart-token.ts new file mode 100644 index 00000000..8352f9ad --- /dev/null +++ b/services/frontend/packages/spree/src/utils/tokens/cart-token.ts @@ -0,0 +1,21 @@ +import { requireConfigValue } from '../../isomorphic-config' +import Cookies from 'js-cookie' + +export const getCartToken = () => + Cookies.get(requireConfigValue('cartCookieName') as string) + +export const setCartToken = (cartToken: string) => { + const cookieOptions = { + expires: requireConfigValue('cartCookieExpire') as number, + } + + Cookies.set( + requireConfigValue('cartCookieName') as string, + cartToken, + cookieOptions + ) +} + +export const removeCartToken = () => { + Cookies.remove(requireConfigValue('cartCookieName') as string) +} diff --git a/services/frontend/packages/spree/src/utils/tokens/ensure-fresh-user-access-token.ts b/services/frontend/packages/spree/src/utils/tokens/ensure-fresh-user-access-token.ts new file mode 100644 index 00000000..de22634f --- /dev/null +++ b/services/frontend/packages/spree/src/utils/tokens/ensure-fresh-user-access-token.ts @@ -0,0 +1,51 @@ +import { SpreeSdkResponseWithRawResponse } from '../../types' +import type { Client } from '@spree/storefront-api-v2-sdk' +import type { IOAuthToken } from '@spree/storefront-api-v2-sdk/types/interfaces/Token' +import getSpreeSdkMethodFromEndpointPath from '../get-spree-sdk-method-from-endpoint-path' +import { + ensureUserTokenResponse, + removeUserTokenResponse, + setUserTokenResponse, +} from './user-token-response' +import AccessTokenError from '../../errors/AccessTokenError' + +/** + * If the user has a saved access token, make sure it's not expired + * If it is expired, attempt to refresh it. + */ +const ensureFreshUserAccessToken = async (client: Client): Promise => { + const userTokenResponse = ensureUserTokenResponse() + + if (!userTokenResponse) { + // There's no user token or it has an invalid format. + return + } + + const isAccessTokenExpired = + (userTokenResponse.created_at + userTokenResponse.expires_in) * 1000 < + Date.now() + + if (!isAccessTokenExpired) { + return + } + + const spreeRefreshAccessTokenSdkMethod = getSpreeSdkMethodFromEndpointPath< + Client, + SpreeSdkResponseWithRawResponse & IOAuthToken + >(client, 'authentication.refreshToken') + + const spreeRefreshAccessTokenResponse = + await spreeRefreshAccessTokenSdkMethod({ + refresh_token: userTokenResponse.refresh_token, + }) + + if (spreeRefreshAccessTokenResponse.isFail()) { + removeUserTokenResponse() + + throw new AccessTokenError('Could not refresh access token.') + } + + setUserTokenResponse(spreeRefreshAccessTokenResponse.success()) +} + +export default ensureFreshUserAccessToken diff --git a/services/frontend/packages/spree/src/utils/tokens/ensure-itoken.ts b/services/frontend/packages/spree/src/utils/tokens/ensure-itoken.ts new file mode 100644 index 00000000..0d4e6f89 --- /dev/null +++ b/services/frontend/packages/spree/src/utils/tokens/ensure-itoken.ts @@ -0,0 +1,25 @@ +import type { IToken } from '@spree/storefront-api-v2-sdk/types/interfaces/Token' +import { getCartToken } from './cart-token' +import { ensureUserTokenResponse } from './user-token-response' + +const ensureIToken = (): IToken | undefined => { + const userTokenResponse = ensureUserTokenResponse() + + if (userTokenResponse) { + return { + bearerToken: userTokenResponse.access_token, + } + } + + const cartToken = getCartToken() + + if (cartToken) { + return { + orderToken: cartToken, + } + } + + return undefined +} + +export default ensureIToken diff --git a/services/frontend/packages/spree/src/utils/tokens/is-logged-in.ts b/services/frontend/packages/spree/src/utils/tokens/is-logged-in.ts new file mode 100644 index 00000000..218c25bd --- /dev/null +++ b/services/frontend/packages/spree/src/utils/tokens/is-logged-in.ts @@ -0,0 +1,9 @@ +import { ensureUserTokenResponse } from './user-token-response' + +const isLoggedIn = (): boolean => { + const userTokenResponse = ensureUserTokenResponse() + + return !!userTokenResponse +} + +export default isLoggedIn diff --git a/services/frontend/packages/spree/src/utils/tokens/revoke-user-tokens.ts b/services/frontend/packages/spree/src/utils/tokens/revoke-user-tokens.ts new file mode 100644 index 00000000..82133542 --- /dev/null +++ b/services/frontend/packages/spree/src/utils/tokens/revoke-user-tokens.ts @@ -0,0 +1,49 @@ +import type { GraphQLFetcherResult } from '@vercel/commerce/api' +import type { HookFetcherContext } from '@vercel/commerce/utils/types' +import TokensNotRejectedError from '../../errors/TokensNotRejectedError' +import type { UserOAuthTokens } from '../../types' +import type { EmptyObjectResponse } from '@spree/storefront-api-v2-sdk/types/interfaces/EmptyObject' + +const revokeUserTokens = async ( + fetch: HookFetcherContext<{ + data: any + }>['fetch'], + userTokens: UserOAuthTokens +): Promise => { + const spreeRevokeTokensResponses = await Promise.allSettled([ + fetch>({ + variables: { + methodPath: 'authentication.revokeToken', + arguments: [ + { + token: userTokens.refreshToken, + }, + ], + }, + }), + fetch>({ + variables: { + methodPath: 'authentication.revokeToken', + arguments: [ + { + token: userTokens.accessToken, + }, + ], + }, + }), + ]) + + const anyRejected = spreeRevokeTokensResponses.some( + (response) => response.status === 'rejected' + ) + + if (anyRejected) { + throw new TokensNotRejectedError( + 'Some tokens could not be rejected in Spree.' + ) + } + + return undefined +} + +export default revokeUserTokens diff --git a/services/frontend/packages/spree/src/utils/tokens/user-token-response.ts b/services/frontend/packages/spree/src/utils/tokens/user-token-response.ts new file mode 100644 index 00000000..0c524ecc --- /dev/null +++ b/services/frontend/packages/spree/src/utils/tokens/user-token-response.ts @@ -0,0 +1,58 @@ +import { requireConfigValue } from '../../isomorphic-config' +import Cookies from 'js-cookie' +import type { IOAuthToken } from '@spree/storefront-api-v2-sdk/types/interfaces/Token' +import UserTokenResponseParseError from '../../errors/UserTokenResponseParseError' + +export const getUserTokenResponse = (): IOAuthToken | undefined => { + const stringifiedToken = Cookies.get( + requireConfigValue('userCookieName') as string + ) + + if (!stringifiedToken) { + return undefined + } + + try { + const token: IOAuthToken = JSON.parse(stringifiedToken) + + return token + } catch (parseError) { + throw new UserTokenResponseParseError( + 'Could not parse stored user token response.' + ) + } +} + +/** + * Retrieves the saved user token response. If the response fails json parsing, + * removes the saved token and returns @type {undefined} instead. + */ +export const ensureUserTokenResponse = (): IOAuthToken | undefined => { + try { + return getUserTokenResponse() + } catch (error) { + if (error instanceof UserTokenResponseParseError) { + removeUserTokenResponse() + + return undefined + } + + throw error + } +} + +export const setUserTokenResponse = (token: IOAuthToken) => { + const cookieOptions = { + expires: requireConfigValue('userCookieExpire') as number, + } + + Cookies.set( + requireConfigValue('userCookieName') as string, + JSON.stringify(token), + cookieOptions + ) +} + +export const removeUserTokenResponse = () => { + Cookies.remove(requireConfigValue('userCookieName') as string) +} diff --git a/services/frontend/packages/spree/src/utils/validations/validate-all-products-taxonomy-id.ts b/services/frontend/packages/spree/src/utils/validations/validate-all-products-taxonomy-id.ts new file mode 100644 index 00000000..5eaaa0b4 --- /dev/null +++ b/services/frontend/packages/spree/src/utils/validations/validate-all-products-taxonomy-id.ts @@ -0,0 +1,13 @@ +const validateAllProductsTaxonomyId = (taxonomyId: unknown): string | false => { + if (!taxonomyId || taxonomyId === 'false') { + return false + } + + if (typeof taxonomyId === 'string') { + return taxonomyId + } + + throw new TypeError('taxonomyId must be a string or falsy.') +} + +export default validateAllProductsTaxonomyId diff --git a/services/frontend/packages/spree/src/utils/validations/validate-cookie-expire.ts b/services/frontend/packages/spree/src/utils/validations/validate-cookie-expire.ts new file mode 100644 index 00000000..1bd98727 --- /dev/null +++ b/services/frontend/packages/spree/src/utils/validations/validate-cookie-expire.ts @@ -0,0 +1,21 @@ +const validateCookieExpire = (expire: unknown): number => { + let expireInteger: number + + if (typeof expire === 'string') { + expireInteger = parseFloat(expire) + } else if (typeof expire === 'number') { + expireInteger = expire + } else { + throw new TypeError( + 'expire must be a string containing a number or an integer.' + ) + } + + if (expireInteger < 0) { + throw new RangeError('expire must be non-negative.') + } + + return expireInteger +} + +export default validateCookieExpire diff --git a/services/frontend/packages/spree/src/utils/validations/validate-images-option-filter.ts b/services/frontend/packages/spree/src/utils/validations/validate-images-option-filter.ts new file mode 100644 index 00000000..8b6ef989 --- /dev/null +++ b/services/frontend/packages/spree/src/utils/validations/validate-images-option-filter.ts @@ -0,0 +1,15 @@ +const validateImagesOptionFilter = ( + optionTypeNameOrFalse: unknown +): string | false => { + if (!optionTypeNameOrFalse || optionTypeNameOrFalse === 'false') { + return false + } + + if (typeof optionTypeNameOrFalse === 'string') { + return optionTypeNameOrFalse + } + + throw new TypeError('optionTypeNameOrFalse must be a string or falsy.') +} + +export default validateImagesOptionFilter diff --git a/services/frontend/packages/spree/src/utils/validations/validate-images-quality.ts b/services/frontend/packages/spree/src/utils/validations/validate-images-quality.ts new file mode 100644 index 00000000..909caad5 --- /dev/null +++ b/services/frontend/packages/spree/src/utils/validations/validate-images-quality.ts @@ -0,0 +1,23 @@ +const validateImagesQuality = (quality: unknown): number => { + let quality_level: number + + if (typeof quality === 'string') { + quality_level = parseInt(quality) + } else if (typeof quality === 'number') { + quality_level = quality + } else { + throw new TypeError( + 'prerenderCount count must be a string containing a number or an integer.' + ) + } + + if (quality_level === NaN) { + throw new TypeError( + 'prerenderCount count must be a string containing a number or an integer.' + ) + } + + return quality_level +} + +export default validateImagesQuality diff --git a/services/frontend/packages/spree/src/utils/validations/validate-images-size.ts b/services/frontend/packages/spree/src/utils/validations/validate-images-size.ts new file mode 100644 index 00000000..e02036da --- /dev/null +++ b/services/frontend/packages/spree/src/utils/validations/validate-images-size.ts @@ -0,0 +1,13 @@ +const validateImagesSize = (size: unknown): string => { + if (typeof size !== 'string') { + throw new TypeError('size must be a string.') + } + + if (!size.includes('x') || size.split('x').length != 2) { + throw new Error("size must have two numbers separated with an 'x'") + } + + return size +} + +export default validateImagesSize diff --git a/services/frontend/packages/spree/src/utils/validations/validate-placeholder-image-url.ts b/services/frontend/packages/spree/src/utils/validations/validate-placeholder-image-url.ts new file mode 100644 index 00000000..cce2e27d --- /dev/null +++ b/services/frontend/packages/spree/src/utils/validations/validate-placeholder-image-url.ts @@ -0,0 +1,15 @@ +const validatePlaceholderImageUrl = ( + placeholderUrlOrFalse: unknown +): string | false => { + if (!placeholderUrlOrFalse || placeholderUrlOrFalse === 'false') { + return false + } + + if (typeof placeholderUrlOrFalse === 'string') { + return placeholderUrlOrFalse + } + + throw new TypeError('placeholderUrlOrFalse must be a string or falsy.') +} + +export default validatePlaceholderImageUrl diff --git a/services/frontend/packages/spree/src/utils/validations/validate-products-prerender-count.ts b/services/frontend/packages/spree/src/utils/validations/validate-products-prerender-count.ts new file mode 100644 index 00000000..024db1ea --- /dev/null +++ b/services/frontend/packages/spree/src/utils/validations/validate-products-prerender-count.ts @@ -0,0 +1,21 @@ +const validateProductsPrerenderCount = (prerenderCount: unknown): number => { + let prerenderCountInteger: number + + if (typeof prerenderCount === 'string') { + prerenderCountInteger = parseInt(prerenderCount) + } else if (typeof prerenderCount === 'number') { + prerenderCountInteger = prerenderCount + } else { + throw new TypeError( + 'prerenderCount count must be a string containing a number or an integer.' + ) + } + + if (prerenderCountInteger < 0) { + throw new RangeError('prerenderCount must be non-negative.') + } + + return prerenderCountInteger +} + +export default validateProductsPrerenderCount diff --git a/services/frontend/packages/spree/src/wishlist/index.ts b/services/frontend/packages/spree/src/wishlist/index.ts new file mode 100644 index 00000000..241af3c7 --- /dev/null +++ b/services/frontend/packages/spree/src/wishlist/index.ts @@ -0,0 +1,3 @@ +export { default as useAddItem } from './use-add-item' +export { default as useWishlist } from './use-wishlist' +export { default as useRemoveItem } from './use-remove-item' diff --git a/services/frontend/packages/spree/src/wishlist/use-add-item.tsx b/services/frontend/packages/spree/src/wishlist/use-add-item.tsx new file mode 100644 index 00000000..010a71e7 --- /dev/null +++ b/services/frontend/packages/spree/src/wishlist/use-add-item.tsx @@ -0,0 +1,88 @@ +import { useCallback } from 'react' +import type { MutationHook } from '@vercel/commerce/utils/types' +import useAddItem from '@vercel/commerce/wishlist/use-add-item' +import type { UseAddItem } from '@vercel/commerce/wishlist/use-add-item' +import useWishlist from './use-wishlist' +import type { ExplicitWishlistAddItemHook } from '../types' +import type { + WishedItem, + WishlistsAddWishedItem, +} from '@spree/storefront-api-v2-sdk/types/interfaces/WishedItem' +import type { GraphQLFetcherResult } from '@vercel/commerce/api' +import ensureIToken from '../utils/tokens/ensure-itoken' +import type { IToken } from '@spree/storefront-api-v2-sdk/types/interfaces/Token' +import type { AddItemHook } from '@vercel/commerce/types/wishlist' +import isLoggedIn from '../utils/tokens/is-logged-in' + +export default useAddItem as UseAddItem + +export const handler: MutationHook = { + fetchOptions: { + url: 'wishlists', + query: 'addWishedItem', + }, + async fetcher({ input, options, fetch }) { + console.info( + 'useAddItem (wishlist) fetcher called. Configuration: ', + 'input: ', + input, + 'options: ', + options + ) + + const { + item: { productId, variantId, wishlistToken }, + } = input + + if (!isLoggedIn() || !wishlistToken) { + return null + } + + let token: IToken | undefined = ensureIToken() + + const addItemParameters: WishlistsAddWishedItem = { + variant_id: `${variantId}`, + quantity: 1, + } + + await fetch>({ + variables: { + methodPath: 'wishlists.addWishedItem', + arguments: [token, wishlistToken, addItemParameters], + }, + }) + + return null + }, + useHook: ({ fetch }) => { + const useWrappedHook: ReturnType< + MutationHook['useHook'] + > = () => { + const wishlist = useWishlist() + + return useCallback( + async (item) => { + if (!wishlist.data) { + return null + } + + const data = await fetch({ + input: { + item: { + ...item, + wishlistToken: wishlist.data.token, + }, + }, + }) + + await wishlist.mutate() + + return data + }, + [wishlist] + ) + } + + return useWrappedHook + }, +} diff --git a/services/frontend/packages/spree/src/wishlist/use-remove-item.tsx b/services/frontend/packages/spree/src/wishlist/use-remove-item.tsx new file mode 100644 index 00000000..d8481f5a --- /dev/null +++ b/services/frontend/packages/spree/src/wishlist/use-remove-item.tsx @@ -0,0 +1,75 @@ +import { useCallback } from 'react' +import type { MutationHook } from '@vercel/commerce/utils/types' +import useRemoveItem from '@vercel/commerce/wishlist/use-remove-item' +import type { UseRemoveItem } from '@vercel/commerce/wishlist/use-remove-item' +import useWishlist from './use-wishlist' +import type { ExplicitWishlistRemoveItemHook } from '../types' +import isLoggedIn from '../utils/tokens/is-logged-in' +import ensureIToken from '../utils/tokens/ensure-itoken' +import type { IToken } from '@spree/storefront-api-v2-sdk/types/interfaces/Token' +import type { GraphQLFetcherResult } from '@vercel/commerce/api' +import type { WishedItem } from '@spree/storefront-api-v2-sdk/types/interfaces/WishedItem' + +export default useRemoveItem as UseRemoveItem + +export const handler: MutationHook = { + fetchOptions: { + url: 'wishlists', + query: 'removeWishedItem', + }, + async fetcher({ input, options, fetch }) { + console.info( + 'useRemoveItem (wishlist) fetcher called. Configuration: ', + 'input: ', + input, + 'options: ', + options + ) + + const { itemId, wishlistToken } = input + + if (!isLoggedIn() || !wishlistToken) { + return null + } + + let token: IToken | undefined = ensureIToken() + + await fetch>({ + variables: { + methodPath: 'wishlists.removeWishedItem', + arguments: [token, wishlistToken, itemId], + }, + }) + + return null + }, + useHook: ({ fetch }) => { + const useWrappedHook: ReturnType< + MutationHook['useHook'] + > = () => { + const wishlist = useWishlist() + + return useCallback( + async (input) => { + if (!wishlist.data) { + return null + } + + const data = await fetch({ + input: { + itemId: `${input.id}`, + wishlistToken: wishlist.data.token, + }, + }) + + await wishlist.mutate() + + return data + }, + [wishlist] + ) + } + + return useWrappedHook + }, +} diff --git a/services/frontend/packages/spree/src/wishlist/use-wishlist.tsx b/services/frontend/packages/spree/src/wishlist/use-wishlist.tsx new file mode 100644 index 00000000..9f258625 --- /dev/null +++ b/services/frontend/packages/spree/src/wishlist/use-wishlist.tsx @@ -0,0 +1,93 @@ +import { useMemo } from 'react' +import type { SWRHook } from '@vercel/commerce/utils/types' +import useWishlist from '@vercel/commerce/wishlist/use-wishlist' +import type { UseWishlist } from '@vercel/commerce/wishlist/use-wishlist' +import type { GetWishlistHook } from '@vercel/commerce/types/wishlist' +import type { IToken } from '@spree/storefront-api-v2-sdk/types/interfaces/Token' +import type { GraphQLFetcherResult } from '@vercel/commerce/api' +import type { Wishlist } from '@spree/storefront-api-v2-sdk/types/interfaces/Wishlist' +import ensureIToken from '../utils/tokens/ensure-itoken' +import normalizeWishlist from '../utils/normalizations/normalize-wishlist' +import isLoggedIn from '../utils/tokens/is-logged-in' + +export default useWishlist as UseWishlist + +export const handler: SWRHook = { + // Provide fetchOptions for SWR cache key + fetchOptions: { + url: 'wishlists', + query: 'default', + }, + async fetcher({ input, options, fetch }) { + console.info( + 'useWishlist fetcher called. Configuration: ', + 'input: ', + input, + 'options: ', + options + ) + + if (!isLoggedIn()) { + return null + } + + // TODO: Optimize with includeProducts. + + const token: IToken | undefined = ensureIToken() + + const { data: spreeWishlistsDefaultSuccessResponse } = await fetch< + GraphQLFetcherResult + >({ + variables: { + methodPath: 'wishlists.default', + arguments: [ + token, + { + include: [ + 'wished_items', + 'wished_items.variant', + 'wished_items.variant.product', + 'wished_items.variant.product.primary_variant', + 'wished_items.variant.product.images', + 'wished_items.variant.product.option_types', + 'wished_items.variant.product.variants', + 'wished_items.variant.product.variants.option_values', + ].join(','), + }, + ], + }, + }) + + return normalizeWishlist( + spreeWishlistsDefaultSuccessResponse, + spreeWishlistsDefaultSuccessResponse.data + ) + }, + useHook: ({ useData }) => { + const useWrappedHook: ReturnType['useHook']> = ( + input + ) => { + const response = useData({ + swrOptions: { + revalidateOnFocus: false, + ...input?.swrOptions, + }, + }) + + return useMemo( + () => + Object.create(response, { + isEmpty: { + get() { + return (response.data?.items?.length || 0) <= 0 + }, + enumerable: true, + }, + }), + [response] + ) + } + + return useWrappedHook + }, +} diff --git a/services/frontend/packages/spree/taskfile.js b/services/frontend/packages/spree/taskfile.js new file mode 100644 index 00000000..39b1b2a8 --- /dev/null +++ b/services/frontend/packages/spree/taskfile.js @@ -0,0 +1,20 @@ +export async function build(task, opts) { + await task + .source('src/**/*.+(ts|tsx|js)') + .swc({ dev: opts.dev, outDir: 'dist', baseUrl: 'src' }) + .target('dist') + .source('src/**/*.+(cjs|json)') + .target('dist') + task.$.log('Compiled src files') +} + +export async function release(task) { + await task.clear('dist').start('build') +} + +export default async function dev(task) { + const opts = { dev: true } + await task.clear('dist') + await task.start('build', opts) + await task.watch('src/**/*.+(ts|tsx|js|cjs|json)', 'build', opts) +} diff --git a/services/frontend/packages/spree/tsconfig.json b/services/frontend/packages/spree/tsconfig.json new file mode 100644 index 00000000..cd04ab2f --- /dev/null +++ b/services/frontend/packages/spree/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "outDir": "dist", + "baseUrl": "src", + "lib": ["dom", "dom.iterable", "esnext"], + "declaration": true, + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "incremental": true, + "jsx": "react-jsx" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/services/frontend/packages/swell/.env.template b/services/frontend/packages/swell/.env.template new file mode 100644 index 00000000..62bbcf50 --- /dev/null +++ b/services/frontend/packages/swell/.env.template @@ -0,0 +1,7 @@ +COMMERCE_PROVIDER=@vercel/commerce-swell + +SWELL_STORE_DOMAIN= +SWELL_STOREFRONT_ACCESS_TOKEN= + +NEXT_PUBLIC_SWELL_STORE_ID= +NEXT_PUBLIC_SWELL_PUBLIC_KEY= diff --git a/services/frontend/packages/swell/.prettierignore b/services/frontend/packages/swell/.prettierignore new file mode 100644 index 00000000..f06235c4 --- /dev/null +++ b/services/frontend/packages/swell/.prettierignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/services/frontend/packages/swell/.prettierrc b/services/frontend/packages/swell/.prettierrc new file mode 100644 index 00000000..e1076edf --- /dev/null +++ b/services/frontend/packages/swell/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": false, + "singleQuote": true, + "tabWidth": 2, + "useTabs": false +} diff --git a/services/frontend/packages/swell/package.json b/services/frontend/packages/swell/package.json new file mode 100644 index 00000000..27a17988 --- /dev/null +++ b/services/frontend/packages/swell/package.json @@ -0,0 +1,83 @@ +{ + "name": "@vercel/commerce-swell", + "version": "0.0.1", + "license": "MIT", + "scripts": { + "release": "taskr release", + "build": "taskr build", + "dev": "taskr", + "types": "tsc --emitDeclarationOnly", + "prettier-fix": "prettier --write ." + }, + "sideEffects": false, + "type": "module", + "exports": { + ".": "./dist/index.js", + "./*": [ + "./dist/*.js", + "./dist/*/index.js" + ], + "./next.config": "./dist/next.config.cjs" + }, + "typesVersions": { + "*": { + "*": [ + "src/*", + "src/*/index" + ], + "next.config": [ + "dist/next.config.d.cts" + ] + } + }, + "files": [ + "dist", + "schema.d.ts" + ], + "publishConfig": { + "typesVersions": { + "*": { + "*": [ + "dist/*.d.ts", + "dist/*/index.d.ts" + ], + "next.config": [ + "dist/next.config.d.cts" + ] + } + } + }, + "dependencies": { + "@vercel/commerce": "^0.0.1", + "@vercel/fetch": "^6.1.1", + "swell-js": "^4.0.0-next.0", + "lodash.debounce": "^4.0.8" + }, + "peerDependencies": { + "next": "^12", + "react": "^17", + "react-dom": "^17" + }, + "devDependencies": { + "@taskr/clear": "^1.1.0", + "@taskr/esnext": "^1.1.0", + "@taskr/watch": "^1.1.0", + "@types/lodash.debounce": "^4.0.6", + "@types/node": "^17.0.8", + "@types/react": "^17.0.38", + "lint-staged": "^12.1.7", + "next": "^12.0.8", + "prettier": "^2.5.1", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "taskr": "^1.1.0", + "taskr-swc": "^0.0.1", + "typescript": "^4.5.4" + }, + "lint-staged": { + "**/*.{js,jsx,ts,tsx,json}": [ + "prettier --write", + "git add" + ] + } +} diff --git a/services/frontend/packages/swell/schema.d.ts b/services/frontend/packages/swell/schema.d.ts new file mode 100644 index 00000000..0ec62504 --- /dev/null +++ b/services/frontend/packages/swell/schema.d.ts @@ -0,0 +1,5002 @@ +export type Maybe = T | null +export type Exact = { + [K in keyof T]: T[K] +} +export type MakeOptional = Omit & + { [SubKey in K]?: Maybe } +export type MakeMaybe = Omit & + { [SubKey in K]: Maybe } +/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: string + String: string + Boolean: boolean + Int: number + Float: number + /** An ISO-8601 encoded UTC date time string. Example value: `"2019-07-03T20:47:55Z"`. */ + DateTime: any + /** A signed decimal number, which supports arbitrary precision and is serialized as a string. Example value: `"29.99"`. */ + Decimal: any + /** A string containing HTML code. Example value: `"

Grey cotton knit sweater.

"`. */ + HTML: any + /** A monetary value string. Example value: `"100.57"`. */ + Money: any + /** + * An RFC 3986 and RFC 3987 compliant URI string. + * + * Example value: `"https://johns-apparel.myshopify.com"`. + * + */ + URL: any +} + +/** A version of the API. */ +export type ApiVersion = { + __typename?: 'ApiVersion' + /** The human-readable name of the version. */ + displayName: Scalars['String'] + /** The unique identifier of an ApiVersion. All supported API versions have a date-based (YYYY-MM) or `unstable` handle. */ + handle: Scalars['String'] + /** Whether the version is supported by Shopify. */ + supported: Scalars['Boolean'] +} + +/** Details about the gift card used on the checkout. */ +export type AppliedGiftCard = Node & { + __typename?: 'AppliedGiftCard' + /** + * The amount that was taken from the gift card by applying it. + * @deprecated Use `amountUsedV2` instead + */ + amountUsed: Scalars['Money'] + /** The amount that was taken from the gift card by applying it. */ + amountUsedV2: MoneyV2 + /** + * The amount left on the gift card. + * @deprecated Use `balanceV2` instead + */ + balance: Scalars['Money'] + /** The amount left on the gift card. */ + balanceV2: MoneyV2 + /** Globally unique identifier. */ + id: Scalars['ID'] + /** The last characters of the gift card. */ + lastCharacters: Scalars['String'] + /** The amount that was applied to the checkout in its currency. */ + presentmentAmountUsed: MoneyV2 +} + +/** An article in an online store blog. */ +export type Article = Node & { + __typename?: 'Article' + /** + * The article's author. + * @deprecated Use `authorV2` instead + */ + author: ArticleAuthor + /** The article's author. */ + authorV2?: Maybe + /** The blog that the article belongs to. */ + blog: Blog + /** List of comments posted on the article. */ + comments: CommentConnection + /** Stripped content of the article, single line with HTML tags removed. */ + content: Scalars['String'] + /** The content of the article, complete with HTML formatting. */ + contentHtml: Scalars['HTML'] + /** Stripped excerpt of the article, single line with HTML tags removed. */ + excerpt?: Maybe + /** The excerpt of the article, complete with HTML formatting. */ + excerptHtml?: Maybe + /** A human-friendly unique string for the Article automatically generated from its title. */ + handle: Scalars['String'] + /** Globally unique identifier. */ + id: Scalars['ID'] + /** The image associated with the article. */ + image?: Maybe + /** The date and time when the article was published. */ + publishedAt: Scalars['DateTime'] + /** The article’s SEO information. */ + seo?: Maybe + /** A categorization that a article can be tagged with. */ + tags: Array + /** The article’s name. */ + title: Scalars['String'] + /** The url pointing to the article accessible from the web. */ + url: Scalars['URL'] +} + +/** An article in an online store blog. */ +export type ArticleCommentsArgs = { + first?: Maybe + after?: Maybe + last?: Maybe + before?: Maybe + reverse?: Maybe +} + +/** An article in an online store blog. */ +export type ArticleContentArgs = { + truncateAt?: Maybe +} + +/** An article in an online store blog. */ +export type ArticleExcerptArgs = { + truncateAt?: Maybe +} + +/** An article in an online store blog. */ +export type ArticleImageArgs = { + maxWidth?: Maybe + maxHeight?: Maybe + crop?: Maybe + scale?: Maybe +} + +/** The author of an article. */ +export type ArticleAuthor = { + __typename?: 'ArticleAuthor' + /** The author's bio. */ + bio?: Maybe + /** The author’s email. */ + email: Scalars['String'] + /** The author's first name. */ + firstName: Scalars['String'] + /** The author's last name. */ + lastName: Scalars['String'] + /** The author's full name. */ + name: Scalars['String'] +} + +/** An auto-generated type for paginating through multiple Articles. */ +export type ArticleConnection = { + __typename?: 'ArticleConnection' + /** A list of edges. */ + edges: Array + /** Information to aid in pagination. */ + pageInfo: PageInfo +} + +/** An auto-generated type which holds one Article and a cursor during pagination. */ +export type ArticleEdge = { + __typename?: 'ArticleEdge' + /** A cursor for use in pagination. */ + cursor: Scalars['String'] + /** The item at the end of ArticleEdge. */ + node: Article +} + +/** The set of valid sort keys for the Article query. */ +export enum ArticleSortKeys { + /** Sort by the `title` value. */ + Title = 'TITLE', + /** Sort by the `blog_title` value. */ + BlogTitle = 'BLOG_TITLE', + /** Sort by the `author` value. */ + Author = 'AUTHOR', + /** Sort by the `updated_at` value. */ + UpdatedAt = 'UPDATED_AT', + /** Sort by the `published_at` value. */ + PublishedAt = 'PUBLISHED_AT', + /** Sort by the `id` value. */ + Id = 'ID', + /** + * During a search (i.e. when the `query` parameter has been specified on the connection) this sorts the + * results by relevance to the search term(s). When no search query is specified, this sort key is not + * deterministic and should not be used. + */ + Relevance = 'RELEVANCE', +} + +/** Represents a generic custom attribute. */ +export type Attribute = { + __typename?: 'Attribute' + /** Key or name of the attribute. */ + key: Scalars['String'] + /** Value of the attribute. */ + value?: Maybe +} + +/** Specifies the input fields required for an attribute. */ +export type AttributeInput = { + /** Key or name of the attribute. */ + key: Scalars['String'] + /** Value of the attribute. */ + value: Scalars['String'] +} + +/** Automatic discount applications capture the intentions of a discount that was automatically applied. */ +export type AutomaticDiscountApplication = DiscountApplication & { + __typename?: 'AutomaticDiscountApplication' + /** The method by which the discount's value is allocated to its entitled items. */ + allocationMethod: DiscountApplicationAllocationMethod + /** Which lines of targetType that the discount is allocated over. */ + targetSelection: DiscountApplicationTargetSelection + /** The type of line that the discount is applicable towards. */ + targetType: DiscountApplicationTargetType + /** The title of the application. */ + title: Scalars['String'] + /** The value of the discount application. */ + value: PricingValue +} + +/** A collection of available shipping rates for a checkout. */ +export type AvailableShippingRates = { + __typename?: 'AvailableShippingRates' + /** + * Whether or not the shipping rates are ready. + * The `shippingRates` field is `null` when this value is `false`. + * This field should be polled until its value becomes `true`. + */ + ready: Scalars['Boolean'] + /** The fetched shipping rates. `null` until the `ready` field is `true`. */ + shippingRates?: Maybe> +} + +/** An online store blog. */ +export type Blog = Node & { + __typename?: 'Blog' + /** Find an article by its handle. */ + articleByHandle?: Maybe
+ /** List of the blog's articles. */ + articles: ArticleConnection + /** The authors who have contributed to the blog. */ + authors: Array + /** A human-friendly unique string for the Blog automatically generated from its title. */ + handle: Scalars['String'] + /** Globally unique identifier. */ + id: Scalars['ID'] + /** The blog's SEO information. */ + seo?: Maybe + /** The blogs’s title. */ + title: Scalars['String'] + /** The url pointing to the blog accessible from the web. */ + url: Scalars['URL'] +} + +/** An online store blog. */ +export type BlogArticleByHandleArgs = { + handle: Scalars['String'] +} + +/** An online store blog. */ +export type BlogArticlesArgs = { + first?: Maybe + after?: Maybe + last?: Maybe + before?: Maybe + reverse?: Maybe + sortKey?: Maybe + query?: Maybe +} + +/** An auto-generated type for paginating through multiple Blogs. */ +export type BlogConnection = { + __typename?: 'BlogConnection' + /** A list of edges. */ + edges: Array + /** Information to aid in pagination. */ + pageInfo: PageInfo +} + +/** An auto-generated type which holds one Blog and a cursor during pagination. */ +export type BlogEdge = { + __typename?: 'BlogEdge' + /** A cursor for use in pagination. */ + cursor: Scalars['String'] + /** The item at the end of BlogEdge. */ + node: Blog +} + +/** The set of valid sort keys for the Blog query. */ +export enum BlogSortKeys { + /** Sort by the `handle` value. */ + Handle = 'HANDLE', + /** Sort by the `title` value. */ + Title = 'TITLE', + /** Sort by the `id` value. */ + Id = 'ID', + /** + * During a search (i.e. when the `query` parameter has been specified on the connection) this sorts the + * results by relevance to the search term(s). When no search query is specified, this sort key is not + * deterministic and should not be used. + */ + Relevance = 'RELEVANCE', +} + +/** Card brand, such as Visa or Mastercard, which can be used for payments. */ +export enum CardBrand { + /** Visa */ + Visa = 'VISA', + /** Mastercard */ + Mastercard = 'MASTERCARD', + /** Discover */ + Discover = 'DISCOVER', + /** American Express */ + AmericanExpress = 'AMERICAN_EXPRESS', + /** Diners Club */ + DinersClub = 'DINERS_CLUB', + /** JCB */ + Jcb = 'JCB', +} + +/** A container for all the information required to checkout items and pay. */ +export type Checkout = { + name: string + currency: string + support_email: string + fields: any[] + scripts: any[] + accounts: string + email_optin: boolean + terms_policy?: string + refund_policy?: string + privacy_policy?: string + theme?: string + countries: any[] + currencies: any[] + payment_methods: any[] + coupons: boolean + giftcards: boolean + + // __typename?: 'Checkout' + // /** The gift cards used on the checkout. */ + // appliedGiftCards: Array + // /** + // * The available shipping rates for this Checkout. + // * Should only be used when checkout `requiresShipping` is `true` and + // * the shipping address is valid. + // */ + // availableShippingRates?: Maybe + // /** The date and time when the checkout was completed. */ + // completedAt?: Maybe + // /** The date and time when the checkout was created. */ + // createdAt: Scalars['DateTime'] + // /** The currency code for the Checkout. */ + // currencyCode: CurrencyCode + // /** A list of extra information that is added to the checkout. */ + // customAttributes: Array + // /** + // * The customer associated with the checkout. + // * @deprecated This field will always return null. If you have an authentication token for the customer, you can use the `customer` field on the query root to retrieve it. + // */ + // customer?: Maybe + // /** Discounts that have been applied on the checkout. */ + // discountApplications: DiscountApplicationConnection + // /** The email attached to this checkout. */ + // email?: Maybe + // /** Globally unique identifier. */ + // id: Scalars['ID'] + // /** A list of line item objects, each one containing information about an item in the checkout. */ + // lineItems: CheckoutLineItemConnection + // /** The sum of all the prices of all the items in the checkout. Duties, taxes, shipping and discounts excluded. */ + // lineItemsSubtotalPrice: MoneyV2 + // /** The note associated with the checkout. */ + // note?: Maybe + // /** The resulting order from a paid checkout. */ + // order?: Maybe + // /** The Order Status Page for this Checkout, null when checkout is not completed. */ + // orderStatusUrl?: Maybe + // /** + // * The amount left to be paid. This is equal to the cost of the line items, taxes and shipping minus discounts and gift cards. + // * @deprecated Use `paymentDueV2` instead + // */ + // paymentDue: Scalars['Money'] + // /** The amount left to be paid. This is equal to the cost of the line items, duties, taxes and shipping minus discounts and gift cards. */ + // paymentDueV2: MoneyV2 + // /** + // * Whether or not the Checkout is ready and can be completed. Checkouts may + // * have asynchronous operations that can take time to finish. If you want + // * to complete a checkout or ensure all the fields are populated and up to + // * date, polling is required until the value is true. + // */ + // ready: Scalars['Boolean'] + // /** States whether or not the fulfillment requires shipping. */ + // requiresShipping: Scalars['Boolean'] + // /** The shipping address to where the line items will be shipped. */ + // shippingAddress?: Maybe + // /** The discounts that have been allocated onto the shipping line by discount applications. */ + // shippingDiscountAllocations: Array + // /** Once a shipping rate is selected by the customer it is transitioned to a `shipping_line` object. */ + // shippingLine?: Maybe + // /** + // * Price of the checkout before shipping and taxes. + // * @deprecated Use `subtotalPriceV2` instead + // */ + // subtotalPrice: Scalars['Money'] + // /** Price of the checkout before duties, shipping and taxes. */ + // subtotalPriceV2: MoneyV2 + // /** Specifies if the Checkout is tax exempt. */ + // taxExempt: Scalars['Boolean'] + // /** Specifies if taxes are included in the line item and shipping line prices. */ + // taxesIncluded: Scalars['Boolean'] + // /** + // * The sum of all the prices of all the items in the checkout, taxes and discounts included. + // * @deprecated Use `totalPriceV2` instead + // */ + // totalPrice: Scalars['Money'] + // /** The sum of all the prices of all the items in the checkout, duties, taxes and discounts included. */ + // totalPriceV2: MoneyV2 + // /** + // * The sum of all the taxes applied to the line items and shipping lines in the checkout. + // * @deprecated Use `totalTaxV2` instead + // */ + // totalTax: Scalars['Money'] + // /** The sum of all the taxes applied to the line items and shipping lines in the checkout. */ + // totalTaxV2: MoneyV2 + // /** The date and time when the checkout was last updated. */ + // updatedAt: Scalars['DateTime'] + // /** The url pointing to the checkout accessible from the web. */ + // webUrl: Scalars['URL'] +} + +/** A container for all the information required to checkout items and pay. */ +export type CheckoutDiscountApplicationsArgs = { + first?: Maybe + after?: Maybe + last?: Maybe + before?: Maybe + reverse?: Maybe +} + +/** A container for all the information required to checkout items and pay. */ +export type CheckoutLineItemsArgs = { + first?: Maybe + after?: Maybe + last?: Maybe + before?: Maybe + reverse?: Maybe +} + +/** Specifies the fields required to update a checkout's attributes. */ +export type CheckoutAttributesUpdateInput = { + /** The text of an optional note that a shop owner can attach to the checkout. */ + note?: Maybe + /** A list of extra information that is added to the checkout. */ + customAttributes?: Maybe> + /** + * Allows setting partial addresses on a Checkout, skipping the full validation of attributes. + * The required attributes are city, province, and country. + * Full validation of the addresses is still done at complete time. + */ + allowPartialAddresses?: Maybe +} + +/** Return type for `checkoutAttributesUpdate` mutation. */ +export type CheckoutAttributesUpdatePayload = { + __typename?: 'CheckoutAttributesUpdatePayload' + /** The updated checkout object. */ + checkout: Checkout + /** List of errors that occurred executing the mutation. */ + checkoutUserErrors: Array + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `checkoutUserErrors` instead + */ + userErrors: Array +} + +/** Specifies the fields required to update a checkout's attributes. */ +export type CheckoutAttributesUpdateV2Input = { + /** The text of an optional note that a shop owner can attach to the checkout. */ + note?: Maybe + /** A list of extra information that is added to the checkout. */ + customAttributes?: Maybe> + /** + * Allows setting partial addresses on a Checkout, skipping the full validation of attributes. + * The required attributes are city, province, and country. + * Full validation of the addresses is still done at complete time. + */ + allowPartialAddresses?: Maybe +} + +/** Return type for `checkoutAttributesUpdateV2` mutation. */ +export type CheckoutAttributesUpdateV2Payload = { + __typename?: 'CheckoutAttributesUpdateV2Payload' + /** The updated checkout object. */ + checkout?: Maybe + /** List of errors that occurred executing the mutation. */ + checkoutUserErrors: Array + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `checkoutUserErrors` instead + */ + userErrors: Array +} + +/** Return type for `checkoutCompleteFree` mutation. */ +export type CheckoutCompleteFreePayload = { + __typename?: 'CheckoutCompleteFreePayload' + /** The updated checkout object. */ + checkout?: Maybe + /** List of errors that occurred executing the mutation. */ + checkoutUserErrors: Array + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `checkoutUserErrors` instead + */ + userErrors: Array +} + +/** Return type for `checkoutCompleteWithCreditCard` mutation. */ +export type CheckoutCompleteWithCreditCardPayload = { + __typename?: 'CheckoutCompleteWithCreditCardPayload' + /** The checkout on which the payment was applied. */ + checkout: Checkout + /** List of errors that occurred executing the mutation. */ + checkoutUserErrors: Array + /** A representation of the attempted payment. */ + payment?: Maybe + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `checkoutUserErrors` instead + */ + userErrors: Array +} + +/** Return type for `checkoutCompleteWithCreditCardV2` mutation. */ +export type CheckoutCompleteWithCreditCardV2Payload = { + __typename?: 'CheckoutCompleteWithCreditCardV2Payload' + /** The checkout on which the payment was applied. */ + checkout?: Maybe + /** List of errors that occurred executing the mutation. */ + checkoutUserErrors: Array + /** A representation of the attempted payment. */ + payment?: Maybe + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `checkoutUserErrors` instead + */ + userErrors: Array +} + +/** Return type for `checkoutCompleteWithTokenizedPayment` mutation. */ +export type CheckoutCompleteWithTokenizedPaymentPayload = { + __typename?: 'CheckoutCompleteWithTokenizedPaymentPayload' + /** The checkout on which the payment was applied. */ + checkout: Checkout + /** List of errors that occurred executing the mutation. */ + checkoutUserErrors: Array + /** A representation of the attempted payment. */ + payment?: Maybe + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `checkoutUserErrors` instead + */ + userErrors: Array +} + +/** Return type for `checkoutCompleteWithTokenizedPaymentV2` mutation. */ +export type CheckoutCompleteWithTokenizedPaymentV2Payload = { + __typename?: 'CheckoutCompleteWithTokenizedPaymentV2Payload' + /** The checkout on which the payment was applied. */ + checkout?: Maybe + /** List of errors that occurred executing the mutation. */ + checkoutUserErrors: Array + /** A representation of the attempted payment. */ + payment?: Maybe + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `checkoutUserErrors` instead + */ + userErrors: Array +} + +/** Return type for `checkoutCompleteWithTokenizedPaymentV3` mutation. */ +export type CheckoutCompleteWithTokenizedPaymentV3Payload = { + __typename?: 'CheckoutCompleteWithTokenizedPaymentV3Payload' + /** The checkout on which the payment was applied. */ + checkout?: Maybe + /** List of errors that occurred executing the mutation. */ + checkoutUserErrors: Array + /** A representation of the attempted payment. */ + payment?: Maybe + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `checkoutUserErrors` instead + */ + userErrors: Array +} + +/** Specifies the fields required to create a checkout. */ +export type CheckoutCreateInput = { + /** The email with which the customer wants to checkout. */ + email?: Maybe + /** A list of line item objects, each one containing information about an item in the checkout. */ + lineItems?: Maybe> + /** The shipping address to where the line items will be shipped. */ + shippingAddress?: Maybe + /** The text of an optional note that a shop owner can attach to the checkout. */ + note?: Maybe + /** A list of extra information that is added to the checkout. */ + customAttributes?: Maybe> + /** + * Allows setting partial addresses on a Checkout, skipping the full validation of attributes. + * The required attributes are city, province, and country. + * Full validation of addresses is still done at complete time. + */ + allowPartialAddresses?: Maybe + /** + * The three-letter currency code of one of the shop's enabled presentment currencies. + * Including this field creates a checkout in the specified currency. By default, new + * checkouts are created in the shop's primary currency. + */ + presentmentCurrencyCode?: Maybe +} + +/** Return type for `checkoutCreate` mutation. */ +export type CheckoutCreatePayload = { + __typename?: 'CheckoutCreatePayload' + /** The new checkout object. */ + checkout?: Maybe + /** List of errors that occurred executing the mutation. */ + checkoutUserErrors: Array + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `checkoutUserErrors` instead + */ + userErrors: Array +} + +/** Return type for `checkoutCustomerAssociate` mutation. */ +export type CheckoutCustomerAssociatePayload = { + __typename?: 'CheckoutCustomerAssociatePayload' + /** The updated checkout object. */ + checkout: Checkout + /** The associated customer object. */ + customer?: Maybe + /** List of errors that occurred executing the mutation. */ + userErrors: Array +} + +/** Return type for `checkoutCustomerAssociateV2` mutation. */ +export type CheckoutCustomerAssociateV2Payload = { + __typename?: 'CheckoutCustomerAssociateV2Payload' + /** The updated checkout object. */ + checkout?: Maybe + /** List of errors that occurred executing the mutation. */ + checkoutUserErrors: Array + /** The associated customer object. */ + customer?: Maybe + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `checkoutUserErrors` instead + */ + userErrors: Array +} + +/** Return type for `checkoutCustomerDisassociate` mutation. */ +export type CheckoutCustomerDisassociatePayload = { + __typename?: 'CheckoutCustomerDisassociatePayload' + /** The updated checkout object. */ + checkout: Checkout + /** List of errors that occurred executing the mutation. */ + checkoutUserErrors: Array + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `checkoutUserErrors` instead + */ + userErrors: Array +} + +/** Return type for `checkoutCustomerDisassociateV2` mutation. */ +export type CheckoutCustomerDisassociateV2Payload = { + __typename?: 'CheckoutCustomerDisassociateV2Payload' + /** The updated checkout object. */ + checkout?: Maybe + /** List of errors that occurred executing the mutation. */ + checkoutUserErrors: Array + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `checkoutUserErrors` instead + */ + userErrors: Array +} + +/** Return type for `checkoutDiscountCodeApply` mutation. */ +export type CheckoutDiscountCodeApplyPayload = { + __typename?: 'CheckoutDiscountCodeApplyPayload' + /** The updated checkout object. */ + checkout: Checkout + /** List of errors that occurred executing the mutation. */ + checkoutUserErrors: Array + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `checkoutUserErrors` instead + */ + userErrors: Array +} + +/** Return type for `checkoutDiscountCodeApplyV2` mutation. */ +export type CheckoutDiscountCodeApplyV2Payload = { + __typename?: 'CheckoutDiscountCodeApplyV2Payload' + /** The updated checkout object. */ + checkout?: Maybe + /** List of errors that occurred executing the mutation. */ + checkoutUserErrors: Array + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `checkoutUserErrors` instead + */ + userErrors: Array +} + +/** Return type for `checkoutDiscountCodeRemove` mutation. */ +export type CheckoutDiscountCodeRemovePayload = { + __typename?: 'CheckoutDiscountCodeRemovePayload' + /** The updated checkout object. */ + checkout?: Maybe + /** List of errors that occurred executing the mutation. */ + checkoutUserErrors: Array + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `checkoutUserErrors` instead + */ + userErrors: Array +} + +/** Return type for `checkoutEmailUpdate` mutation. */ +export type CheckoutEmailUpdatePayload = { + __typename?: 'CheckoutEmailUpdatePayload' + /** The checkout object with the updated email. */ + checkout: Checkout + /** List of errors that occurred executing the mutation. */ + checkoutUserErrors: Array + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `checkoutUserErrors` instead + */ + userErrors: Array +} + +/** Return type for `checkoutEmailUpdateV2` mutation. */ +export type CheckoutEmailUpdateV2Payload = { + __typename?: 'CheckoutEmailUpdateV2Payload' + /** The checkout object with the updated email. */ + checkout?: Maybe + /** List of errors that occurred executing the mutation. */ + checkoutUserErrors: Array + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `checkoutUserErrors` instead + */ + userErrors: Array +} + +/** Possible error codes that could be returned by CheckoutUserError. */ +export enum CheckoutErrorCode { + /** Input value is blank. */ + Blank = 'BLANK', + /** Input value is invalid. */ + Invalid = 'INVALID', + /** Input value is too long. */ + TooLong = 'TOO_LONG', + /** Input value is not present. */ + Present = 'PRESENT', + /** Input value should be less than maximum allowed value. */ + LessThan = 'LESS_THAN', + /** Input value should be greater than or equal to minimum allowed value. */ + GreaterThanOrEqualTo = 'GREATER_THAN_OR_EQUAL_TO', + /** Input value should be less or equal to maximum allowed value. */ + LessThanOrEqualTo = 'LESS_THAN_OR_EQUAL_TO', + /** Checkout is already completed. */ + AlreadyCompleted = 'ALREADY_COMPLETED', + /** Checkout is locked. */ + Locked = 'LOCKED', + /** Input value is not supported. */ + NotSupported = 'NOT_SUPPORTED', + /** Input email contains an invalid domain name. */ + BadDomain = 'BAD_DOMAIN', + /** Input Zip is invalid for country provided. */ + InvalidForCountry = 'INVALID_FOR_COUNTRY', + /** Input Zip is invalid for country and province provided. */ + InvalidForCountryAndProvince = 'INVALID_FOR_COUNTRY_AND_PROVINCE', + /** Invalid state in country. */ + InvalidStateInCountry = 'INVALID_STATE_IN_COUNTRY', + /** Invalid province in country. */ + InvalidProvinceInCountry = 'INVALID_PROVINCE_IN_COUNTRY', + /** Invalid region in country. */ + InvalidRegionInCountry = 'INVALID_REGION_IN_COUNTRY', + /** Shipping rate expired. */ + ShippingRateExpired = 'SHIPPING_RATE_EXPIRED', + /** Gift card cannot be applied to a checkout that contains a gift card. */ + GiftCardUnusable = 'GIFT_CARD_UNUSABLE', + /** Gift card is disabled. */ + GiftCardDisabled = 'GIFT_CARD_DISABLED', + /** Gift card code is invalid. */ + GiftCardCodeInvalid = 'GIFT_CARD_CODE_INVALID', + /** Gift card has already been applied. */ + GiftCardAlreadyApplied = 'GIFT_CARD_ALREADY_APPLIED', + /** Gift card currency does not match checkout currency. */ + GiftCardCurrencyMismatch = 'GIFT_CARD_CURRENCY_MISMATCH', + /** Gift card is expired. */ + GiftCardExpired = 'GIFT_CARD_EXPIRED', + /** Gift card has no funds left. */ + GiftCardDepleted = 'GIFT_CARD_DEPLETED', + /** Gift card was not found. */ + GiftCardNotFound = 'GIFT_CARD_NOT_FOUND', + /** Cart does not meet discount requirements notice. */ + CartDoesNotMeetDiscountRequirementsNotice = 'CART_DOES_NOT_MEET_DISCOUNT_REQUIREMENTS_NOTICE', + /** Discount expired. */ + DiscountExpired = 'DISCOUNT_EXPIRED', + /** Discount disabled. */ + DiscountDisabled = 'DISCOUNT_DISABLED', + /** Discount limit reached. */ + DiscountLimitReached = 'DISCOUNT_LIMIT_REACHED', + /** Discount not found. */ + DiscountNotFound = 'DISCOUNT_NOT_FOUND', + /** Customer already used once per customer discount notice. */ + CustomerAlreadyUsedOncePerCustomerDiscountNotice = 'CUSTOMER_ALREADY_USED_ONCE_PER_CUSTOMER_DISCOUNT_NOTICE', + /** Checkout is already completed. */ + Empty = 'EMPTY', + /** Not enough in stock. */ + NotEnoughInStock = 'NOT_ENOUGH_IN_STOCK', + /** Missing payment input. */ + MissingPaymentInput = 'MISSING_PAYMENT_INPUT', + /** The amount of the payment does not match the value to be paid. */ + TotalPriceMismatch = 'TOTAL_PRICE_MISMATCH', + /** Line item was not found in checkout. */ + LineItemNotFound = 'LINE_ITEM_NOT_FOUND', + /** Unable to apply discount. */ + UnableToApply = 'UNABLE_TO_APPLY', + /** Discount already applied. */ + DiscountAlreadyApplied = 'DISCOUNT_ALREADY_APPLIED', +} + +/** Return type for `checkoutGiftCardApply` mutation. */ +export type CheckoutGiftCardApplyPayload = { + __typename?: 'CheckoutGiftCardApplyPayload' + /** The updated checkout object. */ + checkout: Checkout + /** List of errors that occurred executing the mutation. */ + checkoutUserErrors: Array + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `checkoutUserErrors` instead + */ + userErrors: Array +} + +/** Return type for `checkoutGiftCardRemove` mutation. */ +export type CheckoutGiftCardRemovePayload = { + __typename?: 'CheckoutGiftCardRemovePayload' + /** The updated checkout object. */ + checkout: Checkout + /** List of errors that occurred executing the mutation. */ + checkoutUserErrors: Array + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `checkoutUserErrors` instead + */ + userErrors: Array +} + +/** Return type for `checkoutGiftCardRemoveV2` mutation. */ +export type CheckoutGiftCardRemoveV2Payload = { + __typename?: 'CheckoutGiftCardRemoveV2Payload' + /** The updated checkout object. */ + checkout?: Maybe + /** List of errors that occurred executing the mutation. */ + checkoutUserErrors: Array + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `checkoutUserErrors` instead + */ + userErrors: Array +} + +/** Return type for `checkoutGiftCardsAppend` mutation. */ +export type CheckoutGiftCardsAppendPayload = { + __typename?: 'CheckoutGiftCardsAppendPayload' + /** The updated checkout object. */ + checkout?: Maybe + /** List of errors that occurred executing the mutation. */ + checkoutUserErrors: Array + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `checkoutUserErrors` instead + */ + userErrors: Array +} + +/** A single line item in the checkout, grouped by variant and attributes. */ +export type CheckoutLineItem = Node & { + __typename?: 'CheckoutLineItem' + /** Extra information in the form of an array of Key-Value pairs about the line item. */ + customAttributes: Array + /** The discounts that have been allocated onto the checkout line item by discount applications. */ + discountAllocations: Array + /** Globally unique identifier. */ + id: Scalars['ID'] + /** The quantity of the line item. */ + quantity: Scalars['Int'] + /** Title of the line item. Defaults to the product's title. */ + title: Scalars['String'] + /** Unit price of the line item. */ + unitPrice?: Maybe + /** Product variant of the line item. */ + variant?: Maybe +} + +/** An auto-generated type for paginating through multiple CheckoutLineItems. */ +export type CheckoutLineItemConnection = { + __typename?: 'CheckoutLineItemConnection' + /** A list of edges. */ + edges: Array + /** Information to aid in pagination. */ + pageInfo: PageInfo +} + +/** An auto-generated type which holds one CheckoutLineItem and a cursor during pagination. */ +export type CheckoutLineItemEdge = { + __typename?: 'CheckoutLineItemEdge' + /** A cursor for use in pagination. */ + cursor: Scalars['String'] + /** The item at the end of CheckoutLineItemEdge. */ + node: CheckoutLineItem +} + +/** Specifies the input fields to create a line item on a checkout. */ +export type CheckoutLineItemInput = { + /** Extra information in the form of an array of Key-Value pairs about the line item. */ + customAttributes?: Maybe> + /** The quantity of the line item. */ + quantity: Scalars['Int'] + /** The identifier of the product variant for the line item. */ + variantId: Scalars['ID'] +} + +/** Specifies the input fields to update a line item on the checkout. */ +export type CheckoutLineItemUpdateInput = { + /** The identifier of the line item. */ + id?: Maybe + /** The variant identifier of the line item. */ + variantId?: Maybe + /** The quantity of the line item. */ + quantity?: Maybe + /** Extra information in the form of an array of Key-Value pairs about the line item. */ + customAttributes?: Maybe> +} + +/** Return type for `checkoutLineItemsAdd` mutation. */ +export type CheckoutLineItemsAddPayload = { + __typename?: 'CheckoutLineItemsAddPayload' + /** The updated checkout object. */ + items?: Maybe + /** List of errors that occurred executing the mutation. */ + // checkoutUserErrors: Array + // /** + // * List of errors that occurred executing the mutation. + // * @deprecated Use `checkoutUserErrors` instead + // */ + // userErrors: Array +} + +/** Return type for `checkoutLineItemsRemove` mutation. */ +export type CheckoutLineItemsRemovePayload = { + __typename?: 'CheckoutLineItemsRemovePayload' + /** The updated checkout object. */ + items?: Maybe + /** List of errors that occurred executing the mutation. */ + // checkoutUserErrors: Array + // /** + // * List of errors that occurred executing the mutation. + // * @deprecated Use `checkoutUserErrors` instead + // */ + // userErrors: Array +} + +/** Return type for `checkoutLineItemsReplace` mutation. */ +export type CheckoutLineItemsReplacePayload = { + __typename?: 'CheckoutLineItemsReplacePayload' + /** The updated checkout object. */ + checkout?: Maybe + /** List of errors that occurred executing the mutation. */ + userErrors: Array +} + +/** Return type for `checkoutLineItemsUpdate` mutation. */ +export type CheckoutLineItemsUpdatePayload = { + __typename?: 'CheckoutLineItemsUpdatePayload' + /** The updated checkout object. */ + items?: Maybe + /** List of errors that occurred executing the mutation. */ + // checkoutUserErrors: Array + // /** + // * List of errors that occurred executing the mutation. + // * @deprecated Use `checkoutUserErrors` instead + // */ + // userErrors: Array +} + +/** Return type for `checkoutShippingAddressUpdate` mutation. */ +export type CheckoutShippingAddressUpdatePayload = { + __typename?: 'CheckoutShippingAddressUpdatePayload' + /** The updated checkout object. */ + checkout: Checkout + /** List of errors that occurred executing the mutation. */ + checkoutUserErrors: Array + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `checkoutUserErrors` instead + */ + userErrors: Array +} + +/** Return type for `checkoutShippingAddressUpdateV2` mutation. */ +export type CheckoutShippingAddressUpdateV2Payload = { + __typename?: 'CheckoutShippingAddressUpdateV2Payload' + /** The updated checkout object. */ + checkout?: Maybe + /** List of errors that occurred executing the mutation. */ + checkoutUserErrors: Array + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `checkoutUserErrors` instead + */ + userErrors: Array +} + +/** Return type for `checkoutShippingLineUpdate` mutation. */ +export type CheckoutShippingLineUpdatePayload = { + __typename?: 'CheckoutShippingLineUpdatePayload' + /** The updated checkout object. */ + checkout?: Maybe + /** List of errors that occurred executing the mutation. */ + checkoutUserErrors: Array + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `checkoutUserErrors` instead + */ + userErrors: Array +} + +/** Represents an error that happens during execution of a checkout mutation. */ +export type CheckoutUserError = DisplayableError & { + __typename?: 'CheckoutUserError' + /** Error code to uniquely identify the error. */ + code?: Maybe + /** Path to the input field which caused the error. */ + field?: Maybe> + /** The error message. */ + message: Scalars['String'] +} + +/** A collection represents a grouping of products that a shop owner can create to organize them or make their shops easier to browse. */ +export type Collection = Node & { + __typename?: 'Collection' + /** Stripped description of the collection, single line with HTML tags removed. */ + description: Scalars['String'] + /** The description of the collection, complete with HTML formatting. */ + descriptionHtml: Scalars['HTML'] + /** + * A human-friendly unique string for the collection automatically generated from its title. + * Limit of 255 characters. + */ + handle: Scalars['String'] + /** Globally unique identifier. */ + id: Scalars['ID'] + /** Image associated with the collection. */ + image?: Maybe + /** List of products in the collection. */ + products: ProductConnection + /** The collection’s name. Limit of 255 characters. */ + title: Scalars['String'] + /** The date and time when the collection was last modified. */ + updatedAt: Scalars['DateTime'] +} + +/** A collection represents a grouping of products that a shop owner can create to organize them or make their shops easier to browse. */ +export type CollectionDescriptionArgs = { + truncateAt?: Maybe +} + +/** A collection represents a grouping of products that a shop owner can create to organize them or make their shops easier to browse. */ +export type CollectionImageArgs = { + maxWidth?: Maybe + maxHeight?: Maybe + crop?: Maybe + scale?: Maybe +} + +/** A collection represents a grouping of products that a shop owner can create to organize them or make their shops easier to browse. */ +export type CollectionProductsArgs = { + first?: Maybe + after?: Maybe + last?: Maybe + before?: Maybe + reverse?: Maybe + sortKey?: Maybe +} + +/** An auto-generated type for paginating through multiple Collections. */ +export type CollectionConnection = { + __typename?: 'CollectionConnection' + /** A list of edges. */ + edges: Array + /** Information to aid in pagination. */ + pageInfo: PageInfo +} + +/** An auto-generated type which holds one Collection and a cursor during pagination. */ +export type CollectionEdge = { + __typename?: 'CollectionEdge' + /** A cursor for use in pagination. */ + cursor: Scalars['String'] + /** The item at the end of CollectionEdge. */ + node: Collection +} + +/** The set of valid sort keys for the Collection query. */ +export enum CollectionSortKeys { + /** Sort by the `title` value. */ + Title = 'TITLE', + /** Sort by the `updated_at` value. */ + UpdatedAt = 'UPDATED_AT', + /** Sort by the `id` value. */ + Id = 'ID', + /** + * During a search (i.e. when the `query` parameter has been specified on the connection) this sorts the + * results by relevance to the search term(s). When no search query is specified, this sort key is not + * deterministic and should not be used. + */ + Relevance = 'RELEVANCE', +} + +/** A comment on an article. */ +export type Comment = Node & { + __typename?: 'Comment' + /** The comment’s author. */ + author: CommentAuthor + /** Stripped content of the comment, single line with HTML tags removed. */ + content: Scalars['String'] + /** The content of the comment, complete with HTML formatting. */ + contentHtml: Scalars['HTML'] + /** Globally unique identifier. */ + id: Scalars['ID'] +} + +/** A comment on an article. */ +export type CommentContentArgs = { + truncateAt?: Maybe +} + +/** The author of a comment. */ +export type CommentAuthor = { + __typename?: 'CommentAuthor' + /** The author's email. */ + email: Scalars['String'] + /** The author’s name. */ + name: Scalars['String'] +} + +/** An auto-generated type for paginating through multiple Comments. */ +export type CommentConnection = { + __typename?: 'CommentConnection' + /** A list of edges. */ + edges: Array + /** Information to aid in pagination. */ + pageInfo: PageInfo +} + +/** An auto-generated type which holds one Comment and a cursor during pagination. */ +export type CommentEdge = { + __typename?: 'CommentEdge' + /** A cursor for use in pagination. */ + cursor: Scalars['String'] + /** The item at the end of CommentEdge. */ + node: Comment +} + +/** ISO 3166-1 alpha-2 country codes with some differences. */ +export enum CountryCode { + /** Afghanistan. */ + Af = 'AF', + /** Åland Islands. */ + Ax = 'AX', + /** Albania. */ + Al = 'AL', + /** Algeria. */ + Dz = 'DZ', + /** Andorra. */ + Ad = 'AD', + /** Angola. */ + Ao = 'AO', + /** Anguilla. */ + Ai = 'AI', + /** Antigua & Barbuda. */ + Ag = 'AG', + /** Argentina. */ + Ar = 'AR', + /** Armenia. */ + Am = 'AM', + /** Aruba. */ + Aw = 'AW', + /** Australia. */ + Au = 'AU', + /** Austria. */ + At = 'AT', + /** Azerbaijan. */ + Az = 'AZ', + /** Bahamas. */ + Bs = 'BS', + /** Bahrain. */ + Bh = 'BH', + /** Bangladesh. */ + Bd = 'BD', + /** Barbados. */ + Bb = 'BB', + /** Belarus. */ + By = 'BY', + /** Belgium. */ + Be = 'BE', + /** Belize. */ + Bz = 'BZ', + /** Benin. */ + Bj = 'BJ', + /** Bermuda. */ + Bm = 'BM', + /** Bhutan. */ + Bt = 'BT', + /** Bolivia. */ + Bo = 'BO', + /** Bosnia & Herzegovina. */ + Ba = 'BA', + /** Botswana. */ + Bw = 'BW', + /** Bouvet Island. */ + Bv = 'BV', + /** Brazil. */ + Br = 'BR', + /** British Indian Ocean Territory. */ + Io = 'IO', + /** Brunei. */ + Bn = 'BN', + /** Bulgaria. */ + Bg = 'BG', + /** Burkina Faso. */ + Bf = 'BF', + /** Burundi. */ + Bi = 'BI', + /** Cambodia. */ + Kh = 'KH', + /** Canada. */ + Ca = 'CA', + /** Cape Verde. */ + Cv = 'CV', + /** Caribbean Netherlands. */ + Bq = 'BQ', + /** Cayman Islands. */ + Ky = 'KY', + /** Central African Republic. */ + Cf = 'CF', + /** Chad. */ + Td = 'TD', + /** Chile. */ + Cl = 'CL', + /** China. */ + Cn = 'CN', + /** Christmas Island. */ + Cx = 'CX', + /** Cocos (Keeling) Islands. */ + Cc = 'CC', + /** Colombia. */ + Co = 'CO', + /** Comoros. */ + Km = 'KM', + /** Congo - Brazzaville. */ + Cg = 'CG', + /** Congo - Kinshasa. */ + Cd = 'CD', + /** Cook Islands. */ + Ck = 'CK', + /** Costa Rica. */ + Cr = 'CR', + /** Croatia. */ + Hr = 'HR', + /** Cuba. */ + Cu = 'CU', + /** Curaçao. */ + Cw = 'CW', + /** Cyprus. */ + Cy = 'CY', + /** Czechia. */ + Cz = 'CZ', + /** Côte d’Ivoire. */ + Ci = 'CI', + /** Denmark. */ + Dk = 'DK', + /** Djibouti. */ + Dj = 'DJ', + /** Dominica. */ + Dm = 'DM', + /** Dominican Republic. */ + Do = 'DO', + /** Ecuador. */ + Ec = 'EC', + /** Egypt. */ + Eg = 'EG', + /** El Salvador. */ + Sv = 'SV', + /** Equatorial Guinea. */ + Gq = 'GQ', + /** Eritrea. */ + Er = 'ER', + /** Estonia. */ + Ee = 'EE', + /** Eswatini. */ + Sz = 'SZ', + /** Ethiopia. */ + Et = 'ET', + /** Falkland Islands. */ + Fk = 'FK', + /** Faroe Islands. */ + Fo = 'FO', + /** Fiji. */ + Fj = 'FJ', + /** Finland. */ + Fi = 'FI', + /** France. */ + Fr = 'FR', + /** French Guiana. */ + Gf = 'GF', + /** French Polynesia. */ + Pf = 'PF', + /** French Southern Territories. */ + Tf = 'TF', + /** Gabon. */ + Ga = 'GA', + /** Gambia. */ + Gm = 'GM', + /** Georgia. */ + Ge = 'GE', + /** Germany. */ + De = 'DE', + /** Ghana. */ + Gh = 'GH', + /** Gibraltar. */ + Gi = 'GI', + /** Greece. */ + Gr = 'GR', + /** Greenland. */ + Gl = 'GL', + /** Grenada. */ + Gd = 'GD', + /** Guadeloupe. */ + Gp = 'GP', + /** Guatemala. */ + Gt = 'GT', + /** Guernsey. */ + Gg = 'GG', + /** Guinea. */ + Gn = 'GN', + /** Guinea-Bissau. */ + Gw = 'GW', + /** Guyana. */ + Gy = 'GY', + /** Haiti. */ + Ht = 'HT', + /** Heard & McDonald Islands. */ + Hm = 'HM', + /** Vatican City. */ + Va = 'VA', + /** Honduras. */ + Hn = 'HN', + /** Hong Kong SAR. */ + Hk = 'HK', + /** Hungary. */ + Hu = 'HU', + /** Iceland. */ + Is = 'IS', + /** India. */ + In = 'IN', + /** Indonesia. */ + Id = 'ID', + /** Iran. */ + Ir = 'IR', + /** Iraq. */ + Iq = 'IQ', + /** Ireland. */ + Ie = 'IE', + /** Isle of Man. */ + Im = 'IM', + /** Israel. */ + Il = 'IL', + /** Italy. */ + It = 'IT', + /** Jamaica. */ + Jm = 'JM', + /** Japan. */ + Jp = 'JP', + /** Jersey. */ + Je = 'JE', + /** Jordan. */ + Jo = 'JO', + /** Kazakhstan. */ + Kz = 'KZ', + /** Kenya. */ + Ke = 'KE', + /** Kiribati. */ + Ki = 'KI', + /** North Korea. */ + Kp = 'KP', + /** Kosovo. */ + Xk = 'XK', + /** Kuwait. */ + Kw = 'KW', + /** Kyrgyzstan. */ + Kg = 'KG', + /** Laos. */ + La = 'LA', + /** Latvia. */ + Lv = 'LV', + /** Lebanon. */ + Lb = 'LB', + /** Lesotho. */ + Ls = 'LS', + /** Liberia. */ + Lr = 'LR', + /** Libya. */ + Ly = 'LY', + /** Liechtenstein. */ + Li = 'LI', + /** Lithuania. */ + Lt = 'LT', + /** Luxembourg. */ + Lu = 'LU', + /** Macao SAR. */ + Mo = 'MO', + /** Madagascar. */ + Mg = 'MG', + /** Malawi. */ + Mw = 'MW', + /** Malaysia. */ + My = 'MY', + /** Maldives. */ + Mv = 'MV', + /** Mali. */ + Ml = 'ML', + /** Malta. */ + Mt = 'MT', + /** Martinique. */ + Mq = 'MQ', + /** Mauritania. */ + Mr = 'MR', + /** Mauritius. */ + Mu = 'MU', + /** Mayotte. */ + Yt = 'YT', + /** Mexico. */ + Mx = 'MX', + /** Moldova. */ + Md = 'MD', + /** Monaco. */ + Mc = 'MC', + /** Mongolia. */ + Mn = 'MN', + /** Montenegro. */ + Me = 'ME', + /** Montserrat. */ + Ms = 'MS', + /** Morocco. */ + Ma = 'MA', + /** Mozambique. */ + Mz = 'MZ', + /** Myanmar (Burma). */ + Mm = 'MM', + /** Namibia. */ + Na = 'NA', + /** Nauru. */ + Nr = 'NR', + /** Nepal. */ + Np = 'NP', + /** Netherlands. */ + Nl = 'NL', + /** Netherlands Antilles. */ + An = 'AN', + /** New Caledonia. */ + Nc = 'NC', + /** New Zealand. */ + Nz = 'NZ', + /** Nicaragua. */ + Ni = 'NI', + /** Niger. */ + Ne = 'NE', + /** Nigeria. */ + Ng = 'NG', + /** Niue. */ + Nu = 'NU', + /** Norfolk Island. */ + Nf = 'NF', + /** North Macedonia. */ + Mk = 'MK', + /** Norway. */ + No = 'NO', + /** Oman. */ + Om = 'OM', + /** Pakistan. */ + Pk = 'PK', + /** Palestinian Territories. */ + Ps = 'PS', + /** Panama. */ + Pa = 'PA', + /** Papua New Guinea. */ + Pg = 'PG', + /** Paraguay. */ + Py = 'PY', + /** Peru. */ + Pe = 'PE', + /** Philippines. */ + Ph = 'PH', + /** Pitcairn Islands. */ + Pn = 'PN', + /** Poland. */ + Pl = 'PL', + /** Portugal. */ + Pt = 'PT', + /** Qatar. */ + Qa = 'QA', + /** Cameroon. */ + Cm = 'CM', + /** Réunion. */ + Re = 'RE', + /** Romania. */ + Ro = 'RO', + /** Russia. */ + Ru = 'RU', + /** Rwanda. */ + Rw = 'RW', + /** St. Barthélemy. */ + Bl = 'BL', + /** St. Helena. */ + Sh = 'SH', + /** St. Kitts & Nevis. */ + Kn = 'KN', + /** St. Lucia. */ + Lc = 'LC', + /** St. Martin. */ + Mf = 'MF', + /** St. Pierre & Miquelon. */ + Pm = 'PM', + /** Samoa. */ + Ws = 'WS', + /** San Marino. */ + Sm = 'SM', + /** São Tomé & Príncipe. */ + St = 'ST', + /** Saudi Arabia. */ + Sa = 'SA', + /** Senegal. */ + Sn = 'SN', + /** Serbia. */ + Rs = 'RS', + /** Seychelles. */ + Sc = 'SC', + /** Sierra Leone. */ + Sl = 'SL', + /** Singapore. */ + Sg = 'SG', + /** Sint Maarten. */ + Sx = 'SX', + /** Slovakia. */ + Sk = 'SK', + /** Slovenia. */ + Si = 'SI', + /** Solomon Islands. */ + Sb = 'SB', + /** Somalia. */ + So = 'SO', + /** South Africa. */ + Za = 'ZA', + /** South Georgia & South Sandwich Islands. */ + Gs = 'GS', + /** South Korea. */ + Kr = 'KR', + /** South Sudan. */ + Ss = 'SS', + /** Spain. */ + Es = 'ES', + /** Sri Lanka. */ + Lk = 'LK', + /** St. Vincent & Grenadines. */ + Vc = 'VC', + /** Sudan. */ + Sd = 'SD', + /** Suriname. */ + Sr = 'SR', + /** Svalbard & Jan Mayen. */ + Sj = 'SJ', + /** Sweden. */ + Se = 'SE', + /** Switzerland. */ + Ch = 'CH', + /** Syria. */ + Sy = 'SY', + /** Taiwan. */ + Tw = 'TW', + /** Tajikistan. */ + Tj = 'TJ', + /** Tanzania. */ + Tz = 'TZ', + /** Thailand. */ + Th = 'TH', + /** Timor-Leste. */ + Tl = 'TL', + /** Togo. */ + Tg = 'TG', + /** Tokelau. */ + Tk = 'TK', + /** Tonga. */ + To = 'TO', + /** Trinidad & Tobago. */ + Tt = 'TT', + /** Tunisia. */ + Tn = 'TN', + /** Turkey. */ + Tr = 'TR', + /** Turkmenistan. */ + Tm = 'TM', + /** Turks & Caicos Islands. */ + Tc = 'TC', + /** Tuvalu. */ + Tv = 'TV', + /** Uganda. */ + Ug = 'UG', + /** Ukraine. */ + Ua = 'UA', + /** United Arab Emirates. */ + Ae = 'AE', + /** United Kingdom. */ + Gb = 'GB', + /** United States. */ + Us = 'US', + /** U.S. Outlying Islands. */ + Um = 'UM', + /** Uruguay. */ + Uy = 'UY', + /** Uzbekistan. */ + Uz = 'UZ', + /** Vanuatu. */ + Vu = 'VU', + /** Venezuela. */ + Ve = 'VE', + /** Vietnam. */ + Vn = 'VN', + /** British Virgin Islands. */ + Vg = 'VG', + /** Wallis & Futuna. */ + Wf = 'WF', + /** Western Sahara. */ + Eh = 'EH', + /** Yemen. */ + Ye = 'YE', + /** Zambia. */ + Zm = 'ZM', + /** Zimbabwe. */ + Zw = 'ZW', +} + +/** Credit card information used for a payment. */ +export type CreditCard = { + __typename?: 'CreditCard' + /** The brand of the credit card. */ + brand?: Maybe + /** The expiry month of the credit card. */ + expiryMonth?: Maybe + /** The expiry year of the credit card. */ + expiryYear?: Maybe + /** The credit card's BIN number. */ + firstDigits?: Maybe + /** The first name of the card holder. */ + firstName?: Maybe + /** The last 4 digits of the credit card. */ + lastDigits?: Maybe + /** The last name of the card holder. */ + lastName?: Maybe + /** The masked credit card number with only the last 4 digits displayed. */ + maskedNumber?: Maybe +} + +/** + * Specifies the fields required to complete a checkout with + * a Shopify vaulted credit card payment. + */ +export type CreditCardPaymentInput = { + /** The amount of the payment. */ + amount: Scalars['Money'] + /** A unique client generated key used to avoid duplicate charges. When a duplicate payment is found, the original is returned instead of creating a new one. */ + idempotencyKey: Scalars['String'] + /** The billing address for the payment. */ + billingAddress: MailingAddressInput + /** The ID returned by Shopify's Card Vault. */ + vaultId: Scalars['String'] + /** Executes the payment in test mode if possible. Defaults to `false`. */ + test?: Maybe +} + +/** + * Specifies the fields required to complete a checkout with + * a Shopify vaulted credit card payment. + */ +export type CreditCardPaymentInputV2 = { + /** The amount and currency of the payment. */ + paymentAmount: MoneyInput + /** A unique client generated key used to avoid duplicate charges. When a duplicate payment is found, the original is returned instead of creating a new one. */ + idempotencyKey: Scalars['String'] + /** The billing address for the payment. */ + billingAddress: MailingAddressInput + /** The ID returned by Shopify's Card Vault. */ + vaultId: Scalars['String'] + /** Executes the payment in test mode if possible. Defaults to `false`. */ + test?: Maybe +} + +/** The part of the image that should remain after cropping. */ +export enum CropRegion { + /** Keep the center of the image. */ + Center = 'CENTER', + /** Keep the top of the image. */ + Top = 'TOP', + /** Keep the bottom of the image. */ + Bottom = 'BOTTOM', + /** Keep the left of the image. */ + Left = 'LEFT', + /** Keep the right of the image. */ + Right = 'RIGHT', +} + +/** Currency codes. */ +export enum CurrencyCode { + /** United States Dollars (USD). */ + Usd = 'USD', + /** Euro (EUR). */ + Eur = 'EUR', + /** United Kingdom Pounds (GBP). */ + Gbp = 'GBP', + /** Canadian Dollars (CAD). */ + Cad = 'CAD', + /** Afghan Afghani (AFN). */ + Afn = 'AFN', + /** Albanian Lek (ALL). */ + All = 'ALL', + /** Algerian Dinar (DZD). */ + Dzd = 'DZD', + /** Angolan Kwanza (AOA). */ + Aoa = 'AOA', + /** Argentine Pesos (ARS). */ + Ars = 'ARS', + /** Armenian Dram (AMD). */ + Amd = 'AMD', + /** Aruban Florin (AWG). */ + Awg = 'AWG', + /** Australian Dollars (AUD). */ + Aud = 'AUD', + /** Barbadian Dollar (BBD). */ + Bbd = 'BBD', + /** Azerbaijani Manat (AZN). */ + Azn = 'AZN', + /** Bangladesh Taka (BDT). */ + Bdt = 'BDT', + /** Bahamian Dollar (BSD). */ + Bsd = 'BSD', + /** Bahraini Dinar (BHD). */ + Bhd = 'BHD', + /** Burundian Franc (BIF). */ + Bif = 'BIF', + /** Belarusian Ruble (BYN). */ + Byn = 'BYN', + /** Belarusian Ruble (BYR). */ + Byr = 'BYR', + /** Belize Dollar (BZD). */ + Bzd = 'BZD', + /** Bermudian Dollar (BMD). */ + Bmd = 'BMD', + /** Bhutanese Ngultrum (BTN). */ + Btn = 'BTN', + /** Bosnia and Herzegovina Convertible Mark (BAM). */ + Bam = 'BAM', + /** Brazilian Real (BRL). */ + Brl = 'BRL', + /** Bolivian Boliviano (BOB). */ + Bob = 'BOB', + /** Botswana Pula (BWP). */ + Bwp = 'BWP', + /** Brunei Dollar (BND). */ + Bnd = 'BND', + /** Bulgarian Lev (BGN). */ + Bgn = 'BGN', + /** Burmese Kyat (MMK). */ + Mmk = 'MMK', + /** Cambodian Riel. */ + Khr = 'KHR', + /** Cape Verdean escudo (CVE). */ + Cve = 'CVE', + /** Cayman Dollars (KYD). */ + Kyd = 'KYD', + /** Central African CFA Franc (XAF). */ + Xaf = 'XAF', + /** Chilean Peso (CLP). */ + Clp = 'CLP', + /** Chinese Yuan Renminbi (CNY). */ + Cny = 'CNY', + /** Colombian Peso (COP). */ + Cop = 'COP', + /** Comorian Franc (KMF). */ + Kmf = 'KMF', + /** Congolese franc (CDF). */ + Cdf = 'CDF', + /** Costa Rican Colones (CRC). */ + Crc = 'CRC', + /** Croatian Kuna (HRK). */ + Hrk = 'HRK', + /** Czech Koruny (CZK). */ + Czk = 'CZK', + /** Danish Kroner (DKK). */ + Dkk = 'DKK', + /** Djiboutian Franc (DJF). */ + Djf = 'DJF', + /** Dominican Peso (DOP). */ + Dop = 'DOP', + /** East Caribbean Dollar (XCD). */ + Xcd = 'XCD', + /** Egyptian Pound (EGP). */ + Egp = 'EGP', + /** Eritrean Nakfa (ERN). */ + Ern = 'ERN', + /** Ethiopian Birr (ETB). */ + Etb = 'ETB', + /** Falkland Islands Pounds (FKP). */ + Fkp = 'FKP', + /** CFP Franc (XPF). */ + Xpf = 'XPF', + /** Fijian Dollars (FJD). */ + Fjd = 'FJD', + /** Gibraltar Pounds (GIP). */ + Gip = 'GIP', + /** Gambian Dalasi (GMD). */ + Gmd = 'GMD', + /** Ghanaian Cedi (GHS). */ + Ghs = 'GHS', + /** Guatemalan Quetzal (GTQ). */ + Gtq = 'GTQ', + /** Guyanese Dollar (GYD). */ + Gyd = 'GYD', + /** Georgian Lari (GEL). */ + Gel = 'GEL', + /** Guinean Franc (GNF). */ + Gnf = 'GNF', + /** Haitian Gourde (HTG). */ + Htg = 'HTG', + /** Honduran Lempira (HNL). */ + Hnl = 'HNL', + /** Hong Kong Dollars (HKD). */ + Hkd = 'HKD', + /** Hungarian Forint (HUF). */ + Huf = 'HUF', + /** Icelandic Kronur (ISK). */ + Isk = 'ISK', + /** Indian Rupees (INR). */ + Inr = 'INR', + /** Indonesian Rupiah (IDR). */ + Idr = 'IDR', + /** Israeli New Shekel (NIS). */ + Ils = 'ILS', + /** Iranian Rial (IRR). */ + Irr = 'IRR', + /** Iraqi Dinar (IQD). */ + Iqd = 'IQD', + /** Jamaican Dollars (JMD). */ + Jmd = 'JMD', + /** Japanese Yen (JPY). */ + Jpy = 'JPY', + /** Jersey Pound. */ + Jep = 'JEP', + /** Jordanian Dinar (JOD). */ + Jod = 'JOD', + /** Kazakhstani Tenge (KZT). */ + Kzt = 'KZT', + /** Kenyan Shilling (KES). */ + Kes = 'KES', + /** Kiribati Dollar (KID). */ + Kid = 'KID', + /** Kuwaiti Dinar (KWD). */ + Kwd = 'KWD', + /** Kyrgyzstani Som (KGS). */ + Kgs = 'KGS', + /** Laotian Kip (LAK). */ + Lak = 'LAK', + /** Latvian Lati (LVL). */ + Lvl = 'LVL', + /** Lebanese Pounds (LBP). */ + Lbp = 'LBP', + /** Lesotho Loti (LSL). */ + Lsl = 'LSL', + /** Liberian Dollar (LRD). */ + Lrd = 'LRD', + /** Libyan Dinar (LYD). */ + Lyd = 'LYD', + /** Lithuanian Litai (LTL). */ + Ltl = 'LTL', + /** Malagasy Ariary (MGA). */ + Mga = 'MGA', + /** Macedonia Denar (MKD). */ + Mkd = 'MKD', + /** Macanese Pataca (MOP). */ + Mop = 'MOP', + /** Malawian Kwacha (MWK). */ + Mwk = 'MWK', + /** Maldivian Rufiyaa (MVR). */ + Mvr = 'MVR', + /** Mauritanian Ouguiya (MRU). */ + Mru = 'MRU', + /** Mexican Pesos (MXN). */ + Mxn = 'MXN', + /** Malaysian Ringgits (MYR). */ + Myr = 'MYR', + /** Mauritian Rupee (MUR). */ + Mur = 'MUR', + /** Moldovan Leu (MDL). */ + Mdl = 'MDL', + /** Moroccan Dirham. */ + Mad = 'MAD', + /** Mongolian Tugrik. */ + Mnt = 'MNT', + /** Mozambican Metical. */ + Mzn = 'MZN', + /** Namibian Dollar. */ + Nad = 'NAD', + /** Nepalese Rupee (NPR). */ + Npr = 'NPR', + /** Netherlands Antillean Guilder. */ + Ang = 'ANG', + /** New Zealand Dollars (NZD). */ + Nzd = 'NZD', + /** Nicaraguan Córdoba (NIO). */ + Nio = 'NIO', + /** Nigerian Naira (NGN). */ + Ngn = 'NGN', + /** Norwegian Kroner (NOK). */ + Nok = 'NOK', + /** Omani Rial (OMR). */ + Omr = 'OMR', + /** Panamian Balboa (PAB). */ + Pab = 'PAB', + /** Pakistani Rupee (PKR). */ + Pkr = 'PKR', + /** Papua New Guinean Kina (PGK). */ + Pgk = 'PGK', + /** Paraguayan Guarani (PYG). */ + Pyg = 'PYG', + /** Peruvian Nuevo Sol (PEN). */ + Pen = 'PEN', + /** Philippine Peso (PHP). */ + Php = 'PHP', + /** Polish Zlotych (PLN). */ + Pln = 'PLN', + /** Qatari Rial (QAR). */ + Qar = 'QAR', + /** Romanian Lei (RON). */ + Ron = 'RON', + /** Russian Rubles (RUB). */ + Rub = 'RUB', + /** Rwandan Franc (RWF). */ + Rwf = 'RWF', + /** Samoan Tala (WST). */ + Wst = 'WST', + /** Saint Helena Pounds (SHP). */ + Shp = 'SHP', + /** Saudi Riyal (SAR). */ + Sar = 'SAR', + /** Sao Tome And Principe Dobra (STD). */ + Std = 'STD', + /** Serbian dinar (RSD). */ + Rsd = 'RSD', + /** Seychellois Rupee (SCR). */ + Scr = 'SCR', + /** Sierra Leonean Leone (SLL). */ + Sll = 'SLL', + /** Singapore Dollars (SGD). */ + Sgd = 'SGD', + /** Sudanese Pound (SDG). */ + Sdg = 'SDG', + /** Somali Shilling (SOS). */ + Sos = 'SOS', + /** Syrian Pound (SYP). */ + Syp = 'SYP', + /** South African Rand (ZAR). */ + Zar = 'ZAR', + /** South Korean Won (KRW). */ + Krw = 'KRW', + /** South Sudanese Pound (SSP). */ + Ssp = 'SSP', + /** Solomon Islands Dollar (SBD). */ + Sbd = 'SBD', + /** Sri Lankan Rupees (LKR). */ + Lkr = 'LKR', + /** Surinamese Dollar (SRD). */ + Srd = 'SRD', + /** Swazi Lilangeni (SZL). */ + Szl = 'SZL', + /** Swedish Kronor (SEK). */ + Sek = 'SEK', + /** Swiss Francs (CHF). */ + Chf = 'CHF', + /** Taiwan Dollars (TWD). */ + Twd = 'TWD', + /** Thai baht (THB). */ + Thb = 'THB', + /** Tajikistani Somoni (TJS). */ + Tjs = 'TJS', + /** Tanzanian Shilling (TZS). */ + Tzs = 'TZS', + /** Tongan Pa'anga (TOP). */ + Top = 'TOP', + /** Trinidad and Tobago Dollars (TTD). */ + Ttd = 'TTD', + /** Tunisian Dinar (TND). */ + Tnd = 'TND', + /** Turkish Lira (TRY). */ + Try = 'TRY', + /** Turkmenistani Manat (TMT). */ + Tmt = 'TMT', + /** Ugandan Shilling (UGX). */ + Ugx = 'UGX', + /** Ukrainian Hryvnia (UAH). */ + Uah = 'UAH', + /** United Arab Emirates Dirham (AED). */ + Aed = 'AED', + /** Uruguayan Pesos (UYU). */ + Uyu = 'UYU', + /** Uzbekistan som (UZS). */ + Uzs = 'UZS', + /** Vanuatu Vatu (VUV). */ + Vuv = 'VUV', + /** Venezuelan Bolivares (VEF). */ + Vef = 'VEF', + /** Venezuelan Bolivares (VES). */ + Ves = 'VES', + /** Vietnamese đồng (VND). */ + Vnd = 'VND', + /** West African CFA franc (XOF). */ + Xof = 'XOF', + /** Yemeni Rial (YER). */ + Yer = 'YER', + /** Zambian Kwacha (ZMW). */ + Zmw = 'ZMW', +} + +/** A customer represents a customer account with the shop. Customer accounts store contact information for the customer, saving logged-in customers the trouble of having to provide it at every checkout. */ +export type Customer = { + __typename?: 'Customer' + /** Indicates whether the customer has consented to be sent marketing material via email. */ + acceptsMarketing: Scalars['Boolean'] + /** A list of addresses for the customer. */ + addresses: MailingAddressConnection + /** The date and time when the customer was created. */ + createdAt: Scalars['DateTime'] + /** The customer’s default address. */ + defaultAddress?: Maybe + /** The customer’s name, email or phone number. */ + displayName: Scalars['String'] + /** The customer’s email address. */ + email?: Maybe + /** The customer’s first name. */ + firstName?: Maybe + /** A unique identifier for the customer. */ + id: Scalars['ID'] + /** The customer's most recently updated, incomplete checkout. */ + lastIncompleteCheckout?: Maybe + /** The customer’s last name. */ + lastName?: Maybe + /** The orders associated with the customer. */ + orders: OrderConnection + /** The customer’s phone number. */ + phone?: Maybe + /** + * A comma separated list of tags that have been added to the customer. + * Additional access scope required: unauthenticated_read_customer_tags. + */ + tags: Array + /** The date and time when the customer information was updated. */ + updatedAt: Scalars['DateTime'] +} + +/** A customer represents a customer account with the shop. Customer accounts store contact information for the customer, saving logged-in customers the trouble of having to provide it at every checkout. */ +export type CustomerAddressesArgs = { + first?: Maybe + after?: Maybe + last?: Maybe + before?: Maybe + reverse?: Maybe +} + +/** A customer represents a customer account with the shop. Customer accounts store contact information for the customer, saving logged-in customers the trouble of having to provide it at every checkout. */ +export type CustomerOrdersArgs = { + first?: Maybe + after?: Maybe + last?: Maybe + before?: Maybe + reverse?: Maybe + sortKey?: Maybe + query?: Maybe +} + +/** A CustomerAccessToken represents the unique token required to make modifications to the customer object. */ +export type CustomerAccessToken = { + __typename?: 'CustomerAccessToken' + /** The customer’s access token. */ + accessToken: Scalars['String'] + /** The date and time when the customer access token expires. */ + expiresAt: Scalars['DateTime'] +} + +/** Specifies the input fields required to create a customer access token. */ +export type CustomerAccessTokenCreateInput = { + /** The email associated to the customer. */ + email: Scalars['String'] + /** The login password to be used by the customer. */ + password: Scalars['String'] +} + +/** Return type for `customerAccessTokenCreate` mutation. */ +export type CustomerAccessTokenCreatePayload = { + __typename?: 'CustomerAccessTokenCreatePayload' + /** The newly created customer access token object. */ + customerAccessToken?: Maybe + /** List of errors that occurred executing the mutation. */ + customerUserErrors: Array + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `customerUserErrors` instead + */ + userErrors: Array +} + +/** Return type for `customerAccessTokenCreateWithMultipass` mutation. */ +export type CustomerAccessTokenCreateWithMultipassPayload = { + __typename?: 'CustomerAccessTokenCreateWithMultipassPayload' + /** An access token object associated with the customer. */ + customerAccessToken?: Maybe + /** List of errors that occurred executing the mutation. */ + customerUserErrors: Array +} + +/** Return type for `customerAccessTokenDelete` mutation. */ +export type CustomerAccessTokenDeletePayload = { + __typename?: 'CustomerAccessTokenDeletePayload' + /** The destroyed access token. */ + deletedAccessToken?: Maybe + /** ID of the destroyed customer access token. */ + deletedCustomerAccessTokenId?: Maybe + /** List of errors that occurred executing the mutation. */ + userErrors: Array +} + +/** Return type for `customerAccessTokenRenew` mutation. */ +export type CustomerAccessTokenRenewPayload = { + __typename?: 'CustomerAccessTokenRenewPayload' + /** The renewed customer access token object. */ + customerAccessToken?: Maybe + /** List of errors that occurred executing the mutation. */ + userErrors: Array +} + +/** Return type for `customerActivateByUrl` mutation. */ +export type CustomerActivateByUrlPayload = { + __typename?: 'CustomerActivateByUrlPayload' + /** The customer that was activated. */ + customer?: Maybe + /** A new customer access token for the customer. */ + customerAccessToken?: Maybe + /** List of errors that occurred executing the mutation. */ + customerUserErrors: Array +} + +/** Specifies the input fields required to activate a customer. */ +export type CustomerActivateInput = { + /** The activation token required to activate the customer. */ + activationToken: Scalars['String'] + /** New password that will be set during activation. */ + password: Scalars['String'] +} + +/** Return type for `customerActivate` mutation. */ +export type CustomerActivatePayload = { + __typename?: 'CustomerActivatePayload' + /** The customer object. */ + customer?: Maybe + /** A newly created customer access token object for the customer. */ + customerAccessToken?: Maybe + /** List of errors that occurred executing the mutation. */ + customerUserErrors: Array + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `customerUserErrors` instead + */ + userErrors: Array +} + +/** Return type for `customerAddressCreate` mutation. */ +export type CustomerAddressCreatePayload = { + __typename?: 'CustomerAddressCreatePayload' + /** The new customer address object. */ + customerAddress?: Maybe + /** List of errors that occurred executing the mutation. */ + customerUserErrors: Array + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `customerUserErrors` instead + */ + userErrors: Array +} + +/** Return type for `customerAddressDelete` mutation. */ +export type CustomerAddressDeletePayload = { + __typename?: 'CustomerAddressDeletePayload' + /** List of errors that occurred executing the mutation. */ + customerUserErrors: Array + /** ID of the deleted customer address. */ + deletedCustomerAddressId?: Maybe + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `customerUserErrors` instead + */ + userErrors: Array +} + +/** Return type for `customerAddressUpdate` mutation. */ +export type CustomerAddressUpdatePayload = { + __typename?: 'CustomerAddressUpdatePayload' + /** The customer’s updated mailing address. */ + customerAddress?: Maybe + /** List of errors that occurred executing the mutation. */ + customerUserErrors: Array + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `customerUserErrors` instead + */ + userErrors: Array +} + +/** Specifies the fields required to create a new customer. */ +export type CustomerCreateInput = { + /** The customer’s first name. */ + firstName?: Maybe + /** The customer’s last name. */ + lastName?: Maybe + /** The customer’s email. */ + email: Scalars['String'] + /** + * A unique phone number for the customer. + * + * Formatted using E.164 standard. For example, _+16135551111_. + */ + phone?: Maybe + /** The login password used by the customer. */ + password: Scalars['String'] + /** Indicates whether the customer has consented to be sent marketing material via email. */ + acceptsMarketing?: Maybe +} + +/** Return type for `customerCreate` mutation. */ +export type CustomerCreatePayload = { + __typename?: 'CustomerCreatePayload' + /** The created customer object. */ + customer?: Maybe + /** List of errors that occurred executing the mutation. */ + customerUserErrors: Array + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `customerUserErrors` instead + */ + userErrors: Array +} + +/** Return type for `customerDefaultAddressUpdate` mutation. */ +export type CustomerDefaultAddressUpdatePayload = { + __typename?: 'CustomerDefaultAddressUpdatePayload' + /** The updated customer object. */ + customer?: Maybe + /** List of errors that occurred executing the mutation. */ + customerUserErrors: Array + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `customerUserErrors` instead + */ + userErrors: Array +} + +/** Possible error codes that could be returned by CustomerUserError. */ +export enum CustomerErrorCode { + /** Input value is blank. */ + Blank = 'BLANK', + /** Input value is invalid. */ + Invalid = 'INVALID', + /** Input value is already taken. */ + Taken = 'TAKEN', + /** Input value is too long. */ + TooLong = 'TOO_LONG', + /** Input value is too short. */ + TooShort = 'TOO_SHORT', + /** Unidentified customer. */ + UnidentifiedCustomer = 'UNIDENTIFIED_CUSTOMER', + /** Customer is disabled. */ + CustomerDisabled = 'CUSTOMER_DISABLED', + /** Input password starts or ends with whitespace. */ + PasswordStartsOrEndsWithWhitespace = 'PASSWORD_STARTS_OR_ENDS_WITH_WHITESPACE', + /** Input contains HTML tags. */ + ContainsHtmlTags = 'CONTAINS_HTML_TAGS', + /** Input contains URL. */ + ContainsUrl = 'CONTAINS_URL', + /** Invalid activation token. */ + TokenInvalid = 'TOKEN_INVALID', + /** Customer already enabled. */ + AlreadyEnabled = 'ALREADY_ENABLED', + /** Address does not exist. */ + NotFound = 'NOT_FOUND', + /** Input email contains an invalid domain name. */ + BadDomain = 'BAD_DOMAIN', + /** Multipass token is not valid. */ + InvalidMultipassRequest = 'INVALID_MULTIPASS_REQUEST', +} + +/** Return type for `customerRecover` mutation. */ +export type CustomerRecoverPayload = { + __typename?: 'CustomerRecoverPayload' + /** List of errors that occurred executing the mutation. */ + customerUserErrors: Array + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `customerUserErrors` instead + */ + userErrors: Array +} + +/** Return type for `customerResetByUrl` mutation. */ +export type CustomerResetByUrlPayload = { + __typename?: 'CustomerResetByUrlPayload' + /** The customer object which was reset. */ + customer?: Maybe + /** A newly created customer access token object for the customer. */ + customerAccessToken?: Maybe + /** List of errors that occurred executing the mutation. */ + customerUserErrors: Array + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `customerUserErrors` instead + */ + userErrors: Array +} + +/** Specifies the fields required to reset a customer’s password. */ +export type CustomerResetInput = { + /** The reset token required to reset the customer’s password. */ + resetToken: Scalars['String'] + /** New password that will be set as part of the reset password process. */ + password: Scalars['String'] +} + +/** Return type for `customerReset` mutation. */ +export type CustomerResetPayload = { + __typename?: 'CustomerResetPayload' + /** The customer object which was reset. */ + customer?: Maybe + /** A newly created customer access token object for the customer. */ + customerAccessToken?: Maybe + /** List of errors that occurred executing the mutation. */ + customerUserErrors: Array + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `customerUserErrors` instead + */ + userErrors: Array +} + +/** Specifies the fields required to update the Customer information. */ +export type CustomerUpdateInput = { + /** The customer’s first name. */ + firstName?: Maybe + /** The customer’s last name. */ + lastName?: Maybe + /** The customer’s email. */ + email?: Maybe + /** + * A unique phone number for the customer. + * + * Formatted using E.164 standard. For example, _+16135551111_. To remove the phone number, specify `null`. + */ + phone?: Maybe + /** The login password used by the customer. */ + password?: Maybe + /** Indicates whether the customer has consented to be sent marketing material via email. */ + acceptsMarketing?: Maybe +} + +/** Return type for `customerUpdate` mutation. */ +export type CustomerUpdatePayload = { + __typename?: 'CustomerUpdatePayload' + /** The updated customer object. */ + customer?: Maybe + /** + * The newly created customer access token. If the customer's password is updated, all previous access tokens + * (including the one used to perform this mutation) become invalid, and a new token is generated. + */ + customerAccessToken?: Maybe + /** List of errors that occurred executing the mutation. */ + customerUserErrors: Array + /** + * List of errors that occurred executing the mutation. + * @deprecated Use `customerUserErrors` instead + */ + userErrors: Array +} + +/** Represents an error that happens during execution of a customer mutation. */ +export type CustomerUserError = DisplayableError & { + __typename?: 'CustomerUserError' + /** Error code to uniquely identify the error. */ + code?: Maybe + /** Path to the input field which caused the error. */ + field?: Maybe> + /** The error message. */ + message: Scalars['String'] +} + +/** Digital wallet, such as Apple Pay, which can be used for accelerated checkouts. */ +export enum DigitalWallet { + /** Apple Pay. */ + ApplePay = 'APPLE_PAY', + /** Android Pay. */ + AndroidPay = 'ANDROID_PAY', + /** Google Pay. */ + GooglePay = 'GOOGLE_PAY', + /** Shopify Pay. */ + ShopifyPay = 'SHOPIFY_PAY', +} + +/** An amount discounting the line that has been allocated by a discount. */ +export type DiscountAllocation = { + __typename?: 'DiscountAllocation' + /** Amount of discount allocated. */ + allocatedAmount: MoneyV2 + /** The discount this allocated amount originated from. */ + discountApplication: DiscountApplication +} + +/** + * Discount applications capture the intentions of a discount source at + * the time of application. + */ +export type DiscountApplication = { + /** The method by which the discount's value is allocated to its entitled items. */ + allocationMethod: DiscountApplicationAllocationMethod + /** Which lines of targetType that the discount is allocated over. */ + targetSelection: DiscountApplicationTargetSelection + /** The type of line that the discount is applicable towards. */ + targetType: DiscountApplicationTargetType + /** The value of the discount application. */ + value: PricingValue +} + +/** The method by which the discount's value is allocated onto its entitled lines. */ +export enum DiscountApplicationAllocationMethod { + /** The value is spread across all entitled lines. */ + Across = 'ACROSS', + /** The value is applied onto every entitled line. */ + Each = 'EACH', + /** The value is specifically applied onto a particular line. */ + One = 'ONE', +} + +/** An auto-generated type for paginating through multiple DiscountApplications. */ +export type DiscountApplicationConnection = { + __typename?: 'DiscountApplicationConnection' + /** A list of edges. */ + edges: Array + /** Information to aid in pagination. */ + pageInfo: PageInfo +} + +/** An auto-generated type which holds one DiscountApplication and a cursor during pagination. */ +export type DiscountApplicationEdge = { + __typename?: 'DiscountApplicationEdge' + /** A cursor for use in pagination. */ + cursor: Scalars['String'] + /** The item at the end of DiscountApplicationEdge. */ + node: DiscountApplication +} + +/** + * Which lines on the order that the discount is allocated over, of the type + * defined by the Discount Application's target_type. + */ +export enum DiscountApplicationTargetSelection { + /** The discount is allocated onto all the lines. */ + All = 'ALL', + /** The discount is allocated onto only the lines it is entitled for. */ + Entitled = 'ENTITLED', + /** The discount is allocated onto explicitly chosen lines. */ + Explicit = 'EXPLICIT', +} + +/** The type of line (i.e. line item or shipping line) on an order that the discount is applicable towards. */ +export enum DiscountApplicationTargetType { + /** The discount applies onto line items. */ + LineItem = 'LINE_ITEM', + /** The discount applies onto shipping lines. */ + ShippingLine = 'SHIPPING_LINE', +} + +/** + * Discount code applications capture the intentions of a discount code at + * the time that it is applied. + */ +export type DiscountCodeApplication = DiscountApplication & { + __typename?: 'DiscountCodeApplication' + /** The method by which the discount's value is allocated to its entitled items. */ + allocationMethod: DiscountApplicationAllocationMethod + /** Specifies whether the discount code was applied successfully. */ + applicable: Scalars['Boolean'] + /** The string identifying the discount code that was used at the time of application. */ + code: Scalars['String'] + /** Which lines of targetType that the discount is allocated over. */ + targetSelection: DiscountApplicationTargetSelection + /** The type of line that the discount is applicable towards. */ + targetType: DiscountApplicationTargetType + /** The value of the discount application. */ + value: PricingValue +} + +/** Represents an error in the input of a mutation. */ +export type DisplayableError = { + /** Path to the input field which caused the error. */ + field?: Maybe> + /** The error message. */ + message: Scalars['String'] +} + +/** Represents a web address. */ +export type Domain = { + __typename?: 'Domain' + /** The host name of the domain (eg: `example.com`). */ + host: Scalars['String'] + /** Whether SSL is enabled or not. */ + sslEnabled: Scalars['Boolean'] + /** The URL of the domain (eg: `https://example.com`). */ + url: Scalars['URL'] +} + +/** Represents a video hosted outside of Shopify. */ +export type ExternalVideo = Node & + Media & { + __typename?: 'ExternalVideo' + /** A word or phrase to share the nature or contents of a media. */ + alt?: Maybe + /** The URL. */ + embeddedUrl: Scalars['URL'] + /** Globally unique identifier. */ + id: Scalars['ID'] + /** The media content type. */ + mediaContentType: MediaContentType + /** The preview image for the media. */ + previewImage?: Maybe + } + +/** Represents a single fulfillment in an order. */ +export type Fulfillment = { + __typename?: 'Fulfillment' + /** List of the fulfillment's line items. */ + fulfillmentLineItems: FulfillmentLineItemConnection + /** The name of the tracking company. */ + trackingCompany?: Maybe + /** + * Tracking information associated with the fulfillment, + * such as the tracking number and tracking URL. + */ + trackingInfo: Array +} + +/** Represents a single fulfillment in an order. */ +export type FulfillmentFulfillmentLineItemsArgs = { + first?: Maybe + after?: Maybe + last?: Maybe + before?: Maybe + reverse?: Maybe +} + +/** Represents a single fulfillment in an order. */ +export type FulfillmentTrackingInfoArgs = { + first?: Maybe +} + +/** Represents a single line item in a fulfillment. There is at most one fulfillment line item for each order line item. */ +export type FulfillmentLineItem = { + __typename?: 'FulfillmentLineItem' + /** The associated order's line item. */ + lineItem: OrderLineItem + /** The amount fulfilled in this fulfillment. */ + quantity: Scalars['Int'] +} + +/** An auto-generated type for paginating through multiple FulfillmentLineItems. */ +export type FulfillmentLineItemConnection = { + __typename?: 'FulfillmentLineItemConnection' + /** A list of edges. */ + edges: Array + /** Information to aid in pagination. */ + pageInfo: PageInfo +} + +/** An auto-generated type which holds one FulfillmentLineItem and a cursor during pagination. */ +export type FulfillmentLineItemEdge = { + __typename?: 'FulfillmentLineItemEdge' + /** A cursor for use in pagination. */ + cursor: Scalars['String'] + /** The item at the end of FulfillmentLineItemEdge. */ + node: FulfillmentLineItem +} + +/** Tracking information associated with the fulfillment. */ +export type FulfillmentTrackingInfo = { + __typename?: 'FulfillmentTrackingInfo' + /** The tracking number of the fulfillment. */ + number?: Maybe + /** The URL to track the fulfillment. */ + url?: Maybe +} + +/** Represents information about the metafields associated to the specified resource. */ +export type HasMetafields = { + /** The metafield associated with the resource. */ + metafield?: Maybe + /** A paginated list of metafields associated with the resource. */ + metafields: MetafieldConnection +} + +/** Represents information about the metafields associated to the specified resource. */ +export type HasMetafieldsMetafieldArgs = { + namespace: Scalars['String'] + key: Scalars['String'] +} + +/** Represents information about the metafields associated to the specified resource. */ +export type HasMetafieldsMetafieldsArgs = { + namespace?: Maybe + first?: Maybe + after?: Maybe + last?: Maybe + before?: Maybe + reverse?: Maybe +} + +/** Represents an image resource. */ +export type Image = { + __typename?: 'Image' + /** A word or phrase to share the nature or contents of an image. */ + altText?: Maybe + /** The original height of the image in pixels. Returns `null` if the image is not hosted by Shopify. */ + height?: Maybe + /** A unique identifier for the image. */ + id?: Maybe + /** + * The location of the original image as a URL. + * + * If there are any existing transformations in the original source URL, they will remain and not be stripped. + */ + originalSrc: Scalars['URL'] + /** + * The location of the image as a URL. + * @deprecated Previously an image had a single `src` field. This could either return the original image + * location or a URL that contained transformations such as sizing or scale. + * + * These transformations were specified by arguments on the parent field. + * + * Now an image has two distinct URL fields: `originalSrc` and `transformedSrc`. + * + * * `originalSrc` - the original unmodified image URL + * * `transformedSrc` - the image URL with the specified transformations included + * + * To migrate to the new fields, image transformations should be moved from the parent field to `transformedSrc`. + * + * Before: + * ```graphql + * { + * shop { + * productImages(maxWidth: 200, scale: 2) { + * edges { + * node { + * src + * } + * } + * } + * } + * } + * ``` + * + * After: + * ```graphql + * { + * shop { + * productImages { + * edges { + * node { + * transformedSrc(maxWidth: 200, scale: 2) + * } + * } + * } + * } + * } + * ``` + * + */ + src: Scalars['URL'] + /** + * The location of the transformed image as a URL. + * + * All transformation arguments are considered "best-effort". If they can be applied to an image, they will be. + * Otherwise any transformations which an image type does not support will be ignored. + */ + transformedSrc: Scalars['URL'] + /** The original width of the image in pixels. Returns `null` if the image is not hosted by Shopify. */ + width?: Maybe +} + +/** Represents an image resource. */ +export type ImageTransformedSrcArgs = { + maxWidth?: Maybe + maxHeight?: Maybe + crop?: Maybe + scale?: Maybe + preferredContentType?: Maybe +} + +/** An auto-generated type for paginating through multiple Images. */ +export type ImageConnection = { + __typename?: 'ImageConnection' + /** A list of edges. */ + edges: Array + /** Information to aid in pagination. */ + pageInfo: PageInfo +} + +/** List of supported image content types. */ +export enum ImageContentType { + /** A PNG image. */ + Png = 'PNG', + /** A JPG image. */ + Jpg = 'JPG', + /** A WEBP image. */ + Webp = 'WEBP', +} + +/** An auto-generated type which holds one Image and a cursor during pagination. */ +export type ImageEdge = { + __typename?: 'ImageEdge' + /** A cursor for use in pagination. */ + cursor: Scalars['String'] + /** The item at the end of ImageEdge. */ + node: Image +} + +/** Represents a mailing address for customers and shipping. */ +export type MailingAddress = Node & { + __typename?: 'MailingAddress' + /** The first line of the address. Typically the street address or PO Box number. */ + address1?: Maybe + /** The second line of the address. Typically the number of the apartment, suite, or unit. */ + address2?: Maybe + /** The name of the city, district, village, or town. */ + city?: Maybe + /** The name of the customer's company or organization. */ + company?: Maybe + /** The name of the country. */ + country?: Maybe + /** + * The two-letter code for the country of the address. + * + * For example, US. + * @deprecated Use `countryCodeV2` instead + */ + countryCode?: Maybe + /** + * The two-letter code for the country of the address. + * + * For example, US. + */ + countryCodeV2?: Maybe + /** The first name of the customer. */ + firstName?: Maybe + /** A formatted version of the address, customized by the provided arguments. */ + formatted: Array + /** A comma-separated list of the values for city, province, and country. */ + formattedArea?: Maybe + /** Globally unique identifier. */ + id: Scalars['ID'] + /** The last name of the customer. */ + lastName?: Maybe + /** The latitude coordinate of the customer address. */ + latitude?: Maybe + /** The longitude coordinate of the customer address. */ + longitude?: Maybe + /** The full name of the customer, based on firstName and lastName. */ + name?: Maybe + /** + * A unique phone number for the customer. + * + * Formatted using E.164 standard. For example, _+16135551111_. + */ + phone?: Maybe + /** The region of the address, such as the province, state, or district. */ + province?: Maybe + /** + * The two-letter code for the region. + * + * For example, ON. + */ + provinceCode?: Maybe + /** The zip or postal code of the address. */ + zip?: Maybe +} + +/** Represents a mailing address for customers and shipping. */ +export type MailingAddressFormattedArgs = { + withName?: Maybe + withCompany?: Maybe +} + +/** An auto-generated type for paginating through multiple MailingAddresses. */ +export type MailingAddressConnection = { + __typename?: 'MailingAddressConnection' + /** A list of edges. */ + edges: Array + /** Information to aid in pagination. */ + pageInfo: PageInfo +} + +/** An auto-generated type which holds one MailingAddress and a cursor during pagination. */ +export type MailingAddressEdge = { + __typename?: 'MailingAddressEdge' + /** A cursor for use in pagination. */ + cursor: Scalars['String'] + /** The item at the end of MailingAddressEdge. */ + node: MailingAddress +} + +/** Specifies the fields accepted to create or update a mailing address. */ +export type MailingAddressInput = { + /** The first line of the address. Typically the street address or PO Box number. */ + address1?: Maybe + /** The second line of the address. Typically the number of the apartment, suite, or unit. */ + address2?: Maybe + /** The name of the city, district, village, or town. */ + city?: Maybe + /** The name of the customer's company or organization. */ + company?: Maybe + /** The name of the country. */ + country?: Maybe + /** The first name of the customer. */ + firstName?: Maybe + /** The last name of the customer. */ + lastName?: Maybe + /** + * A unique phone number for the customer. + * + * Formatted using E.164 standard. For example, _+16135551111_. + */ + phone?: Maybe + /** The region of the address, such as the province, state, or district. */ + province?: Maybe + /** The zip or postal code of the address. */ + zip?: Maybe +} + +/** Manual discount applications capture the intentions of a discount that was manually created. */ +export type ManualDiscountApplication = DiscountApplication & { + __typename?: 'ManualDiscountApplication' + /** The method by which the discount's value is allocated to its entitled items. */ + allocationMethod: DiscountApplicationAllocationMethod + /** The description of the application. */ + description?: Maybe + /** Which lines of targetType that the discount is allocated over. */ + targetSelection: DiscountApplicationTargetSelection + /** The type of line that the discount is applicable towards. */ + targetType: DiscountApplicationTargetType + /** The title of the application. */ + title: Scalars['String'] + /** The value of the discount application. */ + value: PricingValue +} + +/** Represents a media interface. */ +export type Media = { + /** A word or phrase to share the nature or contents of a media. */ + alt?: Maybe + /** The media content type. */ + mediaContentType: MediaContentType + /** The preview image for the media. */ + previewImage?: Maybe +} + +/** An auto-generated type for paginating through multiple Media. */ +export type MediaConnection = { + __typename?: 'MediaConnection' + /** A list of edges. */ + edges: Array + /** Information to aid in pagination. */ + pageInfo: PageInfo +} + +/** The possible content types for a media object. */ +export enum MediaContentType { + /** An externally hosted video. */ + ExternalVideo = 'EXTERNAL_VIDEO', + /** A Shopify hosted image. */ + Image = 'IMAGE', + /** A 3d model. */ + Model_3D = 'MODEL_3D', + /** A Shopify hosted video. */ + Video = 'VIDEO', +} + +/** An auto-generated type which holds one Media and a cursor during pagination. */ +export type MediaEdge = { + __typename?: 'MediaEdge' + /** A cursor for use in pagination. */ + cursor: Scalars['String'] + /** The item at the end of MediaEdge. */ + node: Media +} + +/** Represents a Shopify hosted image. */ +export type MediaImage = Node & + Media & { + __typename?: 'MediaImage' + /** A word or phrase to share the nature or contents of a media. */ + alt?: Maybe + /** Globally unique identifier. */ + id: Scalars['ID'] + /** The image for the media. */ + image?: Maybe + /** The media content type. */ + mediaContentType: MediaContentType + /** The preview image for the media. */ + previewImage?: Maybe + } + +/** + * Metafields represent custom metadata attached to a resource. Metafields can be sorted into namespaces and are + * comprised of keys, values, and value types. + */ +export type Metafield = Node & { + __typename?: 'Metafield' + /** The date and time when the storefront metafield was created. */ + createdAt: Scalars['DateTime'] + /** The description of a metafield. */ + description?: Maybe + /** Globally unique identifier. */ + id: Scalars['ID'] + /** The key name for a metafield. */ + key: Scalars['String'] + /** The namespace for a metafield. */ + namespace: Scalars['String'] + /** The parent object that the metafield belongs to. */ + parentResource: MetafieldParentResource + /** The date and time when the storefront metafield was updated. */ + updatedAt: Scalars['DateTime'] + /** The value of a metafield. */ + value: Scalars['String'] + /** Represents the metafield value type. */ + valueType: MetafieldValueType +} + +/** An auto-generated type for paginating through multiple Metafields. */ +export type MetafieldConnection = { + __typename?: 'MetafieldConnection' + /** A list of edges. */ + edges: Array + /** Information to aid in pagination. */ + pageInfo: PageInfo +} + +/** An auto-generated type which holds one Metafield and a cursor during pagination. */ +export type MetafieldEdge = { + __typename?: 'MetafieldEdge' + /** A cursor for use in pagination. */ + cursor: Scalars['String'] + /** The item at the end of MetafieldEdge. */ + node: Metafield +} + +/** A resource that the metafield belongs to. */ +export type MetafieldParentResource = Product | ProductVariant + +/** Metafield value types. */ +export enum MetafieldValueType { + /** A string metafield. */ + String = 'STRING', + /** An integer metafield. */ + Integer = 'INTEGER', + /** A json string metafield. */ + JsonString = 'JSON_STRING', +} + +/** Represents a Shopify hosted 3D model. */ +export type Model3d = Node & + Media & { + __typename?: 'Model3d' + /** A word or phrase to share the nature or contents of a media. */ + alt?: Maybe + /** Globally unique identifier. */ + id: Scalars['ID'] + /** The media content type. */ + mediaContentType: MediaContentType + /** The preview image for the media. */ + previewImage?: Maybe + /** The sources for a 3d model. */ + sources: Array + } + +/** Represents a source for a Shopify hosted 3d model. */ +export type Model3dSource = { + __typename?: 'Model3dSource' + /** The filesize of the 3d model. */ + filesize: Scalars['Int'] + /** The format of the 3d model. */ + format: Scalars['String'] + /** The MIME type of the 3d model. */ + mimeType: Scalars['String'] + /** The URL of the 3d model. */ + url: Scalars['String'] +} + +/** Specifies the fields for a monetary value with currency. */ +export type MoneyInput = { + /** Decimal money amount. */ + amount: Scalars['Decimal'] + /** Currency of the money. */ + currencyCode: CurrencyCode +} + +/** + * A monetary value with currency. + * + * To format currencies, combine this type's amount and currencyCode fields with your client's locale. + * + * For example, in JavaScript you could use Intl.NumberFormat: + * + * ```js + * new Intl.NumberFormat(locale, { + * style: 'currency', + * currency: currencyCode + * }).format(amount); + * ``` + * + * Other formatting libraries include: + * + * * iOS - [NumberFormatter](https://developer.apple.com/documentation/foundation/numberformatter) + * * Android - [NumberFormat](https://developer.android.com/reference/java/text/NumberFormat.html) + * * PHP - [NumberFormatter](http://php.net/manual/en/class.numberformatter.php) + * + * For a more general solution, the [Unicode CLDR number formatting database] is available with many implementations + * (such as [TwitterCldr](https://github.com/twitter/twitter-cldr-rb)). + */ +export type MoneyV2 = { + __typename?: 'MoneyV2' + /** Decimal money amount. */ + amount: Scalars['Decimal'] + /** Currency of the money. */ + currencyCode: CurrencyCode +} + +/** An auto-generated type for paginating through multiple MoneyV2s. */ +export type MoneyV2Connection = { + __typename?: 'MoneyV2Connection' + /** A list of edges. */ + edges: Array + /** Information to aid in pagination. */ + pageInfo: PageInfo +} + +/** An auto-generated type which holds one MoneyV2 and a cursor during pagination. */ +export type MoneyV2Edge = { + __typename?: 'MoneyV2Edge' + /** A cursor for use in pagination. */ + cursor: Scalars['String'] + /** The item at the end of MoneyV2Edge. */ + node: MoneyV2 +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type Mutation = { + __typename?: 'Mutation' + /** + * Updates the attributes of a checkout. + * @deprecated Use `checkoutAttributesUpdateV2` instead + */ + checkoutAttributesUpdate?: Maybe + /** Updates the attributes of a checkout. */ + checkoutAttributesUpdateV2?: Maybe + /** Completes a checkout without providing payment information. You can use this mutation for free items or items whose purchase price is covered by a gift card. */ + checkoutCompleteFree?: Maybe + /** + * Completes a checkout using a credit card token from Shopify's Vault. + * @deprecated Use `checkoutCompleteWithCreditCardV2` instead + */ + checkoutCompleteWithCreditCard?: Maybe + /** Completes a checkout using a credit card token from Shopify's card vault. Before you can complete checkouts using CheckoutCompleteWithCreditCardV2, you need to [_request payment processing_](https://help.shopify.com/api/guides/sales-channel-sdk/getting-started#request-payment-processing). */ + checkoutCompleteWithCreditCardV2?: Maybe + /** + * Completes a checkout with a tokenized payment. + * @deprecated Use `checkoutCompleteWithTokenizedPaymentV2` instead + */ + checkoutCompleteWithTokenizedPayment?: Maybe + /** + * Completes a checkout with a tokenized payment. + * @deprecated Use `checkoutCompleteWithTokenizedPaymentV3` instead + */ + checkoutCompleteWithTokenizedPaymentV2?: Maybe + /** Completes a checkout with a tokenized payment. */ + checkoutCompleteWithTokenizedPaymentV3?: Maybe + /** Creates a new checkout. */ + checkoutCreate?: Maybe + /** + * Associates a customer to the checkout. + * @deprecated Use `checkoutCustomerAssociateV2` instead + */ + checkoutCustomerAssociate?: Maybe + /** Associates a customer to the checkout. */ + checkoutCustomerAssociateV2?: Maybe + /** + * Disassociates the current checkout customer from the checkout. + * @deprecated Use `checkoutCustomerDisassociateV2` instead + */ + checkoutCustomerDisassociate?: Maybe + /** Disassociates the current checkout customer from the checkout. */ + checkoutCustomerDisassociateV2?: Maybe + /** + * Applies a discount to an existing checkout using a discount code. + * @deprecated Use `checkoutDiscountCodeApplyV2` instead + */ + checkoutDiscountCodeApply?: Maybe + /** Applies a discount to an existing checkout using a discount code. */ + checkoutDiscountCodeApplyV2?: Maybe + /** Removes the applied discount from an existing checkout. */ + checkoutDiscountCodeRemove?: Maybe + /** + * Updates the email on an existing checkout. + * @deprecated Use `checkoutEmailUpdateV2` instead + */ + checkoutEmailUpdate?: Maybe + /** Updates the email on an existing checkout. */ + checkoutEmailUpdateV2?: Maybe + /** + * Applies a gift card to an existing checkout using a gift card code. This will replace all currently applied gift cards. + * @deprecated Use `checkoutGiftCardsAppend` instead + */ + checkoutGiftCardApply?: Maybe + /** + * Removes an applied gift card from the checkout. + * @deprecated Use `checkoutGiftCardRemoveV2` instead + */ + checkoutGiftCardRemove?: Maybe + /** Removes an applied gift card from the checkout. */ + checkoutGiftCardRemoveV2?: Maybe + /** Appends gift cards to an existing checkout. */ + checkoutGiftCardsAppend?: Maybe + /** Adds a list of line items to a checkout. */ + checkoutLineItemsAdd?: Maybe + /** Removes line items from an existing checkout. */ + checkoutLineItemsRemove?: Maybe + /** Sets a list of line items to a checkout. */ + checkoutLineItemsReplace?: Maybe + /** Updates line items on a checkout. */ + checkoutLineItemsUpdate?: Maybe + /** + * Updates the shipping address of an existing checkout. + * @deprecated Use `checkoutShippingAddressUpdateV2` instead + */ + checkoutShippingAddressUpdate?: Maybe + /** Updates the shipping address of an existing checkout. */ + checkoutShippingAddressUpdateV2?: Maybe + /** Updates the shipping lines on an existing checkout. */ + checkoutShippingLineUpdate?: Maybe + /** + * Creates a customer access token. + * The customer access token is required to modify the customer object in any way. + */ + customerAccessTokenCreate?: Maybe + /** + * Creates a customer access token using a multipass token instead of email and password. + * A customer record is created if customer does not exist. If a customer record already + * exists but the record is disabled, then it's enabled. + */ + customerAccessTokenCreateWithMultipass?: Maybe + /** Permanently destroys a customer access token. */ + customerAccessTokenDelete?: Maybe + /** + * Renews a customer access token. + * + * Access token renewal must happen *before* a token expires. + * If a token has already expired, a new one should be created instead via `customerAccessTokenCreate`. + */ + customerAccessTokenRenew?: Maybe + /** Activates a customer. */ + customerActivate?: Maybe + /** Activates a customer with the activation url received from `customerCreate`. */ + customerActivateByUrl?: Maybe + /** Creates a new address for a customer. */ + customerAddressCreate?: Maybe + /** Permanently deletes the address of an existing customer. */ + customerAddressDelete?: Maybe + /** Updates the address of an existing customer. */ + customerAddressUpdate?: Maybe + /** Creates a new customer. */ + customerCreate?: Maybe + /** Updates the default address of an existing customer. */ + customerDefaultAddressUpdate?: Maybe + /** Sends a reset password email to the customer, as the first step in the reset password process. */ + customerRecover?: Maybe + /** Resets a customer’s password with a token received from `CustomerRecover`. */ + customerReset?: Maybe + /** Resets a customer’s password with the reset password url received from `CustomerRecover`. */ + customerResetByUrl?: Maybe + /** Updates an existing customer. */ + customerUpdate?: Maybe +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCheckoutAttributesUpdateArgs = { + checkoutId: Scalars['ID'] + input: CheckoutAttributesUpdateInput +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCheckoutAttributesUpdateV2Args = { + checkoutId: Scalars['ID'] + input: CheckoutAttributesUpdateV2Input +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCheckoutCompleteFreeArgs = { + checkoutId: Scalars['ID'] +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCheckoutCompleteWithCreditCardArgs = { + checkoutId: Scalars['ID'] + payment: CreditCardPaymentInput +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCheckoutCompleteWithCreditCardV2Args = { + checkoutId: Scalars['ID'] + payment: CreditCardPaymentInputV2 +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCheckoutCompleteWithTokenizedPaymentArgs = { + checkoutId: Scalars['ID'] + payment: TokenizedPaymentInput +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCheckoutCompleteWithTokenizedPaymentV2Args = { + checkoutId: Scalars['ID'] + payment: TokenizedPaymentInputV2 +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCheckoutCompleteWithTokenizedPaymentV3Args = { + checkoutId: Scalars['ID'] + payment: TokenizedPaymentInputV3 +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCheckoutCreateArgs = { + input: CheckoutCreateInput +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCheckoutCustomerAssociateArgs = { + checkoutId: Scalars['ID'] + customerAccessToken: Scalars['String'] +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCheckoutCustomerAssociateV2Args = { + checkoutId: Scalars['ID'] + customerAccessToken: Scalars['String'] +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCheckoutCustomerDisassociateArgs = { + checkoutId: Scalars['ID'] +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCheckoutCustomerDisassociateV2Args = { + checkoutId: Scalars['ID'] +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCheckoutDiscountCodeApplyArgs = { + discountCode: Scalars['String'] + checkoutId: Scalars['ID'] +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCheckoutDiscountCodeApplyV2Args = { + discountCode: Scalars['String'] + checkoutId: Scalars['ID'] +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCheckoutDiscountCodeRemoveArgs = { + checkoutId: Scalars['ID'] +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCheckoutEmailUpdateArgs = { + checkoutId: Scalars['ID'] + email: Scalars['String'] +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCheckoutEmailUpdateV2Args = { + checkoutId: Scalars['ID'] + email: Scalars['String'] +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCheckoutGiftCardApplyArgs = { + giftCardCode: Scalars['String'] + checkoutId: Scalars['ID'] +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCheckoutGiftCardRemoveArgs = { + appliedGiftCardId: Scalars['ID'] + checkoutId: Scalars['ID'] +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCheckoutGiftCardRemoveV2Args = { + appliedGiftCardId: Scalars['ID'] + checkoutId: Scalars['ID'] +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCheckoutGiftCardsAppendArgs = { + giftCardCodes: Array + checkoutId: Scalars['ID'] +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCheckoutLineItemsAddArgs = { + lineItems: Array + checkoutId: Scalars['ID'] +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCheckoutLineItemsRemoveArgs = { + checkoutId: Scalars['ID'] + lineItemIds: Array +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCheckoutLineItemsReplaceArgs = { + lineItems: Array + checkoutId: Scalars['ID'] +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCheckoutLineItemsUpdateArgs = { + checkoutId: Scalars['ID'] + lineItems: Array +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCheckoutShippingAddressUpdateArgs = { + shippingAddress: MailingAddressInput + checkoutId: Scalars['ID'] +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCheckoutShippingAddressUpdateV2Args = { + shippingAddress: MailingAddressInput + checkoutId: Scalars['ID'] +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCheckoutShippingLineUpdateArgs = { + checkoutId: Scalars['ID'] + shippingRateHandle: Scalars['String'] +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCustomerAccessTokenCreateArgs = { + input: CustomerAccessTokenCreateInput +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCustomerAccessTokenCreateWithMultipassArgs = { + multipassToken: Scalars['String'] +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCustomerAccessTokenDeleteArgs = { + customerAccessToken: Scalars['String'] +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCustomerAccessTokenRenewArgs = { + customerAccessToken: Scalars['String'] +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCustomerActivateArgs = { + id: Scalars['ID'] + input: CustomerActivateInput +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCustomerActivateByUrlArgs = { + activationUrl: Scalars['URL'] + password: Scalars['String'] +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCustomerAddressCreateArgs = { + customerAccessToken: Scalars['String'] + address: MailingAddressInput +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCustomerAddressDeleteArgs = { + id: Scalars['ID'] + customerAccessToken: Scalars['String'] +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCustomerAddressUpdateArgs = { + customerAccessToken: Scalars['String'] + id: Scalars['ID'] + address: MailingAddressInput +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCustomerCreateArgs = { + input: CustomerCreateInput +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCustomerDefaultAddressUpdateArgs = { + customerAccessToken: Scalars['String'] + addressId: Scalars['ID'] +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCustomerRecoverArgs = { + email: Scalars['String'] +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCustomerResetArgs = { + id: Scalars['ID'] + input: CustomerResetInput +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCustomerResetByUrlArgs = { + resetUrl: Scalars['URL'] + password: Scalars['String'] +} + +/** The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. */ +export type MutationCustomerUpdateArgs = { + customerAccessToken: Scalars['String'] + customer: CustomerUpdateInput +} + +/** An object with an ID to support global identification. */ +export type Node = { + /** Globally unique identifier. */ + id: Scalars['ID'] +} + +/** An order is a customer’s completed request to purchase one or more products from a shop. An order is created when a customer completes the checkout process, during which time they provides an email address, billing address and payment information. */ +export type Order = Node & { + __typename?: 'Order' + /** The reason for the order's cancellation. Returns `null` if the order wasn't canceled. */ + cancelReason?: Maybe + /** The date and time when the order was canceled. Returns null if the order wasn't canceled. */ + canceledAt?: Maybe + /** The code of the currency used for the payment. */ + currencyCode: CurrencyCode + /** The subtotal of line items and their discounts, excluding line items that have been removed. Does not contain order-level discounts, duties, shipping costs, or shipping discounts. Taxes are not included unless the order is a taxes-included order. */ + currentSubtotalPrice: MoneyV2 + /** The total amount of the order, including duties, taxes and discounts, minus amounts for line items that have been removed. */ + currentTotalPrice: MoneyV2 + /** The total of all taxes applied to the order, excluding taxes for returned line items. */ + currentTotalTax: MoneyV2 + /** The locale code in which this specific order happened. */ + customerLocale?: Maybe + /** The unique URL that the customer can use to access the order. */ + customerUrl?: Maybe + /** Discounts that have been applied on the order. */ + discountApplications: DiscountApplicationConnection + /** Whether the order has had any edits applied or not. */ + edited: Scalars['Boolean'] + /** The customer's email address. */ + email?: Maybe + /** The financial status of the order. */ + financialStatus?: Maybe + /** The fulfillment status for the order. */ + fulfillmentStatus: OrderFulfillmentStatus + /** Globally unique identifier. */ + id: Scalars['ID'] + /** List of the order’s line items. */ + lineItems: OrderLineItemConnection + /** + * Unique identifier for the order that appears on the order. + * For example, _#1000_ or _Store1001. + */ + name: Scalars['String'] + /** A unique numeric identifier for the order for use by shop owner and customer. */ + orderNumber: Scalars['Int'] + /** The total price of the order before any applied edits. */ + originalTotalPrice: MoneyV2 + /** The customer's phone number for receiving SMS notifications. */ + phone?: Maybe + /** + * The date and time when the order was imported. + * This value can be set to dates in the past when importing from other systems. + * If no value is provided, it will be auto-generated based on current date and time. + */ + processedAt: Scalars['DateTime'] + /** The address to where the order will be shipped. */ + shippingAddress?: Maybe + /** The discounts that have been allocated onto the shipping line by discount applications. */ + shippingDiscountAllocations: Array + /** The unique URL for the order's status page. */ + statusUrl: Scalars['URL'] + /** + * Price of the order before shipping and taxes. + * @deprecated Use `subtotalPriceV2` instead + */ + subtotalPrice?: Maybe + /** Price of the order before duties, shipping and taxes. */ + subtotalPriceV2?: Maybe + /** List of the order’s successful fulfillments. */ + successfulFulfillments?: Maybe> + /** + * The sum of all the prices of all the items in the order, taxes and discounts included (must be positive). + * @deprecated Use `totalPriceV2` instead + */ + totalPrice: Scalars['Money'] + /** The sum of all the prices of all the items in the order, duties, taxes and discounts included (must be positive). */ + totalPriceV2: MoneyV2 + /** + * The total amount that has been refunded. + * @deprecated Use `totalRefundedV2` instead + */ + totalRefunded: Scalars['Money'] + /** The total amount that has been refunded. */ + totalRefundedV2: MoneyV2 + /** + * The total cost of shipping. + * @deprecated Use `totalShippingPriceV2` instead + */ + totalShippingPrice: Scalars['Money'] + /** The total cost of shipping. */ + totalShippingPriceV2: MoneyV2 + /** + * The total cost of taxes. + * @deprecated Use `totalTaxV2` instead + */ + totalTax?: Maybe + /** The total cost of taxes. */ + totalTaxV2?: Maybe +} + +/** An order is a customer’s completed request to purchase one or more products from a shop. An order is created when a customer completes the checkout process, during which time they provides an email address, billing address and payment information. */ +export type OrderDiscountApplicationsArgs = { + first?: Maybe + after?: Maybe + last?: Maybe + before?: Maybe + reverse?: Maybe +} + +/** An order is a customer’s completed request to purchase one or more products from a shop. An order is created when a customer completes the checkout process, during which time they provides an email address, billing address and payment information. */ +export type OrderLineItemsArgs = { + first?: Maybe + after?: Maybe + last?: Maybe + before?: Maybe + reverse?: Maybe +} + +/** An order is a customer’s completed request to purchase one or more products from a shop. An order is created when a customer completes the checkout process, during which time they provides an email address, billing address and payment information. */ +export type OrderSuccessfulFulfillmentsArgs = { + first?: Maybe +} + +/** Represents the reason for the order's cancellation. */ +export enum OrderCancelReason { + /** The customer wanted to cancel the order. */ + Customer = 'CUSTOMER', + /** The order was fraudulent. */ + Fraud = 'FRAUD', + /** There was insufficient inventory. */ + Inventory = 'INVENTORY', + /** Payment was declined. */ + Declined = 'DECLINED', + /** The order was canceled for an unlisted reason. */ + Other = 'OTHER', +} + +/** An auto-generated type for paginating through multiple Orders. */ +export type OrderConnection = { + __typename?: 'OrderConnection' + /** A list of edges. */ + edges: Array + /** Information to aid in pagination. */ + pageInfo: PageInfo +} + +/** An auto-generated type which holds one Order and a cursor during pagination. */ +export type OrderEdge = { + __typename?: 'OrderEdge' + /** A cursor for use in pagination. */ + cursor: Scalars['String'] + /** The item at the end of OrderEdge. */ + node: Order +} + +/** Represents the order's current financial status. */ +export enum OrderFinancialStatus { + /** Displayed as **Pending**. */ + Pending = 'PENDING', + /** Displayed as **Authorized**. */ + Authorized = 'AUTHORIZED', + /** Displayed as **Partially paid**. */ + PartiallyPaid = 'PARTIALLY_PAID', + /** Displayed as **Partially refunded**. */ + PartiallyRefunded = 'PARTIALLY_REFUNDED', + /** Displayed as **Voided**. */ + Voided = 'VOIDED', + /** Displayed as **Paid**. */ + Paid = 'PAID', + /** Displayed as **Refunded**. */ + Refunded = 'REFUNDED', +} + +/** Represents the order's current fulfillment status. */ +export enum OrderFulfillmentStatus { + /** Displayed as **Unfulfilled**. */ + Unfulfilled = 'UNFULFILLED', + /** Displayed as **Partially fulfilled**. */ + PartiallyFulfilled = 'PARTIALLY_FULFILLED', + /** Displayed as **Fulfilled**. */ + Fulfilled = 'FULFILLED', + /** Displayed as **Restocked**. */ + Restocked = 'RESTOCKED', + /** Displayed as **Pending fulfillment**. */ + PendingFulfillment = 'PENDING_FULFILLMENT', + /** Displayed as **Open**. */ + Open = 'OPEN', + /** Displayed as **In progress**. */ + InProgress = 'IN_PROGRESS', + /** Displayed as **Scheduled**. */ + Scheduled = 'SCHEDULED', +} + +/** Represents a single line in an order. There is one line item for each distinct product variant. */ +export type OrderLineItem = { + __typename?: 'OrderLineItem' + /** The number of entries associated to the line item minus the items that have been removed. */ + currentQuantity: Scalars['Int'] + /** List of custom attributes associated to the line item. */ + customAttributes: Array + /** The discounts that have been allocated onto the order line item by discount applications. */ + discountAllocations: Array + /** The total price of the line item, including discounts, and displayed in the presentment currency. */ + discountedTotalPrice: MoneyV2 + /** The total price of the line item, not including any discounts. The total price is calculated using the original unit price multiplied by the quantity, and it is displayed in the presentment currency. */ + originalTotalPrice: MoneyV2 + /** The number of products variants associated to the line item. */ + quantity: Scalars['Int'] + /** The title of the product combined with title of the variant. */ + title: Scalars['String'] + /** The product variant object associated to the line item. */ + variant?: Maybe +} + +/** An auto-generated type for paginating through multiple OrderLineItems. */ +export type OrderLineItemConnection = { + __typename?: 'OrderLineItemConnection' + /** A list of edges. */ + edges: Array + /** Information to aid in pagination. */ + pageInfo: PageInfo +} + +/** An auto-generated type which holds one OrderLineItem and a cursor during pagination. */ +export type OrderLineItemEdge = { + __typename?: 'OrderLineItemEdge' + /** A cursor for use in pagination. */ + cursor: Scalars['String'] + /** The item at the end of OrderLineItemEdge. */ + node: OrderLineItem +} + +/** The set of valid sort keys for the Order query. */ +export enum OrderSortKeys { + /** Sort by the `processed_at` value. */ + ProcessedAt = 'PROCESSED_AT', + /** Sort by the `total_price` value. */ + TotalPrice = 'TOTAL_PRICE', + /** Sort by the `id` value. */ + Id = 'ID', + /** + * During a search (i.e. when the `query` parameter has been specified on the connection) this sorts the + * results by relevance to the search term(s). When no search query is specified, this sort key is not + * deterministic and should not be used. + */ + Relevance = 'RELEVANCE', +} + +/** Shopify merchants can create pages to hold static HTML content. Each Page object represents a custom page on the online store. */ +export type Page = Node & { + __typename?: 'Page' + /** The description of the page, complete with HTML formatting. */ + body: Scalars['HTML'] + /** Summary of the page body. */ + bodySummary: Scalars['String'] + /** The timestamp of the page creation. */ + createdAt: Scalars['DateTime'] + /** A human-friendly unique string for the page automatically generated from its title. */ + handle: Scalars['String'] + /** Globally unique identifier. */ + id: Scalars['ID'] + /** The page's SEO information. */ + seo?: Maybe + /** The title of the page. */ + title: Scalars['String'] + /** The timestamp of the latest page update. */ + updatedAt: Scalars['DateTime'] + /** The url pointing to the page accessible from the web. */ + url: Scalars['URL'] +} + +/** An auto-generated type for paginating through multiple Pages. */ +export type PageConnection = { + __typename?: 'PageConnection' + /** A list of edges. */ + edges: Array + /** Information to aid in pagination. */ + pageInfo: PageInfo +} + +/** An auto-generated type which holds one Page and a cursor during pagination. */ +export type PageEdge = { + __typename?: 'PageEdge' + /** A cursor for use in pagination. */ + cursor: Scalars['String'] + /** The item at the end of PageEdge. */ + node: Page +} + +/** Information about pagination in a connection. */ +export type PageInfo = { + __typename?: 'PageInfo' + /** Indicates if there are more pages to fetch. */ + hasNextPage: Scalars['Boolean'] + /** Indicates if there are any pages prior to the current page. */ + hasPreviousPage: Scalars['Boolean'] +} + +/** The set of valid sort keys for the Page query. */ +export enum PageSortKeys { + /** Sort by the `title` value. */ + Title = 'TITLE', + /** Sort by the `updated_at` value. */ + UpdatedAt = 'UPDATED_AT', + /** Sort by the `id` value. */ + Id = 'ID', + /** + * During a search (i.e. when the `query` parameter has been specified on the connection) this sorts the + * results by relevance to the search term(s). When no search query is specified, this sort key is not + * deterministic and should not be used. + */ + Relevance = 'RELEVANCE', +} + +/** A payment applied to a checkout. */ +export type Payment = Node & { + __typename?: 'Payment' + /** + * The amount of the payment. + * @deprecated Use `amountV2` instead + */ + amount: Scalars['Money'] + /** The amount of the payment. */ + amountV2: MoneyV2 + /** The billing address for the payment. */ + billingAddress?: Maybe + /** The checkout to which the payment belongs. */ + checkout: Checkout + /** The credit card used for the payment in the case of direct payments. */ + creditCard?: Maybe + /** A message describing a processing error during asynchronous processing. */ + errorMessage?: Maybe + /** Globally unique identifier. */ + id: Scalars['ID'] + /** A client-side generated token to identify a payment and perform idempotent operations. */ + idempotencyKey?: Maybe + /** The URL where the customer needs to be redirected so they can complete the 3D Secure payment flow. */ + nextActionUrl?: Maybe + /** Whether or not the payment is still processing asynchronously. */ + ready: Scalars['Boolean'] + /** A flag to indicate if the payment is to be done in test mode for gateways that support it. */ + test: Scalars['Boolean'] + /** The actual transaction recorded by Shopify after having processed the payment with the gateway. */ + transaction?: Maybe +} + +/** Settings related to payments. */ +export type PaymentSettings = { + __typename?: 'PaymentSettings' + /** List of the card brands which the shop accepts. */ + acceptedCardBrands: Array + /** The url pointing to the endpoint to vault credit cards. */ + cardVaultUrl: Scalars['URL'] + /** The country where the shop is located. */ + countryCode: CountryCode + /** The three-letter code for the shop's primary currency. */ + currencyCode: CurrencyCode + /** A list of enabled currencies (ISO 4217 format) that the shop accepts. Merchants can enable currencies from their Shopify Payments settings in the Shopify admin. */ + enabledPresentmentCurrencies: Array + /** The shop’s Shopify Payments account id. */ + shopifyPaymentsAccountId?: Maybe + /** List of the digital wallets which the shop supports. */ + supportedDigitalWallets: Array +} + +/** The valid values for the types of payment token. */ +export enum PaymentTokenType { + /** Apple Pay token type. */ + ApplePay = 'APPLE_PAY', + /** Vault payment token type. */ + Vault = 'VAULT', + /** Shopify Pay token type. */ + ShopifyPay = 'SHOPIFY_PAY', + /** Google Pay token type. */ + GooglePay = 'GOOGLE_PAY', +} + +/** The value of the percentage pricing object. */ +export type PricingPercentageValue = { + __typename?: 'PricingPercentageValue' + /** The percentage value of the object. */ + percentage: Scalars['Float'] +} + +/** The price value (fixed or percentage) for a discount application. */ +export type PricingValue = MoneyV2 | PricingPercentageValue + +/** + * A product represents an individual item for sale in a Shopify store. Products are often physical, but they don't have to be. + * For example, a digital download (such as a movie, music or ebook file) also qualifies as a product, as do services (such as equipment rental, work for hire, customization of another product or an extended warranty). + */ +export type Product = Node & + HasMetafields & { + __typename?: 'Product' + /** Indicates if at least one product variant is available for sale. */ + availableForSale: Scalars['Boolean'] + /** List of collections a product belongs to. */ + collections: CollectionConnection + /** The compare at price of the product across all variants. */ + compareAtPriceRange: ProductPriceRange + /** The date and time when the product was created. */ + createdAt: Scalars['DateTime'] + /** Stripped description of the product, single line with HTML tags removed. */ + description: Scalars['String'] + /** The description of the product, complete with HTML formatting. */ + descriptionHtml: Scalars['HTML'] + /** + * A human-friendly unique string for the Product automatically generated from its title. + * They are used by the Liquid templating language to refer to objects. + */ + handle: Scalars['String'] + /** Globally unique identifier. */ + id: Scalars['ID'] + /** List of images associated with the product. */ + images: ImageConnection + /** The media associated with the product. */ + media: MediaConnection + /** The metafield associated with the resource. */ + metafield?: Maybe + /** A paginated list of metafields associated with the resource. */ + metafields: MetafieldConnection + /** + * The online store URL for the product. + * A value of `null` indicates that the product is not published to the Online Store sales channel. + */ + onlineStoreUrl?: Maybe + /** List of product options. */ + options: Array + /** List of price ranges in the presentment currencies for this shop. */ + presentmentPriceRanges: ProductPriceRangeConnection + /** The price range. */ + priceRange: ProductPriceRange + /** A categorization that a product can be tagged with, commonly used for filtering and searching. */ + productType: Scalars['String'] + /** The date and time when the product was published to the channel. */ + publishedAt: Scalars['DateTime'] + /** The product's SEO information. */ + seo: Seo + /** + * A comma separated list of tags that have been added to the product. + * Additional access scope required for private apps: unauthenticated_read_product_tags. + */ + tags: Array + /** The product’s title. */ + title: Scalars['String'] + /** The total quantity of inventory in stock for this Product. */ + totalInventory?: Maybe + /** + * The date and time when the product was last modified. + * A product's `updatedAt` value can change for different reasons. For example, if an order + * is placed for a product that has inventory tracking set up, then the inventory adjustment + * is counted as an update. + */ + updatedAt: Scalars['DateTime'] + /** + * Find a product’s variant based on its selected options. + * This is useful for converting a user’s selection of product options into a single matching variant. + * If there is not a variant for the selected options, `null` will be returned. + */ + variantBySelectedOptions?: Maybe + /** List of the product’s variants. */ + variants: ProductVariantConnection + /** The product’s vendor name. */ + vendor: Scalars['String'] + } + +/** + * A product represents an individual item for sale in a Shopify store. Products are often physical, but they don't have to be. + * For example, a digital download (such as a movie, music or ebook file) also qualifies as a product, as do services (such as equipment rental, work for hire, customization of another product or an extended warranty). + */ +export type ProductCollectionsArgs = { + first?: Maybe + after?: Maybe + last?: Maybe + before?: Maybe + reverse?: Maybe +} + +/** + * A product represents an individual item for sale in a Shopify store. Products are often physical, but they don't have to be. + * For example, a digital download (such as a movie, music or ebook file) also qualifies as a product, as do services (such as equipment rental, work for hire, customization of another product or an extended warranty). + */ +export type ProductDescriptionArgs = { + truncateAt?: Maybe +} + +/** + * A product represents an individual item for sale in a Shopify store. Products are often physical, but they don't have to be. + * For example, a digital download (such as a movie, music or ebook file) also qualifies as a product, as do services (such as equipment rental, work for hire, customization of another product or an extended warranty). + */ +export type ProductImagesArgs = { + first?: Maybe + after?: Maybe + last?: Maybe + before?: Maybe + reverse?: Maybe + sortKey?: Maybe + maxWidth?: Maybe + maxHeight?: Maybe + crop?: Maybe + scale?: Maybe +} + +/** + * A product represents an individual item for sale in a Shopify store. Products are often physical, but they don't have to be. + * For example, a digital download (such as a movie, music or ebook file) also qualifies as a product, as do services (such as equipment rental, work for hire, customization of another product or an extended warranty). + */ +export type ProductMediaArgs = { + first?: Maybe + after?: Maybe + last?: Maybe + before?: Maybe + reverse?: Maybe + sortKey?: Maybe +} + +/** + * A product represents an individual item for sale in a Shopify store. Products are often physical, but they don't have to be. + * For example, a digital download (such as a movie, music or ebook file) also qualifies as a product, as do services (such as equipment rental, work for hire, customization of another product or an extended warranty). + */ +export type ProductMetafieldArgs = { + namespace: Scalars['String'] + key: Scalars['String'] +} + +/** + * A product represents an individual item for sale in a Shopify store. Products are often physical, but they don't have to be. + * For example, a digital download (such as a movie, music or ebook file) also qualifies as a product, as do services (such as equipment rental, work for hire, customization of another product or an extended warranty). + */ +export type ProductMetafieldsArgs = { + namespace?: Maybe + first?: Maybe + after?: Maybe + last?: Maybe + before?: Maybe + reverse?: Maybe +} + +/** + * A product represents an individual item for sale in a Shopify store. Products are often physical, but they don't have to be. + * For example, a digital download (such as a movie, music or ebook file) also qualifies as a product, as do services (such as equipment rental, work for hire, customization of another product or an extended warranty). + */ +export type ProductOptionsArgs = { + first?: Maybe +} + +/** + * A product represents an individual item for sale in a Shopify store. Products are often physical, but they don't have to be. + * For example, a digital download (such as a movie, music or ebook file) also qualifies as a product, as do services (such as equipment rental, work for hire, customization of another product or an extended warranty). + */ +export type ProductPresentmentPriceRangesArgs = { + presentmentCurrencies?: Maybe> + first?: Maybe + after?: Maybe + last?: Maybe + before?: Maybe + reverse?: Maybe +} + +/** + * A product represents an individual item for sale in a Shopify store. Products are often physical, but they don't have to be. + * For example, a digital download (such as a movie, music or ebook file) also qualifies as a product, as do services (such as equipment rental, work for hire, customization of another product or an extended warranty). + */ +export type ProductVariantBySelectedOptionsArgs = { + selectedOptions: Array +} + +/** + * A product represents an individual item for sale in a Shopify store. Products are often physical, but they don't have to be. + * For example, a digital download (such as a movie, music or ebook file) also qualifies as a product, as do services (such as equipment rental, work for hire, customization of another product or an extended warranty). + */ +export type ProductVariantsArgs = { + first?: Maybe + after?: Maybe + last?: Maybe + before?: Maybe + reverse?: Maybe + sortKey?: Maybe +} + +/** The set of valid sort keys for the ProductCollection query. */ +export enum ProductCollectionSortKeys { + /** Sort by the `title` value. */ + Title = 'TITLE', + /** Sort by the `price` value. */ + Price = 'PRICE', + /** Sort by the `best-selling` value. */ + BestSelling = 'BEST_SELLING', + /** Sort by the `created` value. */ + Created = 'CREATED', + /** Sort by the `id` value. */ + Id = 'ID', + /** Sort by the `manual` value. */ + Manual = 'MANUAL', + /** Sort by the `collection-default` value. */ + CollectionDefault = 'COLLECTION_DEFAULT', + /** + * During a search (i.e. when the `query` parameter has been specified on the connection) this sorts the + * results by relevance to the search term(s). When no search query is specified, this sort key is not + * deterministic and should not be used. + */ + Relevance = 'RELEVANCE', +} + +/** An auto-generated type for paginating through multiple Products. */ +export type ProductConnection = { + __typename?: 'ProductConnection' + /** A list of edges. */ + edges: Array + /** Information to aid in pagination. */ + pageInfo: PageInfo +} + +/** An auto-generated type which holds one Product and a cursor during pagination. */ +export type ProductEdge = { + __typename?: 'ProductEdge' + /** A cursor for use in pagination. */ + cursor: Scalars['String'] + /** The item at the end of ProductEdge. */ + node: Product +} + +/** The set of valid sort keys for the ProductImage query. */ +export enum ProductImageSortKeys { + /** Sort by the `created_at` value. */ + CreatedAt = 'CREATED_AT', + /** Sort by the `position` value. */ + Position = 'POSITION', + /** Sort by the `id` value. */ + Id = 'ID', + /** + * During a search (i.e. when the `query` parameter has been specified on the connection) this sorts the + * results by relevance to the search term(s). When no search query is specified, this sort key is not + * deterministic and should not be used. + */ + Relevance = 'RELEVANCE', +} + +/** The set of valid sort keys for the ProductMedia query. */ +export enum ProductMediaSortKeys { + /** Sort by the `position` value. */ + Position = 'POSITION', + /** Sort by the `id` value. */ + Id = 'ID', + /** + * During a search (i.e. when the `query` parameter has been specified on the connection) this sorts the + * results by relevance to the search term(s). When no search query is specified, this sort key is not + * deterministic and should not be used. + */ + Relevance = 'RELEVANCE', +} + +/** + * Product property names like "Size", "Color", and "Material" that the customers can select. + * Variants are selected based on permutations of these options. + * 255 characters limit each. + */ +export type ProductOption = Node & { + __typename?: 'ProductOption' + /** Globally unique identifier. */ + id: Scalars['ID'] + /** The product option’s name. */ + name: Scalars['String'] + /** The corresponding value to the product option name. */ + values: Array +} + +/** The price range of the product. */ +export type ProductPriceRange = { + __typename?: 'ProductPriceRange' + /** The highest variant's price. */ + maxVariantPrice: MoneyV2 + /** The lowest variant's price. */ + minVariantPrice: MoneyV2 +} + +/** An auto-generated type for paginating through multiple ProductPriceRanges. */ +export type ProductPriceRangeConnection = { + __typename?: 'ProductPriceRangeConnection' + /** A list of edges. */ + edges: Array + /** Information to aid in pagination. */ + pageInfo: PageInfo +} + +/** An auto-generated type which holds one ProductPriceRange and a cursor during pagination. */ +export type ProductPriceRangeEdge = { + __typename?: 'ProductPriceRangeEdge' + /** A cursor for use in pagination. */ + cursor: Scalars['String'] + /** The item at the end of ProductPriceRangeEdge. */ + node: ProductPriceRange +} + +/** The set of valid sort keys for the Product query. */ +export enum ProductSortKeys { + /** Sort by the `title` value. */ + Title = 'TITLE', + /** Sort by the `product_type` value. */ + ProductType = 'PRODUCT_TYPE', + /** Sort by the `vendor` value. */ + Vendor = 'VENDOR', + /** Sort by the `updated_at` value. */ + UpdatedAt = 'UPDATED_AT', + /** Sort by the `created_at` value. */ + CreatedAt = 'CREATED_AT', + /** Sort by the `best_selling` value. */ + BestSelling = 'BEST_SELLING', + /** Sort by the `price` value. */ + Price = 'PRICE', + /** Sort by the `id` value. */ + Id = 'ID', + /** + * During a search (i.e. when the `query` parameter has been specified on the connection) this sorts the + * results by relevance to the search term(s). When no search query is specified, this sort key is not + * deterministic and should not be used. + */ + Relevance = 'RELEVANCE', +} + +/** A product variant represents a different version of a product, such as differing sizes or differing colors. */ +export type ProductVariant = Node & + HasMetafields & { + __typename?: 'ProductVariant' + /** + * Indicates if the product variant is in stock. + * @deprecated Use `availableForSale` instead + */ + available?: Maybe + /** Indicates if the product variant is available for sale. */ + availableForSale: Scalars['Boolean'] + /** + * The compare at price of the variant. This can be used to mark a variant as on sale, when `compareAtPrice` is higher than `price`. + * @deprecated Use `compareAtPriceV2` instead + */ + compareAtPrice?: Maybe + /** The compare at price of the variant. This can be used to mark a variant as on sale, when `compareAtPriceV2` is higher than `priceV2`. */ + compareAtPriceV2?: Maybe + /** Whether a product is out of stock but still available for purchase (used for backorders). */ + currentlyNotInStock: Scalars['Boolean'] + /** Globally unique identifier. */ + id: Scalars['ID'] + /** Image associated with the product variant. This field falls back to the product image if no image is available. */ + image?: Maybe + /** The metafield associated with the resource. */ + metafield?: Maybe + /** A paginated list of metafields associated with the resource. */ + metafields: MetafieldConnection + /** List of prices and compare-at prices in the presentment currencies for this shop. */ + presentmentPrices: ProductVariantPricePairConnection + /** List of unit prices in the presentment currencies for this shop. */ + presentmentUnitPrices: MoneyV2Connection + /** + * The product variant’s price. + * @deprecated Use `priceV2` instead + */ + price: Scalars['Money'] + /** The product variant’s price. */ + priceV2: MoneyV2 + /** The product object that the product variant belongs to. */ + product: Product + /** The total sellable quantity of the variant for online sales channels. */ + quantityAvailable?: Maybe + /** Whether a customer needs to provide a shipping address when placing an order for the product variant. */ + requiresShipping: Scalars['Boolean'] + /** List of product options applied to the variant. */ + selectedOptions: Array + /** The SKU (stock keeping unit) associated with the variant. */ + sku?: Maybe + /** The product variant’s title. */ + title: Scalars['String'] + /** The unit price value for the variant based on the variant's measurement. */ + unitPrice?: Maybe + /** The unit price measurement for the variant. */ + unitPriceMeasurement?: Maybe + /** The weight of the product variant in the unit system specified with `weight_unit`. */ + weight?: Maybe + /** Unit of measurement for weight. */ + weightUnit: WeightUnit + } + +/** A product variant represents a different version of a product, such as differing sizes or differing colors. */ +export type ProductVariantImageArgs = { + maxWidth?: Maybe + maxHeight?: Maybe + crop?: Maybe + scale?: Maybe +} + +/** A product variant represents a different version of a product, such as differing sizes or differing colors. */ +export type ProductVariantMetafieldArgs = { + namespace: Scalars['String'] + key: Scalars['String'] +} + +/** A product variant represents a different version of a product, such as differing sizes or differing colors. */ +export type ProductVariantMetafieldsArgs = { + namespace?: Maybe + first?: Maybe + after?: Maybe + last?: Maybe + before?: Maybe + reverse?: Maybe +} + +/** A product variant represents a different version of a product, such as differing sizes or differing colors. */ +export type ProductVariantPresentmentPricesArgs = { + presentmentCurrencies?: Maybe> + first?: Maybe + after?: Maybe + last?: Maybe + before?: Maybe + reverse?: Maybe +} + +/** A product variant represents a different version of a product, such as differing sizes or differing colors. */ +export type ProductVariantPresentmentUnitPricesArgs = { + presentmentCurrencies?: Maybe> + first?: Maybe + after?: Maybe + last?: Maybe + before?: Maybe + reverse?: Maybe +} + +/** An auto-generated type for paginating through multiple ProductVariants. */ +export type ProductVariantConnection = { + __typename?: 'ProductVariantConnection' + /** A list of edges. */ + edges: Array + /** Information to aid in pagination. */ + pageInfo: PageInfo +} + +/** An auto-generated type which holds one ProductVariant and a cursor during pagination. */ +export type ProductVariantEdge = { + __typename?: 'ProductVariantEdge' + /** A cursor for use in pagination. */ + cursor: Scalars['String'] + /** The item at the end of ProductVariantEdge. */ + node: ProductVariant +} + +/** The compare-at price and price of a variant sharing a currency. */ +export type ProductVariantPricePair = { + __typename?: 'ProductVariantPricePair' + /** The compare-at price of the variant with associated currency. */ + compareAtPrice?: Maybe + /** The price of the variant with associated currency. */ + price: MoneyV2 +} + +/** An auto-generated type for paginating through multiple ProductVariantPricePairs. */ +export type ProductVariantPricePairConnection = { + __typename?: 'ProductVariantPricePairConnection' + /** A list of edges. */ + edges: Array + /** Information to aid in pagination. */ + pageInfo: PageInfo +} + +/** An auto-generated type which holds one ProductVariantPricePair and a cursor during pagination. */ +export type ProductVariantPricePairEdge = { + __typename?: 'ProductVariantPricePairEdge' + /** A cursor for use in pagination. */ + cursor: Scalars['String'] + /** The item at the end of ProductVariantPricePairEdge. */ + node: ProductVariantPricePair +} + +/** The set of valid sort keys for the ProductVariant query. */ +export enum ProductVariantSortKeys { + /** Sort by the `title` value. */ + Title = 'TITLE', + /** Sort by the `sku` value. */ + Sku = 'SKU', + /** Sort by the `position` value. */ + Position = 'POSITION', + /** Sort by the `id` value. */ + Id = 'ID', + /** + * During a search (i.e. when the `query` parameter has been specified on the connection) this sorts the + * results by relevance to the search term(s). When no search query is specified, this sort key is not + * deterministic and should not be used. + */ + Relevance = 'RELEVANCE', +} + +/** The schema’s entry-point for queries. This acts as the public, top-level API from which all queries must start. */ +export type QueryRoot = { + __typename?: 'QueryRoot' + /** List of the shop's articles. */ + articles: ArticleConnection + /** Find a blog by its handle. */ + blogByHandle?: Maybe + /** List of the shop's blogs. */ + blogs: BlogConnection + /** Find a collection by its handle. */ + collectionByHandle?: Maybe + /** List of the shop’s collections. */ + collections: CollectionConnection + /** Find a customer by its access token. */ + customer?: Maybe + node?: Maybe + nodes: Array> + /** Find a page by its handle. */ + pageByHandle?: Maybe + /** List of the shop's pages. */ + pages: PageConnection + /** Find a product by its handle. */ + productByHandle?: Maybe + /** + * Find recommended products related to a given `product_id`. + * To learn more about how recommendations are generated, see + * [*Showing product recommendations on product pages*](https://help.shopify.com/themes/development/recommended-products). + */ + productRecommendations?: Maybe> + /** + * Tags added to products. + * Additional access scope required: unauthenticated_read_product_tags. + */ + productTags: StringConnection + /** List of product types for the shop's products that are published to your app. */ + productTypes: StringConnection + /** List of the shop’s products. */ + products: ProductConnection + /** The list of public Storefront API versions, including supported, release candidate and unstable versions. */ + publicApiVersions: Array + /** The shop associated with the storefront access token. */ + shop: Shop +} + +/** The schema’s entry-point for queries. This acts as the public, top-level API from which all queries must start. */ +export type QueryRootArticlesArgs = { + first?: Maybe + after?: Maybe + last?: Maybe + before?: Maybe + reverse?: Maybe + sortKey?: Maybe + query?: Maybe +} + +/** The schema’s entry-point for queries. This acts as the public, top-level API from which all queries must start. */ +export type QueryRootBlogByHandleArgs = { + handle: Scalars['String'] +} + +/** The schema’s entry-point for queries. This acts as the public, top-level API from which all queries must start. */ +export type QueryRootBlogsArgs = { + first?: Maybe + after?: Maybe + last?: Maybe + before?: Maybe + reverse?: Maybe + sortKey?: Maybe + query?: Maybe +} + +/** The schema’s entry-point for queries. This acts as the public, top-level API from which all queries must start. */ +export type QueryRootCollectionByHandleArgs = { + handle: Scalars['String'] +} + +/** The schema’s entry-point for queries. This acts as the public, top-level API from which all queries must start. */ +export type QueryRootCollectionsArgs = { + first?: Maybe + after?: Maybe + last?: Maybe + before?: Maybe + reverse?: Maybe + sortKey?: Maybe + query?: Maybe +} + +/** The schema’s entry-point for queries. This acts as the public, top-level API from which all queries must start. */ +export type QueryRootCustomerArgs = { + customerAccessToken: Scalars['String'] +} + +/** The schema’s entry-point for queries. This acts as the public, top-level API from which all queries must start. */ +export type QueryRootNodeArgs = { + id: Scalars['ID'] +} + +/** The schema’s entry-point for queries. This acts as the public, top-level API from which all queries must start. */ +export type QueryRootNodesArgs = { + ids: Array +} + +/** The schema’s entry-point for queries. This acts as the public, top-level API from which all queries must start. */ +export type QueryRootPageByHandleArgs = { + handle: Scalars['String'] +} + +/** The schema’s entry-point for queries. This acts as the public, top-level API from which all queries must start. */ +export type QueryRootPagesArgs = { + first?: Maybe + after?: Maybe + last?: Maybe + before?: Maybe + reverse?: Maybe + sortKey?: Maybe + query?: Maybe +} + +/** The schema’s entry-point for queries. This acts as the public, top-level API from which all queries must start. */ +export type QueryRootProductByHandleArgs = { + handle: Scalars['String'] +} + +/** The schema’s entry-point for queries. This acts as the public, top-level API from which all queries must start. */ +export type QueryRootProductRecommendationsArgs = { + productId: Scalars['ID'] +} + +/** The schema’s entry-point for queries. This acts as the public, top-level API from which all queries must start. */ +export type QueryRootProductTagsArgs = { + first: Scalars['Int'] +} + +/** The schema’s entry-point for queries. This acts as the public, top-level API from which all queries must start. */ +export type QueryRootProductTypesArgs = { + first: Scalars['Int'] +} + +/** The schema’s entry-point for queries. This acts as the public, top-level API from which all queries must start. */ +export type QueryRootProductsArgs = { + first?: Maybe + after?: Maybe + last?: Maybe + before?: Maybe + reverse?: Maybe + sortKey?: Maybe + query?: Maybe +} + +/** SEO information. */ +export type Seo = { + __typename?: 'SEO' + /** The meta description. */ + description?: Maybe + /** The SEO title. */ + title?: Maybe +} + +/** + * Script discount applications capture the intentions of a discount that + * was created by a Shopify Script. + */ +export type ScriptDiscountApplication = DiscountApplication & { + __typename?: 'ScriptDiscountApplication' + /** The method by which the discount's value is allocated to its entitled items. */ + allocationMethod: DiscountApplicationAllocationMethod + /** + * The description of the application as defined by the Script. + * @deprecated Use `title` instead + */ + description: Scalars['String'] + /** Which lines of targetType that the discount is allocated over. */ + targetSelection: DiscountApplicationTargetSelection + /** The type of line that the discount is applicable towards. */ + targetType: DiscountApplicationTargetType + /** The title of the application as defined by the Script. */ + title: Scalars['String'] + /** The value of the discount application. */ + value: PricingValue +} + +/** + * Properties used by customers to select a product variant. + * Products can have multiple options, like different sizes or colors. + */ +export type SelectedOption = { + __typename?: 'SelectedOption' + /** The product option’s name. */ + name: Scalars['String'] + /** The product option’s value. */ + value: Scalars['String'] +} + +/** Specifies the input fields required for a selected option. */ +export type SelectedOptionInput = { + /** The product option’s name. */ + name: Scalars['String'] + /** The product option’s value. */ + value: Scalars['String'] +} + +/** A shipping rate to be applied to a checkout. */ +export type ShippingRate = { + __typename?: 'ShippingRate' + /** Human-readable unique identifier for this shipping rate. */ + handle: Scalars['String'] + /** + * Price of this shipping rate. + * @deprecated Use `priceV2` instead + */ + price: Scalars['Money'] + /** Price of this shipping rate. */ + priceV2: MoneyV2 + /** Title of this shipping rate. */ + title: Scalars['String'] +} + +/** Shop represents a collection of the general settings and information about the shop. */ +export type Shop = { + __typename?: 'Shop' + /** + * List of the shop' articles. + * @deprecated Use `QueryRoot.articles` instead. + */ + articles: ArticleConnection + /** + * List of the shop' blogs. + * @deprecated Use `QueryRoot.blogs` instead. + */ + blogs: BlogConnection + /** + * Find a collection by its handle. + * @deprecated Use `QueryRoot.collectionByHandle` instead. + */ + collectionByHandle?: Maybe + /** + * List of the shop’s collections. + * @deprecated Use `QueryRoot.collections` instead. + */ + collections: CollectionConnection + /** + * The three-letter code for the currency that the shop accepts. + * @deprecated Use `paymentSettings` instead + */ + currencyCode: CurrencyCode + /** A description of the shop. */ + description?: Maybe + /** A string representing the way currency is formatted when the currency isn’t specified. */ + moneyFormat: Scalars['String'] + /** The shop’s name. */ + name: Scalars['String'] + /** Settings related to payments. */ + paymentSettings: PaymentSettings + /** The shop’s primary domain. */ + primaryDomain: Domain + /** The shop’s privacy policy. */ + privacyPolicy?: Maybe + /** + * Find a product by its handle. + * @deprecated Use `QueryRoot.productByHandle` instead. + */ + productByHandle?: Maybe + /** + * A list of tags that have been added to products. + * Additional access scope required: unauthenticated_read_product_tags. + * @deprecated Use `QueryRoot.productTags` instead. + */ + productTags: StringConnection + /** + * List of the shop’s product types. + * @deprecated Use `QueryRoot.productTypes` instead. + */ + productTypes: StringConnection + /** + * List of the shop’s products. + * @deprecated Use `QueryRoot.products` instead. + */ + products: ProductConnection + /** The shop’s refund policy. */ + refundPolicy?: Maybe + /** The shop’s shipping policy. */ + shippingPolicy?: Maybe + /** Countries that the shop ships to. */ + shipsToCountries: Array + /** + * The shop’s Shopify Payments account id. + * @deprecated Use `paymentSettings` instead + */ + shopifyPaymentsAccountId?: Maybe + /** The shop’s terms of service. */ + termsOfService?: Maybe +} + +/** Shop represents a collection of the general settings and information about the shop. */ +export type ShopArticlesArgs = { + first?: Maybe + after?: Maybe + last?: Maybe + before?: Maybe + reverse?: Maybe + sortKey?: Maybe + query?: Maybe +} + +/** Shop represents a collection of the general settings and information about the shop. */ +export type ShopBlogsArgs = { + first?: Maybe + after?: Maybe + last?: Maybe + before?: Maybe + reverse?: Maybe + sortKey?: Maybe + query?: Maybe +} + +/** Shop represents a collection of the general settings and information about the shop. */ +export type ShopCollectionByHandleArgs = { + handle: Scalars['String'] +} + +/** Shop represents a collection of the general settings and information about the shop. */ +export type ShopCollectionsArgs = { + first?: Maybe + after?: Maybe + last?: Maybe + before?: Maybe + reverse?: Maybe + sortKey?: Maybe + query?: Maybe +} + +/** Shop represents a collection of the general settings and information about the shop. */ +export type ShopProductByHandleArgs = { + handle: Scalars['String'] +} + +/** Shop represents a collection of the general settings and information about the shop. */ +export type ShopProductTagsArgs = { + first: Scalars['Int'] +} + +/** Shop represents a collection of the general settings and information about the shop. */ +export type ShopProductTypesArgs = { + first: Scalars['Int'] +} + +/** Shop represents a collection of the general settings and information about the shop. */ +export type ShopProductsArgs = { + first?: Maybe + after?: Maybe + last?: Maybe + before?: Maybe + reverse?: Maybe + sortKey?: Maybe + query?: Maybe +} + +/** Policy that a merchant has configured for their store, such as their refund or privacy policy. */ +export type ShopPolicy = Node & { + __typename?: 'ShopPolicy' + /** Policy text, maximum size of 64kb. */ + body: Scalars['String'] + /** Policy’s handle. */ + handle: Scalars['String'] + /** Globally unique identifier. */ + id: Scalars['ID'] + /** Policy’s title. */ + title: Scalars['String'] + /** Public URL to the policy. */ + url: Scalars['URL'] +} + +/** An auto-generated type for paginating through multiple Strings. */ +export type StringConnection = { + __typename?: 'StringConnection' + /** A list of edges. */ + edges: Array + /** Information to aid in pagination. */ + pageInfo: PageInfo +} + +/** An auto-generated type which holds one String and a cursor during pagination. */ +export type StringEdge = { + __typename?: 'StringEdge' + /** A cursor for use in pagination. */ + cursor: Scalars['String'] + /** The item at the end of StringEdge. */ + node: Scalars['String'] +} + +/** + * Specifies the fields required to complete a checkout with + * a tokenized payment. + */ +export type TokenizedPaymentInput = { + /** The amount of the payment. */ + amount: Scalars['Money'] + /** A unique client generated key used to avoid duplicate charges. When a duplicate payment is found, the original is returned instead of creating a new one. */ + idempotencyKey: Scalars['String'] + /** The billing address for the payment. */ + billingAddress: MailingAddressInput + /** The type of payment token. */ + type: Scalars['String'] + /** A simple string or JSON containing the required payment data for the tokenized payment. */ + paymentData: Scalars['String'] + /** Executes the payment in test mode if possible. Defaults to `false`. */ + test?: Maybe + /** Public Hash Key used for AndroidPay payments only. */ + identifier?: Maybe +} + +/** + * Specifies the fields required to complete a checkout with + * a tokenized payment. + */ +export type TokenizedPaymentInputV2 = { + /** The amount and currency of the payment. */ + paymentAmount: MoneyInput + /** A unique client generated key used to avoid duplicate charges. When a duplicate payment is found, the original is returned instead of creating a new one. */ + idempotencyKey: Scalars['String'] + /** The billing address for the payment. */ + billingAddress: MailingAddressInput + /** A simple string or JSON containing the required payment data for the tokenized payment. */ + paymentData: Scalars['String'] + /** Whether to execute the payment in test mode, if possible. Test mode is not supported in production stores. Defaults to `false`. */ + test?: Maybe + /** Public Hash Key used for AndroidPay payments only. */ + identifier?: Maybe + /** The type of payment token. */ + type: Scalars['String'] +} + +/** + * Specifies the fields required to complete a checkout with + * a tokenized payment. + */ +export type TokenizedPaymentInputV3 = { + /** The amount and currency of the payment. */ + paymentAmount: MoneyInput + /** A unique client generated key used to avoid duplicate charges. When a duplicate payment is found, the original is returned instead of creating a new one. */ + idempotencyKey: Scalars['String'] + /** The billing address for the payment. */ + billingAddress: MailingAddressInput + /** A simple string or JSON containing the required payment data for the tokenized payment. */ + paymentData: Scalars['String'] + /** Whether to execute the payment in test mode, if possible. Test mode is not supported in production stores. Defaults to `false`. */ + test?: Maybe + /** Public Hash Key used for AndroidPay payments only. */ + identifier?: Maybe + /** The type of payment token. */ + type: PaymentTokenType +} + +/** An object representing exchange of money for a product or service. */ +export type Transaction = { + __typename?: 'Transaction' + /** + * The amount of money that the transaction was for. + * @deprecated Use `amountV2` instead + */ + amount: Scalars['Money'] + /** The amount of money that the transaction was for. */ + amountV2: MoneyV2 + /** The kind of the transaction. */ + kind: TransactionKind + /** + * The status of the transaction. + * @deprecated Use `statusV2` instead + */ + status: TransactionStatus + /** The status of the transaction. */ + statusV2?: Maybe + /** Whether the transaction was done in test mode or not. */ + test: Scalars['Boolean'] +} + +export enum TransactionKind { + Sale = 'SALE', + Capture = 'CAPTURE', + Authorization = 'AUTHORIZATION', + EmvAuthorization = 'EMV_AUTHORIZATION', + Change = 'CHANGE', +} + +export enum TransactionStatus { + Pending = 'PENDING', + Success = 'SUCCESS', + Failure = 'FAILURE', + Error = 'ERROR', +} + +/** The measurement used to calculate a unit price for a product variant (e.g. $9.99 / 100ml). */ +export type UnitPriceMeasurement = { + __typename?: 'UnitPriceMeasurement' + /** The type of unit of measurement for the unit price measurement. */ + measuredType?: Maybe + /** The quantity unit for the unit price measurement. */ + quantityUnit?: Maybe + /** The quantity value for the unit price measurement. */ + quantityValue: Scalars['Float'] + /** The reference unit for the unit price measurement. */ + referenceUnit?: Maybe + /** The reference value for the unit price measurement. */ + referenceValue: Scalars['Int'] +} + +/** The accepted types of unit of measurement. */ +export enum UnitPriceMeasurementMeasuredType { + /** Unit of measurements representing volumes. */ + Volume = 'VOLUME', + /** Unit of measurements representing weights. */ + Weight = 'WEIGHT', + /** Unit of measurements representing lengths. */ + Length = 'LENGTH', + /** Unit of measurements representing areas. */ + Area = 'AREA', +} + +/** The valid units of measurement for a unit price measurement. */ +export enum UnitPriceMeasurementMeasuredUnit { + /** 1000 milliliters equals 1 liter. */ + Ml = 'ML', + /** 100 centiliters equals 1 liter. */ + Cl = 'CL', + /** Metric system unit of volume. */ + L = 'L', + /** 1 cubic meter equals 1000 liters. */ + M3 = 'M3', + /** 1000 milligrams equals 1 gram. */ + Mg = 'MG', + /** Metric system unit of weight. */ + G = 'G', + /** 1 kilogram equals 1000 grams. */ + Kg = 'KG', + /** 1000 millimeters equals 1 meter. */ + Mm = 'MM', + /** 100 centimeters equals 1 meter. */ + Cm = 'CM', + /** Metric system unit of length. */ + M = 'M', + /** Metric system unit of area. */ + M2 = 'M2', +} + +/** Represents an error in the input of a mutation. */ +export type UserError = DisplayableError & { + __typename?: 'UserError' + /** Path to the input field which caused the error. */ + field?: Maybe> + /** The error message. */ + message: Scalars['String'] +} + +/** Represents a Shopify hosted video. */ +export type Video = Node & + Media & { + __typename?: 'Video' + /** A word or phrase to share the nature or contents of a media. */ + alt?: Maybe + /** Globally unique identifier. */ + id: Scalars['ID'] + /** The media content type. */ + mediaContentType: MediaContentType + /** The preview image for the media. */ + previewImage?: Maybe + /** The sources for a video. */ + sources: Array + } + +/** Represents a source for a Shopify hosted video. */ +export type VideoSource = { + __typename?: 'VideoSource' + /** The format of the video source. */ + format: Scalars['String'] + /** The height of the video. */ + height: Scalars['Int'] + /** The video MIME type. */ + mimeType: Scalars['String'] + /** The URL of the video. */ + url: Scalars['String'] + /** The width of the video. */ + width: Scalars['Int'] +} + +/** Units of measurement for weight. */ +export enum WeightUnit { + /** 1 kilogram equals 1000 grams. */ + Kilograms = 'KILOGRAMS', + /** Metric system unit of mass. */ + Grams = 'GRAMS', + /** 1 pound equals 16 ounces. */ + Pounds = 'POUNDS', + /** Imperial system unit of mass. */ + Ounces = 'OUNCES', +} + +export type Unnamed_1_QueryVariables = Exact<{ + first: Scalars['Int'] +}> + +export type Unnamed_1_Query = { __typename?: 'QueryRoot' } & { + pages: { __typename?: 'PageConnection' } & { + edges: Array< + { __typename?: 'PageEdge' } & { + node: { __typename?: 'Page' } & Pick< + Page, + 'id' | 'title' | 'handle' | 'body' | 'bodySummary' | 'url' + > + } + > + } +} diff --git a/services/frontend/packages/swell/schema.graphql b/services/frontend/packages/swell/schema.graphql new file mode 100644 index 00000000..822e6007 --- /dev/null +++ b/services/frontend/packages/swell/schema.graphql @@ -0,0 +1,9631 @@ +schema { + query: QueryRoot + mutation: Mutation +} + +""" +Marks an element of a GraphQL schema as having restricted access. +""" +directive @accessRestricted( + """ + Explains the reason around this restriction + """ + reason: String = null +) on FIELD_DEFINITION | OBJECT + +""" +A version of the API. +""" +type ApiVersion { + """ + The human-readable name of the version. + """ + displayName: String! + + """ + The unique identifier of an ApiVersion. All supported API versions have a date-based (YYYY-MM) or `unstable` handle. + """ + handle: String! + + """ + Whether the version is supported by Shopify. + """ + supported: Boolean! +} + +""" +Details about the gift card used on the checkout. +""" +type AppliedGiftCard implements Node { + """ + The amount that was taken from the gift card by applying it. + """ + amountUsed: Money! @deprecated(reason: "Use `amountUsedV2` instead") + + """ + The amount that was taken from the gift card by applying it. + """ + amountUsedV2: MoneyV2! + + """ + The amount left on the gift card. + """ + balance: Money! @deprecated(reason: "Use `balanceV2` instead") + + """ + The amount left on the gift card. + """ + balanceV2: MoneyV2! + + """ + Globally unique identifier. + """ + id: ID! + + """ + The last characters of the gift card. + """ + lastCharacters: String! + + """ + The amount that was applied to the checkout in its currency. + """ + presentmentAmountUsed: MoneyV2! +} + +""" +An article in an online store blog. +""" +type Article implements Node { + """ + The article's author. + """ + author: ArticleAuthor! @deprecated(reason: "Use `authorV2` instead") + + """ + The article's author. + """ + authorV2: ArticleAuthor + + """ + The blog that the article belongs to. + """ + blog: Blog! + + """ + List of comments posted on the article. + """ + comments( + """ + Returns up to the first `n` elements from the list. + """ + first: Int + + """ + Returns the elements that come after the specified cursor. + """ + after: String + + """ + Returns up to the last `n` elements from the list. + """ + last: Int + + """ + Returns the elements that come before the specified cursor. + """ + before: String + + """ + Reverse the order of the underlying list. + """ + reverse: Boolean = false + ): CommentConnection! + + """ + Stripped content of the article, single line with HTML tags removed. + """ + content( + """ + Truncates string after the given length. + """ + truncateAt: Int + ): String! + + """ + The content of the article, complete with HTML formatting. + """ + contentHtml: HTML! + + """ + Stripped excerpt of the article, single line with HTML tags removed. + """ + excerpt( + """ + Truncates string after the given length. + """ + truncateAt: Int + ): String + + """ + The excerpt of the article, complete with HTML formatting. + """ + excerptHtml: HTML + + """ + A human-friendly unique string for the Article automatically generated from its title. + """ + handle: String! + + """ + Globally unique identifier. + """ + id: ID! + + """ + The image associated with the article. + """ + image( + """ + Image width in pixels between 1 and 2048. This argument is deprecated: Use `maxWidth` on `Image.transformedSrc` instead. + """ + maxWidth: Int + + """ + Image height in pixels between 1 and 2048. This argument is deprecated: Use `maxHeight` on `Image.transformedSrc` instead. + """ + maxHeight: Int + + """ + Crops the image according to the specified region. This argument is deprecated: Use `crop` on `Image.transformedSrc` instead. + """ + crop: CropRegion + + """ + Image size multiplier for high-resolution retina displays. Must be between 1 and 3. This argument is deprecated: Use `scale` on `Image.transformedSrc` instead. + """ + scale: Int = 1 + ): Image + + """ + The date and time when the article was published. + """ + publishedAt: DateTime! + + """ + The article’s SEO information. + """ + seo: SEO + + """ + A categorization that a article can be tagged with. + """ + tags: [String!]! + + """ + The article’s name. + """ + title: String! + + """ + The url pointing to the article accessible from the web. + """ + url: URL! +} + +""" +The author of an article. +""" +type ArticleAuthor { + """ + The author's bio. + """ + bio: String + + """ + The author’s email. + """ + email: String! + + """ + The author's first name. + """ + firstName: String! + + """ + The author's last name. + """ + lastName: String! + + """ + The author's full name. + """ + name: String! +} + +""" +An auto-generated type for paginating through multiple Articles. +""" +type ArticleConnection { + """ + A list of edges. + """ + edges: [ArticleEdge!]! + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An auto-generated type which holds one Article and a cursor during pagination. +""" +type ArticleEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of ArticleEdge. + """ + node: Article! +} + +""" +The set of valid sort keys for the Article query. +""" +enum ArticleSortKeys { + """ + Sort by the `title` value. + """ + TITLE + + """ + Sort by the `blog_title` value. + """ + BLOG_TITLE + + """ + Sort by the `author` value. + """ + AUTHOR + + """ + Sort by the `updated_at` value. + """ + UPDATED_AT + + """ + Sort by the `published_at` value. + """ + PUBLISHED_AT + + """ + Sort by the `id` value. + """ + ID + + """ + During a search (i.e. when the `query` parameter has been specified on the connection) this sorts the + results by relevance to the search term(s). When no search query is specified, this sort key is not + deterministic and should not be used. + """ + RELEVANCE +} + +""" +Represents a generic custom attribute. +""" +type Attribute { + """ + Key or name of the attribute. + """ + key: String! + + """ + Value of the attribute. + """ + value: String +} + +""" +Specifies the input fields required for an attribute. +""" +input AttributeInput { + """ + Key or name of the attribute. + """ + key: String! + + """ + Value of the attribute. + """ + value: String! +} + +""" +Automatic discount applications capture the intentions of a discount that was automatically applied. +""" +type AutomaticDiscountApplication implements DiscountApplication { + """ + The method by which the discount's value is allocated to its entitled items. + """ + allocationMethod: DiscountApplicationAllocationMethod! + + """ + Which lines of targetType that the discount is allocated over. + """ + targetSelection: DiscountApplicationTargetSelection! + + """ + The type of line that the discount is applicable towards. + """ + targetType: DiscountApplicationTargetType! + + """ + The title of the application. + """ + title: String! + + """ + The value of the discount application. + """ + value: PricingValue! +} + +""" +A collection of available shipping rates for a checkout. +""" +type AvailableShippingRates { + """ + Whether or not the shipping rates are ready. + The `shippingRates` field is `null` when this value is `false`. + This field should be polled until its value becomes `true`. + """ + ready: Boolean! + + """ + The fetched shipping rates. `null` until the `ready` field is `true`. + """ + shippingRates: [ShippingRate!] +} + +""" +An online store blog. +""" +type Blog implements Node { + """ + Find an article by its handle. + """ + articleByHandle( + """ + The handle of the article. + """ + handle: String! + ): Article + + """ + List of the blog's articles. + """ + articles( + """ + Returns up to the first `n` elements from the list. + """ + first: Int + + """ + Returns the elements that come after the specified cursor. + """ + after: String + + """ + Returns up to the last `n` elements from the list. + """ + last: Int + + """ + Returns the elements that come before the specified cursor. + """ + before: String + + """ + Reverse the order of the underlying list. + """ + reverse: Boolean = false + + """ + Sort the underlying list by the given key. + """ + sortKey: ArticleSortKeys = ID + + """ + Supported filter parameters: + - `author` + - `blog_title` + - `created_at` + - `tag` + - `updated_at` + + See the detailed [search syntax](https://help.shopify.com/api/getting-started/search-syntax) + for more information about using filters. + """ + query: String + ): ArticleConnection! + + """ + The authors who have contributed to the blog. + """ + authors: [ArticleAuthor!]! + + """ + A human-friendly unique string for the Blog automatically generated from its title. + """ + handle: String! + + """ + Globally unique identifier. + """ + id: ID! + + """ + The blog's SEO information. + """ + seo: SEO + + """ + The blogs’s title. + """ + title: String! + + """ + The url pointing to the blog accessible from the web. + """ + url: URL! +} + +""" +An auto-generated type for paginating through multiple Blogs. +""" +type BlogConnection { + """ + A list of edges. + """ + edges: [BlogEdge!]! + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An auto-generated type which holds one Blog and a cursor during pagination. +""" +type BlogEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of BlogEdge. + """ + node: Blog! +} + +""" +The set of valid sort keys for the Blog query. +""" +enum BlogSortKeys { + """ + Sort by the `handle` value. + """ + HANDLE + + """ + Sort by the `title` value. + """ + TITLE + + """ + Sort by the `id` value. + """ + ID + + """ + During a search (i.e. when the `query` parameter has been specified on the connection) this sorts the + results by relevance to the search term(s). When no search query is specified, this sort key is not + deterministic and should not be used. + """ + RELEVANCE +} + +""" +Card brand, such as Visa or Mastercard, which can be used for payments. +""" +enum CardBrand { + """ + Visa + """ + VISA + + """ + Mastercard + """ + MASTERCARD + + """ + Discover + """ + DISCOVER + + """ + American Express + """ + AMERICAN_EXPRESS + + """ + Diners Club + """ + DINERS_CLUB + + """ + JCB + """ + JCB +} + +""" +A container for all the information required to checkout items and pay. +""" +type Checkout implements Node { + """ + The gift cards used on the checkout. + """ + appliedGiftCards: [AppliedGiftCard!]! + + """ + The available shipping rates for this Checkout. + Should only be used when checkout `requiresShipping` is `true` and + the shipping address is valid. + """ + availableShippingRates: AvailableShippingRates + + """ + The date and time when the checkout was completed. + """ + completedAt: DateTime + + """ + The date and time when the checkout was created. + """ + createdAt: DateTime! + + """ + The currency code for the Checkout. + """ + currencyCode: CurrencyCode! + + """ + A list of extra information that is added to the checkout. + """ + customAttributes: [Attribute!]! + + """ + The customer associated with the checkout. + """ + customer: Customer + @deprecated( + reason: "This field will always return null. If you have an authentication token for the customer, you can use the `customer` field on the query root to retrieve it." + ) + + """ + Discounts that have been applied on the checkout. + """ + discountApplications( + """ + Returns up to the first `n` elements from the list. + """ + first: Int + + """ + Returns the elements that come after the specified cursor. + """ + after: String + + """ + Returns up to the last `n` elements from the list. + """ + last: Int + + """ + Returns the elements that come before the specified cursor. + """ + before: String + + """ + Reverse the order of the underlying list. + """ + reverse: Boolean = false + ): DiscountApplicationConnection! + + """ + The email attached to this checkout. + """ + email: String + + """ + Globally unique identifier. + """ + id: ID! + + """ + A list of line item objects, each one containing information about an item in the checkout. + """ + lineItems( + """ + Returns up to the first `n` elements from the list. + """ + first: Int + + """ + Returns the elements that come after the specified cursor. + """ + after: String + + """ + Returns up to the last `n` elements from the list. + """ + last: Int + + """ + Returns the elements that come before the specified cursor. + """ + before: String + + """ + Reverse the order of the underlying list. + """ + reverse: Boolean = false + ): CheckoutLineItemConnection! + + """ + The sum of all the prices of all the items in the checkout. Duties, taxes, shipping and discounts excluded. + """ + lineItemsSubtotalPrice: MoneyV2! + + """ + The note associated with the checkout. + """ + note: String + + """ + The resulting order from a paid checkout. + """ + order: Order + + """ + The Order Status Page for this Checkout, null when checkout is not completed. + """ + orderStatusUrl: URL + + """ + The amount left to be paid. This is equal to the cost of the line items, taxes and shipping minus discounts and gift cards. + """ + paymentDue: Money! @deprecated(reason: "Use `paymentDueV2` instead") + + """ + The amount left to be paid. This is equal to the cost of the line items, duties, taxes and shipping minus discounts and gift cards. + """ + paymentDueV2: MoneyV2! + + """ + Whether or not the Checkout is ready and can be completed. Checkouts may + have asynchronous operations that can take time to finish. If you want + to complete a checkout or ensure all the fields are populated and up to + date, polling is required until the value is true. + """ + ready: Boolean! + + """ + States whether or not the fulfillment requires shipping. + """ + requiresShipping: Boolean! + + """ + The shipping address to where the line items will be shipped. + """ + shippingAddress: MailingAddress + + """ + The discounts that have been allocated onto the shipping line by discount applications. + """ + shippingDiscountAllocations: [DiscountAllocation!]! + + """ + Once a shipping rate is selected by the customer it is transitioned to a `shipping_line` object. + """ + shippingLine: ShippingRate + + """ + Price of the checkout before shipping and taxes. + """ + subtotalPrice: Money! @deprecated(reason: "Use `subtotalPriceV2` instead") + + """ + Price of the checkout before duties, shipping and taxes. + """ + subtotalPriceV2: MoneyV2! + + """ + Specifies if the Checkout is tax exempt. + """ + taxExempt: Boolean! + + """ + Specifies if taxes are included in the line item and shipping line prices. + """ + taxesIncluded: Boolean! + + """ + The sum of all the prices of all the items in the checkout, taxes and discounts included. + """ + totalPrice: Money! @deprecated(reason: "Use `totalPriceV2` instead") + + """ + The sum of all the prices of all the items in the checkout, duties, taxes and discounts included. + """ + totalPriceV2: MoneyV2! + + """ + The sum of all the taxes applied to the line items and shipping lines in the checkout. + """ + totalTax: Money! @deprecated(reason: "Use `totalTaxV2` instead") + + """ + The sum of all the taxes applied to the line items and shipping lines in the checkout. + """ + totalTaxV2: MoneyV2! + + """ + The date and time when the checkout was last updated. + """ + updatedAt: DateTime! + + """ + The url pointing to the checkout accessible from the web. + """ + webUrl: URL! +} + +""" +Specifies the fields required to update a checkout's attributes. +""" +input CheckoutAttributesUpdateInput { + """ + The text of an optional note that a shop owner can attach to the checkout. + """ + note: String + + """ + A list of extra information that is added to the checkout. + """ + customAttributes: [AttributeInput!] + + """ + Allows setting partial addresses on a Checkout, skipping the full validation of attributes. + The required attributes are city, province, and country. + Full validation of the addresses is still done at complete time. + """ + allowPartialAddresses: Boolean +} + +""" +Return type for `checkoutAttributesUpdate` mutation. +""" +type CheckoutAttributesUpdatePayload { + """ + The updated checkout object. + """ + checkout: Checkout! + + """ + List of errors that occurred executing the mutation. + """ + checkoutUserErrors: [CheckoutUserError!]! + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `checkoutUserErrors` instead") +} + +""" +Specifies the fields required to update a checkout's attributes. +""" +input CheckoutAttributesUpdateV2Input { + """ + The text of an optional note that a shop owner can attach to the checkout. + """ + note: String + + """ + A list of extra information that is added to the checkout. + """ + customAttributes: [AttributeInput!] + + """ + Allows setting partial addresses on a Checkout, skipping the full validation of attributes. + The required attributes are city, province, and country. + Full validation of the addresses is still done at complete time. + """ + allowPartialAddresses: Boolean +} + +""" +Return type for `checkoutAttributesUpdateV2` mutation. +""" +type CheckoutAttributesUpdateV2Payload { + """ + The updated checkout object. + """ + checkout: Checkout + + """ + List of errors that occurred executing the mutation. + """ + checkoutUserErrors: [CheckoutUserError!]! + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `checkoutUserErrors` instead") +} + +""" +Return type for `checkoutCompleteFree` mutation. +""" +type CheckoutCompleteFreePayload { + """ + The updated checkout object. + """ + checkout: Checkout + + """ + List of errors that occurred executing the mutation. + """ + checkoutUserErrors: [CheckoutUserError!]! + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `checkoutUserErrors` instead") +} + +""" +Return type for `checkoutCompleteWithCreditCard` mutation. +""" +type CheckoutCompleteWithCreditCardPayload { + """ + The checkout on which the payment was applied. + """ + checkout: Checkout! + + """ + List of errors that occurred executing the mutation. + """ + checkoutUserErrors: [CheckoutUserError!]! + + """ + A representation of the attempted payment. + """ + payment: Payment + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `checkoutUserErrors` instead") +} + +""" +Return type for `checkoutCompleteWithCreditCardV2` mutation. +""" +type CheckoutCompleteWithCreditCardV2Payload { + """ + The checkout on which the payment was applied. + """ + checkout: Checkout + + """ + List of errors that occurred executing the mutation. + """ + checkoutUserErrors: [CheckoutUserError!]! + + """ + A representation of the attempted payment. + """ + payment: Payment + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `checkoutUserErrors` instead") +} + +""" +Return type for `checkoutCompleteWithTokenizedPayment` mutation. +""" +type CheckoutCompleteWithTokenizedPaymentPayload { + """ + The checkout on which the payment was applied. + """ + checkout: Checkout! + + """ + List of errors that occurred executing the mutation. + """ + checkoutUserErrors: [CheckoutUserError!]! + + """ + A representation of the attempted payment. + """ + payment: Payment + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `checkoutUserErrors` instead") +} + +""" +Return type for `checkoutCompleteWithTokenizedPaymentV2` mutation. +""" +type CheckoutCompleteWithTokenizedPaymentV2Payload { + """ + The checkout on which the payment was applied. + """ + checkout: Checkout + + """ + List of errors that occurred executing the mutation. + """ + checkoutUserErrors: [CheckoutUserError!]! + + """ + A representation of the attempted payment. + """ + payment: Payment + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `checkoutUserErrors` instead") +} + +""" +Return type for `checkoutCompleteWithTokenizedPaymentV3` mutation. +""" +type CheckoutCompleteWithTokenizedPaymentV3Payload { + """ + The checkout on which the payment was applied. + """ + checkout: Checkout + + """ + List of errors that occurred executing the mutation. + """ + checkoutUserErrors: [CheckoutUserError!]! + + """ + A representation of the attempted payment. + """ + payment: Payment + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `checkoutUserErrors` instead") +} + +""" +Specifies the fields required to create a checkout. +""" +input CheckoutCreateInput { + """ + The email with which the customer wants to checkout. + """ + email: String + + """ + A list of line item objects, each one containing information about an item in the checkout. + """ + lineItems: [CheckoutLineItemInput!] + + """ + The shipping address to where the line items will be shipped. + """ + shippingAddress: MailingAddressInput + + """ + The text of an optional note that a shop owner can attach to the checkout. + """ + note: String + + """ + A list of extra information that is added to the checkout. + """ + customAttributes: [AttributeInput!] + + """ + Allows setting partial addresses on a Checkout, skipping the full validation of attributes. + The required attributes are city, province, and country. + Full validation of addresses is still done at complete time. + """ + allowPartialAddresses: Boolean + + """ + The three-letter currency code of one of the shop's enabled presentment currencies. + Including this field creates a checkout in the specified currency. By default, new + checkouts are created in the shop's primary currency. + """ + presentmentCurrencyCode: CurrencyCode +} + +""" +Return type for `checkoutCreate` mutation. +""" +type CheckoutCreatePayload { + """ + The new checkout object. + """ + checkout: Checkout + + """ + List of errors that occurred executing the mutation. + """ + checkoutUserErrors: [CheckoutUserError!]! + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `checkoutUserErrors` instead") +} + +""" +Return type for `checkoutCustomerAssociate` mutation. +""" +type CheckoutCustomerAssociatePayload { + """ + The updated checkout object. + """ + checkout: Checkout! + + """ + The associated customer object. + """ + customer: Customer + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! +} + +""" +Return type for `checkoutCustomerAssociateV2` mutation. +""" +type CheckoutCustomerAssociateV2Payload { + """ + The updated checkout object. + """ + checkout: Checkout + + """ + List of errors that occurred executing the mutation. + """ + checkoutUserErrors: [CheckoutUserError!]! + + """ + The associated customer object. + """ + customer: Customer + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `checkoutUserErrors` instead") +} + +""" +Return type for `checkoutCustomerDisassociate` mutation. +""" +type CheckoutCustomerDisassociatePayload { + """ + The updated checkout object. + """ + checkout: Checkout! + + """ + List of errors that occurred executing the mutation. + """ + checkoutUserErrors: [CheckoutUserError!]! + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `checkoutUserErrors` instead") +} + +""" +Return type for `checkoutCustomerDisassociateV2` mutation. +""" +type CheckoutCustomerDisassociateV2Payload { + """ + The updated checkout object. + """ + checkout: Checkout + + """ + List of errors that occurred executing the mutation. + """ + checkoutUserErrors: [CheckoutUserError!]! + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `checkoutUserErrors` instead") +} + +""" +Return type for `checkoutDiscountCodeApply` mutation. +""" +type CheckoutDiscountCodeApplyPayload { + """ + The updated checkout object. + """ + checkout: Checkout! + + """ + List of errors that occurred executing the mutation. + """ + checkoutUserErrors: [CheckoutUserError!]! + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `checkoutUserErrors` instead") +} + +""" +Return type for `checkoutDiscountCodeApplyV2` mutation. +""" +type CheckoutDiscountCodeApplyV2Payload { + """ + The updated checkout object. + """ + checkout: Checkout + + """ + List of errors that occurred executing the mutation. + """ + checkoutUserErrors: [CheckoutUserError!]! + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `checkoutUserErrors` instead") +} + +""" +Return type for `checkoutDiscountCodeRemove` mutation. +""" +type CheckoutDiscountCodeRemovePayload { + """ + The updated checkout object. + """ + checkout: Checkout + + """ + List of errors that occurred executing the mutation. + """ + checkoutUserErrors: [CheckoutUserError!]! + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `checkoutUserErrors` instead") +} + +""" +Return type for `checkoutEmailUpdate` mutation. +""" +type CheckoutEmailUpdatePayload { + """ + The checkout object with the updated email. + """ + checkout: Checkout! + + """ + List of errors that occurred executing the mutation. + """ + checkoutUserErrors: [CheckoutUserError!]! + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `checkoutUserErrors` instead") +} + +""" +Return type for `checkoutEmailUpdateV2` mutation. +""" +type CheckoutEmailUpdateV2Payload { + """ + The checkout object with the updated email. + """ + checkout: Checkout + + """ + List of errors that occurred executing the mutation. + """ + checkoutUserErrors: [CheckoutUserError!]! + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `checkoutUserErrors` instead") +} + +""" +Possible error codes that could be returned by CheckoutUserError. +""" +enum CheckoutErrorCode { + """ + Input value is blank. + """ + BLANK + + """ + Input value is invalid. + """ + INVALID + + """ + Input value is too long. + """ + TOO_LONG + + """ + Input value is not present. + """ + PRESENT + + """ + Input value should be less than maximum allowed value. + """ + LESS_THAN + + """ + Input value should be greater than or equal to minimum allowed value. + """ + GREATER_THAN_OR_EQUAL_TO + + """ + Input value should be less or equal to maximum allowed value. + """ + LESS_THAN_OR_EQUAL_TO + + """ + Checkout is already completed. + """ + ALREADY_COMPLETED + + """ + Checkout is locked. + """ + LOCKED + + """ + Input value is not supported. + """ + NOT_SUPPORTED + + """ + Input email contains an invalid domain name. + """ + BAD_DOMAIN + + """ + Input Zip is invalid for country provided. + """ + INVALID_FOR_COUNTRY + + """ + Input Zip is invalid for country and province provided. + """ + INVALID_FOR_COUNTRY_AND_PROVINCE + + """ + Invalid state in country. + """ + INVALID_STATE_IN_COUNTRY + + """ + Invalid province in country. + """ + INVALID_PROVINCE_IN_COUNTRY + + """ + Invalid region in country. + """ + INVALID_REGION_IN_COUNTRY + + """ + Shipping rate expired. + """ + SHIPPING_RATE_EXPIRED + + """ + Gift card cannot be applied to a checkout that contains a gift card. + """ + GIFT_CARD_UNUSABLE + + """ + Gift card is disabled. + """ + GIFT_CARD_DISABLED + + """ + Gift card code is invalid. + """ + GIFT_CARD_CODE_INVALID + + """ + Gift card has already been applied. + """ + GIFT_CARD_ALREADY_APPLIED + + """ + Gift card currency does not match checkout currency. + """ + GIFT_CARD_CURRENCY_MISMATCH + + """ + Gift card is expired. + """ + GIFT_CARD_EXPIRED + + """ + Gift card has no funds left. + """ + GIFT_CARD_DEPLETED + + """ + Gift card was not found. + """ + GIFT_CARD_NOT_FOUND + + """ + Cart does not meet discount requirements notice. + """ + CART_DOES_NOT_MEET_DISCOUNT_REQUIREMENTS_NOTICE + + """ + Discount expired. + """ + DISCOUNT_EXPIRED + + """ + Discount disabled. + """ + DISCOUNT_DISABLED + + """ + Discount limit reached. + """ + DISCOUNT_LIMIT_REACHED + + """ + Discount not found. + """ + DISCOUNT_NOT_FOUND + + """ + Customer already used once per customer discount notice. + """ + CUSTOMER_ALREADY_USED_ONCE_PER_CUSTOMER_DISCOUNT_NOTICE + + """ + Checkout is already completed. + """ + EMPTY + + """ + Not enough in stock. + """ + NOT_ENOUGH_IN_STOCK + + """ + Missing payment input. + """ + MISSING_PAYMENT_INPUT + + """ + The amount of the payment does not match the value to be paid. + """ + TOTAL_PRICE_MISMATCH + + """ + Line item was not found in checkout. + """ + LINE_ITEM_NOT_FOUND + + """ + Unable to apply discount. + """ + UNABLE_TO_APPLY + + """ + Discount already applied. + """ + DISCOUNT_ALREADY_APPLIED +} + +""" +Return type for `checkoutGiftCardApply` mutation. +""" +type CheckoutGiftCardApplyPayload { + """ + The updated checkout object. + """ + checkout: Checkout! + + """ + List of errors that occurred executing the mutation. + """ + checkoutUserErrors: [CheckoutUserError!]! + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `checkoutUserErrors` instead") +} + +""" +Return type for `checkoutGiftCardRemove` mutation. +""" +type CheckoutGiftCardRemovePayload { + """ + The updated checkout object. + """ + checkout: Checkout! + + """ + List of errors that occurred executing the mutation. + """ + checkoutUserErrors: [CheckoutUserError!]! + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `checkoutUserErrors` instead") +} + +""" +Return type for `checkoutGiftCardRemoveV2` mutation. +""" +type CheckoutGiftCardRemoveV2Payload { + """ + The updated checkout object. + """ + checkout: Checkout + + """ + List of errors that occurred executing the mutation. + """ + checkoutUserErrors: [CheckoutUserError!]! + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `checkoutUserErrors` instead") +} + +""" +Return type for `checkoutGiftCardsAppend` mutation. +""" +type CheckoutGiftCardsAppendPayload { + """ + The updated checkout object. + """ + checkout: Checkout + + """ + List of errors that occurred executing the mutation. + """ + checkoutUserErrors: [CheckoutUserError!]! + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `checkoutUserErrors` instead") +} + +""" +A single line item in the checkout, grouped by variant and attributes. +""" +type CheckoutLineItem implements Node { + """ + Extra information in the form of an array of Key-Value pairs about the line item. + """ + customAttributes: [Attribute!]! + + """ + The discounts that have been allocated onto the checkout line item by discount applications. + """ + discountAllocations: [DiscountAllocation!]! + + """ + Globally unique identifier. + """ + id: ID! + + """ + The quantity of the line item. + """ + quantity: Int! + + """ + Title of the line item. Defaults to the product's title. + """ + title: String! + + """ + Unit price of the line item. + """ + unitPrice: MoneyV2 + + """ + Product variant of the line item. + """ + variant: ProductVariant +} + +""" +An auto-generated type for paginating through multiple CheckoutLineItems. +""" +type CheckoutLineItemConnection { + """ + A list of edges. + """ + edges: [CheckoutLineItemEdge!]! + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An auto-generated type which holds one CheckoutLineItem and a cursor during pagination. +""" +type CheckoutLineItemEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of CheckoutLineItemEdge. + """ + node: CheckoutLineItem! +} + +""" +Specifies the input fields to create a line item on a checkout. +""" +input CheckoutLineItemInput { + """ + Extra information in the form of an array of Key-Value pairs about the line item. + """ + customAttributes: [AttributeInput!] + + """ + The quantity of the line item. + """ + quantity: Int! + + """ + The identifier of the product variant for the line item. + """ + variantId: ID! +} + +""" +Specifies the input fields to update a line item on the checkout. +""" +input CheckoutLineItemUpdateInput { + """ + The identifier of the line item. + """ + id: ID + + """ + The variant identifier of the line item. + """ + variantId: ID + + """ + The quantity of the line item. + """ + quantity: Int + + """ + Extra information in the form of an array of Key-Value pairs about the line item. + """ + customAttributes: [AttributeInput!] +} + +""" +Return type for `checkoutLineItemsAdd` mutation. +""" +type CheckoutLineItemsAddPayload { + """ + The updated checkout object. + """ + checkout: Checkout + + """ + List of errors that occurred executing the mutation. + """ + checkoutUserErrors: [CheckoutUserError!]! + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `checkoutUserErrors` instead") +} + +""" +Return type for `checkoutLineItemsRemove` mutation. +""" +type CheckoutLineItemsRemovePayload { + """ + The updated checkout object. + """ + checkout: Checkout + + """ + List of errors that occurred executing the mutation. + """ + checkoutUserErrors: [CheckoutUserError!]! + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `checkoutUserErrors` instead") +} + +""" +Return type for `checkoutLineItemsReplace` mutation. +""" +type CheckoutLineItemsReplacePayload { + """ + The updated checkout object. + """ + checkout: Checkout + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [CheckoutUserError!]! +} + +""" +Return type for `checkoutLineItemsUpdate` mutation. +""" +type CheckoutLineItemsUpdatePayload { + """ + The updated checkout object. + """ + checkout: Checkout + + """ + List of errors that occurred executing the mutation. + """ + checkoutUserErrors: [CheckoutUserError!]! + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `checkoutUserErrors` instead") +} + +""" +Return type for `checkoutShippingAddressUpdate` mutation. +""" +type CheckoutShippingAddressUpdatePayload { + """ + The updated checkout object. + """ + checkout: Checkout! + + """ + List of errors that occurred executing the mutation. + """ + checkoutUserErrors: [CheckoutUserError!]! + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `checkoutUserErrors` instead") +} + +""" +Return type for `checkoutShippingAddressUpdateV2` mutation. +""" +type CheckoutShippingAddressUpdateV2Payload { + """ + The updated checkout object. + """ + checkout: Checkout + + """ + List of errors that occurred executing the mutation. + """ + checkoutUserErrors: [CheckoutUserError!]! + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `checkoutUserErrors` instead") +} + +""" +Return type for `checkoutShippingLineUpdate` mutation. +""" +type CheckoutShippingLineUpdatePayload { + """ + The updated checkout object. + """ + checkout: Checkout + + """ + List of errors that occurred executing the mutation. + """ + checkoutUserErrors: [CheckoutUserError!]! + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `checkoutUserErrors` instead") +} + +""" +Represents an error that happens during execution of a checkout mutation. +""" +type CheckoutUserError implements DisplayableError { + """ + Error code to uniquely identify the error. + """ + code: CheckoutErrorCode + + """ + Path to the input field which caused the error. + """ + field: [String!] + + """ + The error message. + """ + message: String! +} + +""" +A collection represents a grouping of products that a shop owner can create to organize them or make their shops easier to browse. +""" +type Collection implements Node { + """ + Stripped description of the collection, single line with HTML tags removed. + """ + description( + """ + Truncates string after the given length. + """ + truncateAt: Int + ): String! + + """ + The description of the collection, complete with HTML formatting. + """ + descriptionHtml: HTML! + + """ + A human-friendly unique string for the collection automatically generated from its title. + Limit of 255 characters. + """ + handle: String! + + """ + Globally unique identifier. + """ + id: ID! + + """ + Image associated with the collection. + """ + image( + """ + Image width in pixels between 1 and 2048. This argument is deprecated: Use `maxWidth` on `Image.transformedSrc` instead. + """ + maxWidth: Int + + """ + Image height in pixels between 1 and 2048. This argument is deprecated: Use `maxHeight` on `Image.transformedSrc` instead. + """ + maxHeight: Int + + """ + Crops the image according to the specified region. This argument is deprecated: Use `crop` on `Image.transformedSrc` instead. + """ + crop: CropRegion + + """ + Image size multiplier for high-resolution retina displays. Must be between 1 and 3. This argument is deprecated: Use `scale` on `Image.transformedSrc` instead. + """ + scale: Int = 1 + ): Image + + """ + List of products in the collection. + """ + products( + """ + Returns up to the first `n` elements from the list. + """ + first: Int + + """ + Returns the elements that come after the specified cursor. + """ + after: String + + """ + Returns up to the last `n` elements from the list. + """ + last: Int + + """ + Returns the elements that come before the specified cursor. + """ + before: String + + """ + Reverse the order of the underlying list. + """ + reverse: Boolean = false + + """ + Sort the underlying list by the given key. + """ + sortKey: ProductCollectionSortKeys = COLLECTION_DEFAULT + ): ProductConnection! + + """ + The collection’s name. Limit of 255 characters. + """ + title: String! + + """ + The date and time when the collection was last modified. + """ + updatedAt: DateTime! +} + +""" +An auto-generated type for paginating through multiple Collections. +""" +type CollectionConnection { + """ + A list of edges. + """ + edges: [CollectionEdge!]! + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An auto-generated type which holds one Collection and a cursor during pagination. +""" +type CollectionEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of CollectionEdge. + """ + node: Collection! +} + +""" +The set of valid sort keys for the Collection query. +""" +enum CollectionSortKeys { + """ + Sort by the `title` value. + """ + TITLE + + """ + Sort by the `updated_at` value. + """ + UPDATED_AT + + """ + Sort by the `id` value. + """ + ID + + """ + During a search (i.e. when the `query` parameter has been specified on the connection) this sorts the + results by relevance to the search term(s). When no search query is specified, this sort key is not + deterministic and should not be used. + """ + RELEVANCE +} + +""" +A comment on an article. +""" +type Comment implements Node { + """ + The comment’s author. + """ + author: CommentAuthor! + + """ + Stripped content of the comment, single line with HTML tags removed. + """ + content( + """ + Truncates string after the given length. + """ + truncateAt: Int + ): String! + + """ + The content of the comment, complete with HTML formatting. + """ + contentHtml: HTML! + + """ + Globally unique identifier. + """ + id: ID! +} + +""" +The author of a comment. +""" +type CommentAuthor { + """ + The author's email. + """ + email: String! + + """ + The author’s name. + """ + name: String! +} + +""" +An auto-generated type for paginating through multiple Comments. +""" +type CommentConnection { + """ + A list of edges. + """ + edges: [CommentEdge!]! + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An auto-generated type which holds one Comment and a cursor during pagination. +""" +type CommentEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of CommentEdge. + """ + node: Comment! +} + +""" +ISO 3166-1 alpha-2 country codes with some differences. +""" +enum CountryCode { + """ + Afghanistan. + """ + AF + + """ + Åland Islands. + """ + AX + + """ + Albania. + """ + AL + + """ + Algeria. + """ + DZ + + """ + Andorra. + """ + AD + + """ + Angola. + """ + AO + + """ + Anguilla. + """ + AI + + """ + Antigua & Barbuda. + """ + AG + + """ + Argentina. + """ + AR + + """ + Armenia. + """ + AM + + """ + Aruba. + """ + AW + + """ + Australia. + """ + AU + + """ + Austria. + """ + AT + + """ + Azerbaijan. + """ + AZ + + """ + Bahamas. + """ + BS + + """ + Bahrain. + """ + BH + + """ + Bangladesh. + """ + BD + + """ + Barbados. + """ + BB + + """ + Belarus. + """ + BY + + """ + Belgium. + """ + BE + + """ + Belize. + """ + BZ + + """ + Benin. + """ + BJ + + """ + Bermuda. + """ + BM + + """ + Bhutan. + """ + BT + + """ + Bolivia. + """ + BO + + """ + Bosnia & Herzegovina. + """ + BA + + """ + Botswana. + """ + BW + + """ + Bouvet Island. + """ + BV + + """ + Brazil. + """ + BR + + """ + British Indian Ocean Territory. + """ + IO + + """ + Brunei. + """ + BN + + """ + Bulgaria. + """ + BG + + """ + Burkina Faso. + """ + BF + + """ + Burundi. + """ + BI + + """ + Cambodia. + """ + KH + + """ + Canada. + """ + CA + + """ + Cape Verde. + """ + CV + + """ + Caribbean Netherlands. + """ + BQ + + """ + Cayman Islands. + """ + KY + + """ + Central African Republic. + """ + CF + + """ + Chad. + """ + TD + + """ + Chile. + """ + CL + + """ + China. + """ + CN + + """ + Christmas Island. + """ + CX + + """ + Cocos (Keeling) Islands. + """ + CC + + """ + Colombia. + """ + CO + + """ + Comoros. + """ + KM + + """ + Congo - Brazzaville. + """ + CG + + """ + Congo - Kinshasa. + """ + CD + + """ + Cook Islands. + """ + CK + + """ + Costa Rica. + """ + CR + + """ + Croatia. + """ + HR + + """ + Cuba. + """ + CU + + """ + Curaçao. + """ + CW + + """ + Cyprus. + """ + CY + + """ + Czechia. + """ + CZ + + """ + Côte d’Ivoire. + """ + CI + + """ + Denmark. + """ + DK + + """ + Djibouti. + """ + DJ + + """ + Dominica. + """ + DM + + """ + Dominican Republic. + """ + DO + + """ + Ecuador. + """ + EC + + """ + Egypt. + """ + EG + + """ + El Salvador. + """ + SV + + """ + Equatorial Guinea. + """ + GQ + + """ + Eritrea. + """ + ER + + """ + Estonia. + """ + EE + + """ + Eswatini. + """ + SZ + + """ + Ethiopia. + """ + ET + + """ + Falkland Islands. + """ + FK + + """ + Faroe Islands. + """ + FO + + """ + Fiji. + """ + FJ + + """ + Finland. + """ + FI + + """ + France. + """ + FR + + """ + French Guiana. + """ + GF + + """ + French Polynesia. + """ + PF + + """ + French Southern Territories. + """ + TF + + """ + Gabon. + """ + GA + + """ + Gambia. + """ + GM + + """ + Georgia. + """ + GE + + """ + Germany. + """ + DE + + """ + Ghana. + """ + GH + + """ + Gibraltar. + """ + GI + + """ + Greece. + """ + GR + + """ + Greenland. + """ + GL + + """ + Grenada. + """ + GD + + """ + Guadeloupe. + """ + GP + + """ + Guatemala. + """ + GT + + """ + Guernsey. + """ + GG + + """ + Guinea. + """ + GN + + """ + Guinea-Bissau. + """ + GW + + """ + Guyana. + """ + GY + + """ + Haiti. + """ + HT + + """ + Heard & McDonald Islands. + """ + HM + + """ + Vatican City. + """ + VA + + """ + Honduras. + """ + HN + + """ + Hong Kong SAR. + """ + HK + + """ + Hungary. + """ + HU + + """ + Iceland. + """ + IS + + """ + India. + """ + IN + + """ + Indonesia. + """ + ID + + """ + Iran. + """ + IR + + """ + Iraq. + """ + IQ + + """ + Ireland. + """ + IE + + """ + Isle of Man. + """ + IM + + """ + Israel. + """ + IL + + """ + Italy. + """ + IT + + """ + Jamaica. + """ + JM + + """ + Japan. + """ + JP + + """ + Jersey. + """ + JE + + """ + Jordan. + """ + JO + + """ + Kazakhstan. + """ + KZ + + """ + Kenya. + """ + KE + + """ + Kiribati. + """ + KI + + """ + North Korea. + """ + KP + + """ + Kosovo. + """ + XK + + """ + Kuwait. + """ + KW + + """ + Kyrgyzstan. + """ + KG + + """ + Laos. + """ + LA + + """ + Latvia. + """ + LV + + """ + Lebanon. + """ + LB + + """ + Lesotho. + """ + LS + + """ + Liberia. + """ + LR + + """ + Libya. + """ + LY + + """ + Liechtenstein. + """ + LI + + """ + Lithuania. + """ + LT + + """ + Luxembourg. + """ + LU + + """ + Macao SAR. + """ + MO + + """ + Madagascar. + """ + MG + + """ + Malawi. + """ + MW + + """ + Malaysia. + """ + MY + + """ + Maldives. + """ + MV + + """ + Mali. + """ + ML + + """ + Malta. + """ + MT + + """ + Martinique. + """ + MQ + + """ + Mauritania. + """ + MR + + """ + Mauritius. + """ + MU + + """ + Mayotte. + """ + YT + + """ + Mexico. + """ + MX + + """ + Moldova. + """ + MD + + """ + Monaco. + """ + MC + + """ + Mongolia. + """ + MN + + """ + Montenegro. + """ + ME + + """ + Montserrat. + """ + MS + + """ + Morocco. + """ + MA + + """ + Mozambique. + """ + MZ + + """ + Myanmar (Burma). + """ + MM + + """ + Namibia. + """ + NA + + """ + Nauru. + """ + NR + + """ + Nepal. + """ + NP + + """ + Netherlands. + """ + NL + + """ + Netherlands Antilles. + """ + AN + + """ + New Caledonia. + """ + NC + + """ + New Zealand. + """ + NZ + + """ + Nicaragua. + """ + NI + + """ + Niger. + """ + NE + + """ + Nigeria. + """ + NG + + """ + Niue. + """ + NU + + """ + Norfolk Island. + """ + NF + + """ + North Macedonia. + """ + MK + + """ + Norway. + """ + NO + + """ + Oman. + """ + OM + + """ + Pakistan. + """ + PK + + """ + Palestinian Territories. + """ + PS + + """ + Panama. + """ + PA + + """ + Papua New Guinea. + """ + PG + + """ + Paraguay. + """ + PY + + """ + Peru. + """ + PE + + """ + Philippines. + """ + PH + + """ + Pitcairn Islands. + """ + PN + + """ + Poland. + """ + PL + + """ + Portugal. + """ + PT + + """ + Qatar. + """ + QA + + """ + Cameroon. + """ + CM + + """ + Réunion. + """ + RE + + """ + Romania. + """ + RO + + """ + Russia. + """ + RU + + """ + Rwanda. + """ + RW + + """ + St. Barthélemy. + """ + BL + + """ + St. Helena. + """ + SH + + """ + St. Kitts & Nevis. + """ + KN + + """ + St. Lucia. + """ + LC + + """ + St. Martin. + """ + MF + + """ + St. Pierre & Miquelon. + """ + PM + + """ + Samoa. + """ + WS + + """ + San Marino. + """ + SM + + """ + São Tomé & Príncipe. + """ + ST + + """ + Saudi Arabia. + """ + SA + + """ + Senegal. + """ + SN + + """ + Serbia. + """ + RS + + """ + Seychelles. + """ + SC + + """ + Sierra Leone. + """ + SL + + """ + Singapore. + """ + SG + + """ + Sint Maarten. + """ + SX + + """ + Slovakia. + """ + SK + + """ + Slovenia. + """ + SI + + """ + Solomon Islands. + """ + SB + + """ + Somalia. + """ + SO + + """ + South Africa. + """ + ZA + + """ + South Georgia & South Sandwich Islands. + """ + GS + + """ + South Korea. + """ + KR + + """ + South Sudan. + """ + SS + + """ + Spain. + """ + ES + + """ + Sri Lanka. + """ + LK + + """ + St. Vincent & Grenadines. + """ + VC + + """ + Sudan. + """ + SD + + """ + Suriname. + """ + SR + + """ + Svalbard & Jan Mayen. + """ + SJ + + """ + Sweden. + """ + SE + + """ + Switzerland. + """ + CH + + """ + Syria. + """ + SY + + """ + Taiwan. + """ + TW + + """ + Tajikistan. + """ + TJ + + """ + Tanzania. + """ + TZ + + """ + Thailand. + """ + TH + + """ + Timor-Leste. + """ + TL + + """ + Togo. + """ + TG + + """ + Tokelau. + """ + TK + + """ + Tonga. + """ + TO + + """ + Trinidad & Tobago. + """ + TT + + """ + Tunisia. + """ + TN + + """ + Turkey. + """ + TR + + """ + Turkmenistan. + """ + TM + + """ + Turks & Caicos Islands. + """ + TC + + """ + Tuvalu. + """ + TV + + """ + Uganda. + """ + UG + + """ + Ukraine. + """ + UA + + """ + United Arab Emirates. + """ + AE + + """ + United Kingdom. + """ + GB + + """ + United States. + """ + US + + """ + U.S. Outlying Islands. + """ + UM + + """ + Uruguay. + """ + UY + + """ + Uzbekistan. + """ + UZ + + """ + Vanuatu. + """ + VU + + """ + Venezuela. + """ + VE + + """ + Vietnam. + """ + VN + + """ + British Virgin Islands. + """ + VG + + """ + Wallis & Futuna. + """ + WF + + """ + Western Sahara. + """ + EH + + """ + Yemen. + """ + YE + + """ + Zambia. + """ + ZM + + """ + Zimbabwe. + """ + ZW +} + +""" +Credit card information used for a payment. +""" +type CreditCard { + """ + The brand of the credit card. + """ + brand: String + + """ + The expiry month of the credit card. + """ + expiryMonth: Int + + """ + The expiry year of the credit card. + """ + expiryYear: Int + + """ + The credit card's BIN number. + """ + firstDigits: String + + """ + The first name of the card holder. + """ + firstName: String + + """ + The last 4 digits of the credit card. + """ + lastDigits: String + + """ + The last name of the card holder. + """ + lastName: String + + """ + The masked credit card number with only the last 4 digits displayed. + """ + maskedNumber: String +} + +""" +Specifies the fields required to complete a checkout with +a Shopify vaulted credit card payment. +""" +input CreditCardPaymentInput { + """ + The amount of the payment. + """ + amount: Money! + + """ + A unique client generated key used to avoid duplicate charges. When a duplicate payment is found, the original is returned instead of creating a new one. + """ + idempotencyKey: String! + + """ + The billing address for the payment. + """ + billingAddress: MailingAddressInput! + + """ + The ID returned by Shopify's Card Vault. + """ + vaultId: String! + + """ + Executes the payment in test mode if possible. Defaults to `false`. + """ + test: Boolean +} + +""" +Specifies the fields required to complete a checkout with +a Shopify vaulted credit card payment. +""" +input CreditCardPaymentInputV2 { + """ + The amount and currency of the payment. + """ + paymentAmount: MoneyInput! + + """ + A unique client generated key used to avoid duplicate charges. When a duplicate payment is found, the original is returned instead of creating a new one. + """ + idempotencyKey: String! + + """ + The billing address for the payment. + """ + billingAddress: MailingAddressInput! + + """ + The ID returned by Shopify's Card Vault. + """ + vaultId: String! + + """ + Executes the payment in test mode if possible. Defaults to `false`. + """ + test: Boolean +} + +""" +The part of the image that should remain after cropping. +""" +enum CropRegion { + """ + Keep the center of the image. + """ + CENTER + + """ + Keep the top of the image. + """ + TOP + + """ + Keep the bottom of the image. + """ + BOTTOM + + """ + Keep the left of the image. + """ + LEFT + + """ + Keep the right of the image. + """ + RIGHT +} + +""" +Currency codes. +""" +enum CurrencyCode { + """ + United States Dollars (USD). + """ + USD + + """ + Euro (EUR). + """ + EUR + + """ + United Kingdom Pounds (GBP). + """ + GBP + + """ + Canadian Dollars (CAD). + """ + CAD + + """ + Afghan Afghani (AFN). + """ + AFN + + """ + Albanian Lek (ALL). + """ + ALL + + """ + Algerian Dinar (DZD). + """ + DZD + + """ + Angolan Kwanza (AOA). + """ + AOA + + """ + Argentine Pesos (ARS). + """ + ARS + + """ + Armenian Dram (AMD). + """ + AMD + + """ + Aruban Florin (AWG). + """ + AWG + + """ + Australian Dollars (AUD). + """ + AUD + + """ + Barbadian Dollar (BBD). + """ + BBD + + """ + Azerbaijani Manat (AZN). + """ + AZN + + """ + Bangladesh Taka (BDT). + """ + BDT + + """ + Bahamian Dollar (BSD). + """ + BSD + + """ + Bahraini Dinar (BHD). + """ + BHD + + """ + Burundian Franc (BIF). + """ + BIF + + """ + Belarusian Ruble (BYN). + """ + BYN + + """ + Belarusian Ruble (BYR). + """ + BYR + + """ + Belize Dollar (BZD). + """ + BZD + + """ + Bermudian Dollar (BMD). + """ + BMD + + """ + Bhutanese Ngultrum (BTN). + """ + BTN + + """ + Bosnia and Herzegovina Convertible Mark (BAM). + """ + BAM + + """ + Brazilian Real (BRL). + """ + BRL + + """ + Bolivian Boliviano (BOB). + """ + BOB + + """ + Botswana Pula (BWP). + """ + BWP + + """ + Brunei Dollar (BND). + """ + BND + + """ + Bulgarian Lev (BGN). + """ + BGN + + """ + Burmese Kyat (MMK). + """ + MMK + + """ + Cambodian Riel. + """ + KHR + + """ + Cape Verdean escudo (CVE). + """ + CVE + + """ + Cayman Dollars (KYD). + """ + KYD + + """ + Central African CFA Franc (XAF). + """ + XAF + + """ + Chilean Peso (CLP). + """ + CLP + + """ + Chinese Yuan Renminbi (CNY). + """ + CNY + + """ + Colombian Peso (COP). + """ + COP + + """ + Comorian Franc (KMF). + """ + KMF + + """ + Congolese franc (CDF). + """ + CDF + + """ + Costa Rican Colones (CRC). + """ + CRC + + """ + Croatian Kuna (HRK). + """ + HRK + + """ + Czech Koruny (CZK). + """ + CZK + + """ + Danish Kroner (DKK). + """ + DKK + + """ + Djiboutian Franc (DJF). + """ + DJF + + """ + Dominican Peso (DOP). + """ + DOP + + """ + East Caribbean Dollar (XCD). + """ + XCD + + """ + Egyptian Pound (EGP). + """ + EGP + + """ + Eritrean Nakfa (ERN). + """ + ERN + + """ + Ethiopian Birr (ETB). + """ + ETB + + """ + Falkland Islands Pounds (FKP). + """ + FKP + + """ + CFP Franc (XPF). + """ + XPF + + """ + Fijian Dollars (FJD). + """ + FJD + + """ + Gibraltar Pounds (GIP). + """ + GIP + + """ + Gambian Dalasi (GMD). + """ + GMD + + """ + Ghanaian Cedi (GHS). + """ + GHS + + """ + Guatemalan Quetzal (GTQ). + """ + GTQ + + """ + Guyanese Dollar (GYD). + """ + GYD + + """ + Georgian Lari (GEL). + """ + GEL + + """ + Guinean Franc (GNF). + """ + GNF + + """ + Haitian Gourde (HTG). + """ + HTG + + """ + Honduran Lempira (HNL). + """ + HNL + + """ + Hong Kong Dollars (HKD). + """ + HKD + + """ + Hungarian Forint (HUF). + """ + HUF + + """ + Icelandic Kronur (ISK). + """ + ISK + + """ + Indian Rupees (INR). + """ + INR + + """ + Indonesian Rupiah (IDR). + """ + IDR + + """ + Israeli New Shekel (NIS). + """ + ILS + + """ + Iranian Rial (IRR). + """ + IRR + + """ + Iraqi Dinar (IQD). + """ + IQD + + """ + Jamaican Dollars (JMD). + """ + JMD + + """ + Japanese Yen (JPY). + """ + JPY + + """ + Jersey Pound. + """ + JEP + + """ + Jordanian Dinar (JOD). + """ + JOD + + """ + Kazakhstani Tenge (KZT). + """ + KZT + + """ + Kenyan Shilling (KES). + """ + KES + + """ + Kiribati Dollar (KID). + """ + KID + + """ + Kuwaiti Dinar (KWD). + """ + KWD + + """ + Kyrgyzstani Som (KGS). + """ + KGS + + """ + Laotian Kip (LAK). + """ + LAK + + """ + Latvian Lati (LVL). + """ + LVL + + """ + Lebanese Pounds (LBP). + """ + LBP + + """ + Lesotho Loti (LSL). + """ + LSL + + """ + Liberian Dollar (LRD). + """ + LRD + + """ + Libyan Dinar (LYD). + """ + LYD + + """ + Lithuanian Litai (LTL). + """ + LTL + + """ + Malagasy Ariary (MGA). + """ + MGA + + """ + Macedonia Denar (MKD). + """ + MKD + + """ + Macanese Pataca (MOP). + """ + MOP + + """ + Malawian Kwacha (MWK). + """ + MWK + + """ + Maldivian Rufiyaa (MVR). + """ + MVR + + """ + Mauritanian Ouguiya (MRU). + """ + MRU + + """ + Mexican Pesos (MXN). + """ + MXN + + """ + Malaysian Ringgits (MYR). + """ + MYR + + """ + Mauritian Rupee (MUR). + """ + MUR + + """ + Moldovan Leu (MDL). + """ + MDL + + """ + Moroccan Dirham. + """ + MAD + + """ + Mongolian Tugrik. + """ + MNT + + """ + Mozambican Metical. + """ + MZN + + """ + Namibian Dollar. + """ + NAD + + """ + Nepalese Rupee (NPR). + """ + NPR + + """ + Netherlands Antillean Guilder. + """ + ANG + + """ + New Zealand Dollars (NZD). + """ + NZD + + """ + Nicaraguan Córdoba (NIO). + """ + NIO + + """ + Nigerian Naira (NGN). + """ + NGN + + """ + Norwegian Kroner (NOK). + """ + NOK + + """ + Omani Rial (OMR). + """ + OMR + + """ + Panamian Balboa (PAB). + """ + PAB + + """ + Pakistani Rupee (PKR). + """ + PKR + + """ + Papua New Guinean Kina (PGK). + """ + PGK + + """ + Paraguayan Guarani (PYG). + """ + PYG + + """ + Peruvian Nuevo Sol (PEN). + """ + PEN + + """ + Philippine Peso (PHP). + """ + PHP + + """ + Polish Zlotych (PLN). + """ + PLN + + """ + Qatari Rial (QAR). + """ + QAR + + """ + Romanian Lei (RON). + """ + RON + + """ + Russian Rubles (RUB). + """ + RUB + + """ + Rwandan Franc (RWF). + """ + RWF + + """ + Samoan Tala (WST). + """ + WST + + """ + Saint Helena Pounds (SHP). + """ + SHP + + """ + Saudi Riyal (SAR). + """ + SAR + + """ + Sao Tome And Principe Dobra (STD). + """ + STD + + """ + Serbian dinar (RSD). + """ + RSD + + """ + Seychellois Rupee (SCR). + """ + SCR + + """ + Sierra Leonean Leone (SLL). + """ + SLL + + """ + Singapore Dollars (SGD). + """ + SGD + + """ + Sudanese Pound (SDG). + """ + SDG + + """ + Somali Shilling (SOS). + """ + SOS + + """ + Syrian Pound (SYP). + """ + SYP + + """ + South African Rand (ZAR). + """ + ZAR + + """ + South Korean Won (KRW). + """ + KRW + + """ + South Sudanese Pound (SSP). + """ + SSP + + """ + Solomon Islands Dollar (SBD). + """ + SBD + + """ + Sri Lankan Rupees (LKR). + """ + LKR + + """ + Surinamese Dollar (SRD). + """ + SRD + + """ + Swazi Lilangeni (SZL). + """ + SZL + + """ + Swedish Kronor (SEK). + """ + SEK + + """ + Swiss Francs (CHF). + """ + CHF + + """ + Taiwan Dollars (TWD). + """ + TWD + + """ + Thai baht (THB). + """ + THB + + """ + Tajikistani Somoni (TJS). + """ + TJS + + """ + Tanzanian Shilling (TZS). + """ + TZS + + """ + Tongan Pa'anga (TOP). + """ + TOP + + """ + Trinidad and Tobago Dollars (TTD). + """ + TTD + + """ + Tunisian Dinar (TND). + """ + TND + + """ + Turkish Lira (TRY). + """ + TRY + + """ + Turkmenistani Manat (TMT). + """ + TMT + + """ + Ugandan Shilling (UGX). + """ + UGX + + """ + Ukrainian Hryvnia (UAH). + """ + UAH + + """ + United Arab Emirates Dirham (AED). + """ + AED + + """ + Uruguayan Pesos (UYU). + """ + UYU + + """ + Uzbekistan som (UZS). + """ + UZS + + """ + Vanuatu Vatu (VUV). + """ + VUV + + """ + Venezuelan Bolivares (VEF). + """ + VEF + + """ + Venezuelan Bolivares (VES). + """ + VES + + """ + Vietnamese đồng (VND). + """ + VND + + """ + West African CFA franc (XOF). + """ + XOF + + """ + Yemeni Rial (YER). + """ + YER + + """ + Zambian Kwacha (ZMW). + """ + ZMW +} + +""" +A customer represents a customer account with the shop. Customer accounts store contact information for the customer, saving logged-in customers the trouble of having to provide it at every checkout. +""" +type Customer { + """ + Indicates whether the customer has consented to be sent marketing material via email. + """ + acceptsMarketing: Boolean! + + """ + A list of addresses for the customer. + """ + addresses( + """ + Returns up to the first `n` elements from the list. + """ + first: Int + + """ + Returns the elements that come after the specified cursor. + """ + after: String + + """ + Returns up to the last `n` elements from the list. + """ + last: Int + + """ + Returns the elements that come before the specified cursor. + """ + before: String + + """ + Reverse the order of the underlying list. + """ + reverse: Boolean = false + ): MailingAddressConnection! + + """ + The date and time when the customer was created. + """ + createdAt: DateTime! + + """ + The customer’s default address. + """ + defaultAddress: MailingAddress + + """ + The customer’s name, email or phone number. + """ + displayName: String! + + """ + The customer’s email address. + """ + email: String + + """ + The customer’s first name. + """ + firstName: String + + """ + A unique identifier for the customer. + """ + id: ID! + + """ + The customer's most recently updated, incomplete checkout. + """ + lastIncompleteCheckout: Checkout + + """ + The customer’s last name. + """ + lastName: String + + """ + The orders associated with the customer. + """ + orders( + """ + Returns up to the first `n` elements from the list. + """ + first: Int + + """ + Returns the elements that come after the specified cursor. + """ + after: String + + """ + Returns up to the last `n` elements from the list. + """ + last: Int + + """ + Returns the elements that come before the specified cursor. + """ + before: String + + """ + Reverse the order of the underlying list. + """ + reverse: Boolean = false + + """ + Sort the underlying list by the given key. + """ + sortKey: OrderSortKeys = ID + + """ + Supported filter parameters: + - `processed_at` + + See the detailed [search syntax](https://help.shopify.com/api/getting-started/search-syntax) + for more information about using filters. + """ + query: String + ): OrderConnection! + + """ + The customer’s phone number. + """ + phone: String + + """ + A comma separated list of tags that have been added to the customer. + Additional access scope required: unauthenticated_read_customer_tags. + """ + tags: [String!]! + + """ + The date and time when the customer information was updated. + """ + updatedAt: DateTime! +} + +""" +A CustomerAccessToken represents the unique token required to make modifications to the customer object. +""" +type CustomerAccessToken { + """ + The customer’s access token. + """ + accessToken: String! + + """ + The date and time when the customer access token expires. + """ + expiresAt: DateTime! +} + +""" +Specifies the input fields required to create a customer access token. +""" +input CustomerAccessTokenCreateInput { + """ + The email associated to the customer. + """ + email: String! + + """ + The login password to be used by the customer. + """ + password: String! +} + +""" +Return type for `customerAccessTokenCreate` mutation. +""" +type CustomerAccessTokenCreatePayload { + """ + The newly created customer access token object. + """ + customerAccessToken: CustomerAccessToken + + """ + List of errors that occurred executing the mutation. + """ + customerUserErrors: [CustomerUserError!]! + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `customerUserErrors` instead") +} + +""" +Return type for `customerAccessTokenCreateWithMultipass` mutation. +""" +type CustomerAccessTokenCreateWithMultipassPayload { + """ + An access token object associated with the customer. + """ + customerAccessToken: CustomerAccessToken + + """ + List of errors that occurred executing the mutation. + """ + customerUserErrors: [CustomerUserError!]! +} + +""" +Return type for `customerAccessTokenDelete` mutation. +""" +type CustomerAccessTokenDeletePayload { + """ + The destroyed access token. + """ + deletedAccessToken: String + + """ + ID of the destroyed customer access token. + """ + deletedCustomerAccessTokenId: String + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! +} + +""" +Return type for `customerAccessTokenRenew` mutation. +""" +type CustomerAccessTokenRenewPayload { + """ + The renewed customer access token object. + """ + customerAccessToken: CustomerAccessToken + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! +} + +""" +Return type for `customerActivateByUrl` mutation. +""" +type CustomerActivateByUrlPayload { + """ + The customer that was activated. + """ + customer: Customer + + """ + A new customer access token for the customer. + """ + customerAccessToken: CustomerAccessToken + + """ + List of errors that occurred executing the mutation. + """ + customerUserErrors: [CustomerUserError!]! +} + +""" +Specifies the input fields required to activate a customer. +""" +input CustomerActivateInput { + """ + The activation token required to activate the customer. + """ + activationToken: String! + + """ + New password that will be set during activation. + """ + password: String! +} + +""" +Return type for `customerActivate` mutation. +""" +type CustomerActivatePayload { + """ + The customer object. + """ + customer: Customer + + """ + A newly created customer access token object for the customer. + """ + customerAccessToken: CustomerAccessToken + + """ + List of errors that occurred executing the mutation. + """ + customerUserErrors: [CustomerUserError!]! + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `customerUserErrors` instead") +} + +""" +Return type for `customerAddressCreate` mutation. +""" +type CustomerAddressCreatePayload { + """ + The new customer address object. + """ + customerAddress: MailingAddress + + """ + List of errors that occurred executing the mutation. + """ + customerUserErrors: [CustomerUserError!]! + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `customerUserErrors` instead") +} + +""" +Return type for `customerAddressDelete` mutation. +""" +type CustomerAddressDeletePayload { + """ + List of errors that occurred executing the mutation. + """ + customerUserErrors: [CustomerUserError!]! + + """ + ID of the deleted customer address. + """ + deletedCustomerAddressId: String + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `customerUserErrors` instead") +} + +""" +Return type for `customerAddressUpdate` mutation. +""" +type CustomerAddressUpdatePayload { + """ + The customer’s updated mailing address. + """ + customerAddress: MailingAddress + + """ + List of errors that occurred executing the mutation. + """ + customerUserErrors: [CustomerUserError!]! + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `customerUserErrors` instead") +} + +""" +Specifies the fields required to create a new customer. +""" +input CustomerCreateInput { + """ + The customer’s first name. + """ + firstName: String + + """ + The customer’s last name. + """ + lastName: String + + """ + The customer’s email. + """ + email: String! + + """ + A unique phone number for the customer. + + Formatted using E.164 standard. For example, _+16135551111_. + """ + phone: String + + """ + The login password used by the customer. + """ + password: String! + + """ + Indicates whether the customer has consented to be sent marketing material via email. + """ + acceptsMarketing: Boolean +} + +""" +Return type for `customerCreate` mutation. +""" +type CustomerCreatePayload { + """ + The created customer object. + """ + customer: Customer + + """ + List of errors that occurred executing the mutation. + """ + customerUserErrors: [CustomerUserError!]! + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `customerUserErrors` instead") +} + +""" +Return type for `customerDefaultAddressUpdate` mutation. +""" +type CustomerDefaultAddressUpdatePayload { + """ + The updated customer object. + """ + customer: Customer + + """ + List of errors that occurred executing the mutation. + """ + customerUserErrors: [CustomerUserError!]! + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `customerUserErrors` instead") +} + +""" +Possible error codes that could be returned by CustomerUserError. +""" +enum CustomerErrorCode { + """ + Input value is blank. + """ + BLANK + + """ + Input value is invalid. + """ + INVALID + + """ + Input value is already taken. + """ + TAKEN + + """ + Input value is too long. + """ + TOO_LONG + + """ + Input value is too short. + """ + TOO_SHORT + + """ + Unidentified customer. + """ + UNIDENTIFIED_CUSTOMER + + """ + Customer is disabled. + """ + CUSTOMER_DISABLED + + """ + Input password starts or ends with whitespace. + """ + PASSWORD_STARTS_OR_ENDS_WITH_WHITESPACE + + """ + Input contains HTML tags. + """ + CONTAINS_HTML_TAGS + + """ + Input contains URL. + """ + CONTAINS_URL + + """ + Invalid activation token. + """ + TOKEN_INVALID + + """ + Customer already enabled. + """ + ALREADY_ENABLED + + """ + Address does not exist. + """ + NOT_FOUND + + """ + Input email contains an invalid domain name. + """ + BAD_DOMAIN + + """ + Multipass token is not valid. + """ + INVALID_MULTIPASS_REQUEST +} + +""" +Return type for `customerRecover` mutation. +""" +type CustomerRecoverPayload { + """ + List of errors that occurred executing the mutation. + """ + customerUserErrors: [CustomerUserError!]! + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `customerUserErrors` instead") +} + +""" +Return type for `customerResetByUrl` mutation. +""" +type CustomerResetByUrlPayload { + """ + The customer object which was reset. + """ + customer: Customer + + """ + A newly created customer access token object for the customer. + """ + customerAccessToken: CustomerAccessToken + + """ + List of errors that occurred executing the mutation. + """ + customerUserErrors: [CustomerUserError!]! + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `customerUserErrors` instead") +} + +""" +Specifies the fields required to reset a customer’s password. +""" +input CustomerResetInput { + """ + The reset token required to reset the customer’s password. + """ + resetToken: String! + + """ + New password that will be set as part of the reset password process. + """ + password: String! +} + +""" +Return type for `customerReset` mutation. +""" +type CustomerResetPayload { + """ + The customer object which was reset. + """ + customer: Customer + + """ + A newly created customer access token object for the customer. + """ + customerAccessToken: CustomerAccessToken + + """ + List of errors that occurred executing the mutation. + """ + customerUserErrors: [CustomerUserError!]! + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `customerUserErrors` instead") +} + +""" +Specifies the fields required to update the Customer information. +""" +input CustomerUpdateInput { + """ + The customer’s first name. + """ + firstName: String + + """ + The customer’s last name. + """ + lastName: String + + """ + The customer’s email. + """ + email: String + + """ + A unique phone number for the customer. + + Formatted using E.164 standard. For example, _+16135551111_. To remove the phone number, specify `null`. + """ + phone: String + + """ + The login password used by the customer. + """ + password: String + + """ + Indicates whether the customer has consented to be sent marketing material via email. + """ + acceptsMarketing: Boolean +} + +""" +Return type for `customerUpdate` mutation. +""" +type CustomerUpdatePayload { + """ + The updated customer object. + """ + customer: Customer + + """ + The newly created customer access token. If the customer's password is updated, all previous access tokens + (including the one used to perform this mutation) become invalid, and a new token is generated. + """ + customerAccessToken: CustomerAccessToken + + """ + List of errors that occurred executing the mutation. + """ + customerUserErrors: [CustomerUserError!]! + + """ + List of errors that occurred executing the mutation. + """ + userErrors: [UserError!]! + @deprecated(reason: "Use `customerUserErrors` instead") +} + +""" +Represents an error that happens during execution of a customer mutation. +""" +type CustomerUserError implements DisplayableError { + """ + Error code to uniquely identify the error. + """ + code: CustomerErrorCode + + """ + Path to the input field which caused the error. + """ + field: [String!] + + """ + The error message. + """ + message: String! +} + +""" +An ISO-8601 encoded UTC date time string. Example value: `"2019-07-03T20:47:55Z"`. +""" +scalar DateTime + +""" +A signed decimal number, which supports arbitrary precision and is serialized as a string. Example value: `"29.99"`. +""" +scalar Decimal + +""" +Digital wallet, such as Apple Pay, which can be used for accelerated checkouts. +""" +enum DigitalWallet { + """ + Apple Pay. + """ + APPLE_PAY + + """ + Android Pay. + """ + ANDROID_PAY + + """ + Google Pay. + """ + GOOGLE_PAY + + """ + Shopify Pay. + """ + SHOPIFY_PAY +} + +""" +An amount discounting the line that has been allocated by a discount. +""" +type DiscountAllocation { + """ + Amount of discount allocated. + """ + allocatedAmount: MoneyV2! + + """ + The discount this allocated amount originated from. + """ + discountApplication: DiscountApplication! +} + +""" +Discount applications capture the intentions of a discount source at +the time of application. +""" +interface DiscountApplication { + """ + The method by which the discount's value is allocated to its entitled items. + """ + allocationMethod: DiscountApplicationAllocationMethod! + + """ + Which lines of targetType that the discount is allocated over. + """ + targetSelection: DiscountApplicationTargetSelection! + + """ + The type of line that the discount is applicable towards. + """ + targetType: DiscountApplicationTargetType! + + """ + The value of the discount application. + """ + value: PricingValue! +} + +""" +The method by which the discount's value is allocated onto its entitled lines. +""" +enum DiscountApplicationAllocationMethod { + """ + The value is spread across all entitled lines. + """ + ACROSS + + """ + The value is applied onto every entitled line. + """ + EACH + + """ + The value is specifically applied onto a particular line. + """ + ONE +} + +""" +An auto-generated type for paginating through multiple DiscountApplications. +""" +type DiscountApplicationConnection { + """ + A list of edges. + """ + edges: [DiscountApplicationEdge!]! + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An auto-generated type which holds one DiscountApplication and a cursor during pagination. +""" +type DiscountApplicationEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of DiscountApplicationEdge. + """ + node: DiscountApplication! +} + +""" +Which lines on the order that the discount is allocated over, of the type +defined by the Discount Application's target_type. +""" +enum DiscountApplicationTargetSelection { + """ + The discount is allocated onto all the lines. + """ + ALL + + """ + The discount is allocated onto only the lines it is entitled for. + """ + ENTITLED + + """ + The discount is allocated onto explicitly chosen lines. + """ + EXPLICIT +} + +""" +The type of line (i.e. line item or shipping line) on an order that the discount is applicable towards. +""" +enum DiscountApplicationTargetType { + """ + The discount applies onto line items. + """ + LINE_ITEM + + """ + The discount applies onto shipping lines. + """ + SHIPPING_LINE +} + +""" +Discount code applications capture the intentions of a discount code at +the time that it is applied. +""" +type DiscountCodeApplication implements DiscountApplication { + """ + The method by which the discount's value is allocated to its entitled items. + """ + allocationMethod: DiscountApplicationAllocationMethod! + + """ + Specifies whether the discount code was applied successfully. + """ + applicable: Boolean! + + """ + The string identifying the discount code that was used at the time of application. + """ + code: String! + + """ + Which lines of targetType that the discount is allocated over. + """ + targetSelection: DiscountApplicationTargetSelection! + + """ + The type of line that the discount is applicable towards. + """ + targetType: DiscountApplicationTargetType! + + """ + The value of the discount application. + """ + value: PricingValue! +} + +""" +Represents an error in the input of a mutation. +""" +interface DisplayableError { + """ + Path to the input field which caused the error. + """ + field: [String!] + + """ + The error message. + """ + message: String! +} + +""" +Represents a web address. +""" +type Domain { + """ + The host name of the domain (eg: `example.com`). + """ + host: String! + + """ + Whether SSL is enabled or not. + """ + sslEnabled: Boolean! + + """ + The URL of the domain (eg: `https://example.com`). + """ + url: URL! +} + +""" +Represents a video hosted outside of Shopify. +""" +type ExternalVideo implements Node & Media { + """ + A word or phrase to share the nature or contents of a media. + """ + alt: String + + """ + The URL. + """ + embeddedUrl: URL! + + """ + Globally unique identifier. + """ + id: ID! + + """ + The media content type. + """ + mediaContentType: MediaContentType! + + """ + The preview image for the media. + """ + previewImage: Image +} + +""" +Represents a single fulfillment in an order. +""" +type Fulfillment { + """ + List of the fulfillment's line items. + """ + fulfillmentLineItems( + """ + Returns up to the first `n` elements from the list. + """ + first: Int + + """ + Returns the elements that come after the specified cursor. + """ + after: String + + """ + Returns up to the last `n` elements from the list. + """ + last: Int + + """ + Returns the elements that come before the specified cursor. + """ + before: String + + """ + Reverse the order of the underlying list. + """ + reverse: Boolean = false + ): FulfillmentLineItemConnection! + + """ + The name of the tracking company. + """ + trackingCompany: String + + """ + Tracking information associated with the fulfillment, + such as the tracking number and tracking URL. + """ + trackingInfo( + """ + Truncate the array result to this size. + """ + first: Int + ): [FulfillmentTrackingInfo!]! +} + +""" +Represents a single line item in a fulfillment. There is at most one fulfillment line item for each order line item. +""" +type FulfillmentLineItem { + """ + The associated order's line item. + """ + lineItem: OrderLineItem! + + """ + The amount fulfilled in this fulfillment. + """ + quantity: Int! +} + +""" +An auto-generated type for paginating through multiple FulfillmentLineItems. +""" +type FulfillmentLineItemConnection { + """ + A list of edges. + """ + edges: [FulfillmentLineItemEdge!]! + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An auto-generated type which holds one FulfillmentLineItem and a cursor during pagination. +""" +type FulfillmentLineItemEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of FulfillmentLineItemEdge. + """ + node: FulfillmentLineItem! +} + +""" +Tracking information associated with the fulfillment. +""" +type FulfillmentTrackingInfo { + """ + The tracking number of the fulfillment. + """ + number: String + + """ + The URL to track the fulfillment. + """ + url: URL +} + +""" +A string containing HTML code. Example value: `"

Grey cotton knit sweater.

"`. +""" +scalar HTML + +""" +Represents information about the metafields associated to the specified resource. +""" +interface HasMetafields { + """ + The metafield associated with the resource. + """ + metafield( + """ + Container for a set of metafields (maximum of 20 characters). + """ + namespace: String! + + """ + Identifier for the metafield (maximum of 30 characters). + """ + key: String! + ): Metafield + + """ + A paginated list of metafields associated with the resource. + """ + metafields( + """ + Container for a set of metafields (maximum of 20 characters). + """ + namespace: String + + """ + Returns up to the first `n` elements from the list. + """ + first: Int + + """ + Returns the elements that come after the specified cursor. + """ + after: String + + """ + Returns up to the last `n` elements from the list. + """ + last: Int + + """ + Returns the elements that come before the specified cursor. + """ + before: String + + """ + Reverse the order of the underlying list. + """ + reverse: Boolean = false + ): MetafieldConnection! +} + +""" +Represents an image resource. +""" +type Image { + """ + A word or phrase to share the nature or contents of an image. + """ + altText: String + + """ + The original height of the image in pixels. Returns `null` if the image is not hosted by Shopify. + """ + height: Int + + """ + A unique identifier for the image. + """ + id: ID + + """ + The location of the original image as a URL. + + If there are any existing transformations in the original source URL, they will remain and not be stripped. + """ + originalSrc: URL! + + """ + The location of the image as a URL. + """ + src: URL! + @deprecated( + reason: "Previously an image had a single `src` field. This could either return the original image\nlocation or a URL that contained transformations such as sizing or scale.\n\nThese transformations were specified by arguments on the parent field.\n\nNow an image has two distinct URL fields: `originalSrc` and `transformedSrc`.\n\n* `originalSrc` - the original unmodified image URL\n* `transformedSrc` - the image URL with the specified transformations included\n\nTo migrate to the new fields, image transformations should be moved from the parent field to `transformedSrc`.\n\nBefore:\n```graphql\n{\n shop {\n productImages(maxWidth: 200, scale: 2) {\n edges {\n node {\n src\n }\n }\n }\n }\n}\n```\n\nAfter:\n```graphql\n{\n shop {\n productImages {\n edges {\n node {\n transformedSrc(maxWidth: 200, scale: 2)\n }\n }\n }\n }\n}\n```\n" + ) + + """ + The location of the transformed image as a URL. + + All transformation arguments are considered "best-effort". If they can be applied to an image, they will be. + Otherwise any transformations which an image type does not support will be ignored. + """ + transformedSrc( + """ + Image width in pixels between 1 and 5760. + """ + maxWidth: Int + + """ + Image height in pixels between 1 and 5760. + """ + maxHeight: Int + + """ + Crops the image according to the specified region. + """ + crop: CropRegion + + """ + Image size multiplier for high-resolution retina displays. Must be between 1 and 3. + """ + scale: Int = 1 + + """ + Best effort conversion of image into content type (SVG -> PNG, Anything -> JGP, Anything -> WEBP are supported). + """ + preferredContentType: ImageContentType + ): URL! + + """ + The original width of the image in pixels. Returns `null` if the image is not hosted by Shopify. + """ + width: Int +} + +""" +An auto-generated type for paginating through multiple Images. +""" +type ImageConnection { + """ + A list of edges. + """ + edges: [ImageEdge!]! + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +List of supported image content types. +""" +enum ImageContentType { + """ + A PNG image. + """ + PNG + + """ + A JPG image. + """ + JPG + + """ + A WEBP image. + """ + WEBP +} + +""" +An auto-generated type which holds one Image and a cursor during pagination. +""" +type ImageEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of ImageEdge. + """ + node: Image! +} + +""" +Represents a mailing address for customers and shipping. +""" +type MailingAddress implements Node { + """ + The first line of the address. Typically the street address or PO Box number. + """ + address1: String + + """ + The second line of the address. Typically the number of the apartment, suite, or unit. + """ + address2: String + + """ + The name of the city, district, village, or town. + """ + city: String + + """ + The name of the customer's company or organization. + """ + company: String + + """ + The name of the country. + """ + country: String + + """ + The two-letter code for the country of the address. + + For example, US. + """ + countryCode: String @deprecated(reason: "Use `countryCodeV2` instead") + + """ + The two-letter code for the country of the address. + + For example, US. + """ + countryCodeV2: CountryCode + + """ + The first name of the customer. + """ + firstName: String + + """ + A formatted version of the address, customized by the provided arguments. + """ + formatted( + """ + Whether to include the customer's name in the formatted address. + """ + withName: Boolean = false + + """ + Whether to include the customer's company in the formatted address. + """ + withCompany: Boolean = true + ): [String!]! + + """ + A comma-separated list of the values for city, province, and country. + """ + formattedArea: String + + """ + Globally unique identifier. + """ + id: ID! + + """ + The last name of the customer. + """ + lastName: String + + """ + The latitude coordinate of the customer address. + """ + latitude: Float + + """ + The longitude coordinate of the customer address. + """ + longitude: Float + + """ + The full name of the customer, based on firstName and lastName. + """ + name: String + + """ + A unique phone number for the customer. + + Formatted using E.164 standard. For example, _+16135551111_. + """ + phone: String + + """ + The region of the address, such as the province, state, or district. + """ + province: String + + """ + The two-letter code for the region. + + For example, ON. + """ + provinceCode: String + + """ + The zip or postal code of the address. + """ + zip: String +} + +""" +An auto-generated type for paginating through multiple MailingAddresses. +""" +type MailingAddressConnection { + """ + A list of edges. + """ + edges: [MailingAddressEdge!]! + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An auto-generated type which holds one MailingAddress and a cursor during pagination. +""" +type MailingAddressEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of MailingAddressEdge. + """ + node: MailingAddress! +} + +""" +Specifies the fields accepted to create or update a mailing address. +""" +input MailingAddressInput { + """ + The first line of the address. Typically the street address or PO Box number. + """ + address1: String + + """ + The second line of the address. Typically the number of the apartment, suite, or unit. + """ + address2: String + + """ + The name of the city, district, village, or town. + """ + city: String + + """ + The name of the customer's company or organization. + """ + company: String + + """ + The name of the country. + """ + country: String + + """ + The first name of the customer. + """ + firstName: String + + """ + The last name of the customer. + """ + lastName: String + + """ + A unique phone number for the customer. + + Formatted using E.164 standard. For example, _+16135551111_. + """ + phone: String + + """ + The region of the address, such as the province, state, or district. + """ + province: String + + """ + The zip or postal code of the address. + """ + zip: String +} + +""" +Manual discount applications capture the intentions of a discount that was manually created. +""" +type ManualDiscountApplication implements DiscountApplication { + """ + The method by which the discount's value is allocated to its entitled items. + """ + allocationMethod: DiscountApplicationAllocationMethod! + + """ + The description of the application. + """ + description: String + + """ + Which lines of targetType that the discount is allocated over. + """ + targetSelection: DiscountApplicationTargetSelection! + + """ + The type of line that the discount is applicable towards. + """ + targetType: DiscountApplicationTargetType! + + """ + The title of the application. + """ + title: String! + + """ + The value of the discount application. + """ + value: PricingValue! +} + +""" +Represents a media interface. +""" +interface Media { + """ + A word or phrase to share the nature or contents of a media. + """ + alt: String + + """ + The media content type. + """ + mediaContentType: MediaContentType! + + """ + The preview image for the media. + """ + previewImage: Image +} + +""" +An auto-generated type for paginating through multiple Media. +""" +type MediaConnection { + """ + A list of edges. + """ + edges: [MediaEdge!]! + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +The possible content types for a media object. +""" +enum MediaContentType { + """ + An externally hosted video. + """ + EXTERNAL_VIDEO + + """ + A Shopify hosted image. + """ + IMAGE + + """ + A 3d model. + """ + MODEL_3D + + """ + A Shopify hosted video. + """ + VIDEO +} + +""" +An auto-generated type which holds one Media and a cursor during pagination. +""" +type MediaEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of MediaEdge. + """ + node: Media! +} + +""" +Represents a Shopify hosted image. +""" +type MediaImage implements Node & Media { + """ + A word or phrase to share the nature or contents of a media. + """ + alt: String + + """ + Globally unique identifier. + """ + id: ID! + + """ + The image for the media. + """ + image: Image + + """ + The media content type. + """ + mediaContentType: MediaContentType! + + """ + The preview image for the media. + """ + previewImage: Image +} + +""" +Metafields represent custom metadata attached to a resource. Metafields can be sorted into namespaces and are +comprised of keys, values, and value types. +""" +type Metafield implements Node { + """ + The date and time when the storefront metafield was created. + """ + createdAt: DateTime! + + """ + The description of a metafield. + """ + description: String + + """ + Globally unique identifier. + """ + id: ID! + + """ + The key name for a metafield. + """ + key: String! + + """ + The namespace for a metafield. + """ + namespace: String! + + """ + The parent object that the metafield belongs to. + """ + parentResource: MetafieldParentResource! + + """ + The date and time when the storefront metafield was updated. + """ + updatedAt: DateTime! + + """ + The value of a metafield. + """ + value: String! + + """ + Represents the metafield value type. + """ + valueType: MetafieldValueType! +} + +""" +An auto-generated type for paginating through multiple Metafields. +""" +type MetafieldConnection { + """ + A list of edges. + """ + edges: [MetafieldEdge!]! + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An auto-generated type which holds one Metafield and a cursor during pagination. +""" +type MetafieldEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of MetafieldEdge. + """ + node: Metafield! +} + +""" +A resource that the metafield belongs to. +""" +union MetafieldParentResource = Product | ProductVariant + +""" +Metafield value types. +""" +enum MetafieldValueType { + """ + A string metafield. + """ + STRING + + """ + An integer metafield. + """ + INTEGER + + """ + A json string metafield. + """ + JSON_STRING +} + +""" +Represents a Shopify hosted 3D model. +""" +type Model3d implements Node & Media { + """ + A word or phrase to share the nature or contents of a media. + """ + alt: String + + """ + Globally unique identifier. + """ + id: ID! + + """ + The media content type. + """ + mediaContentType: MediaContentType! + + """ + The preview image for the media. + """ + previewImage: Image + + """ + The sources for a 3d model. + """ + sources: [Model3dSource!]! +} + +""" +Represents a source for a Shopify hosted 3d model. +""" +type Model3dSource { + """ + The filesize of the 3d model. + """ + filesize: Int! + + """ + The format of the 3d model. + """ + format: String! + + """ + The MIME type of the 3d model. + """ + mimeType: String! + + """ + The URL of the 3d model. + """ + url: String! +} + +""" +A monetary value string. Example value: `"100.57"`. +""" +scalar Money + +""" +Specifies the fields for a monetary value with currency. +""" +input MoneyInput { + """ + Decimal money amount. + """ + amount: Decimal! + + """ + Currency of the money. + """ + currencyCode: CurrencyCode! +} + +""" +A monetary value with currency. + +To format currencies, combine this type's amount and currencyCode fields with your client's locale. + +For example, in JavaScript you could use Intl.NumberFormat: + +```js +new Intl.NumberFormat(locale, { + style: 'currency', + currency: currencyCode +}).format(amount); +``` + +Other formatting libraries include: + +* iOS - [NumberFormatter](https://developer.apple.com/documentation/foundation/numberformatter) +* Android - [NumberFormat](https://developer.android.com/reference/java/text/NumberFormat.html) +* PHP - [NumberFormatter](http://php.net/manual/en/class.numberformatter.php) + +For a more general solution, the [Unicode CLDR number formatting database] is available with many implementations +(such as [TwitterCldr](https://github.com/twitter/twitter-cldr-rb)). +""" +type MoneyV2 { + """ + Decimal money amount. + """ + amount: Decimal! + + """ + Currency of the money. + """ + currencyCode: CurrencyCode! +} + +""" +An auto-generated type for paginating through multiple MoneyV2s. +""" +type MoneyV2Connection { + """ + A list of edges. + """ + edges: [MoneyV2Edge!]! + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An auto-generated type which holds one MoneyV2 and a cursor during pagination. +""" +type MoneyV2Edge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of MoneyV2Edge. + """ + node: MoneyV2! +} + +""" +The schema’s entry-point for mutations. This acts as the public, top-level API from which all mutation queries must start. +""" +type Mutation { + """ + Updates the attributes of a checkout. + """ + checkoutAttributesUpdate( + """ + The ID of the checkout. + """ + checkoutId: ID! + + """ + The fields used to update a checkout's attributes. + """ + input: CheckoutAttributesUpdateInput! + ): CheckoutAttributesUpdatePayload + @deprecated(reason: "Use `checkoutAttributesUpdateV2` instead") + + """ + Updates the attributes of a checkout. + """ + checkoutAttributesUpdateV2( + """ + The ID of the checkout. + """ + checkoutId: ID! + + """ + The checkout attributes to update. + """ + input: CheckoutAttributesUpdateV2Input! + ): CheckoutAttributesUpdateV2Payload + + """ + Completes a checkout without providing payment information. You can use this mutation for free items or items whose purchase price is covered by a gift card. + """ + checkoutCompleteFree( + """ + The ID of the checkout. + """ + checkoutId: ID! + ): CheckoutCompleteFreePayload + + """ + Completes a checkout using a credit card token from Shopify's Vault. + """ + checkoutCompleteWithCreditCard( + """ + The ID of the checkout. + """ + checkoutId: ID! + + """ + The credit card info to apply as a payment. + """ + payment: CreditCardPaymentInput! + ): CheckoutCompleteWithCreditCardPayload + @deprecated(reason: "Use `checkoutCompleteWithCreditCardV2` instead") + + """ + Completes a checkout using a credit card token from Shopify's card vault. Before you can complete checkouts using CheckoutCompleteWithCreditCardV2, you need to [_request payment processing_](https://help.shopify.com/api/guides/sales-channel-sdk/getting-started#request-payment-processing). + """ + checkoutCompleteWithCreditCardV2( + """ + The ID of the checkout. + """ + checkoutId: ID! + + """ + The credit card info to apply as a payment. + """ + payment: CreditCardPaymentInputV2! + ): CheckoutCompleteWithCreditCardV2Payload + + """ + Completes a checkout with a tokenized payment. + """ + checkoutCompleteWithTokenizedPayment( + """ + The ID of the checkout. + """ + checkoutId: ID! + + """ + The info to apply as a tokenized payment. + """ + payment: TokenizedPaymentInput! + ): CheckoutCompleteWithTokenizedPaymentPayload + @deprecated(reason: "Use `checkoutCompleteWithTokenizedPaymentV2` instead") + + """ + Completes a checkout with a tokenized payment. + """ + checkoutCompleteWithTokenizedPaymentV2( + """ + The ID of the checkout. + """ + checkoutId: ID! + + """ + The info to apply as a tokenized payment. + """ + payment: TokenizedPaymentInputV2! + ): CheckoutCompleteWithTokenizedPaymentV2Payload + @deprecated(reason: "Use `checkoutCompleteWithTokenizedPaymentV3` instead") + + """ + Completes a checkout with a tokenized payment. + """ + checkoutCompleteWithTokenizedPaymentV3( + """ + The ID of the checkout. + """ + checkoutId: ID! + + """ + The info to apply as a tokenized payment. + """ + payment: TokenizedPaymentInputV3! + ): CheckoutCompleteWithTokenizedPaymentV3Payload + + """ + Creates a new checkout. + """ + checkoutCreate( + """ + The fields used to create a checkout. + """ + input: CheckoutCreateInput! + ): CheckoutCreatePayload + + """ + Associates a customer to the checkout. + """ + checkoutCustomerAssociate( + """ + The ID of the checkout. + """ + checkoutId: ID! + + """ + The customer access token of the customer to associate. + """ + customerAccessToken: String! + ): CheckoutCustomerAssociatePayload + @deprecated(reason: "Use `checkoutCustomerAssociateV2` instead") + + """ + Associates a customer to the checkout. + """ + checkoutCustomerAssociateV2( + """ + The ID of the checkout. + """ + checkoutId: ID! + + """ + The customer access token of the customer to associate. + """ + customerAccessToken: String! + ): CheckoutCustomerAssociateV2Payload + + """ + Disassociates the current checkout customer from the checkout. + """ + checkoutCustomerDisassociate( + """ + The ID of the checkout. + """ + checkoutId: ID! + ): CheckoutCustomerDisassociatePayload + @deprecated(reason: "Use `checkoutCustomerDisassociateV2` instead") + + """ + Disassociates the current checkout customer from the checkout. + """ + checkoutCustomerDisassociateV2( + """ + The ID of the checkout. + """ + checkoutId: ID! + ): CheckoutCustomerDisassociateV2Payload + + """ + Applies a discount to an existing checkout using a discount code. + """ + checkoutDiscountCodeApply( + """ + The discount code to apply to the checkout. + """ + discountCode: String! + + """ + The ID of the checkout. + """ + checkoutId: ID! + ): CheckoutDiscountCodeApplyPayload + @deprecated(reason: "Use `checkoutDiscountCodeApplyV2` instead") + + """ + Applies a discount to an existing checkout using a discount code. + """ + checkoutDiscountCodeApplyV2( + """ + The discount code to apply to the checkout. + """ + discountCode: String! + + """ + The ID of the checkout. + """ + checkoutId: ID! + ): CheckoutDiscountCodeApplyV2Payload + + """ + Removes the applied discount from an existing checkout. + """ + checkoutDiscountCodeRemove( + """ + The ID of the checkout. + """ + checkoutId: ID! + ): CheckoutDiscountCodeRemovePayload + + """ + Updates the email on an existing checkout. + """ + checkoutEmailUpdate( + """ + The ID of the checkout. + """ + checkoutId: ID! + + """ + The email to update the checkout with. + """ + email: String! + ): CheckoutEmailUpdatePayload + @deprecated(reason: "Use `checkoutEmailUpdateV2` instead") + + """ + Updates the email on an existing checkout. + """ + checkoutEmailUpdateV2( + """ + The ID of the checkout. + """ + checkoutId: ID! + + """ + The email to update the checkout with. + """ + email: String! + ): CheckoutEmailUpdateV2Payload + + """ + Applies a gift card to an existing checkout using a gift card code. This will replace all currently applied gift cards. + """ + checkoutGiftCardApply( + """ + The code of the gift card to apply on the checkout. + """ + giftCardCode: String! + + """ + The ID of the checkout. + """ + checkoutId: ID! + ): CheckoutGiftCardApplyPayload + @deprecated(reason: "Use `checkoutGiftCardsAppend` instead") + + """ + Removes an applied gift card from the checkout. + """ + checkoutGiftCardRemove( + """ + The ID of the Applied Gift Card to remove from the Checkout. + """ + appliedGiftCardId: ID! + + """ + The ID of the checkout. + """ + checkoutId: ID! + ): CheckoutGiftCardRemovePayload + @deprecated(reason: "Use `checkoutGiftCardRemoveV2` instead") + + """ + Removes an applied gift card from the checkout. + """ + checkoutGiftCardRemoveV2( + """ + The ID of the Applied Gift Card to remove from the Checkout. + """ + appliedGiftCardId: ID! + + """ + The ID of the checkout. + """ + checkoutId: ID! + ): CheckoutGiftCardRemoveV2Payload + + """ + Appends gift cards to an existing checkout. + """ + checkoutGiftCardsAppend( + """ + A list of gift card codes to append to the checkout. + """ + giftCardCodes: [String!]! + + """ + The ID of the checkout. + """ + checkoutId: ID! + ): CheckoutGiftCardsAppendPayload + + """ + Adds a list of line items to a checkout. + """ + checkoutLineItemsAdd( + """ + A list of line item objects to add to the checkout. + """ + lineItems: [CheckoutLineItemInput!]! + + """ + The ID of the checkout. + """ + checkoutId: ID! + ): CheckoutLineItemsAddPayload + + """ + Removes line items from an existing checkout. + """ + checkoutLineItemsRemove( + """ + The checkout on which to remove line items. + """ + checkoutId: ID! + + """ + Line item ids to remove. + """ + lineItemIds: [ID!]! + ): CheckoutLineItemsRemovePayload + + """ + Sets a list of line items to a checkout. + """ + checkoutLineItemsReplace( + """ + A list of line item objects to set on the checkout. + """ + lineItems: [CheckoutLineItemInput!]! + + """ + The ID of the checkout. + """ + checkoutId: ID! + ): CheckoutLineItemsReplacePayload + + """ + Updates line items on a checkout. + """ + checkoutLineItemsUpdate( + """ + The checkout on which to update line items. + """ + checkoutId: ID! + + """ + Line items to update. + """ + lineItems: [CheckoutLineItemUpdateInput!]! + ): CheckoutLineItemsUpdatePayload + + """ + Updates the shipping address of an existing checkout. + """ + checkoutShippingAddressUpdate( + """ + The shipping address to where the line items will be shipped. + """ + shippingAddress: MailingAddressInput! + + """ + The ID of the checkout. + """ + checkoutId: ID! + ): CheckoutShippingAddressUpdatePayload + @deprecated(reason: "Use `checkoutShippingAddressUpdateV2` instead") + + """ + Updates the shipping address of an existing checkout. + """ + checkoutShippingAddressUpdateV2( + """ + The shipping address to where the line items will be shipped. + """ + shippingAddress: MailingAddressInput! + + """ + The ID of the checkout. + """ + checkoutId: ID! + ): CheckoutShippingAddressUpdateV2Payload + + """ + Updates the shipping lines on an existing checkout. + """ + checkoutShippingLineUpdate( + """ + The ID of the checkout. + """ + checkoutId: ID! + + """ + A unique identifier to a Checkout’s shipping provider, price, and title combination, enabling the customer to select the availableShippingRates. + """ + shippingRateHandle: String! + ): CheckoutShippingLineUpdatePayload + + """ + Creates a customer access token. + The customer access token is required to modify the customer object in any way. + """ + customerAccessTokenCreate( + """ + The fields used to create a customer access token. + """ + input: CustomerAccessTokenCreateInput! + ): CustomerAccessTokenCreatePayload + + """ + Creates a customer access token using a multipass token instead of email and password. + A customer record is created if customer does not exist. If a customer record already + exists but the record is disabled, then it's enabled. + """ + customerAccessTokenCreateWithMultipass( + """ + A valid multipass token to be authenticated. + """ + multipassToken: String! + ): CustomerAccessTokenCreateWithMultipassPayload + + """ + Permanently destroys a customer access token. + """ + customerAccessTokenDelete( + """ + The access token used to identify the customer. + """ + customerAccessToken: String! + ): CustomerAccessTokenDeletePayload + + """ + Renews a customer access token. + + Access token renewal must happen *before* a token expires. + If a token has already expired, a new one should be created instead via `customerAccessTokenCreate`. + """ + customerAccessTokenRenew( + """ + The access token used to identify the customer. + """ + customerAccessToken: String! + ): CustomerAccessTokenRenewPayload + + """ + Activates a customer. + """ + customerActivate( + """ + Specifies the customer to activate. + """ + id: ID! + + """ + The fields used to activate a customer. + """ + input: CustomerActivateInput! + ): CustomerActivatePayload + + """ + Activates a customer with the activation url received from `customerCreate`. + """ + customerActivateByUrl( + """ + The customer activation URL. + """ + activationUrl: URL! + + """ + A new password set during activation. + """ + password: String! + ): CustomerActivateByUrlPayload + + """ + Creates a new address for a customer. + """ + customerAddressCreate( + """ + The access token used to identify the customer. + """ + customerAccessToken: String! + + """ + The customer mailing address to create. + """ + address: MailingAddressInput! + ): CustomerAddressCreatePayload + + """ + Permanently deletes the address of an existing customer. + """ + customerAddressDelete( + """ + Specifies the address to delete. + """ + id: ID! + + """ + The access token used to identify the customer. + """ + customerAccessToken: String! + ): CustomerAddressDeletePayload + + """ + Updates the address of an existing customer. + """ + customerAddressUpdate( + """ + The access token used to identify the customer. + """ + customerAccessToken: String! + + """ + Specifies the customer address to update. + """ + id: ID! + + """ + The customer’s mailing address. + """ + address: MailingAddressInput! + ): CustomerAddressUpdatePayload + + """ + Creates a new customer. + """ + customerCreate( + """ + The fields used to create a new customer. + """ + input: CustomerCreateInput! + ): CustomerCreatePayload + + """ + Updates the default address of an existing customer. + """ + customerDefaultAddressUpdate( + """ + The access token used to identify the customer. + """ + customerAccessToken: String! + + """ + ID of the address to set as the new default for the customer. + """ + addressId: ID! + ): CustomerDefaultAddressUpdatePayload + + """ + Sends a reset password email to the customer, as the first step in the reset password process. + """ + customerRecover( + """ + The email address of the customer to recover. + """ + email: String! + ): CustomerRecoverPayload + + """ + Resets a customer’s password with a token received from `CustomerRecover`. + """ + customerReset( + """ + Specifies the customer to reset. + """ + id: ID! + + """ + The fields used to reset a customer’s password. + """ + input: CustomerResetInput! + ): CustomerResetPayload + + """ + Resets a customer’s password with the reset password url received from `CustomerRecover`. + """ + customerResetByUrl( + """ + The customer's reset password url. + """ + resetUrl: URL! + + """ + New password that will be set as part of the reset password process. + """ + password: String! + ): CustomerResetByUrlPayload + + """ + Updates an existing customer. + """ + customerUpdate( + """ + The access token used to identify the customer. + """ + customerAccessToken: String! + + """ + The customer object input. + """ + customer: CustomerUpdateInput! + ): CustomerUpdatePayload +} + +""" +An object with an ID to support global identification. +""" +interface Node { + """ + Globally unique identifier. + """ + id: ID! +} + +""" +An order is a customer’s completed request to purchase one or more products from a shop. An order is created when a customer completes the checkout process, during which time they provides an email address, billing address and payment information. +""" +type Order implements Node { + """ + The reason for the order's cancellation. Returns `null` if the order wasn't canceled. + """ + cancelReason: OrderCancelReason + + """ + The date and time when the order was canceled. Returns null if the order wasn't canceled. + """ + canceledAt: DateTime + + """ + The code of the currency used for the payment. + """ + currencyCode: CurrencyCode! + + """ + The subtotal of line items and their discounts, excluding line items that have been removed. Does not contain order-level discounts, duties, shipping costs, or shipping discounts. Taxes are not included unless the order is a taxes-included order. + """ + currentSubtotalPrice: MoneyV2! + + """ + The total amount of the order, including duties, taxes and discounts, minus amounts for line items that have been removed. + """ + currentTotalPrice: MoneyV2! + + """ + The total of all taxes applied to the order, excluding taxes for returned line items. + """ + currentTotalTax: MoneyV2! + + """ + The locale code in which this specific order happened. + """ + customerLocale: String + + """ + The unique URL that the customer can use to access the order. + """ + customerUrl: URL + + """ + Discounts that have been applied on the order. + """ + discountApplications( + """ + Returns up to the first `n` elements from the list. + """ + first: Int + + """ + Returns the elements that come after the specified cursor. + """ + after: String + + """ + Returns up to the last `n` elements from the list. + """ + last: Int + + """ + Returns the elements that come before the specified cursor. + """ + before: String + + """ + Reverse the order of the underlying list. + """ + reverse: Boolean = false + ): DiscountApplicationConnection! + + """ + Whether the order has had any edits applied or not. + """ + edited: Boolean! + + """ + The customer's email address. + """ + email: String + + """ + The financial status of the order. + """ + financialStatus: OrderFinancialStatus + + """ + The fulfillment status for the order. + """ + fulfillmentStatus: OrderFulfillmentStatus! + + """ + Globally unique identifier. + """ + id: ID! + + """ + List of the order’s line items. + """ + lineItems( + """ + Returns up to the first `n` elements from the list. + """ + first: Int + + """ + Returns the elements that come after the specified cursor. + """ + after: String + + """ + Returns up to the last `n` elements from the list. + """ + last: Int + + """ + Returns the elements that come before the specified cursor. + """ + before: String + + """ + Reverse the order of the underlying list. + """ + reverse: Boolean = false + ): OrderLineItemConnection! + + """ + Unique identifier for the order that appears on the order. + For example, _#1000_ or _Store1001. + """ + name: String! + + """ + A unique numeric identifier for the order for use by shop owner and customer. + """ + orderNumber: Int! + + """ + The total price of the order before any applied edits. + """ + originalTotalPrice: MoneyV2! + + """ + The customer's phone number for receiving SMS notifications. + """ + phone: String + + """ + The date and time when the order was imported. + This value can be set to dates in the past when importing from other systems. + If no value is provided, it will be auto-generated based on current date and time. + """ + processedAt: DateTime! + + """ + The address to where the order will be shipped. + """ + shippingAddress: MailingAddress + + """ + The discounts that have been allocated onto the shipping line by discount applications. + """ + shippingDiscountAllocations: [DiscountAllocation!]! + + """ + The unique URL for the order's status page. + """ + statusUrl: URL! + + """ + Price of the order before shipping and taxes. + """ + subtotalPrice: Money @deprecated(reason: "Use `subtotalPriceV2` instead") + + """ + Price of the order before duties, shipping and taxes. + """ + subtotalPriceV2: MoneyV2 + + """ + List of the order’s successful fulfillments. + """ + successfulFulfillments( + """ + Truncate the array result to this size. + """ + first: Int + ): [Fulfillment!] + + """ + The sum of all the prices of all the items in the order, taxes and discounts included (must be positive). + """ + totalPrice: Money! @deprecated(reason: "Use `totalPriceV2` instead") + + """ + The sum of all the prices of all the items in the order, duties, taxes and discounts included (must be positive). + """ + totalPriceV2: MoneyV2! + + """ + The total amount that has been refunded. + """ + totalRefunded: Money! @deprecated(reason: "Use `totalRefundedV2` instead") + + """ + The total amount that has been refunded. + """ + totalRefundedV2: MoneyV2! + + """ + The total cost of shipping. + """ + totalShippingPrice: Money! + @deprecated(reason: "Use `totalShippingPriceV2` instead") + + """ + The total cost of shipping. + """ + totalShippingPriceV2: MoneyV2! + + """ + The total cost of taxes. + """ + totalTax: Money @deprecated(reason: "Use `totalTaxV2` instead") + + """ + The total cost of taxes. + """ + totalTaxV2: MoneyV2 +} + +""" +Represents the reason for the order's cancellation. +""" +enum OrderCancelReason { + """ + The customer wanted to cancel the order. + """ + CUSTOMER + + """ + The order was fraudulent. + """ + FRAUD + + """ + There was insufficient inventory. + """ + INVENTORY + + """ + Payment was declined. + """ + DECLINED + + """ + The order was canceled for an unlisted reason. + """ + OTHER +} + +""" +An auto-generated type for paginating through multiple Orders. +""" +type OrderConnection { + """ + A list of edges. + """ + edges: [OrderEdge!]! + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An auto-generated type which holds one Order and a cursor during pagination. +""" +type OrderEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of OrderEdge. + """ + node: Order! +} + +""" +Represents the order's current financial status. +""" +enum OrderFinancialStatus { + """ + Displayed as **Pending**. + """ + PENDING + + """ + Displayed as **Authorized**. + """ + AUTHORIZED + + """ + Displayed as **Partially paid**. + """ + PARTIALLY_PAID + + """ + Displayed as **Partially refunded**. + """ + PARTIALLY_REFUNDED + + """ + Displayed as **Voided**. + """ + VOIDED + + """ + Displayed as **Paid**. + """ + PAID + + """ + Displayed as **Refunded**. + """ + REFUNDED +} + +""" +Represents the order's current fulfillment status. +""" +enum OrderFulfillmentStatus { + """ + Displayed as **Unfulfilled**. + """ + UNFULFILLED + + """ + Displayed as **Partially fulfilled**. + """ + PARTIALLY_FULFILLED + + """ + Displayed as **Fulfilled**. + """ + FULFILLED + + """ + Displayed as **Restocked**. + """ + RESTOCKED + + """ + Displayed as **Pending fulfillment**. + """ + PENDING_FULFILLMENT + + """ + Displayed as **Open**. + """ + OPEN + + """ + Displayed as **In progress**. + """ + IN_PROGRESS + + """ + Displayed as **Scheduled**. + """ + SCHEDULED +} + +""" +Represents a single line in an order. There is one line item for each distinct product variant. +""" +type OrderLineItem { + """ + The number of entries associated to the line item minus the items that have been removed. + """ + currentQuantity: Int! + + """ + List of custom attributes associated to the line item. + """ + customAttributes: [Attribute!]! + + """ + The discounts that have been allocated onto the order line item by discount applications. + """ + discountAllocations: [DiscountAllocation!]! + + """ + The total price of the line item, including discounts, and displayed in the presentment currency. + """ + discountedTotalPrice: MoneyV2! + + """ + The total price of the line item, not including any discounts. The total price is calculated using the original unit price multiplied by the quantity, and it is displayed in the presentment currency. + """ + originalTotalPrice: MoneyV2! + + """ + The number of products variants associated to the line item. + """ + quantity: Int! + + """ + The title of the product combined with title of the variant. + """ + title: String! + + """ + The product variant object associated to the line item. + """ + variant: ProductVariant +} + +""" +An auto-generated type for paginating through multiple OrderLineItems. +""" +type OrderLineItemConnection { + """ + A list of edges. + """ + edges: [OrderLineItemEdge!]! + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An auto-generated type which holds one OrderLineItem and a cursor during pagination. +""" +type OrderLineItemEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of OrderLineItemEdge. + """ + node: OrderLineItem! +} + +""" +The set of valid sort keys for the Order query. +""" +enum OrderSortKeys { + """ + Sort by the `processed_at` value. + """ + PROCESSED_AT + + """ + Sort by the `total_price` value. + """ + TOTAL_PRICE + + """ + Sort by the `id` value. + """ + ID + + """ + During a search (i.e. when the `query` parameter has been specified on the connection) this sorts the + results by relevance to the search term(s). When no search query is specified, this sort key is not + deterministic and should not be used. + """ + RELEVANCE +} + +""" +Shopify merchants can create pages to hold static HTML content. Each Page object represents a custom page on the online store. +""" +type Page implements Node { + """ + The description of the page, complete with HTML formatting. + """ + body: HTML! + + """ + Summary of the page body. + """ + bodySummary: String! + + """ + The timestamp of the page creation. + """ + createdAt: DateTime! + + """ + A human-friendly unique string for the page automatically generated from its title. + """ + handle: String! + + """ + Globally unique identifier. + """ + id: ID! + + """ + The page's SEO information. + """ + seo: SEO + + """ + The title of the page. + """ + title: String! + + """ + The timestamp of the latest page update. + """ + updatedAt: DateTime! + + """ + The url pointing to the page accessible from the web. + """ + url: URL! +} + +""" +An auto-generated type for paginating through multiple Pages. +""" +type PageConnection { + """ + A list of edges. + """ + edges: [PageEdge!]! + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An auto-generated type which holds one Page and a cursor during pagination. +""" +type PageEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of PageEdge. + """ + node: Page! +} + +""" +Information about pagination in a connection. +""" +type PageInfo { + """ + Indicates if there are more pages to fetch. + """ + hasNextPage: Boolean! + + """ + Indicates if there are any pages prior to the current page. + """ + hasPreviousPage: Boolean! +} + +""" +The set of valid sort keys for the Page query. +""" +enum PageSortKeys { + """ + Sort by the `title` value. + """ + TITLE + + """ + Sort by the `updated_at` value. + """ + UPDATED_AT + + """ + Sort by the `id` value. + """ + ID + + """ + During a search (i.e. when the `query` parameter has been specified on the connection) this sorts the + results by relevance to the search term(s). When no search query is specified, this sort key is not + deterministic and should not be used. + """ + RELEVANCE +} + +""" +A payment applied to a checkout. +""" +type Payment implements Node { + """ + The amount of the payment. + """ + amount: Money! @deprecated(reason: "Use `amountV2` instead") + + """ + The amount of the payment. + """ + amountV2: MoneyV2! + + """ + The billing address for the payment. + """ + billingAddress: MailingAddress + + """ + The checkout to which the payment belongs. + """ + checkout: Checkout! + + """ + The credit card used for the payment in the case of direct payments. + """ + creditCard: CreditCard + + """ + A message describing a processing error during asynchronous processing. + """ + errorMessage: String + + """ + Globally unique identifier. + """ + id: ID! + + """ + A client-side generated token to identify a payment and perform idempotent operations. + """ + idempotencyKey: String + + """ + The URL where the customer needs to be redirected so they can complete the 3D Secure payment flow. + """ + nextActionUrl: URL + + """ + Whether or not the payment is still processing asynchronously. + """ + ready: Boolean! + + """ + A flag to indicate if the payment is to be done in test mode for gateways that support it. + """ + test: Boolean! + + """ + The actual transaction recorded by Shopify after having processed the payment with the gateway. + """ + transaction: Transaction +} + +""" +Settings related to payments. +""" +type PaymentSettings { + """ + List of the card brands which the shop accepts. + """ + acceptedCardBrands: [CardBrand!]! + + """ + The url pointing to the endpoint to vault credit cards. + """ + cardVaultUrl: URL! + + """ + The country where the shop is located. + """ + countryCode: CountryCode! + + """ + The three-letter code for the shop's primary currency. + """ + currencyCode: CurrencyCode! + + """ + A list of enabled currencies (ISO 4217 format) that the shop accepts. Merchants can enable currencies from their Shopify Payments settings in the Shopify admin. + """ + enabledPresentmentCurrencies: [CurrencyCode!]! + + """ + The shop’s Shopify Payments account id. + """ + shopifyPaymentsAccountId: String + + """ + List of the digital wallets which the shop supports. + """ + supportedDigitalWallets: [DigitalWallet!]! +} + +""" +The valid values for the types of payment token. +""" +enum PaymentTokenType { + """ + Apple Pay token type. + """ + APPLE_PAY + + """ + Vault payment token type. + """ + VAULT + + """ + Shopify Pay token type. + """ + SHOPIFY_PAY + + """ + Google Pay token type. + """ + GOOGLE_PAY +} + +""" +The value of the percentage pricing object. +""" +type PricingPercentageValue { + """ + The percentage value of the object. + """ + percentage: Float! +} + +""" +The price value (fixed or percentage) for a discount application. +""" +union PricingValue = MoneyV2 | PricingPercentageValue + +""" +A product represents an individual item for sale in a Shopify store. Products are often physical, but they don't have to be. +For example, a digital download (such as a movie, music or ebook file) also qualifies as a product, as do services (such as equipment rental, work for hire, customization of another product or an extended warranty). +""" +type Product implements Node & HasMetafields { + """ + Indicates if at least one product variant is available for sale. + """ + availableForSale: Boolean! + + """ + List of collections a product belongs to. + """ + collections( + """ + Returns up to the first `n` elements from the list. + """ + first: Int + + """ + Returns the elements that come after the specified cursor. + """ + after: String + + """ + Returns up to the last `n` elements from the list. + """ + last: Int + + """ + Returns the elements that come before the specified cursor. + """ + before: String + + """ + Reverse the order of the underlying list. + """ + reverse: Boolean = false + ): CollectionConnection! + + """ + The compare at price of the product across all variants. + """ + compareAtPriceRange: ProductPriceRange! + + """ + The date and time when the product was created. + """ + createdAt: DateTime! + + """ + Stripped description of the product, single line with HTML tags removed. + """ + description( + """ + Truncates string after the given length. + """ + truncateAt: Int + ): String! + + """ + The description of the product, complete with HTML formatting. + """ + descriptionHtml: HTML! + + """ + A human-friendly unique string for the Product automatically generated from its title. + They are used by the Liquid templating language to refer to objects. + """ + handle: String! + + """ + Globally unique identifier. + """ + id: ID! + + """ + List of images associated with the product. + """ + images( + """ + Returns up to the first `n` elements from the list. + """ + first: Int + + """ + Returns the elements that come after the specified cursor. + """ + after: String + + """ + Returns up to the last `n` elements from the list. + """ + last: Int + + """ + Returns the elements that come before the specified cursor. + """ + before: String + + """ + Reverse the order of the underlying list. + """ + reverse: Boolean = false + + """ + Sort the underlying list by the given key. + """ + sortKey: ProductImageSortKeys = POSITION + + """ + Image width in pixels between 1 and 2048. This argument is deprecated: Use `maxWidth` on `Image.transformedSrc` instead. + """ + maxWidth: Int + + """ + Image height in pixels between 1 and 2048. This argument is deprecated: Use `maxHeight` on `Image.transformedSrc` instead. + """ + maxHeight: Int + + """ + Crops the image according to the specified region. This argument is deprecated: Use `crop` on `Image.transformedSrc` instead. + """ + crop: CropRegion + + """ + Image size multiplier for high-resolution retina displays. Must be between 1 and 3. This argument is deprecated: Use `scale` on `Image.transformedSrc` instead. + """ + scale: Int = 1 + ): ImageConnection! + + """ + The media associated with the product. + """ + media( + """ + Returns up to the first `n` elements from the list. + """ + first: Int + + """ + Returns the elements that come after the specified cursor. + """ + after: String + + """ + Returns up to the last `n` elements from the list. + """ + last: Int + + """ + Returns the elements that come before the specified cursor. + """ + before: String + + """ + Reverse the order of the underlying list. + """ + reverse: Boolean = false + + """ + Sort the underlying list by the given key. + """ + sortKey: ProductMediaSortKeys = POSITION + ): MediaConnection! + + """ + The metafield associated with the resource. + """ + metafield( + """ + Container for a set of metafields (maximum of 20 characters). + """ + namespace: String! + + """ + Identifier for the metafield (maximum of 30 characters). + """ + key: String! + ): Metafield + + """ + A paginated list of metafields associated with the resource. + """ + metafields( + """ + Container for a set of metafields (maximum of 20 characters). + """ + namespace: String + + """ + Returns up to the first `n` elements from the list. + """ + first: Int + + """ + Returns the elements that come after the specified cursor. + """ + after: String + + """ + Returns up to the last `n` elements from the list. + """ + last: Int + + """ + Returns the elements that come before the specified cursor. + """ + before: String + + """ + Reverse the order of the underlying list. + """ + reverse: Boolean = false + ): MetafieldConnection! + + """ + The online store URL for the product. + A value of `null` indicates that the product is not published to the Online Store sales channel. + """ + onlineStoreUrl: URL + + """ + List of product options. + """ + options( + """ + Truncate the array result to this size. + """ + first: Int + ): [ProductOption!]! + + """ + List of price ranges in the presentment currencies for this shop. + """ + presentmentPriceRanges( + """ + Specifies the presentment currencies to return a price range in. + """ + presentmentCurrencies: [CurrencyCode!] + + """ + Returns up to the first `n` elements from the list. + """ + first: Int + + """ + Returns the elements that come after the specified cursor. + """ + after: String + + """ + Returns up to the last `n` elements from the list. + """ + last: Int + + """ + Returns the elements that come before the specified cursor. + """ + before: String + + """ + Reverse the order of the underlying list. + """ + reverse: Boolean = false + ): ProductPriceRangeConnection! + + """ + The price range. + """ + priceRange: ProductPriceRange! + + """ + A categorization that a product can be tagged with, commonly used for filtering and searching. + """ + productType: String! + + """ + The date and time when the product was published to the channel. + """ + publishedAt: DateTime! + + """ + The product's SEO information. + """ + seo: SEO! + + """ + A comma separated list of tags that have been added to the product. + Additional access scope required for private apps: unauthenticated_read_product_tags. + """ + tags: [String!]! + + """ + The product’s title. + """ + title: String! + + """ + The total quantity of inventory in stock for this Product. + """ + totalInventory: Int + + """ + The date and time when the product was last modified. + A product's `updatedAt` value can change for different reasons. For example, if an order + is placed for a product that has inventory tracking set up, then the inventory adjustment + is counted as an update. + """ + updatedAt: DateTime! + + """ + Find a product’s variant based on its selected options. + This is useful for converting a user’s selection of product options into a single matching variant. + If there is not a variant for the selected options, `null` will be returned. + """ + variantBySelectedOptions( + """ + The input fields used for a selected option. + """ + selectedOptions: [SelectedOptionInput!]! + ): ProductVariant + + """ + List of the product’s variants. + """ + variants( + """ + Returns up to the first `n` elements from the list. + """ + first: Int + + """ + Returns the elements that come after the specified cursor. + """ + after: String + + """ + Returns up to the last `n` elements from the list. + """ + last: Int + + """ + Returns the elements that come before the specified cursor. + """ + before: String + + """ + Reverse the order of the underlying list. + """ + reverse: Boolean = false + + """ + Sort the underlying list by the given key. + """ + sortKey: ProductVariantSortKeys = POSITION + ): ProductVariantConnection! + + """ + The product’s vendor name. + """ + vendor: String! +} + +""" +The set of valid sort keys for the ProductCollection query. +""" +enum ProductCollectionSortKeys { + """ + Sort by the `title` value. + """ + TITLE + + """ + Sort by the `price` value. + """ + PRICE + + """ + Sort by the `best-selling` value. + """ + BEST_SELLING + + """ + Sort by the `created` value. + """ + CREATED + + """ + Sort by the `id` value. + """ + ID + + """ + Sort by the `manual` value. + """ + MANUAL + + """ + Sort by the `collection-default` value. + """ + COLLECTION_DEFAULT + + """ + During a search (i.e. when the `query` parameter has been specified on the connection) this sorts the + results by relevance to the search term(s). When no search query is specified, this sort key is not + deterministic and should not be used. + """ + RELEVANCE +} + +""" +An auto-generated type for paginating through multiple Products. +""" +type ProductConnection { + """ + A list of edges. + """ + edges: [ProductEdge!]! + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An auto-generated type which holds one Product and a cursor during pagination. +""" +type ProductEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of ProductEdge. + """ + node: Product! +} + +""" +The set of valid sort keys for the ProductImage query. +""" +enum ProductImageSortKeys { + """ + Sort by the `created_at` value. + """ + CREATED_AT + + """ + Sort by the `position` value. + """ + POSITION + + """ + Sort by the `id` value. + """ + ID + + """ + During a search (i.e. when the `query` parameter has been specified on the connection) this sorts the + results by relevance to the search term(s). When no search query is specified, this sort key is not + deterministic and should not be used. + """ + RELEVANCE +} + +""" +The set of valid sort keys for the ProductMedia query. +""" +enum ProductMediaSortKeys { + """ + Sort by the `position` value. + """ + POSITION + + """ + Sort by the `id` value. + """ + ID + + """ + During a search (i.e. when the `query` parameter has been specified on the connection) this sorts the + results by relevance to the search term(s). When no search query is specified, this sort key is not + deterministic and should not be used. + """ + RELEVANCE +} + +""" +Product property names like "Size", "Color", and "Material" that the customers can select. +Variants are selected based on permutations of these options. +255 characters limit each. +""" +type ProductOption implements Node { + """ + Globally unique identifier. + """ + id: ID! + + """ + The product option’s name. + """ + name: String! + + """ + The corresponding value to the product option name. + """ + values: [String!]! +} + +""" +The price range of the product. +""" +type ProductPriceRange { + """ + The highest variant's price. + """ + maxVariantPrice: MoneyV2! + + """ + The lowest variant's price. + """ + minVariantPrice: MoneyV2! +} + +""" +An auto-generated type for paginating through multiple ProductPriceRanges. +""" +type ProductPriceRangeConnection { + """ + A list of edges. + """ + edges: [ProductPriceRangeEdge!]! + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An auto-generated type which holds one ProductPriceRange and a cursor during pagination. +""" +type ProductPriceRangeEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of ProductPriceRangeEdge. + """ + node: ProductPriceRange! +} + +""" +The set of valid sort keys for the Product query. +""" +enum ProductSortKeys { + """ + Sort by the `title` value. + """ + TITLE + + """ + Sort by the `product_type` value. + """ + PRODUCT_TYPE + + """ + Sort by the `vendor` value. + """ + VENDOR + + """ + Sort by the `updated_at` value. + """ + UPDATED_AT + + """ + Sort by the `created_at` value. + """ + CREATED_AT + + """ + Sort by the `best_selling` value. + """ + BEST_SELLING + + """ + Sort by the `price` value. + """ + PRICE + + """ + Sort by the `id` value. + """ + ID + + """ + During a search (i.e. when the `query` parameter has been specified on the connection) this sorts the + results by relevance to the search term(s). When no search query is specified, this sort key is not + deterministic and should not be used. + """ + RELEVANCE +} + +""" +A product variant represents a different version of a product, such as differing sizes or differing colors. +""" +type ProductVariant implements Node & HasMetafields { + """ + Indicates if the product variant is in stock. + """ + available: Boolean @deprecated(reason: "Use `availableForSale` instead") + + """ + Indicates if the product variant is available for sale. + """ + availableForSale: Boolean! + + """ + The compare at price of the variant. This can be used to mark a variant as on sale, when `compareAtPrice` is higher than `price`. + """ + compareAtPrice: Money @deprecated(reason: "Use `compareAtPriceV2` instead") + + """ + The compare at price of the variant. This can be used to mark a variant as on sale, when `compareAtPriceV2` is higher than `priceV2`. + """ + compareAtPriceV2: MoneyV2 + + """ + Whether a product is out of stock but still available for purchase (used for backorders). + """ + currentlyNotInStock: Boolean! + + """ + Globally unique identifier. + """ + id: ID! + + """ + Image associated with the product variant. This field falls back to the product image if no image is available. + """ + image( + """ + Image width in pixels between 1 and 2048. This argument is deprecated: Use `maxWidth` on `Image.transformedSrc` instead. + """ + maxWidth: Int + + """ + Image height in pixels between 1 and 2048. This argument is deprecated: Use `maxHeight` on `Image.transformedSrc` instead. + """ + maxHeight: Int + + """ + Crops the image according to the specified region. This argument is deprecated: Use `crop` on `Image.transformedSrc` instead. + """ + crop: CropRegion + + """ + Image size multiplier for high-resolution retina displays. Must be between 1 and 3. This argument is deprecated: Use `scale` on `Image.transformedSrc` instead. + """ + scale: Int = 1 + ): Image + + """ + The metafield associated with the resource. + """ + metafield( + """ + Container for a set of metafields (maximum of 20 characters). + """ + namespace: String! + + """ + Identifier for the metafield (maximum of 30 characters). + """ + key: String! + ): Metafield + + """ + A paginated list of metafields associated with the resource. + """ + metafields( + """ + Container for a set of metafields (maximum of 20 characters). + """ + namespace: String + + """ + Returns up to the first `n` elements from the list. + """ + first: Int + + """ + Returns the elements that come after the specified cursor. + """ + after: String + + """ + Returns up to the last `n` elements from the list. + """ + last: Int + + """ + Returns the elements that come before the specified cursor. + """ + before: String + + """ + Reverse the order of the underlying list. + """ + reverse: Boolean = false + ): MetafieldConnection! + + """ + List of prices and compare-at prices in the presentment currencies for this shop. + """ + presentmentPrices( + """ + The presentment currencies prices should return in. + """ + presentmentCurrencies: [CurrencyCode!] + + """ + Returns up to the first `n` elements from the list. + """ + first: Int + + """ + Returns the elements that come after the specified cursor. + """ + after: String + + """ + Returns up to the last `n` elements from the list. + """ + last: Int + + """ + Returns the elements that come before the specified cursor. + """ + before: String + + """ + Reverse the order of the underlying list. + """ + reverse: Boolean = false + ): ProductVariantPricePairConnection! + + """ + List of unit prices in the presentment currencies for this shop. + """ + presentmentUnitPrices( + """ + Specify the currencies in which to return presentment unit prices. + """ + presentmentCurrencies: [CurrencyCode!] + + """ + Returns up to the first `n` elements from the list. + """ + first: Int + + """ + Returns the elements that come after the specified cursor. + """ + after: String + + """ + Returns up to the last `n` elements from the list. + """ + last: Int + + """ + Returns the elements that come before the specified cursor. + """ + before: String + + """ + Reverse the order of the underlying list. + """ + reverse: Boolean = false + ): MoneyV2Connection! + + """ + The product variant’s price. + """ + price: Money! @deprecated(reason: "Use `priceV2` instead") + + """ + The product variant’s price. + """ + priceV2: MoneyV2! + + """ + The product object that the product variant belongs to. + """ + product: Product! + + """ + The total sellable quantity of the variant for online sales channels. + """ + quantityAvailable: Int + + """ + Whether a customer needs to provide a shipping address when placing an order for the product variant. + """ + requiresShipping: Boolean! + + """ + List of product options applied to the variant. + """ + selectedOptions: [SelectedOption!]! + + """ + The SKU (stock keeping unit) associated with the variant. + """ + sku: String + + """ + The product variant’s title. + """ + title: String! + + """ + The unit price value for the variant based on the variant's measurement. + """ + unitPrice: MoneyV2 + + """ + The unit price measurement for the variant. + """ + unitPriceMeasurement: UnitPriceMeasurement + + """ + The weight of the product variant in the unit system specified with `weight_unit`. + """ + weight: Float + + """ + Unit of measurement for weight. + """ + weightUnit: WeightUnit! +} + +""" +An auto-generated type for paginating through multiple ProductVariants. +""" +type ProductVariantConnection { + """ + A list of edges. + """ + edges: [ProductVariantEdge!]! + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An auto-generated type which holds one ProductVariant and a cursor during pagination. +""" +type ProductVariantEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of ProductVariantEdge. + """ + node: ProductVariant! +} + +""" +The compare-at price and price of a variant sharing a currency. +""" +type ProductVariantPricePair { + """ + The compare-at price of the variant with associated currency. + """ + compareAtPrice: MoneyV2 + + """ + The price of the variant with associated currency. + """ + price: MoneyV2! +} + +""" +An auto-generated type for paginating through multiple ProductVariantPricePairs. +""" +type ProductVariantPricePairConnection { + """ + A list of edges. + """ + edges: [ProductVariantPricePairEdge!]! + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An auto-generated type which holds one ProductVariantPricePair and a cursor during pagination. +""" +type ProductVariantPricePairEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of ProductVariantPricePairEdge. + """ + node: ProductVariantPricePair! +} + +""" +The set of valid sort keys for the ProductVariant query. +""" +enum ProductVariantSortKeys { + """ + Sort by the `title` value. + """ + TITLE + + """ + Sort by the `sku` value. + """ + SKU + + """ + Sort by the `position` value. + """ + POSITION + + """ + Sort by the `id` value. + """ + ID + + """ + During a search (i.e. when the `query` parameter has been specified on the connection) this sorts the + results by relevance to the search term(s). When no search query is specified, this sort key is not + deterministic and should not be used. + """ + RELEVANCE +} + +""" +The schema’s entry-point for queries. This acts as the public, top-level API from which all queries must start. +""" +type QueryRoot { + """ + List of the shop's articles. + """ + articles( + """ + Returns up to the first `n` elements from the list. + """ + first: Int + + """ + Returns the elements that come after the specified cursor. + """ + after: String + + """ + Returns up to the last `n` elements from the list. + """ + last: Int + + """ + Returns the elements that come before the specified cursor. + """ + before: String + + """ + Reverse the order of the underlying list. + """ + reverse: Boolean = false + + """ + Sort the underlying list by the given key. + """ + sortKey: ArticleSortKeys = ID + + """ + Supported filter parameters: + - `author` + - `blog_title` + - `created_at` + - `tag` + - `updated_at` + + See the detailed [search syntax](https://help.shopify.com/api/getting-started/search-syntax) + for more information about using filters. + """ + query: String + ): ArticleConnection! + + """ + Find a blog by its handle. + """ + blogByHandle( + """ + The handle of the blog. + """ + handle: String! + ): Blog + + """ + List of the shop's blogs. + """ + blogs( + """ + Returns up to the first `n` elements from the list. + """ + first: Int + + """ + Returns the elements that come after the specified cursor. + """ + after: String + + """ + Returns up to the last `n` elements from the list. + """ + last: Int + + """ + Returns the elements that come before the specified cursor. + """ + before: String + + """ + Reverse the order of the underlying list. + """ + reverse: Boolean = false + + """ + Sort the underlying list by the given key. + """ + sortKey: BlogSortKeys = ID + + """ + Supported filter parameters: + - `created_at` + - `handle` + - `title` + - `updated_at` + + See the detailed [search syntax](https://help.shopify.com/api/getting-started/search-syntax) + for more information about using filters. + """ + query: String + ): BlogConnection! + + """ + Find a collection by its handle. + """ + collectionByHandle( + """ + The handle of the collection. + """ + handle: String! + ): Collection + + """ + List of the shop’s collections. + """ + collections( + """ + Returns up to the first `n` elements from the list. + """ + first: Int + + """ + Returns the elements that come after the specified cursor. + """ + after: String + + """ + Returns up to the last `n` elements from the list. + """ + last: Int + + """ + Returns the elements that come before the specified cursor. + """ + before: String + + """ + Reverse the order of the underlying list. + """ + reverse: Boolean = false + + """ + Sort the underlying list by the given key. + """ + sortKey: CollectionSortKeys = ID + + """ + Supported filter parameters: + - `collection_type` + - `title` + - `updated_at` + + See the detailed [search syntax](https://help.shopify.com/api/getting-started/search-syntax) + for more information about using filters. + """ + query: String + ): CollectionConnection! + + """ + Find a customer by its access token. + """ + customer( + """ + The customer access token. + """ + customerAccessToken: String! + ): Customer + node( + """ + The ID of the Node to return. + """ + id: ID! + ): Node + nodes( + """ + The IDs of the Nodes to return. + """ + ids: [ID!]! + ): [Node]! + + """ + Find a page by its handle. + """ + pageByHandle( + """ + The handle of the page. + """ + handle: String! + ): Page + + """ + List of the shop's pages. + """ + pages( + """ + Returns up to the first `n` elements from the list. + """ + first: Int + + """ + Returns the elements that come after the specified cursor. + """ + after: String + + """ + Returns up to the last `n` elements from the list. + """ + last: Int + + """ + Returns the elements that come before the specified cursor. + """ + before: String + + """ + Reverse the order of the underlying list. + """ + reverse: Boolean = false + + """ + Sort the underlying list by the given key. + """ + sortKey: PageSortKeys = ID + + """ + Supported filter parameters: + - `created_at` + - `handle` + - `title` + - `updated_at` + + See the detailed [search syntax](https://help.shopify.com/api/getting-started/search-syntax) + for more information about using filters. + """ + query: String + ): PageConnection! + + """ + Find a product by its handle. + """ + productByHandle( + """ + The handle of the product. + """ + handle: String! + ): Product + + """ + Find recommended products related to a given `product_id`. + To learn more about how recommendations are generated, see + [*Showing product recommendations on product pages*](https://help.shopify.com/themes/development/recommended-products). + """ + productRecommendations( + """ + The id of the product. + """ + productId: ID! + ): [Product!] + + """ + Tags added to products. + Additional access scope required: unauthenticated_read_product_tags. + """ + productTags( + """ + Returns up to the first `n` elements from the list. + """ + first: Int! + ): StringConnection! + + """ + List of product types for the shop's products that are published to your app. + """ + productTypes( + """ + Returns up to the first `n` elements from the list. + """ + first: Int! + ): StringConnection! + + """ + List of the shop’s products. + """ + products( + """ + Returns up to the first `n` elements from the list. + """ + first: Int + + """ + Returns the elements that come after the specified cursor. + """ + after: String + + """ + Returns up to the last `n` elements from the list. + """ + last: Int + + """ + Returns the elements that come before the specified cursor. + """ + before: String + + """ + Reverse the order of the underlying list. + """ + reverse: Boolean = false + + """ + Sort the underlying list by the given key. + """ + sortKey: ProductSortKeys = ID + + """ + Supported filter parameters: + - `available_for_sale` + - `created_at` + - `product_type` + - `tag` + - `title` + - `updated_at` + - `variants.price` + - `vendor` + + See the detailed [search syntax](https://help.shopify.com/api/getting-started/search-syntax) + for more information about using filters. + """ + query: String + ): ProductConnection! + + """ + The list of public Storefront API versions, including supported, release candidate and unstable versions. + """ + publicApiVersions: [ApiVersion!]! + + """ + The shop associated with the storefront access token. + """ + shop: Shop! +} + +""" +SEO information. +""" +type SEO { + """ + The meta description. + """ + description: String + + """ + The SEO title. + """ + title: String +} + +""" +Script discount applications capture the intentions of a discount that +was created by a Shopify Script. +""" +type ScriptDiscountApplication implements DiscountApplication { + """ + The method by which the discount's value is allocated to its entitled items. + """ + allocationMethod: DiscountApplicationAllocationMethod! + + """ + The description of the application as defined by the Script. + """ + description: String! @deprecated(reason: "Use `title` instead") + + """ + Which lines of targetType that the discount is allocated over. + """ + targetSelection: DiscountApplicationTargetSelection! + + """ + The type of line that the discount is applicable towards. + """ + targetType: DiscountApplicationTargetType! + + """ + The title of the application as defined by the Script. + """ + title: String! + + """ + The value of the discount application. + """ + value: PricingValue! +} + +""" +Properties used by customers to select a product variant. +Products can have multiple options, like different sizes or colors. +""" +type SelectedOption { + """ + The product option’s name. + """ + name: String! + + """ + The product option’s value. + """ + value: String! +} + +""" +Specifies the input fields required for a selected option. +""" +input SelectedOptionInput { + """ + The product option’s name. + """ + name: String! + + """ + The product option’s value. + """ + value: String! +} + +""" +A shipping rate to be applied to a checkout. +""" +type ShippingRate { + """ + Human-readable unique identifier for this shipping rate. + """ + handle: String! + + """ + Price of this shipping rate. + """ + price: Money! @deprecated(reason: "Use `priceV2` instead") + + """ + Price of this shipping rate. + """ + priceV2: MoneyV2! + + """ + Title of this shipping rate. + """ + title: String! +} + +""" +Shop represents a collection of the general settings and information about the shop. +""" +type Shop { + """ + List of the shop' articles. + """ + articles( + """ + Returns up to the first `n` elements from the list. + """ + first: Int + + """ + Returns the elements that come after the specified cursor. + """ + after: String + + """ + Returns up to the last `n` elements from the list. + """ + last: Int + + """ + Returns the elements that come before the specified cursor. + """ + before: String + + """ + Reverse the order of the underlying list. + """ + reverse: Boolean = false + + """ + Sort the underlying list by the given key. + """ + sortKey: ArticleSortKeys = ID + + """ + Supported filter parameters: + - `author` + - `blog_title` + - `created_at` + - `tag` + - `updated_at` + + See the detailed [search syntax](https://help.shopify.com/api/getting-started/search-syntax) + for more information about using filters. + """ + query: String + ): ArticleConnection! @deprecated(reason: "Use `QueryRoot.articles` instead.") + + """ + List of the shop' blogs. + """ + blogs( + """ + Returns up to the first `n` elements from the list. + """ + first: Int + + """ + Returns the elements that come after the specified cursor. + """ + after: String + + """ + Returns up to the last `n` elements from the list. + """ + last: Int + + """ + Returns the elements that come before the specified cursor. + """ + before: String + + """ + Reverse the order of the underlying list. + """ + reverse: Boolean = false + + """ + Sort the underlying list by the given key. + """ + sortKey: BlogSortKeys = ID + + """ + Supported filter parameters: + - `created_at` + - `handle` + - `title` + - `updated_at` + + See the detailed [search syntax](https://help.shopify.com/api/getting-started/search-syntax) + for more information about using filters. + """ + query: String + ): BlogConnection! @deprecated(reason: "Use `QueryRoot.blogs` instead.") + + """ + Find a collection by its handle. + """ + collectionByHandle( + """ + The handle of the collection. + """ + handle: String! + ): Collection + @deprecated(reason: "Use `QueryRoot.collectionByHandle` instead.") + + """ + List of the shop’s collections. + """ + collections( + """ + Returns up to the first `n` elements from the list. + """ + first: Int + + """ + Returns the elements that come after the specified cursor. + """ + after: String + + """ + Returns up to the last `n` elements from the list. + """ + last: Int + + """ + Returns the elements that come before the specified cursor. + """ + before: String + + """ + Reverse the order of the underlying list. + """ + reverse: Boolean = false + + """ + Sort the underlying list by the given key. + """ + sortKey: CollectionSortKeys = ID + + """ + Supported filter parameters: + - `collection_type` + - `title` + - `updated_at` + + See the detailed [search syntax](https://help.shopify.com/api/getting-started/search-syntax) + for more information about using filters. + """ + query: String + ): CollectionConnection! + @deprecated(reason: "Use `QueryRoot.collections` instead.") + + """ + The three-letter code for the currency that the shop accepts. + """ + currencyCode: CurrencyCode! + @deprecated(reason: "Use `paymentSettings` instead") + + """ + A description of the shop. + """ + description: String + + """ + A string representing the way currency is formatted when the currency isn’t specified. + """ + moneyFormat: String! + + """ + The shop’s name. + """ + name: String! + + """ + Settings related to payments. + """ + paymentSettings: PaymentSettings! + + """ + The shop’s primary domain. + """ + primaryDomain: Domain! + + """ + The shop’s privacy policy. + """ + privacyPolicy: ShopPolicy + + """ + Find a product by its handle. + """ + productByHandle( + """ + The handle of the product. + """ + handle: String! + ): Product @deprecated(reason: "Use `QueryRoot.productByHandle` instead.") + + """ + A list of tags that have been added to products. + Additional access scope required: unauthenticated_read_product_tags. + """ + productTags( + """ + Returns up to the first `n` elements from the list. + """ + first: Int! + ): StringConnection! + @deprecated(reason: "Use `QueryRoot.productTags` instead.") + + """ + List of the shop’s product types. + """ + productTypes( + """ + Returns up to the first `n` elements from the list. + """ + first: Int! + ): StringConnection! + @deprecated(reason: "Use `QueryRoot.productTypes` instead.") + + """ + List of the shop’s products. + """ + products( + """ + Returns up to the first `n` elements from the list. + """ + first: Int + + """ + Returns the elements that come after the specified cursor. + """ + after: String + + """ + Returns up to the last `n` elements from the list. + """ + last: Int + + """ + Returns the elements that come before the specified cursor. + """ + before: String + + """ + Reverse the order of the underlying list. + """ + reverse: Boolean = false + + """ + Sort the underlying list by the given key. + """ + sortKey: ProductSortKeys = ID + + """ + Supported filter parameters: + - `available_for_sale` + - `created_at` + - `product_type` + - `tag` + - `title` + - `updated_at` + - `variants.price` + - `vendor` + + See the detailed [search syntax](https://help.shopify.com/api/getting-started/search-syntax) + for more information about using filters. + """ + query: String + ): ProductConnection! @deprecated(reason: "Use `QueryRoot.products` instead.") + + """ + The shop’s refund policy. + """ + refundPolicy: ShopPolicy + + """ + The shop’s shipping policy. + """ + shippingPolicy: ShopPolicy + + """ + Countries that the shop ships to. + """ + shipsToCountries: [CountryCode!]! + + """ + The shop’s Shopify Payments account id. + """ + shopifyPaymentsAccountId: String + @deprecated(reason: "Use `paymentSettings` instead") + + """ + The shop’s terms of service. + """ + termsOfService: ShopPolicy +} + +""" +Policy that a merchant has configured for their store, such as their refund or privacy policy. +""" +type ShopPolicy implements Node { + """ + Policy text, maximum size of 64kb. + """ + body: String! + + """ + Policy’s handle. + """ + handle: String! + + """ + Globally unique identifier. + """ + id: ID! + + """ + Policy’s title. + """ + title: String! + + """ + Public URL to the policy. + """ + url: URL! +} + +""" +An auto-generated type for paginating through multiple Strings. +""" +type StringConnection { + """ + A list of edges. + """ + edges: [StringEdge!]! + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An auto-generated type which holds one String and a cursor during pagination. +""" +type StringEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of StringEdge. + """ + node: String! +} + +""" +Specifies the fields required to complete a checkout with +a tokenized payment. +""" +input TokenizedPaymentInput { + """ + The amount of the payment. + """ + amount: Money! + + """ + A unique client generated key used to avoid duplicate charges. When a duplicate payment is found, the original is returned instead of creating a new one. + """ + idempotencyKey: String! + + """ + The billing address for the payment. + """ + billingAddress: MailingAddressInput! + + """ + The type of payment token. + """ + type: String! + + """ + A simple string or JSON containing the required payment data for the tokenized payment. + """ + paymentData: String! + + """ + Executes the payment in test mode if possible. Defaults to `false`. + """ + test: Boolean + + """ + Public Hash Key used for AndroidPay payments only. + """ + identifier: String +} + +""" +Specifies the fields required to complete a checkout with +a tokenized payment. +""" +input TokenizedPaymentInputV2 { + """ + The amount and currency of the payment. + """ + paymentAmount: MoneyInput! + + """ + A unique client generated key used to avoid duplicate charges. When a duplicate payment is found, the original is returned instead of creating a new one. + """ + idempotencyKey: String! + + """ + The billing address for the payment. + """ + billingAddress: MailingAddressInput! + + """ + A simple string or JSON containing the required payment data for the tokenized payment. + """ + paymentData: String! + + """ + Whether to execute the payment in test mode, if possible. Test mode is not supported in production stores. Defaults to `false`. + """ + test: Boolean + + """ + Public Hash Key used for AndroidPay payments only. + """ + identifier: String + + """ + The type of payment token. + """ + type: String! +} + +""" +Specifies the fields required to complete a checkout with +a tokenized payment. +""" +input TokenizedPaymentInputV3 { + """ + The amount and currency of the payment. + """ + paymentAmount: MoneyInput! + + """ + A unique client generated key used to avoid duplicate charges. When a duplicate payment is found, the original is returned instead of creating a new one. + """ + idempotencyKey: String! + + """ + The billing address for the payment. + """ + billingAddress: MailingAddressInput! + + """ + A simple string or JSON containing the required payment data for the tokenized payment. + """ + paymentData: String! + + """ + Whether to execute the payment in test mode, if possible. Test mode is not supported in production stores. Defaults to `false`. + """ + test: Boolean + + """ + Public Hash Key used for AndroidPay payments only. + """ + identifier: String + + """ + The type of payment token. + """ + type: PaymentTokenType! +} + +""" +An object representing exchange of money for a product or service. +""" +type Transaction { + """ + The amount of money that the transaction was for. + """ + amount: Money! @deprecated(reason: "Use `amountV2` instead") + + """ + The amount of money that the transaction was for. + """ + amountV2: MoneyV2! + + """ + The kind of the transaction. + """ + kind: TransactionKind! + + """ + The status of the transaction. + """ + status: TransactionStatus! @deprecated(reason: "Use `statusV2` instead") + + """ + The status of the transaction. + """ + statusV2: TransactionStatus + + """ + Whether the transaction was done in test mode or not. + """ + test: Boolean! +} + +enum TransactionKind { + SALE + CAPTURE + AUTHORIZATION + EMV_AUTHORIZATION + CHANGE +} + +enum TransactionStatus { + PENDING + SUCCESS + FAILURE + ERROR +} + +""" +An RFC 3986 and RFC 3987 compliant URI string. + +Example value: `"https://johns-apparel.myshopify.com"`. +""" +scalar URL + +""" +The measurement used to calculate a unit price for a product variant (e.g. $9.99 / 100ml). +""" +type UnitPriceMeasurement { + """ + The type of unit of measurement for the unit price measurement. + """ + measuredType: UnitPriceMeasurementMeasuredType + + """ + The quantity unit for the unit price measurement. + """ + quantityUnit: UnitPriceMeasurementMeasuredUnit + + """ + The quantity value for the unit price measurement. + """ + quantityValue: Float! + + """ + The reference unit for the unit price measurement. + """ + referenceUnit: UnitPriceMeasurementMeasuredUnit + + """ + The reference value for the unit price measurement. + """ + referenceValue: Int! +} + +""" +The accepted types of unit of measurement. +""" +enum UnitPriceMeasurementMeasuredType { + """ + Unit of measurements representing volumes. + """ + VOLUME + + """ + Unit of measurements representing weights. + """ + WEIGHT + + """ + Unit of measurements representing lengths. + """ + LENGTH + + """ + Unit of measurements representing areas. + """ + AREA +} + +""" +The valid units of measurement for a unit price measurement. +""" +enum UnitPriceMeasurementMeasuredUnit { + """ + 1000 milliliters equals 1 liter. + """ + ML + + """ + 100 centiliters equals 1 liter. + """ + CL + + """ + Metric system unit of volume. + """ + L + + """ + 1 cubic meter equals 1000 liters. + """ + M3 + + """ + 1000 milligrams equals 1 gram. + """ + MG + + """ + Metric system unit of weight. + """ + G + + """ + 1 kilogram equals 1000 grams. + """ + KG + + """ + 1000 millimeters equals 1 meter. + """ + MM + + """ + 100 centimeters equals 1 meter. + """ + CM + + """ + Metric system unit of length. + """ + M + + """ + Metric system unit of area. + """ + M2 +} + +""" +Represents an error in the input of a mutation. +""" +type UserError implements DisplayableError { + """ + Path to the input field which caused the error. + """ + field: [String!] + + """ + The error message. + """ + message: String! +} + +""" +Represents a Shopify hosted video. +""" +type Video implements Node & Media { + """ + A word or phrase to share the nature or contents of a media. + """ + alt: String + + """ + Globally unique identifier. + """ + id: ID! + + """ + The media content type. + """ + mediaContentType: MediaContentType! + + """ + The preview image for the media. + """ + previewImage: Image + + """ + The sources for a video. + """ + sources: [VideoSource!]! +} + +""" +Represents a source for a Shopify hosted video. +""" +type VideoSource { + """ + The format of the video source. + """ + format: String! + + """ + The height of the video. + """ + height: Int! + + """ + The video MIME type. + """ + mimeType: String! + + """ + The URL of the video. + """ + url: String! + + """ + The width of the video. + """ + width: Int! +} + +""" +Units of measurement for weight. +""" +enum WeightUnit { + """ + 1 kilogram equals 1000 grams. + """ + KILOGRAMS + + """ + Metric system unit of mass. + """ + GRAMS + + """ + 1 pound equals 16 ounces. + """ + POUNDS + + """ + Imperial system unit of mass. + """ + OUNCES +} diff --git a/services/frontend/packages/swell/src/api/cart/index.ts b/services/frontend/packages/swell/src/api/cart/index.ts new file mode 100644 index 00000000..ea9b101e --- /dev/null +++ b/services/frontend/packages/swell/src/api/cart/index.ts @@ -0,0 +1 @@ +export default function () {} diff --git a/services/frontend/packages/swell/src/api/catalog/index.ts b/services/frontend/packages/swell/src/api/catalog/index.ts new file mode 100644 index 00000000..ea9b101e --- /dev/null +++ b/services/frontend/packages/swell/src/api/catalog/index.ts @@ -0,0 +1 @@ +export default function () {} diff --git a/services/frontend/packages/swell/src/api/catalog/products.ts b/services/frontend/packages/swell/src/api/catalog/products.ts new file mode 100644 index 00000000..ea9b101e --- /dev/null +++ b/services/frontend/packages/swell/src/api/catalog/products.ts @@ -0,0 +1 @@ +export default function () {} diff --git a/services/frontend/packages/swell/src/api/customer.ts b/services/frontend/packages/swell/src/api/customer.ts new file mode 100644 index 00000000..ea9b101e --- /dev/null +++ b/services/frontend/packages/swell/src/api/customer.ts @@ -0,0 +1 @@ +export default function () {} diff --git a/services/frontend/packages/swell/src/api/customers/index.ts b/services/frontend/packages/swell/src/api/customers/index.ts new file mode 100644 index 00000000..ea9b101e --- /dev/null +++ b/services/frontend/packages/swell/src/api/customers/index.ts @@ -0,0 +1 @@ +export default function () {} diff --git a/services/frontend/packages/swell/src/api/customers/logout.ts b/services/frontend/packages/swell/src/api/customers/logout.ts new file mode 100644 index 00000000..ea9b101e --- /dev/null +++ b/services/frontend/packages/swell/src/api/customers/logout.ts @@ -0,0 +1 @@ +export default function () {} diff --git a/services/frontend/packages/swell/src/api/customers/signup.ts b/services/frontend/packages/swell/src/api/customers/signup.ts new file mode 100644 index 00000000..ea9b101e --- /dev/null +++ b/services/frontend/packages/swell/src/api/customers/signup.ts @@ -0,0 +1 @@ +export default function () {} diff --git a/services/frontend/packages/swell/src/api/endpoints/cart.ts b/services/frontend/packages/swell/src/api/endpoints/cart.ts new file mode 100644 index 00000000..d09c976c --- /dev/null +++ b/services/frontend/packages/swell/src/api/endpoints/cart.ts @@ -0,0 +1 @@ +export default function (_commerce: any) {} diff --git a/services/frontend/packages/swell/src/api/endpoints/catalog/products.ts b/services/frontend/packages/swell/src/api/endpoints/catalog/products.ts new file mode 100644 index 00000000..d09c976c --- /dev/null +++ b/services/frontend/packages/swell/src/api/endpoints/catalog/products.ts @@ -0,0 +1 @@ +export default function (_commerce: any) {} diff --git a/services/frontend/packages/swell/src/api/endpoints/checkout/index.ts b/services/frontend/packages/swell/src/api/endpoints/checkout/index.ts new file mode 100644 index 00000000..2695ea38 --- /dev/null +++ b/services/frontend/packages/swell/src/api/endpoints/checkout/index.ts @@ -0,0 +1,30 @@ +import { CommerceAPI, createEndpoint, GetAPISchema } from '@vercel/commerce/api' +import { CheckoutSchema } from '@vercel/commerce/types/checkout' +import { SWELL_CHECKOUT_URL_COOKIE } from '../../../const' +import checkoutEndpoint from '@vercel/commerce/api/endpoints/checkout' + +const getCheckout: CheckoutEndpoint['handlers']['getCheckout'] = async ({ + req, + res, + config, +}) => { + const { cookies } = req + const checkoutUrl = cookies[SWELL_CHECKOUT_URL_COOKIE] + + if (checkoutUrl) { + res.redirect(checkoutUrl) + } else { + res.redirect('/cart') + } +} +export const handlers: CheckoutEndpoint['handlers'] = { getCheckout } + +export type CheckoutAPI = GetAPISchema +export type CheckoutEndpoint = CheckoutAPI['endpoint'] + +const checkoutApi = createEndpoint({ + handler: checkoutEndpoint, + handlers, +}) + +export default checkoutApi diff --git a/services/frontend/packages/swell/src/api/endpoints/customer/address.ts b/services/frontend/packages/swell/src/api/endpoints/customer/address.ts new file mode 100644 index 00000000..491bf0ac --- /dev/null +++ b/services/frontend/packages/swell/src/api/endpoints/customer/address.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/services/frontend/packages/swell/src/api/endpoints/customer/card.ts b/services/frontend/packages/swell/src/api/endpoints/customer/card.ts new file mode 100644 index 00000000..491bf0ac --- /dev/null +++ b/services/frontend/packages/swell/src/api/endpoints/customer/card.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/services/frontend/packages/swell/src/api/endpoints/customer/index.ts b/services/frontend/packages/swell/src/api/endpoints/customer/index.ts new file mode 100644 index 00000000..491bf0ac --- /dev/null +++ b/services/frontend/packages/swell/src/api/endpoints/customer/index.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/services/frontend/packages/swell/src/api/endpoints/login.ts b/services/frontend/packages/swell/src/api/endpoints/login.ts new file mode 100644 index 00000000..d09c976c --- /dev/null +++ b/services/frontend/packages/swell/src/api/endpoints/login.ts @@ -0,0 +1 @@ +export default function (_commerce: any) {} diff --git a/services/frontend/packages/swell/src/api/endpoints/logout.ts b/services/frontend/packages/swell/src/api/endpoints/logout.ts new file mode 100644 index 00000000..d09c976c --- /dev/null +++ b/services/frontend/packages/swell/src/api/endpoints/logout.ts @@ -0,0 +1 @@ +export default function (_commerce: any) {} diff --git a/services/frontend/packages/swell/src/api/endpoints/signup.ts b/services/frontend/packages/swell/src/api/endpoints/signup.ts new file mode 100644 index 00000000..d09c976c --- /dev/null +++ b/services/frontend/packages/swell/src/api/endpoints/signup.ts @@ -0,0 +1 @@ +export default function (_commerce: any) {} diff --git a/services/frontend/packages/swell/src/api/endpoints/wishlist.ts b/services/frontend/packages/swell/src/api/endpoints/wishlist.ts new file mode 100644 index 00000000..d09c976c --- /dev/null +++ b/services/frontend/packages/swell/src/api/endpoints/wishlist.ts @@ -0,0 +1 @@ +export default function (_commerce: any) {} diff --git a/services/frontend/packages/swell/src/api/index.ts b/services/frontend/packages/swell/src/api/index.ts new file mode 100644 index 00000000..589acae4 --- /dev/null +++ b/services/frontend/packages/swell/src/api/index.ts @@ -0,0 +1,53 @@ +import { + CommerceAPI, + CommerceAPIConfig, + getCommerceApi as commerceApi, +} from '@vercel/commerce/api' +import { + SWELL_CHECKOUT_ID_COOKIE, + SWELL_CUSTOMER_TOKEN_COOKIE, + SWELL_COOKIE_EXPIRE, +} from '../const' + +import fetchApi from './utils/fetch-swell-api' +import login from './operations/login' +import getAllPages from './operations/get-all-pages' +import getPage from './operations/get-page' +import getSiteInfo from './operations/get-site-info' +import getAllProductPaths from './operations/get-all-product-paths' +import getAllProducts from './operations/get-all-products' +import getProduct from './operations/get-product' + +export interface SwellConfig extends CommerceAPIConfig { + fetch: any +} + +const config: SwellConfig = { + locale: 'en-US', + commerceUrl: '', + apiToken: ''!, + cartCookie: SWELL_CHECKOUT_ID_COOKIE, + cartCookieMaxAge: SWELL_COOKIE_EXPIRE, + fetch: fetchApi, + customerCookie: SWELL_CUSTOMER_TOKEN_COOKIE, +} + +const operations = { + login, + getAllPages, + getPage, + getSiteInfo, + getAllProductPaths, + getAllProducts, + getProduct, +} + +export const provider = { config, operations } + +export type Provider = typeof provider + +export function getCommerceApi

( + customProvider: P = provider as any +): CommerceAPI

{ + return commerceApi(customProvider) +} diff --git a/services/frontend/packages/swell/src/api/operations/get-all-pages.ts b/services/frontend/packages/swell/src/api/operations/get-all-pages.ts new file mode 100644 index 00000000..7097d685 --- /dev/null +++ b/services/frontend/packages/swell/src/api/operations/get-all-pages.ts @@ -0,0 +1,44 @@ +import { Provider, SwellConfig } from '..' +import type { OperationContext } from '@vercel/commerce/api/operations' +import type { Page } from '../../types/page' + +export type GetAllPagesResult = + T + +export default function getAllPagesOperation({ + commerce, +}: OperationContext) { + async function getAllPages(opts?: { + config?: Partial + preview?: boolean + }): Promise + + async function getAllPages(opts: { + url: string + config?: Partial + preview?: boolean + }): Promise> + + async function getAllPages({ + config: cfg, + preview, + }: { + url?: string + config?: Partial + preview?: boolean + } = {}): Promise { + const config = commerce.getConfig(cfg) + const { locale, fetch } = config + const data = await fetch('content', 'list', ['pages']) + const pages = + data?.results?.map(({ slug, ...rest }: { slug: string }) => ({ + url: `/${locale}/${slug}`, + ...rest, + })) ?? [] + return { + pages, + } + } + + return getAllPages +} diff --git a/services/frontend/packages/swell/src/api/operations/get-all-product-paths.ts b/services/frontend/packages/swell/src/api/operations/get-all-product-paths.ts new file mode 100644 index 00000000..1e508860 --- /dev/null +++ b/services/frontend/packages/swell/src/api/operations/get-all-product-paths.ts @@ -0,0 +1,51 @@ +import { SwellProduct } from '../../types' +import { SwellConfig, Provider } from '..' +import { + OperationContext, + OperationOptions, +} from '@vercel/commerce/api/operations' +import { GetAllProductPathsOperation } from '@vercel/commerce/types/product' + +export default function getAllProductPathsOperation({ + commerce, +}: OperationContext) { + async function getAllProductPaths< + T extends GetAllProductPathsOperation + >(opts?: { + variables?: T['variables'] + config?: SwellConfig + }): Promise + + async function getAllProductPaths( + opts: { + variables?: T['variables'] + config?: SwellConfig + } & OperationOptions + ): Promise + + async function getAllProductPaths({ + variables, + config: cfg, + }: { + query?: string + variables?: T['variables'] + config?: SwellConfig + } = {}): Promise { + const config = commerce.getConfig(cfg) + // RecursivePartial forces the method to check for every prop in the data, which is + // required in case there's a custom `query` + const { results } = await config.fetch('products', 'list', [ + { + limit: variables?.first, + }, + ]) + + return { + products: results?.map(({ slug: handle }: SwellProduct) => ({ + path: `/${handle}`, + })), + } + } + + return getAllProductPaths +} diff --git a/services/frontend/packages/swell/src/api/operations/get-all-products.ts b/services/frontend/packages/swell/src/api/operations/get-all-products.ts new file mode 100644 index 00000000..74be320a --- /dev/null +++ b/services/frontend/packages/swell/src/api/operations/get-all-products.ts @@ -0,0 +1,43 @@ +import { normalizeProduct } from '../../utils/normalize' +import { SwellProduct } from '../../types' +import { Product } from '@vercel/commerce/types/product' +import { Provider, SwellConfig } from '..' +import { OperationContext } from '@vercel/commerce/api/operations' + +export type ProductVariables = { first?: number } + +export default function getAllProductsOperation({ + commerce, +}: OperationContext) { + async function getAllProducts(opts?: { + variables?: ProductVariables + config?: Partial + preview?: boolean + }): Promise<{ products: Product[] }> + + async function getAllProducts({ + config: cfg, + variables = { first: 250 }, + }: { + query?: string + variables?: ProductVariables + config?: Partial + preview?: boolean + } = {}): Promise<{ products: Product[] | any[] }> { + const config = commerce.getConfig(cfg) + const { results } = await config.fetch('products', 'list', [ + { + limit: variables.first, + }, + ]) + const products = results.map((product: SwellProduct) => + normalizeProduct(product) + ) + + return { + products, + } + } + + return getAllProducts +} diff --git a/services/frontend/packages/swell/src/api/operations/get-page.ts b/services/frontend/packages/swell/src/api/operations/get-page.ts new file mode 100644 index 00000000..7e71f757 --- /dev/null +++ b/services/frontend/packages/swell/src/api/operations/get-page.ts @@ -0,0 +1,57 @@ +import { Page } from '../../../schema' +import { SwellConfig, Provider } from '..' +import { + OperationContext, + OperationOptions, +} from '@vercel/commerce/api/operations' +import { GetPageOperation } from '../../types/page' + +export type GetPageResult = T + +export type PageVariables = { + id: number +} + +export default function getPageOperation({ + commerce, +}: OperationContext) { + async function getPage(opts: { + variables: T['variables'] + config?: Partial + preview?: boolean + }): Promise + + async function getPage( + opts: { + variables: T['variables'] + config?: Partial + preview?: boolean + } & OperationOptions + ): Promise + + async function getPage({ + variables, + config, + }: { + query?: string + variables: T['variables'] + config?: Partial + preview?: boolean + }): Promise { + const { fetch, locale = 'en-US' } = commerce.getConfig(config) + const id = variables.id + const result = await fetch('content', 'get', ['pages', id]) + const page = result + + return { + page: page + ? { + ...page, + url: `/${locale}/${page.slug}`, + } + : null, + } + } + + return getPage +} diff --git a/services/frontend/packages/swell/src/api/operations/get-product.ts b/services/frontend/packages/swell/src/api/operations/get-product.ts new file mode 100644 index 00000000..081524dd --- /dev/null +++ b/services/frontend/packages/swell/src/api/operations/get-product.ts @@ -0,0 +1,33 @@ +import { normalizeProduct } from '../../utils' + +import { Product } from '@vercel/commerce/types/product' +import { OperationContext } from '@vercel/commerce/api/operations' +import { Provider, SwellConfig } from '..' + +export default function getProductOperation({ + commerce, +}: OperationContext) { + async function getProduct({ + variables, + config: cfg, + }: { + query?: string + variables: { slug: string } + config?: Partial + preview?: boolean + }): Promise { + const config = commerce.getConfig(cfg) + + const product = await config.fetch('products', 'get', [variables.slug]) + + if (product && product.variants) { + product.variants = product.variants?.results + } + + return { + product: product ? normalizeProduct(product) : null, + } + } + + return getProduct +} diff --git a/services/frontend/packages/swell/src/api/operations/get-site-info.ts b/services/frontend/packages/swell/src/api/operations/get-site-info.ts new file mode 100644 index 00000000..470b07c6 --- /dev/null +++ b/services/frontend/packages/swell/src/api/operations/get-site-info.ts @@ -0,0 +1,37 @@ +import getCategories from '../../utils/get-categories' +import getVendors, { Brands } from '../../utils/get-vendors' +import { Provider, SwellConfig } from '..' +import type { OperationContext } from '@vercel/commerce/api/operations' +import type { Category } from '@vercel/commerce/types/site' + +export type GetSiteInfoResult< + T extends { categories: any[]; brands: any[] } = { + categories: Category[] + brands: Brands + } +> = T + +export default function getSiteInfoOperation({ + commerce, +}: OperationContext) { + async function getSiteInfo({ + variables, + config: cfg, + }: { + query?: string + variables?: any + config?: Partial + preview?: boolean + } = {}): Promise { + const config = commerce.getConfig(cfg) + const categories = await getCategories(config) + const brands = await getVendors(config) + + return { + categories, + brands, + } + } + + return getSiteInfo +} diff --git a/services/frontend/packages/swell/src/api/operations/login.ts b/services/frontend/packages/swell/src/api/operations/login.ts new file mode 100644 index 00000000..44ff37a5 --- /dev/null +++ b/services/frontend/packages/swell/src/api/operations/login.ts @@ -0,0 +1,46 @@ +import type { ServerResponse } from 'http' +import type { + OperationContext, + OperationOptions, +} from '@vercel/commerce/api/operations' +import type { LoginOperation } from '../../types/login' +import { Provider, SwellConfig } from '..' + +export default function loginOperation({ + commerce, +}: OperationContext) { + async function login(opts: { + variables: T['variables'] + config?: Partial + res: ServerResponse + }): Promise + + async function login( + opts: { + variables: T['variables'] + config?: Partial + res: ServerResponse + } & OperationOptions + ): Promise + + async function login({ + variables, + res: response, + config: cfg, + }: { + query?: string + variables: T['variables'] + res: ServerResponse + config?: Partial + }): Promise { + const config = commerce.getConfig(cfg) + + const { data } = await config.fetch('account', 'login', [variables]) + + return { + result: data, + } + } + + return login +} diff --git a/services/frontend/packages/swell/src/api/utils/fetch-swell-api.ts b/services/frontend/packages/swell/src/api/utils/fetch-swell-api.ts new file mode 100644 index 00000000..83055c5c --- /dev/null +++ b/services/frontend/packages/swell/src/api/utils/fetch-swell-api.ts @@ -0,0 +1,7 @@ +import swell from '../../swell' + +const fetchApi = async (query: string, method: string, variables: [] = []) => { + return swell[query][method](...variables) +} + +export default fetchApi diff --git a/services/frontend/packages/swell/src/api/utils/fetch.ts b/services/frontend/packages/swell/src/api/utils/fetch.ts new file mode 100644 index 00000000..0b836710 --- /dev/null +++ b/services/frontend/packages/swell/src/api/utils/fetch.ts @@ -0,0 +1,2 @@ +import zeitFetch from '@vercel/fetch' +export default zeitFetch() diff --git a/services/frontend/packages/swell/src/api/utils/is-allowed-method.ts b/services/frontend/packages/swell/src/api/utils/is-allowed-method.ts new file mode 100644 index 00000000..78bbba56 --- /dev/null +++ b/services/frontend/packages/swell/src/api/utils/is-allowed-method.ts @@ -0,0 +1,28 @@ +import type { NextApiRequest, NextApiResponse } from 'next' + +export default function isAllowedMethod( + req: NextApiRequest, + res: NextApiResponse, + allowedMethods: string[] +) { + const methods = allowedMethods.includes('OPTIONS') + ? allowedMethods + : [...allowedMethods, 'OPTIONS'] + + if (!req.method || !methods.includes(req.method)) { + res.status(405) + res.setHeader('Allow', methods.join(', ')) + res.end() + return false + } + + if (req.method === 'OPTIONS') { + res.status(200) + res.setHeader('Allow', methods.join(', ')) + res.setHeader('Content-Length', '0') + res.end() + return false + } + + return true +} diff --git a/services/frontend/packages/swell/src/api/wishlist/index.tsx b/services/frontend/packages/swell/src/api/wishlist/index.tsx new file mode 100644 index 00000000..a7285667 --- /dev/null +++ b/services/frontend/packages/swell/src/api/wishlist/index.tsx @@ -0,0 +1,2 @@ +export type WishlistItem = { product: any; id: number } +export default function () {} diff --git a/services/frontend/packages/swell/src/auth/use-login.tsx b/services/frontend/packages/swell/src/auth/use-login.tsx new file mode 100644 index 00000000..b3fe9f52 --- /dev/null +++ b/services/frontend/packages/swell/src/auth/use-login.tsx @@ -0,0 +1,76 @@ +import { useCallback } from 'react' +import type { MutationHook } from '@vercel/commerce/utils/types' +import { CommerceError, ValidationError } from '@vercel/commerce/utils/errors' +import useCustomer from '../customer/use-customer' +import { + CustomerUserError, + Mutation, + MutationCheckoutCreateArgs, +} from '../../schema' +import useLogin, { UseLogin } from '@vercel/commerce/auth/use-login' +import { LoginHook } from '../types/login' +import { setCustomerToken } from '../utils' + +export default useLogin as UseLogin + +const getErrorMessage = ({ code, message }: CustomerUserError) => { + switch (code) { + case 'UNIDENTIFIED_CUSTOMER': + message = 'Cannot find an account that matches the provided credentials' + break + } + return message +} + +export const handler: MutationHook = { + fetchOptions: { + query: 'account', + method: 'login', + }, + async fetcher({ input: { email, password }, options, fetch }) { + if (!(email && password)) { + throw new CommerceError({ + message: + 'A first name, last name, email and password are required to login', + }) + } + + const { customerAccessTokenCreate } = await fetch< + Mutation, + MutationCheckoutCreateArgs + >({ + ...options, + variables: [email, password], + }) + + const errors = customerAccessTokenCreate?.customerUserErrors + + if (errors && errors.length) { + throw new ValidationError({ + message: getErrorMessage(errors[0]), + }) + } + const customerAccessToken = customerAccessTokenCreate?.customerAccessToken + const accessToken = customerAccessToken?.accessToken + + if (accessToken) { + setCustomerToken(accessToken) + } + + return null + }, + useHook: + ({ fetch }) => + () => { + const { mutate } = useCustomer() + + return useCallback( + async function login(input) { + const data = await fetch({ input }) + await mutate() + return data + }, + [fetch, mutate] + ) + }, +} diff --git a/services/frontend/packages/swell/src/auth/use-logout.tsx b/services/frontend/packages/swell/src/auth/use-logout.tsx new file mode 100644 index 00000000..08eec0ba --- /dev/null +++ b/services/frontend/packages/swell/src/auth/use-logout.tsx @@ -0,0 +1,39 @@ +import { useCallback } from 'react' +import type { MutationHook } from '@vercel/commerce/utils/types' +import useLogout, { UseLogout } from '@vercel/commerce/auth/use-logout' +import useCustomer from '../customer/use-customer' +import { getCustomerToken, setCustomerToken } from '../utils/customer-token' +import { LogoutHook } from '../types/logout' + +export default useLogout as UseLogout + +export const handler: MutationHook = { + fetchOptions: { + query: 'account', + method: 'logout', + }, + async fetcher({ options, fetch }) { + await fetch({ + ...options, + variables: { + customerAccessToken: getCustomerToken(), + }, + }) + setCustomerToken(null) + return null + }, + useHook: + ({ fetch }) => + () => { + const { mutate } = useCustomer() + + return useCallback( + async function logout() { + const data = await fetch() + await mutate(null, false) + return data + }, + [fetch, mutate] + ) + }, +} diff --git a/services/frontend/packages/swell/src/auth/use-signup.tsx b/services/frontend/packages/swell/src/auth/use-signup.tsx new file mode 100644 index 00000000..581312db --- /dev/null +++ b/services/frontend/packages/swell/src/auth/use-signup.tsx @@ -0,0 +1,61 @@ +import { useCallback } from 'react' +import type { MutationHook } from '@vercel/commerce/utils/types' +import { CommerceError } from '@vercel/commerce/utils/errors' +import useSignup, { UseSignup } from '@vercel/commerce/auth/use-signup' +import useCustomer from '../customer/use-customer' +import { SignupHook } from '../types/signup' +import handleLogin from '../utils/handle-login' + +export default useSignup as UseSignup + +export const handler: MutationHook = { + fetchOptions: { + query: 'account', + method: 'create', + }, + async fetcher({ + input: { firstName, lastName, email, password }, + options, + fetch, + }) { + if (!(firstName && lastName && email && password)) { + throw new CommerceError({ + message: + 'A first name, last name, email and password are required to signup', + }) + } + const data = await fetch({ + ...options, + variables: { + first_name: firstName, + last_name: lastName, + email, + password, + }, + }) + + try { + const loginData = await fetch({ + query: 'account', + method: 'login', + variables: [email, password], + }) + handleLogin(loginData) + } catch (error) {} + return data + }, + useHook: + ({ fetch }) => + () => { + const { mutate } = useCustomer() + + return useCallback( + async function signup(input) { + const data = await fetch({ input }) + await mutate() + return data + }, + [fetch, mutate] + ) + }, +} diff --git a/services/frontend/packages/swell/src/cart/index.ts b/services/frontend/packages/swell/src/cart/index.ts new file mode 100644 index 00000000..3d288b1d --- /dev/null +++ b/services/frontend/packages/swell/src/cart/index.ts @@ -0,0 +1,3 @@ +export { default as useCart } from './use-cart' +export { default as useAddItem } from './use-add-item' +export { default as useRemoveItem } from './use-remove-item' diff --git a/services/frontend/packages/swell/src/cart/use-add-item.tsx b/services/frontend/packages/swell/src/cart/use-add-item.tsx new file mode 100644 index 00000000..048a1f45 --- /dev/null +++ b/services/frontend/packages/swell/src/cart/use-add-item.tsx @@ -0,0 +1,61 @@ +import type { MutationHook } from '@vercel/commerce/utils/types' +import { CommerceError } from '@vercel/commerce/utils/errors' +import useAddItem, { UseAddItem } from '@vercel/commerce/cart/use-add-item' +import useCart from './use-cart' +import { checkoutToCart } from './utils' +import { getCheckoutId } from '../utils' +import { useCallback } from 'react' +import { AddItemHook } from '../types/cart' + +export default useAddItem as UseAddItem + +export const handler: MutationHook = { + fetchOptions: { + query: 'cart', + method: 'addItem', + }, + async fetcher({ input: item, options, fetch }) { + if ( + item.quantity && + (!Number.isInteger(item.quantity) || item.quantity! < 1) + ) { + throw new CommerceError({ + message: 'The item quantity has to be a valid integer greater than 0', + }) + } + const variables: { + product_id: string | undefined + variant_id?: string + checkoutId?: string + quantity?: number + } = { + checkoutId: getCheckoutId(), + product_id: item.productId, + quantity: item.quantity, + } + if (item.productId !== item.variantId) { + variables.variant_id = item.variantId + } + + const response = await fetch({ + ...options, + variables, + }) + + return checkoutToCart(response) as any + }, + useHook: + ({ fetch }) => + () => { + const { mutate } = useCart() + + return useCallback( + async function addItem(input) { + const data = await fetch({ input }) + await mutate(data, false) + return data + }, + [fetch, mutate] + ) + }, +} diff --git a/services/frontend/packages/swell/src/cart/use-cart.tsx b/services/frontend/packages/swell/src/cart/use-cart.tsx new file mode 100644 index 00000000..5842d1e9 --- /dev/null +++ b/services/frontend/packages/swell/src/cart/use-cart.tsx @@ -0,0 +1,39 @@ +import useCart, { UseCart } from '@vercel/commerce/cart/use-cart' +import { SWRHook } from '@vercel/commerce/utils/types' +import { useMemo } from 'react' +import { normalizeCart } from '../utils/normalize' +import { checkoutCreate, checkoutToCart } from './utils' +import type { GetCartHook } from '@vercel/commerce/types/cart' + +export default useCart as UseCart + +export const handler: SWRHook = { + fetchOptions: { + query: 'cart', + method: 'get', + }, + async fetcher({ fetch }) { + const cart = await checkoutCreate(fetch) + + return cart ? normalizeCart(cart) : null + }, + useHook: + ({ useData }) => + (input) => { + const response = useData({ + swrOptions: { revalidateOnFocus: false, ...input?.swrOptions }, + }) + return useMemo( + () => + Object.create(response, { + isEmpty: { + get() { + return (response.data?.lineItems.length ?? 0) <= 0 + }, + enumerable: true, + }, + }), + [response] + ) + }, +} diff --git a/services/frontend/packages/swell/src/cart/use-remove-item.tsx b/services/frontend/packages/swell/src/cart/use-remove-item.tsx new file mode 100644 index 00000000..6e511656 --- /dev/null +++ b/services/frontend/packages/swell/src/cart/use-remove-item.tsx @@ -0,0 +1,57 @@ +import { useCallback } from 'react' +import type { + MutationHookContext, + HookFetcherContext, +} from '@vercel/commerce/utils/types' + +import useRemoveItem, { + UseRemoveItem, +} from '@vercel/commerce/cart/use-remove-item' +import type { + Cart, + LineItem, + RemoveItemHook, +} from '@vercel/commerce/types/cart' +import useCart from './use-cart' +import { checkoutToCart } from './utils' + +export type RemoveItemFn = T extends LineItem + ? (input?: RemoveItemActionInput) => Promise + : (input: RemoveItemActionInput) => Promise + +export type RemoveItemActionInput = T extends LineItem + ? Partial + : RemoveItemHook['actionInput'] + +export default useRemoveItem as UseRemoveItem + +export const handler = { + fetchOptions: { + query: 'cart', + method: 'removeItem', + }, + async fetcher({ + input: { itemId }, + options, + fetch, + }: HookFetcherContext) { + const response = await fetch({ ...options, variables: [itemId] }) + + return checkoutToCart(response) + }, + useHook: + ({ fetch }: MutationHookContext) => + () => { + const { mutate } = useCart() + + return useCallback( + async function removeItem(input) { + const data = await fetch({ input: { itemId: input.id } }) + await mutate(data, false) + + return data + }, + [fetch, mutate] + ) + }, +} diff --git a/services/frontend/packages/swell/src/cart/use-update-item.tsx b/services/frontend/packages/swell/src/cart/use-update-item.tsx new file mode 100644 index 00000000..c741250c --- /dev/null +++ b/services/frontend/packages/swell/src/cart/use-update-item.tsx @@ -0,0 +1,101 @@ +import { useCallback } from 'react' +import debounce from 'lodash.debounce' +import type { + HookFetcherContext, + MutationHook, + MutationHookContext, +} from '@vercel/commerce/utils/types' +import { ValidationError } from '@vercel/commerce/utils/errors' +// import useUpdateItem, { +// UpdateItemInput as UpdateItemInputBase, +// UseUpdateItem, +// } from '@vercel/commerce/cart/use-update-item' +import useUpdateItem, { + UseUpdateItem, +} from '@vercel/commerce/cart/use-update-item' +import useCart from './use-cart' +import { handler as removeItemHandler } from './use-remove-item' +import { CartItemBody, LineItem } from '@vercel/commerce/types/cart' +import { checkoutToCart } from './utils' +import { UpdateItemHook } from '../types/cart' +// export type UpdateItemInput = T extends LineItem +// ? Partial> +// : UpdateItemInputBase + +export default useUpdateItem as UseUpdateItem + +export type UpdateItemActionInput = T extends LineItem + ? Partial + : UpdateItemHook['actionInput'] + +export const handler = { + fetchOptions: { + query: 'cart', + method: 'updateItem', + }, + async fetcher({ + input: { itemId, item }, + options, + fetch, + }: HookFetcherContext) { + if (Number.isInteger(item.quantity)) { + // Also allow the update hook to remove an item if the quantity is lower than 1 + if (item.quantity! < 1) { + return removeItemHandler.fetcher({ + options: removeItemHandler.fetchOptions, + input: { itemId }, + fetch, + }) + } + } else if (item.quantity) { + throw new ValidationError({ + message: 'The item quantity has to be a valid integer', + }) + } + const response = await fetch({ + ...options, + variables: [itemId, { quantity: item.quantity }], + }) + + return checkoutToCart(response) + }, + useHook: + ({ fetch }: MutationHookContext) => + ( + ctx: { + item?: T + wait?: number + } = {} + ) => { + const { item } = ctx + const { mutate, data: cartData } = useCart() as any + + return useCallback( + debounce(async (input: UpdateItemActionInput) => { + const firstLineItem = cartData.lineItems[0] + const itemId = item?.id || firstLineItem.id + const productId = item?.productId || firstLineItem.productId + const variantId = item?.variant.id || firstLineItem.variant.id + if (!itemId || !productId) { + throw new ValidationError({ + message: 'Invalid input used for this operation', + }) + } + + const data = await fetch({ + input: { + item: { + productId, + variantId, + quantity: input.quantity, + }, + itemId, + }, + }) + await mutate(data, false) + return data + }, ctx.wait ?? 500), + [fetch, mutate] + ) + }, +} diff --git a/services/frontend/packages/swell/src/cart/utils/checkout-create.ts b/services/frontend/packages/swell/src/cart/utils/checkout-create.ts new file mode 100644 index 00000000..a4b82d84 --- /dev/null +++ b/services/frontend/packages/swell/src/cart/utils/checkout-create.ts @@ -0,0 +1,28 @@ +import { SWELL_CHECKOUT_URL_COOKIE } from '../../const' + +import Cookies from 'js-cookie' + +export const checkoutCreate = async (fetch: any) => { + const cart = await fetch({ + query: 'cart', + method: 'get', + }) + + if (!cart) { + await fetch({ + query: 'cart', + method: 'setItems', + variables: [[]], + }) + } + + const checkoutUrl = cart?.checkout_url + + if (checkoutUrl) { + Cookies.set(SWELL_CHECKOUT_URL_COOKIE, checkoutUrl) + } + + return cart +} + +export default checkoutCreate diff --git a/services/frontend/packages/swell/src/cart/utils/checkout-to-cart.ts b/services/frontend/packages/swell/src/cart/utils/checkout-to-cart.ts new file mode 100644 index 00000000..c9becb10 --- /dev/null +++ b/services/frontend/packages/swell/src/cart/utils/checkout-to-cart.ts @@ -0,0 +1,26 @@ +import { Cart } from '../../types' +import { CommerceError } from '@vercel/commerce/utils/errors' + +import { + CheckoutLineItemsAddPayload, + CheckoutLineItemsRemovePayload, + CheckoutLineItemsUpdatePayload, + Maybe, +} from '../../../schema' +import { normalizeCart } from '../../utils' + +export type CheckoutPayload = + | CheckoutLineItemsAddPayload + | CheckoutLineItemsUpdatePayload + | CheckoutLineItemsRemovePayload + +const checkoutToCart = (checkoutPayload?: Maybe): Cart => { + if (!checkoutPayload) { + throw new CommerceError({ + message: 'Invalid response from Swell', + }) + } + return normalizeCart(checkoutPayload as any) +} + +export default checkoutToCart diff --git a/services/frontend/packages/swell/src/cart/utils/index.ts b/services/frontend/packages/swell/src/cart/utils/index.ts new file mode 100644 index 00000000..20d04955 --- /dev/null +++ b/services/frontend/packages/swell/src/cart/utils/index.ts @@ -0,0 +1,2 @@ +export { default as checkoutToCart } from './checkout-to-cart' +export { default as checkoutCreate } from './checkout-create' diff --git a/services/frontend/packages/swell/src/checkout/use-checkout.tsx b/services/frontend/packages/swell/src/checkout/use-checkout.tsx new file mode 100644 index 00000000..76997be7 --- /dev/null +++ b/services/frontend/packages/swell/src/checkout/use-checkout.tsx @@ -0,0 +1,16 @@ +import { SWRHook } from '@vercel/commerce/utils/types' +import useCheckout, { + UseCheckout, +} from '@vercel/commerce/checkout/use-checkout' + +export default useCheckout as UseCheckout + +export const handler: SWRHook = { + fetchOptions: { + query: '', + }, + async fetcher({ input, options, fetch }) {}, + useHook: + ({ useData }) => + async (input) => ({}), +} diff --git a/services/frontend/packages/swell/src/commerce.config.json b/services/frontend/packages/swell/src/commerce.config.json new file mode 100644 index 00000000..01c9bf91 --- /dev/null +++ b/services/frontend/packages/swell/src/commerce.config.json @@ -0,0 +1,6 @@ +{ + "provider": "swell", + "features": { + "wishlist": false + } +} diff --git a/services/frontend/packages/swell/src/const.ts b/services/frontend/packages/swell/src/const.ts new file mode 100644 index 00000000..16d49389 --- /dev/null +++ b/services/frontend/packages/swell/src/const.ts @@ -0,0 +1,11 @@ +export const SWELL_CHECKOUT_ID_COOKIE = 'SWELL_checkoutId' + +export const SWELL_CHECKOUT_URL_COOKIE = 'swell_checkoutUrl' + +export const SWELL_CUSTOMER_TOKEN_COOKIE = 'swell_customerToken' + +export const SWELL_COOKIE_EXPIRE = 30 + +export const SWELL_STORE_ID = process.env.NEXT_PUBLIC_SWELL_STORE_ID + +export const SWELL_PUBLIC_KEY = process.env.NEXT_PUBLIC_SWELL_PUBLIC_KEY diff --git a/services/frontend/packages/swell/src/customer/address/use-add-item.tsx b/services/frontend/packages/swell/src/customer/address/use-add-item.tsx new file mode 100644 index 00000000..4f85c847 --- /dev/null +++ b/services/frontend/packages/swell/src/customer/address/use-add-item.tsx @@ -0,0 +1,17 @@ +import useAddItem, { + UseAddItem, +} from '@vercel/commerce/customer/address/use-add-item' +import { MutationHook } from '@vercel/commerce/utils/types' + +export default useAddItem as UseAddItem + +export const handler: MutationHook = { + fetchOptions: { + query: '', + }, + async fetcher({ input, options, fetch }) {}, + useHook: + ({ fetch }) => + () => + async () => ({}), +} diff --git a/services/frontend/packages/swell/src/customer/card/use-add-item.tsx b/services/frontend/packages/swell/src/customer/card/use-add-item.tsx new file mode 100644 index 00000000..77d149ef --- /dev/null +++ b/services/frontend/packages/swell/src/customer/card/use-add-item.tsx @@ -0,0 +1,17 @@ +import useAddItem, { + UseAddItem, +} from '@vercel/commerce/customer/card/use-add-item' +import { MutationHook } from '@vercel/commerce/utils/types' + +export default useAddItem as UseAddItem + +export const handler: MutationHook = { + fetchOptions: { + query: '', + }, + async fetcher({ input, options, fetch }) {}, + useHook: + ({ fetch }) => + () => + async () => ({}), +} diff --git a/services/frontend/packages/swell/src/customer/index.ts b/services/frontend/packages/swell/src/customer/index.ts new file mode 100644 index 00000000..6c903ecc --- /dev/null +++ b/services/frontend/packages/swell/src/customer/index.ts @@ -0,0 +1 @@ +export { default as useCustomer } from './use-customer' diff --git a/services/frontend/packages/swell/src/customer/use-customer.tsx b/services/frontend/packages/swell/src/customer/use-customer.tsx new file mode 100644 index 00000000..0875310f --- /dev/null +++ b/services/frontend/packages/swell/src/customer/use-customer.tsx @@ -0,0 +1,31 @@ +import useCustomer, { + UseCustomer, +} from '@vercel/commerce/customer/use-customer' +import { SWRHook } from '@vercel/commerce/utils/types' +import { normalizeCustomer } from '../utils/normalize' +import type { CustomerHook } from '../types/customer' + +export default useCustomer as UseCustomer + +export const handler: SWRHook = { + fetchOptions: { + query: 'account', + method: 'get', + }, + async fetcher({ options, fetch }) { + const data = await fetch({ + ...options, + }) + return data ? normalizeCustomer(data) : null + }, + useHook: + ({ useData }) => + (input) => { + return useData({ + swrOptions: { + revalidateOnFocus: false, + ...input?.swrOptions, + }, + }) + }, +} diff --git a/services/frontend/packages/swell/src/fetcher.ts b/services/frontend/packages/swell/src/fetcher.ts new file mode 100644 index 00000000..1d50bf5a --- /dev/null +++ b/services/frontend/packages/swell/src/fetcher.ts @@ -0,0 +1,26 @@ +import { Fetcher } from '@vercel/commerce/utils/types' +import { CommerceError } from '@vercel/commerce/utils/errors' +import { handleFetchResponse } from './utils' +import swell from './swell' + +const fetcher: Fetcher = async ({ method = 'get', variables, query }) => { + async function callSwell() { + if (Array.isArray(variables)) { + const arg1 = variables[0] + const arg2 = variables[1] + const response = await swell[query!][method](arg1, arg2) + return handleFetchResponse(response) + } else { + const response = await swell[query!][method](variables) + return handleFetchResponse(response) + } + } + + if (query && query in swell) { + return await callSwell() + } else { + throw new CommerceError({ message: 'Invalid query argument!' }) + } +} + +export default fetcher diff --git a/services/frontend/packages/swell/src/index.tsx b/services/frontend/packages/swell/src/index.tsx new file mode 100644 index 00000000..d88d75c3 --- /dev/null +++ b/services/frontend/packages/swell/src/index.tsx @@ -0,0 +1,12 @@ +import { + getCommerceProvider, + useCommerce as useCoreCommerce, +} from '@vercel/commerce' +import { swellProvider, SwellProvider } from './provider' + +export { swellProvider } +export type { SwellProvider } + +export const CommerceProvider = getCommerceProvider(swellProvider) + +export const useCommerce = () => useCoreCommerce() diff --git a/services/frontend/packages/swell/src/next.config.cjs b/services/frontend/packages/swell/src/next.config.cjs new file mode 100644 index 00000000..f6ac3834 --- /dev/null +++ b/services/frontend/packages/swell/src/next.config.cjs @@ -0,0 +1,8 @@ +const commerce = require('./commerce.config.json') + +module.exports = { + commerce, + images: { + domains: ['cdn.schema.io'], + }, +} diff --git a/services/frontend/packages/swell/src/product/index.ts b/services/frontend/packages/swell/src/product/index.ts new file mode 100644 index 00000000..426a3edc --- /dev/null +++ b/services/frontend/packages/swell/src/product/index.ts @@ -0,0 +1,2 @@ +export { default as usePrice } from './use-price' +export { default as useSearch } from './use-search' diff --git a/services/frontend/packages/swell/src/product/use-price.tsx b/services/frontend/packages/swell/src/product/use-price.tsx new file mode 100644 index 00000000..fd42d703 --- /dev/null +++ b/services/frontend/packages/swell/src/product/use-price.tsx @@ -0,0 +1,2 @@ +export * from '@vercel/commerce/product/use-price' +export { default } from '@vercel/commerce/product/use-price' diff --git a/services/frontend/packages/swell/src/product/use-search.tsx b/services/frontend/packages/swell/src/product/use-search.tsx new file mode 100644 index 00000000..b996099a --- /dev/null +++ b/services/frontend/packages/swell/src/product/use-search.tsx @@ -0,0 +1,61 @@ +import { SWRHook } from '@vercel/commerce/utils/types' +import useSearch, { UseSearch } from '@vercel/commerce/product/use-search' +import { normalizeProduct } from '../utils' +import { SwellProduct } from '../types' +import type { SearchProductsHook } from '../types/product' + +export default useSearch as UseSearch + +export type SearchProductsInput = { + search?: string + categoryId?: string + brandId?: string + sort?: string +} + +export const handler: SWRHook = { + fetchOptions: { + query: 'products', + method: 'list', + }, + async fetcher({ input, options, fetch }) { + const sortMap = new Map([ + ['latest-desc', ''], + ['price-asc', 'price_asc'], + ['price-desc', 'price_desc'], + ['trending-desc', 'popularity'], + ]) + const { categoryId, search, sort = 'latest-desc' } = input + const mappedSort = sortMap.get(sort) + const { results, count: found } = await fetch({ + query: 'products', + method: 'list', + variables: { category: categoryId, search, sort: mappedSort }, + }) + + const products = results.map((product: SwellProduct) => + normalizeProduct(product) + ) + + return { + products, + found, + } + }, + useHook: + ({ useData }) => + (input = {}) => { + return useData({ + input: [ + ['search', input.search], + ['categoryId', input.categoryId], + ['brandId', input.brandId], + ['sort', input.sort], + ], + swrOptions: { + revalidateOnFocus: false, + ...input.swrOptions, + }, + }) + }, +} diff --git a/services/frontend/packages/swell/src/provider.ts b/services/frontend/packages/swell/src/provider.ts new file mode 100644 index 00000000..afe07dcf --- /dev/null +++ b/services/frontend/packages/swell/src/provider.ts @@ -0,0 +1,30 @@ +import { Provider } from '@vercel/commerce' +import { SWELL_CHECKOUT_ID_COOKIE } from './const' + +import { handler as useCart } from './cart/use-cart' +import { handler as useAddItem } from './cart/use-add-item' +import { handler as useUpdateItem } from './cart/use-update-item' +import { handler as useRemoveItem } from './cart/use-remove-item' + +import { handler as useCustomer } from './customer/use-customer' +import { handler as useSearch } from './product/use-search' + +import { handler as useLogin } from './auth/use-login' +import { handler as useLogout } from './auth/use-logout' +import { handler as useSignup } from './auth/use-signup' + +import fetcher from './fetcher' +import swell from './swell' + +export const swellProvider: Provider & { swell: any } = { + locale: 'en-us', + cartCookie: SWELL_CHECKOUT_ID_COOKIE, + swell, + fetcher, + cart: { useCart, useAddItem, useUpdateItem, useRemoveItem }, + customer: { useCustomer }, + products: { useSearch }, + auth: { useLogin, useLogout, useSignup }, +} + +export type SwellProvider = typeof swellProvider diff --git a/services/frontend/packages/swell/src/swell.ts b/services/frontend/packages/swell/src/swell.ts new file mode 100644 index 00000000..eb49ce55 --- /dev/null +++ b/services/frontend/packages/swell/src/swell.ts @@ -0,0 +1,7 @@ +// @ts-ignore +import swell from 'swell-js' +import { SWELL_STORE_ID, SWELL_PUBLIC_KEY } from './const' + +swell.init(SWELL_STORE_ID, SWELL_PUBLIC_KEY) + +export default swell diff --git a/services/frontend/packages/swell/src/types.ts b/services/frontend/packages/swell/src/types.ts new file mode 100644 index 00000000..b02bbd8c --- /dev/null +++ b/services/frontend/packages/swell/src/types.ts @@ -0,0 +1,112 @@ +import * as Core from '@vercel/commerce/types/cart' +import { Customer } from '@vercel/commerce/types' +import { CheckoutLineItem } from '../schema' + +export type SwellImage = { + file: { + url: String + height: Number + width: Number + } + id: string +} + +export type CartLineItem = { + id: string + product: SwellProduct + price: number + variant: { + name: string | null + sku: string | null + id: string + } + quantity: number +} + +export type SwellCart = { + id: string + account_id: number + currency: string + tax_included_total: number + sub_total: number + grand_total: number + discount_total: number + quantity: number + items: CartLineItem[] + date_created: string + discounts?: { id: number; amount: number }[] | null + // TODO: add missing fields +} + +export type SwellVariant = { + id: string + option_value_ids: string[] + name: string + price?: number + stock_status?: string + __type?: 'MultipleChoiceOption' | undefined +} + +export interface SwellProductOptionValue { + id: string + label: string + hexColors?: string[] +} + +export interface ProductOptionValue { + label: string + hexColors?: string[] +} + +export type ProductOptions = { + id: string + name: string + variant: boolean + values: ProductOptionValue[] + required: boolean + active: boolean + attribute_id: string +} + +export interface SwellProduct { + id: string + description: string + name: string + slug: string + currency: string + price: number + images: any[] + options: any[] + variants: any[] +} + +export type SwellCustomer = any + +export type SwellCheckout = { + id: string + webUrl: string + lineItems: CheckoutLineItem[] +} + +export interface Cart extends Core.Cart { + id: string + lineItems: LineItem[] +} + +export interface LineItem extends Core.LineItem { + options?: any[] +} + +/** + * Cart mutations + */ + +export type OptionSelections = { + option_id: number + option_value: number | string +} + +export type CartItemBody = Core.CartItemBody & { + productId: string // The product id is always required for BC + optionSelections?: OptionSelections +} diff --git a/services/frontend/packages/swell/src/types/cart.ts b/services/frontend/packages/swell/src/types/cart.ts new file mode 100644 index 00000000..e6838fb4 --- /dev/null +++ b/services/frontend/packages/swell/src/types/cart.ts @@ -0,0 +1 @@ +export * from '@vercel/commerce/types/cart' diff --git a/services/frontend/packages/swell/src/types/checkout.ts b/services/frontend/packages/swell/src/types/checkout.ts new file mode 100644 index 00000000..d139db68 --- /dev/null +++ b/services/frontend/packages/swell/src/types/checkout.ts @@ -0,0 +1 @@ +export * from '@vercel/commerce/types/checkout' diff --git a/services/frontend/packages/swell/src/types/common.ts b/services/frontend/packages/swell/src/types/common.ts new file mode 100644 index 00000000..23b8daa1 --- /dev/null +++ b/services/frontend/packages/swell/src/types/common.ts @@ -0,0 +1 @@ +export * from '@vercel/commerce/types/common' diff --git a/services/frontend/packages/swell/src/types/customer.ts b/services/frontend/packages/swell/src/types/customer.ts new file mode 100644 index 00000000..c637055b --- /dev/null +++ b/services/frontend/packages/swell/src/types/customer.ts @@ -0,0 +1 @@ +export * from '@vercel/commerce/types/customer' diff --git a/services/frontend/packages/swell/src/types/index.ts b/services/frontend/packages/swell/src/types/index.ts new file mode 100644 index 00000000..7ab0b7f6 --- /dev/null +++ b/services/frontend/packages/swell/src/types/index.ts @@ -0,0 +1,25 @@ +import * as Cart from './cart' +import * as Checkout from './checkout' +import * as Common from './common' +import * as Customer from './customer' +import * as Login from './login' +import * as Logout from './logout' +import * as Page from './page' +import * as Product from './product' +import * as Signup from './signup' +import * as Site from './site' +import * as Wishlist from './wishlist' + +export type { + Cart, + Checkout, + Common, + Customer, + Login, + Logout, + Page, + Product, + Signup, + Site, + Wishlist, +} diff --git a/services/frontend/packages/swell/src/types/login.ts b/services/frontend/packages/swell/src/types/login.ts new file mode 100644 index 00000000..44b017dc --- /dev/null +++ b/services/frontend/packages/swell/src/types/login.ts @@ -0,0 +1,11 @@ +import * as Core from '@vercel/commerce/types/login' +import { LoginBody, LoginTypes } from '@vercel/commerce/types/login' + +export * from '@vercel/commerce/types/login' + +export type LoginHook = { + data: null + actionInput: LoginBody + fetcherInput: LoginBody + body: T['body'] +} diff --git a/services/frontend/packages/swell/src/types/logout.ts b/services/frontend/packages/swell/src/types/logout.ts new file mode 100644 index 00000000..1de06f8d --- /dev/null +++ b/services/frontend/packages/swell/src/types/logout.ts @@ -0,0 +1 @@ +export * from '@vercel/commerce/types/logout' diff --git a/services/frontend/packages/swell/src/types/page.ts b/services/frontend/packages/swell/src/types/page.ts new file mode 100644 index 00000000..12f6b02d --- /dev/null +++ b/services/frontend/packages/swell/src/types/page.ts @@ -0,0 +1 @@ +export * from '@vercel/commerce/types/page' diff --git a/services/frontend/packages/swell/src/types/product.ts b/services/frontend/packages/swell/src/types/product.ts new file mode 100644 index 00000000..72ca02f0 --- /dev/null +++ b/services/frontend/packages/swell/src/types/product.ts @@ -0,0 +1 @@ +export * from '@vercel/commerce/types/product' diff --git a/services/frontend/packages/swell/src/types/signup.ts b/services/frontend/packages/swell/src/types/signup.ts new file mode 100644 index 00000000..3f0d1af5 --- /dev/null +++ b/services/frontend/packages/swell/src/types/signup.ts @@ -0,0 +1 @@ +export * from '@vercel/commerce/types/signup' diff --git a/services/frontend/packages/swell/src/types/site.ts b/services/frontend/packages/swell/src/types/site.ts new file mode 100644 index 00000000..96a2e476 --- /dev/null +++ b/services/frontend/packages/swell/src/types/site.ts @@ -0,0 +1 @@ +export * from '@vercel/commerce/types/site' diff --git a/services/frontend/packages/swell/src/types/wishlist.ts b/services/frontend/packages/swell/src/types/wishlist.ts new file mode 100644 index 00000000..af92d9f6 --- /dev/null +++ b/services/frontend/packages/swell/src/types/wishlist.ts @@ -0,0 +1 @@ +export * from '@vercel/commerce/types/wishlist' diff --git a/services/frontend/packages/swell/src/utils/customer-token.ts b/services/frontend/packages/swell/src/utils/customer-token.ts new file mode 100644 index 00000000..63bd51bc --- /dev/null +++ b/services/frontend/packages/swell/src/utils/customer-token.ts @@ -0,0 +1,21 @@ +import Cookies, { CookieAttributes } from 'js-cookie' +import { SWELL_COOKIE_EXPIRE, SWELL_CUSTOMER_TOKEN_COOKIE } from '../const' + +export const getCustomerToken = () => Cookies.get(SWELL_CUSTOMER_TOKEN_COOKIE) + +export const setCustomerToken = ( + token: string | null, + options?: CookieAttributes +) => { + if (!token) { + Cookies.remove(SWELL_CUSTOMER_TOKEN_COOKIE) + } else { + Cookies.set( + SWELL_CUSTOMER_TOKEN_COOKIE, + token, + options ?? { + expires: SWELL_COOKIE_EXPIRE, + } + ) + } +} diff --git a/services/frontend/packages/swell/src/utils/get-categories.ts b/services/frontend/packages/swell/src/utils/get-categories.ts new file mode 100644 index 00000000..ab38bae9 --- /dev/null +++ b/services/frontend/packages/swell/src/utils/get-categories.ts @@ -0,0 +1,16 @@ +import { SwellConfig } from '../api' +import { Category } from '../types/site' + +const getCategories = async (config: SwellConfig): Promise => { + const data = await config.fetch('categories', 'get') + return ( + data.results.map(({ id, name, slug }: any) => ({ + id, + name, + slug, + path: `/${slug}`, + })) ?? [] + ) +} + +export default getCategories diff --git a/services/frontend/packages/swell/src/utils/get-checkout-id.ts b/services/frontend/packages/swell/src/utils/get-checkout-id.ts new file mode 100644 index 00000000..07643f47 --- /dev/null +++ b/services/frontend/packages/swell/src/utils/get-checkout-id.ts @@ -0,0 +1,8 @@ +import Cookies from 'js-cookie' +import { SWELL_CHECKOUT_ID_COOKIE } from '../const' + +const getCheckoutId = (id?: string) => { + return id ?? Cookies.get(SWELL_CHECKOUT_ID_COOKIE) +} + +export default getCheckoutId diff --git a/services/frontend/packages/swell/src/utils/get-search-variables.ts b/services/frontend/packages/swell/src/utils/get-search-variables.ts new file mode 100644 index 00000000..c1b40ae5 --- /dev/null +++ b/services/frontend/packages/swell/src/utils/get-search-variables.ts @@ -0,0 +1,27 @@ +import getSortVariables from './get-sort-variables' +import type { SearchProductsInput } from '../product/use-search' + +export const getSearchVariables = ({ + brandId, + search, + categoryId, + sort, +}: SearchProductsInput) => { + let query = '' + + if (search) { + query += `product_type:${search} OR title:${search} OR tag:${search}` + } + + if (brandId) { + query += `${search ? ' AND ' : ''}vendor:${brandId}` + } + + return { + categoryId, + query, + ...getSortVariables(sort, !!categoryId), + } +} + +export default getSearchVariables diff --git a/services/frontend/packages/swell/src/utils/get-sort-variables.ts b/services/frontend/packages/swell/src/utils/get-sort-variables.ts new file mode 100644 index 00000000..b8cdeec5 --- /dev/null +++ b/services/frontend/packages/swell/src/utils/get-sort-variables.ts @@ -0,0 +1,32 @@ +const getSortVariables = (sort?: string, isCategory = false) => { + let output = {} + switch (sort) { + case 'price-asc': + output = { + sortKey: 'PRICE', + reverse: false, + } + break + case 'price-desc': + output = { + sortKey: 'PRICE', + reverse: true, + } + break + case 'trending-desc': + output = { + sortKey: 'BEST_SELLING', + reverse: false, + } + break + case 'latest-desc': + output = { + sortKey: isCategory ? 'CREATED' : 'CREATED_AT', + reverse: true, + } + break + } + return output +} + +export default getSortVariables diff --git a/services/frontend/packages/swell/src/utils/get-vendors.ts b/services/frontend/packages/swell/src/utils/get-vendors.ts new file mode 100644 index 00000000..1ede6883 --- /dev/null +++ b/services/frontend/packages/swell/src/utils/get-vendors.ts @@ -0,0 +1,27 @@ +import { SwellConfig } from '../api' + +export type BrandNode = { + name: string + path: string +} + +export type BrandEdge = { + node: BrandNode +} + +export type Brands = BrandEdge[] + +const getVendors = async (config: SwellConfig) => { + const vendors: [string] = + (await config.fetch('attributes', 'get', ['brand']))?.values ?? [] + + return [...new Set(vendors)].map((v) => ({ + node: { + entityId: v, + name: v, + path: `brands/${v}`, + }, + })) +} + +export default getVendors diff --git a/services/frontend/packages/swell/src/utils/handle-fetch-response.ts b/services/frontend/packages/swell/src/utils/handle-fetch-response.ts new file mode 100644 index 00000000..d120cab1 --- /dev/null +++ b/services/frontend/packages/swell/src/utils/handle-fetch-response.ts @@ -0,0 +1,19 @@ +import { CommerceError } from '@vercel/commerce/utils/errors' + +type SwellFetchResponse = { + error: { + message: string + code?: string + } +} + +const handleFetchResponse = async (res: SwellFetchResponse) => { + if (res) { + if (res.error) { + throw new CommerceError(res.error) + } + return res + } +} + +export default handleFetchResponse diff --git a/services/frontend/packages/swell/src/utils/handle-login.ts b/services/frontend/packages/swell/src/utils/handle-login.ts new file mode 100644 index 00000000..8628262c --- /dev/null +++ b/services/frontend/packages/swell/src/utils/handle-login.ts @@ -0,0 +1,39 @@ +import { ValidationError } from '@vercel/commerce/utils/errors' +import { setCustomerToken } from './customer-token' + +const getErrorMessage = ({ + code, + message, +}: { + code: string + message: string +}) => { + switch (code) { + case 'UNIDENTIFIED_CUSTOMER': + message = 'Cannot find an account that matches the provided credentials' + break + } + return message +} + +const handleLogin = (data: any) => { + const response = data.customerAccessTokenCreate + const errors = response?.customerUserErrors + + if (errors && errors.length) { + throw new ValidationError({ + message: getErrorMessage(errors[0]), + }) + } + + const customerAccessToken = response?.customerAccessToken + const accessToken = customerAccessToken?.accessToken + + if (accessToken) { + setCustomerToken(accessToken) + } + + return customerAccessToken +} + +export default handleLogin diff --git a/services/frontend/packages/swell/src/utils/index.ts b/services/frontend/packages/swell/src/utils/index.ts new file mode 100644 index 00000000..9ec81bfb --- /dev/null +++ b/services/frontend/packages/swell/src/utils/index.ts @@ -0,0 +1,9 @@ +export { default as handleFetchResponse } from './handle-fetch-response' +export { default as getSearchVariables } from './get-search-variables' +export { default as getSortVariables } from './get-sort-variables' +export { default as getVendors } from './get-vendors' +export { default as getCategories } from './get-categories' +export { default as getCheckoutId } from './get-checkout-id' + +export * from './normalize' +export * from './customer-token' diff --git a/services/frontend/packages/swell/src/utils/normalize.ts b/services/frontend/packages/swell/src/utils/normalize.ts new file mode 100644 index 00000000..9bbad596 --- /dev/null +++ b/services/frontend/packages/swell/src/utils/normalize.ts @@ -0,0 +1,226 @@ +import { Customer } from '../types/customer' +import { Product, ProductOption } from '../types/product' +import { MoneyV2 } from '../../schema' + +import type { + Cart, + CartLineItem, + SwellCustomer, + SwellProduct, + SwellImage, + SwellVariant, + ProductOptionValue, + SwellProductOptionValue, + SwellCart, + LineItem, +} from '../types' + +const money = ({ amount, currencyCode }: MoneyV2) => { + return { + value: +amount, + currencyCode, + } +} + +type swellProductOption = { + id: string + name: string + values: any[] +} + +type normalizedProductOption = { + id: string + displayName: string + values: ProductOptionValue[] +} + +const normalizeProductOption = ({ + id, + name: displayName = '', + values = [], +}: swellProductOption): ProductOption => { + let returnValues = values.map((value) => { + let output: any = { + label: value.name, + // id: value?.id || id, + } + if (displayName.match(/colou?r/gi)) { + output = { + ...output, + hexColors: [value.name], + } + } + return output + }) + return { + __typename: 'MultipleChoiceOption', + id, + displayName, + values: returnValues, + } +} + +const normalizeProductImages = (images: SwellImage[]) => { + if (!images || images.length < 1) { + return [{ url: '/' }] + } + return images?.map(({ file, ...rest }: SwellImage) => ({ + url: file?.url + '', + height: Number(file?.height), + width: Number(file?.width), + ...rest, + })) +} + +const normalizeProductVariants = ( + variants: SwellVariant[], + productOptions: swellProductOption[] +) => { + return variants?.map( + ({ id, name, price, option_value_ids: optionValueIds = [] }) => { + const values = name + .split(',') + .map((i) => ({ name: i.trim(), label: i.trim() })) + + const options = optionValueIds.map((id) => { + const matchingOption = productOptions.find((option) => { + return option.values.find( + (value: SwellProductOptionValue) => value.id == id + ) + }) + return normalizeProductOption({ + id, + name: matchingOption?.name ?? '', + values, + }) + }) + + return { + id, + // name, + // sku: sku ?? id, + // price: price ?? null, + // listPrice: price ?? null, + // requiresShipping: true, + options, + } + } + ) +} + +export function normalizeProduct(swellProduct: SwellProduct): Product { + const { + id, + name, + description, + images, + options, + slug, + variants, + price: value, + currency: currencyCode, + } = swellProduct + // ProductView accesses variants for each product + const emptyVariants = [{ options: [], id, name }] + + const productOptions = options + ? options.map((o) => normalizeProductOption(o)) + : [] + const productVariants = variants + ? normalizeProductVariants(variants, options) + : [] + + const productImages = normalizeProductImages(images) + const product = { + ...swellProduct, + description, + id, + vendor: '', + path: `/${slug}`, + images: productImages, + variants: + productVariants && productVariants.length + ? productVariants + : emptyVariants, + options: productOptions, + price: { + value, + currencyCode, + }, + } + return product +} + +export function normalizeCart({ + id, + account_id, + date_created, + currency, + tax_included_total, + items, + sub_total, + grand_total, + discounts, +}: SwellCart) { + const cart: Cart = { + id: id, + customerId: account_id + '', + email: '', + createdAt: date_created, + currency: { code: currency }, + taxesIncluded: tax_included_total > 0, + lineItems: items?.map(normalizeLineItem) ?? [], + lineItemsSubtotalPrice: +sub_total, + subtotalPrice: +sub_total, + totalPrice: grand_total, + discounts: discounts?.map((discount) => ({ value: discount.amount })), + } + return cart +} + +export function normalizeCustomer(customer: SwellCustomer): Customer { + const { first_name: firstName, last_name: lastName } = customer + return { + ...customer, + firstName, + lastName, + } +} + +function normalizeLineItem({ + id, + product, + price, + variant, + quantity, +}: CartLineItem): LineItem { + const item = { + id, + variantId: variant?.id, + productId: product.id ?? '', + name: product?.name ?? '', + quantity, + variant: { + id: variant?.id ?? '', + sku: variant?.sku ?? '', + name: variant?.name!, + image: { + url: + product?.images && product.images.length > 0 + ? product?.images[0].file.url + : '/', + }, + requiresShipping: false, + price: price, + listPrice: price, + }, + path: '', + discounts: [], + options: [ + { + value: variant?.name, + }, + ], + } + return item +} diff --git a/services/frontend/packages/swell/src/utils/storage.ts b/services/frontend/packages/swell/src/utils/storage.ts new file mode 100644 index 00000000..d46dadb2 --- /dev/null +++ b/services/frontend/packages/swell/src/utils/storage.ts @@ -0,0 +1,13 @@ +export const getCheckoutIdFromStorage = (token: string) => { + if (window && window.sessionStorage) { + return window.sessionStorage.getItem(token) + } + + return null +} + +export const setCheckoutIdInStorage = (token: string, id: string | number) => { + if (window && window.sessionStorage) { + return window.sessionStorage.setItem(token, id + '') + } +} diff --git a/services/frontend/packages/swell/src/wishlist/use-add-item.tsx b/services/frontend/packages/swell/src/wishlist/use-add-item.tsx new file mode 100644 index 00000000..75f067c3 --- /dev/null +++ b/services/frontend/packages/swell/src/wishlist/use-add-item.tsx @@ -0,0 +1,13 @@ +import { useCallback } from 'react' + +export function emptyHook() { + const useEmptyHook = async (options = {}) => { + return useCallback(async function () { + return Promise.resolve() + }, []) + } + + return useEmptyHook +} + +export default emptyHook diff --git a/services/frontend/packages/swell/src/wishlist/use-remove-item.tsx b/services/frontend/packages/swell/src/wishlist/use-remove-item.tsx new file mode 100644 index 00000000..a2d3a8a0 --- /dev/null +++ b/services/frontend/packages/swell/src/wishlist/use-remove-item.tsx @@ -0,0 +1,17 @@ +import { useCallback } from 'react' + +type Options = { + includeProducts?: boolean +} + +export function emptyHook(options?: Options) { + const useEmptyHook = async ({ id }: { id: string | number }) => { + return useCallback(async function () { + return Promise.resolve() + }, []) + } + + return useEmptyHook +} + +export default emptyHook diff --git a/services/frontend/packages/swell/src/wishlist/use-wishlist.tsx b/services/frontend/packages/swell/src/wishlist/use-wishlist.tsx new file mode 100644 index 00000000..0506ae9d --- /dev/null +++ b/services/frontend/packages/swell/src/wishlist/use-wishlist.tsx @@ -0,0 +1,46 @@ +// TODO: replace this hook and other wishlist hooks with a handler, or remove them if +// Swell doesn't have a wishlist + +import { HookFetcher } from '@vercel/commerce/utils/types' +import { Product } from '../../schema' + +const defaultOpts = {} + +export type Wishlist = { + items: [ + { + product_id: number + variant_id: number + id: number + product: Product + } + ] +} + +export interface UseWishlistOptions { + includeProducts?: boolean +} + +export interface UseWishlistInput extends UseWishlistOptions { + customerId?: number +} + +export const fetcher: HookFetcher = () => { + return null +} + +export function extendHook( + customFetcher: typeof fetcher, + // swrOptions?: SwrOptions + swrOptions?: any +) { + const useWishlist = ({ includeProducts }: UseWishlistOptions = {}) => { + return { data: null } + } + + useWishlist.extend = extendHook + + return useWishlist +} + +export default extendHook(fetcher) diff --git a/services/frontend/packages/swell/taskfile.js b/services/frontend/packages/swell/taskfile.js new file mode 100644 index 00000000..39b1b2a8 --- /dev/null +++ b/services/frontend/packages/swell/taskfile.js @@ -0,0 +1,20 @@ +export async function build(task, opts) { + await task + .source('src/**/*.+(ts|tsx|js)') + .swc({ dev: opts.dev, outDir: 'dist', baseUrl: 'src' }) + .target('dist') + .source('src/**/*.+(cjs|json)') + .target('dist') + task.$.log('Compiled src files') +} + +export async function release(task) { + await task.clear('dist').start('build') +} + +export default async function dev(task) { + const opts = { dev: true } + await task.clear('dist') + await task.start('build', opts) + await task.watch('src/**/*.+(ts|tsx|js|cjs|json)', 'build', opts) +} diff --git a/services/frontend/packages/swell/tsconfig.json b/services/frontend/packages/swell/tsconfig.json new file mode 100644 index 00000000..cd04ab2f --- /dev/null +++ b/services/frontend/packages/swell/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "outDir": "dist", + "baseUrl": "src", + "lib": ["dom", "dom.iterable", "esnext"], + "declaration": true, + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "incremental": true, + "jsx": "react-jsx" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/services/frontend/packages/taskr-swc/.prettierrc b/services/frontend/packages/taskr-swc/.prettierrc new file mode 100644 index 00000000..a4fb43a4 --- /dev/null +++ b/services/frontend/packages/taskr-swc/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": false, + "singleQuote": true, + "tabWidth": 2, + "useTabs": false +} diff --git a/services/frontend/packages/taskr-swc/package.json b/services/frontend/packages/taskr-swc/package.json new file mode 100644 index 00000000..1a91b5b3 --- /dev/null +++ b/services/frontend/packages/taskr-swc/package.json @@ -0,0 +1,16 @@ +{ + "name": "taskr-swc", + "version": "0.0.1", + "license": "MIT", + "scripts": { + "prettier-fix": "prettier --write ." + }, + "main": "taskfile-swc.js", + "files": [ + "taskfile-swc.js" + ], + "devDependencies": { + "@swc/core": "^1.2.138", + "prettier": "^2.5.1" + } +} diff --git a/services/frontend/packages/taskr-swc/taskfile-swc.js b/services/frontend/packages/taskr-swc/taskfile-swc.js new file mode 100644 index 00000000..26fcad52 --- /dev/null +++ b/services/frontend/packages/taskr-swc/taskfile-swc.js @@ -0,0 +1,123 @@ +// Based on +// https://github.com/vercel/next.js/blob/canary/packages/next/taskfile-swc.js + +// taskr babel plugin with Babel 7 support +// https://github.com/lukeed/taskr/pull/305 + +const path = require('path') +const transform = require('@swc/core').transform + +module.exports = function (task) { + task.plugin( + 'swc', + {}, + function* ( + file, + { server = true, stripExtension, dev, outDir = 'dist', baseUrl = '' } = {} + ) { + // Don't compile .d.ts + if (file.base.endsWith('.d.ts')) return + + const swcClientOptions = { + module: { + type: 'es6', + ignoreDynamic: true, + }, + jsc: { + loose: true, + target: 'es2016', + parser: { + syntax: 'typescript', + dynamicImport: true, + tsx: file.base.endsWith('.tsx'), + }, + transform: { + react: { + runtime: 'automatic', + pragma: 'React.createElement', + pragmaFrag: 'React.Fragment', + throwIfNamespace: true, + development: false, + useBuiltins: true, + }, + }, + }, + } + const swcServerOptions = { + module: { + type: 'es6', + ignoreDynamic: true, + }, + env: { + targets: { + node: '14.0.0', + }, + }, + jsc: { + loose: true, + parser: { + syntax: 'typescript', + dynamicImport: true, + tsx: file.base.endsWith('.tsx'), + }, + transform: { + react: { + runtime: 'automatic', + pragma: 'React.createElement', + pragmaFrag: 'React.Fragment', + throwIfNamespace: true, + development: false, + useBuiltins: true, + }, + }, + }, + } + + const swcOptions = server ? swcServerOptions : swcClientOptions + const filePath = path.join(file.dir, file.base) + const options = { + filename: filePath, + sourceMaps: false, + ...swcOptions, + } + + if (options.sourceMaps && !options.sourceFileName) { + // Using `outDir` and `baseUrl` build a relative path from `outDir` to + // the `baseUrl` path for source maps + const basePath = path.join(__dirname, baseUrl) + const relativeFilePath = path.relative(basePath, filePath) + const fullFilePath = path.join(__dirname, filePath) + const distFilePath = path.dirname( + path.join(__dirname, outDir, relativeFilePath) + ) + + options.sourceFileName = path.relative(distFilePath, fullFilePath) + } + + const output = yield transform(file.data.toString('utf-8'), options) + const ext = path.extname(file.base) + + // Replace `.ts|.tsx` with `.js` in files with an extension + if (ext) { + const extRegex = new RegExp(ext.replace('.', '\\.') + '$', 'i') + // Remove the extension if stripExtension is enabled or replace it with `.js` + file.base = file.base.replace(extRegex, stripExtension ? '' : '.js') + } + + if (output.map) { + const map = `${file.base}.map` + + output.code += Buffer.from(`\n//# sourceMappingURL=${map}`) + + // add sourcemap to `files` array + this._.files.push({ + base: map, + dir: file.dir, + data: Buffer.from(output.map), + }) + } + + file.data = Buffer.from(output.code) + } + ) +} diff --git a/services/frontend/site/.eslintrc b/services/frontend/site/.eslintrc new file mode 100644 index 00000000..96856ac2 --- /dev/null +++ b/services/frontend/site/.eslintrc @@ -0,0 +1,6 @@ +{ + "extends": ["next", "prettier"], + "rules": { + "react/no-unescaped-entities": "off" + } +} diff --git a/services/frontend/site/.gitignore b/services/frontend/site/.gitignore new file mode 100644 index 00000000..f2dd8e07 --- /dev/null +++ b/services/frontend/site/.gitignore @@ -0,0 +1,35 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +.next/ +out/ + +# production +/build + +# misc +.DS_Store +*.pem +.idea + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel diff --git a/services/frontend/site/.prettierignore b/services/frontend/site/.prettierignore new file mode 100644 index 00000000..105738ca --- /dev/null +++ b/services/frontend/site/.prettierignore @@ -0,0 +1,3 @@ +node_modules +.next +public \ No newline at end of file diff --git a/services/frontend/site/.prettierrc b/services/frontend/site/.prettierrc new file mode 100644 index 00000000..e1076edf --- /dev/null +++ b/services/frontend/site/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": false, + "singleQuote": true, + "tabWidth": 2, + "useTabs": false +} diff --git a/services/frontend/site/assets/base.css b/services/frontend/site/assets/base.css new file mode 100644 index 00000000..39e85316 --- /dev/null +++ b/services/frontend/site/assets/base.css @@ -0,0 +1,121 @@ +:root { + --primary: #ffffff; + --primary-2: #632ca6; + --secondary: #000000; + --secondary-2: #111; + --selection: var(--cyan); + --text-base: #000000; + --text-primary: #000000; + --text-secondary: white; + --hover: rgba(0, 0, 0, 0.075); + --hover-1: rgba(0, 0, 0, 0.15); + --hover-2: rgba(0, 0, 0, 0.25); + --cyan: #22b8cf; + --green: #37b679; + --red: #da3c3c; + --purple: #f81ce5; + --blue: #0070f3; + --pink: #ff0080; + --pink-light: #ff379c; + --magenta: #c887ff; + --violet: #632ca6; + --violet-dark: #360043; + --accent-0: #fff; + --accent-1: #fafafa; + --accent-2: #eaeaea; + --accent-3: #999999; + --accent-4: #888888; + --accent-5: #666666; + --accent-6: #444444; + --accent-7: #333333; + --accent-8: #111111; + --accent-9: #000; + --font-sans: -apple-system, system-ui, BlinkMacSystemFont, 'Helvetica Neue', + 'Helvetica', sans-serif; +} + +/* [data-theme='dark'] { + --primary: #000000; + --primary-2: #111; + --secondary: #ffffff; + --secondary-2: #f1f3f5; + --hover: rgba(255, 255, 255, 0.075); + --hover-1: rgba(255, 255, 255, 0.15); + --hover-2: rgba(255, 255, 255, 0.25); + --selection: var(--purple); + --text-base: white; + --text-primary: white; + --text-secondary: black; + --accent-9: #fff; + --accent-8: #fafafa; + --accent-7: #eaeaea; + --accent-6: #999999; + --accent-5: #888888; + --accent-4: #666666; + --accent-3: #444444; + --accent-2: #333333; + --accent-1: #111111; + --accent-0: #000; +} */ + +*, +*:before, +*:after { + box-sizing: inherit; +} + +html, +body { + height: 100%; + box-sizing: border-box; + touch-action: manipulation; + font-family: var(--font-sans); + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background-color: var(--primary); + color: var(--text-primary); + overscroll-behavior-x: none; +} + +body { + position: relative; + min-height: 100%; + margin: 0; +} + +a { + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); +} + +.animated { + animation-duration: 1s; + animation-fill-mode: both; + -webkit-animation-duration: 1s; + -webkit-animation-fill-mode: both; +} + +.fadeIn { + animation-name: fadeIn; + -webkit-animation-name: fadeIn; +} + +@-webkit-keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} diff --git a/services/frontend/site/assets/chrome-bug.css b/services/frontend/site/assets/chrome-bug.css new file mode 100644 index 00000000..245ec8f0 --- /dev/null +++ b/services/frontend/site/assets/chrome-bug.css @@ -0,0 +1,12 @@ +/** + * Chrome has a bug with transitions on load since 2012! + * + * To prevent a "pop" of content, you have to disable all transitions until + * the page is done loading. + * + * https://lab.laukstein.com/bug/input + * https://twitter.com/timer150/status/1345217126680899584 + */ +body.loading * { + transition: none !important; +} diff --git a/services/frontend/site/assets/components.css b/services/frontend/site/assets/components.css new file mode 100644 index 00000000..ebebcc23 --- /dev/null +++ b/services/frontend/site/assets/components.css @@ -0,0 +1,3 @@ +.fit { + min-height: calc(100vh - 88px); +} diff --git a/services/frontend/site/assets/main.css b/services/frontend/site/assets/main.css new file mode 100644 index 00000000..54dd1e50 --- /dev/null +++ b/services/frontend/site/assets/main.css @@ -0,0 +1,7 @@ +@tailwind base; +@import './base.css'; + +@tailwind components; +@import './components.css'; + +@tailwind utilities; diff --git a/services/frontend/site/commerce-config.js b/services/frontend/site/commerce-config.js new file mode 100644 index 00000000..18691c51 --- /dev/null +++ b/services/frontend/site/commerce-config.js @@ -0,0 +1,97 @@ +/** + * This file is expected to be used in next.config.js only + */ + +const path = require('path') +const fs = require('fs') +const merge = require('deepmerge') +const prettier = require('prettier') +const core = require('@vercel/commerce/config') + +const PROVIDERS = [ + '@vercel/commerce-local', + '@vercel/commerce-spree', +] + +function getProviderName() { + return ( + process.env.COMMERCE_PROVIDER || + (process.env.BIGCOMMERCE_STOREFRONT_API_URL + ? '@vercel/commerce-bigcommerce' + : process.env.NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN + ? '@vercel/commerce-shopify' + : process.env.NEXT_PUBLIC_SWELL_STORE_ID + ? '@vercel/commerce-swell' + : '@vercel/commerce-local') + ) +} + +function withCommerceConfig(nextConfig = {}) { + const config = merge( + { commerce: { provider: getProviderName() } }, + nextConfig + ) + const { commerce } = config + const { provider } = commerce + + if (!provider) { + throw new Error( + `The commerce provider is missing, please add a valid provider name or its environment variables` + ) + } + if (!PROVIDERS.includes(provider)) { + throw new Error( + `The commerce provider "${provider}" can't be found, please use one of "${PROVIDERS.join( + ', ' + )}"` + ) + } + + // Update paths in `tsconfig.json` to point to the selected provider + if (commerce.updateTSConfig !== false) { + const tsconfigPath = path.join( + process.cwd(), + commerce.tsconfigPath || 'tsconfig.json' + ) + const tsconfig = require(tsconfigPath) + // The module path is a symlink in node_modules + // -> /node_modules/[name]/dist/index.js + const absolutePath = require.resolve(provider) + // but we want references to go to the real path in /packages instead + // -> packages/[name]/dist + const distPath = path.join(path.relative(process.cwd(), absolutePath), '..') + // -> /packages/[name]/src + const modulePath = path.join(distPath, '../src') + + tsconfig.compilerOptions.paths['@framework'] = [`${modulePath}`] + tsconfig.compilerOptions.paths['@framework/*'] = [`${modulePath}/*`] + + fs.writeFileSync( + tsconfigPath, + prettier.format(JSON.stringify(tsconfig), { parser: 'json' }) + ) + + const webpack = config.webpack + + // To improve the DX of using references, we'll switch from `src` to `dist` + // only for webpack so imports resolve correctly but typechecking goes to `src` + config.webpack = (cfg, options) => { + if (Array.isArray(cfg.resolve.plugins)) { + const jsconfigPaths = cfg.resolve.plugins.find( + (plugin) => plugin.constructor.name === 'JsConfigPathsPlugin' + ) + + if (jsconfigPaths) { + jsconfigPaths.paths['@framework'] = [distPath] + jsconfigPaths.paths['@framework/*'] = [`${distPath}/*`] + } + } + + return webpack ? webpack(cfg, options) : cfg + } + } + + return core.withCommerceConfig(config) +} + +module.exports = { withCommerceConfig, getProviderName } diff --git a/services/frontend/site/commerce.config.json b/services/frontend/site/commerce.config.json new file mode 100644 index 00000000..6fb526aa --- /dev/null +++ b/services/frontend/site/commerce.config.json @@ -0,0 +1,9 @@ +{ + "features": { + "cart": true, + "search": true, + "wishlist": true, + "customerAuth": true, + "customCheckout": true + } +} diff --git a/services/frontend/site/components/auth/ForgotPassword.tsx b/services/frontend/site/components/auth/ForgotPassword.tsx new file mode 100644 index 00000000..dbac371c --- /dev/null +++ b/services/frontend/site/components/auth/ForgotPassword.tsx @@ -0,0 +1,78 @@ +import { FC, useEffect, useState, useCallback } from 'react' +import { validate } from 'email-validator' +import { useUI } from '@components/ui/context' +import { Logo, Button, Input } from '@components/ui' + +interface Props {} + +const ForgotPassword: FC = () => { + // Form State + const [email, setEmail] = useState('') + const [loading, setLoading] = useState(false) + const [message, setMessage] = useState('') + const [dirty, setDirty] = useState(false) + const [disabled, setDisabled] = useState(false) + + const { setModalView, closeModal } = useUI() + + const handleResetPassword = async (e: React.SyntheticEvent) => { + e.preventDefault() + + if (!dirty && !disabled) { + setDirty(true) + handleValidation() + } + } + + const handleValidation = useCallback(() => { + // Unable to send form unless fields are valid. + if (dirty) { + setDisabled(!validate(email)) + } + }, [email, dirty]) + + useEffect(() => { + handleValidation() + }, [handleValidation]) + + return ( +

+
+ +
+
+ {message && ( +
{message}
+ )} + + +
+ +
+ + + Do you have an account? + {` `} + setModalView('LOGIN_VIEW')} + > + Log In + + +
+
+ ) +} + +export default ForgotPassword diff --git a/services/frontend/site/components/auth/LoginView.tsx b/services/frontend/site/components/auth/LoginView.tsx new file mode 100644 index 00000000..3c8faef7 --- /dev/null +++ b/services/frontend/site/components/auth/LoginView.tsx @@ -0,0 +1,103 @@ +import { FC, useEffect, useState, useCallback } from 'react' +import { Logo, Button, Input } from '@components/ui' +import useLogin from '@framework/auth/use-login' +import { useUI } from '@components/ui/context' +import { validate } from 'email-validator' + +const LoginView: React.FC = () => { + // Form State + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [loading, setLoading] = useState(false) + const [message, setMessage] = useState('') + const [dirty, setDirty] = useState(false) + const [disabled, setDisabled] = useState(false) + const { setModalView, closeModal } = useUI() + + const login = useLogin() + + const handleLogin = async (e: React.SyntheticEvent) => { + e.preventDefault() + + if (!dirty && !disabled) { + setDirty(true) + handleValidation() + } + + try { + setLoading(true) + setMessage('') + await login({ + email, + password, + }) + setLoading(false) + closeModal() + } catch (e: any) { + setMessage(e.errors[0].message) + setLoading(false) + setDisabled(false) + } + } + + const handleValidation = useCallback(() => { + // Test for Alphanumeric password + const validPassword = /^(?=.*[a-zA-Z])(?=.*[0-9])/.test(password) + + // Unable to send form unless fields are valid. + if (dirty) { + setDisabled(!validate(email) || password.length < 7 || !validPassword) + } + }, [email, password, dirty]) + + useEffect(() => { + handleValidation() + }, [handleValidation]) + + return ( +
+
+ +
+
+ {message && ( + + )} + + + + +
+ Don't have an account? + {` `} + setModalView('SIGNUP_VIEW')} + > + Sign Up + +
+
+
+ ) +} + +export default LoginView diff --git a/services/frontend/site/components/auth/SignUpView.tsx b/services/frontend/site/components/auth/SignUpView.tsx new file mode 100644 index 00000000..a85a3bc2 --- /dev/null +++ b/services/frontend/site/components/auth/SignUpView.tsx @@ -0,0 +1,114 @@ +import { FC, useEffect, useState, useCallback } from 'react' +import { validate } from 'email-validator' +import { Info } from '@components/icons' +import { useUI } from '@components/ui/context' +import { Logo, Button, Input } from '@components/ui' +import useSignup from '@framework/auth/use-signup' + +interface Props {} + +const SignUpView: FC = () => { + // Form State + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [firstName, setFirstName] = useState('') + const [lastName, setLastName] = useState('') + const [loading, setLoading] = useState(false) + const [message, setMessage] = useState('') + const [dirty, setDirty] = useState(false) + const [disabled, setDisabled] = useState(false) + + const signup = useSignup() + const { setModalView, closeModal } = useUI() + + const handleSignup = async (e: React.SyntheticEvent) => { + e.preventDefault() + + if (!dirty && !disabled) { + setDirty(true) + handleValidation() + } + + try { + setLoading(true) + setMessage('') + await signup({ + email, + firstName, + lastName, + password, + }) + setLoading(false) + closeModal() + } catch ({ errors }) { + setMessage(errors[0].message) + setLoading(false) + } + } + + const handleValidation = useCallback(() => { + // Test for Alphanumeric password + const validPassword = /^(?=.*[a-zA-Z])(?=.*[0-9])/.test(password) + + // Unable to send form unless fields are valid. + if (dirty) { + setDisabled(!validate(email) || password.length < 7 || !validPassword) + } + }, [email, password, dirty]) + + useEffect(() => { + handleValidation() + }, [handleValidation]) + + return ( +
+
+ +
+
+ {message && ( +
{message}
+ )} + + + + + + + + {' '} + + Info: Passwords must be longer than 7 chars and + include numbers.{' '} + + +
+ +
+ + + Do you have an account? + {` `} + setModalView('LOGIN_VIEW')} + > + Log In + + +
+
+ ) +} + +export default SignUpView diff --git a/services/frontend/site/components/auth/index.ts b/services/frontend/site/components/auth/index.ts new file mode 100644 index 00000000..11571fac --- /dev/null +++ b/services/frontend/site/components/auth/index.ts @@ -0,0 +1,3 @@ +export { default as LoginView } from './LoginView' +export { default as SignUpView } from './SignUpView' +export { default as ForgotPassword } from './ForgotPassword' diff --git a/services/frontend/site/components/cart/CartItem/CartItem.module.css b/services/frontend/site/components/cart/CartItem/CartItem.module.css new file mode 100644 index 00000000..dd43314f --- /dev/null +++ b/services/frontend/site/components/cart/CartItem/CartItem.module.css @@ -0,0 +1,32 @@ +.root { + @apply flex flex-col py-4; +} + +.root:first-child { + padding-top: 0; +} + +.quantity { + appearance: textfield; + @apply w-8 border-accent-2 border mx-3 rounded text-center text-sm text-black; +} + +.quantity::-webkit-outer-spin-button, +.quantity::-webkit-inner-spin-button { + @apply appearance-none m-0; +} + +.productImage { + position: absolute; + transform: scale(1.9); + width: 100%; + height: 100%; + left: 30% !important; + top: 30% !important; + z-index: 1; +} + +.productName { + @apply font-medium cursor-pointer pb-1; + margin-top: -4px; +} diff --git a/services/frontend/site/components/cart/CartItem/CartItem.tsx b/services/frontend/site/components/cart/CartItem/CartItem.tsx new file mode 100644 index 00000000..ecd3e39a --- /dev/null +++ b/services/frontend/site/components/cart/CartItem/CartItem.tsx @@ -0,0 +1,159 @@ +import { ChangeEvent, FocusEventHandler, useEffect, useState } from 'react' +import cn from 'clsx' +import Image from 'next/image' +import Link from 'next/link' +import s from './CartItem.module.css' +import { useUI } from '@components/ui/context' +import type { LineItem } from '@commerce/types/cart' +import usePrice from '@framework/product/use-price' +import useUpdateItem from '@framework/cart/use-update-item' +import useRemoveItem from '@framework/cart/use-remove-item' +import Quantity from '@components/ui/Quantity' + +type ItemOption = { + name: string + nameId: number + value: string + valueId: number +} + +const placeholderImg = '/product-img-placeholder.svg' + +const CartItem = ({ + item, + variant = 'default', + currencyCode, + ...rest +}: { + variant?: 'default' | 'display' + item: LineItem + currencyCode: string +}) => { + const { closeSidebarIfPresent } = useUI() + const [removing, setRemoving] = useState(false) + const [quantity, setQuantity] = useState(item.quantity) + const removeItem = useRemoveItem() + const updateItem = useUpdateItem({ item }) + + const { price } = usePrice({ + amount: item.variant.price * item.quantity, + baseAmount: item.variant.listPrice * item.quantity, + currencyCode, + }) + + const handleChange = async ({ + target: { value }, + }: ChangeEvent) => { + setQuantity(Number(value)) + await updateItem({ quantity: Number(value) }) + } + + const increaseQuantity = async (n = 1) => { + const val = Number(quantity) + n + setQuantity(val) + await updateItem({ quantity: val }) + } + + const handleRemove = async () => { + setRemoving(true) + try { + await removeItem(item) + } catch (error) { + setRemoving(false) + } + } + + // TODO: Add a type for this + const options = (item as any).options + + useEffect(() => { + // Reset the quantity state if the item quantity changes + if (item.quantity !== Number(quantity)) { + setQuantity(item.quantity) + } + // TODO: currently not including quantity in deps is intended, but we should + // do this differently as it could break easily + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [item.quantity]) + + return ( +
  • +
    + +
    + + + closeSidebarIfPresent()} + > + {item.name} + + + + {options && options.length > 0 && ( +
    + {options.map((option: ItemOption, i: number) => ( +
    + {option.name} + {option.name === 'Color' ? ( + + ) : ( + + {option.value} + + )} + {i === options.length - 1 ? '' : } +
    + ))} +
    + )} + {variant === 'display' && ( +
    {quantity}x
    + )} +
    +
    + {price} +
    +
    + {variant === 'default' && ( + increaseQuantity(1)} + decrease={() => increaseQuantity(-1)} + /> + )} +
  • + ) +} + +export default CartItem diff --git a/services/frontend/site/components/cart/CartItem/index.ts b/services/frontend/site/components/cart/CartItem/index.ts new file mode 100644 index 00000000..b5f6dc52 --- /dev/null +++ b/services/frontend/site/components/cart/CartItem/index.ts @@ -0,0 +1 @@ +export { default } from './CartItem' diff --git a/services/frontend/site/components/cart/CartSidebarView/CartSidebarView.module.css b/services/frontend/site/components/cart/CartSidebarView/CartSidebarView.module.css new file mode 100644 index 00000000..c9ffbed5 --- /dev/null +++ b/services/frontend/site/components/cart/CartSidebarView/CartSidebarView.module.css @@ -0,0 +1,11 @@ +.root { + min-height: 100vh; +} + +.root.empty { + @apply bg-secondary text-secondary; +} + +.lineItemsList { + @apply py-4 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-accent-2 border-accent-2; +} diff --git a/services/frontend/site/components/cart/CartSidebarView/CartSidebarView.tsx b/services/frontend/site/components/cart/CartSidebarView/CartSidebarView.tsx new file mode 100644 index 00000000..a082d9fa --- /dev/null +++ b/services/frontend/site/components/cart/CartSidebarView/CartSidebarView.tsx @@ -0,0 +1,131 @@ +import cn from 'clsx'; +import Link from 'next/link'; +import { FC } from 'react'; +import s from './CartSidebarView.module.css'; +import CartItem from '../CartItem'; +import { Button, Text } from '@components/ui'; +import { useUI } from '@components/ui/context'; +import { Bag, Cross, Check } from '@components/icons'; +import useCart from '@framework/cart/use-cart'; +import usePrice from '@framework/product/use-price'; +import SidebarLayout from '@components/common/SidebarLayout'; + +const CartSidebarView: FC = () => { + const { closeSidebar, setSidebarView } = useUI(); + const { data, isLoading, isEmpty } = useCart(); + + const { price: subTotal } = usePrice( + data && { + amount: Number(data.subtotalPrice), + currencyCode: data.currency.code, + } + ); + const { price: total } = usePrice( + data && { + amount: Number(data.totalPrice), + currencyCode: data.currency.code, + } + ); + const handleClose = () => closeSidebar(); + const goToCheckout = () => setSidebarView('CHECKOUT_VIEW'); + + const error = null; + const success = null; + + return ( + + {isLoading || isEmpty ? ( +
    + + + +

    + Your cart is empty +

    +

    + Biscuit oat cake wafer icing ice cream tiramisu pudding cupcake. +

    +
    + ) : error ? ( +
    + + + +

    + We couldn’t process the purchase. Please check your card information + and try again. +

    +
    + ) : success ? ( +
    + + + +

    + Thank you for your order. +

    +
    + ) : ( + <> +
    + + + + My Cart + + + +
      + {data!.lineItems.map((item: any) => ( + + ))} +
    +
    + +
    +
      +
    • + Subtotal + {subTotal} +
    • +
    • + Taxes + Calculated at checkout +
    • +
    • + Shipping + FREE +
    • +
    +
    + Total + {total} +
    +
    + +
    +
    + + )} +
    + ); +}; + +export default CartSidebarView; diff --git a/services/frontend/site/components/cart/CartSidebarView/index.ts b/services/frontend/site/components/cart/CartSidebarView/index.ts new file mode 100644 index 00000000..0262e448 --- /dev/null +++ b/services/frontend/site/components/cart/CartSidebarView/index.ts @@ -0,0 +1 @@ +export { default } from './CartSidebarView' diff --git a/services/frontend/site/components/cart/index.ts b/services/frontend/site/components/cart/index.ts new file mode 100644 index 00000000..3e53fa34 --- /dev/null +++ b/services/frontend/site/components/cart/index.ts @@ -0,0 +1,2 @@ +export { default as CartSidebarView } from './CartSidebarView' +export { default as CartItem } from './CartItem' diff --git a/services/frontend/site/components/checkout/CheckoutSidebarView/CheckoutSidebarView.module.css b/services/frontend/site/components/checkout/CheckoutSidebarView/CheckoutSidebarView.module.css new file mode 100644 index 00000000..790e5ec5 --- /dev/null +++ b/services/frontend/site/components/checkout/CheckoutSidebarView/CheckoutSidebarView.module.css @@ -0,0 +1,29 @@ +.root { + min-height: calc(100vh - 322px); +} + +.lineItemsList { + @apply py-4 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-accent-2 border-accent-2; +} + +.fieldset { + @apply flex flex-col my-3; +} + +.fieldset .label { + @apply text-accent-7 uppercase text-xs font-medium mb-2; +} + +.fieldset .input, +.fieldset .select { + @apply p-2 border border-accent-8 w-full text-sm font-normal bg-primary; +} + +.fieldset .input:focus, +.fieldset .select:focus { + @apply outline-none shadow-outline-normal; +} + +.radio { + @apply bg-black; +} diff --git a/services/frontend/site/components/checkout/CheckoutSidebarView/CheckoutSidebarView.tsx b/services/frontend/site/components/checkout/CheckoutSidebarView/CheckoutSidebarView.tsx new file mode 100644 index 00000000..230041b3 --- /dev/null +++ b/services/frontend/site/components/checkout/CheckoutSidebarView/CheckoutSidebarView.tsx @@ -0,0 +1,211 @@ +import Link from 'next/link'; +import { FC, useState } from 'react'; +import cn from 'clsx'; + +import CartItem from '@components/cart/CartItem'; +import { Button, Text } from '@components/ui'; +import { useUI } from '@components/ui/context'; +import SidebarLayout from '@components/common/SidebarLayout'; +import useCart from '@framework/cart/use-cart'; +import usePrice from '@framework/product/use-price'; +import useCheckout from '@framework/checkout/use-checkout'; +import ShippingWidget from '../ShippingWidget'; +import PaymentWidget from '../PaymentWidget'; +import s from './CheckoutSidebarView.module.css'; +import { useCheckoutContext } from '../context'; +import useRemoveItem from '@framework/cart/use-remove-item'; +import { datadogRum } from '@datadog/browser-rum'; + +const onMockCheckout = async () => { + const sleep = (ms) => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }; + + await sleep(2000); + return new Promise((resolve, reject) => { + resolve(true); + }); +}; + +const CheckoutSidebarView: FC = () => { + const [loadingSubmit, setLoadingSubmit] = useState(false); + const [discountInput, setDiscountInput] = useState(''); + const { setSidebarView, closeSidebar } = useUI(); + const { data: cartData, mutate: refreshCart } = useCart(); + const { data: checkoutData, submit: onCheckout } = useCheckout(); + const removeItem = useRemoveItem(); + const { clearCheckoutFields } = useCheckoutContext(); + + const { price: subTotal } = usePrice( + cartData && { + amount: Number(cartData.subtotalPrice), + currencyCode: cartData.currency.code, + } + ); + const { price: total } = usePrice( + cartData && { + amount: Number(cartData.totalPrice), + currencyCode: cartData.currency.code, + } + ); + + async function handleSubmit(event: React.ChangeEvent) { + try { + setLoadingSubmit(true); + event.preventDefault(); + + await onMockCheckout(); + + // Custom RUM action + datadogRum.addAction('Successful Checkout', { + cartTotal: cartData.totalPrice, + createdAt: cartData.createdAt, + discounts: cartData.discounts, + id: cartData.id, + lineItems: cartData.lineItems, + }); + + for (const product of cartData.lineItems) { + await removeItem(product); + } + + clearCheckoutFields(); + setLoadingSubmit(false); + refreshCart(); // This doesn't seem to work + setSidebarView('ORDER_CONFIRM_VIEW'); + } catch (e) { + console.log(e); + setLoadingSubmit(false); + } + } + + async function handleDiscount(event: React.ChangeEvent) { + event.preventDefault(); + + if (!discountInput) { + console.error('No discount input!'); + return; + } + + try { + const discountPath = `${process.env.NEXT_PUBLIC_DISCOUNTS_ROUTE}:${process.env.NEXT_PUBLIC_DISCOUNTS_PORT}`; + const discountCode = discountInput.toUpperCase(); + // call discounts service + const res = await fetch( + `${discountPath}/discount-code?discount_code=${discountCode}` + ); + const discount = await res.json(); + + if (discount?.error) { + throw 'No discount found!'; + } + + console.log('discount accepted', discount); + setDiscountInput(''); + } catch (err) { + console.error(err); + } + } + + return ( + setSidebarView('CART_VIEW')} + > +
    + + + Checkout + + + + setSidebarView('PAYMENT_VIEW')} + /> + setSidebarView('SHIPPING_VIEW')} + /> + +
      + {cartData!.lineItems.map((item: any) => ( + + ))} +
    + +
    +
    + + setDiscountInput(e.target.value)} + /> +
    +
    + +
    +
    +
    + +
    +
      +
    • + Subtotal + {subTotal} +
    • +
    • + Taxes + Calculated at checkout +
    • +
    • + Shipping + FREE +
    • +
    +
    + Total + {total} +
    +
    + {/* Once data is correctly filled */} + +
    +
    +
    + ); +}; + +export default CheckoutSidebarView; diff --git a/services/frontend/site/components/checkout/CheckoutSidebarView/index.ts b/services/frontend/site/components/checkout/CheckoutSidebarView/index.ts new file mode 100644 index 00000000..168bc58f --- /dev/null +++ b/services/frontend/site/components/checkout/CheckoutSidebarView/index.ts @@ -0,0 +1 @@ +export { default } from './CheckoutSidebarView' diff --git a/services/frontend/site/components/checkout/OrderConfirmView/OrderConfirmView.module.css b/services/frontend/site/components/checkout/OrderConfirmView/OrderConfirmView.module.css new file mode 100644 index 00000000..38dcab0c --- /dev/null +++ b/services/frontend/site/components/checkout/OrderConfirmView/OrderConfirmView.module.css @@ -0,0 +1,4 @@ +.root { + @apply border border-accent-2 px-6 py-5 mb-4 text-center + flex items-center cursor-pointer hover:border-accent-4; +} diff --git a/services/frontend/site/components/checkout/OrderConfirmView/OrderConfirmView.tsx b/services/frontend/site/components/checkout/OrderConfirmView/OrderConfirmView.tsx new file mode 100644 index 00000000..788915d4 --- /dev/null +++ b/services/frontend/site/components/checkout/OrderConfirmView/OrderConfirmView.tsx @@ -0,0 +1,20 @@ +import { FC } from 'react'; +import { Text } from '@components/ui'; +import { useUI } from '@components/ui/context'; +import SidebarLayout from '@components/common/SidebarLayout'; + +const OrderConfirmView: FC = () => { + const { setSidebarView } = useUI(); + + return ( +
    + setSidebarView('CHECKOUT_VIEW')}> +
    + Thank you for your purchase! +
    +
    +
    + ); +}; + +export default OrderConfirmView; diff --git a/services/frontend/site/components/checkout/OrderConfirmView/index.ts b/services/frontend/site/components/checkout/OrderConfirmView/index.ts new file mode 100644 index 00000000..b642e14e --- /dev/null +++ b/services/frontend/site/components/checkout/OrderConfirmView/index.ts @@ -0,0 +1 @@ +export { default } from './OrderConfirmView' \ No newline at end of file diff --git a/services/frontend/site/components/checkout/PaymentMethodView/PaymentMethodView.module.css b/services/frontend/site/components/checkout/PaymentMethodView/PaymentMethodView.module.css new file mode 100644 index 00000000..22a92d7c --- /dev/null +++ b/services/frontend/site/components/checkout/PaymentMethodView/PaymentMethodView.module.css @@ -0,0 +1,17 @@ +.fieldset { + @apply flex flex-col my-3; +} + +.fieldset .label { + @apply text-accent-7 uppercase text-xs font-medium mb-2; +} + +.fieldset .input, +.fieldset .select { + @apply p-2 border border-accent-8 w-full text-sm font-normal bg-primary; +} + +.fieldset .input:focus, +.fieldset .select:focus { + @apply outline-none shadow-outline-normal; +} diff --git a/services/frontend/site/components/checkout/PaymentMethodView/PaymentMethodView.tsx b/services/frontend/site/components/checkout/PaymentMethodView/PaymentMethodView.tsx new file mode 100644 index 00000000..659e420a --- /dev/null +++ b/services/frontend/site/components/checkout/PaymentMethodView/PaymentMethodView.tsx @@ -0,0 +1,132 @@ +import { FC } from 'react' +import cn from 'clsx' + +import useAddCard from '@framework/customer/card/use-add-item' +import { Button, Text } from '@components/ui' +import { useUI } from '@components/ui/context' +import SidebarLayout from '@components/common/SidebarLayout' + +import s from './PaymentMethodView.module.css' + +interface Form extends HTMLFormElement { + cardHolder: HTMLInputElement + cardNumber: HTMLInputElement + cardExpireDate: HTMLInputElement + cardCvc: HTMLInputElement + firstName: HTMLInputElement + lastName: HTMLInputElement + company: HTMLInputElement + streetNumber: HTMLInputElement + zipCode: HTMLInputElement + city: HTMLInputElement + country: HTMLSelectElement +} + +const PaymentMethodView: FC = () => { + const { setSidebarView } = useUI() + //const addCard = useAddCard() + + async function handleSubmit(event: React.ChangeEvent
    ) { + event.preventDefault() + + // await addCard({ + // cardHolder: event.target.cardHolder.value, + // cardNumber: event.target.cardNumber.value, + // cardExpireDate: event.target.cardExpireDate.value, + // cardCvc: event.target.cardCvc.value, + // firstName: event.target.firstName.value, + // lastName: event.target.lastName.value, + // company: event.target.company.value, + // streetNumber: event.target.streetNumber.value, + // zipCode: event.target.zipCode.value, + // city: event.target.city.value, + // country: event.target.country.value, + // }) + + setSidebarView('CHECKOUT_VIEW') + } + + return ( + + setSidebarView('CHECKOUT_VIEW')}> +
    + Payment Method +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    +
    +
    + +
    +
    +
    + ) +} + +export default PaymentMethodView diff --git a/services/frontend/site/components/checkout/PaymentMethodView/index.ts b/services/frontend/site/components/checkout/PaymentMethodView/index.ts new file mode 100644 index 00000000..951b3c31 --- /dev/null +++ b/services/frontend/site/components/checkout/PaymentMethodView/index.ts @@ -0,0 +1 @@ +export { default } from './PaymentMethodView' diff --git a/services/frontend/site/components/checkout/PaymentWidget/PaymentWidget.module.css b/services/frontend/site/components/checkout/PaymentWidget/PaymentWidget.module.css new file mode 100644 index 00000000..38dcab0c --- /dev/null +++ b/services/frontend/site/components/checkout/PaymentWidget/PaymentWidget.module.css @@ -0,0 +1,4 @@ +.root { + @apply border border-accent-2 px-6 py-5 mb-4 text-center + flex items-center cursor-pointer hover:border-accent-4; +} diff --git a/services/frontend/site/components/checkout/PaymentWidget/PaymentWidget.tsx b/services/frontend/site/components/checkout/PaymentWidget/PaymentWidget.tsx new file mode 100644 index 00000000..9b496bb4 --- /dev/null +++ b/services/frontend/site/components/checkout/PaymentWidget/PaymentWidget.tsx @@ -0,0 +1,28 @@ +import { FC } from 'react' +import s from './PaymentWidget.module.css' +import { ChevronRight, CreditCard, Check } from '@components/icons' + +interface ComponentProps { + onClick?: () => any + isValid?: boolean +} + +const PaymentWidget: FC = ({ onClick, isValid }) => { + /* Shipping Address + Only available with checkout set to true - + This means that the provider does offer checkout functionality. */ + return ( +
    +
    + + + Add Payment Method + + {/* VISA #### #### #### 2345 */} +
    +
    {isValid ? : }
    +
    + ) +} + +export default PaymentWidget diff --git a/services/frontend/site/components/checkout/PaymentWidget/index.ts b/services/frontend/site/components/checkout/PaymentWidget/index.ts new file mode 100644 index 00000000..18cadea5 --- /dev/null +++ b/services/frontend/site/components/checkout/PaymentWidget/index.ts @@ -0,0 +1 @@ +export { default } from './PaymentWidget' diff --git a/services/frontend/site/components/checkout/ShippingView/ShippingView.module.css b/services/frontend/site/components/checkout/ShippingView/ShippingView.module.css new file mode 100644 index 00000000..1b19044e --- /dev/null +++ b/services/frontend/site/components/checkout/ShippingView/ShippingView.module.css @@ -0,0 +1,21 @@ +.fieldset { + @apply flex flex-col my-3; +} + +.fieldset .label { + @apply text-accent-7 uppercase text-xs font-medium mb-2; +} + +.fieldset .input, +.fieldset .select { + @apply p-2 border border-accent-8 w-full text-sm font-normal bg-primary; +} + +.fieldset .input:focus, +.fieldset .select:focus { + @apply outline-none shadow-outline-normal; +} + +.radio { + @apply bg-black; +} diff --git a/services/frontend/site/components/checkout/ShippingView/ShippingView.tsx b/services/frontend/site/components/checkout/ShippingView/ShippingView.tsx new file mode 100644 index 00000000..6628d944 --- /dev/null +++ b/services/frontend/site/components/checkout/ShippingView/ShippingView.tsx @@ -0,0 +1,119 @@ +import { FC } from 'react' +import cn from 'clsx' + +import Button from '@components/ui/Button' +import { useUI } from '@components/ui/context' +import SidebarLayout from '@components/common/SidebarLayout' +import useAddAddress from '@framework/customer/address/use-add-item' + +import s from './ShippingView.module.css' + +interface Form extends HTMLFormElement { + cardHolder: HTMLInputElement + cardNumber: HTMLInputElement + cardExpireDate: HTMLInputElement + cardCvc: HTMLInputElement + firstName: HTMLInputElement + lastName: HTMLInputElement + company: HTMLInputElement + streetNumber: HTMLInputElement + zipCode: HTMLInputElement + city: HTMLInputElement + country: HTMLSelectElement +} + +const ShippingView: FC = () => { + const { setSidebarView } = useUI() + //const addAddress = useAddAddress() + + async function handleSubmit(event: React.ChangeEvent
    ) { + event.preventDefault() + + // await addAddress({ + // type: event.target.type.value, + // firstName: event.target.firstName.value, + // lastName: event.target.lastName.value, + // company: event.target.company.value, + // streetNumber: event.target.streetNumber.value, + // apartments: event.target.streetNumber.value, + // zipCode: event.target.zipCode.value, + // city: event.target.city.value, + // country: event.target.country.value, + // }) + + setSidebarView('CHECKOUT_VIEW') + } + + return ( + + setSidebarView('CHECKOUT_VIEW')}> +
    +

    + Shipping +

    +
    + {/*
    + + Same as billing address +
    +
    + + + Use a different shipping address + +
    +
    */} +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    +
    +
    + +
    +
    +
    + ) +} + +export default ShippingView diff --git a/services/frontend/site/components/checkout/ShippingView/index.ts b/services/frontend/site/components/checkout/ShippingView/index.ts new file mode 100644 index 00000000..428e7e4f --- /dev/null +++ b/services/frontend/site/components/checkout/ShippingView/index.ts @@ -0,0 +1 @@ +export { default } from './ShippingView' diff --git a/services/frontend/site/components/checkout/ShippingWidget/ShippingWidget.module.css b/services/frontend/site/components/checkout/ShippingWidget/ShippingWidget.module.css new file mode 100644 index 00000000..38dcab0c --- /dev/null +++ b/services/frontend/site/components/checkout/ShippingWidget/ShippingWidget.module.css @@ -0,0 +1,4 @@ +.root { + @apply border border-accent-2 px-6 py-5 mb-4 text-center + flex items-center cursor-pointer hover:border-accent-4; +} diff --git a/services/frontend/site/components/checkout/ShippingWidget/ShippingWidget.tsx b/services/frontend/site/components/checkout/ShippingWidget/ShippingWidget.tsx new file mode 100644 index 00000000..1418a2b5 --- /dev/null +++ b/services/frontend/site/components/checkout/ShippingWidget/ShippingWidget.tsx @@ -0,0 +1,31 @@ +import { FC } from 'react' +import s from './ShippingWidget.module.css' +import { ChevronRight, MapPin, Check } from '@components/icons' + +interface ComponentProps { + onClick?: () => any + isValid?: boolean +} + +const ShippingWidget: FC = ({ onClick, isValid }) => { + /* Shipping Address + Only available with checkout set to true - + This means that the provider does offer checkout functionality. */ + return ( +
    +
    + + + Add Shipping Address + + {/* + 1046 Kearny Street.
    + San Franssisco, California +
    */} +
    +
    {isValid ? : }
    +
    + ) +} + +export default ShippingWidget diff --git a/services/frontend/site/components/checkout/ShippingWidget/index.ts b/services/frontend/site/components/checkout/ShippingWidget/index.ts new file mode 100644 index 00000000..88e6dca4 --- /dev/null +++ b/services/frontend/site/components/checkout/ShippingWidget/index.ts @@ -0,0 +1 @@ +export { default } from './ShippingWidget' diff --git a/services/frontend/site/components/checkout/context.tsx b/services/frontend/site/components/checkout/context.tsx new file mode 100644 index 00000000..b53b45a5 --- /dev/null +++ b/services/frontend/site/components/checkout/context.tsx @@ -0,0 +1,111 @@ +import React, { + FC, + useCallback, + useMemo, + useReducer, + useContext, + createContext, +} from 'react' +import type { CardFields } from '@commerce/types/customer/card' +import type { AddressFields } from '@commerce/types/customer/address' + +export type State = { + cardFields: CardFields + addressFields: AddressFields +} + +type CheckoutContextType = State & { + setCardFields: (cardFields: CardFields) => void + setAddressFields: (addressFields: AddressFields) => void + clearCheckoutFields: () => void +} + +type Action = + | { + type: 'SET_CARD_FIELDS' + card: CardFields + } + | { + type: 'SET_ADDRESS_FIELDS' + address: AddressFields + } + | { + type: 'CLEAR_CHECKOUT_FIELDS' + } + +const initialState: State = { + cardFields: {} as CardFields, + addressFields: {} as AddressFields, +} + +export const CheckoutContext = createContext(initialState) + +CheckoutContext.displayName = 'CheckoutContext' + +const checkoutReducer = (state: State, action: Action): State => { + switch (action.type) { + case 'SET_CARD_FIELDS': + return { + ...state, + cardFields: action.card, + } + case 'SET_ADDRESS_FIELDS': + return { + ...state, + addressFields: action.address, + } + case 'CLEAR_CHECKOUT_FIELDS': + return { + ...state, + cardFields: initialState.cardFields, + addressFields: initialState.addressFields, + } + default: + return state + } +} + +export const CheckoutProvider: FC = (props) => { + const [state, dispatch] = useReducer(checkoutReducer, initialState) + + const setCardFields = useCallback( + (card: CardFields) => dispatch({ type: 'SET_CARD_FIELDS', card }), + [dispatch] + ) + + const setAddressFields = useCallback( + (address: AddressFields) => + dispatch({ type: 'SET_ADDRESS_FIELDS', address }), + [dispatch] + ) + + const clearCheckoutFields = useCallback( + () => dispatch({ type: 'CLEAR_CHECKOUT_FIELDS' }), + [dispatch] + ) + + const cardFields = useMemo(() => state.cardFields, [state.cardFields]) + + const addressFields = useMemo(() => state.addressFields, [state.addressFields]) + + const value = useMemo( + () => ({ + cardFields, + addressFields, + setCardFields, + setAddressFields, + clearCheckoutFields, + }), + [cardFields, addressFields, setCardFields, setAddressFields, clearCheckoutFields] + ) + + return +} + +export const useCheckoutContext = () => { + const context = useContext(CheckoutContext) + if (context === undefined) { + throw new Error(`useCheckoutContext must be used within a CheckoutProvider`) + } + return context +} diff --git a/services/frontend/site/components/common/Ad/Ad.tsx b/services/frontend/site/components/common/Ad/Ad.tsx new file mode 100644 index 00000000..db9b26e3 --- /dev/null +++ b/services/frontend/site/components/common/Ad/Ad.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; + +export interface AdDataResults { + data: object | null + path: string +} +// Advertisement banner +function Ad() { + const [data, setData] = React.useState(null) + const [isLoading, setLoading] = React.useState(false) + const adsPath = `${process.env.NEXT_PUBLIC_ADS_ROUTE}:${process.env.NEXT_PUBLIC_ADS_PORT}` + + function getRandomArbitrary(min: number, max:number) { + return Math.floor(Math.random() * (max - min) + min); + } + + function fetchAd() { + fetch(`${adsPath}/ads`) + .then((res) => res.json()) + .then((data) => { + const index = getRandomArbitrary(0,data.length); + setData(data[index]) + }) + .catch(e => console.error(e.message)) + .finally(() => { + setLoading(false) + }) + } + + React.useEffect(() => { + setLoading(true) + fetchAd() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + if (isLoading) return ( +
    + AD HERE +
    + ) + if (!data) return ( +
    + AD HERE +
    + ) + + return ( +
    + + + Landscape picture + +
    + ) +} + +export default Ad \ No newline at end of file diff --git a/services/frontend/site/components/common/Ad/index.ts b/services/frontend/site/components/common/Ad/index.ts new file mode 100644 index 00000000..2601d24a --- /dev/null +++ b/services/frontend/site/components/common/Ad/index.ts @@ -0,0 +1 @@ +export { default } from './Ad' diff --git a/services/frontend/site/components/common/Avatar/Avatar.tsx b/services/frontend/site/components/common/Avatar/Avatar.tsx new file mode 100644 index 00000000..66353845 --- /dev/null +++ b/services/frontend/site/components/common/Avatar/Avatar.tsx @@ -0,0 +1,24 @@ +import { FC, useRef, useEffect } from 'react' +import { useUserAvatar } from '@lib/hooks/useUserAvatar' + +interface Props { + className?: string + children?: any +} + +const Avatar: FC = ({}) => { + let ref = useRef() as React.MutableRefObject + let { userAvatar } = useUserAvatar() + + return ( +
    + {/* Add an image - We're generating a gradient as placeholder */} +
    + ) +} + +export default Avatar diff --git a/services/frontend/site/components/common/Avatar/index.ts b/services/frontend/site/components/common/Avatar/index.ts new file mode 100644 index 00000000..a4600ec7 --- /dev/null +++ b/services/frontend/site/components/common/Avatar/index.ts @@ -0,0 +1 @@ +export { default } from './Avatar' diff --git a/services/frontend/site/components/common/Discount/Discount.tsx b/services/frontend/site/components/common/Discount/Discount.tsx new file mode 100644 index 00000000..3dccbaaa --- /dev/null +++ b/services/frontend/site/components/common/Discount/Discount.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; + +export interface DiscountCodeResults { + data: string | null +} + +function Discount() { + const [data, setData] = React.useState(null) + const [isLoading, setLoading] = React.useState(false) + const discountPath = `${process.env.NEXT_PUBLIC_DISCOUNTS_ROUTE}:${process.env.NEXT_PUBLIC_DISCOUNTS_PORT}` + + function getRandomArbitrary(min: number, max: number) { + return Math.floor(Math.random() * (max - min) + min); + } + + function fetchDiscountCode() { + fetch(`${discountPath}/discount`) + .then((res) => res.json()) + .then((data) => { + const index = getRandomArbitrary(0, data.length); + setData(data[index]["code"]) + }) + .catch(e => { console.error(e.message) }) + .finally(() => { setLoading(false) }) + } + + React.useEffect(() => { + setLoading(true) + fetchDiscountCode() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + + return ( +
    + { + isLoading ? (GET FREE SHIPPING WITH DISCOUNT CODE) : + !data ? (GET FREE SHIPPING WITH DISCOUNT CODE STOREDOG) : (GET FREE SHIPPING WITH DISCOUNT CODE   {data}) + } +
    + ) + +} + +export default Discount \ No newline at end of file diff --git a/services/frontend/site/components/common/Discount/index.ts b/services/frontend/site/components/common/Discount/index.ts new file mode 100644 index 00000000..79b9185f --- /dev/null +++ b/services/frontend/site/components/common/Discount/index.ts @@ -0,0 +1 @@ +export { default } from './Discount' diff --git a/services/frontend/site/components/common/FeatureBar/FeatureBar.module.css b/services/frontend/site/components/common/FeatureBar/FeatureBar.module.css new file mode 100644 index 00000000..50ebeca5 --- /dev/null +++ b/services/frontend/site/components/common/FeatureBar/FeatureBar.module.css @@ -0,0 +1,6 @@ +.root { + @apply text-center p-6 bg-primary text-sm flex-row + justify-center items-center font-medium fixed bottom-0 + w-full z-30 transition-all duration-300 ease-out + md:flex md:text-left; +} diff --git a/services/frontend/site/components/common/FeatureBar/FeatureBar.tsx b/services/frontend/site/components/common/FeatureBar/FeatureBar.tsx new file mode 100644 index 00000000..07552861 --- /dev/null +++ b/services/frontend/site/components/common/FeatureBar/FeatureBar.tsx @@ -0,0 +1,39 @@ +import cn from 'clsx' +import s from './FeatureBar.module.css' + +interface FeatureBarProps { + className?: string + title: string + description?: string + hide?: boolean + action?: React.ReactNode +} + +const FeatureBar: React.FC = ({ + title, + description, + className, + action, + hide, +}) => { + const rootClassName = cn( + s.root, + { + transform: true, + 'translate-y-0 opacity-100': !hide, + 'translate-y-full opacity-0': hide, + }, + className + ) + return ( +
    + {title} + + {description} + + {action && action} +
    + ) +} + +export default FeatureBar diff --git a/services/frontend/site/components/common/FeatureBar/index.ts b/services/frontend/site/components/common/FeatureBar/index.ts new file mode 100644 index 00000000..d78bc9d2 --- /dev/null +++ b/services/frontend/site/components/common/FeatureBar/index.ts @@ -0,0 +1 @@ +export { default } from './FeatureBar' diff --git a/services/frontend/site/components/common/Footer/Footer.module.css b/services/frontend/site/components/common/Footer/Footer.module.css new file mode 100644 index 00000000..2ba49208 --- /dev/null +++ b/services/frontend/site/components/common/Footer/Footer.module.css @@ -0,0 +1,13 @@ +.root { + @apply border-t border-accent-2; +} + +.link { + & > svg { + @apply transform duration-75 ease-linear; + } + + &:hover > svg { + @apply scale-110; + } +} diff --git a/services/frontend/site/components/common/Footer/Footer.tsx b/services/frontend/site/components/common/Footer/Footer.tsx new file mode 100644 index 00000000..1e5e54a6 --- /dev/null +++ b/services/frontend/site/components/common/Footer/Footer.tsx @@ -0,0 +1,103 @@ +import React, { FC } from 'react'; +import cn from 'clsx'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import type { Page } from '@commerce/types/page'; +import getSlug from '@lib/get-slug'; +import { Logo, Container } from '@components/ui'; +import s from './Footer.module.css'; + +interface Props { + className?: string; + children?: any; + pages?: Page[]; +} + +const links = [ + { + name: 'Home', + url: '/', + }, +]; + +const Footer: FC = ({ className, pages }) => { + const { sitePages } = usePages(pages); + const rootClassName = cn(s.root, className); + + return ( +
    + +
    +
    + + + + + + + +
    +
    +
    + {[...links, ...sitePages].map((page) => ( + + + + {page.name} + + + + ))} +
    +
    +
    +
    +
    + + © {new Date().getFullYear()} Storedog, Inc. All rights + reserved. + +
    + + *Unfortunately, nothing here is actually for sale. This site is + for{' '} + + Datadog + {' '} + training lab purposes only.* + +
    +
    +
    +
    + ); +}; + +function usePages(pages?: Page[]) { + const { locale } = useRouter(); + const sitePages: Page[] = []; + + if (pages) { + pages.forEach((page) => { + const slug = page.url && getSlug(page.url); + if (!slug) return; + if (locale && !slug.startsWith(`${locale}/`)) return; + sitePages.push(page); + }); + } + + return { + sitePages: sitePages.sort(bySortOrder), + }; +} + +// Sort pages by the sort order assigned in the BC dashboard +function bySortOrder(a: Page, b: Page) { + return (a.sort_order ?? 0) - (b.sort_order ?? 0); +} + +export default Footer; diff --git a/services/frontend/site/components/common/Footer/index.ts b/services/frontend/site/components/common/Footer/index.ts new file mode 100644 index 00000000..5d06e9b7 --- /dev/null +++ b/services/frontend/site/components/common/Footer/index.ts @@ -0,0 +1 @@ +export { default } from './Footer' diff --git a/services/frontend/site/components/common/Head/Head.tsx b/services/frontend/site/components/common/Head/Head.tsx new file mode 100644 index 00000000..ed43c0d3 --- /dev/null +++ b/services/frontend/site/components/common/Head/Head.tsx @@ -0,0 +1,17 @@ +import type { VFC } from 'react' +import { SEO } from '@components/common' + +const Head: VFC = () => { + return ( + + + + + ) +} + +export default Head diff --git a/services/frontend/site/components/common/Head/index.ts b/services/frontend/site/components/common/Head/index.ts new file mode 100644 index 00000000..b317a124 --- /dev/null +++ b/services/frontend/site/components/common/Head/index.ts @@ -0,0 +1 @@ +export { default } from './Head' diff --git a/services/frontend/site/components/common/HomeAllProductsGrid/HomeAllProductsGrid.module.css b/services/frontend/site/components/common/HomeAllProductsGrid/HomeAllProductsGrid.module.css new file mode 100644 index 00000000..ec2f8ea4 --- /dev/null +++ b/services/frontend/site/components/common/HomeAllProductsGrid/HomeAllProductsGrid.module.css @@ -0,0 +1,23 @@ +.root { + @apply py-12 flex flex-col w-full px-6; + + @screen md { + @apply flex-row; + } + + & .asideWrapper { + @apply pr-3 w-full relative; + + @screen md { + @apply w-48; + } + } + + & .aside { + @apply flex flex-row w-full justify-around mb-12; + + @screen md { + @apply mb-0 block sticky top-32; + } + } +} diff --git a/services/frontend/site/components/common/HomeAllProductsGrid/HomeAllProductsGrid.tsx b/services/frontend/site/components/common/HomeAllProductsGrid/HomeAllProductsGrid.tsx new file mode 100644 index 00000000..0a40bff2 --- /dev/null +++ b/services/frontend/site/components/common/HomeAllProductsGrid/HomeAllProductsGrid.tsx @@ -0,0 +1,73 @@ +import { FC } from 'react' +import Link from 'next/link' +import type { Product } from '@commerce/types/product' +import { Grid } from '@components/ui' +import { ProductCard } from '@components/product' +import s from './HomeAllProductsGrid.module.css' +import { getCategoryPath, getDesignerPath } from '@lib/search' + +interface Props { + categories?: any + brands?: any + products?: Product[] +} + +const HomeAllProductsGrid: FC = ({ + categories, + brands, + products = [], +}) => { + return ( +
    +
    +
    + + +
    +
    +
    + + {products.map((product) => ( + + ))} + +
    +
    + ) +} + +export default HomeAllProductsGrid diff --git a/services/frontend/site/components/common/HomeAllProductsGrid/index.ts b/services/frontend/site/components/common/HomeAllProductsGrid/index.ts new file mode 100644 index 00000000..31d313d1 --- /dev/null +++ b/services/frontend/site/components/common/HomeAllProductsGrid/index.ts @@ -0,0 +1 @@ +export { default } from './HomeAllProductsGrid' diff --git a/services/frontend/site/components/common/I18nWidget/I18nWidget.module.css b/services/frontend/site/components/common/I18nWidget/I18nWidget.module.css new file mode 100644 index 00000000..5b486d53 --- /dev/null +++ b/services/frontend/site/components/common/I18nWidget/I18nWidget.module.css @@ -0,0 +1,48 @@ +.root { + @apply relative; +} + +.button { + @apply h-10 px-2 rounded-md border border-accent-2 flex items-center justify-center transition-colors ease-linear; +} + +.button:hover { + @apply border-accent-3 shadow-sm; +} + +.button:focus { + @apply outline-none; +} + +.dropdownMenu { + @apply fixed right-0 top-12 mt-2 origin-top-right outline-none bg-primary z-40 w-full h-full; +} + +.item { + @apply flex cursor-pointer px-6 py-3 transition ease-in-out duration-150 text-primary leading-6 font-medium items-center; + text-transform: capitalize; +} + +.item:hover { + @apply bg-accent-1; +} + +.icon { + transition: transform 0.2s ease; +} + +.icon.active { + transform: rotate(180deg); +} + +@screen lg { + .dropdownMenu { + @apply absolute border border-accent-1 shadow-lg w-56 h-auto; + } +} + +@screen md { + .closeButton { + @apply hidden; + } +} diff --git a/services/frontend/site/components/common/I18nWidget/I18nWidget.tsx b/services/frontend/site/components/common/I18nWidget/I18nWidget.tsx new file mode 100644 index 00000000..d09dd1d6 --- /dev/null +++ b/services/frontend/site/components/common/I18nWidget/I18nWidget.tsx @@ -0,0 +1,101 @@ +import cn from 'clsx' +import Link from 'next/link' +import { FC, useState } from 'react' +import { useRouter } from 'next/router' +import s from './I18nWidget.module.css' +import { Cross, ChevronUp } from '@components/icons' +import ClickOutside from '@lib/click-outside' +interface LOCALE_DATA { + name: string + img: { + filename: string + alt: string + } +} + +const LOCALES_MAP: Record = { + es: { + name: 'Español', + img: { + filename: 'flag-es-co.svg', + alt: 'Bandera Colombiana', + }, + }, + 'en-US': { + name: 'English', + img: { + filename: 'flag-en-us.svg', + alt: 'US Flag', + }, + }, +} + +const I18nWidget: FC = () => { + const [display, setDisplay] = useState(false) + const { + locale, + locales, + defaultLocale = 'en-US', + asPath: currentPath, + } = useRouter() + + const options = locales?.filter((val) => val !== locale) + const currentLocale = locale || defaultLocale + + return ( + setDisplay(false)}> + + + ) +} + +export default I18nWidget diff --git a/services/frontend/site/components/common/I18nWidget/index.ts b/services/frontend/site/components/common/I18nWidget/index.ts new file mode 100644 index 00000000..46525c3d --- /dev/null +++ b/services/frontend/site/components/common/I18nWidget/index.ts @@ -0,0 +1 @@ +export { default } from './I18nWidget' diff --git a/services/frontend/site/components/common/Layout/Layout.module.css b/services/frontend/site/components/common/Layout/Layout.module.css new file mode 100644 index 00000000..bb90675a --- /dev/null +++ b/services/frontend/site/components/common/Layout/Layout.module.css @@ -0,0 +1,4 @@ +.root { + @apply h-full bg-primary mx-auto transition-colors duration-150; + max-width: 2460px; +} diff --git a/services/frontend/site/components/common/Layout/Layout.tsx b/services/frontend/site/components/common/Layout/Layout.tsx new file mode 100644 index 00000000..ba6357ad --- /dev/null +++ b/services/frontend/site/components/common/Layout/Layout.tsx @@ -0,0 +1,130 @@ +import cn from 'clsx'; +import s from './Layout.module.css'; +import dynamic from 'next/dynamic'; +import LoginView from '@components/auth/LoginView'; +import { useUI } from '@components/ui/context'; +import { Navbar, Footer } from '@components/common'; +import ShippingView from '@components/checkout/ShippingView'; +import CartSidebarView from '@components/cart/CartSidebarView'; +import { Sidebar, Button, LoadingDots } from '@components/ui'; +import PaymentMethodView from '@components/checkout/PaymentMethodView'; +import CheckoutSidebarView from '@components/checkout/CheckoutSidebarView'; +import OrderConfirmView from '@components/checkout/OrderConfirmView'; +import { CheckoutProvider } from '@components/checkout/context'; +import { MenuSidebarView } from '@components/common/UserNav'; +import Discount from '@components/common/Discount'; +import Ad from '@components/common/Ad'; +import type { Page } from '@commerce/types/page'; +import type { Category } from '@commerce/types/site'; +import type { Link as LinkProps } from '../UserNav/MenuSidebarView'; + +const Loading = () => ( +
    + +
    +); + +const dynamicProps = { + loading: Loading, +}; + +const SignUpView = dynamic(() => import('@components/auth/SignUpView'), { + ...dynamicProps, +}); + +const ForgotPassword = dynamic( + () => import('@components/auth/ForgotPassword'), + { + ...dynamicProps, + } +); + +const FeatureBar = dynamic(() => import('@components/common/FeatureBar'), { + ...dynamicProps, +}); + +const Modal = dynamic(() => import('@components/ui/Modal'), { + ...dynamicProps, + ssr: false, +}); + +interface Props { + pageProps: { + pages?: Page[]; + categories: Category[]; + }; +} + +const ModalView: React.FC<{ modalView: string; closeModal(): any }> = ({ + modalView, + closeModal, +}) => { + return ( + + {modalView === 'LOGIN_VIEW' && } + {modalView === 'SIGNUP_VIEW' && } + {modalView === 'FORGOT_VIEW' && } + + ); +}; + +const ModalUI: React.FC = () => { + const { displayModal, closeModal, modalView } = useUI(); + return displayModal ? ( + + ) : null; +}; + +const SidebarView: React.FC<{ + sidebarView: string; + closeSidebar(): any; + links: LinkProps[]; +}> = ({ sidebarView, closeSidebar, links }) => { + return ( + + {sidebarView === 'CART_VIEW' && } + {sidebarView === 'SHIPPING_VIEW' && } + {sidebarView === 'PAYMENT_VIEW' && } + {sidebarView === 'CHECKOUT_VIEW' && } + {sidebarView === 'MOBILE_MENU_VIEW' && } + {sidebarView === 'ORDER_CONFIRM_VIEW' && } + + ); +}; + +const SidebarUI: React.FC<{ links: LinkProps[] }> = ({ links }) => { + const { displaySidebar, closeSidebar, sidebarView } = useUI(); + return displaySidebar ? ( + + ) : null; +}; + +const Layout: React.FC = ({ + children, + pageProps: { categories = [], ...pageProps }, +}) => { + const navBarlinks = categories.slice(0, 2).map((c) => ({ + label: c.name, + href: `/search/${c.slug}`, + })); + + return ( +
    + + +
    {children}
    + +
    + + + + +
    + ); +}; + +export default Layout; diff --git a/services/frontend/site/components/common/Layout/index.ts b/services/frontend/site/components/common/Layout/index.ts new file mode 100644 index 00000000..0e2737ee --- /dev/null +++ b/services/frontend/site/components/common/Layout/index.ts @@ -0,0 +1 @@ +export { default } from './Layout' diff --git a/services/frontend/site/components/common/Navbar/Navbar.module.css b/services/frontend/site/components/common/Navbar/Navbar.module.css new file mode 100644 index 00000000..eb07ea27 --- /dev/null +++ b/services/frontend/site/components/common/Navbar/Navbar.module.css @@ -0,0 +1,35 @@ +.root { + @apply sticky top-0 bg-primary z-40 transition-all duration-150; + min-height: 74px; +} + +.nav { + @apply relative flex flex-row justify-between py-4 md:py-4; +} + +.navMenu { + @apply hidden ml-6 space-x-4 lg:block; +} + +.link { + @apply inline-flex items-center leading-6 + transition ease-in-out duration-75 cursor-pointer + text-accent-5 text-lg; +} + +.link:hover { + @apply text-accent-9; +} + +.link:focus { + @apply outline-none text-accent-8; +} + +.logo { + @apply cursor-pointer border transform duration-100 ease-in-out px-3 py-2 bg-primary-2 text-white text-2xl; + + &:hover { + @apply shadow-md; + transform: scale(1.05); + } +} diff --git a/services/frontend/site/components/common/Navbar/Navbar.tsx b/services/frontend/site/components/common/Navbar/Navbar.tsx new file mode 100644 index 00000000..96ad8303 --- /dev/null +++ b/services/frontend/site/components/common/Navbar/Navbar.tsx @@ -0,0 +1,58 @@ +import { FC } from 'react'; +import Link from 'next/link'; +import s from './Navbar.module.css'; +import NavbarRoot from './NavbarRoot'; +import { Logo, Container } from '@components/ui'; +import { Searchbar, UserNav } from '@components/common'; + +interface Link { + href: string; + label: string; +} + +interface NavbarProps { + links?: Link[]; +} + +const Navbar: FC = ({ links }) => ( + + +
    +
    + + + + + + +
    + {process.env.COMMERCE_SEARCH_ENABLED && ( +
    + +
    + )} +
    + +
    +
    + {process.env.COMMERCE_SEARCH_ENABLED && ( +
    + +
    + )} +
    +
    +); + +export default Navbar; diff --git a/services/frontend/site/components/common/Navbar/NavbarRoot.tsx b/services/frontend/site/components/common/Navbar/NavbarRoot.tsx new file mode 100644 index 00000000..16556e84 --- /dev/null +++ b/services/frontend/site/components/common/Navbar/NavbarRoot.tsx @@ -0,0 +1,33 @@ +import { FC, useState, useEffect } from 'react' +import throttle from 'lodash.throttle' +import cn from 'clsx' +import s from './Navbar.module.css' + +const NavbarRoot: FC = ({ children }) => { + const [hasScrolled, setHasScrolled] = useState(false) + + useEffect(() => { + const handleScroll = throttle(() => { + const offset = 0 + const { scrollTop } = document.documentElement + const scrolled = scrollTop > offset + + if (hasScrolled !== scrolled) { + setHasScrolled(scrolled) + } + }, 200) + + document.addEventListener('scroll', handleScroll) + return () => { + document.removeEventListener('scroll', handleScroll) + } + }, [hasScrolled]) + + return ( +
    + {children} +
    + ) +} + +export default NavbarRoot diff --git a/services/frontend/site/components/common/Navbar/index.ts b/services/frontend/site/components/common/Navbar/index.ts new file mode 100644 index 00000000..e6400ae4 --- /dev/null +++ b/services/frontend/site/components/common/Navbar/index.ts @@ -0,0 +1 @@ +export { default } from './Navbar' diff --git a/services/frontend/site/components/common/SEO/SEO.tsx b/services/frontend/site/components/common/SEO/SEO.tsx new file mode 100644 index 00000000..b5e3ad23 --- /dev/null +++ b/services/frontend/site/components/common/SEO/SEO.tsx @@ -0,0 +1,157 @@ +import Head from 'next/head' +import { FC, Fragment, ReactNode } from 'react' +import config from '@config/seo_meta.json' + +const storeUrl = + process.env.NEXT_PUBLIC_STORE_URL || process.env.NEXT_PUBLIC_VERCEL_URL +const storeBaseUrl = storeUrl ? `https://${storeUrl}` : null + +interface OgImage { + url?: string + width?: string + height?: string + alt?: string +} + +interface Props { + title?: string + description?: string + robots?: string + openGraph?: { + title?: string + type?: string + locale?: string + description?: string + site_name?: string + url?: string + images?: OgImage[] + } + children?: ReactNode +} + +const ogImage = ({ url, width, height, alt }: OgImage, index: number) => { + // generate full URL for OG image url with store base URL + const imgUrl = storeBaseUrl ? new URL(url!, storeBaseUrl).toString() : url + return ( + + + + + + + ) +} + +const SEO: FC = ({ + title, + description, + openGraph, + robots, + children, +}) => { + /** + * @see https://nextjs.org/docs/api-reference/next/head + * + * meta or any other elements need to be contained as direct children of the Head element, + * or wrapped into maximum one level of or arrays + * otherwise the tags won't be correctly picked up on client-side navigations. + * + * The `key` property makes the tag is only rendered once, + */ + return ( + + + {title ? `${config.titleTemplate.replace(/%s/g, title)}` : config.title} + + + + + + + + {openGraph?.locale && ( + + )} + {openGraph?.images?.length + ? openGraph.images.map((img, index) => ogImage(img, index)) + : ogImage(config.openGraph.images[0], 0)} + {config.twitter.cardType && ( + + )} + {config.twitter.site && ( + + )} + {config.twitter.handle && ( + + )} + + + {children} + + ) +} + +export default SEO diff --git a/services/frontend/site/components/common/SEO/index.ts b/services/frontend/site/components/common/SEO/index.ts new file mode 100644 index 00000000..e20ec860 --- /dev/null +++ b/services/frontend/site/components/common/SEO/index.ts @@ -0,0 +1 @@ +export { default } from './SEO' diff --git a/services/frontend/site/components/common/Searchbar/Searchbar.module.css b/services/frontend/site/components/common/Searchbar/Searchbar.module.css new file mode 100644 index 00000000..7f20ed97 --- /dev/null +++ b/services/frontend/site/components/common/Searchbar/Searchbar.module.css @@ -0,0 +1,29 @@ +.root { + @apply relative text-sm bg-accent-0 text-base w-full transition-colors duration-150 border border-accent-2; +} + +.input { + @apply bg-transparent px-3 py-2 appearance-none w-full transition duration-150 ease-in-out pr-10; +} + +.input::placeholder { + @apply text-accent-3; +} + +.input:focus { + @apply outline-none shadow-outline-normal; +} + +.iconContainer { + @apply absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none; +} + +.icon { + @apply h-5 w-5; +} + +@screen sm { + .input { + min-width: 300px; + } +} diff --git a/services/frontend/site/components/common/Searchbar/Searchbar.tsx b/services/frontend/site/components/common/Searchbar/Searchbar.tsx new file mode 100644 index 00000000..0d9c8e80 --- /dev/null +++ b/services/frontend/site/components/common/Searchbar/Searchbar.tsx @@ -0,0 +1,60 @@ +import { FC, memo, useEffect } from 'react' +import cn from 'clsx' +import s from './Searchbar.module.css' +import { useRouter } from 'next/router' + +interface Props { + className?: string + id?: string +} + +const Searchbar: FC = ({ className, id = 'search' }) => { + const router = useRouter() + + useEffect(() => { + router.prefetch('/search') + }, [router]) + + const handleKeyUp = (e: React.KeyboardEvent) => { + e.preventDefault() + + if (e.key === 'Enter') { + const q = e.currentTarget.value + + router.push( + { + pathname: `/search`, + query: q ? { q } : {}, + }, + undefined, + { shallow: true } + ) + } + } + + return ( +
    + + +
    + + + +
    +
    + ) +} + +export default memo(Searchbar) diff --git a/services/frontend/site/components/common/Searchbar/index.ts b/services/frontend/site/components/common/Searchbar/index.ts new file mode 100644 index 00000000..e6c0e36c --- /dev/null +++ b/services/frontend/site/components/common/Searchbar/index.ts @@ -0,0 +1 @@ +export { default } from './Searchbar' diff --git a/services/frontend/site/components/common/SidebarLayout/SidebarLayout.module.css b/services/frontend/site/components/common/SidebarLayout/SidebarLayout.module.css new file mode 100644 index 00000000..a8940dc5 --- /dev/null +++ b/services/frontend/site/components/common/SidebarLayout/SidebarLayout.module.css @@ -0,0 +1,20 @@ +.root { + @apply relative h-full flex flex-col; +} + +.header { + @apply sticky top-0 pl-4 py-4 pr-6 + flex items-center justify-between + bg-accent-0 box-border w-full z-10; + min-height: 66px; +} + +.container { + @apply flex flex-col flex-1 box-border; +} + +@screen lg { + .header { + min-height: 74px; + } +} diff --git a/services/frontend/site/components/common/SidebarLayout/SidebarLayout.tsx b/services/frontend/site/components/common/SidebarLayout/SidebarLayout.tsx new file mode 100644 index 00000000..d301ccc2 --- /dev/null +++ b/services/frontend/site/components/common/SidebarLayout/SidebarLayout.tsx @@ -0,0 +1,50 @@ +import React, { FC } from 'react'; +import { Cross, ChevronLeft } from '@components/icons'; +import { UserNav } from '@components/common'; +import cn from 'clsx'; +import s from './SidebarLayout.module.css'; + +type ComponentProps = { className?: string } & ( + | { handleClose: () => any; handleBack?: never } + | { handleBack: () => any; handleClose?: never } +); + +const SidebarLayout: FC = ({ + children, + className, + handleBack, + handleClose, +}) => { + return ( +
    +
    + {handleClose && ( + + )} + {handleBack && ( + + )} + + +
    +
    {children}
    +
    + ); +}; + +export default SidebarLayout; diff --git a/services/frontend/site/components/common/SidebarLayout/index.ts b/services/frontend/site/components/common/SidebarLayout/index.ts new file mode 100644 index 00000000..45ded0cf --- /dev/null +++ b/services/frontend/site/components/common/SidebarLayout/index.ts @@ -0,0 +1 @@ +export { default } from './SidebarLayout' diff --git a/services/frontend/site/components/common/UserNav/CustomerMenuContent/CustomerMenuContent.module.css b/services/frontend/site/components/common/UserNav/CustomerMenuContent/CustomerMenuContent.module.css new file mode 100644 index 00000000..93a183a2 --- /dev/null +++ b/services/frontend/site/components/common/UserNav/CustomerMenuContent/CustomerMenuContent.module.css @@ -0,0 +1,31 @@ +.root { + @apply inset-0 fixed; + left: 72px; + z-index: 10; + height: 100vh; + min-width: 100vw; + transition: none; +} + +@media screen(lg) { + .root { + @apply static; + min-width: inherit; + height: inherit; + } +} + +.link { + @apply text-primary flex cursor-pointer px-6 py-3 + transition ease-in-out duration-150 leading-6 + font-medium items-center capitalize w-full box-border + outline-0; +} + +.link:hover { + @apply bg-accent-1 outline-none; +} + +.link.active { + @apply font-bold bg-accent-2; +} diff --git a/services/frontend/site/components/common/UserNav/CustomerMenuContent/CustomerMenuContent.tsx b/services/frontend/site/components/common/UserNav/CustomerMenuContent/CustomerMenuContent.tsx new file mode 100644 index 00000000..992d1717 --- /dev/null +++ b/services/frontend/site/components/common/UserNav/CustomerMenuContent/CustomerMenuContent.tsx @@ -0,0 +1,86 @@ +import cn from 'clsx' +import { useTheme } from 'next-themes' +import { useRouter } from 'next/router' +import { Moon, Sun } from '@components/icons' +import s from './CustomerMenuContent.module.css' +import useLogout from '@framework/auth/use-logout' +import { + DropdownContent, + DropdownMenuItem, +} from '@components/ui/Dropdown/Dropdown' + +const LINKS = [ + { + name: 'My Orders', + href: '/orders', + }, + { + name: 'My Profile', + href: '/profile', + }, + { + name: 'My Cart', + href: '/cart', + }, +] + +export default function CustomerMenuContent() { + const router = useRouter() + const logout = useLogout() + const { pathname } = useRouter() + const { theme, setTheme } = useTheme() + + function handleClick(_: React.MouseEvent, href: string) { + router.push(href) + } + + return ( + + {LINKS.map(({ name, href }) => ( + + handleClick(e, href)} + > + {name} + + + ))} + + { + setTheme(theme === 'dark' ? 'light' : 'dark') + }} + > +
    + Theme: {theme}{' '} +
    +
    + {theme == 'dark' ? ( + + ) : ( + + )} +
    +
    +
    + + logout()} + > + Logout + + +
    + ) +} diff --git a/services/frontend/site/components/common/UserNav/CustomerMenuContent/index.ts b/services/frontend/site/components/common/UserNav/CustomerMenuContent/index.ts new file mode 100644 index 00000000..b465e81d --- /dev/null +++ b/services/frontend/site/components/common/UserNav/CustomerMenuContent/index.ts @@ -0,0 +1 @@ +export { default } from './CustomerMenuContent' diff --git a/services/frontend/site/components/common/UserNav/MenuSidebarView/MenuSidebarView.module.css b/services/frontend/site/components/common/UserNav/MenuSidebarView/MenuSidebarView.module.css new file mode 100644 index 00000000..6c05b013 --- /dev/null +++ b/services/frontend/site/components/common/UserNav/MenuSidebarView/MenuSidebarView.module.css @@ -0,0 +1,7 @@ +.root { + @apply px-4 sm:px-6 sm:w-full flex-1 z-20; +} + +.item { + @apply text-xl font-bold py-2; +} diff --git a/services/frontend/site/components/common/UserNav/MenuSidebarView/MenuSidebarView.tsx b/services/frontend/site/components/common/UserNav/MenuSidebarView/MenuSidebarView.tsx new file mode 100644 index 00000000..0a1aaaa9 --- /dev/null +++ b/services/frontend/site/components/common/UserNav/MenuSidebarView/MenuSidebarView.tsx @@ -0,0 +1,42 @@ +import Link from 'next/link'; +import s from './MenuSidebarView.module.css'; +import { useUI } from '@components/ui/context'; +import SidebarLayout from '@components/common/SidebarLayout'; +import type { Link as LinkProps } from './index'; + +export default function MenuSidebarView({ + links = [], +}: { + links?: LinkProps[]; +}) { + const { closeSidebar } = useUI(); + + return ( + closeSidebar()}> +
    + +
    +
    + ); +} + +MenuSidebarView; diff --git a/services/frontend/site/components/common/UserNav/MenuSidebarView/index.ts b/services/frontend/site/components/common/UserNav/MenuSidebarView/index.ts new file mode 100644 index 00000000..bdb5f8b2 --- /dev/null +++ b/services/frontend/site/components/common/UserNav/MenuSidebarView/index.ts @@ -0,0 +1,5 @@ +export { default } from './MenuSidebarView' +export interface Link { + href: string + label: string +} diff --git a/services/frontend/site/components/common/UserNav/UserNav.module.css b/services/frontend/site/components/common/UserNav/UserNav.module.css new file mode 100644 index 00000000..814cd058 --- /dev/null +++ b/services/frontend/site/components/common/UserNav/UserNav.module.css @@ -0,0 +1,59 @@ +.root { + @apply relative flex items-center; +} + +.list { + @apply flex flex-row items-center justify-items-end h-full; +} + +.item { + @apply ml-6 cursor-pointer relative transition ease-in-out + duration-100 flex items-center outline-none text-primary; +} + +.item:hover { + @apply text-accent-6 transition scale-110 duration-100; +} + +.item:first-child { + @apply ml-0; +} + +.item:focus, +.item:active { + @apply outline-none; +} + +.bagCount { + @apply border border-accent-1 bg-secondary text-secondary absolute rounded-full right-3 top-3 flex items-center justify-center font-bold text-xs; + padding-left: 2.5px; + padding-right: 2.5px; + min-width: 1.25rem; + min-height: 1.25rem; +} + +.avatarButton { + @apply inline-flex justify-center rounded-full; +} + +.mobileMenu { + @apply flex lg:hidden ml-6 text-white; +} + +.avatarButton:focus, +.mobileMenu:focus { + @apply outline-none; +} + +.dropdownDesktop { + @apply hidden -z-10; +} + +@media screen(lg) { + .dropdownDesktop { + @apply block; + } + .dropdownMobile { + @apply hidden; + } +} diff --git a/services/frontend/site/components/common/UserNav/UserNav.tsx b/services/frontend/site/components/common/UserNav/UserNav.tsx new file mode 100644 index 00000000..a5628f66 --- /dev/null +++ b/services/frontend/site/components/common/UserNav/UserNav.tsx @@ -0,0 +1,109 @@ +import cn from 'clsx'; +import Link from 'next/link'; +import s from './UserNav.module.css'; +import { Avatar } from '@components/common'; +import useCart from '@framework/cart/use-cart'; +import { useUI } from '@components/ui/context'; +import { Heart, Bag, Menu } from '@components/icons'; +import CustomerMenuContent from './CustomerMenuContent'; +import useCustomer from '@framework/customer/use-customer'; +import React from 'react'; +import { + Dropdown, + DropdownTrigger as DropdownTriggerInst, + Button, +} from '@components/ui'; + +import type { LineItem } from '@commerce/types/cart'; + +const countItem = (count: number, item: LineItem) => count + item.quantity; + +const UserNav: React.FC<{ + className?: string; +}> = ({ className }) => { + const { data } = useCart(); + const { data: isCustomerLoggedIn } = useCustomer(); + const { + toggleSidebar, + closeSidebarIfPresent, + openModal, + setSidebarView, + openSidebar, + } = useUI(); + + const itemsCount = data?.lineItems.reduce(countItem, 0) ?? 0; + const DropdownTrigger = isCustomerLoggedIn + ? DropdownTriggerInst + : React.Fragment; + + return ( + + ); +}; + +export default UserNav; diff --git a/services/frontend/site/components/common/UserNav/index.ts b/services/frontend/site/components/common/UserNav/index.ts new file mode 100644 index 00000000..0024bd05 --- /dev/null +++ b/services/frontend/site/components/common/UserNav/index.ts @@ -0,0 +1,3 @@ +export { default } from './UserNav' +export { default as MenuSidebarView } from './MenuSidebarView' +export { default as CustomerMenuContent } from './CustomerMenuContent' diff --git a/services/frontend/site/components/common/index.ts b/services/frontend/site/components/common/index.ts new file mode 100644 index 00000000..054110c7 --- /dev/null +++ b/services/frontend/site/components/common/index.ts @@ -0,0 +1,10 @@ +export { default as Avatar } from './Avatar' +export { default as FeatureBar } from './FeatureBar' +export { default as Footer } from './Footer' +export { default as Layout } from './Layout' +export { default as Navbar } from './Navbar' +export { default as Searchbar } from './Searchbar' +export { default as UserNav } from './UserNav' +export { default as Head } from './Head' +export { default as I18nWidget } from './I18nWidget' +export { default as SEO } from './SEO' diff --git a/services/frontend/site/components/icons/ArrowLeft.tsx b/services/frontend/site/components/icons/ArrowLeft.tsx new file mode 100644 index 00000000..8cc1e129 --- /dev/null +++ b/services/frontend/site/components/icons/ArrowLeft.tsx @@ -0,0 +1,27 @@ +const ArrowLeft = ({ ...props }) => { + return ( + + + + + ) +} + +export default ArrowLeft diff --git a/services/frontend/site/components/icons/ArrowRight.tsx b/services/frontend/site/components/icons/ArrowRight.tsx new file mode 100644 index 00000000..e644951d --- /dev/null +++ b/services/frontend/site/components/icons/ArrowRight.tsx @@ -0,0 +1,28 @@ +const ArrowRight = ({ ...props }) => { + return ( + + + + + ) +} + +export default ArrowRight diff --git a/services/frontend/site/components/icons/Bag.tsx b/services/frontend/site/components/icons/Bag.tsx new file mode 100644 index 00000000..de2cde0d --- /dev/null +++ b/services/frontend/site/components/icons/Bag.tsx @@ -0,0 +1,33 @@ +const Bag = ({ ...props }) => { + return ( + + + + + + ) +} + +export default Bag diff --git a/services/frontend/site/components/icons/Check.tsx b/services/frontend/site/components/icons/Check.tsx new file mode 100644 index 00000000..89c91a1e --- /dev/null +++ b/services/frontend/site/components/icons/Check.tsx @@ -0,0 +1,21 @@ +const Check = ({ ...props }) => { + return ( + + + + ) +} + +export default Check diff --git a/services/frontend/site/components/icons/ChevronDown.tsx b/services/frontend/site/components/icons/ChevronDown.tsx new file mode 100644 index 00000000..542e8056 --- /dev/null +++ b/services/frontend/site/components/icons/ChevronDown.tsx @@ -0,0 +1,20 @@ +const ChevronDown = ({ ...props }) => { + return ( + + + + ) +} + +export default ChevronDown diff --git a/services/frontend/site/components/icons/ChevronLeft.tsx b/services/frontend/site/components/icons/ChevronLeft.tsx new file mode 100644 index 00000000..4efb6a52 --- /dev/null +++ b/services/frontend/site/components/icons/ChevronLeft.tsx @@ -0,0 +1,20 @@ +const ChevronLeft = ({ ...props }) => { + return ( + + + + ) +} + +export default ChevronLeft diff --git a/services/frontend/site/components/icons/ChevronRight.tsx b/services/frontend/site/components/icons/ChevronRight.tsx new file mode 100644 index 00000000..8da0122c --- /dev/null +++ b/services/frontend/site/components/icons/ChevronRight.tsx @@ -0,0 +1,20 @@ +const ChevronRight = ({ ...props }) => { + return ( + + + + ) +} + +export default ChevronRight diff --git a/services/frontend/site/components/icons/ChevronUp.tsx b/services/frontend/site/components/icons/ChevronUp.tsx new file mode 100644 index 00000000..69b9959b --- /dev/null +++ b/services/frontend/site/components/icons/ChevronUp.tsx @@ -0,0 +1,20 @@ +const ChevronUp = ({ ...props }) => { + return ( + + + + ) +} + +export default ChevronUp diff --git a/services/frontend/site/components/icons/CreditCard.tsx b/services/frontend/site/components/icons/CreditCard.tsx new file mode 100644 index 00000000..958c3119 --- /dev/null +++ b/services/frontend/site/components/icons/CreditCard.tsx @@ -0,0 +1,21 @@ +const CreditCard = ({ ...props }) => { + return ( + + + + + ) +} + +export default CreditCard diff --git a/services/frontend/site/components/icons/Cross.tsx b/services/frontend/site/components/icons/Cross.tsx new file mode 100644 index 00000000..12e115ac --- /dev/null +++ b/services/frontend/site/components/icons/Cross.tsx @@ -0,0 +1,21 @@ +const Cross = ({ ...props }) => { + return ( + + + + + ) +} + +export default Cross diff --git a/services/frontend/site/components/icons/DoubleChevron.tsx b/services/frontend/site/components/icons/DoubleChevron.tsx new file mode 100644 index 00000000..198c3046 --- /dev/null +++ b/services/frontend/site/components/icons/DoubleChevron.tsx @@ -0,0 +1,22 @@ +const DoubleChevron = ({ ...props }) => { + return ( + + + + ) +} + +export default DoubleChevron diff --git a/services/frontend/site/components/icons/Github.tsx b/services/frontend/site/components/icons/Github.tsx new file mode 100644 index 00000000..1195a3c3 --- /dev/null +++ b/services/frontend/site/components/icons/Github.tsx @@ -0,0 +1,20 @@ +const Github = ({ ...props }) => { + return ( + + + + ) +} + +export default Github diff --git a/services/frontend/site/components/icons/Heart.tsx b/services/frontend/site/components/icons/Heart.tsx new file mode 100644 index 00000000..afa2f6aa --- /dev/null +++ b/services/frontend/site/components/icons/Heart.tsx @@ -0,0 +1,22 @@ +const Heart = ({ ...props }) => { + return ( + + + + ) +} + +export default Heart diff --git a/services/frontend/site/components/icons/Info.tsx b/services/frontend/site/components/icons/Info.tsx new file mode 100644 index 00000000..67c79cf2 --- /dev/null +++ b/services/frontend/site/components/icons/Info.tsx @@ -0,0 +1,22 @@ +const Info = ({ ...props }) => { + return ( + + + + + + ) +} + +export default Info diff --git a/services/frontend/site/components/icons/MapPin.tsx b/services/frontend/site/components/icons/MapPin.tsx new file mode 100644 index 00000000..6323b9c1 --- /dev/null +++ b/services/frontend/site/components/icons/MapPin.tsx @@ -0,0 +1,20 @@ +const MapPin = ({ ...props }) => { + return ( + + + + + ) +} + +export default MapPin diff --git a/services/frontend/site/components/icons/Menu.tsx b/services/frontend/site/components/icons/Menu.tsx new file mode 100644 index 00000000..3f280e31 --- /dev/null +++ b/services/frontend/site/components/icons/Menu.tsx @@ -0,0 +1,21 @@ +const Menu = ({ ...props }) => { + return ( + + + + ) +} + +export default Menu diff --git a/services/frontend/site/components/icons/Minus.tsx b/services/frontend/site/components/icons/Minus.tsx new file mode 100644 index 00000000..1e9411dd --- /dev/null +++ b/services/frontend/site/components/icons/Minus.tsx @@ -0,0 +1,15 @@ +const Minus = ({ ...props }) => { + return ( + + + + ) +} + +export default Minus diff --git a/services/frontend/site/components/icons/Moon.tsx b/services/frontend/site/components/icons/Moon.tsx new file mode 100644 index 00000000..e02f2a30 --- /dev/null +++ b/services/frontend/site/components/icons/Moon.tsx @@ -0,0 +1,20 @@ +const Moon = ({ ...props }) => { + return ( + + + + ) +} + +export default Moon diff --git a/services/frontend/site/components/icons/Plus.tsx b/services/frontend/site/components/icons/Plus.tsx new file mode 100644 index 00000000..ad030b92 --- /dev/null +++ b/services/frontend/site/components/icons/Plus.tsx @@ -0,0 +1,22 @@ +const Plus = ({ ...props }) => { + return ( + + + + + ) +} + +export default Plus diff --git a/services/frontend/site/components/icons/Star.tsx b/services/frontend/site/components/icons/Star.tsx new file mode 100644 index 00000000..d98f55e1 --- /dev/null +++ b/services/frontend/site/components/icons/Star.tsx @@ -0,0 +1,16 @@ +const Star = ({ ...props }) => { + return ( + + + + ) +} + +export default Star diff --git a/services/frontend/site/components/icons/Sun.tsx b/services/frontend/site/components/icons/Sun.tsx new file mode 100644 index 00000000..d3684bcb --- /dev/null +++ b/services/frontend/site/components/icons/Sun.tsx @@ -0,0 +1,28 @@ +const Sun = ({ ...props }) => { + return ( + + + + + + + + + + + + ) +} + +export default Sun diff --git a/services/frontend/site/components/icons/Trash.tsx b/services/frontend/site/components/icons/Trash.tsx new file mode 100644 index 00000000..b005ea89 --- /dev/null +++ b/services/frontend/site/components/icons/Trash.tsx @@ -0,0 +1,43 @@ +const Trash = ({ ...props }) => { + return ( + + + + + + + ) +} + +export default Trash diff --git a/services/frontend/site/components/icons/Vercel.tsx b/services/frontend/site/components/icons/Vercel.tsx new file mode 100644 index 00000000..96e619fd --- /dev/null +++ b/services/frontend/site/components/icons/Vercel.tsx @@ -0,0 +1,40 @@ +const Vercel = ({ ...props }) => { + return ( + + + + + + + + + + ) +} + +export default Vercel diff --git a/services/frontend/site/components/icons/index.ts b/services/frontend/site/components/icons/index.ts new file mode 100644 index 00000000..12e0cc20 --- /dev/null +++ b/services/frontend/site/components/icons/index.ts @@ -0,0 +1,23 @@ +export { default as Bag } from './Bag' +export { default as Sun } from './Sun' +export { default as Moon } from './Moon' +export { default as Menu } from './Menu' +export { default as Info } from './Info' +export { default as Star } from './Star' +export { default as Plus } from './Plus' +export { default as Heart } from './Heart' +export { default as Trash } from './Trash' +export { default as Cross } from './Cross' +export { default as Minus } from './Minus' +export { default as Check } from './Check' +export { default as Github } from './Github' +export { default as Vercel } from './Vercel' +export { default as MapPin } from './MapPin' +export { default as ArrowLeft } from './ArrowLeft' +export { default as ArrowRight } from './ArrowRight' +export { default as CreditCard } from './CreditCard' +export { default as ChevronUp } from './ChevronUp' +export { default as ChevronLeft } from './ChevronLeft' +export { default as ChevronDown } from './ChevronDown' +export { default as ChevronRight } from './ChevronRight' +export { default as DoubleChevron } from './DoubleChevron' diff --git a/services/frontend/site/components/product/ProductCard/ProductCard-v2.tsx b/services/frontend/site/components/product/ProductCard/ProductCard-v2.tsx new file mode 100644 index 00000000..36821fcd --- /dev/null +++ b/services/frontend/site/components/product/ProductCard/ProductCard-v2.tsx @@ -0,0 +1,147 @@ +import { FC } from 'react'; +import cn from 'clsx'; +import Link from 'next/link'; +import type { Product } from '@commerce/types/product'; +import s from './ProductCard.module.css'; +import Image, { ImageProps } from 'next/image'; +import WishlistButton from '@components/wishlist/WishlistButton'; +import usePrice from '@framework/product/use-price'; +import ProductTag from '../ProductTag'; + +interface Props { + className?: string; + product: Product; + noNameTag?: boolean; + imgProps?: Omit; + variant?: 'default' | 'slim' | 'simple'; +} + +const placeholderImg = '/product-img-placeholder.svg'; + +export const ProductCard: FC = ({ + product, + imgProps, + className, + noNameTag = false, + variant = 'default', +}) => { + const { price } = usePrice({ + amount: product.price.value, + baseAmount: product.price.retailPrice, + currencyCode: product.price.currencyCode!, + }); + + const rootClassName = cn( + s.root, + { [s.slim]: variant === 'slim', [s.simple]: variant === 'simple' }, + className + ); + + return ( +
    + {variant === 'slim' && ( + <> +
    + + {product.name} + +
    + {product?.images && ( +
    + {product.name +
    + )} + + )} + + {variant === 'simple' && ( + <> + {process.env.COMMERCE_WISHLIST_ENABLED && ( + + )} + {!noNameTag && ( +
    +

    + + + {product.name} + + +

    +
    + {`${price} ${product.price?.currencyCode}`} +
    +
    + )} +
    + {product?.images && ( +
    + {product.name +
    + )} +
    + + )} + + {variant === 'default' && ( + <> + {process.env.COMMERCE_WISHLIST_ENABLED && ( + + )} + + + + + + +
    + {product?.images && ( +
    + {product.name +
    + )} +
    + + )} +
    + ); +}; + +export default ProductCard; diff --git a/services/frontend/site/components/product/ProductCard/ProductCard.module.css b/services/frontend/site/components/product/ProductCard/ProductCard.module.css new file mode 100644 index 00000000..16fc355a --- /dev/null +++ b/services/frontend/site/components/product/ProductCard/ProductCard.module.css @@ -0,0 +1,92 @@ +.root { + @apply relative max-h-full w-full box-border overflow-hidden + bg-no-repeat bg-center bg-cover transition-transform + ease-linear cursor-pointer inline-block bg-accent-1; + height: 100% !important; +} + +.root:hover { + & .productImage { + transform: scale(1.2625); + } + + & .header .name span, + & .header .name .link, + & .header .price, + & .wishlistButton { + @apply bg-secondary text-secondary; + } +} + +.header { + @apply transition-colors ease-in-out duration-500 + absolute top-0 left-0 z-20 pr-16; +} + +.header .name { + @apply pt-0 max-w-full w-full leading-extra-loose + transition-colors ease-in-out duration-500; + font-size: 2rem; + letter-spacing: 0.4px; +} + +.header .name span, +.header .name .link { + @apply py-1 px-3 bg-primary text-primary font-bold + transition-colors ease-in-out duration-500; + font-size: inherit; + letter-spacing: inherit; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; +} + +.header .price { + @apply py-1 px-3 text-sm bg-primary text-accent-9 + font-semibold inline-block tracking-wide + transition-colors ease-in-out duration-500; +} + +.imageContainer { + @apply flex items-center justify-center overflow-hidden; +} + +.imageContainer > div { + min-width: 100%; +} + +.imageContainer .productImage { + @apply transform transition-transform duration-500 + object-cover scale-120; +} + +.root .wishlistButton { + @apply top-0 right-0 z-30 absolute; +} + +/* Variant Simple */ +.simple .header .name { + @apply text-lg mt-0; +} + +.simple .header .price { + @apply text-sm; +} + +/* Variant Slim */ +.slim { + @apply bg-transparent relative overflow-hidden + box-border; +} + +.slim .header { + @apply absolute inset-0 flex items-center justify-end mr-8 z-20; +} + +.slim span { + @apply bg-gray-900/80 text-accent-0 inline-block p-3 + font-bold text-xl break-words; +} + +.root:global(.secondary) .header span { + @apply bg-accent-0 text-accent-9; +} diff --git a/services/frontend/site/components/product/ProductCard/ProductCard.tsx b/services/frontend/site/components/product/ProductCard/ProductCard.tsx new file mode 100644 index 00000000..483592c4 --- /dev/null +++ b/services/frontend/site/components/product/ProductCard/ProductCard.tsx @@ -0,0 +1,138 @@ +import { FC } from 'react'; +import cn from 'clsx'; +import Link from 'next/link'; +import type { Product } from '@commerce/types/product'; +import s from './ProductCard.module.css'; +import Image, { ImageProps } from 'next/image'; +import WishlistButton from '@components/wishlist/WishlistButton'; +import usePrice from '@framework/product/use-price'; +import ProductTag from '../ProductTag'; + +interface Props { + className?: string; + product: Product; + noNameTag?: boolean; + imgProps?: Omit; + variant?: 'default' | 'slim' | 'simple'; +} + +const placeholderImg = '/product-img-placeholder.svg'; + +const ProductCard: FC = ({ + product, + imgProps, + className, + noNameTag = false, + variant = 'default', +}) => { + const { price } = usePrice({ + amount: product.price.value, + baseAmount: product.price.retailPrice, + currencyCode: product.price.currencyCode!, + }); + + const rootClassName = cn( + s.root, + { [s.slim]: variant === 'slim', [s.simple]: variant === 'simple' }, + className + ); + + return ( + + + {variant === 'slim' && ( + <> +
    + {product.name} +
    + {product?.images && ( +
    + {product.name +
    + )} + + )} + + {variant === 'simple' && ( + <> + {process.env.COMMERCE_WISHLIST_ENABLED && ( + + )} + {!noNameTag && ( +
    +

    + {product.name} +

    +
    + {`${price} ${product.price?.currencyCode}`} +
    +
    + )} +
    + {product?.images && ( +
    + {product.name +
    + )} +
    + + )} + + {variant === 'default' && ( + <> + {process.env.COMMERCE_WISHLIST_ENABLED && ( + + )} + +
    + {product?.images && ( +
    + {product.name +
    + )} +
    + + )} +
    + + ); +}; + +export default ProductCard; diff --git a/services/frontend/site/components/product/ProductCard/index.ts b/services/frontend/site/components/product/ProductCard/index.ts new file mode 100644 index 00000000..4559faa1 --- /dev/null +++ b/services/frontend/site/components/product/ProductCard/index.ts @@ -0,0 +1 @@ +export { default } from './ProductCard' diff --git a/services/frontend/site/components/product/ProductOptions/ProductOptions.tsx b/services/frontend/site/components/product/ProductOptions/ProductOptions.tsx new file mode 100644 index 00000000..15c229ed --- /dev/null +++ b/services/frontend/site/components/product/ProductOptions/ProductOptions.tsx @@ -0,0 +1,52 @@ +import { memo } from 'react' +import { Swatch } from '@components/product' +import type { ProductOption } from '@commerce/types/product' +import { SelectedOptions } from '../helpers' + +interface ProductOptionsProps { + options: ProductOption[] + selectedOptions: SelectedOptions + setSelectedOptions: React.Dispatch> +} + +const ProductOptions: React.FC = ({ + options, + selectedOptions, + setSelectedOptions, +}) => { + return ( +
    + {options.map((opt) => ( +
    +

    + {opt.displayName} +

    +
    + {opt.values.map((v, i: number) => { + const active = selectedOptions[opt.displayName.toLowerCase()] + return ( + { + setSelectedOptions((selectedOptions) => { + return { + ...selectedOptions, + [opt.displayName.toLowerCase()]: v.label.toLowerCase(), + } + }) + }} + /> + ) + })} +
    +
    + ))} +
    + ) +} + +export default memo(ProductOptions) diff --git a/services/frontend/site/components/product/ProductOptions/index.ts b/services/frontend/site/components/product/ProductOptions/index.ts new file mode 100644 index 00000000..252415ab --- /dev/null +++ b/services/frontend/site/components/product/ProductOptions/index.ts @@ -0,0 +1 @@ +export { default } from './ProductOptions' diff --git a/services/frontend/site/components/product/ProductSidebar/ProductSidebar.module.css b/services/frontend/site/components/product/ProductSidebar/ProductSidebar.module.css new file mode 100644 index 00000000..b6ecc2b7 --- /dev/null +++ b/services/frontend/site/components/product/ProductSidebar/ProductSidebar.module.css @@ -0,0 +1,84 @@ +.root { + @apply relative grid items-start gap-1 grid-cols-1 overflow-x-hidden; + min-height: auto; +} + +.main { + @apply relative px-0 pb-0 box-border flex flex-col col-span-1; + min-height: 500px; +} + +.header { + @apply transition-colors ease-in-out duration-500 + absolute top-0 left-0 z-20 pr-16; +} + +.header .name { + @apply pt-0 max-w-full w-full leading-extra-loose; + font-size: 2rem; + letter-spacing: 0.4px; +} + +.header .name span { + @apply py-4 px-6 bg-primary text-primary font-bold; + font-size: inherit; + letter-spacing: inherit; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; +} + +.header .price { + @apply pt-2 px-6 pb-4 text-sm bg-primary text-accent-9 + font-semibold inline-block tracking-wide; +} + +.sidebar { + @apply flex flex-col col-span-1 mx-auto max-w-8xl px-6 py-6 w-full h-full; +} + +.sliderContainer { + @apply flex items-center justify-center overflow-x-hidden bg-violet; +} + +.imageContainer { + @apply text-center; +} + +.imageContainer > div, +.imageContainer > div > div { + @apply h-full; +} + +.sliderContainer .img { + @apply w-full h-auto max-h-full object-cover; +} + +.button { + width: 100%; +} + +.wishlistButton { + @apply absolute z-30 top-0 right-0; +} + +.relatedProductsGrid { + @apply grid grid-cols-2 py-2 gap-2 md:grid-cols-4 md:gap-7; +} + +@screen lg { + .root { + @apply grid-cols-12; + } + + .main { + @apply mx-0 col-span-8; + } + + .sidebar { + @apply col-span-4 py-6; + } + + .imageContainer { + max-height: 600px; + } +} diff --git a/services/frontend/site/components/product/ProductSidebar/ProductSidebar.tsx b/services/frontend/site/components/product/ProductSidebar/ProductSidebar.tsx new file mode 100644 index 00000000..d67e7a8e --- /dev/null +++ b/services/frontend/site/components/product/ProductSidebar/ProductSidebar.tsx @@ -0,0 +1,97 @@ +import s from './ProductSidebar.module.css'; +import { useAddItem } from '@framework/cart'; +import { datadogRum } from '@datadog/browser-rum'; +import { FC, useEffect, useState } from 'react'; +import { ProductOptions } from '@components/product'; +import useCart from '@framework/cart/use-cart'; +import type { Product } from '@commerce/types/product'; +import { Button, Text, Rating, Collapse, useUI } from '@components/ui'; +import { + getProductVariant, + selectDefaultOptionFromProduct, + SelectedOptions, +} from '../helpers'; + +interface ProductSidebarProps { + product: Product; + className?: string; +} + +const ProductSidebar: FC = ({ product, className }) => { + const addItem = useAddItem(); + const { data: cartData } = useCart(); + const { openSidebar, setSidebarView } = useUI(); + const [loading, setLoading] = useState(false); + const [selectedOptions, setSelectedOptions] = useState({}); + + useEffect(() => { + selectDefaultOptionFromProduct(product, setSelectedOptions); + }, [product]); + + const variant = getProductVariant(product, selectedOptions); + const addToCart = async () => { + setLoading(true); + try { + await addItem({ + productId: String(product.id), + variantId: String(variant ? variant.id : product.variants[0]?.id), + }); + datadogRum.addAction('Product Added to Cart', { + cartTotal: cartData.totalPrice, + product: { + name: product.name, + sku: product.sku, + id: product.id, + price: product.price.value, + slug: product.slug, + }, + }); + + setSidebarView('CART_VIEW'); + openSidebar(); + setLoading(false); + } catch (err) { + setLoading(false); + } + }; + + return ( +
    + + +
    + +
    36 reviews
    +
    +
    + {process.env.COMMERCE_CART_ENABLED && ( + + )} +
    +
    + This product is not for resale! +
    +
    + ); +}; + +export default ProductSidebar; diff --git a/services/frontend/site/components/product/ProductSidebar/index.ts b/services/frontend/site/components/product/ProductSidebar/index.ts new file mode 100644 index 00000000..7e00359c --- /dev/null +++ b/services/frontend/site/components/product/ProductSidebar/index.ts @@ -0,0 +1 @@ +export { default } from './ProductSidebar' diff --git a/services/frontend/site/components/product/ProductSlider/ProductSlider.module.css b/services/frontend/site/components/product/ProductSlider/ProductSlider.module.css new file mode 100644 index 00000000..c92d09de --- /dev/null +++ b/services/frontend/site/components/product/ProductSlider/ProductSlider.module.css @@ -0,0 +1,57 @@ +.root { + @apply relative w-full h-full select-none; + overflow: hidden; +} + +.slider { + @apply relative h-full transition-opacity duration-150; + opacity: 0; +} + +.slider.show { + opacity: 1; +} + +.thumb { + @apply overflow-hidden inline-block cursor-pointer h-full; + width: 125px; + width: calc(100% / 3); +} + +.thumb.selected { + @apply bg-white; +} + +.thumb img { + height: 85% !important; +} + +.album { + width: 100%; + height: 100%; + @apply bg-primary; + box-sizing: content-box; + overflow-y: hidden; + overflow-x: auto; + white-space: nowrap; + height: 125px; + scrollbar-width: none; +} + +/* Hide scrollbar for Chrome, Safari and Opera */ +.album::-webkit-scrollbar { + display: none; +} + +@screen md { + .thumb:hover { + transform: scale(1.02); + } + + .album { + height: 182px; + } + .thumb { + width: 235px; + } +} diff --git a/services/frontend/site/components/product/ProductSlider/ProductSlider.tsx b/services/frontend/site/components/product/ProductSlider/ProductSlider.tsx new file mode 100644 index 00000000..009d058c --- /dev/null +++ b/services/frontend/site/components/product/ProductSlider/ProductSlider.tsx @@ -0,0 +1,129 @@ +import { useKeenSlider } from 'keen-slider/react' +import React, { + Children, + isValidElement, + useState, + useRef, + useEffect, +} from 'react' +import cn from 'clsx' +import { a } from '@react-spring/web' +import s from './ProductSlider.module.css' +import ProductSliderControl from '../ProductSliderControl' + +interface ProductSliderProps { + children: React.ReactNode[] + className?: string +} + +const ProductSlider: React.FC = ({ + children, + className = '', +}) => { + const [currentSlide, setCurrentSlide] = useState(0) + const [isMounted, setIsMounted] = useState(false) + const sliderContainerRef = useRef(null) + const thumbsContainerRef = useRef(null) + + const [ref, slider] = useKeenSlider({ + loop: true, + slides: { perView: 1 }, + created: () => setIsMounted(true), + slideChanged(s) { + const slideNumber = s.track.details.rel + setCurrentSlide(slideNumber) + + if (thumbsContainerRef.current) { + const $el = document.getElementById(`thumb-${slideNumber}`) + if (slideNumber >= 3) { + thumbsContainerRef.current.scrollLeft = $el!.offsetLeft + } else { + thumbsContainerRef.current.scrollLeft = 0 + } + } + }, + }) + + // Stop the history navigation gesture on touch devices + useEffect(() => { + const preventNavigation = (event: TouchEvent) => { + // Center point of the touch area + const touchXPosition = event.touches[0].pageX + // Size of the touch area + const touchXRadius = event.touches[0].radiusX || 0 + + // We set a threshold (10px) on both sizes of the screen, + // if the touch area overlaps with the screen edges + // it's likely to trigger the navigation. We prevent the + // touchstart event in that case. + if ( + touchXPosition - touchXRadius < 10 || + touchXPosition + touchXRadius > window.innerWidth - 10 + ) + event.preventDefault() + } + + const slider = sliderContainerRef.current! + + slider.addEventListener('touchstart', preventNavigation) + + return () => { + if (slider) { + slider.removeEventListener('touchstart', preventNavigation) + } + } + }, []) + + const onPrev = React.useCallback(() => slider.current?.prev(), [slider]) + const onNext = React.useCallback(() => slider.current?.next(), [slider]) + + return ( +
    +
    + {slider && } + {Children.map(children, (child) => { + // Add the keen-slider__slide className to children + if (isValidElement(child)) { + return { + ...child, + props: { + ...child.props, + className: `${ + child.props.className ? `${child.props.className} ` : '' + }keen-slider__slide`, + }, + } + } + return child + })} +
    + + + {slider && + Children.map(children, (child, idx) => { + if (isValidElement(child)) { + return { + ...child, + props: { + ...child.props, + className: cn(child.props.className, s.thumb, { + [s.selected]: currentSlide === idx, + }), + id: `thumb-${idx}`, + onClick: () => { + slider.current?.moveToIdx(idx) + }, + }, + } + } + return child + })} + +
    + ) +} + +export default ProductSlider diff --git a/services/frontend/site/components/product/ProductSlider/index.ts b/services/frontend/site/components/product/ProductSlider/index.ts new file mode 100644 index 00000000..50444041 --- /dev/null +++ b/services/frontend/site/components/product/ProductSlider/index.ts @@ -0,0 +1 @@ +export { default } from './ProductSlider' diff --git a/services/frontend/site/components/product/ProductSliderControl/ProductSliderControl.module.css b/services/frontend/site/components/product/ProductSliderControl/ProductSliderControl.module.css new file mode 100644 index 00000000..c744e759 --- /dev/null +++ b/services/frontend/site/components/product/ProductSliderControl/ProductSliderControl.module.css @@ -0,0 +1,29 @@ +.control { + @apply bg-violet absolute bottom-10 right-10 flex flex-row + border-accent-0 border text-accent-0 z-30 shadow-xl select-none; + height: 48px; +} + +.leftControl, +.rightControl { + @apply px-9 cursor-pointer; + transition: background-color 0.2s ease; +} + +.leftControl:hover, +.rightControl:hover { + background-color: var(--violet-dark); +} + +.leftControl:focus, +.rightControl:focus { + @apply outline-none; +} + +.rightControl { + @apply border-l border-accent-0; +} + +.leftControl { + margin-right: -1px; +} diff --git a/services/frontend/site/components/product/ProductSliderControl/ProductSliderControl.tsx b/services/frontend/site/components/product/ProductSliderControl/ProductSliderControl.tsx new file mode 100644 index 00000000..29b30b78 --- /dev/null +++ b/services/frontend/site/components/product/ProductSliderControl/ProductSliderControl.tsx @@ -0,0 +1,30 @@ +import { FC, MouseEventHandler, memo } from 'react' +import cn from 'clsx' +import s from './ProductSliderControl.module.css' +import { ArrowLeft, ArrowRight } from '@components/icons' + +interface ProductSliderControl { + onPrev: MouseEventHandler + onNext: MouseEventHandler +} + +const ProductSliderControl: FC = ({ onPrev, onNext }) => ( +
    + + +
    +) + +export default memo(ProductSliderControl) diff --git a/services/frontend/site/components/product/ProductSliderControl/index.ts b/services/frontend/site/components/product/ProductSliderControl/index.ts new file mode 100644 index 00000000..5b63c466 --- /dev/null +++ b/services/frontend/site/components/product/ProductSliderControl/index.ts @@ -0,0 +1 @@ +export { default } from './ProductSliderControl' diff --git a/services/frontend/site/components/product/ProductTag/ProductTag.module.css b/services/frontend/site/components/product/ProductTag/ProductTag.module.css new file mode 100644 index 00000000..8a1970ef --- /dev/null +++ b/services/frontend/site/components/product/ProductTag/ProductTag.module.css @@ -0,0 +1,32 @@ +.root { + @apply transition-colors ease-in-out duration-500 + absolute top-0 left-0 z-20 pr-16; +} + +.root .name { + @apply pt-0 w-full max-w-3xl leading-extra-loose; + font-size: 2rem; + letter-spacing: 0.4px; + line-height: 2.1em; +} + +.root .name span { + @apply py-1 px-3 bg-primary text-primary font-bold; + line-height: 1.4; + min-height: 70px; + font-size: inherit; + letter-spacing: inherit; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; +} + +.root .name span.fontsizing { + display: flex; + padding-top: 1.5rem; +} + +.root .price { + @apply py-2 px-3 text-sm bg-primary text-accent-9 + font-semibold inline-block tracking-wide; + margin-top: -1px; +} diff --git a/services/frontend/site/components/product/ProductTag/ProductTag.tsx b/services/frontend/site/components/product/ProductTag/ProductTag.tsx new file mode 100644 index 00000000..0db4866e --- /dev/null +++ b/services/frontend/site/components/product/ProductTag/ProductTag.tsx @@ -0,0 +1,36 @@ +import cn from 'clsx' +import { inherits } from 'util' +import s from './ProductTag.module.css' + +interface ProductTagProps { + className?: string + name: string + price: string + fontSize?: number +} + +const ProductTag: React.FC = ({ + name, + price, + className = '', + fontSize = 32, +}) => { + return ( +
    +

    + + {name} + +

    +
    {price}
    +
    + ) +} + +export default ProductTag diff --git a/services/frontend/site/components/product/ProductTag/index.ts b/services/frontend/site/components/product/ProductTag/index.ts new file mode 100644 index 00000000..cb345e8b --- /dev/null +++ b/services/frontend/site/components/product/ProductTag/index.ts @@ -0,0 +1 @@ +export { default } from './ProductTag' diff --git a/services/frontend/site/components/product/ProductView/ProductView.module.css b/services/frontend/site/components/product/ProductView/ProductView.module.css new file mode 100644 index 00000000..7fb37aa7 --- /dev/null +++ b/services/frontend/site/components/product/ProductView/ProductView.module.css @@ -0,0 +1,59 @@ +.root { + @apply relative grid items-start gap-1 grid-cols-1 overflow-x-hidden; + min-height: auto; +} + +.main { + @apply relative px-0 pb-0 box-border flex flex-col col-span-1; + min-height: 500px; +} + +.sidebar { + @apply flex flex-col col-span-1 mx-auto max-w-8xl px-6 py-6 w-full h-full; +} + +.sliderContainer { + @apply flex items-center justify-center overflow-x-hidden bg-secondary; +} + +.imageContainer { + @apply text-center h-full relative; +} + +.imageContainer > span { + height: 100% !important; +} + +.sliderContainer .img { + @apply w-full h-full max-h-full object-cover; +} + +.button { + width: 100%; +} + +.wishlistButton { + @apply absolute z-30 top-0 right-0; +} + +.relatedProductsGrid { + @apply grid grid-cols-2 py-2 gap-2 md:grid-cols-4 md:gap-7; +} + +@screen lg { + .root { + @apply grid-cols-12; + } + + .main { + @apply mx-0 col-span-8; + } + + .sidebar { + @apply col-span-4 py-6; + } + + .imageContainer { + max-height: 600px; + } +} diff --git a/services/frontend/site/components/product/ProductView/ProductView.tsx b/services/frontend/site/components/product/ProductView/ProductView.tsx new file mode 100644 index 00000000..31cbcd57 --- /dev/null +++ b/services/frontend/site/components/product/ProductView/ProductView.tsx @@ -0,0 +1,113 @@ +import cn from 'clsx' +import Image from 'next/image' +import s from './ProductView.module.css' +import { FC } from 'react' +import type { Product } from '@commerce/types/product' +import usePrice from '@framework/product/use-price' +import { WishlistButton } from '@components/wishlist' +import { ProductSlider, ProductCard } from '@components/product' +import { Container, Text } from '@components/ui' +import { SEO } from '@components/common' +import ProductSidebar from '../ProductSidebar' +import ProductTag from '../ProductTag' +interface ProductViewProps { + product: Product + relatedProducts: Product[] +} + +const ProductView: FC = ({ product, relatedProducts }) => { + const { price } = usePrice({ + amount: product.price.value, + baseAmount: product.price.retailPrice, + currencyCode: product.price.currencyCode!, + }) + + return ( + <> + +
    +
    + +
    + + {product.images.map((image, i) => ( +
    + {image.alt +
    + ))} +
    +
    + {process.env.COMMERCE_WISHLIST_ENABLED && ( + + )} +
    + + +
    +
    +
    + Related Products +
    + {relatedProducts.map((p) => ( +
    + +
    + ))} +
    +
    +
    + + + ) +} + +export default ProductView diff --git a/services/frontend/site/components/product/ProductView/index.ts b/services/frontend/site/components/product/ProductView/index.ts new file mode 100644 index 00000000..9ac14480 --- /dev/null +++ b/services/frontend/site/components/product/ProductView/index.ts @@ -0,0 +1 @@ +export { default } from './ProductView' diff --git a/services/frontend/site/components/product/Swatch/Swatch.module.css b/services/frontend/site/components/product/Swatch/Swatch.module.css new file mode 100644 index 00000000..79a69e54 --- /dev/null +++ b/services/frontend/site/components/product/Swatch/Swatch.module.css @@ -0,0 +1,54 @@ +.swatch { + box-sizing: border-box; + composes: root from '@components/ui/Button/Button.module.css'; + @apply h-10 w-10 bg-primary text-primary rounded-full mr-3 inline-flex + items-center justify-center cursor-pointer transition duration-150 ease-in-out + p-0 shadow-none border-accent-3 border box-border select-none; + margin-right: calc(0.75rem - 1px); + overflow: hidden; + width: 48px; + height: 48px; +} + +.swatch::before, +.swatch::after { + box-sizing: border-box; +} + +.swatch:hover { + @apply transform scale-110 bg-hover; +} + +.swatch > span { + @apply absolute; +} + +.color { + @apply text-black transition duration-150 ease-in-out; +} + +.color :hover { + @apply text-black; +} + +.color.dark, +.color.dark:hover { + color: white !important; +} + +.active { + @apply border-accent-9 border-2; + padding-right: 1px; + padding-left: 1px; +} + +.textLabel { + @apply w-auto px-4; + min-width: 3rem; +} + +.active.textLabel { + @apply border-accent-9 border-2; + padding-right: calc(1rem - 1px); + padding-left: calc(1rem - 1px); +} diff --git a/services/frontend/site/components/product/Swatch/Swatch.tsx b/services/frontend/site/components/product/Swatch/Swatch.tsx new file mode 100644 index 00000000..865f4339 --- /dev/null +++ b/services/frontend/site/components/product/Swatch/Swatch.tsx @@ -0,0 +1,62 @@ +import cn from 'clsx' +import React from 'react' +import s from './Swatch.module.css' +import { Check } from '@components/icons' +import Button, { ButtonProps } from '@components/ui/Button' +import { isDark } from '@lib/colors' +interface SwatchProps { + active?: boolean + children?: any + className?: string + variant?: 'size' | 'color' | string + color?: string + label?: string | null +} + +const Swatch: React.FC & SwatchProps> = ({ + active, + className, + color = '', + label = null, + variant = 'size', + ...props +}) => { + variant = variant?.toLowerCase() + + if (label) { + label = label?.toLowerCase() + } + + const swatchClassName = cn( + s.swatch, + { + [s.color]: color, + [s.active]: active, + [s.size]: variant === 'size', + [s.dark]: color ? isDark(color) : false, + [s.textLabel]: !color && label && label.length > 3, + }, + className + ) + + return ( + + ) +} + +export default React.memo(Swatch) diff --git a/services/frontend/site/components/product/Swatch/index.ts b/services/frontend/site/components/product/Swatch/index.ts new file mode 100644 index 00000000..c8a79549 --- /dev/null +++ b/services/frontend/site/components/product/Swatch/index.ts @@ -0,0 +1 @@ +export { default } from './Swatch' diff --git a/services/frontend/site/components/product/helpers.ts b/services/frontend/site/components/product/helpers.ts new file mode 100644 index 00000000..77e385bb --- /dev/null +++ b/services/frontend/site/components/product/helpers.ts @@ -0,0 +1,32 @@ +import type { Product } from '@commerce/types/product' +export type SelectedOptions = Record +import { Dispatch, SetStateAction } from 'react' + +export function getProductVariant(product: Product, opts: SelectedOptions) { + const variant = product.variants.find((variant) => { + return Object.entries(opts).every(([key, value]) => + variant.options.find((option) => { + if ( + option.__typename === 'MultipleChoiceOption' && + option.displayName.toLowerCase() === key.toLowerCase() + ) { + return option.values.find((v) => v.label.toLowerCase() === value) + } + }) + ) + }) + return variant +} + +export function selectDefaultOptionFromProduct( + product: Product, + updater: Dispatch> +) { + // Selects the default option + product.variants[0]?.options?.forEach((v) => { + updater((choices) => ({ + ...choices, + [v.displayName.toLowerCase()]: v.values[0].label.toLowerCase(), + })) + }) +} diff --git a/services/frontend/site/components/product/index.ts b/services/frontend/site/components/product/index.ts new file mode 100644 index 00000000..8b70f8e2 --- /dev/null +++ b/services/frontend/site/components/product/index.ts @@ -0,0 +1,5 @@ +export { default as Swatch } from './Swatch' +export { default as ProductView } from './ProductView' +export { default as ProductCard } from './ProductCard' +export { default as ProductSlider } from './ProductSlider' +export { default as ProductOptions } from './ProductOptions' diff --git a/services/frontend/site/components/search.tsx b/services/frontend/site/components/search.tsx new file mode 100644 index 00000000..ac397a98 --- /dev/null +++ b/services/frontend/site/components/search.tsx @@ -0,0 +1,439 @@ +import cn from 'clsx'; +import type { SearchPropsType } from '@lib/search-props'; +import Link from 'next/link'; +import { useState } from 'react'; +import { useRouter } from 'next/router'; + +import { Layout } from '@components/common'; +import { ProductCard } from '@components/product/ProductCard/ProductCard-v2'; +import type { Product } from '@commerce/types/product'; +import { Container, Skeleton } from '@components/ui'; + +import useSearch from '@framework/product/use-search'; + +import getSlug from '@lib/get-slug'; +import rangeMap from '@lib/range-map'; + +const SORT = { + 'trending-desc': 'Trending', + 'latest-desc': 'Latest arrivals', + 'price-asc': 'Price: Low to high', + 'price-desc': 'Price: High to low', +}; + +import { + filterQuery, + getCategoryPath, + getDesignerPath, + useSearchMeta, +} from '@lib/search'; + +export default function Search({ categories, brands }: SearchPropsType) { + const [activeFilter, setActiveFilter] = useState(''); + const [toggleFilter, setToggleFilter] = useState(false); + + const router = useRouter(); + const { asPath, locale } = router; + const { q, sort } = router.query; + // `q` can be included but because categories and designers can't be searched + // in the same way of products, it's better to ignore the search input if one + // of those is selected + const query = filterQuery({ sort }); + + const { pathname, category, brand } = useSearchMeta(asPath); + const activeCategory = categories.find((cat: any) => cat.slug === category); + const activeBrand = brands.find( + (b: any) => getSlug(b.node.path) === `brands/${brand}` + )?.node; + + const { data } = useSearch({ + search: typeof q === 'string' ? q : '', + categoryId: activeCategory?.id, + brandId: (activeBrand as any)?.entityId, + sort: typeof sort === 'string' ? sort : '', + locale, + }); + + const handleClick = (event: any, filter: string) => { + if (filter !== activeFilter) { + setToggleFilter(true); + } else { + setToggleFilter(!toggleFilter); + } + setActiveFilter(filter); + }; + + return ( + +
    +
    + {/* Categories */} +
    +
    + + + +
    + +
    + + {/* Designs */} +
    +
    + + + +
    + +
    +
    + {/* Products */} +
    + {(q || activeCategory || activeBrand) && ( +
    + {data ? ( + <> + + Showing {data.products.length} results{' '} + {q && ( + <> + for "{q}" + + )} + + + {q ? ( + <> + There are no products that match "{q}" + + ) : ( + <> + There are no products that match the selected category. + + )} + + + ) : q ? ( + <> + Searching for: "{q}" + + ) : ( + <>Searching... + )} +
    + )} + {data ? ( +
    + {data.products.map((product: Product) => ( + + ))} +
    + ) : ( +
    + {rangeMap(12, (i) => ( + +
    + + ))} +
    + )}{' '} +
    + + {/* Sort */} +
    +
    +
    + + + +
    + +
    +
    +
    + + ); +} + +Search.Layout = Layout; diff --git a/services/frontend/site/components/ui/Button/Button.module.css b/services/frontend/site/components/ui/Button/Button.module.css new file mode 100644 index 00000000..34bdea10 --- /dev/null +++ b/services/frontend/site/components/ui/Button/Button.module.css @@ -0,0 +1,57 @@ +.root { + @apply bg-accent-9 text-accent-0 cursor-pointer inline-flex + px-10 py-5 leading-6 transition ease-in-out duration-150 + shadow-sm text-center justify-center uppercase + border border-transparent items-center text-sm font-semibold + tracking-wide; + max-height: 64px; +} + +.root:hover { + @apply border-accent-9 bg-accent-6; +} + +.root:focus { + @apply shadow-outline-normal outline-none; +} + +.root[data-active] { + @apply bg-accent-6; +} + +.loading { + @apply bg-accent-1 text-accent-3 border-accent-2 cursor-not-allowed; +} + +.slim { + @apply py-2 transform-none normal-case; +} + +.ghost { + @apply border border-accent-2 bg-accent-0 text-accent-9 text-sm; +} + +.ghost:hover { + @apply border-accent-9 bg-accent-9 text-accent-0; +} + +.naked { + @apply bg-transparent font-semibold border-none shadow-none outline-none py-0 px-0; +} + +.naked:hover, +.naked:focus { + @apply bg-transparent border-none; +} + +.disabled, +.disabled:hover { + @apply text-accent-4 border-accent-2 bg-accent-1 cursor-not-allowed; + filter: grayscale(1); + -webkit-transform: translateZ(0); + -webkit-perspective: 1000; + -webkit-backface-visibility: hidden; +} + +.progress { +} diff --git a/services/frontend/site/components/ui/Button/Button.tsx b/services/frontend/site/components/ui/Button/Button.tsx new file mode 100644 index 00000000..47d474f2 --- /dev/null +++ b/services/frontend/site/components/ui/Button/Button.tsx @@ -0,0 +1,75 @@ +import cn from 'clsx' +import React, { + forwardRef, + ButtonHTMLAttributes, + JSXElementConstructor, + useRef, +} from 'react' +import mergeRefs from 'react-merge-refs' +import s from './Button.module.css' +import { LoadingDots } from '@components/ui' + +export interface ButtonProps extends ButtonHTMLAttributes { + href?: string + className?: string + variant?: 'flat' | 'slim' | 'ghost' | 'naked' + active?: boolean + type?: 'submit' | 'reset' | 'button' + Component?: string | JSXElementConstructor + width?: string | number + loading?: boolean + disabled?: boolean +} + +// eslint-disable-next-line react/display-name +const Button: React.FC = forwardRef((props, buttonRef) => { + const { + className, + variant = 'flat', + children, + active, + width, + loading = false, + disabled = false, + style = {}, + Component = 'button', + ...rest + } = props + const ref = useRef(null) + + const rootClassName = cn( + s.root, + { + [s.ghost]: variant === 'ghost', + [s.slim]: variant === 'slim', + [s.naked]: variant === 'naked', + [s.loading]: loading, + [s.disabled]: disabled, + }, + className + ) + + return ( + + {children} + {loading && ( + + + + )} + + ) +}) + +export default Button diff --git a/services/frontend/site/components/ui/Button/index.ts b/services/frontend/site/components/ui/Button/index.ts new file mode 100644 index 00000000..aa076c58 --- /dev/null +++ b/services/frontend/site/components/ui/Button/index.ts @@ -0,0 +1,2 @@ +export { default } from './Button' +export * from './Button' diff --git a/services/frontend/site/components/ui/Collapse/Collapse.module.css b/services/frontend/site/components/ui/Collapse/Collapse.module.css new file mode 100644 index 00000000..fb4a82a9 --- /dev/null +++ b/services/frontend/site/components/ui/Collapse/Collapse.module.css @@ -0,0 +1,25 @@ +.root { + @apply border-b border-accent-2 py-4 flex flex-col outline-none; +} + +.header { + @apply flex flex-row items-center; +} + +.header .label { + @apply text-base font-medium; +} + +.content { + @apply pt-3 overflow-hidden pl-8; +} + +.icon { + @apply mr-3 text-accent-6; + margin-left: -6px; + transition: transform 0.2s ease; +} + +.icon.open { + transform: rotate(90deg); +} diff --git a/services/frontend/site/components/ui/Collapse/Collapse.tsx b/services/frontend/site/components/ui/Collapse/Collapse.tsx new file mode 100644 index 00000000..8ec71ee5 --- /dev/null +++ b/services/frontend/site/components/ui/Collapse/Collapse.tsx @@ -0,0 +1,46 @@ +import cn from 'clsx' +import React, { FC, ReactNode, useState } from 'react' +import s from './Collapse.module.css' +import { ChevronRight } from '@components/icons' +import { useSpring, a } from '@react-spring/web' +import useMeasure from 'react-use-measure' + +export interface CollapseProps { + title: string + children: ReactNode +} + +const Collapse: FC = ({ title, children }) => { + const [isActive, setActive] = useState(false) + const [ref, { height: viewHeight }] = useMeasure() + + const animProps = useSpring({ + height: isActive ? viewHeight : 0, + config: { tension: 250, friction: 32, clamp: true, duration: 150 }, + opacity: isActive ? 1 : 0, + }) + + const toggle = () => setActive((x) => !x) + + return ( +
    +
    + + {title} +
    + +
    + {children} +
    +
    +
    + ) +} + +export default React.memo(Collapse) diff --git a/services/frontend/site/components/ui/Collapse/index.ts b/services/frontend/site/components/ui/Collapse/index.ts new file mode 100644 index 00000000..1e584a53 --- /dev/null +++ b/services/frontend/site/components/ui/Collapse/index.ts @@ -0,0 +1,2 @@ +export { default } from './Collapse' +export * from './Collapse' diff --git a/services/frontend/site/components/ui/Container/Container.tsx b/services/frontend/site/components/ui/Container/Container.tsx new file mode 100644 index 00000000..d8425f3b --- /dev/null +++ b/services/frontend/site/components/ui/Container/Container.tsx @@ -0,0 +1,27 @@ +import cn from 'clsx' +import React, { FC } from 'react' + +interface ContainerProps { + className?: string + children?: any + el?: HTMLElement + clean?: boolean +} + +const Container: FC = ({ + children, + className, + el = 'div', + clean = false, // Full Width Screen +}) => { + const rootClassName = cn(className, { + 'mx-auto max-w-7xl px-6 w-full': !clean, + }) + + let Component: React.ComponentType> = + el as any + + return {children} +} + +export default Container diff --git a/services/frontend/site/components/ui/Container/index.ts b/services/frontend/site/components/ui/Container/index.ts new file mode 100644 index 00000000..9dbd596a --- /dev/null +++ b/services/frontend/site/components/ui/Container/index.ts @@ -0,0 +1 @@ +export { default } from './Container' diff --git a/services/frontend/site/components/ui/Dropdown/Dropdown.module.css b/services/frontend/site/components/ui/Dropdown/Dropdown.module.css new file mode 100644 index 00000000..46b2469c --- /dev/null +++ b/services/frontend/site/components/ui/Dropdown/Dropdown.module.css @@ -0,0 +1,32 @@ +.root { + @apply bg-accent-0; + animation: none; + transition: none; + min-width: 100%; +} + +@media screen(lg) { + .root { + @apply bg-accent-0; + box-shadow: hsl(206 22% 7% / 45%) 0px 10px 38px -10px, + hsl(206 22% 7% / 20%) 0px 10px 20px -15px; + min-width: 14rem; + will-change: transform, opacity; + animation-duration: 600ms; + animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1); + animation-fill-mode: forwards; + transform-origin: var(--radix-dropdown-menu-content-transform-origin); + animation-name: slideIn; + } +} + +@keyframes slideIn { + 0% { + opacity: 0; + transform: translateY(2px); + } + 100% { + opacity: 1; + transform: translateY(0px); + } +} diff --git a/services/frontend/site/components/ui/Dropdown/Dropdown.tsx b/services/frontend/site/components/ui/Dropdown/Dropdown.tsx new file mode 100644 index 00000000..566b962d --- /dev/null +++ b/services/frontend/site/components/ui/Dropdown/Dropdown.tsx @@ -0,0 +1,22 @@ +import cn from 'clsx' +import React from 'react' +import s from './Dropdown.module.css' +import * as DropdownMenu from '@radix-ui/react-dropdown-menu' + +export const Dropdown = DropdownMenu.Root +export const DropdownMenuItem = DropdownMenu.Item +export const DropdownTrigger = DropdownMenu.Trigger +export const DropdownMenuLabel = DropdownMenu.Label +export const DropdownMenuGroup = DropdownMenu.Group + +export const DropdownContent = React.forwardRef< + HTMLDivElement, + { children: React.ReactNode } & DropdownMenu.DropdownMenuContentProps & + React.RefAttributes +>(function DropdownContent({ children, className, ...props }, forwardedRef) { + return ( + +
    {children}
    +
    + ) +}) diff --git a/services/frontend/site/components/ui/Grid/Grid.module.css b/services/frontend/site/components/ui/Grid/Grid.module.css new file mode 100644 index 00000000..9e6ab69b --- /dev/null +++ b/services/frontend/site/components/ui/Grid/Grid.module.css @@ -0,0 +1,135 @@ +.root { + @apply grid grid-cols-1 gap-0; +} +.root > * { + @apply row-span-1 bg-transparent box-border overflow-hidden; + height: 500px; + max-height: 800px; +} + +.default > * { + @apply bg-transparent; +} + +.layoutNormal { + @apply gap-3; +} + +.layoutA { + & > *:nth-child(6n + 1), + & > *:nth-child(6n + 5) { + @apply row-span-2 lg:col-span-2; + height: var(--row-height); + } + + &.filled { + & > *:nth-child(6n + 1) { + @apply bg-violet; + } + + & > *:nth-child(6n + 2) { + @apply bg-accent-8; + } + + & > *:nth-child(6n + 3) { + @apply bg-pink; + } + + & > *:nth-child(6n + 6) { + @apply bg-cyan; + } + } +} + +.layoutB { + & > *:nth-child(6n + 2), + & > *:nth-child(6n + 4) { + @apply row-span-2 lg:col-span-2; + height: var(--row-height); + } + + &.filled { + & > *:nth-child(6n + 1) { + @apply bg-violet; + } + + & > *:nth-child(6n + 2) { + @apply bg-accent-8; + } + + & > *:nth-child(6n + 3) { + @apply bg-pink; + } + + & > *:nth-child(6n + 6) { + @apply bg-cyan; + } + } +} + +.layoutC { + & > *:nth-child(12n + 1), + & > *:nth-child(12n + 8) { + @apply row-span-2 lg:col-span-2; + height: var(--row-height); + } + + &.filled { + & > *:nth-child(12n + 1) { + @apply bg-violet; + height: var(--row-height); + } + + & > *:nth-child(12n + 8) { + @apply bg-cyan; + height: var(--row-height); + } + + & > *:nth-child(6n + 3) { + @apply bg-pink; + } + } +} + +.layoutD { + & > *:nth-child(12n + 2), + & > *:nth-child(12n + 7) { + @apply row-span-2 lg:col-span-2; + height: var(--row-height); + } + + &.filled { + & > *:nth-child(12n + 2) { + @apply bg-violet; + } + + & > *:nth-child(12n + 7) { + @apply bg-cyan; + } + + & > *:nth-child(6n + 3) { + @apply bg-pink; + } + } +} + +@screen md { + .layoutNormal > * { + max-height: min-content !important; + } +} + +@screen lg { + .root { + @apply grid-cols-3 grid-rows-2; + } + + .root > * { + @apply col-span-1; + height: inherit; + } + + .layoutNormal > * { + max-height: 400px; + } +} diff --git a/services/frontend/site/components/ui/Grid/Grid.tsx b/services/frontend/site/components/ui/Grid/Grid.tsx new file mode 100644 index 00000000..9b033c0a --- /dev/null +++ b/services/frontend/site/components/ui/Grid/Grid.tsx @@ -0,0 +1,34 @@ +import cn from 'clsx' +import { FC, ReactNode, Component } from 'react' +import s from './Grid.module.css' + +interface GridProps { + className?: string + children?: ReactNode[] | Component[] | any[] + layout?: 'A' | 'B' | 'C' | 'D' | 'normal' + variant?: 'default' | 'filled' +} + +const Grid: FC = ({ + className, + layout = 'A', + children, + variant = 'default', +}) => { + const rootClassName = cn( + s.root, + { + [s.layoutA]: layout === 'A', + [s.layoutB]: layout === 'B', + [s.layoutC]: layout === 'C', + [s.layoutD]: layout === 'D', + [s.layoutNormal]: layout === 'normal', + [s.default]: variant === 'default', + [s.filled]: variant === 'filled', + }, + className + ) + return
    {children}
    +} + +export default Grid diff --git a/services/frontend/site/components/ui/Grid/index.ts b/services/frontend/site/components/ui/Grid/index.ts new file mode 100644 index 00000000..ddb51299 --- /dev/null +++ b/services/frontend/site/components/ui/Grid/index.ts @@ -0,0 +1 @@ +export { default } from './Grid' diff --git a/services/frontend/site/components/ui/Hero/Hero.module.css b/services/frontend/site/components/ui/Hero/Hero.module.css new file mode 100644 index 00000000..a0f1798f --- /dev/null +++ b/services/frontend/site/components/ui/Hero/Hero.module.css @@ -0,0 +1,30 @@ +.root { + @apply flex flex-col py-16 mx-auto; +} + +.title { + @apply text-accent-0 font-extrabold text-4xl leading-none tracking-tight; +} + +.description { + @apply mt-4 text-xl leading-8 text-accent-2 mb-1 lg:max-w-4xl; +} + +@screen lg { + .root { + @apply flex-row items-start justify-center py-32; + } + .title { + @apply text-5xl max-w-xl text-right leading-10 -mt-3; + line-height: 3.5rem; + } + .description { + @apply mt-0 ml-6; + } +} + +@screen xl { + .title { + @apply text-6xl; + } +} diff --git a/services/frontend/site/components/ui/Hero/Hero.tsx b/services/frontend/site/components/ui/Hero/Hero.tsx new file mode 100644 index 00000000..ca71d25f --- /dev/null +++ b/services/frontend/site/components/ui/Hero/Hero.tsx @@ -0,0 +1,33 @@ +import React, { FC } from 'react'; +import { Container } from '@components/ui'; +import { ArrowRight } from '@components/icons'; +import s from './Hero.module.css'; +import Link from 'next/link'; +interface HeroProps { + className?: string; + headline: string; + description: string; +} + +const Hero: FC = ({ headline, description }) => { + return ( +
    + +
    +

    {headline}

    +
    +

    {description}

    + + + Read it here + + + +
    +
    +
    +
    + ); +}; + +export default Hero; diff --git a/services/frontend/site/components/ui/Hero/index.ts b/services/frontend/site/components/ui/Hero/index.ts new file mode 100644 index 00000000..b08fa5ac --- /dev/null +++ b/services/frontend/site/components/ui/Hero/index.ts @@ -0,0 +1 @@ +export { default } from './Hero' diff --git a/services/frontend/site/components/ui/Input/Input.module.css b/services/frontend/site/components/ui/Input/Input.module.css new file mode 100644 index 00000000..34507f44 --- /dev/null +++ b/services/frontend/site/components/ui/Input/Input.module.css @@ -0,0 +1,7 @@ +.root { + @apply bg-primary py-2 px-6 w-full appearance-none transition duration-150 ease-in-out pr-10 border border-accent-3 text-accent-6; +} + +.root:focus { + @apply outline-none shadow-outline-normal; +} diff --git a/services/frontend/site/components/ui/Input/Input.tsx b/services/frontend/site/components/ui/Input/Input.tsx new file mode 100644 index 00000000..8a17588b --- /dev/null +++ b/services/frontend/site/components/ui/Input/Input.tsx @@ -0,0 +1,37 @@ +import cn from 'clsx' +import s from './Input.module.css' +import React, { InputHTMLAttributes } from 'react' + +export interface InputProps extends InputHTMLAttributes { + className?: string + onChange?: (...args: any[]) => any +} + +const Input: React.FC = (props) => { + const { className, children, onChange, ...rest } = props + + const rootClassName = cn(s.root, {}, className) + + const handleOnChange = (e: any) => { + if (onChange) { + onChange(e.target.value) + } + return null + } + + return ( + + ) +} + +export default Input diff --git a/services/frontend/site/components/ui/Input/index.ts b/services/frontend/site/components/ui/Input/index.ts new file mode 100644 index 00000000..aa97178e --- /dev/null +++ b/services/frontend/site/components/ui/Input/index.ts @@ -0,0 +1 @@ +export { default } from './Input' diff --git a/services/frontend/site/components/ui/Link/Link.tsx b/services/frontend/site/components/ui/Link/Link.tsx new file mode 100644 index 00000000..27f30e86 --- /dev/null +++ b/services/frontend/site/components/ui/Link/Link.tsx @@ -0,0 +1,11 @@ +import NextLink, { LinkProps as NextLinkProps } from 'next/link' + +const Link: React.FC = ({ href, children, ...props }) => { + return ( + + {children} + + ) +} + +export default Link diff --git a/services/frontend/site/components/ui/Link/index.ts b/services/frontend/site/components/ui/Link/index.ts new file mode 100644 index 00000000..518d3729 --- /dev/null +++ b/services/frontend/site/components/ui/Link/index.ts @@ -0,0 +1 @@ +export { default } from './Link' diff --git a/services/frontend/site/components/ui/LoadingDots/LoadingDots.module.css b/services/frontend/site/components/ui/LoadingDots/LoadingDots.module.css new file mode 100644 index 00000000..6054de3c --- /dev/null +++ b/services/frontend/site/components/ui/LoadingDots/LoadingDots.module.css @@ -0,0 +1,33 @@ +.root { + @apply inline-flex text-center items-center leading-7; +} + +.root .dot { + @apply rounded-full h-2 w-2; + background-color: currentColor; + animation-name: blink; + animation-duration: 1.4s; + animation-iteration-count: infinite; + animation-fill-mode: both; + margin: 0 2px; +} + +.root .dot:nth-of-type(2) { + animation-delay: 0.2s; +} + +.root .dot::nth-of-type(3) { + animation-delay: 0.4s; +} + +@keyframes blink { + 0% { + opacity: 0.2; + } + 20% { + opacity: 1; + } + 100% { + opacity: 0.2; + } +} diff --git a/services/frontend/site/components/ui/LoadingDots/LoadingDots.tsx b/services/frontend/site/components/ui/LoadingDots/LoadingDots.tsx new file mode 100644 index 00000000..27ce9f25 --- /dev/null +++ b/services/frontend/site/components/ui/LoadingDots/LoadingDots.tsx @@ -0,0 +1,13 @@ +import s from './LoadingDots.module.css' + +const LoadingDots: React.FC = () => { + return ( + + + + + + ) +} + +export default LoadingDots diff --git a/services/frontend/site/components/ui/LoadingDots/index.ts b/services/frontend/site/components/ui/LoadingDots/index.ts new file mode 100644 index 00000000..63df282b --- /dev/null +++ b/services/frontend/site/components/ui/LoadingDots/index.ts @@ -0,0 +1 @@ +export { default } from './LoadingDots' diff --git a/services/frontend/site/components/ui/Logo/Logo.tsx b/services/frontend/site/components/ui/Logo/Logo.tsx new file mode 100644 index 00000000..72b3bb37 --- /dev/null +++ b/services/frontend/site/components/ui/Logo/Logo.tsx @@ -0,0 +1,9 @@ +import React, { FC } from 'react'; + +const Logo: FC = ({ className = '', ...props }) => ( +
    + Storedog +
    +); + +export default Logo; diff --git a/services/frontend/site/components/ui/Logo/index.ts b/services/frontend/site/components/ui/Logo/index.ts new file mode 100644 index 00000000..93dce23b --- /dev/null +++ b/services/frontend/site/components/ui/Logo/index.ts @@ -0,0 +1 @@ +export { default } from './Logo' diff --git a/services/frontend/site/components/ui/Marquee/Marquee.module.css b/services/frontend/site/components/ui/Marquee/Marquee.module.css new file mode 100644 index 00000000..e5ecb16e --- /dev/null +++ b/services/frontend/site/components/ui/Marquee/Marquee.module.css @@ -0,0 +1,22 @@ +.root { + @apply w-full min-w-full relative flex flex-row items-center overflow-hidden py-0; + max-height: 320px; +} + +.root > div { + max-height: 320px; + padding: 0; + margin: 0; +} + +.root > div > * > *:nth-child(2) * { + max-height: 100%; +} + +.primary { + @apply bg-accent-0; +} + +.secondary { + @apply bg-accent-9; +} diff --git a/services/frontend/site/components/ui/Marquee/Marquee.tsx b/services/frontend/site/components/ui/Marquee/Marquee.tsx new file mode 100644 index 00000000..cf5bd436 --- /dev/null +++ b/services/frontend/site/components/ui/Marquee/Marquee.tsx @@ -0,0 +1,39 @@ +import cn from 'clsx' +import s from './Marquee.module.css' +import { FC, ReactNode, Component, Children } from 'react' +import { default as FastMarquee } from 'react-fast-marquee' + +interface MarqueeProps { + className?: string + children?: ReactNode[] | Component[] | any[] + variant?: 'primary' | 'secondary' +} + +const Marquee: FC = ({ + className = '', + children, + variant = 'primary', +}) => { + const rootClassName = cn( + s.root, + { + [s.primary]: variant === 'primary', + [s.secondary]: variant === 'secondary', + }, + className + ) + + return ( + + {Children.map(children, (child) => ({ + ...child, + props: { + ...child.props, + className: cn(child.props.className, `${variant}`), + }, + }))} + + ) +} + +export default Marquee diff --git a/services/frontend/site/components/ui/Marquee/index.ts b/services/frontend/site/components/ui/Marquee/index.ts new file mode 100644 index 00000000..b59b7556 --- /dev/null +++ b/services/frontend/site/components/ui/Marquee/index.ts @@ -0,0 +1 @@ +export { default } from './Marquee' diff --git a/services/frontend/site/components/ui/Modal/Modal.module.css b/services/frontend/site/components/ui/Modal/Modal.module.css new file mode 100644 index 00000000..f30d7c90 --- /dev/null +++ b/services/frontend/site/components/ui/Modal/Modal.module.css @@ -0,0 +1,17 @@ +.root { + @apply fixed bg-black bg-opacity-40 flex items-center inset-0 z-50 justify-center; + backdrop-filter: blur(0.8px); + -webkit-backdrop-filter: blur(0.8px); +} + +.modal { + @apply bg-primary p-12 border border-accent-2 relative; +} + +.modal:focus { + @apply outline-none; +} + +.close { + @apply hover:text-accent-5 transition ease-in-out duration-150 focus:outline-none absolute right-0 top-0 m-6; +} diff --git a/services/frontend/site/components/ui/Modal/Modal.tsx b/services/frontend/site/components/ui/Modal/Modal.tsx new file mode 100644 index 00000000..40ed3b3f --- /dev/null +++ b/services/frontend/site/components/ui/Modal/Modal.tsx @@ -0,0 +1,55 @@ +import { FC, useRef, useEffect, useCallback } from 'react' +import s from './Modal.module.css' +import FocusTrap from '@lib/focus-trap' +import { Cross } from '@components/icons' +import { disableBodyScroll, clearAllBodyScrollLocks } from 'body-scroll-lock' + +interface ModalProps { + className?: string + children?: any + onClose: () => void + onEnter?: () => void | null +} + +const Modal: FC = ({ children, onClose }) => { + const ref = useRef() as React.MutableRefObject + + const handleKey = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Escape') { + return onClose() + } + }, + [onClose] + ) + + useEffect(() => { + const modal = ref.current + + if (modal) { + disableBodyScroll(modal, { reserveScrollBarGap: true }) + window.addEventListener('keydown', handleKey) + } + return () => { + clearAllBodyScrollLocks() + window.removeEventListener('keydown', handleKey) + } + }, [handleKey]) + + return ( +
    +
    + + {children} +
    +
    + ) +} + +export default Modal diff --git a/services/frontend/site/components/ui/Modal/index.ts b/services/frontend/site/components/ui/Modal/index.ts new file mode 100644 index 00000000..e24753a1 --- /dev/null +++ b/services/frontend/site/components/ui/Modal/index.ts @@ -0,0 +1 @@ +export { default } from './Modal' diff --git a/services/frontend/site/components/ui/Quantity/Quantity.module.css b/services/frontend/site/components/ui/Quantity/Quantity.module.css new file mode 100644 index 00000000..fa60cc56 --- /dev/null +++ b/services/frontend/site/components/ui/Quantity/Quantity.module.css @@ -0,0 +1,27 @@ +.actions { + @apply flex p-1 border-accent-2 border items-center justify-center + w-12 text-accent-7; + transition-property: border-color, background, color, transform, box-shadow; + + transition-duration: 0.15s; + transition-timing-function: ease; + user-select: none; +} + +.actions:hover { + @apply border bg-accent-1 border-accent-3 text-accent-9; + transition: border-color; + z-index: 10; +} + +.actions:focus { + @apply outline-none; +} + +.actions:disabled { + @apply cursor-not-allowed; +} + +.input { + @apply bg-transparent px-4 w-full h-full focus:outline-none select-none pointer-events-auto; +} diff --git a/services/frontend/site/components/ui/Quantity/Quantity.tsx b/services/frontend/site/components/ui/Quantity/Quantity.tsx new file mode 100644 index 00000000..7317ddbe --- /dev/null +++ b/services/frontend/site/components/ui/Quantity/Quantity.tsx @@ -0,0 +1,62 @@ +import React, { FC } from 'react' +import s from './Quantity.module.css' +import { Cross, Plus, Minus } from '@components/icons' +import cn from 'clsx' +export interface QuantityProps { + value: number + increase: () => any + decrease: () => any + handleRemove: React.MouseEventHandler + handleChange: React.ChangeEventHandler + max?: number +} + +const Quantity: FC = ({ + value, + increase, + decrease, + handleChange, + handleRemove, + max = 6, +}) => { + return ( +
    + + + + +
    + ) +} + +export default Quantity diff --git a/services/frontend/site/components/ui/Quantity/index.ts b/services/frontend/site/components/ui/Quantity/index.ts new file mode 100644 index 00000000..5ee880cc --- /dev/null +++ b/services/frontend/site/components/ui/Quantity/index.ts @@ -0,0 +1,2 @@ +export { default } from './Quantity' +export * from './Quantity' diff --git a/services/frontend/site/components/ui/README.md b/services/frontend/site/components/ui/README.md new file mode 100644 index 00000000..5bf4fe51 --- /dev/null +++ b/services/frontend/site/components/ui/README.md @@ -0,0 +1,3 @@ +# UI + +Building blocks to build a rich graphical interfaces. Components should be atomic and pure. Serve one purpose. diff --git a/services/frontend/site/components/ui/Rating/Rating.module.css b/services/frontend/site/components/ui/Rating/Rating.module.css new file mode 100644 index 00000000..e69de29b diff --git a/services/frontend/site/components/ui/Rating/Rating.tsx b/services/frontend/site/components/ui/Rating/Rating.tsx new file mode 100644 index 00000000..efd2ca0d --- /dev/null +++ b/services/frontend/site/components/ui/Rating/Rating.tsx @@ -0,0 +1,25 @@ +import { FC, memo } from 'react' +import rangeMap from '@lib/range-map' +import { Star } from '@components/icons' +import cn from 'clsx' + +export interface RatingProps { + value: number +} + +const Quantity: FC = ({ value = 5 }) => ( +
    + {rangeMap(5, (i) => ( + = Math.floor(value), + })} + > + + + ))} +
    +) + +export default memo(Quantity) diff --git a/services/frontend/site/components/ui/Rating/index.ts b/services/frontend/site/components/ui/Rating/index.ts new file mode 100644 index 00000000..1354efb2 --- /dev/null +++ b/services/frontend/site/components/ui/Rating/index.ts @@ -0,0 +1,2 @@ +export { default } from './Rating' +export * from './Rating' diff --git a/services/frontend/site/components/ui/Sidebar/Sidebar.module.css b/services/frontend/site/components/ui/Sidebar/Sidebar.module.css new file mode 100644 index 00000000..71a85995 --- /dev/null +++ b/services/frontend/site/components/ui/Sidebar/Sidebar.module.css @@ -0,0 +1,14 @@ +.root { + @apply fixed inset-0 h-full z-50 box-border outline-none; +} + +.sidebar { + @apply h-full flex flex-col text-base bg-accent-0 shadow-xl overflow-y-auto overflow-x-hidden; + -webkit-overflow-scrolling: touch !important; +} + +.backdrop { + @apply absolute inset-0 bg-black bg-opacity-40 duration-100 ease-linear; + backdrop-filter: blur(0.8px); + -webkit-backdrop-filter: blur(0.8px); +} diff --git a/services/frontend/site/components/ui/Sidebar/Sidebar.tsx b/services/frontend/site/components/ui/Sidebar/Sidebar.tsx new file mode 100644 index 00000000..3c01cbd5 --- /dev/null +++ b/services/frontend/site/components/ui/Sidebar/Sidebar.tsx @@ -0,0 +1,57 @@ +import cn from 'clsx' +import s from './Sidebar.module.css' +import { useEffect, useRef } from 'react' +import { disableBodyScroll, clearAllBodyScrollLocks } from 'body-scroll-lock' +interface SidebarProps { + children: any + onClose: () => void +} + +const Sidebar: React.FC = ({ children, onClose }) => { + const sidebarRef = useRef() as React.MutableRefObject + const contentRef = useRef() as React.MutableRefObject + + const onKeyDownSidebar = (event: React.KeyboardEvent) => { + if (event.code === 'Escape') { + onClose() + } + } + + useEffect(() => { + if (sidebarRef.current) { + sidebarRef.current.focus() + } + + const contentElement = contentRef.current + + if (contentElement) { + disableBodyScroll(contentElement, { reserveScrollBarGap: true }) + } + + return () => { + clearAllBodyScrollLocks() + } + }, []) + + return ( +
    +
    +
    +
    +
    +
    + {children} +
    +
    +
    +
    +
    + ) +} + +export default Sidebar diff --git a/services/frontend/site/components/ui/Sidebar/index.ts b/services/frontend/site/components/ui/Sidebar/index.ts new file mode 100644 index 00000000..877187ca --- /dev/null +++ b/services/frontend/site/components/ui/Sidebar/index.ts @@ -0,0 +1 @@ +export { default } from './Sidebar' diff --git a/services/frontend/site/components/ui/Skeleton/Skeleton.module.css b/services/frontend/site/components/ui/Skeleton/Skeleton.module.css new file mode 100644 index 00000000..37c164de --- /dev/null +++ b/services/frontend/site/components/ui/Skeleton/Skeleton.module.css @@ -0,0 +1,48 @@ +.skeleton { + @apply block; + background-image: linear-gradient( + 270deg, + var(--accent-0), + var(--accent-2), + var(--accent-0), + var(--accent-1) + ); + background-size: 400% 100%; + animation: loading 8s ease-in-out infinite; +} + +.wrapper { + @apply block relative; + + &:not(.show)::before { + content: none; + } + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 100; + background-image: linear-gradient( + 270deg, + var(--accent-0), + var(--accent-2), + var(--accent-0), + var(--accent-1) + ); + background-size: 400% 100%; + animation: loading 8s ease-in-out infinite; + } +} + +@keyframes loading { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} diff --git a/services/frontend/site/components/ui/Skeleton/Skeleton.tsx b/services/frontend/site/components/ui/Skeleton/Skeleton.tsx new file mode 100644 index 00000000..f4ca677e --- /dev/null +++ b/services/frontend/site/components/ui/Skeleton/Skeleton.tsx @@ -0,0 +1,57 @@ +import React, { CSSProperties } from 'react' +import cn from 'clsx' +import px from '@lib/to-pixels' +import s from './Skeleton.module.css' + +interface SkeletonProps { + show?: boolean + block?: boolean + className?: string + style?: CSSProperties + width?: string | number + height?: string | number + boxHeight?: string | number +} + +const Skeleton: React.FC = ({ + style, + width, + height, + children, + className, + show = true, + boxHeight = height, +}) => { + // Automatically calculate the size if there are children + // and no fixed sizes are specified + const shouldAutoSize = !!children && !(width || height) + + // Defaults + width = width || 24 + height = height || 24 + boxHeight = boxHeight || height + + return ( + + {children} + + ) +} + +export default Skeleton diff --git a/services/frontend/site/components/ui/Skeleton/index.ts b/services/frontend/site/components/ui/Skeleton/index.ts new file mode 100644 index 00000000..3ec6c003 --- /dev/null +++ b/services/frontend/site/components/ui/Skeleton/index.ts @@ -0,0 +1 @@ +export { default } from './Skeleton' diff --git a/services/frontend/site/components/ui/Text/Text.module.css b/services/frontend/site/components/ui/Text/Text.module.css new file mode 100644 index 00000000..dd35ff63 --- /dev/null +++ b/services/frontend/site/components/ui/Text/Text.module.css @@ -0,0 +1,75 @@ +.body { + @apply text-base leading-7 max-w-6xl mx-auto; +} + +.heading { + @apply text-5xl pt-1 pb-2 font-semibold tracking-wide cursor-pointer mb-2; +} + +.pageHeading { + @apply pt-1 pb-4 text-2xl leading-7 font-bold tracking-wide; +} + +.sectionHeading { + @apply pt-1 pb-2 text-2xl font-bold tracking-wide cursor-pointer mb-2; +} + +/* Apply base font sizes and styles for typography markup (h2, h2, ul, p, etc.). + A helpful addition for whenn page content is consumed from a source managed through a wysiwyg editor. */ + +.body :is(h1, h2, h3, h4, h5, h6, p, ul, ol) { + @apply mb-4; +} + +.body :is(h1, h2, h3, h4, h5, h6):not(:first-child) { + @apply mt-8; +} + +.body :is(h1, h2, h3, h4, h5, h6) { + @apply font-semibold tracking-wide; +} + +.body h1 { + @apply text-5xl; +} + +.body h2 { + @apply text-4xl; +} + +.body h3 { + @apply text-3xl; +} + +.body h4 { + @apply text-2xl; +} + +.body h5 { + @apply text-xl; +} + +.body h6 { + @apply text-lg; +} + +.body ul, +.body ol { + @apply pl-6; +} + +.body ul { + @apply list-disc; +} + +.body ol { + @apply list-decimal; +} + +.body a { + @apply underline; +} + +.body a:hover { + @apply no-underline; +} diff --git a/services/frontend/site/components/ui/Text/Text.tsx b/services/frontend/site/components/ui/Text/Text.tsx new file mode 100644 index 00000000..486b77d3 --- /dev/null +++ b/services/frontend/site/components/ui/Text/Text.tsx @@ -0,0 +1,70 @@ +import React, { + FunctionComponent, + JSXElementConstructor, + CSSProperties, +} from 'react' +import cn from 'clsx' +import s from './Text.module.css' + +interface TextProps { + variant?: Variant + className?: string + style?: CSSProperties + children?: React.ReactNode | any + html?: string + onClick?: () => any +} + +type Variant = 'heading' | 'body' | 'pageHeading' | 'sectionHeading' + +const Text: FunctionComponent = ({ + style, + className = '', + variant = 'body', + children, + html, + onClick, +}) => { + const componentsMap: { + [P in Variant]: React.ComponentType | string + } = { + body: 'div', + heading: 'h1', + pageHeading: 'h1', + sectionHeading: 'h2', + } + + const Component: + | JSXElementConstructor + | React.ReactElement + | React.ComponentType + | string = componentsMap![variant!] + + const htmlContentProps = html + ? { + dangerouslySetInnerHTML: { __html: html }, + } + : {} + + return ( + + {children} + + ) +} + +export default Text diff --git a/services/frontend/site/components/ui/Text/index.ts b/services/frontend/site/components/ui/Text/index.ts new file mode 100644 index 00000000..e1e5fa74 --- /dev/null +++ b/services/frontend/site/components/ui/Text/index.ts @@ -0,0 +1 @@ +export { default } from './Text' diff --git a/services/frontend/site/components/ui/context.tsx b/services/frontend/site/components/ui/context.tsx new file mode 100644 index 00000000..ca2bfd7e --- /dev/null +++ b/services/frontend/site/components/ui/context.tsx @@ -0,0 +1,216 @@ +import React, { FC, useCallback, useMemo } from 'react' +import { ThemeProvider } from 'next-themes' + +export interface State { + displaySidebar: boolean + displayDropdown: boolean + displayModal: boolean + sidebarView: string + modalView: string + userAvatar: string +} + +const initialState = { + displaySidebar: false, + displayDropdown: false, + displayModal: false, + modalView: 'LOGIN_VIEW', + sidebarView: 'CART_VIEW', + userAvatar: '', +} + +type Action = + | { + type: 'OPEN_SIDEBAR' + } + | { + type: 'CLOSE_SIDEBAR' + } + | { + type: 'OPEN_DROPDOWN' + } + | { + type: 'CLOSE_DROPDOWN' + } + | { + type: 'OPEN_MODAL' + } + | { + type: 'CLOSE_MODAL' + } + | { + type: 'SET_MODAL_VIEW' + view: MODAL_VIEWS + } + | { + type: 'SET_SIDEBAR_VIEW' + view: SIDEBAR_VIEWS + } + | { + type: 'SET_USER_AVATAR' + value: string + } + +type MODAL_VIEWS = + | 'SIGNUP_VIEW' + | 'LOGIN_VIEW' + | 'FORGOT_VIEW' + | 'NEW_SHIPPING_ADDRESS' + | 'NEW_PAYMENT_METHOD' + +type SIDEBAR_VIEWS = 'CART_VIEW' | 'CHECKOUT_VIEW' | 'PAYMENT_METHOD_VIEW' + +export const UIContext = React.createContext(initialState) + +UIContext.displayName = 'UIContext' + +function uiReducer(state: State, action: Action) { + switch (action.type) { + case 'OPEN_SIDEBAR': { + return { + ...state, + displaySidebar: true, + } + } + case 'CLOSE_SIDEBAR': { + return { + ...state, + displaySidebar: false, + } + } + case 'OPEN_DROPDOWN': { + return { + ...state, + displayDropdown: true, + } + } + case 'CLOSE_DROPDOWN': { + return { + ...state, + displayDropdown: false, + } + } + case 'OPEN_MODAL': { + return { + ...state, + displayModal: true, + displaySidebar: false, + } + } + case 'CLOSE_MODAL': { + return { + ...state, + displayModal: false, + } + } + case 'SET_MODAL_VIEW': { + return { + ...state, + modalView: action.view, + } + } + case 'SET_SIDEBAR_VIEW': { + return { + ...state, + sidebarView: action.view, + } + } + case 'SET_USER_AVATAR': { + return { + ...state, + userAvatar: action.value, + } + } + } +} + +export const UIProvider: FC = (props) => { + const [state, dispatch] = React.useReducer(uiReducer, initialState) + + const openSidebar = useCallback( + () => dispatch({ type: 'OPEN_SIDEBAR' }), + [dispatch] + ) + const closeSidebar = useCallback( + () => dispatch({ type: 'CLOSE_SIDEBAR' }), + [dispatch] + ) + const toggleSidebar = useCallback( + () => + state.displaySidebar + ? dispatch({ type: 'CLOSE_SIDEBAR' }) + : dispatch({ type: 'OPEN_SIDEBAR' }), + [dispatch, state.displaySidebar] + ) + const closeSidebarIfPresent = useCallback( + () => state.displaySidebar && dispatch({ type: 'CLOSE_SIDEBAR' }), + [dispatch, state.displaySidebar] + ) + + const openDropdown = useCallback( + () => dispatch({ type: 'OPEN_DROPDOWN' }), + [dispatch] + ) + const closeDropdown = useCallback( + () => dispatch({ type: 'CLOSE_DROPDOWN' }), + [dispatch] + ) + + const openModal = useCallback( + () => dispatch({ type: 'OPEN_MODAL' }), + [dispatch] + ) + const closeModal = useCallback( + () => dispatch({ type: 'CLOSE_MODAL' }), + [dispatch] + ) + + const setUserAvatar = useCallback( + (value: string) => dispatch({ type: 'SET_USER_AVATAR', value }), + [dispatch] + ) + + const setModalView = useCallback( + (view: MODAL_VIEWS) => dispatch({ type: 'SET_MODAL_VIEW', view }), + [dispatch] + ) + + const setSidebarView = useCallback( + (view: SIDEBAR_VIEWS) => dispatch({ type: 'SET_SIDEBAR_VIEW', view }), + [dispatch] + ) + + const value = useMemo( + () => ({ + ...state, + openSidebar, + closeSidebar, + toggleSidebar, + closeSidebarIfPresent, + openDropdown, + closeDropdown, + openModal, + closeModal, + setModalView, + setSidebarView, + setUserAvatar, + }), + [state] + ) + + return +} + +export const useUI = () => { + const context = React.useContext(UIContext) + if (context === undefined) { + throw new Error(`useUI must be used within a UIProvider`) + } + return context +} + +export const ManagedUIContext: FC = ({ children }) => ( + + {children} + +) diff --git a/services/frontend/site/components/ui/index.ts b/services/frontend/site/components/ui/index.ts new file mode 100644 index 00000000..e4b37aff --- /dev/null +++ b/services/frontend/site/components/ui/index.ts @@ -0,0 +1,17 @@ +export { default as Hero } from './Hero' +export { default as Logo } from './Logo' +export { default as Grid } from './Grid' +export { default as Button } from './Button' +export { default as Sidebar } from './Sidebar' +export { default as Marquee } from './Marquee' +export { default as Container } from './Container' +export { default as LoadingDots } from './LoadingDots' +export { default as Skeleton } from './Skeleton' +export { default as Modal } from './Modal' +export { default as Text } from './Text' +export { default as Input } from './Input' +export { default as Collapse } from './Collapse' +export { default as Quantity } from './Quantity' +export { default as Rating } from './Rating' +export * from './Dropdown/Dropdown' +export { useUI } from './context' diff --git a/services/frontend/site/components/wishlist/WishlistButton/WishlistButton.module.css b/services/frontend/site/components/wishlist/WishlistButton/WishlistButton.module.css new file mode 100644 index 00000000..3bdf1e71 --- /dev/null +++ b/services/frontend/site/components/wishlist/WishlistButton/WishlistButton.module.css @@ -0,0 +1,33 @@ +.root { + transition-duration: 0.2s; + transition-timing-function: ease; + transition-property: color, background-color, opacity; + @apply p-3 text-accent-9 flex items-center + justify-center font-semibold cursor-pointer + bg-accent-0 text-sm; +} + +.root:focus { + @apply outline-none; +} + +.icon { + transition-duration: 0.2s; + transition-timing-function: ease; + transition-property: transform, fill; + color: currentColor; +} + +.icon.loading { + fill: var(--pink-light); +} + +.icon.inWishlist { + fill: var(--pink); +} + +@screen lg { + .root { + @apply p-4; + } +} diff --git a/services/frontend/site/components/wishlist/WishlistButton/WishlistButton.tsx b/services/frontend/site/components/wishlist/WishlistButton/WishlistButton.tsx new file mode 100644 index 00000000..f4e0fb31 --- /dev/null +++ b/services/frontend/site/components/wishlist/WishlistButton/WishlistButton.tsx @@ -0,0 +1,84 @@ +import React, { FC, useState } from 'react' +import cn from 'clsx' +import { useUI } from '@components/ui' +import { Heart } from '@components/icons' +import useAddItem from '@framework/wishlist/use-add-item' +import useCustomer from '@framework/customer/use-customer' +import useWishlist from '@framework/wishlist/use-wishlist' +import useRemoveItem from '@framework/wishlist/use-remove-item' +import s from './WishlistButton.module.css' +import type { Product, ProductVariant } from '@commerce/types/product' + +type Props = { + productId: Product['id'] + variant: ProductVariant +} & React.ButtonHTMLAttributes + +const WishlistButton: FC = ({ + productId, + variant, + className, + ...props +}) => { + const { data } = useWishlist() + const addItem = useAddItem() + const removeItem = useRemoveItem() + const { data: customer } = useCustomer() + const { openModal, setModalView } = useUI() + const [loading, setLoading] = useState(false) + + // @ts-ignore Wishlist is not always enabled + const itemInWishlist = data?.items?.find( + // @ts-ignore Wishlist is not always enabled + (item) => + item.product_id === Number(productId) && + item.variant_id === Number(variant.id) + ) + + const handleWishlistChange = async (e: any) => { + e.preventDefault() + + if (loading) return + + // A login is required before adding an item to the wishlist + if (!customer) { + setModalView('LOGIN_VIEW') + return openModal() + } + + setLoading(true) + + try { + if (itemInWishlist) { + await removeItem({ id: itemInWishlist.id! }) + } else { + await addItem({ + productId, + variantId: variant?.id!, + }) + } + + setLoading(false) + } catch (err) { + setLoading(false) + } + } + + return ( + + ) +} + +export default WishlistButton diff --git a/services/frontend/site/components/wishlist/WishlistButton/index.ts b/services/frontend/site/components/wishlist/WishlistButton/index.ts new file mode 100644 index 00000000..66e88074 --- /dev/null +++ b/services/frontend/site/components/wishlist/WishlistButton/index.ts @@ -0,0 +1 @@ +export { default } from './WishlistButton' diff --git a/services/frontend/site/components/wishlist/WishlistCard/WishlistCard.module.css b/services/frontend/site/components/wishlist/WishlistCard/WishlistCard.module.css new file mode 100644 index 00000000..fb2d026b --- /dev/null +++ b/services/frontend/site/components/wishlist/WishlistCard/WishlistCard.module.css @@ -0,0 +1,38 @@ +.root { + @apply relative grid sm:grid-cols-1 lg:grid-cols-12 + w-full gap-6 px-3 py-6 border-b border-accent-2 + transition duration-100 ease-in-out; +} + +.root:nth-child(3n + 1) .imageWrapper { + @apply bg-violet; +} + +.root:nth-child(3n + 2) .imageWrapper { + @apply bg-pink; +} + +.root:nth-child(3n + 3) .imageWrapper { + @apply bg-blue; +} + +.imageWrapper { + @apply col-span-3; + min-width: 230px; + width: 230px; + height: 230px; +} + +.description { + @apply col-span-7 flex flex-col; +} + +.actions { + @apply absolute bg-accent-0 p-3 top-0 right-4; +} + +@media screen(lg) { + .actions { + @apply static col-span-2 flex flex-col justify-between space-y-4; + } +} diff --git a/services/frontend/site/components/wishlist/WishlistCard/WishlistCard.tsx b/services/frontend/site/components/wishlist/WishlistCard/WishlistCard.tsx new file mode 100644 index 00000000..6af6c914 --- /dev/null +++ b/services/frontend/site/components/wishlist/WishlistCard/WishlistCard.tsx @@ -0,0 +1,108 @@ +import { FC, useState } from 'react' +import cn from 'clsx' +import Link from 'next/link' +import Image from 'next/image' +import s from './WishlistCard.module.css' +import { Trash } from '@components/icons' +import { Button, Text } from '@components/ui' + +import { useUI } from '@components/ui/context' +import type { Product } from '@commerce/types/product' +import usePrice from '@framework/product/use-price' +import useAddItem from '@framework/cart/use-add-item' +import useRemoveItem from '@framework/wishlist/use-remove-item' +import type { Wishlist } from '@commerce/types/wishlist' + +const placeholderImg = '/product-img-placeholder.svg' + +const WishlistCard: React.FC<{ + item: Wishlist +}> = ({ item }) => { + const product: Product = item.product + const { price } = usePrice({ + amount: product.price?.value, + baseAmount: product.price?.retailPrice, + currencyCode: product.price?.currencyCode!, + }) + // @ts-ignore Wishlist is not always enabled + const removeItem = useRemoveItem({ wishlist: { includeProducts: true } }) + const [loading, setLoading] = useState(false) + const [removing, setRemoving] = useState(false) + + // TODO: fix this missing argument issue + /* @ts-ignore */ + const addItem = useAddItem() + const { openSidebar } = useUI() + + const handleRemove = async () => { + setRemoving(true) + + try { + // If this action succeeds then there's no need to do `setRemoving(true)` + // because the component will be removed from the view + await removeItem({ id: item.id! }) + } catch (error) { + setRemoving(false) + } + } + const addToCart = async () => { + setLoading(true) + try { + await addItem({ + productId: String(product.id), + variantId: String(product.variants[0].id), + }) + openSidebar() + setLoading(false) + } catch (err) { + setLoading(false) + } + } + + return ( +
    +
    + {product.images[0]?.alt +
    + +
    +
    +

    + + {product.name} + +

    +
    + +
    +
    +
    + +
    +
    +
    +
    {price}
    +
    + +
    +
    +
    + ) +} + +export default WishlistCard diff --git a/services/frontend/site/components/wishlist/WishlistCard/index.ts b/services/frontend/site/components/wishlist/WishlistCard/index.ts new file mode 100644 index 00000000..ef572805 --- /dev/null +++ b/services/frontend/site/components/wishlist/WishlistCard/index.ts @@ -0,0 +1 @@ +export { default } from './WishlistCard' diff --git a/services/frontend/site/components/wishlist/index.ts b/services/frontend/site/components/wishlist/index.ts new file mode 100644 index 00000000..8aee9f81 --- /dev/null +++ b/services/frontend/site/components/wishlist/index.ts @@ -0,0 +1,2 @@ +export { default as WishlistCard } from './WishlistCard' +export { default as WishlistButton } from './WishlistButton' diff --git a/services/frontend/site/config/seo_meta.json b/services/frontend/site/config/seo_meta.json new file mode 100644 index 00000000..5fbfe0c8 --- /dev/null +++ b/services/frontend/site/config/seo_meta.json @@ -0,0 +1,25 @@ +{ + "title": "Storedog | Datadog Training", + "titleTemplate": "%s - Storedog", + "description": "Storedog, an ecommerce app used for training at https://learn.datadoghq.com", + "openGraph": { + "title": "Storedog | Datadog Training", + "description": "Storedog, an ecommerce app used for training at https://learn.datadoghq.com", + "type": "website", + "url": "https://learn.datadoghq.com", + "site_name": "Storedog", + "images": [ + { + "url": "/card.png", + "width": "800", + "height": "600", + "alt": "Storedog" + } + ] + }, + "twitter": { + "handle": "@datadoghq", + "site": "@datadoghq", + "cardType": "summary_large_image" + } +} diff --git a/services/frontend/site/config/user_data.json b/services/frontend/site/config/user_data.json new file mode 100644 index 00000000..7f776c25 --- /dev/null +++ b/services/frontend/site/config/user_data.json @@ -0,0 +1,602 @@ +[ + { + "id": "2b33dbb0-da59-4b73-9344-3cff9804fd43", + "full_name": "Marigold Gargett", + "email": "mgargett0@yellowpages.com", + "ip_address": "42.57.140.39" + }, + { + "id": "d7a82faf-2976-444f-92ef-3d9a103c8ef1", + "full_name": "Hyatt Edmands", + "email": "hedmands1@stanford.edu", + "ip_address": "98.66.176.38" + }, + { + "id": "58b3c6e9-ef64-40b1-b5b7-756c44eda2dc", + "full_name": "Scarlet Cauldwell", + "email": "scauldwell2@1und1.de", + "ip_address": "82.71.116.64" + }, + { + "id": "3fd8dd06-4e20-4bbf-b21f-eef19834da48", + "full_name": "Eimile Kenward", + "email": "ekenward3@biglobe.ne.jp", + "ip_address": "67.163.144.12" + }, + { + "id": "148b80fe-3954-4e3e-9287-1ad7c63d1c98", + "full_name": "Karilynn Braven", + "email": "kbraven4@google.ca", + "ip_address": "18.161.36.46" + }, + { + "id": "53137b9f-9212-4426-927e-3ad290f54b47", + "full_name": "Valry Overill", + "email": "voverill5@ebay.co.uk", + "ip_address": "195.206.171.87" + }, + { + "id": "e4faca04-90f5-4569-bd52-9a9f0de09c22", + "full_name": "Evaleen Palethorpe", + "email": "epalethorpe6@spotify.com", + "ip_address": "223.125.235.194" + }, + { + "id": "b9856854-3785-40dc-9467-17327f8ef02a", + "full_name": "Cosetta Bukowski", + "email": "cbukowski7@comcast.net", + "ip_address": "214.102.118.178" + }, + { + "id": "33981fb8-029c-4a67-ae36-23f817e4a940", + "full_name": "Kristal Skynner", + "email": "kskynner8@rakuten.co.jp", + "ip_address": "113.116.223.188" + }, + { + "id": "e9ec9132-429f-4369-a2c1-6e5d69802814", + "full_name": "Margarita Priestman", + "email": "mpriestman9@goodreads.com", + "ip_address": "177.211.255.104" + }, + { + "id": "cee3a7f3-469f-486c-bdb4-a948ffbeddcc", + "full_name": "Peterus Weems", + "email": "pweemsa@alexa.com", + "ip_address": "7.249.210.228" + }, + { + "id": "85daab0e-17de-45e3-9ed3-472d4ba74b31", + "full_name": "Adeline Coade", + "email": "acoadeb@ameblo.jp", + "ip_address": "91.58.167.246" + }, + { + "id": "1da955c9-6786-498f-b99f-5ff978d03ba4", + "full_name": "Miranda Shimoni", + "email": "mshimonic@ucsd.edu", + "ip_address": "39.67.73.207" + }, + { + "id": "0a9cf094-b1d2-472a-879a-8ea7488acd22", + "full_name": "Alexis Peterken", + "email": "apeterkend@cafepress.com", + "ip_address": "228.66.66.7" + }, + { + "id": "ff4ce511-d089-4245-8499-71da19e9e9ba", + "full_name": "Genna Shiliton", + "email": "gshilitone@dedecms.com", + "ip_address": "207.83.138.166" + }, + { + "id": "09209ca3-ece5-4f61-90d1-aee1c2a0e18f", + "full_name": "Elyssa Gregoratti", + "email": "egregorattif@lulu.com", + "ip_address": "95.90.12.167" + }, + { + "id": "41459ee4-0637-4409-b63c-eaa2a3f41d4c", + "full_name": "Doti Holtham", + "email": "dholthamg@toplist.cz", + "ip_address": "189.130.234.128" + }, + { + "id": "90e69b8e-3f1c-4388-a274-0d1a664f1d79", + "full_name": "Page Rosedale", + "email": "prosedaleh@unblog.fr", + "ip_address": "219.228.78.86" + }, + { + "id": "1350aff2-406e-415a-9e70-f935798caa14", + "full_name": "Frederich Heighway", + "email": "fheighwayi@google.co.uk", + "ip_address": "40.41.139.250" + }, + { + "id": "16647da9-89fe-47e6-a3ac-c7faa8c0b48f", + "full_name": "Ebeneser Carlucci", + "email": "ecarluccij@weibo.com", + "ip_address": "200.24.162.167" + }, + { + "id": "6d4d2547-d34d-418e-9403-7ae5fe69423d", + "full_name": "Maurie Dorman", + "email": "mdormank@google.com.au", + "ip_address": "170.252.140.97" + }, + { + "id": "aea2c3dd-5cd6-49b5-a062-a27608ebc515", + "full_name": "Lina Gallaher", + "email": "lgallaherl@naver.com", + "ip_address": "157.152.203.195" + }, + { + "id": "5542ca27-ad67-4ee9-a8f2-998c20ca78a1", + "full_name": "Benn Hearnshaw", + "email": "bhearnshawm@noaa.gov", + "ip_address": "119.94.72.140" + }, + { + "id": "8ed69184-d9b7-47ce-bd01-38bcb1807078", + "full_name": "Dario Heyball", + "email": "dheyballn@zimbio.com", + "ip_address": "227.209.197.120" + }, + { + "id": "04fc7b28-c55a-458e-ae2b-3111d16fb570", + "full_name": "Bonni Rodenborch", + "email": "brodenborcho@mac.com", + "ip_address": "99.242.201.153" + }, + { + "id": "c9123a0a-cbcc-4160-ba71-66ac7ef0fd3a", + "full_name": "Gabriel Scullard", + "email": "gscullardp@eepurl.com", + "ip_address": "64.171.189.142" + }, + { + "id": "04030594-149f-44b3-86d6-a80d9d7674cd", + "full_name": "Wilhelmine Maskrey", + "email": "wmaskreyq@ycombinator.com", + "ip_address": "156.141.254.252" + }, + { + "id": "2f11b791-724f-475b-9d1d-b77f7d716b62", + "full_name": "Meggi Wallbrook", + "email": "mwallbrookr@craigslist.org", + "ip_address": "79.93.175.72" + }, + { + "id": "6728ab2f-7e40-4c4a-ac12-17ffb70e7185", + "full_name": "Lucias Genese", + "email": "lgeneses@eventbrite.com", + "ip_address": "221.17.48.5" + }, + { + "id": "4ca96cb6-d78d-4d3d-8df6-e102829aa17d", + "full_name": "Julina Maddams", + "email": "jmaddamst@seesaa.net", + "ip_address": "0.74.161.5" + }, + { + "id": "36795539-35ff-47fa-a2ee-33794efe5b23", + "full_name": "Gena Spinello", + "email": "gspinellou@yolasite.com", + "ip_address": "207.10.231.31" + }, + { + "id": "43be10c6-c46e-495f-9763-0fdb405774fd", + "full_name": "Ric Gobell", + "email": "rgobellv@umich.edu", + "ip_address": "172.15.151.110" + }, + { + "id": "6b27b8fb-f152-4bb9-a8bf-acff8a8c7c84", + "full_name": "Vail Eirwin", + "email": "veirwinw@aboutads.info", + "ip_address": "69.111.169.175" + }, + { + "id": "b366f65b-095f-45a2-893d-28e9f141e297", + "full_name": "Zulema Yardy", + "email": "zyardyx@studiopress.com", + "ip_address": "251.199.84.15" + }, + { + "id": "4bf1de95-7797-4725-b1e7-9e6de49e4a10", + "full_name": "Cordey Sainteau", + "email": "csainteauy@vk.com", + "ip_address": "55.36.149.14" + }, + { + "id": "69ab3daa-eee8-4442-9950-203cfd6af454", + "full_name": "Petrina Cottrell", + "email": "pcottrellz@qq.com", + "ip_address": "48.159.177.242" + }, + { + "id": "d6d0d485-5ef7-4409-ae8b-cb9e85aeab0c", + "full_name": "Micky Wyss", + "email": "mwyss10@nymag.com", + "ip_address": "7.100.63.246" + }, + { + "id": "851b8610-5fea-492e-8bdf-c4f58617d220", + "full_name": "Grady Grierson", + "email": "ggrierson11@mit.edu", + "ip_address": "203.74.129.223" + }, + { + "id": "c902b9ba-ea57-4ad2-a1b4-bd3e407f3a3f", + "full_name": "Kaila McNicol", + "email": "kmcnicol12@mit.edu", + "ip_address": "34.231.30.27" + }, + { + "id": "7062f995-d624-4ee2-a772-72c09a611dc2", + "full_name": "Melvyn Tolchard", + "email": "mtolchard13@fema.gov", + "ip_address": "34.38.231.122" + }, + { + "id": "79227602-f0eb-4ef8-bd20-5de6f14d212d", + "full_name": "Tiler Garrood", + "email": "tgarrood14@wufoo.com", + "ip_address": "58.90.149.56" + }, + { + "id": "4c3a0f10-fd84-4bc5-b42c-e828abb5f186", + "full_name": "Theadora Prickett", + "email": "tprickett15@boston.com", + "ip_address": "36.237.255.164" + }, + { + "id": "b7689a8f-aec2-4670-af87-6aabe1ae2e71", + "full_name": "Arty Morad", + "email": "amorad16@nasa.gov", + "ip_address": "130.127.200.124" + }, + { + "id": "c4bd6aed-8ba6-4860-836e-b90066f06368", + "full_name": "Livvie Pennone", + "email": "lpennone17@over-blog.com", + "ip_address": "176.237.8.74" + }, + { + "id": "0b8ceba9-2e5e-454c-b67d-01a07d9693d0", + "full_name": "Gardy Shawdforth", + "email": "gshawdforth18@tamu.edu", + "ip_address": "118.119.216.70" + }, + { + "id": "2fd23e73-29c4-4fec-99f0-6e65c6368441", + "full_name": "Charline Stivers", + "email": "cstivers19@eepurl.com", + "ip_address": "49.168.222.159" + }, + { + "id": "a868e989-0f3f-49b3-8162-ea59f84bc645", + "full_name": "Ketti Phaup", + "email": "kphaup1a@w3.org", + "ip_address": "238.132.165.84" + }, + { + "id": "58d742ec-f28f-4a18-9c28-0fd7cf9d17fd", + "full_name": "Carline Cardon", + "email": "ccardon1b@china.com.cn", + "ip_address": "108.94.41.150" + }, + { + "id": "60673bf0-637f-4f18-8f96-98332fd37a62", + "full_name": "Andriette Whisson", + "email": "awhisson1c@deviantart.com", + "ip_address": "24.178.181.253" + }, + { + "id": "f898c344-ce7d-4abf-b40e-6617cc8684a4", + "full_name": "Ludovico Grigor", + "email": "lgrigor1d@dedecms.com", + "ip_address": "237.114.154.190" + }, + { + "id": "78df5caf-c0df-4131-a5b4-2ac203802bee", + "full_name": "Baron Ciciura", + "email": "bciciura1e@foxnews.com", + "ip_address": "146.254.224.200" + }, + { + "id": "2f429a1b-3b3c-4e78-9f09-99190083eef7", + "full_name": "Jessamyn Cleef", + "email": "jcleef1f@dyndns.org", + "ip_address": "135.72.212.88" + }, + { + "id": "bacc1d46-0d88-427c-bf10-dbadc18bd2d9", + "full_name": "Marlee Guilleton", + "email": "mguilleton1g@umn.edu", + "ip_address": "240.168.234.93" + }, + { + "id": "260db124-b29b-41e2-af19-b2833999fe11", + "full_name": "Marcelo Bordiss", + "email": "mbordiss1h@eventbrite.com", + "ip_address": "225.4.34.91" + }, + { + "id": "2bb9e08f-6760-4602-9d83-3f46857ac447", + "full_name": "Donn Jeeks", + "email": "djeeks1i@stumbleupon.com", + "ip_address": "72.147.59.245" + }, + { + "id": "088e6506-204e-4ca6-920f-3ceb13b101ed", + "full_name": "Dorita Kitson", + "email": "dkitson1j@cnbc.com", + "ip_address": "73.95.71.213" + }, + { + "id": "b49ea0c8-67d5-457f-b472-69f62c703ebe", + "full_name": "Danita Rockwill", + "email": "drockwill1k@amazon.co.jp", + "ip_address": "53.206.184.213" + }, + { + "id": "2c3a730b-5df4-4883-ac02-a9d9e51d125d", + "full_name": "Fred Desport", + "email": "fdesport1l@whitehouse.gov", + "ip_address": "170.201.226.192" + }, + { + "id": "4b1f80db-46f8-4b46-bee8-1138335f00aa", + "full_name": "Niven Khan", + "email": "nkhan1m@globo.com", + "ip_address": "154.59.84.119" + }, + { + "id": "d1ece243-0450-42e0-bae0-c269e08f0605", + "full_name": "Faber Heaysman", + "email": "fheaysman1n@yellowbook.com", + "ip_address": "231.188.65.206" + }, + { + "id": "d3784d8b-8c25-41f2-ada9-d5f93bf6def6", + "full_name": "Erl Collar", + "email": "ecollar1o@bravesites.com", + "ip_address": "162.229.14.52" + }, + { + "id": "296c0b29-2ee2-417f-874c-faab0e711972", + "full_name": "Barbabas Dufaire", + "email": "bdufaire1p@theglobeandmail.com", + "ip_address": "251.25.192.193" + }, + { + "id": "ab2c0c79-a76a-4ddd-86b8-bf339b0c17a4", + "full_name": "Curr Letixier", + "email": "cletixier1q@deviantart.com", + "ip_address": "168.154.211.90" + }, + { + "id": "2a4f2125-979a-4abb-881e-32eaa0bfd631", + "full_name": "Rita Joskovitch", + "email": "rjoskovitch1r@usa.gov", + "ip_address": "53.215.102.241" + }, + { + "id": "3d2398bb-b262-49f2-b811-27eb7904b4e1", + "full_name": "Horace Pyrke", + "email": "hpyrke1s@vk.com", + "ip_address": "88.75.162.35" + }, + { + "id": "6f8b5e14-3517-4247-997f-5b4b3ee98b95", + "full_name": "Ingamar Levane", + "email": "ilevane1t@sun.com", + "ip_address": "197.113.196.244" + }, + { + "id": "33b851bd-8e16-47ad-aa6a-e8ffbd83ad82", + "full_name": "Bronnie Lenglet", + "email": "blenglet1u@discovery.com", + "ip_address": "92.76.96.86" + }, + { + "id": "6c4a1b00-e671-493d-a8a0-af0d161628c1", + "full_name": "Maureene Onele", + "email": "monele1v@prnewswire.com", + "ip_address": "126.9.30.60" + }, + { + "id": "2d2e5c38-7a85-4f14-b60c-502a323018fd", + "full_name": "Tiffany Franca", + "email": "tfranca1w@pagesperso-orange.fr", + "ip_address": "128.80.7.108" + }, + { + "id": "d8265dd7-88d6-4c5b-be88-53ca53436adf", + "full_name": "Audi Bignall", + "email": "abignall1x@g.co", + "ip_address": "182.81.189.80" + }, + { + "id": "6716d290-8880-43fc-b127-258e5b98a5bd", + "full_name": "Welsh Carthew", + "email": "wcarthew1y@stanford.edu", + "ip_address": "84.96.214.19" + }, + { + "id": "b8a71d2a-75fd-4b39-9ecc-9053597662a8", + "full_name": "Ardyce Kiossel", + "email": "akiossel1z@patch.com", + "ip_address": "154.190.41.217" + }, + { + "id": "452b14f1-0368-4ee3-b53d-80ab0b899be8", + "full_name": "Ron Yorkston", + "email": "ryorkston20@cnn.com", + "ip_address": "52.101.135.41" + }, + { + "id": "60ed35c7-dfd3-4bc6-a23b-6050cb8cb573", + "full_name": "Lilias Ravilus", + "email": "lravilus21@instagram.com", + "ip_address": "92.72.189.110" + }, + { + "id": "839f6897-1066-402a-9370-54e7aa66eddc", + "full_name": "Nada Shilliday", + "email": "nshilliday22@flavors.me", + "ip_address": "6.182.120.117" + }, + { + "id": "557da8c8-cf7b-4ed1-ae97-c3834712e0c6", + "full_name": "Mina Wimmer", + "email": "mwimmer23@jimdo.com", + "ip_address": "164.134.208.222" + }, + { + "id": "5172a406-9f33-411e-abd9-675fca3ced4f", + "full_name": "Nerissa Seton", + "email": "nseton24@issuu.com", + "ip_address": "28.186.195.167" + }, + { + "id": "b42a7575-160c-4430-a4d2-e4714cfef182", + "full_name": "Bathsheba Turnpenny", + "email": "bturnpenny25@histats.com", + "ip_address": "146.101.27.188" + }, + { + "id": "8bf547d7-1191-46b5-92ab-8dafb14a9eb6", + "full_name": "Rosina Danilevich", + "email": "rdanilevich26@soundcloud.com", + "ip_address": "223.132.147.118" + }, + { + "id": "95041d8f-b94f-435e-adef-593efc68d8af", + "full_name": "Solly Beardall", + "email": "sbeardall27@eepurl.com", + "ip_address": "192.44.246.76" + }, + { + "id": "ea9c3ccd-dcf1-4cc9-a044-3f05947faf1d", + "full_name": "Denni Kingswood", + "email": "dkingswood28@epa.gov", + "ip_address": "221.91.191.200" + }, + { + "id": "6f9c685f-a72a-4351-8209-7d17baad60d3", + "full_name": "Sasha Lapenna", + "email": "slapenna29@smugmug.com", + "ip_address": "10.214.160.247" + }, + { + "id": "1bce7286-efd0-4131-9264-2bcc9b1ae97c", + "full_name": "Bea O'Siaghail", + "email": "bosiaghail2a@joomla.org", + "ip_address": "255.213.95.244" + }, + { + "id": "49aa9f38-1e51-4331-8df1-f83397bac36d", + "full_name": "Opalina Boman", + "email": "oboman2b@theguardian.com", + "ip_address": "53.30.205.193" + }, + { + "id": "e3968dd8-c2dd-40ba-a575-04b304d61174", + "full_name": "Gaynor Jory", + "email": "gjory2c@dropbox.com", + "ip_address": "222.77.42.126" + }, + { + "id": "c6445874-5d2f-4858-98ab-83813d8f79bd", + "full_name": "Barbee Deane", + "email": "bdeane2d@domainmarket.com", + "ip_address": "134.251.87.32" + }, + { + "id": "e6f6e8e5-7076-44a0-b8e1-193a6eeb0fbd", + "full_name": "Kippy Pietruszka", + "email": "kpietruszka2e@phpbb.com", + "ip_address": "72.14.100.15" + }, + { + "id": "311ec6b0-0661-47e5-ba20-1cb57e4f6e2c", + "full_name": "Niccolo Kivlehan", + "email": "nkivlehan2f@google.it", + "ip_address": "186.224.186.245" + }, + { + "id": "9adae70e-f0c6-4abb-a1d2-a617d7cb5dfa", + "full_name": "Marsh Kinnaird", + "email": "mkinnaird2g@oakley.com", + "ip_address": "36.61.217.58" + }, + { + "id": "68974614-59c4-4dd6-9270-0e420fcdf32c", + "full_name": "Ricky Wheatley", + "email": "rwheatley2h@quantcast.com", + "ip_address": "241.119.105.103" + }, + { + "id": "45d503fd-6181-432d-9213-c1a5c4a55d4a", + "full_name": "Devin McCaffrey", + "email": "dmccaffrey2i@networksolutions.com", + "ip_address": "13.151.152.108" + }, + { + "id": "1b7e8750-decf-42c1-9d5b-3dff839fe555", + "full_name": "Tiffanie Rawsen", + "email": "trawsen2j@tiny.cc", + "ip_address": "210.234.22.199" + }, + { + "id": "cdcbf320-3aea-426c-a0c2-d0727c045e4e", + "full_name": "Pepito Furlonge", + "email": "pfurlonge2k@rakuten.co.jp", + "ip_address": "32.33.152.17" + }, + { + "id": "e3f91274-f1f1-4a52-b2ce-62a61d93d01c", + "full_name": "Mortimer Knowlson", + "email": "mknowlson2l@nationalgeographic.com", + "ip_address": "59.92.135.179" + }, + { + "id": "66357d79-ed7e-4265-a11b-754136ed129e", + "full_name": "Henrie O' Lone", + "email": "ho2m@abc.net.au", + "ip_address": "247.192.179.187" + }, + { + "id": "943be4b8-c0a0-46f0-b0f5-64dffdbe1955", + "full_name": "Guthrie Robert", + "email": "grobert2n@canalblog.com", + "ip_address": "186.42.76.169" + }, + { + "id": "9b90ec6f-dba0-496b-854c-924cf8feff3a", + "full_name": "Amie Moulsdale", + "email": "amoulsdale2o@hugedomains.com", + "ip_address": "30.191.105.151" + }, + { + "id": "9f142c94-af05-4478-9208-c9bd8c8243b3", + "full_name": "Tommie Westraw", + "email": "twestraw2p@amazon.com", + "ip_address": "189.171.114.31" + }, + { + "id": "c222bb47-1972-4ff7-9813-8daa54269fe6", + "full_name": "Helena Kersaw", + "email": "hkersaw2q@surveymonkey.com", + "ip_address": "47.81.118.113" + }, + { + "id": "235248a5-ed80-4f3f-9661-ae1252066cf3", + "full_name": "Malanie Patrie", + "email": "mpatrie2r@domainmarket.com", + "ip_address": "110.203.240.42" + } +] diff --git a/services/frontend/site/global.d.ts b/services/frontend/site/global.d.ts new file mode 100644 index 00000000..498a1f9f --- /dev/null +++ b/services/frontend/site/global.d.ts @@ -0,0 +1,2 @@ +// Declarations for modules without types +declare module 'next-themes' diff --git a/services/frontend/site/lib/api/commerce.ts b/services/frontend/site/lib/api/commerce.ts new file mode 100644 index 00000000..49913700 --- /dev/null +++ b/services/frontend/site/lib/api/commerce.ts @@ -0,0 +1,3 @@ +import { getCommerceApi } from '@framework/api' + +export default getCommerceApi() diff --git a/services/frontend/site/lib/click-outside/click-outside.tsx b/services/frontend/site/lib/click-outside/click-outside.tsx new file mode 100644 index 00000000..973345b0 --- /dev/null +++ b/services/frontend/site/lib/click-outside/click-outside.tsx @@ -0,0 +1,83 @@ +import React, { + useRef, + useEffect, + MouseEvent, + FC, + ReactElement, + forwardRef, + Ref, +} from 'react' +import mergeRefs from 'react-merge-refs' +import hasParent from './has-parent' + +interface ClickOutsideProps { + active: boolean + onClick: (e?: MouseEvent) => void + ref?: Ref +} + +/** + * Use forward ref to allow this component to be used with other components like + * focus-trap-react, that rely on the same type of ref forwarding to direct children + */ +const ClickOutside: FC = forwardRef( + ({ active = true, onClick, children }, forwardedRef) => { + const innerRef = useRef() + + const child = children ? (React.Children.only(children) as any) : undefined + + if (!child || child.type === React.Fragment) { + /** + * React Fragments can't be used, as it would not be possible to pass the ref + * created here to them. + */ + throw new Error('A valid non Fragment React Children should be provided') + } + + if (typeof onClick != 'function') { + throw new Error('onClick must be a valid function') + } + + useEffect(() => { + if (active) { + document.addEventListener('mousedown', handleClick) + document.addEventListener('touchstart', handleClick) + } + return () => { + if (active) { + document.removeEventListener('mousedown', handleClick) + document.removeEventListener('touchstart', handleClick) + } + } + }) + + const handleClick = (event: any) => { + /** + * Check if the clicked element is contained by the top level tag provided to the + * ClickOutside component, if not, Outside clicked! Fire onClick cb + */ + if (!hasParent(event.target, innerRef?.current)) { + onClick(event) + } + } + + /** + * Preserve the child ref prop if exists and merge it with the one used here and the + * proxied by the forwardRef method + */ + const composedRefCallback = (element: ReactElement) => { + if (typeof child.ref === 'function') { + child.ref(element) + } else if (child.ref) { + child.ref.current = element + } + } + + return React.cloneElement(child, { + ref: mergeRefs([composedRefCallback, innerRef, forwardedRef]), + }) + } +) + +ClickOutside.displayName = 'ClickOutside' +export default ClickOutside diff --git a/services/frontend/site/lib/click-outside/has-parent.js b/services/frontend/site/lib/click-outside/has-parent.js new file mode 100644 index 00000000..06cd3ca9 --- /dev/null +++ b/services/frontend/site/lib/click-outside/has-parent.js @@ -0,0 +1,5 @@ +import isInDOM from './is-in-dom' + +export default function hasParent(element, root) { + return root && root.contains(element) && isInDOM(element) +} diff --git a/services/frontend/site/lib/click-outside/index.ts b/services/frontend/site/lib/click-outside/index.ts new file mode 100644 index 00000000..2df916f9 --- /dev/null +++ b/services/frontend/site/lib/click-outside/index.ts @@ -0,0 +1 @@ +export { default } from './click-outside' diff --git a/services/frontend/site/lib/click-outside/is-in-dom.js b/services/frontend/site/lib/click-outside/is-in-dom.js new file mode 100644 index 00000000..5d7438ed --- /dev/null +++ b/services/frontend/site/lib/click-outside/is-in-dom.js @@ -0,0 +1,3 @@ +export default function isInDom(obj) { + return Boolean(obj.closest('body')) +} diff --git a/services/frontend/site/lib/colors.ts b/services/frontend/site/lib/colors.ts new file mode 100644 index 00000000..43947c32 --- /dev/null +++ b/services/frontend/site/lib/colors.ts @@ -0,0 +1,206 @@ +import random from 'lodash.random' + +export function getRandomPairOfColors() { + const colors = ['#37B679', '#DA3C3C', '#3291FF', '#7928CA', '#79FFE1'] + const getRandomIdx = () => random(0, colors.length - 1) + let idx = getRandomIdx() + let idx2 = getRandomIdx() + + // Has to be a different color + while (idx2 === idx) { + idx2 = getRandomIdx() + } + + // Returns a pair of colors + return [colors[idx], colors[idx2]] +} + +function hexToRgb(hex: string = '') { + // @ts-ignore + const match = hex.toString(16).match(/[a-f0-9]{6}|[a-f0-9]{3}/i) + + if (!match) { + return [0, 0, 0] + } + + let colorString = match[0] + + if (match[0].length === 3) { + colorString = colorString + .split('') + .map((char: string) => { + return char + char + }) + .join('') + } + + const integer = parseInt(colorString, 16) + const r = (integer >> 16) & 0xff + const g = (integer >> 8) & 0xff + const b = integer & 0xff + + return [r, g, b] +} + +export const colorMap: Record = { + aliceblue: '#F0F8FF', + antiquewhite: '#FAEBD7', + aqua: '#00FFFF', + aquamarine: '#7FFFD4', + azure: '#F0FFFF', + beige: '#F5F5DC', + bisque: '#FFE4C4', + black: '#000000', + blanchedalmond: '#FFEBCD', + blue: '#0000FF', + blueviolet: '#8A2BE2', + brown: '#A52A2A', + burlywood: '#DEB887', + burgandy: '#800020', + burgundy: '#800020', + cadetblue: '#5F9EA0', + chartreuse: '#7FFF00', + chocolate: '#D2691E', + coral: '#FF7F50', + cornflowerblue: '#6495ED', + cornsilk: '#FFF8DC', + crimson: '#DC143C', + cyan: '#00FFFF', + darkblue: '#00008B', + darkcyan: '#008B8B', + darkgoldenrod: '#B8860B', + darkgray: '#A9A9A9', + darkgreen: '#006400', + darkgrey: '#A9A9A9', + darkkhaki: '#BDB76B', + darkmagenta: '#8B008B', + darkolivegreen: '#556B2F', + darkorange: '#FF8C00', + darkorchid: '#9932CC', + darkred: '#8B0000', + darksalmon: '#E9967A', + darkseagreen: '#8FBC8F', + darkslateblue: '#483D8B', + darkslategray: '#2F4F4F', + darkslategrey: '#2F4F4F', + darkturquoise: '#00CED1', + darkviolet: '#9400D3', + deeppink: '#FF1493', + deepskyblue: '#00BFFF', + dimgray: '#696969', + dimgrey: '#696969', + dodgerblue: '#1E90FF', + firebrick: '#B22222', + floralwhite: '#FFFAF0', + forestgreen: '#228B22', + fuchsia: '#FF00FF', + gainsboro: '#DCDCDC', + ghostwhite: '#F8F8FF', + gold: '#FFD700', + goldenrod: '#DAA520', + gray: '#808080', + green: '#008000', + greenyellow: '#ADFF2F', + grey: '#808080', + honeydew: '#F0FFF0', + hotpink: '#FF69B4', + indianred: '#CD5C5C', + indigo: '#4B0082', + ivory: '#FFFFF0', + khaki: '#F0E68C', + lavender: '#E6E6FA', + lavenderblush: '#FFF0F5', + lawngreen: '#7CFC00', + lemonchiffon: '#FFFACD', + lightblue: '#ADD8E6', + lightcoral: '#F08080', + lightcyan: '#E0FFFF', + lightgoldenrodyellow: '#FAFAD2', + lightgray: '#D3D3D3', + lightgreen: '#90EE90', + lightgrey: '#D3D3D3', + lightpink: '#FFB6C1', + lightsalmon: '#FFA07A', + lightseagreen: '#20B2AA', + lightskyblue: '#87CEFA', + lightslategray: '#778899', + lightslategrey: '#778899', + lightsteelblue: '#B0C4DE', + lightyellow: '#FFFFE0', + lime: '#00FF00', + limegreen: '#32CD32', + linen: '#FAF0E6', + magenta: '#FF00FF', + maroon: '#800000', + mediumaquamarine: '#66CDAA', + mediumblue: '#0000CD', + mediumorchid: '#BA55D3', + mediumpurple: '#9370DB', + mediumseagreen: '#3CB371', + mediumslateblue: '#7B68EE', + mediumspringgreen: '#00FA9A', + mediumturquoise: '#48D1CC', + mediumvioletred: '#C71585', + midnightblue: '#191970', + mintcream: '#F5FFFA', + mistyrose: '#FFE4E1', + moccasin: '#FFE4B5', + navajowhite: '#FFDEAD', + navy: '#000080', + oldlace: '#FDF5E6', + olive: '#808000', + olivedrab: '#6B8E23', + orange: '#FFA500', + orangered: '#FF4500', + orchid: '#DA70D6', + palegoldenrod: '#EEE8AA', + palegreen: '#98FB98', + paleturquoise: '#AFEEEE', + palevioletred: '#DB7093', + papayawhip: '#FFEFD5', + peachpuff: '#FFDAB9', + peru: '#CD853F', + pink: '#FFC0CB', + plum: '#DDA0DD', + powderblue: '#B0E0E6', + purple: '#800080', + rebeccapurple: '#663399', + red: '#FF0000', + rosybrown: '#BC8F8F', + royalblue: '#4169E1', + saddlebrown: '#8B4513', + salmon: '#FA8072', + sandybrown: '#F4A460', + seagreen: '#2E8B57', + seashell: '#FFF5EE', + sienna: '#A0522D', + silver: '#C0C0C0', + skyblue: '#87CEEB', + slateblue: '#6A5ACD', + slategray: '#708090', + slategrey: '#708090', + spacegrey: '#65737e', + spacegray: '#65737e', + snow: '#FFFAFA', + springgreen: '#00FF7F', + steelblue: '#4682B4', + tan: '#D2B48C', + teal: '#008080', + thistle: '#D8BFD8', + tomato: '#FF6347', + turquoise: '#40E0D0', + violet: '#EE82EE', + wheat: '#F5DEB3', + white: '#FFFFFF', + whitesmoke: '#F5F5F5', + yellow: '#FFFF00', + yellowgreen: '#9ACD32', +} + +export function isDark(color: string = ''): boolean { + color = color.toLowerCase() + // Equation from http://24ways.org/2010/calculating-color-contrast + let rgb = colorMap[color] ? hexToRgb(colorMap[color]) : hexToRgb(color) + const res = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000 + return res < 128 +} diff --git a/services/frontend/site/lib/focus-trap.tsx b/services/frontend/site/lib/focus-trap.tsx new file mode 100644 index 00000000..d886d6df --- /dev/null +++ b/services/frontend/site/lib/focus-trap.tsx @@ -0,0 +1,67 @@ +import React, { useEffect, RefObject } from 'react' +import { tabbable } from 'tabbable' + +interface Props { + children: React.ReactNode | any + focusFirst?: boolean +} + +export default function FocusTrap({ children, focusFirst = false }: Props) { + const root: RefObject = React.useRef() + const anchor: RefObject = React.useRef(document.activeElement) + + const returnFocus = () => { + // Returns focus to the last focused element prior to trap. + if (anchor) { + anchor.current.focus() + } + } + + const trapFocus = () => { + // Focus the container element + if (root.current) { + root.current.focus() + if (focusFirst) { + selectFirstFocusableEl() + } + } + } + + const selectFirstFocusableEl = () => { + // Try to find focusable elements, if match then focus + // Up to 6 seconds of load time threshold + let match = false + let end = 60 // Try to find match at least n times + let i = 0 + const timer = setInterval(() => { + if (!match !== i > end) { + match = !!tabbable(root.current).length + if (match) { + // Attempt to focus the first el + tabbable(root.current)[0].focus() + } + i = i + 1 + } else { + // Clear interval after n attempts + clearInterval(timer) + } + }, 100) + } + + useEffect(() => { + setTimeout(trapFocus, 20) + return () => { + returnFocus() + } + }, [root, children]) + + return React.createElement( + 'div', + { + ref: root, + className: 'outline-none focus-trap', + tabIndex: -1, + }, + children + ) +} diff --git a/services/frontend/site/lib/get-slug.ts b/services/frontend/site/lib/get-slug.ts new file mode 100644 index 00000000..329c5a27 --- /dev/null +++ b/services/frontend/site/lib/get-slug.ts @@ -0,0 +1,5 @@ +// Remove trailing and leading slash, usually included in nodes +// returned by the BigCommerce API +const getSlug = (path: string) => path.replace(/^\/|\/$/g, '') + +export default getSlug diff --git a/services/frontend/site/lib/hooks/useAcceptCookies.ts b/services/frontend/site/lib/hooks/useAcceptCookies.ts new file mode 100644 index 00000000..7f33adf4 --- /dev/null +++ b/services/frontend/site/lib/hooks/useAcceptCookies.ts @@ -0,0 +1,24 @@ +import Cookies from 'js-cookie' +import { useEffect, useState } from 'react' + +const COOKIE_NAME = 'accept_cookies' + +export const useAcceptCookies = () => { + const [acceptedCookies, setAcceptedCookies] = useState(true) + + useEffect(() => { + if (!Cookies.get(COOKIE_NAME)) { + setAcceptedCookies(false) + } + }, []) + + const acceptCookies = () => { + setAcceptedCookies(true) + Cookies.set(COOKIE_NAME, 'accepted', { expires: 365 }) + } + + return { + acceptedCookies, + onAcceptCookies: acceptCookies, + } +} diff --git a/services/frontend/site/lib/hooks/useUserAvatar.ts b/services/frontend/site/lib/hooks/useUserAvatar.ts new file mode 100644 index 00000000..840daae6 --- /dev/null +++ b/services/frontend/site/lib/hooks/useUserAvatar.ts @@ -0,0 +1,26 @@ +import { useEffect } from 'react' +import { useUI } from '@components/ui/context' +import { getRandomPairOfColors } from '@lib/colors' + +export const useUserAvatar = (name = 'userAvatar') => { + const { userAvatar, setUserAvatar } = useUI() + + useEffect(() => { + if (!userAvatar && localStorage.getItem(name)) { + // Get bg from localStorage and push it to the context. + setUserAvatar(localStorage.getItem(name)) + } + if (!localStorage.getItem(name)) { + // bg not set locally, generating one, setting localStorage and context to persist. + const bg = getRandomPairOfColors() + const value = `linear-gradient(140deg, ${bg[0]}, ${bg[1]} 100%)` + localStorage.setItem(name, value) + setUserAvatar(value) + } + }, []) + + return { + userAvatar, + setUserAvatar, + } +} diff --git a/services/frontend/site/lib/range-map.ts b/services/frontend/site/lib/range-map.ts new file mode 100644 index 00000000..886f20d6 --- /dev/null +++ b/services/frontend/site/lib/range-map.ts @@ -0,0 +1,7 @@ +export default function rangeMap(n: number, fn: (i: number) => any) { + const arr = [] + while (n > arr.length) { + arr.push(fn(arr.length)) + } + return arr +} diff --git a/services/frontend/site/lib/search-props.tsx b/services/frontend/site/lib/search-props.tsx new file mode 100644 index 00000000..6910e0d8 --- /dev/null +++ b/services/frontend/site/lib/search-props.tsx @@ -0,0 +1,27 @@ +import type { GetStaticPropsContext, InferGetStaticPropsType } from 'next' + +import commerce from '@lib/api/commerce' + +export async function getSearchStaticProps({ + preview, + locale, + locales, +}: GetStaticPropsContext) { + const config = { locale, locales } + const pagesPromise = commerce.getAllPages({ config, preview }) + const siteInfoPromise = commerce.getSiteInfo({ config, preview }) + const { pages } = await pagesPromise + const { categories, brands } = await siteInfoPromise + return { + props: { + pages, + categories, + brands, + }, + revalidate: 200, + } +} + +export type SearchPropsType = InferGetStaticPropsType< + typeof getSearchStaticProps +> diff --git a/services/frontend/site/lib/search.tsx b/services/frontend/site/lib/search.tsx new file mode 100644 index 00000000..eaeaf66f --- /dev/null +++ b/services/frontend/site/lib/search.tsx @@ -0,0 +1,52 @@ +import { useEffect, useState } from 'react' +import getSlug from './get-slug' + +export function useSearchMeta(asPath: string) { + const [pathname, setPathname] = useState('/search') + const [category, setCategory] = useState() + const [brand, setBrand] = useState() + + useEffect(() => { + // Only access asPath after hydration to avoid a server mismatch + const path = asPath.split('?')[0] + const parts = path.split('/') + + let c = parts[2] + let b = parts[3] + + if (c === 'designers') { + c = parts[4] + } + + if (path !== pathname) setPathname(path) + if (c !== category) setCategory(c) + if (b !== brand) setBrand(b) + }, [asPath, pathname, category, brand]) + + return { pathname, category, brand } +} + +// Removes empty query parameters from the query object +export const filterQuery = (query: any) => + Object.keys(query).reduce((obj, key) => { + if (query[key]?.length) { + obj[key] = query[key] + } + return obj + }, {}) + +export const getCategoryPath = (path: string, brand?: string) => { + const category = getSlug(path) + + return `/search${brand ? `/designers/${brand}` : ''}${ + category ? `/${category}` : '' + }` +} + +export const getDesignerPath = (path: string, category?: string) => { + const designer = getSlug(path).replace(/^brands/, 'designers') + + return `/search${designer ? `/${designer}` : ''}${ + category ? `/${category}` : '' + }` +} diff --git a/services/frontend/site/lib/to-pixels.ts b/services/frontend/site/lib/to-pixels.ts new file mode 100644 index 00000000..1701a85f --- /dev/null +++ b/services/frontend/site/lib/to-pixels.ts @@ -0,0 +1,13 @@ +// Convert numbers or strings to pixel value +// Helpful for styled-jsx when using a prop +// height: ${toPixels(height)}; (supports height={20} and height="20px") + +const toPixels = (value: string | number) => { + if (typeof value === 'number') { + return `${value}px` + } + + return value +} + +export default toPixels diff --git a/services/frontend/site/lib/usage-warns.ts b/services/frontend/site/lib/usage-warns.ts new file mode 100644 index 00000000..2033cd02 --- /dev/null +++ b/services/frontend/site/lib/usage-warns.ts @@ -0,0 +1,26 @@ +/** + * The utils here are used to help developers use the example + */ + +export function missingLocaleInPages(): [string[], () => void] { + const invalidPaths: string[] = [] + const log = () => { + if (invalidPaths.length) { + const single = invalidPaths.length === 1 + const pages = single ? 'page' : 'pages' + + console.log( + `The ${pages} "${invalidPaths.join(', ')}" ${ + single ? 'does' : 'do' + } not include a locale, or the locale is not supported. When using i18n, web pages from +BigCommerce are expected to have a locale or they will be ignored.\n +Please update the ${pages} to include the default locale or make the ${pages} invisible by +unchecking the "Navigation Menu" option in the settings of ${ + single ? 'the' : 'each' + } web page\n` + ) + } + } + + return [invalidPaths, log] +} diff --git a/services/frontend/site/next-env.d.ts b/services/frontend/site/next-env.d.ts new file mode 100644 index 00000000..4f11a03d --- /dev/null +++ b/services/frontend/site/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/services/frontend/site/next.config.js b/services/frontend/site/next.config.js new file mode 100644 index 00000000..cf0d42fd --- /dev/null +++ b/services/frontend/site/next.config.js @@ -0,0 +1,44 @@ +const commerce = require('./commerce.config.json') +const { withCommerceConfig, getProviderName } = require('./commerce-config') + +const provider = commerce.provider || getProviderName() +const isBC = false +const isShopify = false +const isSaleor = false +const isSwell = false +const isVendure = false + +module.exports = withCommerceConfig({ + commerce, + images: { + domains: [process.env.NEXT_PUBLIC_SPREE_ALLOWED_IMAGE_DOMAIN], + }, + i18n: { + locales: ['en-US', 'es'], + defaultLocale: 'en-US', + }, + rewrites() { + return [ + (isBC || isShopify || isSwell || isVendure || isSaleor) && { + source: '/checkout', + destination: '/api/checkout', + }, + // The logout is also an action so this route is not required, but it's also another way + // you can allow a logout! + isBC && { + source: '/logout', + destination: '/api/logout?redirect_to=/', + }, + // For Vendure, rewrite the local api url to the remote (external) api url. This is required + // to make the session cookies work. + isVendure && + process.env.NEXT_PUBLIC_VENDURE_LOCAL_URL && { + source: `${process.env.NEXT_PUBLIC_VENDURE_LOCAL_URL}/:path*`, + destination: `${process.env.NEXT_PUBLIC_VENDURE_SHOP_API_URL}/:path*`, + }, + ].filter(Boolean) + }, +}) + +// Don't delete this console log, useful to see the commerce config in Vercel deployments +console.log('next.config.js', JSON.stringify(module.exports, null, 2)) diff --git a/services/frontend/site/package.json b/services/frontend/site/package.json new file mode 100644 index 00000000..b3c9630d --- /dev/null +++ b/services/frontend/site/package.json @@ -0,0 +1,100 @@ +{ + "name": "next-commerce", + "version": "0.0.1", + "license": "MIT", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "analyze": "BUNDLE_ANALYZE=both next build", + "lint": "next lint", + "prettier-fix": "prettier --write .", + "find:unused": "npx next-unused" + }, + "sideEffects": false, + "dependencies": { + "@datadog/browser-rum": "^4.14.0", + "@radix-ui/react-dropdown-menu": "^0.1.6", + "@react-spring/web": "^9.4.1", + "@vercel/commerce": "^0.0.1", + "@vercel/commerce-local": "^0.0.1", + "@vercel/commerce-spree": "^0.0.1", + "@vercel/commerce-swell": "^0.0.1", + "autoprefixer": "^10.4.2", + "body-scroll-lock": "^4.0.0-beta.0", + "clsx": "^1.1.1", + "email-validator": "^2.0.4", + "js-cookie": "^3.0.1", + "keen-slider": "^6.6.3", + "lodash.random": "^3.2.0", + "lodash.throttle": "^4.1.1", + "next": "^12.0.8", + "next-themes": "^0.0.15", + "postcss": "^8.3.5", + "postcss-nesting": "^8.0.1", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-fast-marquee": "^1.3.1", + "react-merge-refs": "^1.1.0", + "react-use-measure": "^2.1.1", + "tabbable": "^5.2.1", + "tailwindcss": "^3.0.13" + }, + "devDependencies": { + "@next/bundle-analyzer": "^12.0.8", + "@types/body-scroll-lock": "^3.1.0", + "@types/js-cookie": "^3.0.1", + "@types/lodash.random": "^3.2.6", + "@types/lodash.throttle": "^4.1.6", + "@types/node": "^17.0.8", + "@types/react": "^17.0.38", + "eslint": "^8.6.0", + "eslint-config-next": "^12.0.8", + "eslint-config-prettier": "^8.3.0", + "lint-staged": "^12.1.7", + "postcss-flexbugs-fixes": "^5.0.2", + "postcss-preset-env": "^7.2.3", + "prettier": "^2.5.1", + "typescript": "4.3.4" + }, + "lint-staged": { + "**/*.{js,jsx,ts,tsx}": [ + "eslint", + "prettier --write", + "git add" + ], + "**/*.{md,mdx,json}": [ + "prettier --write", + "git add" + ] + }, + "next-unused": { + "alias": { + "@lib/*": [ + "lib/*" + ], + "@assets/*": [ + "assets/*" + ], + "@config/*": [ + "config/*" + ], + "@components/*": [ + "components/*" + ], + "@utils/*": [ + "utils/*" + ] + }, + "debug": true, + "include": [ + "components", + "lib", + "pages" + ], + "exclude": [], + "entrypoints": [ + "pages" + ] + } +} diff --git a/services/frontend/site/pages/404.tsx b/services/frontend/site/pages/404.tsx new file mode 100644 index 00000000..bd085010 --- /dev/null +++ b/services/frontend/site/pages/404.tsx @@ -0,0 +1,35 @@ +import type { GetStaticPropsContext } from 'next' +import commerce from '@lib/api/commerce' +import { Layout } from '@components/common' +import { Text } from '@components/ui' + +export async function getStaticProps({ + preview, + locale, + locales, +}: GetStaticPropsContext) { + const config = { locale, locales } + const { pages } = await commerce.getAllPages({ config, preview }) + const { categories, brands } = await commerce.getSiteInfo({ config, preview }) + return { + props: { + pages, + categories, + brands, + }, + revalidate: 200, + } +} + +export default function NotFound() { + return ( +
    + Not Found + + The requested page doesn't exist or you don't have access to it. + +
    + ) +} + +NotFound.Layout = Layout diff --git a/services/frontend/site/pages/[...pages].tsx b/services/frontend/site/pages/[...pages].tsx new file mode 100644 index 00000000..f0f7e599 --- /dev/null +++ b/services/frontend/site/pages/[...pages].tsx @@ -0,0 +1,87 @@ +import type { + GetStaticPathsContext, + GetStaticPropsContext, + InferGetStaticPropsType, +} from 'next' +import commerce from '@lib/api/commerce' +import { Text } from '@components/ui' +import { Layout } from '@components/common' +import getSlug from '@lib/get-slug' +import { missingLocaleInPages } from '@lib/usage-warns' +import type { Page } from '@commerce/types/page' +import { useRouter } from 'next/router' + +export async function getStaticProps({ + preview, + params, + locale, + locales, +}: GetStaticPropsContext<{ pages: string[] }>) { + const config = { locale, locales } + const pagesPromise = commerce.getAllPages({ config, preview }) + const siteInfoPromise = commerce.getSiteInfo({ config, preview }) + const { pages } = await pagesPromise + const { categories } = await siteInfoPromise + const path = params?.pages.join('/') + const slug = locale ? `${locale}/${path}` : path + const pageItem = pages.find((p: Page) => + p.url ? getSlug(p.url) === slug : false + ) + const data = + pageItem && + (await commerce.getPage({ + variables: { id: pageItem.id! }, + config, + preview, + })) + + const page = data?.page + + if (!page) { + return { + notFound: true, + } + } + + return { + props: { pages, page, categories }, + revalidate: 60 * 60, // Every hour + } +} + +export async function getStaticPaths({ locales }: GetStaticPathsContext) { + const config = { locales } + const { pages }: { pages: Page[] } = await commerce.getAllPages({ config }) + const [invalidPaths, log] = missingLocaleInPages() + const paths = pages + .map((page) => page.url) + .filter((url) => { + if (!url || !locales) return url + // If there are locales, only include the pages that include one of the available locales + if (locales.includes(getSlug(url).split('/')[0])) return url + + invalidPaths.push(url) + }) + log() + + return { + paths, + fallback: 'blocking', + } +} + +export default function Pages({ + page, +}: {page: Page}) { + const router = useRouter() + + return router.isFallback ? ( +

    Loading...

    // TODO (BC) Add Skeleton Views + ) : ( +
    + {page?.body && } +
    + ) +} + +Pages.Layout = Layout diff --git a/services/frontend/site/pages/_app.tsx b/services/frontend/site/pages/_app.tsx new file mode 100644 index 00000000..76da976e --- /dev/null +++ b/services/frontend/site/pages/_app.tsx @@ -0,0 +1,88 @@ +import '@assets/main.css'; +import '@assets/chrome-bug.css'; +import 'keen-slider/keen-slider.min.css'; + +import { FC, useEffect } from 'react'; +import type { AppProps } from 'next/app'; +import { useRouter } from 'next/router'; +import { CommerceProvider } from '@framework'; +import useCart from '@framework/cart/use-cart'; + +import { Head } from '@components/common'; +import { ManagedUIContext } from '@components/ui/context'; +import { datadogRum } from '@datadog/browser-rum'; + +import userData from '@config/user_data.json'; + +datadogRum.init({ + applicationId: `${ + process.env.NEXT_PUBLIC_DD_APPLICATION_ID || 'DD_APPLICATION_ID_PLACEHOLDER' + }`, + clientToken: `${ + process.env.NEXT_PUBLIC_DD_CLIENT_TOKEN || 'DD_CLIENT_TOKEN_PLACEHOLDER' + }`, + site: `${process.env.NEXT_PUBLIC_DD_SITE || 'datadoghq.com'}`, + service: `${process.env.NEXT_PUBLIC_DD_SERVICE || 'frontend'}`, + version: `${process.env.NEXT_PUBLIC_DD_VERSION || '1.0.0'}`, + env: `${process.env.NEXT_PUBLIC_DD_ENV || 'development'}`, + sampleRate: 100, + trackInteractions: true, + trackFrustrations: true, + defaultPrivacyLevel: 'mask-user-input', + allowedTracingOrigins: [/https:\/\/.*\.env.play.instruqt\.com/], +}); + +datadogRum.startSessionReplayRecording(); + +const user = userData[Math.floor(Math.random() * userData.length)]; + +datadogRum.setUser(user); + +const Noop: FC = ({ children }) => <>{children}; + +const CartWatcher = () => { + const { data: cartData } = useCart(); + useEffect(() => { + if (!cartData) { + return; + } + + datadogRum.addRumGlobalContext('cart_status', { + cartTotal: cartData.totalPrice, + lineItems: cartData.lineItems, + }); + + datadogRum.addAction('Cart Updated', { + cartTotal: cartData.totalPrice, + discounts: cartData.discounts, + id: cartData.id, + lineItems: cartData.lineItems, + }); + }, [cartData]); + + return null; +}; + +export default function MyApp({ Component, pageProps }: AppProps) { + const { locale = 'en-US' } = useRouter(); + + const Layout = (Component as any).Layout || Noop; + + useEffect(() => { + document.body.classList?.remove('loading'); + }, []); + + return ( + <> + + + + + + + + + + + ); +} diff --git a/services/frontend/site/pages/_document.tsx b/services/frontend/site/pages/_document.tsx new file mode 100644 index 00000000..dcd214e4 --- /dev/null +++ b/services/frontend/site/pages/_document.tsx @@ -0,0 +1,17 @@ +import Document, { Head, Html, Main, NextScript } from 'next/document' + +class MyDocument extends Document { + render() { + return ( + + + +
    + + + + ) + } +} + +export default MyDocument diff --git a/services/frontend/site/pages/api/cart.ts b/services/frontend/site/pages/api/cart.ts new file mode 100644 index 00000000..64289110 --- /dev/null +++ b/services/frontend/site/pages/api/cart.ts @@ -0,0 +1,4 @@ +import cartApi from '@framework/api/endpoints/cart' +import commerce from '@lib/api/commerce' + +export default cartApi(commerce) diff --git a/services/frontend/site/pages/api/catalog/products.ts b/services/frontend/site/pages/api/catalog/products.ts new file mode 100644 index 00000000..631bfd51 --- /dev/null +++ b/services/frontend/site/pages/api/catalog/products.ts @@ -0,0 +1,4 @@ +import productsApi from '@framework/api/endpoints/catalog/products' +import commerce from '@lib/api/commerce' + +export default productsApi(commerce) diff --git a/services/frontend/site/pages/api/checkout.ts b/services/frontend/site/pages/api/checkout.ts new file mode 100644 index 00000000..7bf0fd9a --- /dev/null +++ b/services/frontend/site/pages/api/checkout.ts @@ -0,0 +1,4 @@ +import checkoutApi from '@framework/api/endpoints/checkout' +import commerce from '@lib/api/commerce' + +export default checkoutApi(commerce) diff --git a/services/frontend/site/pages/api/customer/address.ts b/services/frontend/site/pages/api/customer/address.ts new file mode 100644 index 00000000..5815ea46 --- /dev/null +++ b/services/frontend/site/pages/api/customer/address.ts @@ -0,0 +1,4 @@ +import customerAddressApi from '@framework/api/endpoints/customer/address' +import commerce from '@lib/api/commerce' + +export default customerAddressApi(commerce) diff --git a/services/frontend/site/pages/api/customer/card.ts b/services/frontend/site/pages/api/customer/card.ts new file mode 100644 index 00000000..6f88b8c7 --- /dev/null +++ b/services/frontend/site/pages/api/customer/card.ts @@ -0,0 +1,4 @@ +import customerCardApi from '@framework/api/endpoints/customer/card' +import commerce from '@lib/api/commerce' + +export default customerCardApi(commerce) diff --git a/services/frontend/site/pages/api/customer/index.ts b/services/frontend/site/pages/api/customer/index.ts new file mode 100644 index 00000000..0c86e76e --- /dev/null +++ b/services/frontend/site/pages/api/customer/index.ts @@ -0,0 +1,4 @@ +import customerApi from '@framework/api/endpoints/customer' +import commerce from '@lib/api/commerce' + +export default customerApi(commerce) diff --git a/services/frontend/site/pages/api/login.ts b/services/frontend/site/pages/api/login.ts new file mode 100644 index 00000000..9d0b6ae5 --- /dev/null +++ b/services/frontend/site/pages/api/login.ts @@ -0,0 +1,4 @@ +import loginApi from '@framework/api/endpoints/login' +import commerce from '@lib/api/commerce' + +export default loginApi(commerce) diff --git a/services/frontend/site/pages/api/logout.ts b/services/frontend/site/pages/api/logout.ts new file mode 100644 index 00000000..0cf0fc4d --- /dev/null +++ b/services/frontend/site/pages/api/logout.ts @@ -0,0 +1,4 @@ +import logoutApi from '@framework/api/endpoints/logout' +import commerce from '@lib/api/commerce' + +export default logoutApi(commerce) diff --git a/services/frontend/site/pages/api/signup.ts b/services/frontend/site/pages/api/signup.ts new file mode 100644 index 00000000..e19d67ee --- /dev/null +++ b/services/frontend/site/pages/api/signup.ts @@ -0,0 +1,4 @@ +import singupApi from '@framework/api/endpoints/signup' +import commerce from '@lib/api/commerce' + +export default singupApi(commerce) diff --git a/services/frontend/site/pages/api/wishlist.ts b/services/frontend/site/pages/api/wishlist.ts new file mode 100644 index 00000000..3b968120 --- /dev/null +++ b/services/frontend/site/pages/api/wishlist.ts @@ -0,0 +1,4 @@ +import wishlistApi from '@framework/api/endpoints/wishlist' +import commerce from '@lib/api/commerce' + +export default wishlistApi(commerce) diff --git a/services/frontend/site/pages/cart.tsx b/services/frontend/site/pages/cart.tsx new file mode 100644 index 00000000..6823f7b6 --- /dev/null +++ b/services/frontend/site/pages/cart.tsx @@ -0,0 +1,192 @@ +import type { GetStaticPropsContext } from 'next' +import useCart from '@framework/cart/use-cart' +import usePrice from '@framework/product/use-price' +import commerce from '@lib/api/commerce' +import { Layout } from '@components/common' +import { Button, Text, Container } from '@components/ui' +import { Bag, Cross, Check, MapPin, CreditCard } from '@components/icons' +import { CartItem } from '@components/cart' +import { useUI } from '@components/ui/context' + +export async function getStaticProps({ + preview, + locale, + locales, +}: GetStaticPropsContext) { + const config = { locale, locales } + const pagesPromise = commerce.getAllPages({ config, preview }) + const siteInfoPromise = commerce.getSiteInfo({ config, preview }) + const { pages } = await pagesPromise + const { categories } = await siteInfoPromise + return { + props: { pages, categories }, + } +} + +export default function Cart() { + const error = null + const success = null + const { data, isLoading, isEmpty } = useCart() + const { openSidebar, setSidebarView } = useUI() + + const { price: subTotal } = usePrice( + data && { + amount: Number(data.subtotalPrice), + currencyCode: data.currency.code, + } + ) + const { price: total } = usePrice( + data && { + amount: Number(data.totalPrice), + currencyCode: data.currency.code, + } + ) + + const goToCheckout = () => { + openSidebar() + setSidebarView('CHECKOUT_VIEW') + } + + return ( + +
    + {isLoading || isEmpty ? ( +
    + + + +

    + Your cart is empty +

    +

    + Biscuit oat cake wafer icing ice cream tiramisu pudding cupcake. +

    +
    + ) : error ? ( +
    + + + +

    + We couldn’t process the purchase. Please check your card + information and try again. +

    +
    + ) : success ? ( +
    + + + +

    + Thank you for your order. +

    +
    + ) : ( +
    + My Cart + Review your Order +
      + {data!.lineItems.map((item: any) => ( + + ))} +
    +
    + + Before you leave, take a look at these items. We picked them + just for you + +
    + {[1, 2, 3, 4, 5, 6].map((x) => ( +
    + ))} +
    +
    +
    + )} +
    +
    +
    + {process.env.COMMERCE_CUSTOMCHECKOUT_ENABLED && ( + <> + {/* Shipping Address */} + {/* Only available with customCheckout set to true - Meaning that the provider does offer checkout functionality. */} +
    +
    + +
    +
    + + Add Shipping Address + {/* + 1046 Kearny Street.
    + San Franssisco, California +
    */} +
    +
    + {/* Payment Method */} + {/* Only available with customCheckout set to true - Meaning that the provider does offer checkout functionality. */} +
    +
    + +
    +
    + + Add Payment Method + {/* VISA #### #### #### 2345 */} +
    +
    + + )} +
    +
      +
    • + Subtotal + {subTotal} +
    • +
    • + Taxes + Calculated at checkout +
    • +
    • + Estimated Shipping + FREE +
    • +
    +
    + Total + {total} +
    +
    +
    +
    + {isEmpty ? ( + + ) : ( + <> + {process.env.COMMERCE_CUSTOMCHECKOUT_ENABLED ? ( + + ) : ( + + )} + + )} +
    +
    +
    +
    + + ) +} + +Cart.Layout = Layout diff --git a/services/frontend/site/pages/index.tsx b/services/frontend/site/pages/index.tsx new file mode 100644 index 00000000..270bd639 --- /dev/null +++ b/services/frontend/site/pages/index.tsx @@ -0,0 +1,78 @@ +import commerce from '@lib/api/commerce'; +import { Layout } from '@components/common'; +import Ad from '@components/common/Ad'; +import { ProductCard } from '@components/product'; +import { Grid, Marquee, Hero } from '@components/ui'; +// import HomeAllProductsGrid from '@components/common/HomeAllProductsGrid' +import type { GetStaticPropsContext, InferGetStaticPropsType } from 'next'; + +export async function getStaticProps({ + preview, + locale, + locales, +}: GetStaticPropsContext) { + const config = { locale, locales }; + const productsPromise = commerce.getAllProducts({ + variables: { first: 6 }, + config, + preview, + // Saleor provider only + ...({ featured: true } as any), + }); + const pagesPromise = commerce.getAllPages({ config, preview }); + const siteInfoPromise = commerce.getSiteInfo({ config, preview }); + const { products } = await productsPromise; + const { pages } = await pagesPromise; + const { categories, brands } = await siteInfoPromise; + + return { + props: { + products, + categories, + brands, + pages, + }, + revalidate: 60, + }; +} + +export default function Home({ + products, +}: InferGetStaticPropsType) { + return ( + <> + + {products.slice(0, 3).map((product: any, i: number) => ( + + ))} + + + + + + {products.map((product: any, i: number) => ( + + ))} + + {/* */} + + ); +} + +Home.Layout = Layout; diff --git a/services/frontend/site/pages/orders.tsx b/services/frontend/site/pages/orders.tsx new file mode 100644 index 00000000..58f44450 --- /dev/null +++ b/services/frontend/site/pages/orders.tsx @@ -0,0 +1,42 @@ +import type { GetStaticPropsContext } from 'next' +import commerce from '@lib/api/commerce' +import { Bag } from '@components/icons' +import { Layout } from '@components/common' +import { Container, Text } from '@components/ui' + +export async function getStaticProps({ + preview, + locale, + locales, +}: GetStaticPropsContext) { + const config = { locale, locales } + const pagesPromise = commerce.getAllPages({ config, preview }) + const siteInfoPromise = commerce.getSiteInfo({ config, preview }) + const { pages } = await pagesPromise + const { categories } = await siteInfoPromise + + return { + props: { pages, categories }, + } +} + +export default function Orders() { + return ( + + My Orders +
    + + + +

    + No orders found +

    +

    + Biscuit oat cake wafer icing ice cream tiramisu pudding cupcake. +

    +
    +
    + ) +} + +Orders.Layout = Layout diff --git a/services/frontend/site/pages/product/[slug].tsx b/services/frontend/site/pages/product/[slug].tsx new file mode 100644 index 00000000..7dfd80ff --- /dev/null +++ b/services/frontend/site/pages/product/[slug].tsx @@ -0,0 +1,89 @@ +import type { GetServerSidePropsContext, InferGetStaticPropsType } from 'next'; +import { useRouter } from 'next/router'; +import { useEffect, useState } from 'react'; +import commerce from '@lib/api/commerce'; +import { Layout } from '@components/common'; +import { ProductView } from '@components/product'; + +function later(delay) { + return new Promise(function (resolve) { + setTimeout(resolve, delay); + }); +} + +export async function getServerSideProps({ + req, + params, + locale, + locales, + preview, +}: GetServerSidePropsContext<{ slug: string }>) { + const config = { locale, locales }; + const pagesPromise = commerce.getAllPages({ config, preview }); + const siteInfoPromise = commerce.getSiteInfo({ config, preview }); + const productPromise = commerce.getProduct({ + variables: { slug: params!.slug }, + config, + preview, + }); + + const allProductsPromise = commerce.getAllProducts({ + variables: { first: 4 }, + config, + preview, + }); + const { pages } = await pagesPromise; + const { categories } = await siteInfoPromise; + const { product } = await productPromise; + const { products: relatedProducts } = await allProductsPromise; + + if (!product) { + throw new Error(`Product with slug '${params!.slug}' not found`); + } + + return { + props: { + pages, + product, + relatedProducts, + categories, + headers: req.headers, + }, + }; +} + +export default function Slug({ + product, + relatedProducts, + headers, + categories, + pages, +}: InferGetStaticPropsType) { + const router = useRouter(); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (loading) { + loadData(); + } + }, [loading]); + + async function loadData() { + if (headers.referer.includes('/search')) { + await later(Math.round(Math.random() * 7000) + 500); + } + setLoading(false); + } + + return router.isFallback || loading ? ( +

    Loading...

    + ) : ( + + ); +} + +Slug.Layout = Layout; diff --git a/services/frontend/site/pages/profile.tsx b/services/frontend/site/pages/profile.tsx new file mode 100644 index 00000000..4c883059 --- /dev/null +++ b/services/frontend/site/pages/profile.tsx @@ -0,0 +1,52 @@ +import type { GetStaticPropsContext } from 'next' +import useCustomer from '@framework/customer/use-customer' +import commerce from '@lib/api/commerce' +import { Layout } from '@components/common' +import { Container, Text } from '@components/ui' + +export async function getStaticProps({ + preview, + locale, + locales, +}: GetStaticPropsContext) { + const config = { locale, locales } + const pagesPromise = commerce.getAllPages({ config, preview }) + const siteInfoPromise = commerce.getSiteInfo({ config, preview }) + const { pages } = await pagesPromise + const { categories } = await siteInfoPromise + + return { + props: { pages, categories }, + } +} + +export default function Profile() { + const { data } = useCustomer() + return ( + + My Profile +
    + {data && ( +
    +
    + + Full Name + + + {data.firstName} {data.lastName} + +
    +
    + + Email + + {data.email} +
    +
    + )} +
    +
    + ) +} + +Profile.Layout = Layout diff --git a/services/frontend/site/pages/search.tsx b/services/frontend/site/pages/search.tsx new file mode 100644 index 00000000..23f0ee04 --- /dev/null +++ b/services/frontend/site/pages/search.tsx @@ -0,0 +1,9 @@ +import { getSearchStaticProps } from '@lib/search-props' +import type { GetStaticPropsContext } from 'next' +import Search from '@components/search' + +export async function getStaticProps(context: GetStaticPropsContext) { + return getSearchStaticProps(context) +} + +export default Search diff --git a/services/frontend/site/pages/search/[category].tsx b/services/frontend/site/pages/search/[category].tsx new file mode 100644 index 00000000..02eddd03 --- /dev/null +++ b/services/frontend/site/pages/search/[category].tsx @@ -0,0 +1,16 @@ +import { getSearchStaticProps } from '@lib/search-props' +import type { GetStaticPathsResult, GetStaticPropsContext } from 'next' +import Search from '@components/search' + +export async function getStaticProps(context: GetStaticPropsContext) { + return getSearchStaticProps(context) +} + +export function getStaticPaths(): GetStaticPathsResult { + return { + paths: [], + fallback: 'blocking', + } +} + +export default Search diff --git a/services/frontend/site/pages/search/designers/[name].tsx b/services/frontend/site/pages/search/designers/[name].tsx new file mode 100644 index 00000000..02eddd03 --- /dev/null +++ b/services/frontend/site/pages/search/designers/[name].tsx @@ -0,0 +1,16 @@ +import { getSearchStaticProps } from '@lib/search-props' +import type { GetStaticPathsResult, GetStaticPropsContext } from 'next' +import Search from '@components/search' + +export async function getStaticProps(context: GetStaticPropsContext) { + return getSearchStaticProps(context) +} + +export function getStaticPaths(): GetStaticPathsResult { + return { + paths: [], + fallback: 'blocking', + } +} + +export default Search diff --git a/services/frontend/site/pages/search/designers/[name]/[category].tsx b/services/frontend/site/pages/search/designers/[name]/[category].tsx new file mode 100644 index 00000000..02eddd03 --- /dev/null +++ b/services/frontend/site/pages/search/designers/[name]/[category].tsx @@ -0,0 +1,16 @@ +import { getSearchStaticProps } from '@lib/search-props' +import type { GetStaticPathsResult, GetStaticPropsContext } from 'next' +import Search from '@components/search' + +export async function getStaticProps(context: GetStaticPropsContext) { + return getSearchStaticProps(context) +} + +export function getStaticPaths(): GetStaticPathsResult { + return { + paths: [], + fallback: 'blocking', + } +} + +export default Search diff --git a/services/frontend/site/pages/wishlist.tsx b/services/frontend/site/pages/wishlist.tsx new file mode 100644 index 00000000..1b8edb31 --- /dev/null +++ b/services/frontend/site/pages/wishlist.tsx @@ -0,0 +1,82 @@ +import type { GetStaticPropsContext } from 'next' +import commerce from '@lib/api/commerce' +import { Heart } from '@components/icons' +import { Layout } from '@components/common' +import { Text, Container, Skeleton } from '@components/ui' +import { useCustomer } from '@framework/customer' +import { WishlistCard } from '@components/wishlist' +import useWishlist from '@framework/wishlist/use-wishlist' +import rangeMap from '@lib/range-map' + +export async function getStaticProps({ + preview, + locale, + locales, +}: GetStaticPropsContext) { + // Disabling page if Feature is not available + if (!process.env.COMMERCE_WISHLIST_ENABLED) { + return { + notFound: true, + } + } + + const config = { locale, locales } + const pagesPromise = commerce.getAllPages({ config, preview }) + const siteInfoPromise = commerce.getSiteInfo({ config, preview }) + const { pages } = await pagesPromise + const { categories } = await siteInfoPromise + + return { + props: { + pages, + categories, + }, + } +} + +export default function Wishlist() { + const { data: customer } = useCustomer() + // @ts-ignore Shopify - Fix this types + const { data, isLoading, isEmpty } = useWishlist({ includeProducts: true }) + + return ( + +
    + My Wishlist +
    + {isLoading ? ( +
    + {rangeMap(12, (i) => ( + +
    + + ))} +
    + ) : isEmpty ? ( +
    + + + +

    + Your wishlist is empty +

    +

    + Biscuit oat cake wafer icing ice cream tiramisu pudding cupcake. +

    +
    + ) : ( +
    + {data && + // @ts-ignore - Wishlist Item Type + data.items?.map((item) => ( + + ))} +
    + )} +
    +
    + + ) +} + +Wishlist.Layout = Layout diff --git a/services/frontend/site/postcss.config.js b/services/frontend/site/postcss.config.js new file mode 100644 index 00000000..5f89537e --- /dev/null +++ b/services/frontend/site/postcss.config.js @@ -0,0 +1,20 @@ +module.exports = { + plugins: [ + 'tailwindcss/nesting', + 'tailwindcss', + 'autoprefixer', + 'postcss-flexbugs-fixes', + [ + 'postcss-preset-env', + { + autoprefixer: { + flexbox: 'no-2009', + }, + stage: 3, + features: { + 'custom-properties': false, + }, + }, + ], + ], +} diff --git a/services/frontend/site/public/assets/drop-shirt-0.png b/services/frontend/site/public/assets/drop-shirt-0.png new file mode 100644 index 0000000000000000000000000000000000000000..b698a83edb6cde791fdb82820b930692e633f36c GIT binary patch literal 157663 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%0A(* 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