From d77d00043112a597133e34889f894e4ec841ff00 Mon Sep 17 00:00:00 2001 From: Tomek Niezgoda <1410097+tniezg@users.noreply.github.com> Date: Mon, 13 Dec 2021 21:42:30 +0100 Subject: [PATCH] Spree Commerce Provider (#484) * Include @spree/storefront-api-v2-sdk * Add basic Spree framework structure * Add Spree as allowed Framework * Fetch product images, standardize API fetch using Spree SDK * Include slug and path in products * Fetch single product during build time * PLP with searching by category * Fetch Spree Categories and Brands * Sort PLP * Search products by name * Fix option values collection * Fix hasNonMasterVariants * Sort Categories and Brands * Add configuration to show product options when there's one variant available * Enable text search for the Spree Framework * Allow removing line items * Allow updating line item quantity * Add __typename to variant options to allow adding the selected variant to the cart * Use fetch and Request from node-fetch in Spree SDK * Update Spree SDK fetcher * Show placeholder message for /chechout and adjust api fetcher type * Use kebab case instead of camel case * Remove outdated comments * Remove outdated comment * Resolve isColorProductOption duplication * Type Spree variants and line items and temporarily remove height, width and depth * Remove outdated comment * Update comments about cart discounts * Remove 'spree' prefix from isomorphicConfig and add lastUpdatedProductsPrerenderCount * Implement getAllProductPaths to prerender some products during build time * Adjust fetchers to the latest Spree SDK interface * Add types to Spree taxons mapping * Revert port change in package.json scripts * Add basic README describing Spree installation * Expand README's installation section * Upgrade Spree SDK to 4.7.0 and add node-fetch to dependencies * Order providers alphanumerically Co-authored-by: Damian Legawiec * Sort products by available_on when using the Trending sorting in useSearch * Change the default Spree port to 4000 and update README in sync with Spree Starter changes * Save primary variant's SKU when normalizing a product from Spree * Create a new cart if Spree can't find the current using a token * Add separator to README * Add missing Error subclass * Allow placeholder images for products and line items without images * Add image * Reset tsconfig.json paths to originla values * Search taxonomies by permalinks instead of IDs * Upgrade Spree SDK to version 4.7.1 * Remove references to @framework and use relative paths instead * Generalize TypeScript and add typings to getPage * Update fetcher to avoid parsing non-JSON responses * Use original product image by default instead of resized * Link to an online demo of the Spree integration in the README * Flatten fetcher responses * Include Spree in the list of supported ecommerce backends in README * Update README.md * Format Spree's README * Add link to the Spree demo site in the main README * Update README.md * Update README.md * Allow setting a taxon id for getAllProducts * Use Spree SDK's JSON:API helpers * Sort getAllProducts by -updated_at when using a taxonomy * Remove slash '/' from line item's paths * Allow filtering variant images by option type * Upgrade checkout behavior in line with core NextJS Commerce changes * Remove dummy submitCheckout function * [NX-24] Display PDP option types sorted by position from Spree * Supply Spree primary variant if a product has no option variants * Do not throw an error if a product doesn't have NEXT_PUBLIC_SPREE_IMAGES_OPTION_FILTER * [NX-43] Uses image transformations when fetching products images * Use bind to properly call Spree SDK methods and update SDK fetcher in line with SDK 4.12.0 * Fix ESLint issues in useHook * Support account sign up, login and logout Also - Converts the guest cart to a persisted cart tied to the logged in user after log in. - Fixes issues with use-remove-item. The cart will now properly refresh after an item is removed. - Uses the logged in user's token to adjust the cart and make other authenticated requests. - Transparently refreshed the access token of the logged in user with a refresh token. Replays requests to Spree which fail with a 401 error after refreshing the access token. * Fetch logged in user's cart after login or signup but associate guest cart only after signup * Support Spree default wishlist show, add and remove wished items operations * Fetch Spree CMS Pages * Fix login, handle critical token errors and fix WishlistCard Fix to WishlistCard changes its props to be consistent with WishlistButton when calling useRemoveItem * Fix variable name (#574) Variable name should be `ChevronRight` * Update get-cart.ts (#474) include digital items Co-authored-by: Gonzalo Pozzo * Update normalize.ts (#475) add missing options property to `normalizeLineItem` Co-authored-by: Gonzalo Pozzo * Update add-item.ts (#473) * Update add-item.ts include digital items * Update add-item.ts include digital items Co-authored-by: Gonzalo Pozzo * fix typo (#572) Co-authored-by: Gonzalo Pozzo * Fix authentication.refreshToken arguments * Remove redundant comments and logs * Fix createEmptyCart request to Spree and add option to disable auto login * Fix formatting issues * Apply image transformation when fetching images for products in cart * Replace call to qs with Spree SDK built-in helper * Upgrade Spree SDK to 5.0.1 * Rename zeitFetch import to vercelFetch * Abstract fetcher JSON Content-Type checking into separate function * Rename imageUrl to url getMediaGallery already provides context for the constant * Remove return type for getProductPath The return type can be trivially determined from the returned value. * Change URL to Spree demo store in root README Co-authored-by: Gonzalo Pozzo * Change label for link to Spree demo store in Spree's README Co-authored-by: Gonzalo Pozzo * Change URL to Spree demo store in Spree's README Co-authored-by: Gonzalo Pozzo * Use only relative paths to /framework/spree from itself Co-authored-by: tniezg Co-authored-by: Damian Legawiec Co-authored-by: Robert Nowakowski Co-authored-by: Grey <57859708+greyhere@users.noreply.github.com> Co-authored-by: pfcodes Co-authored-by: Gonzalo Pozzo Co-authored-by: Konrad Kruk --- .env.template | 2 +- README.md | 3 +- framework/commerce/config.js | 1 + framework/spree/.env.template | 25 ++ framework/spree/README-assets/screenshots.png | Bin 0 -> 117099 bytes framework/spree/README.md | 33 +++ framework/spree/api/endpoints/cart/index.ts | 1 + .../spree/api/endpoints/catalog/index.ts | 1 + .../spree/api/endpoints/catalog/products.ts | 1 + .../api/endpoints/checkout/get-checkout.ts | 44 ++++ .../spree/api/endpoints/checkout/index.ts | 22 ++ .../spree/api/endpoints/customer/address.ts | 1 + .../spree/api/endpoints/customer/card.ts | 1 + .../spree/api/endpoints/customer/index.ts | 1 + framework/spree/api/endpoints/login/index.ts | 1 + framework/spree/api/endpoints/logout/index.ts | 1 + framework/spree/api/endpoints/signup/index.ts | 1 + .../spree/api/endpoints/wishlist/index.tsx | 1 + framework/spree/api/index.ts | 45 ++++ .../spree/api/operations/get-all-pages.ts | 82 ++++++ .../api/operations/get-all-product-paths.ts | 97 +++++++ .../spree/api/operations/get-all-products.ts | 92 +++++++ .../api/operations/get-customer-wishlist.ts | 6 + framework/spree/api/operations/get-page.ts | 81 ++++++ framework/spree/api/operations/get-product.ts | 90 +++++++ .../spree/api/operations/get-site-info.ts | 135 ++++++++++ framework/spree/api/operations/index.ts | 6 + framework/spree/api/utils/create-api-fetch.ts | 79 ++++++ framework/spree/api/utils/fetch.ts | 3 + framework/spree/auth/index.ts | 3 + framework/spree/auth/use-login.tsx | 85 +++++++ framework/spree/auth/use-logout.tsx | 80 ++++++ framework/spree/auth/use-signup.tsx | 95 +++++++ framework/spree/cart/index.ts | 4 + framework/spree/cart/use-add-item.tsx | 117 +++++++++ framework/spree/cart/use-cart.tsx | 123 +++++++++ framework/spree/cart/use-remove-item.tsx | 118 +++++++++ framework/spree/cart/use-update-item.tsx | 145 +++++++++++ framework/spree/checkout/use-checkout.tsx | 17 ++ framework/spree/commerce.config.json | 10 + .../spree/customer/address/use-add-item.tsx | 18 ++ .../spree/customer/card/use-add-item.tsx | 19 ++ framework/spree/customer/index.ts | 1 + framework/spree/customer/use-customer.tsx | 83 ++++++ framework/spree/errors/AccessTokenError.ts | 1 + .../spree/errors/MisconfigurationError.ts | 1 + .../errors/MissingConfigurationValueError.ts | 1 + .../errors/MissingLineItemVariantError.ts | 1 + .../spree/errors/MissingOptionValueError.ts | 1 + .../errors/MissingPrimaryVariantError.ts | 1 + framework/spree/errors/MissingProductError.ts | 1 + .../spree/errors/MissingSlugVariableError.ts | 1 + framework/spree/errors/MissingVariantError.ts | 1 + framework/spree/errors/RefreshTokenError.ts | 1 + .../spree/errors/SpreeResponseContentError.ts | 1 + .../SpreeSdkMethodFromEndpointPathError.ts | 1 + .../spree/errors/TokensNotRejectedError.ts | 1 + .../errors/UserTokenResponseParseError.ts | 1 + framework/spree/fetcher.ts | 116 +++++++++ framework/spree/index.tsx | 49 ++++ framework/spree/isomorphic-config.ts | 81 ++++++ framework/spree/next.config.js | 16 ++ framework/spree/product/index.ts | 2 + framework/spree/product/use-price.tsx | 2 + framework/spree/product/use-search.tsx | 101 ++++++++ framework/spree/provider.ts | 35 +++ framework/spree/types/index.ts | 164 ++++++++++++ .../convert-spree-error-to-graph-ql-error.ts | 52 ++++ .../utils/create-customized-fetch-fetcher.ts | 105 ++++++++ framework/spree/utils/create-empty-cart.ts | 22 ++ .../utils/create-get-absolute-image-url.ts | 26 ++ framework/spree/utils/expand-options.ts | 103 ++++++++ .../utils/force-isomorphic-config-values.ts | 43 ++++ framework/spree/utils/get-image-url.ts | 44 ++++ framework/spree/utils/get-media-gallery.ts | 25 ++ framework/spree/utils/get-product-path.ts | 7 + ...get-spree-sdk-method-from-endpoint-path.ts | 61 +++++ framework/spree/utils/handle-token-errors.ts | 14 + framework/spree/utils/is-json-content-type.ts | 5 + framework/spree/utils/is-server.ts | 1 + framework/spree/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 +++++ framework/spree/utils/require-config.ts | 16 ++ framework/spree/utils/sort-option-types.ts | 11 + framework/spree/utils/tokens/cart-token.ts | 21 ++ .../tokens/ensure-fresh-user-access-token.ts | 51 ++++ framework/spree/utils/tokens/ensure-itoken.ts | 25 ++ framework/spree/utils/tokens/is-logged-in.ts | 9 + .../spree/utils/tokens/revoke-user-tokens.ts | 49 ++++ .../spree/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 ++ framework/spree/wishlist/index.ts | 3 + framework/spree/wishlist/use-add-item.tsx | 87 +++++++ framework/spree/wishlist/use-remove-item.tsx | 75 ++++++ framework/spree/wishlist/use-wishlist.tsx | 93 +++++++ package-lock.json | 27 ++ package.json | 1 + 107 files changed, 4142 insertions(+), 2 deletions(-) create mode 100644 framework/spree/.env.template create mode 100644 framework/spree/README-assets/screenshots.png create mode 100644 framework/spree/README.md create mode 100644 framework/spree/api/endpoints/cart/index.ts create mode 100644 framework/spree/api/endpoints/catalog/index.ts create mode 100644 framework/spree/api/endpoints/catalog/products.ts create mode 100644 framework/spree/api/endpoints/checkout/get-checkout.ts create mode 100644 framework/spree/api/endpoints/checkout/index.ts create mode 100644 framework/spree/api/endpoints/customer/address.ts create mode 100644 framework/spree/api/endpoints/customer/card.ts create mode 100644 framework/spree/api/endpoints/customer/index.ts create mode 100644 framework/spree/api/endpoints/login/index.ts create mode 100644 framework/spree/api/endpoints/logout/index.ts create mode 100644 framework/spree/api/endpoints/signup/index.ts create mode 100644 framework/spree/api/endpoints/wishlist/index.tsx create mode 100644 framework/spree/api/index.ts create mode 100644 framework/spree/api/operations/get-all-pages.ts create mode 100644 framework/spree/api/operations/get-all-product-paths.ts create mode 100644 framework/spree/api/operations/get-all-products.ts create mode 100644 framework/spree/api/operations/get-customer-wishlist.ts create mode 100644 framework/spree/api/operations/get-page.ts create mode 100644 framework/spree/api/operations/get-product.ts create mode 100644 framework/spree/api/operations/get-site-info.ts create mode 100644 framework/spree/api/operations/index.ts create mode 100644 framework/spree/api/utils/create-api-fetch.ts create mode 100644 framework/spree/api/utils/fetch.ts create mode 100644 framework/spree/auth/index.ts create mode 100644 framework/spree/auth/use-login.tsx create mode 100644 framework/spree/auth/use-logout.tsx create mode 100644 framework/spree/auth/use-signup.tsx create mode 100644 framework/spree/cart/index.ts create mode 100644 framework/spree/cart/use-add-item.tsx create mode 100644 framework/spree/cart/use-cart.tsx create mode 100644 framework/spree/cart/use-remove-item.tsx create mode 100644 framework/spree/cart/use-update-item.tsx create mode 100644 framework/spree/checkout/use-checkout.tsx create mode 100644 framework/spree/commerce.config.json create mode 100644 framework/spree/customer/address/use-add-item.tsx create mode 100644 framework/spree/customer/card/use-add-item.tsx create mode 100644 framework/spree/customer/index.ts create mode 100644 framework/spree/customer/use-customer.tsx create mode 100644 framework/spree/errors/AccessTokenError.ts create mode 100644 framework/spree/errors/MisconfigurationError.ts create mode 100644 framework/spree/errors/MissingConfigurationValueError.ts create mode 100644 framework/spree/errors/MissingLineItemVariantError.ts create mode 100644 framework/spree/errors/MissingOptionValueError.ts create mode 100644 framework/spree/errors/MissingPrimaryVariantError.ts create mode 100644 framework/spree/errors/MissingProductError.ts create mode 100644 framework/spree/errors/MissingSlugVariableError.ts create mode 100644 framework/spree/errors/MissingVariantError.ts create mode 100644 framework/spree/errors/RefreshTokenError.ts create mode 100644 framework/spree/errors/SpreeResponseContentError.ts create mode 100644 framework/spree/errors/SpreeSdkMethodFromEndpointPathError.ts create mode 100644 framework/spree/errors/TokensNotRejectedError.ts create mode 100644 framework/spree/errors/UserTokenResponseParseError.ts create mode 100644 framework/spree/fetcher.ts create mode 100644 framework/spree/index.tsx create mode 100644 framework/spree/isomorphic-config.ts create mode 100644 framework/spree/next.config.js create mode 100644 framework/spree/product/index.ts create mode 100644 framework/spree/product/use-price.tsx create mode 100644 framework/spree/product/use-search.tsx create mode 100644 framework/spree/provider.ts create mode 100644 framework/spree/types/index.ts create mode 100644 framework/spree/utils/convert-spree-error-to-graph-ql-error.ts create mode 100644 framework/spree/utils/create-customized-fetch-fetcher.ts create mode 100644 framework/spree/utils/create-empty-cart.ts create mode 100644 framework/spree/utils/create-get-absolute-image-url.ts create mode 100644 framework/spree/utils/expand-options.ts create mode 100644 framework/spree/utils/force-isomorphic-config-values.ts create mode 100644 framework/spree/utils/get-image-url.ts create mode 100644 framework/spree/utils/get-media-gallery.ts create mode 100644 framework/spree/utils/get-product-path.ts create mode 100644 framework/spree/utils/get-spree-sdk-method-from-endpoint-path.ts create mode 100644 framework/spree/utils/handle-token-errors.ts create mode 100644 framework/spree/utils/is-json-content-type.ts create mode 100644 framework/spree/utils/is-server.ts create mode 100644 framework/spree/utils/login.ts create mode 100644 framework/spree/utils/normalizations/normalize-cart.ts create mode 100644 framework/spree/utils/normalizations/normalize-page.ts create mode 100644 framework/spree/utils/normalizations/normalize-product.ts create mode 100644 framework/spree/utils/normalizations/normalize-user.ts create mode 100644 framework/spree/utils/normalizations/normalize-wishlist.ts create mode 100644 framework/spree/utils/require-config.ts create mode 100644 framework/spree/utils/sort-option-types.ts create mode 100644 framework/spree/utils/tokens/cart-token.ts create mode 100644 framework/spree/utils/tokens/ensure-fresh-user-access-token.ts create mode 100644 framework/spree/utils/tokens/ensure-itoken.ts create mode 100644 framework/spree/utils/tokens/is-logged-in.ts create mode 100644 framework/spree/utils/tokens/revoke-user-tokens.ts create mode 100644 framework/spree/utils/tokens/user-token-response.ts create mode 100644 framework/spree/utils/validations/validate-all-products-taxonomy-id.ts create mode 100644 framework/spree/utils/validations/validate-cookie-expire.ts create mode 100644 framework/spree/utils/validations/validate-images-option-filter.ts create mode 100644 framework/spree/utils/validations/validate-images-quality.ts create mode 100644 framework/spree/utils/validations/validate-images-size.ts create mode 100644 framework/spree/utils/validations/validate-placeholder-image-url.ts create mode 100644 framework/spree/utils/validations/validate-products-prerender-count.ts create mode 100644 framework/spree/wishlist/index.ts create mode 100644 framework/spree/wishlist/use-add-item.tsx create mode 100644 framework/spree/wishlist/use-remove-item.tsx create mode 100644 framework/spree/wishlist/use-wishlist.tsx diff --git a/.env.template b/.env.template index a5885494e..32649d29e 100644 --- a/.env.template +++ b/.env.template @@ -1,4 +1,4 @@ -# Available providers: local, bigcommerce, shopify, swell, saleor +# Available providers: local, bigcommerce, shopify, swell, saleor, spree COMMERCE_PROVIDER= BIGCOMMERCE_STOREFRONT_API_URL= diff --git a/README.md b/README.md index b6266246e..1c862e172 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Demo live at: [demo.vercel.store](https://demo.vercel.store/) - Vendure Demo: https://vendure.vercel.store - Saleor Demo: https://saleor.vercel.store/ - Ordercloud Demo: https://ordercloud.vercel.store/ +- Spree Demo: https://spree.vercel.store/ ## Features @@ -28,7 +29,7 @@ Demo live at: [demo.vercel.store](https://demo.vercel.store/) ## Integrations -Next.js Commerce integrates out-of-the-box with BigCommerce, Shopify, Swell, Saleor and Vendure. We plan to support all major ecommerce backends. +Next.js Commerce integrates out-of-the-box with BigCommerce, Shopify, Swell, Saleor, Vendure and Spree. We plan to support all major ecommerce backends. ## Considerations diff --git a/framework/commerce/config.js b/framework/commerce/config.js index 7fd0536f8..7e61921ae 100644 --- a/framework/commerce/config.js +++ b/framework/commerce/config.js @@ -15,6 +15,7 @@ const PROVIDERS = [ 'swell', 'vendure', 'ordercloud', + 'spree', ] function getProviderName() { diff --git a/framework/spree/.env.template b/framework/spree/.env.template new file mode 100644 index 000000000..8f4dbf5dd --- /dev/null +++ b/framework/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=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/framework/spree/README-assets/screenshots.png b/framework/spree/README-assets/screenshots.png new file mode 100644 index 0000000000000000000000000000000000000000..93c133e06d4038d15d9a42ff0667328338085684 GIT binary patch literal 117099 zcmagEV~`+C*91B?c5K_WZQHhOYsa>2+qONkW83!Kecmr_+<&)!baYjBL}yo?lP6C{ zh0Du|!9rm|0RRBNN{9<90sw$%0{{TgK>+`qA*~Lz`#X4-msS?}J5*Cs`+NTXJycay z|KDeB?;rkmt)imx|4#mYum4UeDJlK$*2u`n*VotW?d{Lc&-Z`F*x1 z$K2uJ;fm^x`Gp0K2L77*p52?5mDQ!5-rkYrleN9`#(_;DJp9eg_2SZ|hu~l=bj;1e z%a5hSm9692uC?}&y|2^L{<)*`i}UA{l%C1GgVWp9)2IFYgNl~Lw)T$V`kBddU{yqB`FxYz_nI_86-cqSH6UQVv*nVA6p!2E);-(eBFq{O7i@VB@C+??b)K9K7-k4b2B_TPIaX#C3pp|JvU0LY7Oj(; z2mLLvnQcR9;TdhSw?;;t(SA}GzEk_HZZ577N%hWT11?@EI!0b<0s8XD+$>*CR<a}oF^Nl2})7s@js)`~)2WrY*eZA-q>s`1pR(>A`mX1{{sEqO20Y1tbCnJ}I>b1FtL#$CawK(U*nIU5rb}jYpP)?_>4w zoIas{9=*X#61z+0yxMsh0O0V0gs_0J`=3jl3)>4;7*_Eu>W#V}RGIn~cCVk(O%4TQ z4MK|XtGdfDQ0vn9TusTx#6jPP_Sd8}x;jj_JcCU4}SD`6w`vH2h{T~Nkn7)3I0wbOGpj#0r{0(}s zcsuSOyrv159oo4qr+l66g>^GE<{JrYo_IJ4qqrAA5H?6r5%mBf>ff4Ti8AjKQSmNO zF_P%OZXq);p(_WT{ZrQ4zCUjJpF?j-_Bj(GKxCjEh{=;3hO&@4^J(4W%jytcLafP4 z;T18d%wyF@%oSQoaTyvViLLWR?Fy{z<0-)yk?1LNFo=03lIACXOebLB%HHCTcF?`b zW*^JLnIO&W$Z-+xD;N)}hr8+qd>PpYA(B%YAS2x#5c(369ohB%5eZDlNmi;ZDC*Ib z$GOnTg5Yz@9Z2cd&bVYVa#?)9CmnIQiB6;Z;M8r<4 z3Z$FpyQx^;FehM*djR@Uy9kb!-ilIbl;iqWvSXUu}66< zb16%0e0O0zoniCCZ)as5bnu7eD^D!>v@o0NtIoCl!mu=y`#;{W;nsC_%`ciGU z?Fv<6x1u2zp9$hVN%#ir*8Rt^{L;C&K6m3+pw*fA+V_KTTr$6nSPHqwjy(=I9B0^SB4r3Ps{x7Wa{wJQ~jrs6E=k=`YkTPY5EVv|s3MKQ_9 z!ZBs++r6F!>=c?8hFS2u{?OrA&159k(3JT6j0%Y{VJFxPgghJqY#7iQT8KF%2L0k! z8#j4hzpEoM=(QVFRL3z|E4^_lRe#zof+&JUaT4rE1!Cdz&|COJ>7I8?t&4DIz$mz+ zC;>d_0qPe&=8{mwZ zpzjDqN^h{5ij;(sfJ6j;j{aMu-7Y@JNH3g#G;VFz18UQJEEV$4{L=i`yNumhL-DYRU8nHJ1M(3i2eX&t7a)Y=_hog5{vqO%Ut2eNDvD9WHwa;CB`U zXGO_do$9ZIm{SOu#3 z%<`pJ&nbrxz)PFye<}Clm8IpY!gR{GW-mT1f)a{Gh%ENI3>6BCZfwDMNEcVKonR3E zvt}7zqZiss&2(=G5%JnNgnV2#it&;)otFO->K-UAdPJ2;%vjn}`PR6b;pbG8HyuAr zLv2E!;MX8jMBK#&YtwObDzgpY9Ldv!os*SDed;sxB(PV}SKb>nOG&THB!qOnY{4tX zQqTJB+dneN_PCSJXdm54*BMP8q}-)hk7yvvW4GK=`ybxw+88cxB+_dGq*{bmHOh*s^ zr(tG4MWsaVf0u9hUx2p!>n3nRS=l zNr7qeq%1~-hDoE#wryT^4K-ceAq9gIbe3(B$(IaxPOds%+$txSnoNlFtLDQAi4K9c zS6(k_&~Yj=0%q5~P}PPlFD10l4ewa>q*Pv+md73B_cb8N9Eyvsgnm&nG+Gq4 zI@rg!z;~?Eft*3%D~)+HQMgcH-A#4!aL+f6>90JLys$&sKbAyD%U2uXp4#q-7>g-> zrBv4@5nXuO<9rOqmg63KF{#NBP~7$Y{k+O%Akb67jTasvHJMtrM#GkvoJro79jA@a9(-fFm< z)PkR;X)fk*>u+3p*z&J@UZVNZ7D`6Hxlp!&z1C=SAAhBMTaVVj^(by5-1mg~IOpPl zH5xPL=T%B2Ys$>cd6VD-^)3@<^tffchWs|2#5Wm93k@vvGTDghxERCzqiq>zl;*Y) zuExryuZ@6DYs&B5rar8!Utk5JLc|Kqiu2*6v;HgB_Q^_CUjm2UTu~h_8>p!e==H zX$bbVL)5DjqKmg75=;;hJ|dX}KUmD=|@^>k_dXUB3`Nwfa2wQ{f6cU99Dxpn-olmc`3f;TCDP&&13&#C6dV8RI}qIP8|EF zHFSyD9Va-igjKFA>QFPuZOyN{-Ksa2ddbnJ)qK7Y%Eh05MQey zDw_Kb6LZ!g5Y8Es*2fuHsudeh&vetQceEsh`j+~iyE$FTk<)Szlb#Qml{D@$dJKB) z8>>Au!PNn}mqe?R66sBOD9AD$^gC9PY1o0*ELXKQY`q4D_W0Iq<0|OP&_Gaeo+Y9~ zTKN5ZL>RH9??0`BC~&|_?1F(Q-8eK0i}5yOh*$rJnH^|H>rr^SXH$tNHaEmOlfHPW zTC1@kgzdm9s$Vb(_I@}PTY-MYGLx6qA}?@H9qI@B!-`Xg6f6Cb8h+ke*cWhBw>E>W zcW99D5*not_bN@F9fiBVQ*9yUkS#~QG#*3HxyXu`tGfoxGQV-WSflt!}0 zeeY!kG@`hxnU0tOBL2hS+OMk1GEJ0@5ggeVsRt-PlF44dc@_!?NjSOlSMW!F3qotA z7Y)6uG7)~9#@NvOz5qHS6uG8n1jM~1&G$u2?5AHqiNl@0rhW#fBXAzQcJ8QQbBi!2 z_^j~$me9obSG#|5_qw1ds6GHp-<&ISVg_LjxbX}onGq8G+>y9FxmZ~_4vo-Mpr(e> zPxzb)&_~7>zy=U3J|;3_ClJYxjEsWgGc5=C!i|AOlt-n`il)>3;uw}~zL4-#OQABG zV5;idowFf%4l(YXQe3dig}FSDoPOOgHQl6*KFgJA$QUPg7XRn=A|L8UGcMjk)bxCs zuDg9KY!dI(WCO>WrN!KE&qV~-*V-jMpZJp~l`J%XNCUz-x|4oS=?&Fw%9@05?t(`F zAgDzTB`2NE3;JTgVG_hBTO}cGy_l+8S&(wej&bQM7T%iUZ)B(hf>2KWc8`9t%N;co z_W37TNcOVC6S{>4=lqo6tL-u}@r<}fx?$-iGTKXSiW_U-@1N5SehQX+Aj3v#xe3V~ z6X|4BRqHM!yMk#94fehW0LKgVQ+8yUF{C1!Q)}CDH~Wm7zQ?UjvWo1WE*~$jgQp=| zB+*6V=0sY5M*FkU&|Vw&V+BmR*#G>hWX|~)`NUatA(#9w<@Qrt-YB$tSMf1z$k+kc zB;=l^5ahQtMu-t8fSHg>_xn7i&f>;<8j*SjczhI^4ZUD!ky{Tn{zgkMMsPsn$ol>b ztF)s>QlK6z)7d5Wv)ck+Tm!meX$%`KBYM(Z9o?K?vK_z1SKfMa8o(BsFGAdX8LG-> z9y?4${s+Xb--BAP$>4?M*p^w8*^Al&ud|Tj*RRE#@$p2tr6C(lJB{DnW!GfBr^b#w zVIruf`q?EVzt2-ZmXJtRsWCs!GkyvK3HXwWeo+Vp@0UPN#rP={dMMb4FW4g&*weXT z{rL?igWlnT#uoP`Z>$24Z@~>C5XW17y(#L8_5EL6Q?i$RbHr#r_R=#id1r)8n(!;3 z;kmSs39VFF>mx#o9>A4L$G}Hx)5UvImoYIiAuBz&>dyXclSoZy=u7WpaaqB#MdzUg zL{GvXLj1#cietwMvy`o$0lHv=qHTJcu<1YNHRR#t0YVC>aI3S1p<52N%Vwcv2+45~ zc(Adb@?R|aKd9Fv!Khm${Zh40yBC!Ld(6kEyqMpiR_ixBt-h`$scw0YYJ>CTIbLl_67LN@zNbuk(T^ai2c870s+kDd9_*I{ z3gH2}Ys1+k@Pj$5b*=Y#O$TD>NE-d00D0UvRbgOP{?ZR@`3or3V2}9IitblmmaNQ` zx?GrIcHJUEU=eQQQVO3#Y3&QOT@B)@s4HJZIfD=J{e0-}qVhx*Z~LTyb6AR)X_VKg z92KRnR~fH#F|B+wLcNBedgZ>%IAsD{y7sRGQ!E8 zc2e*L+~?Axg{ghlZt_?!j$Z|};er54YCMgrK4*}6gs-+dfjqK%UME@9g3+e@@uT8~ z#VamzK%^^m&;8Dj`ugUSlMGF#98rar|7*h_^C3(6J4lHw zlb`LRYnamEvT78RV(NecgR)kn@l^`WU=0>{^$x0A!#1>xeIq&=TX-$>AHEfI> zx|MHyl&2t`4X}l_)zrrBj?g{r^M4E@C89!i3e}!Igac1XrN{pZ>}zU5AW5*XOyvjJV(Fr z92#BK`0f>&&XudPww#CjMB|0;GEC)5rQ$0wmp*$?^QmMo!>X0@u?{}|AkdTGW;84s zj(!s(-maUze>5keoCWZR{89qc(;o}xDZ=%=+&X>GtN=hq9i^QFzTYla*!^d4PIkMt z*5H5c>MJDMB@@FlGza~=pw7ZM$4&K@I%IDBO?i0UbI2Q1vhv5z6GQok)o7$ z#4Sz3#>fDA|5}YQ-<@hBLf1o0(!1l{N9ll3KeoX!I~o_|(D2*NqT9J6)%uY+CJeJ) z4S*KTS`#Mz*qyNF)&Vq?JQHQ8_0iqa|2jXynX?3ZDNz)=byf}!2Z=?F1re~*R2aZ) zq$ev99!&CXT;Oy~SLySJyRA#hn%R;PYLkIV6_4+bD$|xSuDDb$8T6I2()bd~cVmJ{ z*2DLHuZY$z(SCmoT(k=DMx7pv0d^lfaW@+`OEj}sotkpEETT8W9{DGs&^GTEPH7?9 zrY4T{|Fh-lLiyK&C*mdJr+UE`%{nSyM$ei1zaYXEM(T+e@RljLU|*VVpBS6%Hvxi!`{54@s3yRVJxP=^}KFipR4Og^$$M3Q?91ejKd z43G(vl-EmQ@Aq^l4~f$Z5RuH(H(ojS$R}VTl)Rx~RL=f$SA6{(@)gO(MM!s`!oi{V zmX_;d558$$P8lGNg(;wubzS283ZDaOBAEEe-R?T9u$65qHM;wvb(ecfS zM135@+QH*fG7nR`#aT#<^}z=JrE{Xa?4&a}_yt6}?jH@m@+?Cw{1tGAg|%eAj1WN^ zBMw@NFBNB)+5!*@2+--@;fI~I!mtZHJkAz`90Q)oE42M^y3%k{L(0y>aI6_~qIBOe zNt3-y+~bg6U2QAf=f`a3%gOk7h!sD|Z(%QY1ghD}M27i}i0KsqjS za{j#klE*K=i3rpHERhi<9tCYb4}Ae6`*JSyQ;7ELcoy;$D0?g;2&YdW0PX&*5}gq) zRV5`1&MIWLVDK5cQ*Pv_RZB>b3*C@Eu%qfyGe4nYSH`(KvZr1KRErNGssbps-0sSi ztb#Fj#9Y-Lj56}|-Vt#_W`VHN?gQRStpl3_Do7^*Aos|XBw3n-hvng0uDy9HpMw~1 z`_gt6{?mxm#_9CEj+$Rxh0fEfnDR-7#A?)I^sgS`?x;_~P1}aq>0N_JZWB#to9iDY z+#!pQ0P9oyZ7jB@|FDF0P5dozRVF}arx04>Iv;;-x9>@Uo{&KTZ z4po@(Vi|Z7;rdwizNpL=iVKA5$ z%6`NCLu3y(->ViiY^{ZqRd9@pGj3{fNS|SDY`sz~Pj>}2+F;nKS7y*hYzWI`kZ&Fi zMyaSNmEzm2H8)TGxS7#+AprhI>`|Xa5VA5T_`n*#zO($|&+5;Hx^daE|J2iXZTmsj!KdG-M~j(<%{nM+3L&CX*W>*? z6&HjHWvzRm)_0`H1vvX;DVsB!uGJgEk@=<|{G#Fox>Pp0&ymMf;ELGFGKWp3Qyv!S zDGP|+lT~6>cBtQ1K^%_XI9GoFqQbSsG;*Pr_+5ludAZ(aitzg3WjD(K{M~yh+$P_C zMvIP*Ip+-5Os>BZ&hfhv;*_B(Nz;9(cI6SsvL;S(fAyMC^PjftdJ9aX3bw5v63k=N z7p$E@oMt3(Wh09||ANX+cNObJ zS9IKxEI^@tBlVk}Xv2Ke0V=r=P0g>!t}(GgZK4lS4leH#TkCV~h(WZ_7kRn3KY}4O zX?`0;>K_awds|G^=yc`%nm3}z;8dDZRsQ{*Ml0U68J4OeVEmGp@*^Yf8Q8>(=eC)G zew+O@qloKb2`D3~KK>@&fs6^%70r64D)#lXJC ze!kZ~S@L-wLWrZ2hOy)NE_EioPuMEZANGR~C^#oLCvtsn?UvcyDVp*}M*PFx&F9*H1$%qd+$^O{ z>B$&pI@lHKqVr(FSOy0-v_>TocepnCZ0z_-n~K?*xiE0O?yc^m09QDH$41-a8H7(+ zY|>G)^Bd|hmzuXo1K8lS{!37Ynv)6^BX@UCPZV>w%2|SQNWzZ%Eq?KyFbua!Rae?g zpe+-6C|aGxcd|9X#*V8zOePjfFd8Rcy`c#bf$8`{l|0OhESZ%VSHE4nZ~fs6Mc)0H zbAyNQ!xLI(y+2L&)*mFPhqPVSMFuUQ<7Iqktw0CP7jeOT3zIgfr)v(||4jUktOo3Z z0mo13%VH&)wM33xfwRnFLOLFhXzU6>aZVgKJt|UjEXeXW04psER_;u8xCTL0i7y*M zKKZZhnD5A#Tqa0zm|ApLVdA6$E$`fqYtNHWig3C+p210jGtdx)4D!W_GYb910X|K) z+cUW$knQTTzvA%s@9UFXefV;QR!F{@;By66%IPTG6kEXz2QX>rdh$_lR)H#oJa5EW zRV+jK+r1`74}@`G$tP_W*;mjpp7vFc@nAtAGAMhE-i6H;fqrq*+?+`fbeWvR~cF#W<TO}Hw)0+2>drMt#YjTJZ60}nJkOe9)E6s5RlXxFCT zVA@mExwC^hW(e#FC*Dy2xM6_&zeG~9$E97aprFul)1yaH7p}{8w@7MCqnpUj_nH9z zlmF%URMs$&@0LdZYX?%T!aaRjt#4K+3pp7I>leM|7tfbQw(M5^?0|3u+K{s9)wm{1 z6Ua$wH8^J8b#W%>vCcX=B*7YCvM}H?!o%l1cPszMGJu*g(xBw}d%%LY5Y^Ir+Y`K^ z+&RlF>nE4pOq8dASA{$svBLhf?a4bt8+$3v(4tZ{UWaMc8OxfIh;qSKSv6SRHtLe! zPX9V^Aq5G$7SM9#k>JFaugK1D_rXiF)X=z8ttqE8x=Y+VO}+Z7L~QcUA~9=1s;I8P z0lgrx!yOO&RW1la${7m4i_xoU;{ftGcDNGcZ=!jUs<~ z*27WIlswkJ0ir6fr;>z9tAUD%#A~RyaG!p=$x$n~A#oA>q+W_UfcqjnigP2W9$8aV zg8J&ma1hBLp3w1}&NLNkj)SVu7J+w`cK?eY4E7UZqlX!vz^EY;{?Z~TMa_nIsJeus zK%LH4WR|>`tXi4R36NoscpsL~hD>Vaq@4>cw+J{NrdFAJM!EWXS`y*rz%~oR_k`g_ zc^OZWv_7pBI|HjFzDo11E*%U(qmC^9hZAN35=2W){IDKcJ=M^y5vonJZs>1AUb5)cKcH9QGLJbZmGap5M{ zKc5ipRCCV4Mxy0F+Nem3iXl|KKkmgCbjzlKWar7nk&D>^=#Q^CiTO}_LIXD;^;KO( z2vviIJhm0)ckxGZ&w$(zBH0FYlTac*3&bH7YFFqjKBPr>V=hFy#n~7fyS1bkbnzM` zu)}gvwd~kz>Zraj3bdrG9m9_|jWp8Q0#Ao$gtXRY`VGyV@vL42>;~4iBl~6~Ssl#nd z&VN4zQFLN1y~L)~S6=)p6sR+F1yiElhZI2!KdyllKP;1Ba&b{28cs^=!J4Kbe8po0 z9rRzNHv)LL`V-G&?GbEdqlx)=ob9?66Qjlnqw1Jh;Qv7>b!X_h5R9d)7)L%2Adn6uAG<6YK=v zA8&V;9#Od)T~K>(mQtwbdliAuU|!RyMj+yLZTnHF4X|d)TQU%lc|}~l9=iv{5@@x*#a}g`vb8kWjcBW=>;MY;uSm&A z2~H|GhQCo+r~Lhujkh1ptZc($43>B{LBg;n##VOz1mxRJT>{S8@vIvzOYHnw*^${8 zPIyz|&sW&?_3D8%d9kVR4EVp|Y0BOS9L-p0VH2M=N1!+5cpMnUlmCuPe84JTO`CSX zz3bn#perQ~bcEEhh<#00E}Lgie+~b5b(Br(AWxcPC~1p&OGbt{^o}*i{{pGO%b4hE z8pPgW-a~VcPNre^p~D~kjIg%it#J^VEriKUp%^z7H#5xLmLZT8o~@Cls`C7{ig2U~ zUi)2(KM8*sewpgM1MzpSAJ3kmUMsbdYB5A)$jB?|al^wUIle z;9f?57JIl9a7?<2O0>J}bSd9Ih$ZEY+9e`CxiM@>wkxow@JjqyE`z9{nx+BG{~sr0 zyIuHJSX!F}n#PDN__ZPRn;6N7QlH`|AF40Wmxw>)5LtZVEQ|nu05PM1zkuD?P{>i2BDqi$deGj1fHsfL^n?}CmG{JBNeg-CaR(& z`;{>QS%|$wIICfVz$OsC%U!1>0PaV`TtpPY11jg9ivLM%IQvdlQZjex$+6MHZj%N} zE qqd81C7vA~7AmP@!2*ho)yn4NZYOvyd9z%sS^wG*I;CXmfoC~$pf%$8wd;EBx z-8E?DP)N@Z`R-TKkxF_gQYd6DP|fk$Wj0;keRZ5UC?B_4v_W?eh124d6a;?jd+_IdJPqc=niib*Q^(@|yn;-~AWO2m%m;%;7 zx1wMtOijbr-EPtU_7(+i2STamX^MQwY&P!^DsPbkPfQ1eRFR%Rv1R`~6OVyv375UU zfrsBx*gdm}E$tk=w-@NoSShY)5EF#DX6QdQPIf<=sWHpyJbCe3S5Q;NxT6?C>g;b> z^pZa1uAuDc4YQ9qBw0 z3ZH-PKd+`A)u+%+nFsYfqT3jNV?0s4*4R97qRtg_tPnIud1f{ap~1$H7<#t<)Wt?4 zC6SNhl~E=%!DmRXKHmVh!QMow0&f(GNZv&P%XRe`J%17yuaLWY_v~e)AuP$M*R?t|*2#?~0z>4-wuQV{{ zbB*VKPc5HQJmsKBw!CO7r!jtsSKARnvcV{&4&@J#nHLQc_e5h19ejL71RKn_g9IE% z8$;#VqdS!HmZ^3a39sl#7JMH*MeeVEVe-Q@yN=%+sVGyo>$wR>F4QJf)ayTom%Mja! z5rKyervA|;HEz$nB9h;3VUK{9M_x$t=|^=c2rJC0p{2@FVo8q@vC3#x1V`k2an4dA zlb^))pZ^ERGQ-lSwm;QeRC~{;9?vtt)&&zASix7!7WHD2vxbaGxJ9?N=MXy1*QtSP zrW@th+;i+co|HF7&1QpRv3`LCP~yNLHILSct^IQ-&7s&}jB|GjJ$$@L4^J9&tjfzh zz<`^I+@Yk5ypL-C2xY-D$By1>LX-yN3b4o$P@GxS!Zqwp9y6u$~amD;-N<=ID1dAv5Rv9rmWgo@3ZUH4m{fkHtuxxVR zCe@EQnv=;zQNhbOQA0Tv<*vZlQw+Dwo!^JYPLDJ6IHGV8xAG<+oIx=vW9{^e zINaeV%q~s4kE=m;DDEp0*JV-2^&dyGgKvxyw`-3|j`HB`p05L=Vg{_?$`WRgm#CE$ zCe$0iI*g})(BFa^fyhhsFq~NOOVoZ%`WjLyozhVRk9PihxnA)F5PGB zPbf3C2BYBfyV}nvRMhX}jky#csZoQ<-M~lOvBc7TH1Grp$xE{!L8>L=f$dec`LbuZ@*hba z?5$ON4%c>Xk=>P(HdvwkAQa<#4e=n5n0XieTY2y{*K?y=l`m@)u5Ey_X~G4@6YIFg zf;&Q%L=i=#MHJse5Y{S;JD@Xo4w4ScH^!Lul;viM2`iH_vE0CVmc8jp)sQ?X?7BX$ z$%T-zdmEV_X-(x-B^=OT^uv<1C#DU~nPX(sHDk348X7RbvHYmw#_-S;+1itpxbP9? z5ZZ)ptD-FQk0)1d6B6@d&5Pa0v4XOMtE&;ugFx%)7L&)eXA>vMvp-zCu>ojKnZ+Rv zzjjh>V%@*fv(BHp4Tk|n((uNhd&c>iJ_3I`4{|X-j*ahanmD?FtbO_V0WW< z0&wWdJk2{r)@kifs6-=*+L`Y6&a2YP1bE)2;*g1F6QfjNhjqRQF{~;2MdU3WR#xK`^QFDU7?yETOm$iM; z12%hn8<9jxrgqj~Nu~2&2j7jQY5M?%0zy4()Ld3tfj&tio_@B4K}kwA@oZMD50;9n z8_Q6)aL{*BWsKOczai@S@4gs|zqy zBb4nRXni21@HBT7tQs&m0sxg1FoPU~VG+z^4Lhn=FkD0Ktb6Z&&RHAJ`7vb8x6v9H zRMp|M#IwoI818|4wP=64F!E(uJ$C~LO(3u(N=%k>03clp?WvTlJwzbVT-%oZT;liJ6K!Kh#0))d6E zqq)mI>dz>mZX`V%5Kw#N)L?Ytg}KBzN!i zO+4$Q2@_J>9FI?DtqJaeU;5x{u#JDk#v3@vC$s9bknzgbfsaTX7CkYPD%^q(0vKd{ zXqX(vQ{@v1^cA8rBs~Y&E*5Heak+w?(pxwTkS-!^DLI&q4A%ds$r_Ts_tnPT z%Ef%i$+5j=CWr_;H7pjlmmPBiF0Ft){Cd7?tRM77v6~J6J~V0$lF}x8)CQQY@VRdas~Z_b}63#wOHY6hdy$BWp56!#%;}CD)%ePr#v5=t@SwJez}`8 z6Ed9J0wC%hLTd~d%ng9{0G2^YF`E-} zI7K&8fm=%A?kF*HLB+W~zBBYLGm8FTREbicHhy2B!sg`b6*4H8KzLF4eP1q8+S zLj;OQvHFHv7>lZA(Y5Dl1@MMaxsh2?FIy~lV^)T}CVz(-AP*JNs|QRT>NZ3*GRMgH zh44(`&x`=F7%|CuFUFo#6L+B%JJd{3$L==ZWuzq<3pZ+81WUztvP=bG78l|d2pQwo z+$ys0T6$_NovjS{iwM3+}_?rDU}}wLg#>t1^JA?RII@;fi=l3G$Xy=~UU7VcvmTjKgzdm23a?sDw%ig@gkPP5Q3xbh4WC&Y`GpXuW z&Vb}#gXJW*iv#CPUe^=MJ<}|D=i}Ti-g&VbL^eR@?V>{wX6TC z>PWW!L#6_RFf~Ek4F%@bAJo7DRM_wO>omJym)P`8@N=8Q0m|)gEn-G$-`sg(_daTG zVq^8v{-bub)UO*j^zQm#U;lmJbTM$=tP_H^jN8l=>x%D^lB66u6qb`{xtn3FT(^|E zEX0>Q4VeVcnn6GGnhLBTeiN z(LDI&FEEevAZTOI<^a^q#2B0fRzf5SDW{GYEOaXr_OW6gL-mEAH=ej6w?D^clz1Kh zQT8(EPTNo{XfI%cC^wCH9qvv>*VR%n42@8MO-JiF^=u6fW^ExJTP8m#&@~$ypMF=B zPa_=I_Vs?AQ9bS=_InEu5PSaMCZfa%Tj#=M*@rG9e3mYIYA6QpDGI%wT^8A%%fKv~ zOtp?jS;>Ep5^_iwc_i1P=m(ydx*=#MqePG6;ZjeH8UN^WKxl^xBa!d^Xo|HltMFR; zTZ3!JBx`QJ^S7tie@h_w-XN2d;w)88qJb_V3Fb~F35FW2|VfZ8MYN)lLLr%X>zP;7FuVJOr~zYnOl)TLT;drk8^0>4^2$oLO{ zA3rOA=n|TDAkDSkk_H?#6f&`317K0jQoO7vBT!NCXlj=7^JeXk&^BqAb@_ z?saX)LHGlRXa8|1i;&?twYQ|LMp>;XKx}hY3p3j)t=&8s+~caDrA9>sPkl=gfDA&P z-E{#YZ!ABmiZiiv|NAA>li8guOs#5|k(0V9SLiWD=x+>sZ8b+Vm^3GHLjDhb&GC!P zLFhi3?`EoOGG?o52B*a+EjXL46>`WNO`nY`;?Usw+^GmXHw5arCT^EIBZx3RDllMh zmCG|wAlmGLExwUh;g1kSb{Y2D*n!Rkb3=Zv@m8t3@;$Df_f33krWaqUX^6IErI;C2 zrI<^+HmL+oMFGq%x1fZuUPfiCRfopCrfloNkfSXzFk+IgnJ4_r9OI~?(wm;-J~9rT zW5#%FKr(?sx0dz6*$&^d165p3e(ai7lHh=@`GQ1aDJfU7DayVB%yy~|Al&|gHb5MY zin81e+yI7{ ztt!6-nNe&4}J&;LBazEdwBsi3zEf@KMxZ9tqL-X+Z4a%wLL@P(&!sF^pEo4Q7N zlhCc_E&0bb%`PMZAnP0p#xn0=VzmIlq4^F9&MX)+W_<5m110+|XKCA#wH*y?@lsBZ z1Jk_A@t@CNHpeAiSgEu9sA-8|nYU7s*oZu+B$>=|MW9f#m$9~@L+{K2G*1Zz@hZ7+ z7#s2IYIz6Jz#8)gB z90g@&{iQ?J+ddMZZG|PdgES}5s4lDHD1Sb{R7-sc=yzB{!=J$Q;7>|56^AY;kcCSm zb&+L_FF9CuL)kr$n-oH;@Ys{wy`ki0je&_$u?zfs_ES_Migt%KY@kY9%$J#^wq~Wv zKu|%3Fl?R+zCg-z^-#~nP@jwX4VD^FpY9Z%#atR}N}c_8 zsZrP}g)m~et15mkUJAJ#+tzFJFFydCP96rJ_a%6!-UcJHpi_7Kd=c7H!R1<5MWU|H=5?d`0V*w}T~)^%N@%;}L{ z;?(L$HZeQFn6GjcC*+xU8CsubH=nQ1gvBwFAI}ChaaN&l?d#~0=)JFHUouxL)T=?O@wD*YpU`?|~Cn=`0Q%*zl51XK0)A&kXEmZjMTnm1)g>d{R-`HJev*x0Zrkb`jyE(jCySQ}E zb9uMAAmXw;!_%#B*=2-%>|w7TGPZg8`fm01bYbf@95{{8eeLD#^m_Ypoa*Vp0=EAa zk5t<&J5~n8=%yq#E9^@U`2~{B;GBzgaKu&ApTlGvEVSdcKEb=n?S_y8nd~7GRkw)u=W*COMk!=_RufxmjNEg^Xf)e&YRGu8TSvze zt#>P`F7{P$ceU%<<|e$}_2WQewb>pvrz6H=EqWLX*w@3u_bH3}wpp&w2>r?6HwPDb zgO50^w#&!|5+DRMjH9s`4XuJ=8C-(m(jaImzIQuvh2q-%A@_1PHHuXs9^ZM8t**)~ ziqW*4==VU>Z?;NksJ6vG!YSGHQVCo`*vY6AQWO|6y#_UeVh=2h2K)Jq}cxr2|h z)P*^b2=!(0F{(J-)!|dm*SBoEl%_yj36qe4;zz<$Goxu*)^pMpLsBUP=_kXV;jwIQ zjTFiBRSqNpDsdfFh8py)i_+3jB1aAj)*C#w=a`n~jm+q$XHNr14$Dn5 z-a4UU2qV9+DD7ufxxo(uI&18g0S=gy$*0{%lN(VOqxckG17F}ebF zi~|xN#!{xvazjXD;jbj*qLrEi<4!L4q7Ke;!FZCG*8ySK;JxCqK;tqtzr4bW4%#jH z_9n!0abDe9w4MJ4OF*>0vM9+BA3e~tTG&44u@~%>k;p52TOT8e zM(?o|BRIyQRbS{TMP=N17*QcH|-vKPYB7=FRWD@NEXBj2AT8>*Zp?{@7fJV{W-bBch2r`y+07 z%9$l$7eFmS+@+oTwf|(+%N?>@?#bF=>gbeo1Kp``s`&6nqBMde9eBVGN&c)&mE6}>cBUJkWaW|b zV9_cBVqu{`PtQ!nYfnLSKT*tOfAwu#k2|gU)Mi-`UP+{Xd?{fVzKvo=Fx|tp=of>w z%XVV9j9)P5a<|AO4Mn5<=tGOSASnj|;sBksiwLy5t|kQHikVp`&w}ajdGTP0Bs5_@ zlH6K_63cI^6kDASnKfd8)k?CApnh;5$TetAE;EC*BII(OCYQ%wP8`dRRh=_i-F(t& zHqive?pR=TkNyPTr(In(uq^iJPsl9& zgUkt>ktLI#PiKGmLih>3YWOAAr-5>*rs9icR)r|Z+D;}PRaU^sIpwX zN;rs1B5Mrwo>9#6}8t_9c(umn5zOV>2 zW@}8Av`M8Ru|lb|YuB~f-B=W5ps;)^Crw#khbz6?HcW3#}vM%$uJfS%wW6 z+o@e#pwy|bB^Z$$j`FS{3)W3muTzstW+*)q&GMZZGfDUA`eYUc*Qx)cE{?#wrsKy5cqEVJy=V_>Wr=Hi{D^tZY7a9k>bf`K zpR!llX0)SR{@ZXXmwlLAjCwgFY!$aEW^i(HQG(yTe4pEEJZV>gRc8X#1vV1FX~A@1 zSY2$n%|hdXoGrkBONhy3!WvO`DYmp&UW*m7Yw1ipl}&DB))H$==~+vxL^oscY+)(7 zHlIzwLLs}InT;lv>aE29Ji9m(w#L49SMn>VcwLa#yz=CeM~|*fA964~x4L>XnS@r! z_HQ)_aQFqt#mg-K#3N=9>~Xkj@zpN$T#L<_S6nX!IY-hd+}Cg+!vv88A^YaO;+iKe&VxI7{k z#wP%dW8lZgB^h@_3sWpd=ca*Jj-5LF@oB)Bk54TeJ9cP#dah7_?bEBt{0eK8oLV}_ zZi74Pqc+JLy|fz8&#%Qswqx5%OR-XHGoLM`V(o`oxm@N<_aWs%8qy6!C)}z^$xxpr zX=bf<77_Oj2*|9`WCx->ei0E(;P>n1EZ5QPG$PERL`6dcM*NK2Ir++ta`{iA7SLzn z#hEIyq)2TmBJT=g+K1})BHD>Bro%^;GQ$uO2TQ=zN8!gA7!lq`Ym^gdgu6|*jFpHc z5mcC5VgtF^XsOg+T8^&7rm{A<#5SUZk?6K1T{aU#{e>miH8yXN3oOK|4R0r2UkAD=r5_yPd4=T4tGb!q`{41`P)kg_|wtYoUx z0w0fS%%xur+8-3rZ%MhtfmgEm#lql9sgOxkO6?p+OieCe4m|jD_#x^VMa&Px^Mb~! z5%CbMqN6XCH?NcnZj+_BG=f-%M3`6+w%<_)jtE$^^xpiitTDVK|| z{V-_49H>EY-JiO3v(A>O1|_G@V+b1b+C`ll)zH`+mvw@6H0E>emLF$2`}s3S)!h(4 z<5%bcbD@}AHliD)fx$`zluI%;RkX;ZAEZh?W|2#BIUb7-l&qLbx@?n63XuzdiIy6* zpoO~!LAg|!<>=fTNR}5)ee=!fZ@#g((NoFC6rJ}zQ6fSYNRpubhv)iPRwO*f|1K*-t;d3U6O9g5H&IGZlU-ws_OAO zwoh;uaisgy-77PTCD@kNjmwL7`+-8ggU2t4~?~;pei!6=s zWu2n;IhCDmNNOn|RKzd>^3f~}EU853mS(%4#O;V9UJ*~L8$xiRJhmB->7`3m!pP{5 z%YPJ%TY;Ot<@F98$y< z4IamJ+hdE~C;&2HkRs@($rt*wijxlaI2e7!s;grJ`9zXH8JqLFA_{^Dwt-?wk z!Y;`S%CpP`^|`|B$bdA|aV=l1>h>Kr z-;mpCP^spzWw9A`2`OFZ7Mq%y`YT4F9P&f|PzNnSmK7OhT|F8ep;e9$vNFh_FzruV$!Nt5L@)bq zx~Z6laljzI{v41C;})J=0FOU@;>3H;oH$|A3q)Vey}V@&@)D1)WNQtaxjf7^aE@fg zF2(&%JP{0R3Z9g}W>UWAwuKrJl^v3BBN$KI51@MK1d=xfqlg1R4=rK|Wez zTHKMnk}kkKf+}4vOFqd?Q>uGY%1p|KH2LwJA}}N9Nlk?dp-((YN4GSfKR;2PUj$ys z4&){l56n)CADEaQn@>-b)x(9&j5ka!9naZcN-m!EmW>sCWk0Ou<-0taMbO?tx&7Rt z&Oe!nsVivnBd2C?>l!%1aPjCRsI+s<2Z&!4FW4sK;;@U5U~E>|53rO=&dzlV!i7ci zfnKWI!pOzumS^94&0?1mHoJUs_S6=%8B3;??ylYhZpQ<6c{zyLrFG?!N}*sBwm!PHgN??zmJ6BL+7NfKplmQ)&x2d+mzJJCoCKW79v@qpTN?G}h9dbEVl`cRo zj9cpD0;sdg$(MH=a$yaf;mb*vns%{1Si3l`v9DTGF3H?Ozy0>m-D_@U**gFJ`#&wT zze^=nE;hmJt2WnXLrLftxQc1OQ%*>O9pTBfO?Zk-W-8d1K#*>hb|nbt{x!k~YK#zc z7B4_1K|FS#vrAy2GlW^m2|vfYZ1Ut%9-m34CqcOkXEKTL%z<1cJ(f67oJnK`ROknG z;mIX^04Bv^bZqKCG@F|&=c0?*8oA7@Ped6KuxZR$oEe{C+bqB_^U=<@$O>}dp{m3X zIl&NQA-*7LH*;7r9J!i6oJ^7iw?zf2P+X*|Nd*6La$#@%8ma7ew2B+n=Is<9X^{)C z3nLfi6k~2NAQy*SfLu;g$pxg#N^%2~i>n_E7kQ~S8x!`XY~3xk{oC(fTKM(RB->PY z^CJg4v9<$~%RZ!BA}pIdK>6zkiv^X<54x5Ve zGl}(R2Z&t2bS~n8`l`?(m3wfdik&i(l$5VtBH|UNERz;8d=Xh?5)Y`P$+GY}8-4#_ zrcnD3(iLi_a$!sYpX!XQIOLK{C9U3*thE>;7sf4zflw@Rfgg*zyke2dQeic@R&%=X zeld<-P_D5WS|XR(U%#y4_Xn#=W$U9Fj>p;uOfER9s~SY^WV%B7p2z4lJnQ&+q*mc4 z0kjw2gdOAN$1pOQhDKMW;=VHY`S4{mHI0(?cA~T={fjW0odj~(KH!kc>|l*t4h$!X zW9ydFZE7kpF*#69Ol1d#Kq{(6Gei|Zu zS~c2FE@Reo5=bUG)gOh=RCIEvws&}Dcxq;Nd~$MN0_4$9bYfzt3vQ)@J5CNYVC+;F-L zmJ3{WW>?{4X58`3gY1S}uCwq1OYb7Ye*5dh?%|6**7ofrAis5J+6lTiW%NrIel5fR=iTJ;L|fzfm|S@Cu!{uo zYksVZpb+;t8pQ{(PWU>nJ%~W(NX9;na0ZI=D-)T>U@*l|5QEPjd#$uS8KGT7z)B|f zZ)6iu%NWuIa%m-?-`|Pzx6oQ_A4@I1j}=bTVasr<36tXJI+ed>)h=V~x(?9Bo2F^f z>RO0$mI|t7e}}N zhuCn)r6yc#M#E*iL@~c zZLkjyZF2N8e6d}VOPbM(uw57fTSCaK=JTtCt*z;5N0-xe;o>M4_}a|!nZqoOa(V9b z@#D)oh5WWdSsbVnAy+KIWaOCd|ULWq3) z)VhIG5tA}uUvs04jTwFkck2*xa4R}06M0{IHHfGpiRrPO$_0QYY&vAkJHHlwLwG!w z+FV>3di37fh9EaWz7iC*oJ3Y&YRE53q%D5~josCBKY(GIW-mc~TR7^|kjc~fEWi>W zOS)B?xFUwgWw{jJ%#93g!%`--oJ+;l;>+<;DW1(Gk#b4HXTQKDR`Z4VVHC)vkl)(c za(YpoI?XaxT9JGf(0}&X`|q!|L755a|# zI%e5N71v} z{>5+dMXJ{>uVz?M$v3f~_z52>6691dP8oZ{Q;If=si%*$nvS2=9@+E>=nwxz==i+; z24HfzOgv0@gp~^vT!%TgRbdgzh9_J?jb2~Hf4P70rFC=H#Od|>nlcD%(FQg;A{_F` z8*|yn6gCUV*-elvgKKM>Yq8B_HksdEDkZlOxg_AFo~W@3$1gmQjI!D2Xq{Ygg^kMK zW@Y1$mALZMsneEQ?3_(5wnVXX%LhO#_X9w?INSohG9VYLr%TRt1I{%j@2k&x>$e(n zaaaYWHV(q36fjErn~SMOqQvA93=>@Y(LE){Kzx7{jRXdNqRG_o#jsAr2lrUFr@`9O zgag=wTI!b{(N$*RyFcu*Nq5^a>fY2Lm;XL)u;I@Aa`i<}L9_cqUBVYNt;_UP1%jFm zK_rFx*az#obgrM)l>&vWHU`?5c?7s6;%i#Hs5!Pc=Zm2`W}&-ZASRBv6xK#&x8rMT z@%++YtS~qd8`<25RhCC)D-EsP|8<|*&mm;=2op<`T$^6EnP)$E_BFr< zuRZhBE1$e#ZMNCv6!fFaxRndLX}!xngLiDogVv-)a#;q^Qp2P6rhJ>@`D*2Y1Cls) zfIub7gq;|cj^}Ei&cMh|%MqE*&b}7ME%4(81U0ls0}i(U8b`qKAp{k6kJ{=OC?y(7 z{ehIrBz)kz>kpjVA(y`iIB_MD|6OXZQNu|+NJk|DJfaj#J%Z{F6?F5}gk?SKZ5@s8 z8%9@9_w4eDb!Kt|HrC zd+2}l$CnHbt~i^H*SX~_iEwE%xuBSfhN>JI+o(p^w-AQid~ks>uoG@b6i{{6W=%YX zvox!3_fjQ~;Y{_>E)Ju!xGLoxmm){>P zux$%!ZfPI6T;%eM@#80wo<eGG#T(fP#Blr+*rKrs~VY8@d(Lg;{q#=??B z(m_lvleT!-H9E>gfi{y13tRdnXwWcwIDMXo;3%Cx6kCzbMh&l@WH7tCl&|H4`0`08 zQxytZmXD;Ys||rDJ;_YN*`SnJ3Ta)r$W;qq)RrrCFBs;Sn>K+~>{qo5@#OTe(`U}v z`1thcmyb<@)65bz`K2wu!kJeN15Uzll(VA@yn?}9Dp5L^R+S52Z;T^wCr2S7Ivs&4Y!}u3e+v**}7EM6wd)@rD^rkFZE}k-`jT#_)!PNJHcN^`ej{ zOJPoZ1en1+ve`s;moWZoTo^>vN|ADzlzn|aymEPC_~Az$e&n84?>TahwR%UittigU zEM~I<^U;3@L#6rR;DNydn~TMXv0Tx!_mO+k@yIYcxvt#g!Tca7Z$cwQ*nT; zYF0KXmCEtwUV(7`CpNj%f;et!v8Slrm`yBPcm0+J3riIWULF~-*iwP^2K2A?kPEJI ztM}mq!C_Qj3G%r0u_LHwA5nxld)2Nm33^<+OytYxHd|&_b;2Al^Q+wCQZ5%W^A8LU z&x}nC+nBn!I&W0E)X3$?k$XQp^86d$-uo@QKZoA9^VMiOu{c}KPt5k`qW=*3H?xzu z^74UFbas7yJLB04e}+4beE7(R_dP#+8F@y}_QTo|o* zP*W5F+~Q#LuH}&=#9M$pfKe>C2aP-1C#}4kTktkibx=Rh(OkPX9@s}*X?JEnbJAU2aW3;OjG6hcB;OpkfZc=U=sy3w-Yz zjB*b+;Ie$cX!&wSGf>n)rM4GwwGWAf`SX2qWdaggV_WN8>$nC`DhI)KgN(*t6IMs z)tRrCkor8K34e;~ZG7D@KKBOnS!YH*knac=j9aRogOyZ#Bn7CG3uBn-40geiO)p7+ zm5D5AZC$CHKc8E)Xv&{n_;Fr5gCD=WSIFh+TBQpX0kuFj1N9A8Yo@T&T#Vkm4hUTC zFNNmS9+7&PjVPis)P1V%5<$eCRE2#|L4-0^qssVE$V)Es2aBaeP%fo%Hg|Jo{a`MA zS9a`RHgW4>CcVzulZht76Xo(mC!pUpsLACbwjgOsd5P?GOah|9MbT&w0rs{Q`^_#C z2@zVgkTCz!4^dPcDQ8eRDzO+e$c~1?TLAUDm3h65>zc$5wNO6SBY(6;Fql)^xGS64 z$fri)Hn}jk#S5^?N=?q#$64%>j4#cv#VbGMfLsy@*X7}c99;Rv_#Q9N=Vfn^%Vi$( z@YZH@u@(r(%1W!FfQP*81_*yyTsfmMQJ|?C?Pv-!k_6&Irr^rD>q4Yk5_+K?in+v1 zF6l(kRxX*FCo{v@@oaXzoJk*CT%TGTAIlE=Vq(10TCCkrE0@buO_8s3`V7iy9i~zi zut>azT>^ea7YHH(kk~}T@J&P+;zwZ)`ZxtbGJET`pSI5m^$!Nu{mK~(*(!# znu6<-T7B}G>QO?L;mD<2%$9*%W~}e}JgncGPQ!ONJzjo*#Z|-;K&Q1>`+>>jB2P~- zPTFJVELFAE4|+#M4x)kPh^YAXpvJ_Z`pt|+{KAy-)?GE6t2{@-W!+`D+x-+5Q;R*q z&Oc9gJz_*LbiQoJj9rrp#;8QK@WKUF&u`7m9X(pG$z=o(v%Yo2pIqIr9Afs)I3pKJ zyQIJ`w)56o=g%L0;=RM~Jk9&Y_B&JT8@q{bl)RV71?pNnToor8aOR{}s84zt>o6bo z4r>w-@k*c205tSYR_9+5stE(BAFVpmsQkg@o0_Lntgg(a`?*X?C!KySe=|^Sv3>Bf z;1&=;UV^YRFvK-ri9A-Qb4mqgY9X#NN!{=M0CSTWgg*mk!dEAt4PzN)8gL1meM5BC zwG#IRvg!a@ZIxUSxIaFMv)E(;Uwc-I<{zIv1mv<6=hR6(<}&@_^ihXUIBrQMj{>>8 zb>fxdZ=Hv1Fdv`$j$IKoOXdlNW0$-36uEfg`Hpdfg`8nH7J=QoaC|E;-8Txz+C@fc zi$RJTZ+g)yb1skuGmnvGvUif|&*UsQK-?_moUe0F$dOA+)H3OOJLJ-Kr~~>q5hMoT zs#7WiLdciPk3x?NY76K^L(bIi5p=oDdr*ZlSQjY0>=ZVWjcQ>;H2lOZlglVCTG$7> z!q}vS(PPt)@nw2B6&I4r(KDZ%oMyyhZ)1(dlBxLW*)PuRybJ>%KYk9#rN&vp*o)US z#u|+6C33lXA2JU7vb};U%Dj)qY$n$bQEgZ6BA%+3M#OCoD5?v}O`S3>P{!)1V>*;K z6=Z27XnHh~QSaWY!B>?2Me$XGDVL!R+6zQ3bt96wR;h2AqHd&KxMPEy_}5SvZ$yfO# z?gF^N4xK&x-dj(`y|A;y#9usn_S~`OPCWkXXNSLdx=vS&T`b@X2g9fu&*9t-A%694^A`ARgreB;pI``u?_`Wz;3^I~@;q2jO-#g_{OUgoWC0|%7 zB&~pc@|6>hA3lEaJ&RmUjNQV+9o`3wa@vKHE_;Yv{$%gWep)G`0G`a&7K%XKcmILW zl!vNyjTjN4! z#s|0f1MAg4Vj2X;tv-MPwA1IXw%Nv*GvUk+R?cs!$S1 zoK|Jnw++8qQfHT?*fmCS0n#o;bkG;ndc9nXEWJ-!+^*x=o#3zDM4 zg*VM*EGscywxy;E#NafIvn#LT*!sg~I`JvbkpGXnQdk62^*26UM8je^bD$iLgGhzI zg$}b|j~FRx(Fu?Rt~0Y8V7aUg4Qt>68^94LV;TQo_tj_K>})4&QQB6{1%rK~T>}H7 zBhNeoaM}3$<5yue<&5%nSI~R`A$2dR?ShS$!Yr371FHuXrUDCnt4#&Lq4*-*rB;iLj#h|SoSz+MTAzzc+wtPXIi9!~vDUn?!4|^_!C`eLyV!#+?Zzsn>&%Iz zTniWZZ;~`zJi8OYt%Lh;AyP!R97S$GG;37r-w}*`r6RT7s1cPGGo{GG>%{4w$)!n9 zmi|^188dT4^i9y2rLI4%oyKZrW2Ok%V&pK6;DW4{E0hZnU&r|P_}owxMCenJ0Lks= z-`=sY`zerPTX{%jOV8B7K(K16r(+=44RBeS9r^fU0MKJsq=euFJ`$&n5$i<@ms|6P zi*KrF^sL}nzNss7qe1cEa;&a>1#dXbF2`0~A}lw&Xiv^9w+x5>EGE_w@dQYjY;mek z$Ie{x6?mKlJM_ucqGG}Y9AZz8d^@|k50{+886P8Zjr`qDL_U!}yobn#6kGr)v{3)& z=-gaPmqi|(JoAx^CpB^mw&XKVsoV$+^tdF!j8nX+%1QVG)qa(tdCgaOk>NrN7hE-o zbC1cvKLvw^b_Fh>zTx59&({WA;J5MU_U@;^kok@zZ<-QLvAL`zS@1bQ-K$(6%w=KW%BF?Uo}SVFhFk1dq#PBwFaJss>!456tm^9o-t>e@(sWO@WaR)LN^8~= zlGD2DEP8*gm2r-T1UbmeI|}g1XX0c!qAP-4iA(jC%gW}atV;6!Z~<;H$IRaB>RhmQ zA1+0P3#~wg3y7mNiL}FmQ!^?75~UZ?dnM!add?`aqpVf|C3s~xD-)C}HqQk*m4MSL zqhJ(3l8DMlW=O=z&=`Sy0dOJHi*dXKM_N?NLMuby7n=$N=eA%JB?;$4R`Pg<3YTtx zNcZ+Yk23x^kt;Sl+|%{c!jiK4;pz1^A9xi!XEWzsajqg-#1O#H&M|DfTz^Cb^fktP zdJv_BoYMlmg*Q%OeP$YROBX1yI|1?qyyKXn9-C@Id%rt^543yDu zaUBA_Y_6kv|=bwK` z!sYpU9>3>x3>O+NN2L&gS0Zl`dSrXQ%9B4trz+A*8GQ_U*^61}gxdH26#7LRQe3iE zVDx*nGyhVINFiUiP!n6uqpBwLSfRlJK!9sQxMO^6Xm1b7DIZElUB=hIT{Z!5dFIV$ zK$fE{(3v%$< zR4az=55|*fF@Y@KvP=nyHrpv>`fO;Cg#y}a7b(Namf2LXqp+hnGV>K1FJywt%Hb8y zrhMp-tia`v?A&y1ZsrY_y}i9Kgj_rtxWpo|?2f^Gtff^Z0(&A(cO=&0kZY9LFL_8q z;2h;H%GdR!`~0Ly*}9LO?@rbjB;D+m3w8UvNz!K_}b(K|4gXASuR8z@7`b_ zn9FSkn*;ssK&@Qc53_UXP2e*2MU24Z#~-7=eV>HOH}~B0%{Le>xN)V8aS|rbqk?T& zoU{q4rHR+Mm`k783f3}~1T(}*VzRZXWT*~y+=R?fM|s?h{5ZoG+4HwtG;G0TlY}hE z3&G2|a3$8p-emilKM)*+Mlc`)&<-XV9q5R*4?G2y%d2lb4jh4$vDt;u;U>j+fym3; ze1a|AOX1}YKZHc&=gv(FytRrLf-*Y#LI6YB@E?hA0lX;Mffk4+R8WRe6>aFm>1ae8 zT17}$lX=J%%p7p2ODsbwux}5~vcL(TnF+(=9qNssOW!U@eO0UauVN|Hy!d3D* zRuY!W3fL{1hvAa9<>H;K9}4$TaBBx#7%+D8 z7Y;cO8IoO|zPi3(TPW!F2M6J!X8f?b-#6G_AMyp-{DUE%FEH5Wa{7h_eMA0(ZL1F1 z5%7hALvnxfYQS6A5X-|iz(3e)hYtU+vU$S~o_FD2?S_>@H()OJ_j!W;P_Ru7cvb`T zK4ZARFyt?;BXGIu`=1G1K6>DR2M}CPKwmpaNWYwU%BuxoyK^VvBHYaaO%Z# zIgBH!G+zCsths!Gc4gaXg|etD#9CN_E6A*cxy2}(g`^(iNWLoS{kd8h+-mO$`T}$9 z9UbkH(TRzTjg2S}1~4-TaOr$g0SVk}w6AqM1yd*&W=A?QE-NBsfx@g_oDfE zV3`f+DsETF)5x?j201f|U0^r;hmb95y z3}cMB6u7v-d~pF?oqO52NK$w~zA?HL|B(b2opO|rl&|#s22{tM ziz=Wg69z%ZGQ8qN%1AjWmjrnp2Vv-ud>pG~4A7)Zb52!4UdZch8`0!_EYzN(|Aka! z_t$%#hQMBUs9EC{TWo=w8n|C{I~J?2iMVc-Z-!m1F4@`Ga)-0#mRsbTU2+~$2jm)R zuJhK_dmR9fhPK{NLv7nRa|Al})M0}K+jKpxkbN)8jd=1N{oU4$4{+C~VnQgWhPKcq8>qn!-E@=p*%NE$aDUUByd9K33xvvu5(F zN?F?B0@`crp%u~Rq2sy@Ic1HSpP@Dyc*M8Fw$Ry}qX{CEkpK(zO#Nr`RKQ4tOR4&_ zV%52P<*jS0t8eJ*`#)rh{asSSN=0VJT<8;Wfox@;4`NOVDaX~yeiPiF%!>AB`Nhi_ zrj(IzVhX$E$hlT9T!_h1OX!mLp}3MXBrsfh`=|DT!M*6j#`gC1#>C_RkOeZw051w% zM%JgNN0uM%>`c&Q>4VRe(icVEU!R4D%sH3Qf(;lVgfCRySYdFP8tO@uQwIGha9nw1 z5<9nR(2{(J>?)q+LV_-8whNcNnNn6xN>#|1J-iOB+7e$>>t3R7*e#JY#*s=)jYe>+ z&?MU+!WCzvqL}@1iXu#*>QT{tu9%jK?DjA8*4BExy@NyhaLHDv3jJF&75d-FkDFBJ zYv<=5OWewgV?8~d*nA=J%vDBD0T*zAmAb23)SK@bP2(s)&~B_#bIKaUzBtK5B?Mg> zjFeKlGP2uNI9vcM$!og#3e#jEaz)jgl5RP%0ce3Vv2l`={LoWtv(vNTSs={v^77Ks zvU2|cL|wW;UR_$BefTkjEte>lOAuoyy3lPc75N+BGS^484zwi!Lij;8jdv9@picyY zr6h+_5u8zCLD`epzMU=O`B#NG0VVSo$I%Qd@I3l+OrB2nw>XApkj{$#ELDq6E{^5m zIoMm*Q19`0>*`$cz7#=>1Nx^iB5epeXu+ex5l*?{!H;x~vhtwBEc6o7`p8lH^Qjrb zjsm(K3~4ZPh`;4;jky3#RI??8mYEb?QaM~mE=ybh1P4dws`j=ZQEU>j#GYz;==LOA z2G+KsYg_Bn>+3rs>+9>YAR{}Ut8jULd3~?;lC&fk16KqWDt`<`_6vZ^0pD3EX$q*u z$@yAm7~X|C#T2t~CEI^1QY++Cb%k5?Wscy)6<90FR!U5VjIb%;&GLgw$zm2P$gjZZ zKkyVA<iWiL#rd3p+oA7Qfiq|wm8Ys=Q5qDaoJ-}Fn=14_Mx}ta#-eL$;2E19QDl00b}PCy zJEFn`BK+%n=U!qUm@leO!IugIa6^yAV9iKcDRSXW?>e;)dbd)P$?G@j+*MtgFhMDv18`nfioF&b^;q4G)u9bSNxsXVrGr?=>#!s zvuNQG7#-aj-HJk<*i*_Ru<`Mylv>L0#?H>h&ijxsHovqyKfk=Q9*(YsN8SUt09%$v z)`ORwouJ9t%7umt5tHBxX~$m(T$+MZ%s!~MQeYp(3H(}gxR8K87Ha&vad_htv;av! zy%fL9njutZH9;Cjwh9mleMh^+;1hW>pPoeU#Gr|URSfHoBw)BWp>|#dYb6$k4QE7w zORTXb;*O2Q7Gqh}&hMk7xPsww)KMx}l9gfxBsoqy1Ro!vB`SZ5WKoHhBKf%03*ibfS<+xhprC(hYi(_7 z@`LCAkmad?fkZha)VzEQ9rbtLC$c;ej!r_Cebp%ja0w_-fh)PwNb-&8&_4W7zOlmK zGPM9VqqheR1QIT>p)@*H2}x4EG2ej$eMN^0ondxW4MUx_k@Dul;Fs z1d!$88$B2#yZ$M-AYt@LvJuwgo+2rtz$Z%^?P#QmDLVve@t0B7KA-8266lx`DLBJz zkV>__0ZIG!9d8PmvgDs57}mKsTIGn_DZ5;9tL%acTw88-Mcm3B*q;?#_UTf5F%0M* zn;00b{m(vKf;-)e;47%c=Zid;N5wWdT^qLYv;_R#s~LlSbwWOVX_jSsd* zmPVG|T6%bAy<>c0XL)CP=VR3;28PS*AQ&u$q6zXRM3bFN6}}V%m#Qwf>GI9>^n?=p zNW;b7L@XDw3&v*_A1KjT*_q=n|%BrO6w0WyvPbhi&5vEDK7#6$-apRJ3F?Tht;HHF;RQCgt2Juv~i9 zfG1ng_Hh`RKi=Lx`_=67!%J^H_sPp|gFN?jbo+g!On>)%D9ztd;d0^G8AKO=q4AQg zsJt+5xOC0IP0v|9=a2U2ESH7R+jVZSt}C0mitZMpCsG(YdPE72AYz-vq+hDOrUZ$g z-jHYW`dk|x`Jb{Ik;$8EOC~+UxooRuD$F%g;!UGNcy~#$;Nn!p<#g?5A`>YFT#nV! z=cGK0$TuNHXjrU^P2oK||Fi==No#J+a`1e>Ywb~#) z`N4tw%e283HE%d2!G*P+E~OuGG#qX37{79SGWylm-#+s8*Dt^Aef#ZZfXiD-pRxIm zcOkyNyuLmHvR-wrIavr^aQYbC6Q%&m<^0fD8d;c1>_gG}^Qm3bZ^d@b*Ch3(S%#n3z5tE6}Sv^C}I2!Ak5@=2Usra zUn_8V``fR-on0Dve;yiGcJ7WS=F9Z@$PT0?2Tj?6qcBEzDF`kgtHW?Z=Oy1xCyN>G z`iJ16Ss2(0B&tWy3VaBavf6%Rkav%9qd+@w z%aTv8$lF(JRnM#>3J3yj5n~LE;{>col0o4aXX>IW7%r;Jq(d$6gM^N?q+MbOBjb|u z=7xv6fG&`JyfLA4M{ZZ5`eCTfU!UGypMG!m{Z~5`xV*m$Nn=V;<@(P0tnVUPjNpRk zLWI~Z`MbrybZJU$2ZxjNKvv56#1n>6mH(lDK8MT^5ut=(5r}M?-*8YpFOqW^*eH{9 zAIc$ng&5W53M6U@1+0=q+v?dy^hw)0R?NS(3_a4BS=b;I$1Uan=^L3C^6tYW7jb=& zk{0==H;8;EfA|fN2h1w;PgJf)kyRidRZ#~UYnGg#JFwae`NFYP$&Tqk!>2q;%bg=! z(SGL1G<9;3(@j%vSe~|op~n{lml>+{u};(~d>vefB<+hnfXh=)b*xP&Z~?k(Z^K*{ z=qdKq*I&Q<7NF%-rSCq#MTxZlT&AI-GCT-8p)RVJSTE$Wb#%EuZ@2(S1_@Gpql758 zBaon7#e>UH$5NB@h=?Z19=utH7y*^+W?uESOl#JX=^#y9RlMejl^XO>XI)5nC>^$F z{9e3>)s}K+FntvgI=eJ{j&X0X)3Rg}3jVm3^fv?+Fp-z7vF4;7<<9H2R8gGf;aqk_A z?&?Lid(que{YC2?)%QAYZpjZWzy5m3-eg;C{XSfBhRc^JxcvFYA8EKeZw{B!Qu-rO znS#dCAZ(`oF9}&Gc4W2VtQP!k6`HA!5RpoI@9TvTO+btnXO+9lPO(}NFIP0^0)aN4Gu?}$E&>B3qSq_msj?e_s5K3|<&uy}5`;oWx=*Bf99E{R_-T^i;Foul>r zRnCCFIS_Q3z$N}gguvy;U)Fy4p1|dsZ(hf6p=VYcdsJ#Q@Wgw{jN9=#-4ZRhJu()q zROHR`#o%$1S&%u6Tq$R1Q%N~l(WMD9$X1bWlcr<%zcZju$bz$vbw-OWj!*IBg5jkWien4_^Zxtu-HECB^CQzhmN1wtz?QYuOD;;#1up7_u>oTY7q}+ra^8^w z7rhw8L^$$`L@l@zbn1c){4!(yaP|@|hlP$0)E0x*tP+(nkNymfXgW2Un4N!y(nAH* zvNEd?NfycjNH{7`aB(}KBeJv65xdvnSajbZx8C7yy;p9zBQMLvd+V>izVf6mq=ic) z0T*af7K_InfRlUOk$Ai@GS(PbguQNPIuogh=K+_&KyzQn4>-5RNkgqRLYYdloEcRu3hW>9qr)KCs@W2CjhCbm6r9W&rNq3G< zrV63hM7AQ}Mb8!SJw%#?&rVoJzV0f`r1jS@h~ZLwzmn;I=bTksXDnBD-97faP+1fP#x79=k)1xff$H@WkOd^! zV~dOT#$)$Z%L-^BK%ZD1eyTRF|DezBmj|2Mg0dV61-t+kzsDa4)dhX$1o}dQZ3q2> zwGF-i7%cS-bqbCGwY}cnw*K1M!M_78D=V+LUOT+GviZZx;X^AcD?b>*rJ=9ix7s%3 zXl`h08w_|2;qt|o;1o;4<*MJ)Zn1lA!*J2bvFVjuPL9^4$tGRLqvgsQ9bxULAU^Xt zli-t1G13%G@I3wA7RG{Qlz|yDO_O~o? zB2*dnofjz;+2#EeT8_UPT5`UbhJ9)p*$=X z?~KFi^>}5kXQtloZ>aOaF7S|n56oL9_cp^#ZL_o2(d+SgnjKDOuisykmJ?`|b~~z54?QxWzC9;X}b|*9@Q9TxT3}`Em^MkcnID`=7yZAp!jd z?z!i6oIFVNuE}Tg8QzXhxe|4ON zE#}xfnJd&SMxrgs1=}sDIbK?_D3%M27&!W0G1YYd2Dvl=T%sTfUOFH(ISftrlW-Y< zvKNpog)QNwuUqbJx%pDPTm(NVa!>#U(dCGQi*dFXFX6fK1uj)y6P_%$s5%5&tS582 zkdK9InnpEC^qUBog4t+Ew&3YVylJl6@%S$)P3O!Nsfg0$P~j37?EOFVe>uE~W#u1C zMLsQca%RaCskZ8mpk)RXyJ8jxjNSRHPa!t$cQbe_K>T8G^Eu)Job8R7( zi<;$axoFeGuFy7nAw~--Yf*(_%f(fYjNAoudFrY5@Pr}?UZNY@JInJ*JRiF3k3dl+ z;6-7}+IsibcQ>}&{X|WYCvclET*z%gw2UEmQL#o^@*hcXIg#!yrVE}}(P$uxY2t7R zS$z__)R1kF3x$kr;qZIvNAq#0;0sO8L?yE=SS5Us0}BPRCu$CH@p63gctCoZC0`B+%u6;ul*>1Y5#NkK~P=k!7Xv3yjR4A6XA?!3E&5 zwlzKf>er32mc|yKi%u|VG%hlfm3#!jMVWS_X<|XQj7Lj>nxboLUEl+hR0iEbkoEL z)~ZuoLpI^2S+79>fx)n_jbKl)(La3bo`K9Q);4~LZ+eSiyCF5ODI zO2`odx`4EY=exV#y7}(L$P-sTQFGU&=5kRIG}T(~7{SYt1{YO3$ek{n&EaLE?v^yG z(vmuLDWw-9d4Sq4WNh_Xt}4X|T2&B{G4*!~j|g)TMNM9(KkNh=cD@dViwnlAsEG@QyY``8lXxjrWL#|`!}g%bRFQS4od5{GJ1+{H z+3A~w+JV_~GU`8wZMx$Mvs@5da4o*E$wE4}T>rw84?ft{Rn^r5(y>@D~5{#m$kL7wWV%=OUo0DjaT1y_0`wv>7w}^uyX08)o=%RNs2D=a%91U zkcGfSK!;ScQgR%}NZUhPCLOS6YuuxHdBcN#MGjEn(0fvkoIu_$7xxBNX8P}dizvr{ zFV{e^iwmadJ0L3G3dzZ`)18l7thwnx+m#DL`);u!2yMBXrZru1R|_cc2SFDZN2UDhd>DH^CxcR=TpSb#_YY9;9Qm%KA z4H_|2=!M|rh=fa0k_(zMbVOORaYk7y-ttNUS9ugZq}T#hTdIy zcXoAf8ceUW>Gcn&8tDg;P#SmA1IkecmrKHd8W&;^LZ+%z3nX`B*Ej{CAS@qMjVlX` zX(nR8m>3$%823s^GEI+Z)A7_!J7afxPG{OlJN>EOx64EO^xf4;$aGqlFVbqY+FeQZ zd;0A6{;!h_xa`{XKIQ@q)d|+89S*p`ae>U`efqYMS~{b)5Nq?^bR(F9H4=35p5#{C z3uY^gEq1Hyt;x}$pQikAQf9T)~uw`N*5f4s)>H-Ck2cQlNNtwLx%1fks z%UfU*yC7J^Jf}V9FMb8QFmK#{qNcaErK_f~u8vV!2w$)YLll@QS{P=t>B~a|mtCoF zDNI$GwTwQYDn*C@DCA6iEatJ*W7WG?Vl_vb_9Jr0WG&ZXLX#TY!|O$pnHKJ-UJVvb zefkwiHJ7dmOU1fj-O>{-vp|CW^m;keftjv7Rl5V1|2k1DT#5hZKg^a}#ZRyxHwusD zP?2i;l{|zqX@;3*b3cu&)_`BkT{P0q#8J!?DOeA3g$q_&*qjzjmSiF0Pt1ej0&Hr^FHu5xl#t||lo4?F_%G}Uqa6Ff8Q;F!& z)Swr#wI|TP1ui8Po6CZ=JXM1947C}}1+q=5%6n#8J6bHW<(7(5ruA@3&urD9a7E^D z*=eEwACUBL(a<52Y0QZ>ohJNVspS9xMFlXL&C~0srcc&1YQ6W~srGqwh;3T&z;s_( zkMpCUv_6F#$MO&40mB8m@UgEYX%xFIDlYEe4d@wW73gyK$_+8>7?u~#zjWgqXf1~a zPP;u9ULcAKL?$1NJ^@y-ks5$YW2=A^kt^Z~S&C*d);dPLFO)8y*g9OG#c{CRYD|j9 z6Y)UH;|W+K#a+qNNcO-gBQc{fXf>kNFUHCsoT=LAp(jy+i_{+}tgBsmNJkRelAAuw zmp3Enc_#2zlLC+3Slbt#;E}FHN<4eyBLY)+e7+2E!ly-0Uh)GJq@`a0FU~f2$WQfl z@jIk;3KO61eD8f$~9`T#TehmNHr~ua}Op5-yEl7S~+eO@6;= zVG0$xsaSteZ5l57wnrK~v6B4(k>m^)Yz~tZRLuD4wNU{sZtKx2kT?md3rVFM0J_{D zOt~Q>w%oWfFmM#o_s_p@k?1V|mw_vRnhm+N6h94<3!Zs^myIy1S+J(qQ=Tdygd?akH#jVu|y$V{mxH z6LLr04s&%dFcFCw&5=kn7@aU$$q6eX(GWl>>W+n?pwM}&(MT{9i&tjhbIC(7RO$LI zuuWM}vaamteUy`_yqe}xM>+ij5ApXV8~jtBJXr#7au@vGrI=T|$~;A}$!SB8ci{40 zD^j>%p9?9I&5Y0jGEP{(U?EqiFyPtJN<1AncoYLT(rDXsjw-clNow2!g3BgZ;3hGN!JYNs!;+Dw78im7Tior|0U*fH(^e4D zISk}5+`x_R@sM^e5Ty)kIVQm6JHI-7+O?U!&1mC-_#7&fy~_$UtqF3#9Q zygF)37^7yRxgD4g^F+-NV`Uuh;R9u6BIJr#&BpkU(HM=n;~`_j84dZ2l_6iKGGS~V zvPR;Gi6J;uwPylOVhl#Djz|;^gZBtUJieijCmynfTyf)&dqQ1vk@NuP6}96>6$zl~ zRw=+G@39mpP=;5JTq)Ehvyvs(dH9PI;-HvPVFRjM+^Dyo| z(dL*)1xWV}Z;))K&jqDV;n`% zrW-H4a1j(25iZ|ozyH|rpND;<$oV#wo;M9j`$Y$zCy8;w?nF>a2*^JvWIP5@j&v0z+)iy2@N za)->Zm~kQ&NdSb*py?PRiRyTW5GiH`xWwJ=j5L>Bgf8^Lp@jCF%x4Cq7M01D9k5#I z9NK-SG(z-Z)(?Of@b^NK`L55M7=>8sCh0&|GO`Nipi$28=_7H=C| z$`3$)CC6sx;%h7}IgGH8DlcP* z6=z;+_FeKbPMiAS@|kNnx`Rdmta)^kK3Q5bcet>bEx5-d<%*mLn#9vfCm_e=$dMCA z+P`{QpbJ%A04butI@|^>Jb#(67^% zO^n+sjn(jHw88?OSCi+}?O+zO!lMy3S*wlhunHzEvPUI(u^pbYlPy+q?B=$1cuuxe z!e)9vH5>zXD|yVmwiwiDK+#kvNtaF`G+cT4L93I?ea|nz`jB$KA{~9|py1I{G?m!v zG;~@e1w>lPnG;r&>pIhyaMjLj)?9411Ge8?J`?z4+nyb`P}v5!plD0frAR?=bKwH{ zfy60}sAAduHKnM1eI}oSS2YjhCFg&E4&@ZkFTg*7>YHVlWQGf*=RZKW;25!_#)9DT z*lmbI201Z$q}_T(fQ#T=u@Y$)mZFRQ-1!$S6Pp;&h4@^4b@r<_o~S!cq!HlK(JC(~ zd`aSkxb}fA2OqL%v0c)V;ky-vwhDWDK*I;ftCsYV@MqDyt)>kR{;=ekgR!+q_#9DNfGvXJBEaP* zU%l`#fQyKi%e9fvW@P^2IR)!NDH{@|3P-HDOrMfzrZT-n&BZo& z;fq-a ziwKu9_lN5@;c}dSq(l5mtG-B77m$ZZbNLXBLlz|6wM3zk{fbB9lODA-(F=zcroJP` zxc!+41(L6l4~9=w#C$6RnbJ~r3&xm^vJ5$qu!s)Q($wMvoK$mxE?(JdOb~-%UlRaFze|YtGZ%yu@O|NZYtxcvOvGH`hxtGlS_ z+1aB+N3WLfzRJM@JTG#DQ}MVINZv-rqp71s3C!#UPTs5`ZhVj81tT*vq10`j!ar8- znhPZh&chc(s;^MKeCGDen{xy%C#DwWPgI{4;Q}IB#|T(}EY~Pn2w9-~J}d}aPQNu= zOMEWn1TLnI+FAi59l#a=Ggys5fPuW1Lk|yJSdr-N#TykSAHksEl*z%fKf*k6%}5(i zqM#ZMnF&giul-BW8!Gi4F3ECA%fgh`Dc&THYK>+oj!GE11@i0 z`NtnqnTF@TG^ow9`rfy;By(&hQf z$G&tD$dVM|(7zJc|4D$0X~Qm7Qv-Mbwg@+>#!$)}5Em&a{CjeTORzE%0K~|iDch<& z!o!4>-JJ5Y(vvA%b|JW6q0Tz>sluTdqLrB&wD&8AO0v91N(wzyhR|$4b(kdY3R*;w zG*1AgjaE-q73|j<0#FVs_sSsvJX3`L>uX%*GmV9d#k9WOVp)f-5YXv%71L#w4ntSZou29GQ`1lZU>e?gx}sv* zaA@7I1#ueuWlhZio6TVROLVJ!Cfw+ib%Iy8}q$bTqhJ2EX0mXbu=MmN)l7`^kN9gZ`8IZtZ*R^*`>r zbxWobrPKVl%heEY7`)8^SKx7ZxZGX86TZ9I=J?8=Hhbtl^UQA%Ty{y&!ptA7q|+)# zS&%3G!ZVj^?Ebt&CD1ZqmihEzN5iW*BzbwqrVj0m{5{1w4h?%{cxSQIYs0a3~4hu2l#RlSXhA zvICpTdYo#~W;E($yJILGrWFHc;0x(e0i=;;FXOk;A)RhJqs1;=ve}w%IDFpjto5E*%c*HY`RsbQCmimooi$j1F+K27c#CkcIh@`>r*ChG z__Be^YcIb1@~t;-z4-FJTW`J&z4Z6JDc3`P(%e?=Ff~K>@>7AA4fUZ~=@&tnnxS!yWS( zt7EQ&u{sVnYh)-g5lkf7Bk@RkJQkUN{7PTKn217FWylKID)1PotTazV%x<8PCl*V< zHY1#=(&z|z%%JBu+@T3L!WWDV8O`L?pgHaixsB%FkUN;j#w_+;1_vB7WavyuZ41t4 zS<{kdyIknxec*gC{{7b`S2S|Ats+hxECR4f#W&UzrNL>Tu&&L_Q1B&S8TARwtdqcCS5PclaEWuDH$b3xKY&*B)>< z{dT7h;No!FqRo?!!%lX8HgI|K#am^!UIc-~Yve%|F2k;7^Kg@Su&LSZ_j?Cr;d1BB zDPRkQ%fJ5hkH1s6$Z0O@wHb)aaMY@2o6txRe1x8f^3rBB)IN+VA7OQbXHKY5fs&p$ zTS|(xvU?RvszG6q`&QfC=2#)Kg(XXh(z55{x91lYMu{JPZSm$5QCz03UkA7(*>dF@ za65PIIViagiVK0tG5Dj3%Xhx=o$~{Z+fRSI2B<=%5kiuIF11v9!Hh}q?hBCHGF+<7 zppAH=u_0G1>NI-H&PXH@j|GR!@L-M&#Q`qCki!#;7(+29Y>NiXLlMwFeDTU?q|)d! zM-oG^L^K$S0a+5Ucx0&E=mT6;S`$uBEa8ipLDPvF;h>1Q+6*dD9F(3&LK!ZsT?}g) z_<|{4G!`TOiu&&^$3(HSwUn?1uBWr$UA+3w^gC5usI(%-N7RkMtZ11mfPxG#ip+yl zr^B+HaQVUgp&_Z}0xy)44l(71igHuA0Uk}l7L%}60XO1<$zHm_kx>TCFQJW0xuLAW zRMt(-TV^V=!4`O_%w!vMI@@4F8S%3SI~&OE~pybblLQ|{QXaVN;>qPefHUJBettpaol7P3xh@SWvGXC@ z#%@{V08E{b*1#)$k<#rli?to=IPio&PT=zJOZSUd2wSjhYbY{EK309nFe*^2kpKRa zU;8|EuxL@}K9h*No5`oqC3T^^O!R49)xv-sEe~8h!CDnDFXsjqpbA?%Oya(mlTYnm zS(q9f9Tl=$t}iVvjh>hSSs1M*DUSm z#yZel1iFkMytI-#BOAuCPdy~jVlXl2jh!4gObczHJ2As69e6M-sA-GM+}G1%$fCGN zAsmKFGn%z%Gk$+gea5MmOJb4=6vK8fhxl#QT-t25t1xLk_J0DG2PGXzxU4g9`S~VW zzOCkSNrvHSr2cKaLTTXwB`rsnRX{VlZ!xEozg2=KMzXhyUOs%1&k7EBtSd~$?KF!? zO<^gn7A&B(EP>uKdg6qb-LkZ@1SYYCwFMC_0|O#kZV30YXU{$-#EM1_mqn3(JW z0$c&Oh`yH*>UzNR4$#%Hl@QJbL zGrQi>F}Ttv-w6xPGjM_EW77kJOWW688gB3=d^>P?z=GiNzSInTUg=7PN8wVs{B)M( z0v>4~d6E0}C6=771P+cDN`=kz!#m9j92OQX|{&ms5|MEYf9e zvlY!~yzsHKD!w4S!aL`&8#0OX%ula%aq75A$cRICpa`Xcetuf<5fH z4Y~H*wQH|D|AGh?kS~!Ns4s7vJ$nE4?T=TD)HjOmd_n4JT1fA(UIG{Ddgd+;gq$CDAtCHE23@#B9GVX1`Z%h_lIy1Dk z^g;Ms*3xx|fTjm$x)y4VP8Nr=@?wsIixt5ey zeoW|cj9SIO<8tic^WQjn-}Q7?RaGxBi-~YCfzzdHWQ24NYpfISG6E*ox*m`YiWg)X z%KhS<>t!0wY7yQ@UX0j zfykqy*B7oY{B34+b#8HG;mlEL6eDnX<&{?eEEnM6{Hr2dz#c|pkuSeGFmT`XWLFh& z=o1l_0GBF&OC5m=(O+stYFb(j^a#>H?l>xW>u{NH_&gC`0`wJQ+!2Zja7iSLp->!p z6ju&G>oCuRHxx>EJdie-5MG1!#R=$13Ehv4QSuzROh2iXd>isnIX>-3cW-jx6C#21~2#H@~xHQ|U< zo7Lf>P%^v-z=1B8c(te#x+}-(;e5Igny>uZtd*ql7t&2^hi?sg7A&;#7d}~X zR#Skd3yvgH{=L96Gq1Er`ek@_-+bYN?)1?AAC)9r7@xjEa3RN}G8VAo3Aa%x#AL|? zRC!sDteEL?j8!aN$#YY}DS2KVVZD=jvDc90jz;hMo~)8M^hLO|5W5(O7Xz<8@xF}oj(}9P0$MO&2wxt{+2>+5BEh?4EU_&* z^zoHfCW40tZg#3iv5Qz;XSZ>hl(Q*KRJM02hdvH3?P~{5^fFYF%n%LE!@lTfvP}44uwq?r_99V|rart0X9r5LtCzY3)gW$vmTaH_s-aIGCX71B6zZ6MM9QMc5f4229+<6+!GcqpviXn7X6Tz9 zkWjdEcXxktp!&=ibHh%X<82Y#Lw{Ft{_t$dFdi}`WSp}EC{grzaH5+8kLn^tLU8hz ze;nMHV;@QX(0&89xtNxVEnaFH)u$)y%N(I6#7 zfd$11gGRrV_6}TT;WpTVLOu15sE5|SKSQgZhAL8OQ?M8vj%EAd^46Jw%ZIP*-GNIs zQeHs?{tt^Kb32m1~l9I5NQ zcm4Xk@!sQ9x>{OV2wcD>27qZKD+D({7r3`}^ssn&c+Fz(leJ-OgW!RQy7ki;RFpJn zahaS{NB5JamgQ%eAd{E2wll4L3ATVjn#6|Ub955Tj%OM;Z$DgY-QB)3zy9f&EA}0@ zY^}D~tm%87rSca^ZL#ml!sSC$eUY~Uze`3D=Tyj}Hp)j+|p9hIP&M~ zkT`R1tgB9h%Sac%1?*zIK$yCk#yVKEKr&^`2+6IitF31o`#EVYm2RepacrWJJjSd{ zw@J5T4-YJ?u+z4}C22D*;c@L%JZ9M3^>?WZ*WGA*;(~Tr3}9u>sI~E@!2reVw*1sK zUd6Br(o;mny-S?G`Dm8sY2aXFKURo)*mk(sY)$uneD?X@G&JqNWqYe4v%(d>gGb;{ zE@P($jYUz4S#?4uE;PhL$!9lhcBhT?#ETD0(#8dxELX2mx`>*~*S~-A>hJn)yY^cS9vW%s9l7^sBKQ9Gx4AJ( z4U`w7aH$vI(o5h%%2@W22<5t(+M2o^D!IZXHZ&B9L`di{8ZF1sW~F?b?pDJ@bi4PHoHgOnQ`FEB7}v7{;D8aLwKUd)_5#WZfeSE&tP8CwYw8)g?AdyrODG<5n;|*U6*WUjB($A0 zy5sJ6#0YSS!e)OI66_;pN5lh}l_Yx~aw)506TzX_e@1h`qihSRdts`1wIofK7x?2o z@QysKLMN}?I+aS_c$gMy=&Tm^;neu0DO6ME(d0mrGGClyd8uU5rZc_kw+$}77-T~I z=9>&$Ol2uy%Pr)wwI$~;W}C2VB4+D;do2PJreqqxP(bWRli^cNR23m+8 z|AiNTCZc?a!UgC8c)9w!&asuTJ}B)1Nt9JxL`F!(m->3LXzA(#7Yl(4Nu+E9xKz~x zT*zu~Bk-lZmZ3{-aG5YCAW_onPDFiiPsr#7oD9Wsth24ISaSs_8=&;nq`Ir;D@OX|wwSZ8ra;Z!j=u z_csL^?5@2oTl28r=52~Lc!3%X&a%K@!{DUXzqifXWZr8ZZ1&rnwqaPbMWMW=-P|zg zDtFp^zHWyhD|vI@>$h&be(SY;w{Gow9r_!?!)tPFj$Ph1yKi!@3qG&UVROpE}FCTQEV`~1$LLL@LO<%XnXBKY`2g7=g?9%+0ka zp?0MkYQU_FKs*;XM!GD&A!HFlk2h#?>Cz=a7vk3!J^Jwc@~6kgR{9UMRDn7|lo67Q zPl}1b{diqnRTp_hWQ|}ItF3}!$*=+7Qr`>2sjrgKC0B=jNU4wHwhD&JrX!N(8KZHf z)JdO{RFe2e$_UiyCC6*BECsGdYKdxaN__CO&)(V=uR{FJhfOW3e=xHS2_f$l(J(B&NNc1;fZhRYp}hGDzG1Woh(4#%Lg2~-lN-PIKE4+Dl0 zfxXaG-|6c1nrsexGwcDM+2ymj%-v01^RU;KEnHrH@io#n|HYU0-Fown(1vo~i*j&j zi#NC&-L8;nFp%&&UGi;?Po0`g_Rxo0Qgiw8ml0f0;a8>X-aIs1KEO0($fS{Dfp`)w zH42#%!K4*>IIwk>^^Bf^$8Fw65MvlEN1t8GBhAJUrN~t&f2j(>DUh^Cmpfbzh<}27 zd~R`Ret8w_Q~(tah(jii7E33dUHp7!3$dm_O=QqbfFl4FQR*S8xeLlwf(URSjl=3-A=bC7 z>4j~up@+&t6^gt|3YfG%x4clyn5R!9znC0iK~W}SF>7bOdKr8z6bC>@6cxY=v?mQZ z74j^SZz(MUpes!N1IW+QB6?sJqD^(^+f9KoV2a7_u(@nbyB&UZ9g`Wcr96;o7K4KO z;MsFFNa51c(t5};ZK<+U)LJazve~e~0(aA?aP4e(I(%q-%bJVBHSG1foc`v$j^XCL zUa!yBYzhEfeBQ|+*Kok=+UsqGy&TN}zu(_X)ETeMWjFYpUb}Oz&q4MmGngLtb$eaS zlL5coYtI%gFTQ;1&08;ny!P^IfEYlH99+CZ4UfBQUdP~IVA$>)l!Xhl(EnT#E^oj6 zkGHAj^2{^8#c-i6ODcS3L5LdECW)+I3l?@v@~lRzEo4WqHYLVchfHB=7*p==#XjZ5 zzWBVlaY3888pbi~%>^z1m89Czdf=(C#g&`$V=ELcM0M%!Jb2J@5a7~p0l2KL5V&kK z3;%S*5~2`{cYz6qB)fJYtRqqgPH zA<$gvNE#)<2?(ex!aBJ730&%>ZkIg|4_sJ5g~HWZLEt?Zn;bFYRx5e&#IGCKD&IkC9o&A=EVfO_%x_Pmk1~?Us!oL(LeI>h?CfUHBkgg*^xD_Kp2b*?*w&*Z0Xt zH{P*KSTb*dG?%+~@7%e&0hhP`@Q1fIqs5+i=9zTSV(+81`LYl^z_}tiUXT$=!%jLa zMhzTJTh(Dw=;eGVnk?0BT}s8he75L?hHVtakb;Ao2I+4)dX1>Xh17G`T&T3Rer6fM zkRcQqTCex&|t!;0!-(FvjVK=hH;+^O>siddYPYhYC;XS?>o!m2`nD zg-q@XvIyWKs2}7#N^Sm&QM>?}&$wVYhapKNceotb6w)K*)3+g4^4{WVC&Y1e9_%~_ zbP+U{u1-j*gbkg2V+1Zol5l~==$|dMt-m2`5fUZ^xWIx~#egV8#O1%)JNuwE(mIYi zyLsXDpj7M2`Tp{cW9-mBuws1~->3XL-nOp51IhijK&Su)EppZbJJ! z`Rwogkv{}ux6>i7*EiML%B*5-3@*@INOu8T+|X*U6L0};;LFGBtuCSu!G(znW%zd( zTwbd7=hL<{qEAp{OJ7u1zhft3>YO*{=PsGWqv5Xy?D#7l}APu+4VuP^J=V2 z@u(LgODw{eE^QWC3%A|3YmcijbZnO8s4O2YYOs*J{T6EM$Fj*#Fdk!?iw8KFh33)+ zxXcnhk^LP;%< z4b6q+=(o9?PCOrULW}8rfapWgI;h~Kt#_b#XTc?>w>)p_h4<>rM~!Ukg)ikiVKSAX z*;iQ$d`#0e%zI2GWr^kwd~A99k|>*?xpa(QK6`X&7cM19V>1^#`5_l<8<&64iZFx( z&b%n^9>jkCb7X672-WAEX=Arml}2I67Q86um+>kx;!M(U#n|-kmFf!y%E-C;VbaFO zv1y`~YgJ&XF@x?VXr-;fMXj|cc=;eYORmP(BUG~zZ~BMN{0#;vE(H_>)x!{?YTp%uzs-#G)feC```l2WoBvSzQ^UX_6W7k!yJ@yc${PTNrAB8IJ{+=5P4jXTKnB>u_D4n+o3 zG@48A8&)!LNNjd(jdJwKjZbbCkK65=jYX8Ou}~~SaN%rW8`0Z$y>BI_i_*P?IrJe+ zaRFYqRqWy|pXc`N_!M};I!=;NjBPRjIh-wk3sn*$-n2OhE_9Tl*tC+gDFI{6I}a|H z7hEtP?YMa`u5QQoiUU|=kG(|A++IkRt~67Rs>~DaP2gCvM!%7nV9YP2RS` zm4VM(30(Q?mB4O>zNwU76~_7XwV~WtqkDjbgnYSXo9_uviFr^Hi}C zRaz)s3~-t9Qwn7;7zA8alSp*Q2^a&X%iX)7b_N&4b)QpRI9WaptJs+jJm9{2`>r31 zU__zDEt`r7bHKQKeg!UwA%h}=oO~8frU*6(K&`D{3x0MOTvTHI#cVCgcxoxISHy^v ze@&ZNr!h#SK@0!qiX=fqrbV|fYnA4be0W#rPKA2mlZogR3>bBWlZ&F=?z!f&+bnF` z#n>!HQOTD4(O+Y3RJQAv-V>&ZNtGj+U$07-5&(zcq^{S*7nf3V38%?<5s@NtB)t!A z+niC7;DM6M_qnL-VZF3)rI0@fxXdQwvq8|s>-T%SAuz@5_8<@$5`^0Ys>@0Or7NfL znBKrk;_lts?|xqycn#&gD1MM&@m_S{ZUTL z#mIv^esBiv{Zpj6Y#7CyIBcL9Y}sj=i%Rr5(rv{BLsBjJzads!v~WQfi%|w-{gz5J zzo5A3Vl#f3cqKGn(U$x>8wijDDT%?R>eTK-Ib%pw>a=CEnCblSodMfYEEN54eXylTLc|)kU@9iVF zpbU)P*GDbGP+JUe38F0}TInYeiF9cvIb0w_C6eYHXcl7%J$?na)UnZw z^+vR?MTnFv-pnM)_|0z*h{b3`?w7it0lt>s1Gn3N==>73)Q@Z7I5@ zI`>kgK5<@doKLDrTN6zWl%u&txTw(r>Asc8M5mLH_!@;Gqq=^u&*z6fKNRVk>Wcv` zAthi8<@Y0K8n%q4`e`N61x}as+tJl_f(wM;LK&1wp37AzE?~=zYa{4kj1tP8sWwG( zVQ7I6TsE=*v_MP0DUqb_cEPJc-hC(F0_Q$1D|6iKLRA1%wMOO{m zBiuukQ$NKn#4ML0mi!M;NjaDX=&`;9aOowkFt*_F)>LYF z`t(XxrsncInmR9}HA&gI9;Mr%>%{a#i7!mm4~a9$@Ke-n z@h`Cov#B_=SjvYBQH8LgNZXrJ$?Wu}qVZrRxEAuxM&co+xdgpf`=|~~BnHO|%cKlK zp^2d9G0}y=g*2Dx#G4;^gu&&i8ZHW59=SN~M0l~AqL5+tVmbF{0LCn13@%th;AjC< zsGAG11-}FrUi}5TAvmhLx%pJ{4uXq#=f$KZul$}k2r6vsJJnKb4Sq`*6G^9QU%lbL zm(^R^Vw;pyS=zKMUCheM+V}z6374LW$BsRA;<3lh{NXdZyx9gZOro@6sOz^`t<6Pr z*-QA=to%Y@e^s6QcNIC@_#n3@u$eerPWQ7pOO@abgNdVtqY&dOH5h zjT<)zC5$ZJXK*1Qqu3K403O^vs_5c!4Jwpyk-9=qVZ>koV=lmj0EGVe1Q%ck#{w>G zIFIQ&IGEtlyp!MpQHLX&g?orM^%Xqyu~2A?)#ysR?n|v0Ppn&of-mfXS(;B=^@Z$` zc+pyVZjlPvpheG+Tt+H@AbTrSfpFUsiu}iij(qV8Uwr8J@iQa4c`n-_#z~YmWwg}` zv!_xiS_FdR;01jtQ;9?r zp<~hY_4G7U7h(&1E?T%CfAY~Mu2X+}zmGihKn5`a8;mDRd7%Y`h%vjEb82u9V8JGi zY-4Z%V{iaYVhh7Y;Bx4YvgW;wk8bQftgNxxe&KM*(_Pg6!4m(*&bKPbHa0?Y z(_{-$DcpPQ)m%(6)ob`&nI&)B)|kf^)a!y|51(OGCi>G}{rs9e{Gc56_U8S$67QtU zKj-$qrT>XffAJ?zAAjihSHARze#fp9E$#&_O^zS_iOUZiAN?Gck80tPulnbgNwTW* zDb4QJ#8>MJx2hM73;RUdt(ZR}Hu#qECyJ#M7h;vF6J~ARWXvaHt;vu=XfAGo3(R5)Bb*E_InN8(#()&~Rsa_#_0Ml< z15=1C3_h)#L%q!n4b9zU!R7KfWj%ISfy$Wj^z72c%iBPgLk4is&;?uQJSpVK=Wb>y zO@_o4a`CNO^X|)hQLCHLDB8tjJ>J-jLMm%jhgj%&A%sdsD62F}_LQ~@E{}csp`ZQw zD-V6~*e+a35!ZV+<))@^($zwgtFo z@N%EI{dbS7?Vn#~F!rqGRFQSxqa@j@KbfkHEC1E1=YduDti=SjS%hc-J(J!`Z07M~ zlBN@7`{?NCy)lbj{NfiM{n^vUkALON?{?v`eN~YOF4}%!`Hl6Yy#M$6s|;p`T!lu*74y*zz-x?=Ql(_Hwlut&Grv3iaf)3yC*ev8lY5(PEq{c%VJXTL2fx^rucQ z$7VggU}SmKgG3h!4_ov2;M(_lNm&VcJkVmOR5I8CSw+2=OnN#UTTkQc_*d1@ViYC@ zFU%v4e(kZ02e|M}5nQ?nE~JWZ0bICF(n~IVr)zMqh2R2lwJOzL=qM6ayAs7K8_I*r z60G12OZ$%w(=FzS{YQGRo;gSeq?|7a`wojvq$%z`}~D-mmhra z#JO{4Pds?y!h@!v$e0oZybQ7`-PdZUvFt0TTUYRN`Dr%2ti_^3Y9yX&Vk(mEcBM90 z#l(td;ZIAKDx7UCP!$=i8=JbY4o{AuJ~DqyHa3+Gm(M)$(49L^!z^}j=KsRya!*U{ z|K{ckp(vw!Ds`OYVPVGI|3izarTD=?3YZo?{7?hZS@6jmZw*kwfXnN(9&Suk;y za9o-yQv8cU;(yxKWzUxLTsT^YEd$-}x|PbNv)L$GWawJ$%G^q^s-k5$nJTL#;> zL5%%k3AJPZKaiFw@PiQJY?;Cmud;&tgN#Si7MV^2Tuzl`7Q5hZFuT~K!$FF~p?*g@ zvxYewOSA@E4(%^r9{l`SKxO~sqn95%a^dKS3nz|TK6m)Ug+g#aG-aLfjZ}46(O#n1 zjciGLVAhBSvn@&Qf|VLy(cap^QCxPjPbU}Ev%Gwf)Nl!pjWf6eMkZ#4#%CtOV_he^ zu8)R?N{$w5xbn51-1*8w$G-B14(#I6+^wlEk1taK(cFT17o@ex=V);abOTE+hpWjq z;BeFK-nnKRuO~gj0ltY1UBMkxyJp8`&9ly)e&v-ewAp8%=*c@Z+%z=fa7+yKqj{vR zH2vlMqWoA;*>EMtW>1?$#shpKFvvoO1=^C%{EXD+84rgYCr z?h{+sFUxm-Up%#v$il=0!&nkN`_)V=$lwA`K7x^1fn+Z=4#OUQFxcm3F9n0BIu^-9 zqwA5onfJYG^=>!Ja#S>^g(q2T%61*Mo57u3tGV@HUPFj+|<|@V8OvI zz(px+NlVBe=mO5*q=r)sWx*vpGaROM$4KX8qCjA&fu3F;8lI%}fy)P=Uz9IL+2atx z2WWvMdzfl2DzA&hE{qk46pt;5VJ9QISa|tGI+LjmCNTRJ^>dzWqp^c=!-a72^Gll) z=C`T*3VH{q;W9HbL2wxvp1(fPHPSdd%pOf+GbO{N|JzUh^rv6?%J1&{NPP!CH`lz- z;v%QDZ^7fbxzLMENB3N>yJeuo<8N8$J@57mEH*a#ely@}Iq#oyJ3KAFS!fxUJ3lbj z=2#r)o%1d9&bfUp-3tvKU+%gifsU?;_JcD$GhO4I6GL6?lM^7>OxMt4SL1wmcqTA5 zf4y^fvS+Mgva4&dvuo_&J`wiBVi@bfT-LYjUh- zrUzGV3XgQP2ZqMR0^>$)j{ox1Q$K$4@gG0+Uid5Vqs0mipqQA~1gP@PjYGS=8c>C8CLVeD%$54<$dq67m6;&yqVIaC!96 zYfl`@CWC%AV!@oGxu6dv$tGO76_0-N00T`6Rjzco@jIo!g=&u}Y~jf;cx|9exPU06 zKpcK>|Dh9D!!dH?^R%W{AV=u2?ZV;mQnMsVZl zR4+2GRV(jYhb*ywr+OW*sG|$4x^M}Mw|9_|GTAgSf$sCe^D|>TLyeR3 zGef0UMgI1k-~IIIJ2!rPC!m7M`I`faEptFj%iPVy?zuU~++vfX$#t`3?mTw5Z@TAh z&H*=$Mc?@WzsKD@=V-p^Kku4zFD`mKj)4Ww-1)`Dxw!>A_0Or`(la@OzWF0F!!t|6 zohL_T9Ai+*0~04FhTBI1L&Ni7NPqiq*wH^WG&z2Kd}e0yz$6YFYZx0@0#w3{=npV6 z42+%}nIE1XJ~=Wz(=|MCa-?gx6B9a6t_6qYpoP@z~`oG#79JY{|jJ*~SUOGbz~y94=0x3QN)7fD0YYllt-Fg7yQr z?2tT{`<|Dl*K3DFePPwLBG>F#D=!ki(?!12mK6B-0>kF`&o_=gPUGor5{JAd21%-J`|d z-0EMLYg$}zEO@|=#l^-2fAb=Cb>H+Y_O?)){Y3=jx|*SQrm?b%mj>437l@fsrv#W2td;vZH6Ded1upP$1kjHqq4) z4usoB#yh%ZhKCxvI!8u(VD=jgcXbX;gxkY_O8flCWLLOnCNL5140i2BNH8XJ6u&4F24a=a&Yp1zlBv;TCKN8F&lB+>4i$;d#;`Wj+ zpTUJJWPpngFF^no?9Zg5E1BhZEVQ=XPKt{nP+T5;_}bUb9J`QR4Z1-F^0qkDaKSSM z7bVizNh|XClP8}{Xkbj6l19n+qNpxT5?60i?+$^BHdYK9`WmXG*hpci>~wZ3Ux+Zd zHz|y^QV-Z`WZbhGrmYpyYasHz?at6|`1-FO4t(aBjvw85Cf~8`bNnY{XlPV%sFdre z9~v?bcWZo7&*##xMTb6g7w&WU*WcmJ-*o7I2TKtyFVr>*vy0!`;^dHwO-1$k1|y3& zX{_uv&ScuztoqZ63~I z<0P5tZr(9)0Z%j{iE4|n|GHXr=t~5(py-%=Syq>$C~_>z`!u~^>%>nbNkSbLeJx7L z-!DHOzos8tY`keR#GsHk#E-LXPlkTOXMg=P`97}Qd3d+{%l3yN=cZBqkf|)bah+VS z-|v%wvz|3?74U=PPiTn!+9= zws5qt4S4m*X4bod-~z)~0nMexDE~w*)=F#7A3_zzNAMB4)d)(7z*HyzRQpy!(OR3y zJQ51b2-8ofrXfhBs&~xxX)fRY_S4t?_{ZP<;LeW%j$J8Mntr<}3y!~im&@NAANvlM z4+(I&PiR5F-1G<^x%xa<$K@HCL3(yfZfQa^R9x zQ((MWveeN^^_SU{4{XVwUbcIrxqKjgE1p@2o{lD#W1-0HWNJ2*UdgP6;ISvTz%Vw8 z6iV;~noA!9W-&PX{a8}UOW)dhI*l%tN%(5wtKbSmfy=e8ee25v7eBZFx**UCetHEi zPAt)(i~(h!m&J@JO3Kp8-B5mE@XVEo#*+j1l z$sW3V%^tC&o>;$}R+7jDKC#3z%?)WHC+wzhkqs*RsYSNr_S+3Df!m|GT>15{?`+5} zT((6xTwbV5Ah9ZPWnLYa>aBH(FyRWst};1o?dlS3NQHx7D)D+D^`942)8tp@Pm3!4 z%JcIGEb*0gc9aj7x7|vi2W2XKIz63D&U%xvR4kRqW+H$K1op5#z=eK-&|Lg}{{q0m z6c?W#JCXO2Or#T$_}v&(owd99N{DRd6 zw!-q^^3K&vDw)c}v!|o!>E$(q7suC-ub&~a7zK$zd+~%okr0w66}W&b9%}dEW^e&| zR?=xg(k!6}stb!mhRcN%mtz-}{YYbBF~sE0N7-Y-3v?D*b}PA*3@#9CJV0=9VL>!{ z;1uveuaiv;>p@%vm)UV}$+sEbZ{h&2;o+90S8Ej!z(ZFI5oM#nXN)hLuUiPO-n1-p zr8uI+aGP4bty_>oGW-LpOoAXR8n`swBWdLJo#y_)!LJ@X*xAw9=qd(7vTj>RqM)thI2}s~p+2I9&p~r9;nMoP_;Mzd z$;6gVpUy_pAVerRn@XgYSA!c#l#w6^GaCY2pt|_oK0kwt--qBaZz$LYy2KOd>?)it zA^dbeZQ*b^^Oa-AF5Oa+R7eJ$3@(5O!3C`0R%3m8KFe9F&D=2P<=p`t;6NS$^Wt(QNu;+&qvaWXtc6ww`E~xtqxcAnZ;zi$Y6MGgNW16bnshC z3kl^{1$1lc^{y*W7<;w_F1y})Q`x4H`!7@y#!PwvNk;mNzJ_n%7pfMnBJmCLHmG&^ zmy{U-Ws4Z!FG5h$o5HI5$5dfUf3g(5`;-h54XW+|Tv)^4Rb)>lmzU#7I9*oK*;%AT zhT@4R!3CL;Xl%R|qS`MJFGzwiV^Cfo9zQ4p%mjnvdkJOJX_OfY;n?8YuQ02a0vB{l zT6!bvf#1f2O29z60T(QtE41w;&5?dgFwV8z)U$&1*ROhoOJw` z<%DjAJ~tZYN`D!MRkTtW2VBy&Ss`V+rA{1Jh0f!BAML{7f=Gr1EcGv5X_{7MR}Qn- zv}c*Y=(##f+wDa8zbs-8eYT}vMg?NYA6M1<89z)F`J%KXR!<*nzUFoLH~cc+)Wkaq zC1YOkO5GW?&&RU+~21rCoiQM0zC|qu%=w@0IUexuOJ% zUHmfOGBThP&4-Z7g`mRV0&zjW6WW6nI6@A4){e4;tY6qebt_#?M2w-w7@@}rA-e1w zxV%uzFt&*Q_o5Y;zbKS8)%dzqDz9Cs(fxwWd!g#Wii^cuwJ}D6g=_b^T$F|CvW0z_ zlG#K79!;vS*);sI=H6vH;L_N%E88lKYPh_VL4_km7zf7VOD;61#AmN@I9@4E1=~vs z>k9VViIP^^AQ6Lt*(;Lph9yaTA;yRlwCA2#Bvp^TGT}n)=-(V)o{qu_mP#ez(qE0G z;}nVvxWs2!Z?HbkY6Ngujlkv=TyO&}3w|a(--6%gK?E7LA}i@cd~IzlvK9=DUfF=l zHL{95@g|l82`&&!2rk_&f{PPCA+{)Rp#|$8_P|uj6lpH(r3aWeTPgY&K!Lm8#oz+j zF>n!^9a%QVZoRy5Fa9Fqks_OXHJipZip9ch<`;uhW53N#FPE0$Wfh8!S@AF_d0uc& zjozm-K-2xZn#&H6ygV17lPgIDFB5Fx4Twx!5Rw=TQ=elCzU)o8B?>>$YGgDMx|6BT zE8Kivt`({`mnJsdC*}KGfEGNu-xtqBr_-ox94FN!6-;JR@wH@hIvWF1yzs5~XOT`B zT#W=HNNORo7?|QCxM1n?BliVAo>T%fiooUK4Zdxo`pcuRioxaLVo8w9#R9kx9RL?_ z1xQig(%Z(QS1I+|TF6+5o39{+X*B0I17!38Lly{YN zA*0O+c#VU}D{Y04r1v5}QMs>rv39CfT7@wU80McRiY%~guXc!=2oD$&s}Wjlxi{G| zTmTj(Z@qOZvvPVG)n8)q<>+)`Hn_Z=Os+;xLlXg8DC#&E@#1$iv3orI#m$?I>PLr%rFS--*bc`AcP7}vEyR4z?5i}JG+uW-< zLR37SudQ=bYs{D$<6+L{aGfyH?YZaGd$nH{s!91`{*PcU+KRXRp~(FW4gDPrOm=xA z$hP*-7sXsfDFB;lyvo1oA+OXKhK31X;)(o_f>F(q{u@>qGO?ajNvpv9A(q%}H{ocJ zvURv@$h%X?WF~rgdODg}MW^HFvNyDzO|3@pJO;f5Z1IF59;FdwBoYKyke$!CLJMpY zTUy-ySQdGgcViJa|KCXk_tD7Z%(3IgkG+N9Lhw+k=qqq(+o)nmh=IpfQ+-6Q20 zx~)bN+*&SNPBk}wU>TueE7Q;JDk|Y zekU)#&w5QlHJXAbZ~;t^t52`txxyAGE;|Y?`L)Fiog9_Wd<#j9$+t0fSofITy3{If zrFWb?S8f;!TVBerto3Hd<_%hOTTA9;RYP4-e;yTHZ6yZuzl6@HP@CgLxC~8pbkKTc z46B@UG`5>VrAw>1G&MAJUcJ)a)U*qil4N0#OS9uooAN`)hd;sPbH+)OB&q>uF>cE( z6&{V%n*-j8n-k4feQ8eiR|qExg}p2AM3@bxZ&)W7v&F_wQsF{ra+T`4My?b9BbTeW zGz{E=#<9GTK7IOhG#jT9l*^Ieav~L-o?ed6GM9@7T=7uLe1eMyT1uaX;6jcUg3E%N z!NtFtNFba!m7MMK9we8`?=F6s;L;7UK&Y6Qlfi}I#o5x`jUC;94sizSjrz!g2yvcr z0WMH)pra6G2AMI;<)jtk4|ePvxa3uTdEv%*v*Sm^hJL|UNpJAfnkzOkAKZ29N$DaR zg(?f_S-QM5uGmy1TvitTP&~1kRa+Lz+1NL)ViDmo+8>yo$9lMNWM*;_{V2m@lkLOp z*GDHO#(IW_d&b5ZjMy?1&}uHwSo*I%64-^yJt|!J3&X;d_u+A$iCIk25BAun-*r;( zW4zj-5<|L3+BcGnPvl6j*X!!>;&~;_POI)DtHAsaOFbLU9ZPv;v4&I7Tm~}J&{o@dr zqp89MeZ6eh7}+E!SvF%^e(SDUktZev0Oi6-HX_m_jln={7B(^8HQC(QI6mAp+&I>Z zekYyiL>b2ROyh7EeN85fy6HDf_G>j4K&9cvx5E9qX_ebh71^{|Sgoaq9uKv7_x_?< z&v(B{+dM!^7Y-(&WJJBeiRL1nXEcE%4Hso|2;DDUh~rJtmUS)Gj!8=cINk#}R6pqYVg@q!B80(|jl?y%&7cz^%4MR?Ne>|IAPOYuYdVOy| zkK;dHJ98XzfQSHf!?~fkFn0>{m<_megDp9@pe8clGDQ?(HDU&*AfS_zN{z7-5j)0M z)AApLOJMvY`o5xl-X(`)taH4xXT)*6Dd1RIYPxWJJ}_Rs=7Jcp+^VFS54nn`re_&z z))s6D=UH+C*Z8d~jRMJP3{{=y>IK1x9hR)-fnzwdToIdv4YhZY=E9VhaK~6z&+x>| z#Kh>q=5IM%o_+S&zjL_! z{^uVO;gTVgiyRjx9xy(nxzcQh3PIIj82~hSJ2=1Q=m&WlSU%tYBCDqBr@n1#%fejPnX#c zIrP00EkPi(4g4Mlzlb4@~wESBkEEY<>^9(nU!s<6$Ul#(|r z=iY{%Y*i%vPi`yb-^Zef77KK$c)?f2iq_}SeD3n)vnNif;nFxa$6jwy7Ak3;^E5dY z=USSmm6)sa&S2pV?fnqCO!i|v+T)nVQaPr9t!T%Dsux&*mJ{rt4%!;-pBZw@5BJao zik5~Szh6gwB^=ka)HFF37#?!;40nLm25>q5o14GkohYCE@;`Z-1R@`M2yl6e(vXGK$%pNXIrIS+Y&julSq$V5V|oD>@~Jo}JOD>Qa{*|k2wQIE z$tU%O5-J&T+&=Gr4lbjP&cN_N$K*rTp;I`%jA$`dc(0C+ z3|aYNw}_23sE2etMCVUX2N)l_q=s8Pk@`tHx@X;(rK~yLebn|LaH)S`x&aOF{r*O3TI@Mydg|Kgb~MnR_IP}!c)Zh1zG?rp)4q>N zS9ZoaqC?T>#9&)nv?n?=FxV67q2|YJgMDb1e@B0`1@EFmJ%ba2Eo0GWUu$%zZ?H8w zfd{3i$J0B|+t)nwM9&>{o{lGmLY}B6>b(%XkUhl;+8n=h|Ks1j^zO@OZv5^2FW>+8 zRkR zu@K3CE-wA-cq=TiE7Q`?DIQnkkOwEN#c5`!cIUY#Egvp*B;d;>bF-wnz~e%Y!E|O} z9xfMVE6c>t>X^Lt@a$(}@aKnF6fzPiX$sqz0v9YIVPynq`U^8(ei4(asx%o!LZifu=$+WMBxm<-lp590bE*}TlbK2IMWo`#1)y%gUuE$bTBXmp#mqcrWrx-9pJ zWOSfSXGg)|A?Q5nac`Nb>{3(2Rj9RZs6miTfNZ(tUqBZ2)6kyzeiAy1LKn#74ANbg zBZdkihXW)~VBpT5Lxw(6SrpqC78qSvsta@wUpTh_)g?ha#Xi0FktYc*cbuRUt)y~c zP(wIejwa=#y`40bc4`@xFaAOa`UDs3Ms+KFfJ-1qhB0a%26Gte6^1#Kr_TQpT-MWQ zE@mQY^XrA!T$_4V$ue)MQj7(V1>bp<`V20iD3N&$SXUW2IHn4SK-;Pk$}X{}~8d`u63QzWw&8r(SyMsh6L6>7|#jpaYlIzB+us z*hFh+sH1n%9;auF=oU=PSjV z{U+)?oodxvvc!^P8Q~>~A82L_b{N#j&{Ry{q_nYEw&sH0Sn3y;#E34VpbKUTq_`lj za(*EN8y5++qeRKExd?nN1Qo!A&AZvg&OkTda+J@XfJ^`TU*d4OwB38_Ll*%qQGyG! z5HJNke;@Rfyyn966*7>ux1R>gU?*Y_gUbkOpbxmvslgVX4{JzefiIt_E_5>Ae+e#X zv5OD#cF8ng(D43iGwau*%4%w6kU|q`LUJffDBHKXc^T&Hz!Y5x$o^ zThQG_albU3@*jlD)-(~6b}Ok8I#oIiZmgA>8@kKDR%Dws|%k- z*i6==&Q|V4(ug$633J?A4mTUM|WMC^R7`Uvs&H;Ie0WJ{8l@ z1v3byvopX6?(p4ycw18GTxt#-j}^G!7fbN)I}323 z{Ct*P-_#CIiUOBL1{WB^z!z`=v-VTkk6~~*>IC@neIv9RHla@pUgW(Wq4;B>3z3Q~ z9DT-r5H3tiQVUg4f>QBeRI)$<8#i95QgrRY<65<_#w4TJpq!?YU&}PFuza*K1d(hu zs_cdbSz|_8LAXG3xp};G6)vSn{YQ(^==tFpE?;}j{fx`GfuaH+i@2rvEsP5dYa6DIQ& z`Pn1zx_tkAqPx*I#^CaRf`cZAkHMvpG?$|R7Xk~baZHfm8T!yjP-VH1G3c0)Kp@;1 zB)<#m4+g+MpaczYcka(TFo6AU?^Y=+H?YuJRtp-M!O>Ko{u zMbdjG@gtUB^lW2{Yf(=Z|G2IACO)?d_%RRIUeQjJfJ^AS6VLnPlhwkNDS@&k|R9)ec-cgb%mbNqOd)G5=*)UJAhKsHNQ!D%bixouVrU;aDbSgwbnF4d*yvDNYEcY;)oOPScQv) zln_tS8%AhkY`nY}Dem-8^A0OiSrOpN7G#&DybOQn)?4!+ z3Vv~7gH64`*xe$0mdTC|!w@&#jNB;1-^Dln?Qnsh#By76b7-~0%gP9c%hu-QlQ=`R z;R_5b5>16`#Y}Z^8>$l4VT!JzLzx}RHpM|Co3KjP<5{Wftq`h`vr+qH-E8JuHeBk- zn@^fc{b3mt7Msn?#ekQ2#uZ=%vkNepr|OjimsmKQgyVk>H7r3FKZPLgO`aG36l+Hb z+Zj|B)JA?Efy@ctQNgL*0hee#3>i?Nd=*k$Kn_^J4q;vuVhc^MNU>r_av1?!Xcznj zJEy1Pe<@u4Wuo=DY{vC^eubEK_NpKch20PelHmXm4Qc$e*8Y22Aub%|wI!>FHx48B ziBwaP!Y;WtHTdgfJ{691tM+w@{|xeKv#_;TM6(zhKqWRXv6&U^vX|y$7pYc7ckrSQ zTwlS;%BT7c`?h?`lRD1uvuUx&SW0 zFe>RMzDy>wtUxmG0=NJ$n07Pc7=sIt5QxpB-#>KkMxTEWE|+~+d@*r^!38gpCIvT5 zry<9MwkoA7DFgq^5m?2rFD1pqv)4>_{y*S?qOLR*p1f>MRHam|a8(aVHxFLtS-yB@ zC9>OMyT$-4iqjU+w*4MSAFay!^#}R$qVd%cT5^y1IUq z5E&^+g_q8ToFO2ATMe2Tr*O0pBxK#IvZ7=x*>&p^3CHnsTUVeFgFj`5)xfK^+slUw zM4`)p@8`(Bl1^rmnM^J{dBN4-ilV-pcfo8r86mq^HXMlO=8&Wx2U|LssZ2o&bL*qs zOMq-yg)1ZFy0zN`l1TL$xvZAlKY$j?h4)Frg#x^=lngwg6%0(*;AmLJd zi0-Xl);VxT`YJBIS!30;n^Q>^2ZqaT;W~0#KF!&*26(x)cKL7tTD+Hd>(4kg^ZqE& zBo|BMzMoy7`p8#bed38%gE8k_Jvyu8x@04P?3b)bemK$z2{6Tl(j3X@LNjLc3L;BC zxzB!pOZ=hh?rOgTaA^Wkh%Mj>CJJ1dh%J=tf+eQ6Fb#%P4Qo_N^M_xE$FtdV!MA?U z94-&8jd0;u$rHxlc4>KKIE%gQ^UK(QACelq+qBdAPA*Hr>c3p@9krHlS%`7AOaT&Q zwTJ$HLGp0fNW~U4T-^FsaV0MoWKfXRVKw5!ZY%XWs@WutJ|~-)9mpwB@&_98gbW(h zCT0q^dCE-rnhW59pM#S#@3RQ8(NrXxLoZ6I0t2ila=~O8@yDGgT^WdcNog;Ucs$Y> z4zuR^5I;+@q#0`&7I;66Ed2xu!o;t-=;jjum!p`gg~5d|1FEo;O29=ax)0%+%h4ww ziJxEtxPUC#a3oy-lMhVc^5Exht&MQO@KA|<03|NfE!DBXPK^<38}bKnzvhkH8WJeI+$R z9bUq5IQOw4pYTG#V?nAu)(yA>{khrMh3`{nwI9h{bTh1CM*}1fUy$H}Il+W?q-=!3 zqNpw;fDVy}itIOz1ha_*TEi@8S#pqtS?sOPA6yIJ0=n=Cp1dS;ZNR3NGPMOgc*x7G z=0ZsN8$~MlOPVWF)%=erQsvuFb2eFBa=)cvpsNS-K5tLg&_rk;)YH?}vpl%a$Qru& zjJR42;{{kz zrZ9^&o(Ul}X9U)<&TJN4Fa2o^I*$1K^Uv=ygv$q?)8@4iE<8woJ=W&9XsIbCBe6=W z@z-iSH;5yy(N~LY{G#K&pY;V@LR)uLvV;nDtuYN|lY}jn6~nev`*&mwFDpxL78dGl ziMA?m=|FY*;b^aSux+_;sc&0z@evo_)v*c}E-MKauK%xBL@9cgrIlIe?qV}zi}q&k z*2kIofnsql>kpK5vIAr@+H7e4>?Z%!W-)L5;mKTjbaDZPG01ExIir+F9w)^mZx_p; zmrEob@nd6!(i<^zhau|J#)JTHBmm;GOyB*!AGjHZfd3fquok zErX$n!QP4Hf%={c8Jo}HgXvJ25A!xHt^;03sxc;d-dPcESHek2?B2Vww60-?wV z7>k6N&xOGyLNn^6&+K1;MCO+i8tI3#<2iUySP(L|iUk{)+g>3GmK9;`@-e3ZVvR~_ zrH^uH0IY=qcv$#c3f_P4;M$3KX%>=V9>RKz5*b@#EvW=a(-b-03fJ;k9OMgopxX{+&?eD6as6*qC z*5zt0Z5LmD0m z*PZ{{?3L>m(8m}w@>2hfGT=f+G4GypncOUV_zN?$^P{kZ&1TY2T_^|{6N1Z$lj)fx zXo7IDa3qz3*CiZ_Kwu5?vmdsISY*_JVRQ+nvdI+sQ6@$sN3n*+U}B`0jlz17Qho!(rM<$+Zu&sCe6 zePC_z&QyExW%)JVUn&DGB$VVrttUs(A}qZCrDZfdGs~R$5TeV;Cy!D!ip1`?g|3(rmup|( z{apU|>8J00U2GP1>KjJQ!q!`yRqudU{*XM zXdu{xFI81UxPF{z6_KmiHH(H1$8P0r7_^gg zT05f)!DR&XD(S`EpBb1Yw)|h+G|)OK_>b z=GuCC=ks9dzsBd8y0+2pZLjwOxv4s#dRCSP;F z%D{!>w39Mv5(E%WDs&me5>yw(olmL@EM=(}qQtV1U_72m#KV9~Oa&KcF=&QNnoN)g zGU5wEn@MC-lj$=dsKf8`!SAvsQRB2j;KJvc3oDSk=yw^RUXv_+5{dpt`zdDNFVS2c zTnpf$e)ihBLND-Ls$@v87hb;AE|W(xTM?kx#I0H>*$ewkvaM|DDk)~!CT9I&%EvYS zni9lry@Rb_z&LfQtx4M`^J?|TlUJ%La>whhy!YNK&)w3Yfy)bLO#82yLM#0y54Fng zo;u^2=_&v8we6nrWX#*qap7K1M2Ch3hlY0V0Zev}_V*6$KHNJn*8fCT$M9ftG}_zS z_C%M*GdS2%*XQXS!u!x)rMMjT(*2J=^-|Zj=%N4ZyDz_c|NY0k`?yY<QXd@ePm<@LJx6;W;QbY_hR6LFbB$zHC% zMdG15PBHwF?@>zq^1n=Tso#5!G#AvhpP8ATMR9v7zdt*xg$t+?o0|(K;(>S!N({4# zu_7=^2}?hg@D|hVZeoKE_A!4v`{ihk`rSAArpTdBzdSw+z86Lo?s9=|pOhBLZ-MiL zvh~k|0ggT+vhabad~Uw^zO_Vi(S%c~2_P)l1+7=Ri@3(d3%OJq^aj_ibm(c1q}u%TSIbTx;@hWeTZh9>rE?uh~}gJT0j(cuew zqg{2q9nBNHLoI#1KnnJ~uw|$x)HOUd+!L+)%iwbVyHC|W_3q>MKmPLL?>=?^{rA88 z?n^pw=^yNib_@*T(;>Yb`aSeNzJ}Ps;d12f94=2IRLop+A@8N`wIN9=$DK{ZAIZEN zIEh@cYkhsyJ=V2LU1904ig3lki!qJ3*Z8pz%cgZ zWl4|%aOw6zf;3UGTNKAWX3Trf9Qwo-w0t?Uk+J2>V}j&yfy5GE7C{U$MRMTbhfj6W ze82Epmh#|(`f#~=ap{O7%4bCRaM5}s)wlH;+4K?KC0v;%HYCYZovBu#MWm3dMUN~V zEDQJcqBRlVPk@@k{6ucujgnqgL#6H=W5wa(ZFu;->rTGm!}nh4S4E3?r@HH>(6i{2 z`YGJjPw{xp@YkOKsjqMHlqVtFe0oA1bsZgDbQ~E#zod`+6@E=Vz)AFnR?14ODKtA zuQmTbr|Oxl%F2K(6)^-Qr?B!bfy*h!C*j8@nE_sq#*$3sSp648b2$;O6VF4AesXRO znuI@+NF@`oxdh+>nuLih%AFr*iBrA{dK9DVevmTuzq~AV!kNdM0D?e$zu6R=YV@JB zrH={Kq*UON4>~?ZNuwmS1!gg5F2_KQif3_toHUzV3 z*x7UkYmurgbL1cu1xXAdRBS8UiPGD3C*X4Ob??2{^1ld0zW*gr9{*dS#jN_dIP_iJ zGOx>Or50PTMHZs$i#$+gIFuLzu))&b=dRHmQxaY1CCdi+k>e^eTI_&hGig#>0GEZC zTy7?lOlH#aOK>5%U=+T7s4J+(5>8;JWFnE6Q+y(RB8y*v3wYD1AxRCOq*u{>*tBG2M9Tmw!!kvIiBn*IoCBW0(2xrEN;Bw&9 z-kp|YffyuFo_LosAKZ7>eQ$nZeE6;>dUyR>Mcd|hBaKtvypWtNe?>~-qqxWbn^+8@ z$dX^ON;Q&sD=?v@pZ%oDIt=^BO*0rKj^ z2`*ERAm9>YRy7c+dy3u#-g+?qBffwYWnxhN<>tJivio@_jzna@7T9IP?WE^ z@ZqtwI?z|OyS!^Q| zM&={JhF8UM<3wtTztu}7c7lqcpyXJlFW%*k8EGS1dwg^Rzb zDhDobx#SiQC`Mqx3u;Lw1KP}w=BvM)2)X)#@yUf;3brtUNhCq44tAacPPk7W?OY;6 z7jnChb0rSkM7|&GBwH6?fY$m19iLKN3@jl?FsRT(5vnY9!U(ktBeU4|5%3D`XyBj~rX|N@VQ@*MjJ++jMk!fWr{{_eUlhlXi*J(hL~*JaDanS#|0xet zgrMEvFRa!by3W55E|*^U%8#%A`234|t-@sm#KPxNUGN`XTYTXmNp)S>ioMlK>Z5QW zOC>8jwxv(s#L6aCX`pDRMzouARW7LE!M~zjhEGMI>DpEfTpHkV;c)p9Mlq5cn&6L) z11@>P*m>xtKbxM3quPE9=`8VhjMSI;nFNa!rbJ3aiTN2?SmT!ulKyb--Ip#gX$d3<)3;o~)xE#&i9tF6}{}QIunQ%6l4NnETgLFt6R$kD*Q#%sFQ?B^s0xiio!0hva-#QkMXr>ZrC=5Fhca-$--(4 zs_f>byJ~aaz4&nz25uQ!-pgyl_^e%7xOB8$xbG21?7Qz({|}!_xl%q{xbm;-jI*hR zl-*)nII+9XL(=e8&p{?sCv(t=cJLY;D4A8nFg#8RNU?3K%b1)SxM`C%t5P%=ZcHox zB8UDdvDD0L9xh}QOQupO6_jY#&M&0jPb4B)e;}M?L0r&xW)k`0$N{_p1eY*Y z_@QJp`h(q_+4)JZ0&oFW0#m^tgA2(BP4>(OiD5FL)PkYNF{m>mO;p8l;rj$S_miH4 zqBFEVc4b0N7YITGIxWe9pS7oOsUNHmr29PkBBj*VNlC3a_|E#! z4X?If$kjTG{3nuenR1T4T1>4TSXVnyuAQPS|H1*Yk=9(x^Sqp;_dw-2G9(h{qN_%1 zX`?+V1l2>Ni>QA{+(#-JCM2iMaV0%=R>+Ji!uBfBvMh<`-~|qCWx(Y?KmzrUXNfIv zx1^FuuDM)5avrmatywdU)E7i5&&A@M*#uHvkW!gMR!ovQ9y7NKgG)RM6E zqYF&MAP+o)i{j8%nZq=^wKB4(pp!L>vy>k#69LuhywAv$;-len*W-q70qRWoU196sA6| zFP>ivg*NILXnFS#HJ9NYNEa4Dr8lCiTkWC$_la0?AF?%DH2+UGD;Vxn#UQ%*Qh{Ym zyw$sno~0yC7x6&T;p)^6}x0=3N8aXz1$Z56`AcP8)^va8y_$3U=vSJtXd>hv11&% z(aVN^g-v|#65*)*Y^T6y;UgWidu0J!qP;ymL;YUwP}^W#M;D~0t*+JE)zQ#f@3oLn z&#LC~cM6Bg;>h6(OuZJpZf;Sft}3Z#D(Ip`ge>eUo|e-^WJ?WsI5Z=vq9BcH7v1B; zB7*B1jFoDA4M*Zjn2OFKJ}Vn8@aLy;3-gn+l%bDEG16QBmmJ`78Q{V+7r$vJVtl{gJzP9F4QWF=mLj7rS5|^fJ^5umnDcLK}dkX#mAdba?J&cy!H!z zsW7q+!nPzxbBRN;angmrD|q!sm;hL$;WTycCW=~fS!>_|5&OBAcZ!#dt*S1ExRd1> z#Xcz7JWH9bdI1~e3=k1yE32#W#zyK%g?co(gnVn&(`t?QuTTJ&9)Kl;-(IhGVz{?w zxVNqC!r|d)+eFk#lc^tDHH#G|D<3T;>)zwn-`IEn$rh0KWR=;84HR=)+B^&5c zcp!$Aq+GI+aelDoI4u|}G+(;b#L-f@WOI{>`-^ScE^(<0xSVoqD!ni{Kg%MKnOW@p zyygORMe*npTu4yQa&~kf0R<&NXyJ6BGBD6AfDdemN9G_zg9s()hY2oBkAWkGB|w5H zL=>#Ylw?H7pv07v5}#6G4DiV%70o3^j+Y?1l68|tgN+JZ=UkAeGiCt;2AuF!m%X?L*RqhR7{fG4xVf$0TySSgQLx$=+JSGUu^v@eg;}c~v!i zxYSSdpafP|-$ef)x>JUl`@B}t+NOmIvy{~{?s!&ZB}6Q_&qS3q#phCSK5$vx|J$wI zv$VZH2+bA5h@Jb5-c~5N6)m`|!Q@KKS4R4msoyq_-T> ze%c`i?|tBb`*p`oNhn#H1g}64LIGj{F06~pgC)wpU2|D?GY%JP*+%~fq_AF{WS*5T zw)YbEbJ|Uu{nd+KqOxX5GpV6tUHVXTssUN6cyhK7A2Ve=h09iW4oOF8+CYTa(ss91 zwAj!u(tD9|*P9h1#&vLOSX-p~!W04P9S-($LU##u=;1%nFF$ zbK#WH*QDLc@3rk(oNd2d{#I{aN3JNm;tInr9coH6m(Yhr@3>0sN3733Q?Sh$0&Y_} zSk|7Vx>{p-s1W}}q&=7%w!d2TS(3gJPdxjqn_m0y?bqLX?6Jqrzx`RrIj`RK&~?YM zJr5%}9&#+nNhJFmj$8hc97=L9NniA1J@wQpo<0A?vo5;ss>5+Cm;IRF?>P3L z^5pYf|7B8C4}F{UwJ)z@*Y9~$Dbx&-4b*-IHk#{2KmespN&+xU^94k%RPh#)*1mR| zVJwWe(vYH(#qC61p=dFJ%cZy8amFfKxU3jll=?}Vam%tSUc%z_<_^%oy;;#p^3e%q~& zJaX?jw*xMBzDjU;c+Wle)Z&E!0+ce9zVRn-`0#Zg@AfO+dFRwC@Wmc|=(@WAmnFXZ z$KkS(02fi5La$-si2;9{hFGfQ>m3CtERx2|RZ-9o!y#OmauH8{PO-fN-%x^QWphiw zT3g0s{P2@($XC^{6SLvDLU3saJ$&bjhzdBPYZWfVOT$MWbMbqAc!tZ@o^zk(a(WRN z`ce@OBO*~Yx7-(W;kSCbN^7wjI}+WrmFS|aIjK$Al4b2J-Jz^qJkBnprkI)2DvQcl zu+A0`;X-Knlf83|X{3tcxS38LP!PAi-x@!fYRZQq_}ZW@=$at9)ZL<>#;xu)q%F17 zLa7$DcD1o#Ewm^FxL=o@smAxjoaFRu?t9 z{QDZ_)soyhzd7fgd(Jf!T%LL+X^Si@uN`$dUv=&S`vBnbYS91G4cyke^}?ge)ihUt zELpZRG#;`XvKhNZt&ydrxzxl&u=j0X3n&F}$%&&2C`XrVutZJKQ;&Upgu}9u6HC#+L;yRI{Mnu)cn^6?mS(0-T zKx_dnhGL`T>da;Nj$udAV;u`GaF$E&zISJ5hhk1JU6KHoj1XODxHM;#0f*y|LA{`}*Q0$iSxO1Tey0DjxW6-hx% zso}`#qSmVB??${)myi}(>FCWRV#EGQff^W0{?kkWl*5)w z7ApW2BD>24DUMoON5X5XtE<7ZHJMJ2zv^5{-f%UdOAamwErc7_JmW~h2#z%z23Rcw zml8vBiC{1_;SUZqTzl=c4Mcgmz*z`Q_#&)ntRL>{>hf6UmzKje$WU&yrPt?DtIijB zjxo>)1{dJ~5C&Z)86n$&L933Xhn)rly`586CAjc)$vb=*JQqT2kYFe{AU5jCj7WMi9V7RE;hp z{J5@GLTgG<-t{|l7}MdnolV{qfDA_MUMP%=rwehbx1 z8L>eX7M^7O6*b!g|4wX=%ebReWkoPo7f-xb6I(_sQxh$QTFGpjTkOuuhKAOD{`p;# ziK?N$yX9t0leVe5N!J2}DdR1nmiV4{w|4iQ&vl^|&F=1QjdoXP`;304PT$njqT9pm zw`Z45vlzNx2kUmiv3vGti)HZ|9PMUvd!Ws1)GIB)6ogbkx$0U8y$)pDfT~b+IbF7i>6_xsyl?nn1lz?AF`?^la z+wVBB-dUnQqe{T&R-(eKDmWRvyQ;*gWQDilF3BLvg(C!UhY@WBIi`$2mYiBMi%=lX z_|)*1Up>mF3&9K6E?ck9s+cTboZR6$;PE8KU<79cTv@STDmXqqKH;Bp#_qZO+MM!^ zF$5Q^dZV$GqY4y`+1~}$l_zp^W$CE14GyL8ax5(umy6VOaSR&a;{d$+_79Z7gN?Ub z&W6rGPhyuBvdf8g1T|_Oxs<{xv95B4&|0dd3@NIGiGdGPm0*+{ZxQ_7+JZOvH2!zi zkbVA%s)|Z*$~LkRrTMa-pX1>2vzda+)3?ur;vuM7vU@S!q7BW2LZP^RF|O4vhT=P6 z%k7I#Yj?%FXW}!V?YrW)?}8QaU7@&6zX;{MYQg5c<^8UfsUV9&^=f8IA zTc3RJ$-#rS9u%sf?>pRH@9@Eye7<_S&mjz##l`p+g&O+5{gkhv|G)ze2w5(ti;cM` zDEmN-w~Dq*Rhf1L9xzuEsIaIeMp;;6VDC~#s3~UF7TN4_vIWHivE_QjFr^3*v7-V7E9N_GrM;<```AS_bVs(Lry0z-#b2IJnpYZiB<(^|}WSJ4^$XsNK@m z?+*B;z#=*9@OJntfvCl63AB4Hh8~C8;tSXvc851GXpasW++MBL+wQRRyW8CkbHJga z;PT1AxAbovJb3UmczYi{-x7pNV4%lg@39YdM0og4?T#;R2lCfrWds zdY%EIw1T?2R#L6m1!LEEFt*RRH6z+L26Wk$WeWusB8$sqwOVbX9{<`=h(PxHC;Ul& z4B!Gq!=AVfRKN@HgU=RWJp(@m(qk^{OB%s?F^&Xd!Nl8FG!nRMh3Bz{i>s>u7wJaB#*ev>K?M=FEI*cCiE>D)rEZS4NVT?e3pR~h@CVRFg zsW5%ez|!`nCcXaVCfIK$tl7RtqixY?x?$^{COnh@elJs# z8Oq2&@N)v43-8QJfx!A)6Oq`Avjx~tK%4^ESj-V05h zZs`}2a2JJOfmz`h)-+tQ;=qyx%(ivU)c!0|jH9cMj{;ft9~d67Ip@~ip4?in3;1-| zR)iKlU7qjjGkR<`Pj~^M#8MOKblR3q09;lkc0-2!?bi{$d_YmIM2Tr>6c3xSXAvoR8DL$mA#n3Q4*I zvOFI#r7AIvT}cA%E3d)Ll!(2Q!9!%cQNiLnFjt2UkgP(IaA>{D`XGU%4l~lwOPU)#zK#fYVnms(A4og z(JVQe8Xqpl#lILq=U5fJ@ia2GQhGWXF5iNCeE}|ce;7Qof=hE?ba=QcW3IGXS10|z z7+3wUD>a!|Ir@11+D*n@vUF)|CS0M!apR6BxitJ94+M;H9x;D9>4^kaU;@pG)ehxY z_TGJ6P8sv1Sz3*QE~9ZIxw`M|*w~o$$m;4!BG3rJbUB`*#ZIQ+QeD=JbVY#?%5%{H zGBkjK8(HPTMhf08jFcM6zLK2fuNFVCwC4dOd6g^4VTDiGMwU6w(#8DZ+E<&Q$mOfM z0GGuw;X)Upynrp|wn$M~dsoQI-?vKed(>j>xf;cvh=QxOJV#p{6~}Don;)U4`(o065cV zyqpMd$qR={G6GG6RY(UVDp59-YGj>AM(e4={z4qg8iQd!RyN~FuiY8Ik%EXO(Ia4S-M-NysYFcjf!Pv^4fO(C zjvnze@7TL{@6NkH1TPK6L}^CP2K&T%I6OLPwWc7mSS*%UTJm1c3DM;^g-f0*XY*2q zfALmgiU8nwJ9_`{KzcV0VU={8HPN40*D>MDumKaXBpQ*7qTNeoUYu|i?a@`^ofWMK=+gtUe3 zH>hfdr0Rs%(Qt`sj@3Mu7k~WayB~jianB}PHb^)tvQ#BX>E0l#D_uTFDVaK@R`e_6 zrHUL8p@Pgx`foY!eCJSHlVgxF8B7Spwc&b`CBvMR1-JlM_*M(iEr%wK%ts*MoDVukaD&%k>~i7gPVTIe5S(7EJj3zn=b(9m!(1$ul@_=E``dZ3J6 z*YAzy>KE(b(bH+x>$8hUdar)7SlEUO!{u}!3Z+6DSEH&7H5E1!EEd@Zv_*o4FNoBF zmN%o6xYIf%+lkLtl$q`oOUY6x^;Oc{zNUCUKZ6#+7AmRnmBaq9Z4|5xYuFzQCQ|eM zHRr1dOFlycAYl>3(^p40!=4p*1LySW30zTdT1#qb!_3Cg|T| z1%1_2%KfOXJ<37Ct}}PSw8MbK>jT^j26SG#!RLhuicz=4;CA%84G1ni&1NOae?u59 z#G#y9z@>Y|Bp&aNOMBtu#IQzE$mp&TGkHjeq%{2$qV8fwxbHH;d$NVEW)NI}EyQY} z6HjiNN=B@nQ5&QKgM^dI;raO$=SncpkQJ3BKTDSe9#(R?{*7s zJb!E~;s>u-YzPFY{Evd5kE z%ggige#pSTaxI}=o-fB1E?51>PYf5nZ7<})VTu}Abx9v;IpybM|0J7{yn=HmN=G-# zin`Tl$S4yNL!GydoDff%6a^)=TsjTDHZOcybmoBB=Y>j??RJOGV%OQ-I-fo2Hh7~3 zn7eNF`Vd?UW*%I0`b}-9bkvy3$=r+dAgZ0!a7$Eimb&a;TTmlEuc%9@%Zq(MgO99; zOQDW`LwDgxwQ^JtuN>^1f;N>ECxh{YssXa_$dyJ`P;kkjg`*3F7SJJ=H8M6j>i_bc z+1YpB3C7?NO!*oLqKmKqTxhafd*vH1O+N-zDPITc<%K67^4P4AjOJHXxKJ@CyscV4 zy=gYlL=h!60LT93?gr(OPR!Q9*+ zP`{D)5gxbSYjxVS_lR=7FIM6SlTEh-9mC8Q*0sGA=}sq7UBaXQgIfR&SD z5Di!XI@Iu)s6stPBxOyl$jvEitSSkYCU1|gpCg6aZnk^-HNN&vUx$0p;B)ur+<}fj zv=j2GbQ;>d1|5=N({lMYMc|SF3z~$yMPg;))^{KoV{kib6@=5%B!5rb$kCje$C zdB@dz0WDy<+;r1TEjhg0eH~)U^}qi5&Nu55=_JTzOTVVtT46B9U0?=lDZFR{+ zl^r7%)eXn1TTG}-tGb4l=n+ea?!2U>;O|@E3n^|Y^kHO_%RH; zs8(IeP+={?UG=g}9nX6B@4$r?YWLVdO07|eT(y-9-asUi9Fjy1Iz=VpJ#u_%nokP; z&7&OM=P|{wlt}W5S5t7o9x*@*Xsc_)79N9Ql&f5A;>lPL3a8YAzv|)3Hg#gCtWgaH zN9|)VWv0Bt_4!2oUNWZ8{8?maWp~+x%L$;0;UY9ojKvL=JtKD|R-(&wd6Dj_vRqCI z$|YB!WI`?n8UO3#{O_0NeifUy*{fMK`o~1Yiu_pxHhwLEO93q?rExukV~ttI<{_7{ zbF!DqHJP@95pmh3P7swc)+;YBhC7W)K{^)Z6aEuNET2+~ewfg=sWvT_6G4BmcO5NH z1YuZawxpT_JJvs7z+6PZ0t#Y9P}GQmMnn-&31T@YsDy+_uwXlgEtsH(U~gElU^(bP z@E}-DUPJ^%!IQJDv%9nJ&djcQa_8~WZxc8B?FTda%=5nQyw4wy%a9=DV)`GO1*Ur6 zGge_qdx1HGOlKp5`b!jMB)zA|N|s{`!=#M@~PXEW0JWC3jet63zBavP?jTs*P` zn0zhSzxW`@z;C$F*i$`z@)&71*_gO;%9Nr)OyGTPV>;kvNcE$($r-n$3`_||M@}eY zbZ`C@sjyhQ@Mp@!+e3-2li)KuD;Dp0VLlQCGXw?-K>~9S&

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/framework/spree/api/endpoints/checkout/index.ts b/framework/spree/api/endpoints/checkout/index.ts new file mode 100644 index 000000000..0a5ee9e72 --- /dev/null +++ b/framework/spree/api/endpoints/checkout/index.ts @@ -0,0 +1,22 @@ +import { createEndpoint } from '@commerce/api' +import type { GetAPISchema, CommerceAPI } from '@commerce/api' +import checkoutEndpoint from '@commerce/api/endpoints/checkout' +import type { CheckoutSchema } from '@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/framework/spree/api/endpoints/customer/address.ts b/framework/spree/api/endpoints/customer/address.ts new file mode 100644 index 000000000..491bf0ac9 --- /dev/null +++ b/framework/spree/api/endpoints/customer/address.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/framework/spree/api/endpoints/customer/card.ts b/framework/spree/api/endpoints/customer/card.ts new file mode 100644 index 000000000..491bf0ac9 --- /dev/null +++ b/framework/spree/api/endpoints/customer/card.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/framework/spree/api/endpoints/customer/index.ts b/framework/spree/api/endpoints/customer/index.ts new file mode 100644 index 000000000..491bf0ac9 --- /dev/null +++ b/framework/spree/api/endpoints/customer/index.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/framework/spree/api/endpoints/login/index.ts b/framework/spree/api/endpoints/login/index.ts new file mode 100644 index 000000000..491bf0ac9 --- /dev/null +++ b/framework/spree/api/endpoints/login/index.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/framework/spree/api/endpoints/logout/index.ts b/framework/spree/api/endpoints/logout/index.ts new file mode 100644 index 000000000..491bf0ac9 --- /dev/null +++ b/framework/spree/api/endpoints/logout/index.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/framework/spree/api/endpoints/signup/index.ts b/framework/spree/api/endpoints/signup/index.ts new file mode 100644 index 000000000..491bf0ac9 --- /dev/null +++ b/framework/spree/api/endpoints/signup/index.ts @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/framework/spree/api/endpoints/wishlist/index.tsx b/framework/spree/api/endpoints/wishlist/index.tsx new file mode 100644 index 000000000..491bf0ac9 --- /dev/null +++ b/framework/spree/api/endpoints/wishlist/index.tsx @@ -0,0 +1 @@ +export default function noopApi(...args: any[]): void {} diff --git a/framework/spree/api/index.ts b/framework/spree/api/index.ts new file mode 100644 index 000000000..d9ef79e1a --- /dev/null +++ b/framework/spree/api/index.ts @@ -0,0 +1,45 @@ +import type { CommerceAPI, CommerceAPIConfig } from '@commerce/api' +import { getCommerceApi as commerceApi } from '@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/framework/spree/api/operations/get-all-pages.ts b/framework/spree/api/operations/get-all-pages.ts new file mode 100644 index 000000000..580a74999 --- /dev/null +++ b/framework/spree/api/operations/get-all-pages.ts @@ -0,0 +1,82 @@ +import type { + OperationContext, + OperationOptions, +} from '@commerce/api/operations' +import type { GetAllPagesOperation, Page } from '@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/framework/spree/api/operations/get-all-product-paths.ts b/framework/spree/api/operations/get-all-product-paths.ts new file mode 100644 index 000000000..4795d1fdb --- /dev/null +++ b/framework/spree/api/operations/get-all-product-paths.ts @@ -0,0 +1,97 @@ +import type { + OperationContext, + OperationOptions, +} from '@commerce/api/operations' +import type { Product } from '@commerce/types/product' +import type { GetAllProductPathsOperation } from '@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/framework/spree/api/operations/get-all-products.ts b/framework/spree/api/operations/get-all-products.ts new file mode 100644 index 000000000..a292e6097 --- /dev/null +++ b/framework/spree/api/operations/get-all-products.ts @@ -0,0 +1,92 @@ +import type { Product } from '@commerce/types/product' +import type { GetAllProductsOperation } from '@commerce/types/product' +import type { + OperationContext, + OperationOptions, +} from '@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/framework/spree/api/operations/get-customer-wishlist.ts b/framework/spree/api/operations/get-customer-wishlist.ts new file mode 100644 index 000000000..8c34b9e87 --- /dev/null +++ b/framework/spree/api/operations/get-customer-wishlist.ts @@ -0,0 +1,6 @@ +export default function getCustomerWishlistOperation() { + function getCustomerWishlist(): any { + return { wishlist: {} } + } + return getCustomerWishlist +} diff --git a/framework/spree/api/operations/get-page.ts b/framework/spree/api/operations/get-page.ts new file mode 100644 index 000000000..ecb02755d --- /dev/null +++ b/framework/spree/api/operations/get-page.ts @@ -0,0 +1,81 @@ +import type { + OperationContext, + OperationOptions, +} from '@commerce/api/operations' +import type { GetPageOperation } from '@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/framework/spree/api/operations/get-product.ts b/framework/spree/api/operations/get-product.ts new file mode 100644 index 000000000..18e9643cd --- /dev/null +++ b/framework/spree/api/operations/get-product.ts @@ -0,0 +1,90 @@ +import type { SpreeApiConfig, SpreeApiProvider } from '../index' +import type { GetProductOperation } from '@commerce/types/product' +import type { + OperationContext, + OperationOptions, +} from '@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/framework/spree/api/operations/get-site-info.ts b/framework/spree/api/operations/get-site-info.ts new file mode 100644 index 000000000..4d9aaf0ad --- /dev/null +++ b/framework/spree/api/operations/get-site-info.ts @@ -0,0 +1,135 @@ +import type { + OperationContext, + OperationOptions, +} from '@commerce/api/operations' +import type { Category, GetSiteInfoOperation } from '@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/framework/spree/api/operations/index.ts b/framework/spree/api/operations/index.ts new file mode 100644 index 000000000..086fdf83a --- /dev/null +++ b/framework/spree/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/framework/spree/api/utils/create-api-fetch.ts b/framework/spree/api/utils/create-api-fetch.ts new file mode 100644 index 000000000..0c7d51b0b --- /dev/null +++ b/framework/spree/api/utils/create-api-fetch.ts @@ -0,0 +1,79 @@ +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 '@commerce/api' +import createCustomizedFetchFetcher, { + fetchResponseKey, +} from '../../utils/create-customized-fetch-fetcher' +import fetch, { Request } from 'node-fetch' +import type { SpreeSdkResponseWithRawResponse } from '../../types' + +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) { + throw convertSpreeErrorToGraphQlError(storeResponseError) + } + + throw storeResponseError + } +} + +export default createApiFetch diff --git a/framework/spree/api/utils/fetch.ts b/framework/spree/api/utils/fetch.ts new file mode 100644 index 000000000..26f9ab674 --- /dev/null +++ b/framework/spree/api/utils/fetch.ts @@ -0,0 +1,3 @@ +import vercelFetch from '@vercel/fetch' + +export default vercelFetch() diff --git a/framework/spree/auth/index.ts b/framework/spree/auth/index.ts new file mode 100644 index 000000000..36e757a89 --- /dev/null +++ b/framework/spree/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/framework/spree/auth/use-login.tsx b/framework/spree/auth/use-login.tsx new file mode 100644 index 000000000..308ac6597 --- /dev/null +++ b/framework/spree/auth/use-login.tsx @@ -0,0 +1,85 @@ +import { useCallback } from 'react' +import type { MutationHook } from '@commerce/utils/types' +import useLogin, { UseLogin } from '@commerce/auth/use-login' +import type { LoginHook } from '@commerce/types/login' +import type { AuthTokenAttr } from '@spree/storefront-api-v2-sdk/types/interfaces/Authentication' +import { FetcherError, ValidationError } from '@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['useHook']> = + () => { + const customer = useCustomer() + const cart = useCart() + const wishlist = useWishlist() + + return useCallback( + async function login(input) { + const data = await fetch({ input }) + + await customer.revalidate() + await cart.revalidate() + await wishlist.revalidate() + + return data + }, + [customer, cart, wishlist] + ) + } + + return useWrappedHook + }, +} diff --git a/framework/spree/auth/use-logout.tsx b/framework/spree/auth/use-logout.tsx new file mode 100644 index 000000000..0d8eb4bc9 --- /dev/null +++ b/framework/spree/auth/use-logout.tsx @@ -0,0 +1,80 @@ +import { MutationHook } from '@commerce/utils/types' +import useLogout, { UseLogout } from '@commerce/auth/use-logout' +import type { LogoutHook } from '@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['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/framework/spree/auth/use-signup.tsx b/framework/spree/auth/use-signup.tsx new file mode 100644 index 000000000..708668b9c --- /dev/null +++ b/framework/spree/auth/use-signup.tsx @@ -0,0 +1,95 @@ +import { useCallback } from 'react' +import type { GraphQLFetcherResult } from '@commerce/api' +import type { MutationHook } from '@commerce/utils/types' +import useSignup, { UseSignup } from '@commerce/auth/use-signup' +import type { SignupHook } from '@commerce/types/signup' +import { ValidationError } from '@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['useHook']> = + () => { + const customer = useCustomer() + const cart = useCart() + const wishlist = useWishlist() + + return useCallback( + async (input) => { + const data = await fetch({ input }) + + await customer.revalidate() + await cart.revalidate() + await wishlist.revalidate() + + return data + }, + [customer, cart, wishlist] + ) + } + + return useWrappedHook + }, +} diff --git a/framework/spree/cart/index.ts b/framework/spree/cart/index.ts new file mode 100644 index 000000000..3b8ba990e --- /dev/null +++ b/framework/spree/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/framework/spree/cart/use-add-item.tsx b/framework/spree/cart/use-add-item.tsx new file mode 100644 index 000000000..74bdd633f --- /dev/null +++ b/framework/spree/cart/use-add-item.tsx @@ -0,0 +1,117 @@ +import useAddItem from '@commerce/cart/use-add-item' +import type { UseAddItem } from '@commerce/cart/use-add-item' +import type { MutationHook } from '@commerce/utils/types' +import { useCallback } from 'react' +import useCart from './use-cart' +import type { AddItemHook } from '@commerce/types/cart' +import normalizeCart from '../utils/normalizations/normalize-cart' +import type { GraphQLFetcherResult } from '@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 '@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['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/framework/spree/cart/use-cart.tsx b/framework/spree/cart/use-cart.tsx new file mode 100644 index 000000000..e700c27fa --- /dev/null +++ b/framework/spree/cart/use-cart.tsx @@ -0,0 +1,123 @@ +import { useMemo } from 'react' +import type { SWRHook } from '@commerce/utils/types' +import useCart from '@commerce/cart/use-cart' +import type { UseCart } from '@commerce/cart/use-cart' +import type { GetCartHook } from '@commerce/types/cart' +import normalizeCart from '../utils/normalizations/normalize-cart' +import type { GraphQLFetcherResult } from '@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 '@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 @framework/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/framework/spree/cart/use-remove-item.tsx b/framework/spree/cart/use-remove-item.tsx new file mode 100644 index 000000000..42e7536a9 --- /dev/null +++ b/framework/spree/cart/use-remove-item.tsx @@ -0,0 +1,118 @@ +import type { MutationHook } from '@commerce/utils/types' +import useRemoveItem from '@commerce/cart/use-remove-item' +import type { UseRemoveItem } from '@commerce/cart/use-remove-item' +import type { RemoveItemHook } from '@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 '@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 '@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['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/framework/spree/cart/use-update-item.tsx b/framework/spree/cart/use-update-item.tsx new file mode 100644 index 000000000..86b8599fa --- /dev/null +++ b/framework/spree/cart/use-update-item.tsx @@ -0,0 +1,145 @@ +import type { MutationHook } from '@commerce/utils/types' +import useUpdateItem, { UseUpdateItem } from '@commerce/cart/use-update-item' +import type { UpdateItemHook } from '@commerce/types/cart' +import useCart from './use-cart' +import { useMemo } from 'react' +import { FetcherError, ValidationError } from '@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 '@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['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/framework/spree/checkout/use-checkout.tsx b/framework/spree/checkout/use-checkout.tsx new file mode 100644 index 000000000..dfd7fe02f --- /dev/null +++ b/framework/spree/checkout/use-checkout.tsx @@ -0,0 +1,17 @@ +import { SWRHook } from '@commerce/utils/types' +import useCheckout, { UseCheckout } from '@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/framework/spree/commerce.config.json b/framework/spree/commerce.config.json new file mode 100644 index 000000000..6f8399fb5 --- /dev/null +++ b/framework/spree/commerce.config.json @@ -0,0 +1,10 @@ +{ + "provider": "spree", + "features": { + "wishlist": true, + "cart": true, + "search": true, + "customerAuth": true, + "customCheckout": false + } +} diff --git a/framework/spree/customer/address/use-add-item.tsx b/framework/spree/customer/address/use-add-item.tsx new file mode 100644 index 000000000..c2f645a16 --- /dev/null +++ b/framework/spree/customer/address/use-add-item.tsx @@ -0,0 +1,18 @@ +import useAddItem from '@commerce/customer/address/use-add-item' +import type { UseAddItem } from '@commerce/customer/address/use-add-item' +import type { MutationHook } from '@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/framework/spree/customer/card/use-add-item.tsx b/framework/spree/customer/card/use-add-item.tsx new file mode 100644 index 000000000..a8bb3cd88 --- /dev/null +++ b/framework/spree/customer/card/use-add-item.tsx @@ -0,0 +1,19 @@ +import useAddItem from '@commerce/customer/address/use-add-item' +import type { UseAddItem } from '@commerce/customer/address/use-add-item' +import type { MutationHook } from '@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/framework/spree/customer/index.ts b/framework/spree/customer/index.ts new file mode 100644 index 000000000..6c903ecc5 --- /dev/null +++ b/framework/spree/customer/index.ts @@ -0,0 +1 @@ +export { default as useCustomer } from './use-customer' diff --git a/framework/spree/customer/use-customer.tsx b/framework/spree/customer/use-customer.tsx new file mode 100644 index 000000000..647645ac2 --- /dev/null +++ b/framework/spree/customer/use-customer.tsx @@ -0,0 +1,83 @@ +import type { SWRHook } from '@commerce/utils/types' +import useCustomer from '@commerce/customer/use-customer' +import type { UseCustomer } from '@commerce/customer/use-customer' +import type { CustomerHook } from '@commerce/types/customer' +import type { IToken } from '@spree/storefront-api-v2-sdk/types/interfaces/Token' +import type { GraphQLFetcherResult } from '@commerce/api' +import type { IAccount } from '@spree/storefront-api-v2-sdk/types/interfaces/Account' +import { FetcherError } from '@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/framework/spree/errors/AccessTokenError.ts b/framework/spree/errors/AccessTokenError.ts new file mode 100644 index 000000000..4c79c0be8 --- /dev/null +++ b/framework/spree/errors/AccessTokenError.ts @@ -0,0 +1 @@ +export default class AccessTokenError extends Error {} diff --git a/framework/spree/errors/MisconfigurationError.ts b/framework/spree/errors/MisconfigurationError.ts new file mode 100644 index 000000000..0717ae404 --- /dev/null +++ b/framework/spree/errors/MisconfigurationError.ts @@ -0,0 +1 @@ +export default class MisconfigurationError extends Error {} diff --git a/framework/spree/errors/MissingConfigurationValueError.ts b/framework/spree/errors/MissingConfigurationValueError.ts new file mode 100644 index 000000000..02b497bf1 --- /dev/null +++ b/framework/spree/errors/MissingConfigurationValueError.ts @@ -0,0 +1 @@ +export default class MissingConfigurationValueError extends Error {} diff --git a/framework/spree/errors/MissingLineItemVariantError.ts b/framework/spree/errors/MissingLineItemVariantError.ts new file mode 100644 index 000000000..d9bee0803 --- /dev/null +++ b/framework/spree/errors/MissingLineItemVariantError.ts @@ -0,0 +1 @@ +export default class MissingLineItemVariantError extends Error {} diff --git a/framework/spree/errors/MissingOptionValueError.ts b/framework/spree/errors/MissingOptionValueError.ts new file mode 100644 index 000000000..04457ac5e --- /dev/null +++ b/framework/spree/errors/MissingOptionValueError.ts @@ -0,0 +1 @@ +export default class MissingOptionValueError extends Error {} diff --git a/framework/spree/errors/MissingPrimaryVariantError.ts b/framework/spree/errors/MissingPrimaryVariantError.ts new file mode 100644 index 000000000..f9af41b03 --- /dev/null +++ b/framework/spree/errors/MissingPrimaryVariantError.ts @@ -0,0 +1 @@ +export default class MissingPrimaryVariantError extends Error {} diff --git a/framework/spree/errors/MissingProductError.ts b/framework/spree/errors/MissingProductError.ts new file mode 100644 index 000000000..3098be689 --- /dev/null +++ b/framework/spree/errors/MissingProductError.ts @@ -0,0 +1 @@ +export default class MissingProductError extends Error {} diff --git a/framework/spree/errors/MissingSlugVariableError.ts b/framework/spree/errors/MissingSlugVariableError.ts new file mode 100644 index 000000000..09b9d2e20 --- /dev/null +++ b/framework/spree/errors/MissingSlugVariableError.ts @@ -0,0 +1 @@ +export default class MissingSlugVariableError extends Error {} diff --git a/framework/spree/errors/MissingVariantError.ts b/framework/spree/errors/MissingVariantError.ts new file mode 100644 index 000000000..5ed9e0ed2 --- /dev/null +++ b/framework/spree/errors/MissingVariantError.ts @@ -0,0 +1 @@ +export default class MissingVariantError extends Error {} diff --git a/framework/spree/errors/RefreshTokenError.ts b/framework/spree/errors/RefreshTokenError.ts new file mode 100644 index 000000000..a79365bbb --- /dev/null +++ b/framework/spree/errors/RefreshTokenError.ts @@ -0,0 +1 @@ +export default class RefreshTokenError extends Error {} diff --git a/framework/spree/errors/SpreeResponseContentError.ts b/framework/spree/errors/SpreeResponseContentError.ts new file mode 100644 index 000000000..19c10cf2e --- /dev/null +++ b/framework/spree/errors/SpreeResponseContentError.ts @@ -0,0 +1 @@ +export default class SpreeResponseContentError extends Error {} diff --git a/framework/spree/errors/SpreeSdkMethodFromEndpointPathError.ts b/framework/spree/errors/SpreeSdkMethodFromEndpointPathError.ts new file mode 100644 index 000000000..bf15aada0 --- /dev/null +++ b/framework/spree/errors/SpreeSdkMethodFromEndpointPathError.ts @@ -0,0 +1 @@ +export default class SpreeSdkMethodFromEndpointPathError extends Error {} diff --git a/framework/spree/errors/TokensNotRejectedError.ts b/framework/spree/errors/TokensNotRejectedError.ts new file mode 100644 index 000000000..245f66414 --- /dev/null +++ b/framework/spree/errors/TokensNotRejectedError.ts @@ -0,0 +1 @@ +export default class TokensNotRejectedError extends Error {} diff --git a/framework/spree/errors/UserTokenResponseParseError.ts b/framework/spree/errors/UserTokenResponseParseError.ts new file mode 100644 index 000000000..9631971c1 --- /dev/null +++ b/framework/spree/errors/UserTokenResponseParseError.ts @@ -0,0 +1 @@ +export default class UserTokenResponseParseError extends Error {} diff --git a/framework/spree/fetcher.ts b/framework/spree/fetcher.ts new file mode 100644 index 000000000..c9505e4c9 --- /dev/null +++ b/framework/spree/fetcher.ts @@ -0,0 +1,116 @@ +import type { Fetcher } from '@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 '@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' + +const client = makeClient({ + host: requireConfigValue('apiHost') 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) { + throw convertSpreeErrorToGraphQlError(storeResponseError) + } + + throw storeResponseError +} + +export default fetcher diff --git a/framework/spree/index.tsx b/framework/spree/index.tsx new file mode 100644 index 000000000..f7eff69e9 --- /dev/null +++ b/framework/spree/index.tsx @@ -0,0 +1,49 @@ +import type { ComponentType, FunctionComponent } from 'react' +import { + Provider, + CommerceProviderProps, + CoreCommerceProvider, + useCommerce as useCoreCommerce, +} from '@commerce' +import { spreeProvider } from './provider' +import type { SpreeProvider } from './provider' +import { SWRConfig } from 'swr' +import handleTokenErrors from './utils/handle-token-errors' +import useLogout from '@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/framework/spree/isomorphic-config.ts b/framework/spree/isomorphic-config.ts new file mode 100644 index 000000000..b824fd80a --- /dev/null +++ b/framework/spree/isomorphic-config.ts @@ -0,0 +1,81 @@ +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, + 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', + '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/framework/spree/next.config.js b/framework/spree/next.config.js new file mode 100644 index 000000000..0aaa87e0a --- /dev/null +++ b/framework/spree/next.config.js @@ -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/framework/spree/product/index.ts b/framework/spree/product/index.ts new file mode 100644 index 000000000..426a3edcd --- /dev/null +++ b/framework/spree/product/index.ts @@ -0,0 +1,2 @@ +export { default as usePrice } from './use-price' +export { default as useSearch } from './use-search' diff --git a/framework/spree/product/use-price.tsx b/framework/spree/product/use-price.tsx new file mode 100644 index 000000000..0174faf5e --- /dev/null +++ b/framework/spree/product/use-price.tsx @@ -0,0 +1,2 @@ +export * from '@commerce/product/use-price' +export { default } from '@commerce/product/use-price' diff --git a/framework/spree/product/use-search.tsx b/framework/spree/product/use-search.tsx new file mode 100644 index 000000000..5912a72ca --- /dev/null +++ b/framework/spree/product/use-search.tsx @@ -0,0 +1,101 @@ +import type { SWRHook } from '@commerce/utils/types' +import useSearch from '@commerce/product/use-search' +import type { Product, SearchProductsHook } from '@commerce/types/product' +import type { UseSearch } from '@commerce/product/use-search' +import normalizeProduct from '../utils/normalizations/normalize-product' +import type { GraphQLFetcherResult } from '@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/framework/spree/provider.ts b/framework/spree/provider.ts new file mode 100644 index 000000000..de6ddb207 --- /dev/null +++ b/framework/spree/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/framework/spree/types/index.ts b/framework/spree/types/index.ts new file mode 100644 index 000000000..79b75c249 --- /dev/null +++ b/framework/spree/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 '@commerce/types/product' +import type { + AddItemHook, + RemoveItemHook, + WishlistItemBody, + WishlistTypes, +} from '@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 @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/framework/spree/utils/convert-spree-error-to-graph-ql-error.ts b/framework/spree/utils/convert-spree-error-to-graph-ql-error.ts new file mode 100644 index 000000000..def4920ba --- /dev/null +++ b/framework/spree/utils/convert-spree-error-to-graph-ql-error.ts @@ -0,0 +1,52 @@ +import { FetcherError } from '@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/framework/spree/utils/create-customized-fetch-fetcher.ts b/framework/spree/utils/create-customized-fetch-fetcher.ts new file mode 100644 index 000000000..1c10b19e9 --- /dev/null +++ b/framework/spree/utils/create-customized-fetch-fetcher.ts @@ -0,0 +1,105 @@ +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 { + 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/framework/spree/utils/create-empty-cart.ts b/framework/spree/utils/create-empty-cart.ts new file mode 100644 index 000000000..0bf0aa522 --- /dev/null +++ b/framework/spree/utils/create-empty-cart.ts @@ -0,0 +1,22 @@ +import type { GraphQLFetcherResult } from '@commerce/api' +import type { HookFetcherContext } from '@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/framework/spree/utils/create-get-absolute-image-url.ts b/framework/spree/utils/create-get-absolute-image-url.ts new file mode 100644 index 000000000..6e9e3260a --- /dev/null +++ b/framework/spree/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/framework/spree/utils/expand-options.ts b/framework/spree/utils/expand-options.ts new file mode 100644 index 000000000..29b9d6760 --- /dev/null +++ b/framework/spree/utils/expand-options.ts @@ -0,0 +1,103 @@ +import type { ProductOptionValues } from '@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/framework/spree/utils/force-isomorphic-config-values.ts b/framework/spree/utils/force-isomorphic-config-values.ts new file mode 100644 index 000000000..630b6859e --- /dev/null +++ b/framework/spree/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/framework/spree/utils/get-image-url.ts b/framework/spree/utils/get-image-url.ts new file mode 100644 index 000000000..8594f5c34 --- /dev/null +++ b/framework/spree/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/framework/spree/utils/get-media-gallery.ts b/framework/spree/utils/get-media-gallery.ts new file mode 100644 index 000000000..da939c82b --- /dev/null +++ b/framework/spree/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 '@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/framework/spree/utils/get-product-path.ts b/framework/spree/utils/get-product-path.ts new file mode 100644 index 000000000..6749a4a3e --- /dev/null +++ b/framework/spree/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/framework/spree/utils/get-spree-sdk-method-from-endpoint-path.ts b/framework/spree/utils/get-spree-sdk-method-from-endpoint-path.ts new file mode 100644 index 000000000..9b87daadc --- /dev/null +++ b/framework/spree/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/framework/spree/utils/handle-token-errors.ts b/framework/spree/utils/handle-token-errors.ts new file mode 100644 index 000000000..a5d49fde6 --- /dev/null +++ b/framework/spree/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/framework/spree/utils/is-json-content-type.ts b/framework/spree/utils/is-json-content-type.ts new file mode 100644 index 000000000..fd82d65fd --- /dev/null +++ b/framework/spree/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/framework/spree/utils/is-server.ts b/framework/spree/utils/is-server.ts new file mode 100644 index 000000000..4544a4884 --- /dev/null +++ b/framework/spree/utils/is-server.ts @@ -0,0 +1 @@ +export default typeof window === 'undefined' diff --git a/framework/spree/utils/login.ts b/framework/spree/utils/login.ts new file mode 100644 index 000000000..3894b7952 --- /dev/null +++ b/framework/spree/utils/login.ts @@ -0,0 +1,58 @@ +import type { GraphQLFetcherResult } from '@commerce/api' +import type { HookFetcherContext } from '@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/framework/spree/utils/normalizations/normalize-cart.ts b/framework/spree/utils/normalizations/normalize-cart.ts new file mode 100644 index 000000000..a1751eaec --- /dev/null +++ b/framework/spree/utils/normalizations/normalize-cart.ts @@ -0,0 +1,211 @@ +import type { + Cart, + LineItem, + ProductVariant, + SelectedOption, +} from '@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 '@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/framework/spree/utils/normalizations/normalize-page.ts b/framework/spree/utils/normalizations/normalize-page.ts new file mode 100644 index 000000000..c49d862d1 --- /dev/null +++ b/framework/spree/utils/normalizations/normalize-page.ts @@ -0,0 +1,42 @@ +import { Page } from '@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/framework/spree/utils/normalizations/normalize-product.ts b/framework/spree/utils/normalizations/normalize-product.ts new file mode 100644 index 000000000..e70bd34b4 --- /dev/null +++ b/framework/spree/utils/normalizations/normalize-product.ts @@ -0,0 +1,240 @@ +import type { + Product, + ProductImage, + ProductPrice, + ProductVariant, +} from '@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/framework/spree/utils/normalizations/normalize-user.ts b/framework/spree/utils/normalizations/normalize-user.ts new file mode 100644 index 000000000..897b1c59b --- /dev/null +++ b/framework/spree/utils/normalizations/normalize-user.ts @@ -0,0 +1,16 @@ +import type { Customer } from '@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/framework/spree/utils/normalizations/normalize-wishlist.ts b/framework/spree/utils/normalizations/normalize-wishlist.ts new file mode 100644 index 000000000..c9cfee2db --- /dev/null +++ b/framework/spree/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/framework/spree/utils/require-config.ts b/framework/spree/utils/require-config.ts new file mode 100644 index 000000000..92b7916ca --- /dev/null +++ b/framework/spree/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/framework/spree/utils/sort-option-types.ts b/framework/spree/utils/sort-option-types.ts new file mode 100644 index 000000000..bac632e09 --- /dev/null +++ b/framework/spree/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/framework/spree/utils/tokens/cart-token.ts b/framework/spree/utils/tokens/cart-token.ts new file mode 100644 index 000000000..8352f9ada --- /dev/null +++ b/framework/spree/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/framework/spree/utils/tokens/ensure-fresh-user-access-token.ts b/framework/spree/utils/tokens/ensure-fresh-user-access-token.ts new file mode 100644 index 000000000..de22634fb --- /dev/null +++ b/framework/spree/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/framework/spree/utils/tokens/ensure-itoken.ts b/framework/spree/utils/tokens/ensure-itoken.ts new file mode 100644 index 000000000..0d4e6f899 --- /dev/null +++ b/framework/spree/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/framework/spree/utils/tokens/is-logged-in.ts b/framework/spree/utils/tokens/is-logged-in.ts new file mode 100644 index 000000000..218c25bdd --- /dev/null +++ b/framework/spree/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/framework/spree/utils/tokens/revoke-user-tokens.ts b/framework/spree/utils/tokens/revoke-user-tokens.ts new file mode 100644 index 000000000..9c603a884 --- /dev/null +++ b/framework/spree/utils/tokens/revoke-user-tokens.ts @@ -0,0 +1,49 @@ +import type { GraphQLFetcherResult } from '@commerce/api' +import type { HookFetcherContext } from '@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/framework/spree/utils/tokens/user-token-response.ts b/framework/spree/utils/tokens/user-token-response.ts new file mode 100644 index 000000000..0c524eccf --- /dev/null +++ b/framework/spree/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/framework/spree/utils/validations/validate-all-products-taxonomy-id.ts b/framework/spree/utils/validations/validate-all-products-taxonomy-id.ts new file mode 100644 index 000000000..5eaaa0b4b --- /dev/null +++ b/framework/spree/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/framework/spree/utils/validations/validate-cookie-expire.ts b/framework/spree/utils/validations/validate-cookie-expire.ts new file mode 100644 index 000000000..1bd987273 --- /dev/null +++ b/framework/spree/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/framework/spree/utils/validations/validate-images-option-filter.ts b/framework/spree/utils/validations/validate-images-option-filter.ts new file mode 100644 index 000000000..8b6ef9892 --- /dev/null +++ b/framework/spree/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/framework/spree/utils/validations/validate-images-quality.ts b/framework/spree/utils/validations/validate-images-quality.ts new file mode 100644 index 000000000..909caad57 --- /dev/null +++ b/framework/spree/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/framework/spree/utils/validations/validate-images-size.ts b/framework/spree/utils/validations/validate-images-size.ts new file mode 100644 index 000000000..e02036dad --- /dev/null +++ b/framework/spree/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/framework/spree/utils/validations/validate-placeholder-image-url.ts b/framework/spree/utils/validations/validate-placeholder-image-url.ts new file mode 100644 index 000000000..cce2e27da --- /dev/null +++ b/framework/spree/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/framework/spree/utils/validations/validate-products-prerender-count.ts b/framework/spree/utils/validations/validate-products-prerender-count.ts new file mode 100644 index 000000000..024db1ea6 --- /dev/null +++ b/framework/spree/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/framework/spree/wishlist/index.ts b/framework/spree/wishlist/index.ts new file mode 100644 index 000000000..241af3c7e --- /dev/null +++ b/framework/spree/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/framework/spree/wishlist/use-add-item.tsx b/framework/spree/wishlist/use-add-item.tsx new file mode 100644 index 000000000..dac003ddc --- /dev/null +++ b/framework/spree/wishlist/use-add-item.tsx @@ -0,0 +1,87 @@ +import { useCallback } from 'react' +import type { MutationHook } from '@commerce/utils/types' +import useAddItem from '@commerce/wishlist/use-add-item' +import type { UseAddItem } from '@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 '@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 '@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['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.revalidate() + + return data + }, + [wishlist] + ) + } + + return useWrappedHook + }, +} diff --git a/framework/spree/wishlist/use-remove-item.tsx b/framework/spree/wishlist/use-remove-item.tsx new file mode 100644 index 000000000..3b92b029f --- /dev/null +++ b/framework/spree/wishlist/use-remove-item.tsx @@ -0,0 +1,75 @@ +import { useCallback } from 'react' +import type { MutationHook } from '@commerce/utils/types' +import useRemoveItem from '@commerce/wishlist/use-remove-item' +import type { UseRemoveItem } from '@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 '@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.revalidate() + + return data + }, + [wishlist] + ) + } + + return useWrappedHook + }, +} diff --git a/framework/spree/wishlist/use-wishlist.tsx b/framework/spree/wishlist/use-wishlist.tsx new file mode 100644 index 000000000..0292d4096 --- /dev/null +++ b/framework/spree/wishlist/use-wishlist.tsx @@ -0,0 +1,93 @@ +import { useMemo } from 'react' +import type { SWRHook } from '@commerce/utils/types' +import useWishlist from '@commerce/wishlist/use-wishlist' +import type { UseWishlist } from '@commerce/wishlist/use-wishlist' +import type { GetWishlistHook } from '@commerce/types/wishlist' +import type { IToken } from '@spree/storefront-api-v2-sdk/types/interfaces/Token' +import type { GraphQLFetcherResult } from '@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/package-lock.json b/package-lock.json index 9628d7fa8..196a45582 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@react-spring/web": "^9.2.1", + "@spree/storefront-api-v2-sdk": "^5.0.1", "@vercel/fetch": "^6.1.1", "autoprefixer": "^10.2.6", "body-scroll-lock": "^3.1.5", @@ -2456,6 +2457,26 @@ "node": ">=6" } }, + "node_modules/@spree/storefront-api-v2-sdk": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@spree/storefront-api-v2-sdk/-/storefront-api-v2-sdk-5.0.1.tgz", + "integrity": "sha512-4soQAydchJ9G1d3Xa96XRZ5Uq6IqE0amc8jEjL3H0QLv1NJEv1IK4OfbLK5VRMxv+7QcL/ewHEo2zHm6tqBizA==", + "engines": { + "node": ">=14.17.0" + }, + "peerDependencies": { + "axios": "^0.24.0", + "node-fetch": "^2.6.6" + }, + "peerDependenciesMeta": { + "axios": { + "optional": true + }, + "node-fetch": { + "optional": true + } + } + }, "node_modules/@szmarczak/http-timer": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz", @@ -17201,6 +17222,12 @@ "integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==", "dev": true }, + "@spree/storefront-api-v2-sdk": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@spree/storefront-api-v2-sdk/-/storefront-api-v2-sdk-5.0.1.tgz", + "integrity": "sha512-4soQAydchJ9G1d3Xa96XRZ5Uq6IqE0amc8jEjL3H0QLv1NJEv1IK4OfbLK5VRMxv+7QcL/ewHEo2zHm6tqBizA==", + "requires": {} + }, "@szmarczak/http-timer": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz", diff --git a/package.json b/package.json index f1161db9d..b53d79ece 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@react-spring/web": "^9.2.1", + "@spree/storefront-api-v2-sdk": "^5.0.1", "@vercel/fetch": "^6.1.1", "autoprefixer": "^10.2.6", "body-scroll-lock": "^3.1.5",